feat: add stats, borrow and return
Every proxy keeps its figures in its workspace, but reading them meant opening JSON files one proxy at a time, and the /stats page starts from zero with each restart. `stats` reads all workspaces, jails included, and shows how much of the saving is tool definitions rather than compression, how many calls ran past the size where /clear pays more, and when each weekly limit runs out at the current pace. Switching accounts moves the whole profile, which is wrong when one account is out of limit and the other lacks its plugins and settings. `borrow` lends one account's login into another's profile for a while. The old helper did this and failed twice: an abort left the swap half done, and a token refreshed in one copy broke the other. Here the profile's own login is written to a journal before anything changes, the lent credentials file is a link to the lender's, and `return` puts everything back from the journal, by hand or from a systemd timer. Profiles that jails run in are refused unless asked for, since the login decides whose account customer data goes out under, and status reports a borrow whose timer was lost.
This commit is contained in:
1 parent
aea75fbf1c
commit
9ed50eab57
7 files changed
+1084
-1
No files matched your search
@@ -23,6 +23,10 @@ swap half-done. Here nothing moves: an account is a directory, and switching
|
||||
means pointing `CLAUDE_CONFIG_DIR` at another one. There is nothing to restore,
|
||||
and the plugins of an account come with it.
|
||||
|
||||
Lending a login is still useful when one account runs out of limit and its
|
||||
profile is the one with everything set up, so `borrow` brings it back, built
|
||||
against both ways the old helper failed -- see [Borrowing a login](#borrowing-a-login).
|
||||
|
||||
## How the switch reaches `claude`
|
||||
|
||||
`~/.local/bin/claude` is a small wrapper that asks this tool which profile is
|
||||
@@ -87,6 +91,9 @@ workspaces over unchanged.
|
||||
| `switch <account>` | make an account the one `claude` runs as |
|
||||
| `add <name>` | register an account: profile, port, workspace, proxy, all at once |
|
||||
| `remove <account>` | forget one; its profile directory stays |
|
||||
| `stats [--json]` | what every proxy recorded since it was set up, side by side |
|
||||
| `borrow <lender> [--into <account>] [--for 4h]` | run a profile on another account's login for a while |
|
||||
| `return [account]` | give a borrowed login back |
|
||||
| `completions [shell]` | print a completion script, or `--install` it |
|
||||
| `setup` | wrapper, proxies, router and completions in one go |
|
||||
| `adopt` | take over services that were written by hand |
|
||||
@@ -113,6 +120,65 @@ directory and the proxy workspace stay where they are -- they hold a login,
|
||||
projects and memories -- so a profile that still has credentials keeps showing
|
||||
up in `list`, found rather than configured.
|
||||
|
||||
## What the proxies recorded
|
||||
|
||||
```sh
|
||||
claude-account stats
|
||||
```
|
||||
|
||||
Every account's proxy, and every jail's, keeps its figures in its workspace.
|
||||
`stats` reads them all and puts them side by side: calls, input, how much the
|
||||
proxy saved and how much of that is compression rather than tool definitions
|
||||
held back, the cache-read share, and how many calls ran above 150k tokens of
|
||||
context -- the size at which a `/clear` pays more than any proxy. Below the
|
||||
table it says what the totals mean, and lists each account's weekly limit with
|
||||
the pace of the last three hours and when that pace would use it up. `--json`
|
||||
prints the same figures for scripts.
|
||||
|
||||
The figures are the proxy's own, counted since its workspace was created; the
|
||||
counters on its `/stats` page start again with every restart.
|
||||
|
||||
## Borrowing a login
|
||||
|
||||
```sh
|
||||
claude-account borrow personal --into work --for 4h
|
||||
claude # a new session, on the personal login
|
||||
claude-account return work # or wait for the four hours
|
||||
```
|
||||
|
||||
The profile stays -- its plugins, settings, projects and memories -- and only
|
||||
the login and the `oauthAccount` entry of `.claude.json` are lent. `--into`
|
||||
defaults to the active account; without `--for` the borrow lasts until
|
||||
`return`. Sessions that are already open keep the login they started with, so
|
||||
the borrow is felt from the next session on.
|
||||
|
||||
What makes it safe where the old helper was not:
|
||||
|
||||
* **A journal before anything moves.** The profile's own credentials and
|
||||
`oauthAccount` are written to `~/.local/share/claude-account/borrows/`
|
||||
(mode 0600) first. `return` restores from it, whether it is run by hand, by
|
||||
the timer `--for` sets, or after a crash or an interrupted borrow.
|
||||
* **One login, not two copies.** The profile's `.credentials.json` becomes a
|
||||
link to the lender's, so a token refreshed in either profile is the one both
|
||||
use. If Claude Code replaces the link with a file, `return` gives whichever
|
||||
copy expires later back to the lender.
|
||||
* **Jails are refused.** A jail keeps customer data in a sandbox, and the
|
||||
login decides whose account that data goes out under. Borrowing into a
|
||||
profile a jail runs in needs `--with-jails`.
|
||||
* **A lost timer is reported.** `--for` sets a transient user timer, which a
|
||||
reboot drops; `status` and `doctor` then show the borrow as overdue with the
|
||||
command that returns it.
|
||||
|
||||
The profile's proxy polls the lender's limits while the borrow lasts, and
|
||||
counts the lender's calls in its figures. The `enqs-info` status line shows
|
||||
`⇄ <lender>` next to the account name for as long as it does.
|
||||
|
||||
A borrow is for one person's own accounts. A login shared between people is
|
||||
against Anthropic's terms, however it is passed around -- a Team plan with a
|
||||
seat each is the way to pool limits. And a private account is covered by the
|
||||
consumer terms, not the company's agreement: work and customer data belong in
|
||||
the work login.
|
||||
|
||||
## Completions
|
||||
|
||||
```sh
|
||||
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
//! Lend one account's login to another account's profile for a while.
|
||||
//!
|
||||
//! The rest of this tool never moves a credential: an account is a directory,
|
||||
//! and switching points `CLAUDE_CONFIG_DIR` at another one. That keeps the
|
||||
//! plugins, settings and memories of an account together with its login,
|
||||
//! which is right until the login runs out of limit in the middle of a day and
|
||||
//! the profile it belongs to is the one with everything set up. Switching then
|
||||
//! means working in the other profile, with its plugins, its settings and its
|
||||
//! history, or not at all.
|
||||
//!
|
||||
//! A borrow is the other way round: the profile stays, and the login of another
|
||||
//! account is lent into it until it is returned. That is what the helper
|
||||
//! before this tool did, and it went wrong in two ways that this module is
|
||||
//! built against:
|
||||
//!
|
||||
//! * An abort left the swap half done. Here the profile's own login is
|
||||
//! written to a journal before anything in the profile is touched, and
|
||||
//! `return` restores from that journal whenever it runs -- by hand, from
|
||||
//! the timer `--for` sets, or after a crash.
|
||||
//! * A token refreshed in one copy invalidated the other. Here the lent
|
||||
//! credentials file is a link to the lender's, so a refresh in either
|
||||
//! profile is seen by both. Should Claude Code replace the link with a
|
||||
//! file of its own, `return` gives whichever copy expires later back to
|
||||
//! the lender.
|
||||
//!
|
||||
//! The profile's `.claude.json` names the account that is logged in, and only
|
||||
//! that one key, `oauthAccount`, is lent with the credentials. The file holds
|
||||
//! the profile's projects and settings besides, and stays the profile's.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::account::{self, Account};
|
||||
use crate::config::Config;
|
||||
use crate::paths::{data_dir, shorten};
|
||||
use crate::time;
|
||||
|
||||
/// What a borrow changed, written before it changes it.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Journal {
|
||||
/// The account whose profile holds the lent login.
|
||||
pub target: String,
|
||||
pub target_dir: PathBuf,
|
||||
/// The account the login belongs to.
|
||||
pub lender: String,
|
||||
pub lender_dir: PathBuf,
|
||||
pub since: i64,
|
||||
/// When the timer set by `--for` returns it, if one was set.
|
||||
pub until: Option<i64>,
|
||||
/// The profile's own credentials file, as it was. `None` when there was
|
||||
/// none, and then there is nothing to put back.
|
||||
pub credentials: Option<Value>,
|
||||
/// The profile's own `oauthAccount`, as it was.
|
||||
pub oauth_account: Option<Value>,
|
||||
}
|
||||
|
||||
/// The file in the borrowing profile that says it is borrowing, for the
|
||||
/// status line of the `enqs-info` plugin. It holds no credential.
|
||||
pub const MARKER: &str = "claude-account-borrow.json";
|
||||
|
||||
const CREDENTIALS: &str = ".credentials.json";
|
||||
const PROFILE: &str = ".claude.json";
|
||||
|
||||
fn journal_path(target: &str) -> PathBuf {
|
||||
data_dir().join("borrows").join(format!("{target}.json"))
|
||||
}
|
||||
|
||||
fn timer_unit(target: &str) -> String {
|
||||
format!("claude-account-return-{target}")
|
||||
}
|
||||
|
||||
/// The borrow a profile is in, if it is in one.
|
||||
pub fn of(target: &str) -> Option<Journal> {
|
||||
serde_json::from_str(&std::fs::read_to_string(journal_path(target)).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Every borrow that has not been returned.
|
||||
pub fn all() -> Vec<Journal> {
|
||||
let Ok(entries) = std::fs::read_dir(data_dir().join("borrows")) else { return Vec::new() };
|
||||
let mut out: Vec<Journal> = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
|
||||
.filter_map(|body| serde_json::from_str(&body).ok())
|
||||
.collect();
|
||||
out.sort_by(|left: &Journal, right| left.target.cmp(&right.target));
|
||||
out
|
||||
}
|
||||
|
||||
/// What the caller should tell the reader after a borrow.
|
||||
pub struct Lent {
|
||||
pub journal: Journal,
|
||||
/// Sessions in the borrowing profile that started before the borrow.
|
||||
pub stale_sessions: usize,
|
||||
}
|
||||
|
||||
/// Lend `lender`'s login to `target`'s profile, for `seconds` if given.
|
||||
pub fn lend(config: &Config, lender: &str, target: &str, seconds: Option<i64>) -> Result<Lent> {
|
||||
let lender = account::find(config, lender)?;
|
||||
let target = account::find(config, target)?;
|
||||
if lender.name == target.name {
|
||||
return Err(anyhow!("{} cannot borrow its own login", target.name));
|
||||
}
|
||||
if let Some(journal) = of(&target.name) {
|
||||
return Err(anyhow!(
|
||||
"{} already runs on the login of {}. Return it first: `claude-account return {}`.",
|
||||
target.name,
|
||||
journal.lender,
|
||||
target.name
|
||||
));
|
||||
}
|
||||
// A lender that is itself borrowing would lend what is not its own, and
|
||||
// returning the two in the wrong order would leave the wrong login behind.
|
||||
if let Some(journal) = of(&lender.name) {
|
||||
return Err(anyhow!(
|
||||
"{} runs on the login of {} itself. Return that first: `claude-account return {}`.",
|
||||
lender.name,
|
||||
journal.lender,
|
||||
lender.name
|
||||
));
|
||||
}
|
||||
let lender_credentials = lender.dir.join(CREDENTIALS);
|
||||
if !lender_credentials.is_file() {
|
||||
return Err(anyhow!("{} holds no login to lend", lender.name));
|
||||
}
|
||||
if std::fs::symlink_metadata(target.dir.join(CREDENTIALS)).is_ok_and(|meta| meta.is_symlink()) {
|
||||
return Err(anyhow!(
|
||||
"the credentials of {} are a link already, and no borrow is recorded for it. \
|
||||
Look at {} before borrowing into it.",
|
||||
target.name,
|
||||
shorten(&target.dir.join(CREDENTIALS))
|
||||
));
|
||||
}
|
||||
|
||||
let now = time::now();
|
||||
let journal = Journal {
|
||||
target: target.name.clone(),
|
||||
target_dir: target.dir.clone(),
|
||||
lender: lender.name.clone(),
|
||||
lender_dir: lender.dir.clone(),
|
||||
since: now,
|
||||
until: seconds.map(|seconds| now + seconds),
|
||||
credentials: read_json(&target.dir.join(CREDENTIALS)),
|
||||
oauth_account: read_json(&target.dir.join(PROFILE))
|
||||
.and_then(|profile| profile.get("oauthAccount").cloned()),
|
||||
};
|
||||
if target.dir.join(CREDENTIALS).is_file() && journal.credentials.is_none() {
|
||||
return Err(anyhow!(
|
||||
"cannot read {}, so it could not be put back",
|
||||
shorten(&target.dir.join(CREDENTIALS))
|
||||
));
|
||||
}
|
||||
// The journal first: from here on, `return` can undo whatever follows.
|
||||
write_private(&journal_path(&target.name), &serde_json::to_string_pretty(&journal)?)?;
|
||||
|
||||
let result = swap_in(&lender, &target, &journal);
|
||||
if let Err(error) = result {
|
||||
restore(&journal)
|
||||
.context("the borrow failed half way and could not be undone; run `claude-account return`")?;
|
||||
std::fs::remove_file(journal_path(&target.name))
|
||||
.context("cannot remove the journal of the borrow")?;
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(seconds) = seconds {
|
||||
schedule_return(&target.name, seconds)?;
|
||||
}
|
||||
let stale_sessions = account::running_sessions()
|
||||
.into_iter()
|
||||
.filter(|(_, profile)| profile.as_deref() == Some(target.dir.as_path()))
|
||||
.count();
|
||||
Ok(Lent { journal, stale_sessions })
|
||||
}
|
||||
|
||||
fn swap_in(lender: &Account, target: &Account, journal: &Journal) -> Result<()> {
|
||||
let credentials = target.dir.join(CREDENTIALS);
|
||||
if credentials.exists() {
|
||||
std::fs::remove_file(&credentials)
|
||||
.with_context(|| format!("cannot remove {}", shorten(&credentials)))?;
|
||||
}
|
||||
symlink(lender.dir.join(CREDENTIALS), &credentials)
|
||||
.with_context(|| format!("cannot link {}", shorten(&credentials)))?;
|
||||
let lent = read_json(&lender.dir.join(PROFILE)).and_then(|profile| profile.get("oauthAccount").cloned());
|
||||
set_oauth_account(&target.dir, lent)?;
|
||||
let marker = serde_json::json!({
|
||||
"lender": journal.lender,
|
||||
"since": journal.since,
|
||||
"until": journal.until,
|
||||
});
|
||||
std::fs::write(target.dir.join(MARKER), serde_json::to_string_pretty(&marker)? + "\n")
|
||||
.with_context(|| format!("cannot write {}", shorten(&target.dir.join(MARKER))))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give a lent login back and put the profile's own in its place.
|
||||
pub fn give_back(target: &str) -> Result<Journal> {
|
||||
let journal = of(target).ok_or_else(|| anyhow!("{target} is not borrowing anything"))?;
|
||||
restore(&journal)?;
|
||||
// The timer that would have done this is no longer needed. It is gone
|
||||
// already when it is the one running this, which is not an error.
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", &format!("{}.timer", timer_unit(target))])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
std::fs::remove_file(journal_path(target)).context("cannot remove the journal of the borrow")?;
|
||||
Ok(journal)
|
||||
}
|
||||
|
||||
fn restore(journal: &Journal) -> Result<()> {
|
||||
let credentials = journal.target_dir.join(CREDENTIALS);
|
||||
let lender_credentials = journal.lender_dir.join(CREDENTIALS);
|
||||
match std::fs::symlink_metadata(&credentials) {
|
||||
Ok(meta) if meta.is_symlink() => {}
|
||||
Ok(_) => {
|
||||
// Claude Code wrote a file of its own over the link, with the
|
||||
// lender's login in it and possibly a newer token than the one the
|
||||
// lender holds. Whichever runs longer goes back to the lender, since
|
||||
// a refresh may have made the other one useless.
|
||||
let here = read_json(&credentials);
|
||||
let there = read_json(&lender_credentials);
|
||||
if here.as_ref().and_then(expires_at) > there.as_ref().and_then(expires_at) {
|
||||
if let Some(here) = here {
|
||||
write_private(&lender_credentials, &serde_json::to_string(&here)?)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
if std::fs::symlink_metadata(&credentials).is_ok() {
|
||||
std::fs::remove_file(&credentials)
|
||||
.with_context(|| format!("cannot remove {}", shorten(&credentials)))?;
|
||||
}
|
||||
if let Some(own) = &journal.credentials {
|
||||
write_private(&credentials, &serde_json::to_string(own)?)?;
|
||||
}
|
||||
set_oauth_account(&journal.target_dir, journal.oauth_account.clone())?;
|
||||
let marker = journal.target_dir.join(MARKER);
|
||||
if marker.exists() {
|
||||
std::fs::remove_file(&marker).with_context(|| format!("cannot remove {}", shorten(&marker)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set or remove `oauthAccount` in a profile's `.claude.json`, leaving every
|
||||
/// other key as it is and in its order.
|
||||
fn set_oauth_account(dir: &Path, value: Option<Value>) -> Result<()> {
|
||||
let path = dir.join(PROFILE);
|
||||
let mut profile = match std::fs::read_to_string(&path) {
|
||||
Ok(body) => {
|
||||
serde_json::from_str::<Value>(&body).with_context(|| format!("cannot read {}", shorten(&path)))?
|
||||
}
|
||||
Err(_) if value.is_none() => return Ok(()),
|
||||
Err(_) => serde_json::json!({}),
|
||||
};
|
||||
let object = profile.as_object_mut().ok_or_else(|| anyhow!("{} is not a JSON object", shorten(&path)))?;
|
||||
match value {
|
||||
Some(value) => {
|
||||
object.insert("oauthAccount".to_string(), value);
|
||||
}
|
||||
None => {
|
||||
object.remove("oauthAccount");
|
||||
}
|
||||
}
|
||||
write_private(&path, &serde_json::to_string_pretty(&profile)?)
|
||||
}
|
||||
|
||||
/// Return the borrow after `seconds`, by a transient timer of the user's
|
||||
/// systemd. It survives this process and the terminal, and not a reboot --
|
||||
/// which `status` reports as a borrow past its time.
|
||||
fn schedule_return(target: &str, seconds: i64) -> Result<()> {
|
||||
let program = std::env::current_exe().context("cannot find this program to schedule the return")?;
|
||||
let status = Command::new("systemd-run")
|
||||
.args(["--user", "--quiet", "--collect"])
|
||||
.arg(format!("--unit={}", timer_unit(target)))
|
||||
.arg(format!("--on-active={seconds}s"))
|
||||
.args(located_by())
|
||||
.arg(&program)
|
||||
.args(["return", target])
|
||||
.stdout(Stdio::null())
|
||||
.status()
|
||||
.context("cannot run systemd-run")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"the login is lent, but no timer could be set to return it; run `claude-account return {target}` yourself"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The variables that decide where accounts and journals are found. A unit
|
||||
/// started by the user manager inherits its environment, not this shell's,
|
||||
/// so a return scheduled from a shell that moved them would look elsewhere.
|
||||
fn located_by() -> Vec<String> {
|
||||
["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"]
|
||||
.iter()
|
||||
.filter_map(|name| std::env::var(name).ok().map(|value| format!("--setenv={name}={value}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write a file only its owner may read, in one step: the credentials and
|
||||
/// the journal holding a copy of them must never be readable by anyone else,
|
||||
/// not even for the moment between writing and changing the mode.
|
||||
fn write_private(path: &Path, body: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", shorten(parent)))?;
|
||||
}
|
||||
let temporary = path.with_extension(format!("tmp.{}", std::process::id()));
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)
|
||||
.with_context(|| format!("cannot write {}", shorten(&temporary)))?;
|
||||
file.write_all(body.as_bytes())?;
|
||||
if !body.ends_with('\n') {
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
}
|
||||
std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))?;
|
||||
std::fs::rename(&temporary, path).with_context(|| format!("cannot write {}", shorten(path)))
|
||||
}
|
||||
|
||||
fn expires_at(credentials: &Value) -> Option<i64> {
|
||||
credentials.get("claudeAiOauth")?.get("expiresAt")?.as_i64()
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Option<Value> {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
+2
-1
@@ -18,7 +18,8 @@ use crate::Cli;
|
||||
|
||||
/// The subcommands whose argument is an account. The dynamic rules offer names
|
||||
/// after each of them; anywhere else the generated script has the last word.
|
||||
const TAKES_ACCOUNT: &str = "switch dir env url remove start stop restart logs install uninstall port";
|
||||
const TAKES_ACCOUNT: &str =
|
||||
"switch borrow return dir env url remove start stop restart logs install uninstall port";
|
||||
|
||||
/// The completion script for one shell.
|
||||
pub fn script(shell: Shell) -> String {
|
||||
|
||||
+130
@@ -4,6 +4,7 @@
|
||||
//! whichever account was switched to last.
|
||||
|
||||
mod account;
|
||||
mod borrow;
|
||||
mod completions;
|
||||
mod config;
|
||||
mod headroom;
|
||||
@@ -11,8 +12,10 @@ mod paths;
|
||||
mod router;
|
||||
mod service;
|
||||
mod shim;
|
||||
mod stats;
|
||||
mod status;
|
||||
mod systemd;
|
||||
mod time;
|
||||
mod ui;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -73,6 +76,31 @@ enum Command {
|
||||
/// The account, as `list` names it
|
||||
account: String,
|
||||
},
|
||||
/// Lend another account's login to a profile, plugins and settings staying
|
||||
Borrow {
|
||||
/// The account whose login is lent
|
||||
lender: String,
|
||||
/// The account whose profile runs on it; the active one by default
|
||||
#[arg(long)]
|
||||
into: Option<String>,
|
||||
/// Return it by itself after this long: 90m, 3h, 1h30m, 2d
|
||||
#[arg(long = "for", value_name = "DURATION")]
|
||||
duration: Option<String>,
|
||||
/// Borrow even though jails run in that profile
|
||||
#[arg(long)]
|
||||
with_jails: bool,
|
||||
},
|
||||
/// Give a borrowed login back and put the profile's own in its place
|
||||
Return {
|
||||
/// The account that borrowed; the only borrowing one by default
|
||||
account: Option<String>,
|
||||
},
|
||||
/// What every proxy has recorded, side by side: saving, call sizes, limits
|
||||
Stats {
|
||||
/// Print the figures as JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Print the name of the active account
|
||||
Current,
|
||||
/// Print the profile directory of an account, for `CLAUDE_CONFIG_DIR`
|
||||
@@ -220,6 +248,14 @@ fn run() -> Result<()> {
|
||||
add(&mut config, &name, dir, port, and_switch)
|
||||
}
|
||||
Command::Remove { account } => remove(&mut config, &account),
|
||||
Command::Borrow { lender, into, duration, with_jails } => {
|
||||
borrow(&config, &lender, into.as_deref(), duration.as_deref(), with_jails)
|
||||
}
|
||||
Command::Return { account } => give_back(&config, account.as_deref()),
|
||||
Command::Stats { json } => {
|
||||
stats::report(&config, json);
|
||||
Ok(())
|
||||
}
|
||||
Command::Current => {
|
||||
let account = require_active(&config)?;
|
||||
println!("{}", account.name);
|
||||
@@ -314,6 +350,100 @@ fn add(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lend one account's login to another account's profile.
|
||||
///
|
||||
/// The jails are the one reason to refuse. A jail keeps customer data inside
|
||||
/// a sandbox, and the login decides whose account that data then goes out
|
||||
/// under; a private login lent to the work profile would take it along
|
||||
/// without a word. So a profile a jail runs in is only borrowed into when
|
||||
/// that is said out loud.
|
||||
fn borrow(
|
||||
config: &Config,
|
||||
lender: &str,
|
||||
into: Option<&str>,
|
||||
duration: Option<&str>,
|
||||
with_jails: bool,
|
||||
) -> Result<()> {
|
||||
let target = one(config, into)?;
|
||||
let seconds = duration.map(time::parse_span).transpose()?;
|
||||
let jails = stats::jails_in(&target.dir);
|
||||
if !jails.is_empty() && !with_jails {
|
||||
return Err(anyhow!(
|
||||
"the jails {} run in the profile of {}, and would send what they read through the \
|
||||
login of {lender}. Pass --with-jails to borrow anyway, or borrow into another profile.",
|
||||
jails.join(", "),
|
||||
target.name
|
||||
));
|
||||
}
|
||||
let lent = borrow::lend(config, lender, &target.name, seconds)?;
|
||||
let journal = &lent.journal;
|
||||
let lender = account::find(config, &journal.lender)?;
|
||||
ui::done(format!(
|
||||
"{} runs on the login of {} now, with its own plugins and settings.",
|
||||
ui::bold(&journal.target),
|
||||
ui::bold(&journal.lender)
|
||||
));
|
||||
if let Some(email) = lender.email() {
|
||||
ui::field("login", email);
|
||||
}
|
||||
match journal.until {
|
||||
Some(until) => ui::field("returns", format!("by itself in {}", time::span(until - journal.since))),
|
||||
None => ui::hint(format!("claude-account return {}", journal.target)),
|
||||
}
|
||||
if !jails.is_empty() {
|
||||
ui::field("jails", ui::yellow(format!("{} run on the lent login too", jails.join(", "))));
|
||||
}
|
||||
ui::field(
|
||||
"proxy",
|
||||
format!("{}'s proxy polls the limits of {} meanwhile", journal.target, journal.lender),
|
||||
);
|
||||
if lent.stale_sessions > 0 {
|
||||
ui::field(
|
||||
"note",
|
||||
ui::yellow(format!(
|
||||
"{} session(s) of {} are open and keep the old login until restarted",
|
||||
lent.stale_sessions, journal.target
|
||||
)),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn give_back(config: &Config, name: Option<&str>) -> Result<()> {
|
||||
let target = match name {
|
||||
Some(name) => account::find(config, name)?.name,
|
||||
None => {
|
||||
let borrows = borrow::all();
|
||||
match borrows.as_slice() {
|
||||
[] => return Err(anyhow!("no profile is borrowing a login")),
|
||||
[only] => only.target.clone(),
|
||||
_ => {
|
||||
let names: Vec<&str> = borrows.iter().map(|journal| journal.target.as_str()).collect();
|
||||
return Err(anyhow!("{} are borrowing; name one", names.join(" and ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let journal = borrow::give_back(&target)?;
|
||||
ui::done(format!(
|
||||
"{} runs on its own login again; {} has its back.",
|
||||
ui::bold(&journal.target),
|
||||
ui::bold(&journal.lender)
|
||||
));
|
||||
ui::field("borrowed for", time::span(time::now() - journal.since));
|
||||
let open = account::running_sessions()
|
||||
.into_iter()
|
||||
.filter(|(_, profile)| profile.as_deref() == Some(journal.target_dir.as_path()))
|
||||
.count();
|
||||
if open > 0 {
|
||||
ui::field(
|
||||
"note",
|
||||
format!("{open} session(s) of {} still hold the lent login until restarted", journal.target),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget an account. The profile directory and the workspace stay: this
|
||||
/// command undoes a registration, not a login and not a year of projects.
|
||||
fn remove(config: &mut Config, name: &str) -> Result<()> {
|
||||
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
//! What the proxies have recorded about themselves, side by side.
|
||||
//!
|
||||
//! Every proxy keeps its figures in its own workspace, and its dashboard shows
|
||||
//! one proxy at a time -- the ones of the accounts and the ones the jails
|
||||
//! started. Measured together they said things none of them says alone: that
|
||||
//! nine tenths of the saving are tool definitions held back rather than
|
||||
//! compression, that one compressed call in eight was already past the point
|
||||
//! where a session is better cleared, and how fast a weekly limit is going.
|
||||
//!
|
||||
//! Only the files a proxy keeps about itself are read: `proxy_savings.json`,
|
||||
//! `savings_events.jsonl`, `subscription_state.json` and `toin.json`. The
|
||||
//! retrieval store and the logs hold what the sessions sent, and in a jail that
|
||||
//! is customer data. They are never opened, so this report carries numbers and
|
||||
//! no content.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::account;
|
||||
use crate::config::Config;
|
||||
use crate::paths::{expand_user, home, shorten};
|
||||
use crate::time;
|
||||
use crate::ui;
|
||||
|
||||
/// Where the status line of the `enqs-info` plugin starts to suggest `/clear`,
|
||||
/// in tokens. A compressed call above it is one that re-sends a context a
|
||||
/// fresh session would not have.
|
||||
const CLEAR_AT: u64 = 150_000;
|
||||
|
||||
/// How far back the pace of a weekly limit is measured, in seconds.
|
||||
const PACE_SPAN: i64 = 3 * 3600;
|
||||
|
||||
/// The figures of one proxy.
|
||||
#[derive(Default)]
|
||||
struct Proxy {
|
||||
name: String,
|
||||
workspace: PathBuf,
|
||||
requests: u64,
|
||||
input: u64,
|
||||
output: u64,
|
||||
cache_reads: u64,
|
||||
compression: u64,
|
||||
tool_definitions: u64,
|
||||
/// Sizes of the calls the proxy compressed, before it did.
|
||||
sizes: Vec<u64>,
|
||||
compressions: u64,
|
||||
retrievals: u64,
|
||||
week: Option<Week>,
|
||||
}
|
||||
|
||||
/// The weekly limit as the proxy last polled it.
|
||||
struct Week {
|
||||
used: f64,
|
||||
resets_at: i64,
|
||||
polled_at: i64,
|
||||
/// Points per hour over the last polls, where they span long enough.
|
||||
pace: Option<f64>,
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
fn saved(&self) -> u64 {
|
||||
self.compression + self.tool_definitions
|
||||
}
|
||||
|
||||
fn above_clear(&self) -> usize {
|
||||
self.sizes.iter().filter(|&&size| size > CLEAR_AT).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Print the report, or the same figures as JSON.
|
||||
pub fn report(config: &Config, as_json: bool) {
|
||||
let proxies: Vec<Proxy> =
|
||||
workspaces(config).into_iter().filter_map(|(name, workspace)| read(name, workspace)).collect();
|
||||
if as_json {
|
||||
println!("{}", serde_json::to_string_pretty(&to_json(&proxies)).unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
if proxies.is_empty() {
|
||||
println!("No proxy has recorded anything yet.");
|
||||
return;
|
||||
}
|
||||
print(&proxies);
|
||||
}
|
||||
|
||||
/// Every workspace worth reading: one per account, then one per jail that
|
||||
/// runs a proxy of its own.
|
||||
fn workspaces(config: &Config) -> Vec<(String, PathBuf)> {
|
||||
let mut out: Vec<(String, PathBuf)> =
|
||||
account::all(config).into_iter().map(|account| (account.name, account.workspace)).collect();
|
||||
for (name, dir) in jails() {
|
||||
let workspace = dir.join(".enqs-jail/headroom");
|
||||
if !out.iter().any(|(_, known)| known == &workspace) {
|
||||
out.push((format!("jail {name}"), workspace));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The jails the `enqs-jail` plugin has registered, by name and directory.
|
||||
pub fn jails() -> Vec<(String, PathBuf)> {
|
||||
let Some(registry) = read_json(®istry_path()) else { return Vec::new() };
|
||||
let Some(jails) = registry.get("jails").and_then(Value::as_object) else { return Vec::new() };
|
||||
jails
|
||||
.iter()
|
||||
.filter_map(|(name, jail)| Some((name.clone(), expand_user(jail.get("path")?.as_str()?))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The jails whose sessions run in a given profile directory.
|
||||
pub fn jails_in(profile: &Path) -> Vec<String> {
|
||||
let Some(registry) = read_json(®istry_path()) else { return Vec::new() };
|
||||
let Some(jails) = registry.get("jails").and_then(Value::as_object) else { return Vec::new() };
|
||||
jails
|
||||
.iter()
|
||||
.filter(|(_, jail)| {
|
||||
jail.get("profile").and_then(Value::as_str).map(expand_user).as_deref() == Some(profile)
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `~/.config/enqs/jails.json`, where `enqs-jail` keeps its registry.
|
||||
fn registry_path() -> PathBuf {
|
||||
if let Ok(path) = std::env::var("ENQS_JAIL_REGISTRY") {
|
||||
if !path.is_empty() {
|
||||
return expand_user(&path);
|
||||
}
|
||||
}
|
||||
match std::env::var("XDG_CONFIG_HOME") {
|
||||
Ok(value) if !value.is_empty() => PathBuf::from(value),
|
||||
_ => home().join(".config"),
|
||||
}
|
||||
.join("enqs/jails.json")
|
||||
}
|
||||
|
||||
fn read(name: String, workspace: PathBuf) -> Option<Proxy> {
|
||||
let savings = read_json(&workspace.join("proxy_savings.json"))?;
|
||||
let lifetime = savings.get("lifetime")?;
|
||||
let mut proxy = Proxy {
|
||||
requests: number(lifetime, "requests"),
|
||||
input: number(lifetime, "total_input_tokens"),
|
||||
cache_reads: number(lifetime, "cache_read_tokens"),
|
||||
// `tokens_saved` is compression alone. The tool definitions a proxy
|
||||
// holds back until a tool is searched for are the larger share, and
|
||||
// are only counted per model.
|
||||
compression: number(lifetime, "tokens_saved"),
|
||||
tool_definitions: savings
|
||||
.get("by_model")
|
||||
.and_then(Value::as_object)
|
||||
.map(|models| models.values().map(|model| number(model, "tool_tokens_saved")).sum())
|
||||
.unwrap_or(0),
|
||||
output: savings.pointer("/lifetime_metrics/tokens/output").and_then(Value::as_u64).unwrap_or(0),
|
||||
name,
|
||||
..Proxy::default()
|
||||
};
|
||||
|
||||
if let Ok(body) = std::fs::read_to_string(workspace.join("savings_events.jsonl")) {
|
||||
proxy.sizes = body
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter_map(|event| event.get("before").and_then(Value::as_u64))
|
||||
.collect();
|
||||
proxy.sizes.sort_unstable();
|
||||
}
|
||||
|
||||
if let Some(toin) = read_json(&workspace.join("toin.json")) {
|
||||
if let Some(patterns) = toin.get("patterns").and_then(Value::as_object) {
|
||||
proxy.compressions = patterns.values().map(|pattern| number(pattern, "total_compressions")).sum();
|
||||
proxy.retrievals = patterns.values().map(|pattern| number(pattern, "total_retrievals")).sum();
|
||||
}
|
||||
}
|
||||
|
||||
proxy.week = read_json(&workspace.join("subscription_state.json")).and_then(|state| week(&state));
|
||||
proxy.workspace = workspace;
|
||||
Some(proxy)
|
||||
}
|
||||
|
||||
/// The weekly limit, and how fast it rose over the polls of the last hours.
|
||||
///
|
||||
/// The limit comes in whole points, so polls less than half an hour apart are
|
||||
/// too coarse to give a pace; the proxy polls every five minutes while it is
|
||||
/// used, which is plenty after that.
|
||||
fn week(state: &Value) -> Option<Week> {
|
||||
let latest = state.get("latest")?.get("seven_day")?;
|
||||
let polled_at = time::parse_iso(state.get("latest")?.get("polled_at")?.as_str()?)?;
|
||||
let mut week = Week {
|
||||
used: latest.get("utilization_pct")?.as_f64()?,
|
||||
resets_at: time::parse_iso(latest.get("resets_at")?.as_str()?)?,
|
||||
polled_at,
|
||||
pace: None,
|
||||
};
|
||||
let polls: Vec<(i64, f64)> = state
|
||||
.get("history")
|
||||
.and_then(Value::as_array)
|
||||
.map(|history| {
|
||||
history
|
||||
.iter()
|
||||
.filter_map(|poll| {
|
||||
let at = time::parse_iso(poll.get("polled_at")?.as_str()?)?;
|
||||
let used = poll.get("seven_day")?.get("utilization_pct")?.as_f64()?;
|
||||
Some((at, used))
|
||||
})
|
||||
.filter(|(at, _)| polled_at - at <= PACE_SPAN)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(&(first_at, first_used)) = polls.iter().min_by_key(|(at, _)| *at) {
|
||||
// A reset in between makes the rise negative; that is no pace.
|
||||
if polled_at - first_at >= 1800 && week.used >= first_used {
|
||||
week.pace = Some((week.used - first_used) * 3600.0 / (polled_at - first_at) as f64);
|
||||
}
|
||||
}
|
||||
Some(week)
|
||||
}
|
||||
|
||||
impl Week {
|
||||
/// Seconds from now until the limit is used up at this pace, when that is
|
||||
/// before it resets.
|
||||
fn full_in(&self, now: i64) -> Option<i64> {
|
||||
let pace = self.pace.filter(|pace| *pace > 0.0)?;
|
||||
let left = ((100.0 - self.used) / pace * 3600.0) as i64 - (now - self.polled_at);
|
||||
(left < self.resets_at - now).then_some(left.max(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn print(proxies: &[Proxy]) {
|
||||
let now = time::now();
|
||||
ui::heading("Since each proxy was set up");
|
||||
println!(
|
||||
" {}",
|
||||
ui::dim(format!(
|
||||
"{:<16} {:>6} {:>7} {:>7} {:>7} {:>7} {:>7}",
|
||||
"", "calls", "input", "saved", "compr.", "cache", ">150k"
|
||||
))
|
||||
);
|
||||
for proxy in proxies {
|
||||
println!(
|
||||
" {} {:>6} {:>7} {:>7} {:>7} {:>7} {:>7}",
|
||||
ui::bold(format!("{:<16}", proxy.name)),
|
||||
proxy.requests,
|
||||
tokens(proxy.input),
|
||||
percent(proxy.saved(), proxy.input, 1),
|
||||
percent(proxy.compression, proxy.input, 2),
|
||||
percent(proxy.cache_reads, proxy.input, 0),
|
||||
percent(proxy.above_clear() as u64, proxy.sizes.len() as u64, 1),
|
||||
);
|
||||
}
|
||||
let total = combine(proxies);
|
||||
println!(
|
||||
" {} {:>6} {:>7} {:>7} {:>7} {:>7} {:>7}",
|
||||
ui::bold(format!("{:<16}", "together")),
|
||||
total.requests,
|
||||
tokens(total.input),
|
||||
percent(total.saved(), total.input, 1),
|
||||
percent(total.compression, total.input, 2),
|
||||
percent(total.cache_reads, total.input, 0),
|
||||
percent(total.above_clear() as u64, total.sizes.len() as u64, 1),
|
||||
);
|
||||
|
||||
ui::heading("What that means");
|
||||
ui::field(
|
||||
"saving",
|
||||
format!(
|
||||
"{} of it is tool definitions held back, {} compression of the conversation",
|
||||
percent(total.tool_definitions, total.saved(), 0),
|
||||
percent(total.compression, total.saved(), 0)
|
||||
),
|
||||
);
|
||||
if !total.sizes.is_empty() {
|
||||
ui::field(
|
||||
"call size",
|
||||
format!(
|
||||
"median {}, {} of compressed calls above {} — past the /clear mark",
|
||||
tokens(total.sizes[total.sizes.len() / 2]),
|
||||
percent(total.above_clear() as u64, total.sizes.len() as u64, 1),
|
||||
tokens(CLEAR_AT)
|
||||
),
|
||||
);
|
||||
}
|
||||
ui::field(
|
||||
"output",
|
||||
format!(
|
||||
"{} of input: the limits are spent on reading, not writing",
|
||||
percent(total.output, total.input, 2)
|
||||
),
|
||||
);
|
||||
if total.compressions > 0 {
|
||||
ui::field(
|
||||
"retrievals",
|
||||
format!(
|
||||
"{} of {} compressed tool outputs were fetched back in full",
|
||||
total.retrievals, total.compressions
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let weeks: Vec<&Proxy> = proxies.iter().filter(|proxy| proxy.week.is_some()).collect();
|
||||
if weeks.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui::heading("Weekly limits, as each proxy last polled them");
|
||||
let width = weeks.iter().map(|proxy| proxy.name.len()).max().unwrap_or(0);
|
||||
for proxy in weeks {
|
||||
let Some(week) = &proxy.week else { continue };
|
||||
let name = format!("{:<width$}", proxy.name);
|
||||
let resets = if week.resets_at > now {
|
||||
format!("resets in {}", time::span(week.resets_at - now))
|
||||
} else {
|
||||
"reset since".to_string()
|
||||
};
|
||||
let mut line = format!("{:>3.0} % {resets}", week.used);
|
||||
if let Some(pace) = week.pace {
|
||||
line.push_str(&format!(", {pace:.1} points an hour"));
|
||||
}
|
||||
line.push_str(&ui::dim(format!(", polled {} ago", time::span(now - week.polled_at))));
|
||||
match week.full_in(now) {
|
||||
Some(left) if week.resets_at > now => {
|
||||
ui::warn(&name, format!("{line} — {}", ui::yellow(format!("full in {}", time::span(left)))))
|
||||
}
|
||||
_ => ui::ok(&name, line),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The figures of every proxy added up. The weekly limit is left out: two
|
||||
/// proxies of one account poll the same limit, and a sum of limits is none.
|
||||
fn combine(proxies: &[Proxy]) -> Proxy {
|
||||
let mut total = Proxy::default();
|
||||
for proxy in proxies {
|
||||
total.requests += proxy.requests;
|
||||
total.input += proxy.input;
|
||||
total.output += proxy.output;
|
||||
total.cache_reads += proxy.cache_reads;
|
||||
total.compression += proxy.compression;
|
||||
total.tool_definitions += proxy.tool_definitions;
|
||||
total.sizes.extend(&proxy.sizes);
|
||||
total.compressions += proxy.compressions;
|
||||
total.retrievals += proxy.retrievals;
|
||||
}
|
||||
total.sizes.sort_unstable();
|
||||
total
|
||||
}
|
||||
|
||||
fn to_json(proxies: &[Proxy]) -> Value {
|
||||
let now = time::now();
|
||||
let one = |proxy: &Proxy| {
|
||||
let sizes = &proxy.sizes;
|
||||
json!({
|
||||
"requests": proxy.requests,
|
||||
"input": proxy.input,
|
||||
"output": proxy.output,
|
||||
"cache_reads": proxy.cache_reads,
|
||||
"compression": proxy.compression,
|
||||
"tool_definitions": proxy.tool_definitions,
|
||||
"saved": proxy.saved(),
|
||||
"compressed_calls": sizes.len(),
|
||||
"compressed_calls_above_clear": proxy.above_clear(),
|
||||
"compressed_call_median": sizes.get(sizes.len() / 2),
|
||||
"compressed_call_max": sizes.last(),
|
||||
"compressions": proxy.compressions,
|
||||
"retrievals": proxy.retrievals,
|
||||
})
|
||||
};
|
||||
let mut out = serde_json::Map::new();
|
||||
for proxy in proxies {
|
||||
let mut value = one(proxy);
|
||||
value["workspace"] = json!(shorten(&proxy.workspace));
|
||||
if let Some(week) = &proxy.week {
|
||||
value["week"] = json!({
|
||||
"used_pct": week.used,
|
||||
"resets_at": week.resets_at,
|
||||
"polled_at": week.polled_at,
|
||||
"points_per_hour": week.pace,
|
||||
"full_in_seconds": week.full_in(now),
|
||||
});
|
||||
}
|
||||
out.insert(proxy.name.clone(), value);
|
||||
}
|
||||
json!({ "clear_at": CLEAR_AT, "together": one(&combine(proxies)), "proxies": out })
|
||||
}
|
||||
|
||||
fn number(value: &Value, key: &str) -> u64 {
|
||||
value.get(key).and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn percent(part: u64, whole: u64, digits: usize) -> String {
|
||||
if whole == 0 {
|
||||
return "–".to_string();
|
||||
}
|
||||
format!("{:.digits$} %", 100.0 * part as f64 / whole as f64)
|
||||
}
|
||||
|
||||
/// Tokens as 950, 12k, 1.2M or 937M.
|
||||
fn tokens(count: u64) -> String {
|
||||
match count {
|
||||
0..=999 => count.to_string(),
|
||||
1_000..=999_999 => format!("{}k", count / 1000),
|
||||
1_000_000..=99_999_999 => format!("{:.1}M", count as f64 / 1e6),
|
||||
_ => format!("{}M", count / 1_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Option<Value> {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
//! checks each of them and says the command that repairs it.
|
||||
|
||||
use crate::account::{self, Account};
|
||||
use crate::borrow;
|
||||
use crate::config::Config;
|
||||
use crate::headroom;
|
||||
use crate::paths::shorten;
|
||||
@@ -16,6 +17,7 @@ use crate::router;
|
||||
use crate::service;
|
||||
use crate::shim;
|
||||
use crate::systemd;
|
||||
use crate::time;
|
||||
use crate::ui;
|
||||
|
||||
/// Something that is wrong, and the command that fixes it.
|
||||
@@ -70,6 +72,7 @@ fn account_section(
|
||||
ui::field("profile", shorten(&account.dir));
|
||||
ui::field("proxy", proxy_line(config, account));
|
||||
ui::field("plugins", plugins_line(account));
|
||||
borrow_line(account, problems);
|
||||
match account.token_minutes_left() {
|
||||
Some(minutes) if minutes < 0 => {
|
||||
ui::field("token", ui::red("expired"));
|
||||
@@ -122,6 +125,36 @@ fn account_section(
|
||||
let _ = config;
|
||||
}
|
||||
|
||||
/// Whose login a profile runs on, when it is not its own. A borrow past its
|
||||
/// end means the timer that should have returned it is gone -- a reboot drops
|
||||
/// transient units -- and the profile keeps spending the lender's limits.
|
||||
fn borrow_line(account: &Account, problems: &mut Vec<Problem>) {
|
||||
let Some(journal) = borrow::of(&account.name) else {
|
||||
return;
|
||||
};
|
||||
let now = time::now();
|
||||
match journal.until {
|
||||
Some(until) if until <= now => {
|
||||
ui::field(
|
||||
"borrowed",
|
||||
ui::red(format!("from {}, overdue by {}", journal.lender, time::span(now - until))),
|
||||
);
|
||||
problems.push(Problem::new(
|
||||
format!(
|
||||
"{} still runs on the login of {} past the end of the borrow",
|
||||
account.name, journal.lender
|
||||
),
|
||||
Some(&format!("claude-account return {}", account.name)),
|
||||
));
|
||||
}
|
||||
Some(until) => ui::field(
|
||||
"borrowed",
|
||||
ui::yellow(format!("from {}, returns in {}", journal.lender, time::span(until - now))),
|
||||
),
|
||||
None => ui::field("borrowed", ui::yellow(format!("from {}, until returned", journal.lender))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an account's proxy is reached. Every account has a name of its own,
|
||||
/// so a session of either one can show its dashboard while both are open.
|
||||
fn proxy_line(config: &Config, account: &Account) -> String {
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
//! Seconds, the timestamps Headroom writes, and durations as a reader types
|
||||
//! and reads them.
|
||||
//!
|
||||
//! Written by hand rather than pulled in as a crate: the tool needs to read one
|
||||
//! timestamp format, add and subtract seconds, and print "3h40m". Everything
|
||||
//! is in UTC and relative to now, so no time zone is ever involved.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|elapsed| elapsed.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Seconds since the epoch of `2026-09-23T15:13:45Z`, with or without a
|
||||
/// fraction, and with `Z` or `+00:00` -- the forms Headroom writes. Another
|
||||
/// offset is not UTC and is refused rather than read wrong.
|
||||
pub fn parse_iso(value: &str) -> Option<i64> {
|
||||
let (date, time) = value.split_once('T')?;
|
||||
let mut date = date.split('-').map(|part| part.parse::<i64>().ok());
|
||||
let (year, month, day) = (date.next()??, date.next()??, date.next()??);
|
||||
let time = time.strip_suffix('Z').or_else(|| time.strip_suffix("+00:00"))?;
|
||||
let time = time.split('.').next()?;
|
||||
let mut time = time.split(':').map(|part| part.parse::<i64>().ok());
|
||||
let (hour, minute, second) = (time.next()??, time.next()??, time.next().flatten().unwrap_or(0));
|
||||
Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 of a date in the proleptic Gregorian calendar.
|
||||
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||
let year = if month <= 2 { year - 1 } else { year };
|
||||
let era = year.div_euclid(400);
|
||||
let year_of_era = year - era * 400;
|
||||
let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
|
||||
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||
era * 146_097 + day_of_era - 719_468
|
||||
}
|
||||
|
||||
/// Seconds as the two largest units that say something: 2d4h, 3h40m, 12m.
|
||||
pub fn span(seconds: i64) -> String {
|
||||
let seconds = seconds.max(0);
|
||||
let (days, hours, minutes) = (seconds / 86_400, seconds % 86_400 / 3600, seconds % 3600 / 60);
|
||||
match (days, hours) {
|
||||
(0, 0) => format!("{minutes}m"),
|
||||
(0, _) if minutes == 0 => format!("{hours}h"),
|
||||
(0, _) => format!("{hours}h{minutes:02}m"),
|
||||
_ if hours == 0 => format!("{days}d"),
|
||||
_ => format!("{days}d{hours}h"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration as it is typed: `90m`, `3h`, `1h30m`, `2d`, or plain seconds.
|
||||
pub fn parse_span(value: &str) -> Result<i64> {
|
||||
let invalid = || anyhow!("{value} is not a duration; write it as 90m, 3h, 1h30m or 2d");
|
||||
let mut total = 0;
|
||||
let mut number = String::new();
|
||||
for character in value.trim().chars() {
|
||||
if character.is_ascii_digit() {
|
||||
number.push(character);
|
||||
continue;
|
||||
}
|
||||
let amount: i64 = number.parse().map_err(|_| invalid())?;
|
||||
number.clear();
|
||||
total += amount
|
||||
* match character {
|
||||
'd' => 86_400,
|
||||
'h' => 3600,
|
||||
'm' => 60,
|
||||
's' => 1,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
}
|
||||
if !number.is_empty() {
|
||||
total += number.parse::<i64>().map_err(|_| invalid())?;
|
||||
}
|
||||
if total <= 0 {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn headroom_timestamps_are_read() {
|
||||
assert_eq!(parse_iso("1970-01-01T00:00:00Z"), Some(0));
|
||||
assert_eq!(parse_iso("2026-09-23T15:13:45Z"), Some(1_790_176_425));
|
||||
assert_eq!(parse_iso("2026-09-23T15:13:45.123+00:00"), Some(1_790_176_425));
|
||||
assert_eq!(parse_iso("2026-09-23T15:13:45+02:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_span_names_two_units() {
|
||||
assert_eq!(span(59), "0m");
|
||||
assert_eq!(span(3600), "1h");
|
||||
assert_eq!(span(3600 + 40 * 60), "1h40m");
|
||||
assert_eq!(span(2 * 86_400 + 4 * 3600 + 59), "2d4h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duration_is_read_as_typed() {
|
||||
assert_eq!(parse_span("90m").unwrap(), 5400);
|
||||
assert_eq!(parse_span("1h30m").unwrap(), 5400);
|
||||
assert_eq!(parse_span("2d").unwrap(), 172_800);
|
||||
assert_eq!(parse_span("45").unwrap(), 45);
|
||||
assert!(parse_span("soon").is_err());
|
||||
assert!(parse_span("0h").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user