tokenmaxxing 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -48,7 +48,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
48
48
 
49
49
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
50
50
  1. Read `usage.json`; `exit 0` fast if both windows `< 95%` (metered per `organizationUuid`).
51
- 2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (lowest 7-day, not rate-limited, soonest `resets_at` tiebreak), and **swap the credential** (§3.4).
51
+ 2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, soonest-expiring weekly window first since unused allowance is forfeited at the fixed per-account reset, lowest 7-day usage tiebreak), and **swap the credential** (§3.4).
52
52
  3. Write `respawn/<session_id>` (atomic temp+rename) and `SIGTERM` the parent `claude` (`kill -TERM $PPID`). The turn is already committed, so this is a clean stop.
53
53
 
54
54
  ### 3.3 Respawn (supervisor)
@@ -106,13 +106,13 @@ Trigger at `five_hour >= 95%` OR `seven_day >= 95%`, per org. "Exhausted" is a *
106
106
  ---
107
107
 
108
108
  ## 8. Stack
109
- TypeScript on Bun, `bun build --compile` the `claude` supervisor, the statusLine shim, and the hook as fast-starting single binaries (the Stop path runs every turn, so start-up latency matters). npm name `tokenmaxxing` owned. The supervisor needs a real PTY layer (spawn claude on a pty, forward resize/signals, restore mode between runs).
109
+ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). The supervisor needs a real PTY layer (spawn claude on a pty, forward resize/signals, restore mode between runs).
110
110
 
111
111
  ---
112
112
 
113
113
  ## 9. What the acceptance gate showed (2026-07-09)
114
114
 
115
- Unit suite: **32 pass**. Hermetic swap+concurrency+model-aware E2E: **all pass**. CLI init/doctor/uninstall through the compiled binary: **pass**.
115
+ Unit suite: **32 pass**. Hermetic swap+concurrency+model-aware E2E: **all pass**. CLI init/doctor/uninstall: **pass** (re-verified init/doctor 2026-07-09 through the npm-installed bun shim on linux-arm64 after the switch to source packaging; full suite 47 pass / 0 fail there).
116
116
 
117
117
  1. **Transcript continuity across a process boundary - ✅ proven on real claude/real account.** `claude --session-id X -p …` committed a clean 12-line transcript; `claude --resume X -p …` recalled the earlier turn's codeword. The supervisor's kill→restore-termios→respawn→`--resume` loop is proven with a mock claude. The one step not run live is the abrupt SIGTERM of an *idle interactive* real claude (structurally safe - the transcript is fully committed+fsynced before idle and nothing writes while idle).
118
118
  2. **Terminal restoration - ✅ mechanism proven.** The supervisor saves `stty -g` and restores it between kill and respawn; the path executes in the mock-supervisor run. Visual raw-mode/​resize confirmation wants a live interactive terminal.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Automatic Claude Code account switching.** Run `claude` exactly as you always do; when the active account crosses its usage limit, tokenmaxxing swaps to a fresh account and - at the next safe turn boundary - restarts your session *resumed on it*, automatically. Works across many concurrent sessions.
4
4
 
5
- > **Scope:** Claude Code only, macOS (Apple Silicon) and Linux (x64/arm64). It pools **subscription** accounts (Pro/Max), not API keys.
5
+ > **Scope:** Claude Code only, macOS and Linux. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
7
7
  ```
8
8
  $ claude
@@ -20,10 +20,8 @@ A running `claude` holds its OAuth token in memory and a 429 does **not** make i
20
20
  Requires [Bun](https://bun.sh) and Claude Code, on macOS or Linux.
21
21
 
22
22
  ```sh
