use super::*;
use crate::accounts::{self, Account};
pub(crate) fn shown(a: &Account) -> serde_json::Value {
json!({
"id": a.id,
"label": a.label,
"created_at": a.created_at,
"used_at": a.used_at,
"renew_at": a.created_at + accounts::TOKEN_LIFE_SECS,
})
}
pub(crate) fn listed(app: &App) -> Vec<serde_json::Value> {
app.store
.claude_accounts()
.unwrap_or_default()
.iter()
.map(|a| {
let mut v = shown(a);
v["running"] = json!(app.panes.running_as(a.id));
v
})
.collect()
}
#[derive(Default)]
pub(crate) struct Usage {
quotas: std::sync::Mutex<std::collections::BTreeMap<i64, Quota>>,
broken: std::sync::Mutex<std::collections::BTreeMap<i64, &'static str>>,
}
#[derive(Clone, Copy, PartialEq)]
struct Quota {
five_hour: Option<crate::statusline::Limit>,
seven_day: Option<crate::statusline::Limit>,
at: i64,
}
fn seen(l: Option<crate::statusline::Limit>) -> Option<(i64, i64)> {
l.map(|l| (l.used.round() as i64, l.resets_at))
}
fn left(l: Option<crate::statusline::Limit>, now: i64) -> f64 {
match l {
Some(l) if l.resets_at > now => (100.0 - l.used).clamp(0.0, 100.0),
_ => 100.0,
}
}
impl Usage {
pub(crate) fn heard(
&self,
account: i64,
five_hour: Option<crate::statusline::Limit>,
seven_day: Option<crate::statusline::Limit>,
now: i64,
) -> bool {
let q = Quota {
five_hour,
seven_day,
at: now,
};
let mut all = self.quotas.lock().unwrap_or_else(|e| e.into_inner());
let was = all.insert(account, q);
was.is_none_or(|w| {
(seen(w.five_hour), seen(w.seven_day)) != (seen(five_hour), seen(seven_day))
})
}
pub(crate) fn quotas(&self) -> serde_json::Value {
let all = self.quotas.lock().unwrap_or_else(|e| e.into_inner());
all.iter()
.map(|(id, q)| (id.to_string(), quota_json(q)))
.collect::<serde_json::Map<_, _>>()
.into()
}
pub(crate) fn least(&self, now: i64) -> Option<(i64, serde_json::Value)> {
let all = self.quotas.lock().unwrap_or_else(|e| e.into_inner());
all.iter()
.map(|(id, q)| (*id, q, left(q.five_hour, now).min(left(q.seven_day, now))))
.min_by(|a, b| a.2.total_cmp(&b.2))
.map(|(id, q, _)| (id, quota_json(q)))
}
pub(crate) fn broke(&self, account: i64, why: &'static str) -> bool {
let mut all = self.broken.lock().unwrap_or_else(|e| e.into_inner());
all.insert(account, why) != Some(why)
}
pub(crate) fn mended(&self, account: i64) -> bool {
let mut all = self.broken.lock().unwrap_or_else(|e| e.into_inner());
all.remove(&account).is_some()
}
pub(crate) fn broken(&self) -> serde_json::Value {
let all = self.broken.lock().unwrap_or_else(|e| e.into_inner());
all.iter()
.map(|(id, why)| (id.to_string(), json!(why)))
.collect::<serde_json::Map<_, _>>()
.into()
}
pub(crate) fn forget(&self, account: i64) {
self.quotas
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&account);
self.broken
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&account);
}
}
fn quota_json(q: &Quota) -> serde_json::Value {
json!({ "five_hour": q.five_hour, "seven_day": q.seven_day, "at": q.at })
}
pub(crate) fn accounts_moved(app: &App) {
emit(app, "accounts", json!({}));
desks_moved(app);
}
fn free_label(app: &App) -> String {
accounts::next_label(&app.store.claude_accounts().unwrap_or_default())
}
fn unprocessable(why: &str) -> Response {
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": why })),
)
.into_response()
}
async fn keep_token(app: &App, id: i64, token: String) -> anyhow::Result<crate::secrets::Kept> {
let secrets = app.secrets.clone();
tokio::task::spawn_blocking(move || secrets.keep_claude(id, &token))
.await
.map_err(|e| anyhow::anyhow!("keeping the token: {e}"))?
}
pub(crate) async fn list_accounts(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.claude_accounts() {
Ok(all) => {
Json(json!({ "accounts": all.iter().map(shown).collect::<Vec<_>>() })).into_response()
}
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct AddAccount {
#[serde(default)]
pub(crate) label: String,
pub(crate) token: String,
}
pub(crate) async fn add_account(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<AddAccount>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let token = b.token.trim().to_string();
if let Err(why) = accounts::valid_token(&token) {
return unprocessable(why);
}
let label = accounts::clean_label(&b.label).unwrap_or_else(|| free_label(&app));
let account = match app.store.add_claude_account(&label) {
Ok(a) => a,
Err(e) => return err(e),
};
let kept = match keep_token(&app, account.id, token).await {
Ok(kept) => kept,
Err(e) => {
let _ = app.store.remove_claude_account(account.id);
return err(e);
}
};
accounts_moved(&app);
Json(json!({ "ok": true, "account": shown(&account), "kept": kept })).into_response()
}
#[derive(Deserialize)]
pub(crate) struct SignInBody {
#[serde(default)]
pub(crate) label: String,
#[serde(default)]
pub(crate) renew: Option<i64>,
}
pub(crate) async fn signin_start(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<SignInBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let label = match b.renew {
Some(id) => match app.store.claude_account(id) {
Ok(Some(a)) => a.label,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return err(e),
},
None => accounts::clean_label(&b.label).unwrap_or_else(|| free_label(&app)),
};
let app2 = app.clone();
let (renew, name) = (b.renew, label.clone());
let keep = move |token: String| -> anyhow::Result<i64> {
let id = match renew {
Some(id) => {
app2.secrets.keep_claude(id, &token)?;
let _ = app2.store.renewed_claude_account(id);
app2.usage.mended(id);
id
}
None => {
let a = app2.store.add_claude_account(&name)?;
if let Err(e) = app2.secrets.keep_claude(a.id, &token) {
let _ = app2.store.remove_claude_account(a.id);
return Err(e);
}
a.id
}
};
accounts_moved(&app2);
Ok(id)
};
match app.signins.start(label, b.renew, keep) {
Ok(shown) => Json(json!({ "ok": true, "signin": shown })).into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn signin_now(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
Json(json!({ "signin": app.signins.current() })).into_response()
}
#[derive(Deserialize)]
pub(crate) struct SignInCode {
pub(crate) code: String,
}
pub(crate) async fn signin_code(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<SignInCode>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.signins.code(&b.code) {
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(e) => unprocessable(&format!("{e:#}")),
}
}
pub(crate) async fn signin_cancel(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
Json(json!({ "ok": true, "cancelled": app.signins.cancel() })).into_response()
}
#[derive(Deserialize)]
pub(crate) struct RenameAccount {
pub(crate) label: String,
}
pub(crate) async fn rename_account(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<RenameAccount>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Some(label) = accounts::clean_label(&b.label) else {
return unprocessable("an account needs a name");
};
match app.store.rename_claude_account(id, &label) {
Ok(true) => {
accounts_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct RenewAccount {
pub(crate) token: String,
}
pub(crate) async fn renew_account(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<RenewAccount>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let token = b.token.trim().to_string();
if let Err(why) = accounts::valid_token(&token) {
return unprocessable(why);
}
if !matches!(app.store.claude_account(id), Ok(Some(_))) {
return StatusCode::NOT_FOUND.into_response();
}
let kept = match keep_token(&app, id, token).await {
Ok(kept) => kept,
Err(e) => return err(e),
};
let _ = app.store.renewed_claude_account(id);
app.usage.mended(id);
accounts_moved(&app);
Json(json!({ "ok": true, "kept": kept })).into_response()
}
pub(crate) async fn remove_account(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.remove_claude_account(id) {
Ok(true) => {
let secrets = app.secrets.clone();
let _ = tokio::task::spawn_blocking(move || secrets.forget_claude(id)).await;
app.usage.forget(id);
accounts_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct DeskAccount {
pub(crate) account: i64,
}
pub(crate) async fn set_desk_account(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<DeskAccount>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.claude_account_exists(b.account) {
Ok(true) => {}
Ok(false) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return err(e),
}
match app.store.set_desk_account(id, b.account) {
Ok(true) => {}
Ok(false) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return err(e),
}
let running: Vec<String> = app
.store
.panes_following_desk(id)
.unwrap_or_default()
.into_iter()
.filter(|p| {
let s = app.panes.status(p);
s.running && s.account != b.account
})
.collect();
desks_moved(&app);
Json(json!({ "ok": true, "running": running })).into_response()
}
#[derive(Deserialize)]
pub(crate) struct PaneAccount {
#[serde(default)]
pub(crate) account: Option<i64>,
#[serde(default)]
pub(crate) switch: bool,
#[serde(default)]
pub(crate) renewed: bool,
}
pub(crate) async fn set_pane_account(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<PaneAccount>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(placed)) = app.store.pane(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
if let Some(a) = b.account {
match app.store.claude_account_exists(a) {
Ok(true) => {}
Ok(false) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return err(e),
}
}
let status = app.panes.status(&id);
if b.switch && app.panes.answering(&id) {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "Claude is answering in this panel: switch when it is done" })),
)
.into_response();
}
if let Err(e) = app.store.set_pane_account(&id, b.account) {
return err(e);
}
let was = if status.running {
status.account
} else {
placed.account()
};
let now = accounts::effective(placed.desk_account, b.account);
let restart = b.switch && status.running && (was != now || b.renewed);
desks_moved(&app);
Json(json!({
"ok": true,
"account": now,
"restart": restart,
"resume": restart && !placed.pane.resume.is_empty(),
}))
.into_response()
}