23
- git clone https://github.com/anaclumos/tokenmaxxing && cd tokenmaxxing
24
- bun install
25
- bun run build # → dist/tokenmaxxing (single binary)
26
- ./dist/tokenmaxxing init
23
+ bun add -g tokenmaxxing
24
+ tokenmaxxing init
27
25
  ```
28
26
 
29
27
  `init` imports the account you're already on, installs the `claude` supervisor + three `settings.json` entries (a statusLine shim, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
@@ -83,7 +81,7 @@ State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follo
83
81
 
84
82
  ## How it's built
85
83
 
86
- TypeScript on Bun, compiled to a single binary with `bun build --compile`. [Zod](https://zod.dev) validates every external-boundary payload (credential blobs, hook/statusLine stdin, OAuth responses, config), [es-toolkit](https://es-toolkit.dev) for utilities. The supervisor is process/terminal-only - it never proxies API traffic or touches tokens in flight. Cross-process coordination uses `flock(2)` via `bun:ffi` (macOS has no `flock(1)`; one codepath serves both platforms). Credential I/O goes through one platform-selected store: `security(1)` generic-passwords on macOS, atomic 0600 file writes on Linux.
84
+ TypeScript on Bun: one multi-call entry (`src/main.ts`) serves the CLI, the `claude` supervisor, and the hook/statusLine shims, and runs directly under bun. [Zod](https://zod.dev) validates every external-boundary payload (credential blobs, hook/statusLine stdin, OAuth responses, config), [es-toolkit](https://es-toolkit.dev) for utilities. The supervisor is process/terminal-only - it never proxies API traffic or touches tokens in flight. Cross-process coordination uses `flock(2)` via `bun:ffi` (macOS has no `flock(1)`; one codepath serves both platforms). Credential I/O goes through one platform-selected store: `security(1)` generic-passwords on macOS, atomic 0600 file writes on Linux.
87
85
 
88
86
  ## License
89
87
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.2.0",
4
- "description": "Automatic Claude Code account switching pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
3
+ "version": "0.3.0",
4
+ "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "anaclumos",
@@ -11,10 +11,10 @@
11
11
  "url": "git+https://github.com/anaclumos/tokenmaxxing.git"
12
12
  },
13
13
  "bin": {
14
- "tokenmaxxing": "./dist/tokenmaxxing"
14
+ "tokenmaxxing": "./src/main.ts"
15
15
  },
16
16
  "files": [
17
- "dist/tokenmaxxing",
17
+ "src",
18
18
  "README.md",
19
19
  "DESIGN.md"
20
20
  ],
@@ -22,16 +22,11 @@
22
22
  "bun": ">=1.1.0"
23
23
  },
24
24
  "os": ["darwin", "linux"],
25
- "cpu": ["arm64", "x64"],
26
25
  "scripts": {
27
26
  "dev": "bun run src/main.ts",
28
- "build": "bun build --compile --minify --sourcemap src/main.ts --outfile dist/tokenmaxxing",
29
- "build:dev": "bun build --compile src/main.ts --outfile dist/tokenmaxxing",
30
- "build:linux-x64": "bun build --compile --minify --sourcemap --target=bun-linux-x64 src/main.ts --outfile dist/tokenmaxxing-linux-x64",
31
- "build:linux-arm64": "bun build --compile --minify --sourcemap --target=bun-linux-arm64 src/main.ts --outfile dist/tokenmaxxing-linux-arm64",
32
27
  "test": "bun test",
33
28
  "typecheck": "tsc --noEmit",
34
- "prepublishOnly": "bun run typecheck && bun run test && bun run build"
29
+ "prepublishOnly": "bun run typecheck && bun run test"
35
30
  },
36
31
  "devDependencies": {
37
32
  "@types/bun": "latest",
package/src/cli/add.ts ADDED
@@ -0,0 +1,129 @@
1
+ // `tokenmaxxing add` - register an ADDITIONAL account. Logs one in inside a
2
+ // throwaway CLAUDE_CONFIG_DIR (the ONLY use of CLAUDE_CONFIG_DIR), auto-exits the
3
+ // moment the login lands, samples that account's usage, then harvests its
4
+ // credential + identity into the pool and deletes the temp dir + isolated
5
+ // credential. Your primary login is never touched.
6
+
7
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { z } from "zod";
10
+ import { readItem, writeItem, deleteItem, parkedTarget, isolatedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
11
+ import { resolveRealClaude } from "../lib/claudebin.ts";
12
+ import { probeUsage } from "../lib/usage.ts";
13
+ import { saveTermios, restoreTermios } from "../lib/tty.ts";
14
+ import { loadAccounts, saveAccounts } from "../lib/state.ts";
15
+ import { credItemFor, paths } from "../lib/paths.ts";
16
+ import { CredentialBlobSchema, OAuthAccountSchema, type Account } from "../lib/types.ts";
17
+ import { c } from "./render.ts";
18
+
19
+ /** True once `/login` has written a usable identity into the onboard dir. */
20
+ function identityReady(cjPath: string): boolean {
21
+ if (!existsSync(cjPath)) return false;
22
+ try {
23
+ const oauthAccount = JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount;
24
+ return z.object({ accountUuid: z.string().min(1) }).safeParse(oauthAccount).success;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export async function cmdAdd(): Promise<number> {
31
+ const onboardDir = paths.onboardDir;
32
+ rmSync(onboardDir, { recursive: true, force: true });
33
+ mkdirSync(onboardDir, { recursive: true });
34
+ const iso = isolatedTarget(onboardDir);
35
+ const cjPath = join(onboardDir, ".claude.json");
36
+ const real = resolveRealClaude();
37
+
38
+ console.log(c.cyan("Opening an isolated Claude login - your primary login is untouched."));
39
+ console.log(c.dim(`In the session that opens, run ${c.bold("/login")} with the account to add. It closes itself once you're in.`));
40
+ console.log();
41
+
42
+ const savedTermios = saveTermios();
43
+ // Scrub the ambient credential/identity overrides claude honors BEFORE its
44
+ // keychain lookup (verified 2.1.205) - the onboard session must authenticate
45
+ // only via the /login the user performs inside it.
46
+ const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
47
+ delete env.ANTHROPIC_API_KEY;
48
+ delete env.ANTHROPIC_AUTH_TOKEN;
49
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
50
+ delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
51
+ const p = Bun.spawn([real], {
52
+ stdin: "inherit",
53
+ stdout: "inherit",
54
+ stderr: "inherit",
55
+ env,
56
+ });
57
+
58
+ // Auto-exit (#17): watch for a completed login - identity written AND the
59
+ // isolated credential present - then SIGTERM claude. No manual /exit.
60
+ let exited = false;
61
+ const onExit = p.exited.then(() => { exited = true; });
62
+ while (!exited) {
63
+ await Bun.sleep(400);
64
+ if (identityReady(cjPath) && (await readItem(iso))) {
65
+ p.kill();
66
+ break;
67
+ }
68
+ }
69
+ await p.exited;
70
+ await onExit;
71
+ restoreTermios(savedTermios);
72
+
73
+ const cleanup = async () => {
74
+ await deleteItem(iso);
75
+ rmSync(onboardDir, { recursive: true, force: true });
76
+ };
77
+
78
+ const blobRaw = await readItem(iso);
79
+ if (!blobRaw || !identityReady(cjPath)) {
80
+ console.error(c.red("no login detected in the isolated session - nothing added."));
81
+ await cleanup();
82
+ return 1;
83
+ }
84
+
85
+ let blob, oauthAccount;
86
+ try {
87
+ blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
88
+ oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
89
+ } catch {
90
+ console.error(c.red("could not parse the onboarded account's credential/identity."));
91
+ await cleanup();
92
+ return 1;
93
+ }
94
+
95
+ // Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
96
+ console.log(c.dim("sampling usage…"));
97
+ const sampled = await probeUsage(onboardDir);
98
+ if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
99
+
100
+ const uuid = oauthAccount.accountUuid;
101
+ const keychainItem = credItemFor(uuid);
102
+ await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
103
+
104
+ const idx = loadAccounts();
105
+ const existing = idx.accounts.find((a) => a.accountUuid === uuid);
106
+ const account: Account = {
107
+ accountUuid: uuid,
108
+ email: oauthAccount.emailAddress,
109
+ organizationUuid: oauthAccount.organizationUuid,
110
+ label: existing?.label ?? oauthAccount.emailAddress,
111
+ keychainItem,
112
+ oauthAccount,
113
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
114
+ subscriptionType: blob.claudeAiOauth.subscriptionType,
115
+ needsReauth: false,
116
+ lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
117
+ lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
118
+ };
119
+ if (existing) Object.assign(existing, account);
120
+ else idx.accounts.push(account);
121
+ saveAccounts(idx);
122
+
123
+ await cleanup();
124
+
125
+ console.log();
126
+ const usageNote = sampled ? ` · session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%` : "";
127
+ console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${idx.accounts.length} account(s)`);
128
+ return 0;
129
+ }
@@ -0,0 +1,80 @@
1
+ // `tokenmaxxing doctor` - verify the supervisor + three settings entries survived
2
+ // and the pool is healthy.
3
+
4
+ import { existsSync } from "node:fs";
5
+ import { checkSettings, installedBin } from "../lib/settings.ts";
6
+ import { isBinDirAhead } from "../lib/install.ts";
7
+ import { paths } from "../lib/paths.ts";
8
+ import { loadAccounts, loadConfig } from "../lib/state.ts";
9
+ import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
10
+ import { isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
11
+ import { CredentialBlobSchema, type RolesResponse } from "../lib/types.ts";
12
+ import { c } from "./render.ts";
13
+
14
+ /** The org a stored blob's token truly belongs to; null = expired (unverifiable
15
+ * read-only - doctor never refreshes). Throws on unreadable blob / API failure. */
16
+ async function blobOrg(raw: string): Promise<RolesResponse | null> {
17
+ const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
18
+ if (isAccessTokenExpiring(creds)) return null;
19
+ return fetchTokenOrg(creds.accessToken);
20
+ }
21
+
22
+ export async function cmdDoctor(): Promise<number> {
23
+ let ok = true;
24
+ const check = (cond: boolean, label: string, hint?: string) => {
25
+ console.log(`${cond ? c.green("✓") : c.red("✗")} ${label}${!cond && hint ? c.dim(` - ${hint}`) : ""}`);
26
+ if (!cond) ok = false;
27
+ };
28
+
29
+ check(existsSync(paths.supervisorLink), "claude supervisor wrapper present", "run `tokenmaxxing init`");
30
+ check(existsSync(installedBin()), "tokenmaxxing binary installed", "run `tokenmaxxing init`");
31
+ check(isBinDirAhead(), `${paths.binDir} is ahead of the real claude on PATH`, `export PATH="${paths.binDir}:$PATH"`);
32
+
33
+ const s = checkSettings();
34
+ check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
35
+ check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
36
+ check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
37
+
38
+ const idx = loadAccounts();
39
+ check(idx.accounts.length > 0, "at least one account in the pool", "run `tokenmaxxing init`");
40
+ check(!!idx.activeAccountUuid, "an active account is set");
41
+
42
+ const live = await readItem(liveTarget());
43
+ check(!!live, "live credential readable");
44
+
45
+ // Identity agreement: a stored credential must belong to the account it is
46
+ // filed under - a mislabeled blob once made every consumer of a backup
47
+ // (sampling, swap) silently act on another account.
48
+ const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
49
+ if (live && active) {
50
+ try {
51
+ const org = await blobOrg(live);
52
+ if (org) check(org.organization_uuid === active.organizationUuid, `live credential identity matches active (${active.email})`, `token belongs to ${org.organization_name} - run \`tokenmaxxing switch\``);
53
+ else console.log(c.dim(` · live credential identity unverifiable (access token expired)`));
54
+ } catch (e) {
55
+ check(false, `live credential identity matches active (${active.email})`, String((e as Error).message ?? e).slice(0, 100));
56
+ }
57
+ }
58
+
59
+ for (const a of idx.accounts) {
60
+ const parked = await readItem(parkedTarget(a.keychainItem));
61
+ check(!!parked, `parked credential present for ${a.email}`, "re-run `tokenmaxxing init`/`add`");
62
+ if (parked) {
63
+ try {
64
+ const org = await blobOrg(parked);
65
+ if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - re-auth with \`tokenmaxxing add\``);
66
+ else console.log(c.dim(` · ${a.email} identity unverifiable (access token expired)`));
67
+ } catch (e) {
68
+ check(false, `parked credential identity matches ${a.email}`, String((e as Error).message ?? e).slice(0, 100));
69
+ }
70
+ }
71
+ if (a.needsReauth) check(false, `${a.email} needs re-auth`, "run `tokenmaxxing add` to re-login");
72
+ }
73
+
74
+ const cfg = loadConfig();
75
+ check(!!cfg.claudeBin && existsSync(cfg.claudeBin), "real claude binary resolved", "set claudeBin in config.json");
76
+
77
+ console.log();
78
+ console.log(ok ? c.green("all good ✓") : c.yellow("issues found - see above"));
79
+ return ok ? 0 : 1;
80
+ }
@@ -0,0 +1,124 @@
1
+ // `tokenmaxxing init` - import the account you're already on (no prompts), then
2
+ // install the supervisor + the three settings entries.
3
+
4
+ import { mkdirSync } from "node:fs";
5
+ import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
6
+ import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
7
+ import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
8
+ import { loadAccounts, saveAccounts, loadConfig, saveConfig } from "../lib/state.ts";
9
+ import { installSupervisor, shellRcPath, ensurePathInRc } from "../lib/install.ts";
10
+ import { resolveRealClaude } from "../lib/claudebin.ts";
11
+ import { credItemFor, paths } from "../lib/paths.ts";
12
+ import { CredentialBlobSchema, type Account } from "../lib/types.ts";
13
+ import { c } from "./render.ts";
14
+
15
+ /** Put the supervisor bin dir on PATH via the user's shell rc (idempotent).
16
+ * Falls back to the manual instruction when the shell is unknown. */
17
+ function ensurePathAhead(): void {
18
+ const rc = shellRcPath();
19
+ if (!rc) {
20
+ console.log(c.yellow(`⚠ add to your shell rc: export PATH="${paths.binDir}:$PATH"`));
21
+ return;
22
+ }
23
+ const outcome = ensurePathInRc(rc);
24
+ if (outcome === "added") console.log(`${c.green("✓")} added ${paths.binDir} to PATH in ${rc} - restart your shell (or \`source ${rc}\`)`);
25
+ else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
26
+ }
27
+
28
+ export async function cmdInit(): Promise<number> {
29
+ mkdirSync(paths.home, { recursive: true });
30
+
31
+ // Already initialized → repair install ONLY. Never re-import: ~/.claude.json's
32
+ // oauthAccount can drift from the live keychain cred (after swaps / concurrent
33
+ // sessions), and re-importing would park the wrong cred + mislabel active.
34
+ const existingIdx = loadAccounts();
35
+ if (existingIdx.accounts.length > 0) {
36
+ const out = installSupervisor();
37
+ // repair the claudeBin pin too - hooks run with claude's PATH and must
38
+ // never have to guess which binary is the real claude.
39
+ const cfg = loadConfig();
40
+ cfg.claudeBin = resolveRealClaude();
41
+ saveConfig(cfg);
42
+ const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
43
+ console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
44
+ if (!out.pathAhead) ensurePathAhead();
45
+ console.log(` active: ${c.bold(active?.label ?? "unknown")} · run ${c.cyan("tokenmaxxing add")} for more, ${c.cyan("tokenmaxxing status")} to check`);
46
+ return 0;
47
+ }
48
+
49
+ if (isApiKeyMode()) {
50
+ console.error(c.yellow("tokenmaxxing pools subscription accounts, but you're authed via API key / apiKeyHelper."));
51
+ console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} with a Pro/Max account first, then re-run ${c.cyan("tokenmaxxing init")}.`);
52
+ return 1;
53
+ }
54
+
55
+ const oauthAccount = readOAuthAccount();
56
+ const liveRaw = await readItem(liveTarget());
57
+ if (!oauthAccount || !liveRaw) {
58
+ console.error(c.red("no active Claude subscription login found (missing oauthAccount or credential)."));
59
+ console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
60
+ return 1;
61
+ }
62
+
63
+ let blob;
64
+ try {
65
+ blob = CredentialBlobSchema.parse(JSON.parse(liveRaw));
66
+ } catch {
67
+ console.error(c.red("the live credential is not a recognizable Claude OAuth blob."));
68
+ return 1;
69
+ }
70
+
71
+ // Verify the live credential actually belongs to the identity we're about to
72
+ // file it under - ~/.claude.json's oauthAccount can drift from the live
73
+ // keychain credential, and importing on drifted state parks a mislabeled blob.
74
+ let creds = blob.claudeAiOauth;
75
+ if (isAccessTokenExpiring(creds)) {
76
+ creds = await refreshCredential(creds);
77
+ await writeItem(liveTarget(), mergeIntoLive(liveRaw, creds));
78
+ }
79
+ const org = await fetchTokenOrg(creds.accessToken);
80
+ if (org.organization_uuid !== oauthAccount.organizationUuid) {
81
+ console.error(c.red(`the live credential belongs to ${org.organization_name}, but ~/.claude.json identifies ${oauthAccount.emailAddress} - identity drift.`));
82
+ console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} to realign them, then re-run ${c.cyan("tokenmaxxing init")}.`);
83
+ return 1;
84
+ }
85
+
86
+ const uuid = oauthAccount.accountUuid;
87
+ const keychainItem = credItemFor(uuid);
88
+ await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds })); // park a small backup
89
+
90
+ const idx = loadAccounts();
91
+ const existing = idx.accounts.find((a) => a.accountUuid === uuid);
92
+ const account: Account = {
93
+ accountUuid: uuid,
94
+ email: oauthAccount.emailAddress,
95
+ organizationUuid: oauthAccount.organizationUuid,
96
+ label: existing?.label ?? oauthAccount.emailAddress,
97
+ keychainItem,
98
+ oauthAccount,
99
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
100
+ subscriptionType: blob.claudeAiOauth.subscriptionType,
101
+ needsReauth: false,
102
+ };
103
+ if (existing) Object.assign(existing, account);
104
+ else idx.accounts.push(account);
105
+ idx.activeAccountUuid = uuid;
106
+ saveAccounts(idx);
107
+
108
+ const cfg = loadConfig();
109
+ cfg.claudeBin = resolveRealClaude();
110
+ saveConfig(cfg);
111
+
112
+ const out = installSupervisor();
113
+
114
+ console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account.subscriptionType ?? "?"})`);
115
+ console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
116
+ if (out.priorStatusLine) console.log(`${c.green("✓")} wrapped your existing statusLine (preserved)`);
117
+ if (!out.pathAhead) {
118
+ console.log();
119
+ ensurePathAhead();
120
+ }
121
+ console.log();
122
+ console.log(` pool ready (${idx.accounts.length} account${idx.accounts.length === 1 ? "" : "s"}) · add more with ${c.cyan("tokenmaxxing add")}`);
123
+ return 0;
124
+ }
package/src/cli/ls.ts ADDED
@@ -0,0 +1,24 @@
1
+ // `tokenmaxxing ls` - compact list of pooled accounts.
2
+
3
+ import { loadAccounts } from "../lib/state.ts";
4
+ import { c } from "./render.ts";
5
+
6
+ export function cmdLs(): number {
7
+ const idx = loadAccounts();
8
+ if (idx.accounts.length === 0) {
9
+ console.log(c.dim("no accounts yet - run `tokenmaxxing init` then `tokenmaxxing add`"));
10
+ return 0;
11
+ }
12
+ for (const a of idx.accounts) {
13
+ const active = a.accountUuid === idx.activeAccountUuid;
14
+ const marker = active ? c.green("●") : c.dim("○");
15
+ const flags: string[] = [];
16
+ if (active) flags.push(c.green("active"));
17
+ if (a.needsReauth) flags.push(c.red("needs-reauth"));
18
+ const tag = flags.length ? ` ${flags.join(" ")}` : "";
19
+ const label = a.label || a.email;
20
+ console.log(`${marker} ${c.bold(label)}${tag}`);
21
+ console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)} · ${a.subscriptionType ?? "?"} · uuid ${a.accountUuid.slice(0, 8)}`)}`);
22
+ }
23
+ return 0;
24
+ }
@@ -0,0 +1,33 @@
1
+ // `tokenmaxxing rename <selector> <new-label>` - relabel a pooled account.
2
+
3
+ import { loadAccounts, saveAccounts } from "../lib/state.ts";
4
+ import { c } from "./render.ts";
5
+ import type { Account } from "../lib/types.ts";
6
+
7
+ /** Resolve an account by email, label, or accountUuid prefix. */
8
+ export function findAccount(accounts: Account[], selector: string): Account | undefined {
9
+ const s = selector.toLowerCase();
10
+ return (
11
+ accounts.find((a) => a.email.toLowerCase() === s) ??
12
+ accounts.find((a) => a.label.toLowerCase() === s) ??
13
+ accounts.find((a) => a.accountUuid.toLowerCase().startsWith(s))
14
+ );
15
+ }
16
+
17
+ export function cmdRename(selector?: string, newLabel?: string): number {
18
+ if (!selector || !newLabel) {
19
+ console.error("usage: tokenmaxxing rename <email|label|uuid> <new-label>");
20
+ return 2;
21
+ }
22
+ const idx = loadAccounts();
23
+ const a = findAccount(idx.accounts, selector);
24
+ if (!a) {
25
+ console.error(c.red(`no account matches "${selector}"`));
26
+ return 1;
27
+ }
28
+ const old = a.label;
29
+ a.label = newLabel;
30
+ saveAccounts(idx);
31
+ console.log(`renamed ${c.dim(old)} → ${c.bold(newLabel)}`);
32
+ return 0;
33
+ }
@@ -0,0 +1,36 @@
1
+ // Terminal rendering helpers for the CLI (bars, colors, relative times).
2
+
3
+ import { clamp } from "es-toolkit";
4
+
5
+ const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
6
+
7
+ export const c = {
8
+ dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
9
+ bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
10
+ green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
11
+ yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
12
+ red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
13
+ cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
14
+ };
15
+
16
+ /** A fixed-width usage bar, colored by fill. */
17
+ export function bar(pct: number, width = 16): string {
18
+ const clamped = clamp(pct, 0, 100);
19
+ const filled = Math.round((clamped / 100) * width);
20
+ const body = "█".repeat(filled) + "░".repeat(width - filled);
21
+ const label = `${clamped.toFixed(0).padStart(3)}%`;
22
+ const paint = clamped >= 95 ? c.red : clamped >= 75 ? c.yellow : c.green;
23
+ return `${paint(body)} ${label}`;
24
+ }
25
+
26
+ /** Relative-time string for a reset epoch, e.g. "resets in 2h13m". */
27
+ export function fmtReset(epochMs: number | null | undefined, now = Date.now()): string {
28
+ if (epochMs == null) return "";
29
+ const dsec = Math.round((epochMs - now) / 1000);
30
+ if (dsec <= 0) return "reset now";
31
+ const h = Math.floor(dsec / 3600);
32
+ const m = Math.floor((dsec % 3600) / 60);
33
+ if (h > 24) return `resets in ${Math.floor(h / 24)}d${h % 24}h`;
34
+ if (h > 0) return `resets in ${h}h${m}m`;
35
+ return `resets in ${m}m`;
36
+ }
package/src/cli/rm.ts ADDED
@@ -0,0 +1,28 @@
1
+ // `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
2
+
3
+ import { deleteItem, parkedTarget } from "../lib/credstore.ts";
4
+ import { loadAccounts, saveAccounts } from "../lib/state.ts";
5
+ import { findAccount } from "./rename.ts";
6
+ import { c } from "./render.ts";
7
+
8
+ export async function cmdRm(selector?: string): Promise<number> {
9
+ if (!selector) {
10
+ console.error("usage: tokenmaxxing rm <email|label|uuid>");
11
+ return 2;
12
+ }
13
+ const idx = loadAccounts();
14
+ const a = findAccount(idx.accounts, selector);
15
+ if (!a) {
16
+ console.error(c.red(`no account matches "${selector}"`));
17
+ return 1;
18
+ }
19
+ if (a.accountUuid === idx.activeAccountUuid) {
20
+ console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
21
+ return 1;
22
+ }
23
+ await deleteItem(parkedTarget(a.keychainItem));
24
+ idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
25
+ saveAccounts(idx);
26
+ console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
27
+ return 0;
28
+ }
@@ -0,0 +1,99 @@
1
+ // `tokenmaxxing status`: accounts with 5h / weekly / per-model usage bars.
2
+ // Parked accounts are live-sampled in isolation (`claude -p /usage`); the active
3
+ // account is read off the free statusLine feed (usage.json) so we never poll its
4
+ // own busy token. A sample that fails falls back to the last-known values with a
5
+ // visible "(cached)" note - never a silent stale number. Fresh figures are
6
+ // persisted onto each account for the picker/switch logic.
7
+
8
+ import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
9
+ import { readOAuthAccount } from "../lib/claudejson.ts";
10
+ import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
11
+ import { withLock } from "../lib/lock.ts";
12
+ import { paths } from "../lib/paths.ts";
13
+ import { isExhausted } from "../lib/picker.ts";
14
+ import { bar, c, fmtReset } from "./render.ts";
15
+ import type { FullUsage } from "../lib/usage.ts";
16
+ import type { UsageWindow } from "../lib/types.ts";
17
+
18
+ export async function cmdStatus(): Promise<number> {
19
+ const idx = loadAccounts();
20
+ const cfg = loadConfig();
21
+ const live = loadUsage();
22
+ const modelUsage = loadModelUsage();
23
+ const now = Date.now();
24
+
25
+ if (idx.accounts.length === 0) {
26
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init`"));
27
+ return 0;
28
+ }
29
+
30
+ const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
31
+
32
+ // Sample under the flock so parked refreshes can't collide with an in-flight swap.
33
+ console.error(c.dim("sampling live usage…"));
34
+ const outcomes = new Map<string, SampleOutcome>();
35
+ await withLock(paths.lockFile, () =>
36
+ Promise.all(
37
+ idx.accounts.map(async (a) => {
38
+ const isActive = a.accountUuid === idx.activeAccountUuid && activeOrg === a.organizationUuid;
39
+ // Active account: prefer the free statusLine push (usage.json) so we never
40
+ // poll its own token, which is busy exactly when it matters. per-model
41
+ // comes from model-usage.json (also statusLine-driven).
42
+ const fromStatusLine: FullUsage | null =
43
+ isActive && live && live.org === a.organizationUuid
44
+ ? {
45
+ session: live.fiveHour,
46
+ weekAll: live.sevenDay,
47
+ perModel: modelUsage && modelUsage.org === a.organizationUuid ? modelUsage.perModel : {},
48
+ }
49
+ : null;
50
+ const outcome: SampleOutcome = fromStatusLine
51
+ ? { ok: true, usage: fromStatusLine }
52
+ : isActive
53
+ ? await probeActiveUsage(a)
54
+ : await probeParkedUsage(a);
55
+ outcomes.set(a.accountUuid, outcome);
56
+ if (!outcome.ok) return;
57
+ a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
58
+ if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
59
+ }),
60
+ ),
61
+ );
62
+ saveAccounts(idx);
63
+
64
+ console.log(c.dim(`threshold ${cfg.threshold}% · ${idx.accounts.length} account(s)`));
65
+ console.log();
66
+
67
+ const row = (name: string, w: UsageWindow) =>
68
+ console.log(` ${name.padEnd(5)} ${bar(w.usedPercentage)} ${c.dim(fmtReset(w.resetsAt, now))}`);
69
+
70
+ for (const a of idx.accounts) {
71
+ const active = a.accountUuid === idx.activeAccountUuid;
72
+ const outcome = outcomes.get(a.accountUuid);
73
+ const failed = outcome ? !outcome.ok : false;
74
+ // On a failed sample, fall back to the last-known values (with a note below).
75
+ const usage = outcome?.ok ? outcome.usage : undefined;
76
+ const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
77
+ const perModel = usage ? usage.perModel : a.lastPerModel;
78
+
79
+ const marker = active ? c.green("●") : c.dim("○");
80
+ const badges: string[] = [];
81
+ if (active) badges.push(c.green("active"));
82
+ if (a.needsReauth) badges.push(c.red("needs-reauth"));
83
+ if (isExhausted(a, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid }))
84
+ badges.push(c.yellow("exhausted"));
85
+
86
+ console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
87
+ if (aggregate) {
88
+ row("5h", aggregate.fiveHour);
89
+ row("week", aggregate.sevenDay);
90
+ }
91
+ if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w);
92
+ if (failed && outcome && !outcome.ok) {
93
+ const note = aggregate || perModel ? "cached · live sample failed" : "live sample failed";
94
+ console.log(` ${c.yellow(note)}: ${c.dim(outcome.reason)}`);
95
+ }
96
+ console.log();
97
+ }
98
+ return 0;
99
+ }