tokenmaxxing 0.1.0 → 0.2.1

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 ADDED
@@ -0,0 +1,125 @@
1
+ # tokenmaxxing - design
2
+
3
+ Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses **95%** usage, 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 at once.
4
+
5
+ > Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
6
+ >
7
+ > Status: **implemented** (v0.1.0, 2026-07-09). TypeScript on Bun → single binary; Zod validates every external-boundary payload, JSON config, es-toolkit for utilities, `flock(2)` via `bun:ffi`. All load-bearing external facts were adversarially verified against the `2.1.204` binary + docs (OAuth token endpoint is `platform.claude.com/v1/oauth/token`, client_id `9d1c250a-…`, JSON body). What the acceptance gate actually shows is in §9.
8
+
9
+ ---
10
+
11
+ ## 1. Why there is a thin supervisor (and why that's the whole trick)
12
+
13
+ A **running** `claude` cannot adopt a swapped credential mid-flight - binary-verified: it holds its OAuth token in memory and a 429 (the limit event) does **not** invalidate it, so it never re-reads the keychain on the event we care about. A keychain swap is only picked up by a **fresh** `claude` process.
14
+
15
+ The clean way to exploit that is not to fight the live process but to **replace it at a salvageable moment**: after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt - killing it there loses nothing, and a `claude --resume <session-id>` comes back cold on the new account and continues exactly where it left off. **This is why we swap at 95%: the 5% headroom is the budget to reach a clean turn boundary and respawn before the account actually hits the wall.**
16
+
17
+ A hook can't do the respawn - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
18
+
19
+ ```
20
+ supervisor (you type `claude`) → real claude (in a PTY) → Stop hook
21
+ ▲_______________ relaunch --resume <sid> ______________|
22
+ ```
23
+
24
+ It is a process/PTY manager only - spawn, forward the terminal, wait, restore terminal, relaunch. It never proxies API traffic or handles tokens. Everything else about `claude` is unchanged.
25
+
26
+ ---
27
+
28
+ ## 2. What tokenmaxxing installs
29
+
30
+ - A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`), or a shell function - you invoke it identically.
31
+ - Three `~/.claude/settings.json` entries (merged, preserving anything you already have): a transparent `statusLine` shim, a `Stop` hook, a `SessionStart` hook.
32
+ - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
+ - `config.json` - threshold, account order/policy.
34
+ - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, lastUsage, resetsAt, needs_reauth}`.
35
+ - `usage.json` - live usage, written by the statusLine shim.
36
+ - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal).
37
+ - `bin/claude` - the supervisor.
38
+ - Per-account **credentials** follow the platform's Claude Code store: macOS = login-keychain items `tokenmaxxing-cred-<accountUuid[:8]>` (never plaintext on disk); Linux = 0600 files `creds/tokenmaxxing-cred-<accountUuid[:8]>.json` (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform.
39
+
40
+ No background daemon - it's event-driven (statusline pushes usage; hooks + supervisor react). The `claude` binary and `~/.claude` layout are untouched.
41
+
42
+ ---
43
+
44
+ ## 3. How a switch happens
45
+
46
+ ### 3.1 Usage feed (free, push-based)
47
+ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine shim tees that to `usage.json` (write-on-change, O(ms)) and passes your real statusline through unchanged. Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
48
+
49
+ ### 3.2 Detect + swap + signal (Stop hook, per turn)
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).
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
+
54
+ ### 3.3 Respawn (supervisor)
55
+ The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, resets the terminal, prints `↻ switched to <account>`, and relaunches `claude --resume <sid>`. The resumed process reads the keychain cold → runs on the new account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
56
+
57
+ ### 3.4 Swap sequence (under the lock)
58
+ 1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the roles endpoint (`GET /api/oauth/claude_cli/roles`), NOT the `accounts.json` active label. The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
59
+ 2. **Refresh B** - OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account.
60
+ 3. **Install B** - `security add-generic-password -U … 'Claude Code-credentials' …` with B's fresh (non-expired) `claudeAiOauth` JSON.
61
+ 4. **Swap identity + mark B active** - atomically rewrite only the `oauthAccount` object in `~/.claude.json` (temp+rename) to B's, and write `activeAccountUuid = B` in the SAME critical section, so a crash can't leave the installed credential and the active label pointing at different accounts.
62
+ 5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
63
+
64
+ ### 3.5 Multiple concurrent sessions
65
+ Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, and its own respawn marker. When the shared account hits 95%, the first Stop hook to win the `flock` performs the one swap; **every** session's Stop hook writes its own respawn marker and SIGTERMs its own `claude`; **every** supervisor independently relaunches `claude --resume <its-own-sid>`. All of them come back on the new account, each continuing its own conversation. (They share one credential, so they always move together - consistent with "one current account, many windows.")
66
+
67
+ ---
68
+
69
+ ## 4. Onboarding (no `adopt`)
70
+
71
+ - **`tokenmaxxing init` imports the account you're already on - automatically, no prompts, no re-login.** It reads the live `Claude Code-credentials` keychain blob plus the `oauthAccount` object in `~/.claude.json` (email, `organizationUuid`, `accountUuid`, plan tier) and writes them as **account #1** into tokenmaxxing's store (`tokenmaxxing-cred-<accountUuid[:8]>` + an `accounts.json` index entry). Nothing about your current session changes - that account stays active; it's now just also a registered pool member. After this one command you already have a working (single-account) pool. `init` also installs the supervisor + the three settings entries.
72
+ - If the current auth is API-key mode (`ANTHROPIC_API_KEY`/`apiKeyHelper`) rather than a subscription `/login`, there's no quota-poolable subscription credential to import - `init` says so and points you to `/login` first (per-token API billing isn't what tokenmaxxing pools).
73
+ - **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. This is the **only** time `CLAUDE_CONFIG_DIR` is ever used.
74
+ - Both commands exercise `security` reads/writes interactively (where a macOS keychain ACL prompt is acceptable), so the first access never happens cold inside a headless hook.
75
+
76
+ ---
77
+
78
+ ## 5. Rotation policy
79
+ Trigger at `five_hour >= 95%` OR `seven_day >= 95%`, per org. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`95 − EMA(per-turn Δ%)`) so a single large turn can't blow past 100% before the next Stop hook.
80
+
81
+ **Model-aware trigger.** Claude subscriptions also enforce a **per-model weekly cap** - the capable models (Fable, Opus) have a tighter weekly limit that binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate.
82
+
83
+ ---
84
+
85
+ ## 6. Honest papercuts
86
+ - **Respawn hiccup.** At the swap turn you see `claude` restart (~1–2s) and resume. Anything you typed in the split second before respawn is lost - mitigate by respawning fast and showing a clear "switching" state; the supervisor resets terminal mode so nothing is left garbled.
87
+ - **One cold turn.** Prompt cache is org-scoped: the first turn after resuming on B re-uploads context once (bigger on long transcripts).
88
+ - **Single-turn overshoot.** If one turn jumps from <95% straight past the wall, that turn can end rate-limited before the Stop hook swaps; the respawn then still recovers it. Projected threshold reduces this.
89
+ - **Shared blast radius.** All default-profile sessions share one keychain item, so a swap moves them all (each via its own respawn). The `flock` + re-check is mandatory or racing hooks burn two accounts at once.
90
+ - **Refresh-token rotation / parked-token rot.** Step 1 re-harvest is mandatory; a parked refresh token can be invalidated by logging in elsewhere → picker must catch `invalid_grant`, mark `needs_reauth`, fall through.
91
+ - **statusLine fragility.** The shim is the most visible surface - a bug flickers or breaks your real status line. Keep it O(ms), write-on-change.
92
+ - **Keychain blob size & ps-safety.** The live `Claude Code-credentials` item also holds per-MCP-server OAuth state, so it can exceed `security -i`'s ~4KB interactive line buffer (verified on a real machine - a 4.3KB blob truncated). tokenmaxxing therefore stores parked backups as **`claudeAiOauth`-only** (small → always the ps-safe stdin write) and, on a swap, **merges** the fresh `claudeAiOauth` into the *current* live blob so MCP tokens survive the switch - using the argv write path (secret briefly visible in `ps` on your own machine) only for that one oversized live write.
93
+ - **settings.json is user-owned.** Install by merge; ship `tokenmaxxing doctor` to verify the supervisor + 3 entries survive a `/config` edit or update.
94
+
95
+ ---
96
+
97
+ ## 7. Scope
98
+ **v1:** `tokenmaxxing init` / `add` / `ls` / `status` / `doctor`; the supervisor; statusLine shim + Stop/SessionStart hooks; 95% swap with `flock` + reset-aware picker; platform credential store (macOS keychain / Linux 0600 files, one facade); auto-respawn across concurrent sessions. macOS + Linux.
99
+
100
+ **v2:** projected-threshold pre-emption; a `UserPromptSubmit` guard that respawns *before* a turn starts when already over; Windows.
101
+
102
+ **Later:** Codex as a second pool; tool-agnostic picker.
103
+
104
+ **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap.
105
+
106
+ ---
107
+
108
+ ## 8. Stack
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
+
111
+ ---
112
+
113
+ ## 9. What the acceptance gate showed (2026-07-09)
114
+
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
+
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
+ 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.
119
+ 3. **SessionStart swap - ✅ swap logic proven; ⚠️ "adopted by first turn" needs a 2nd real account.** The hook's decision+swap path is covered by the E2E; the claude-internal timing (SessionStart before first `getToken`) needs a second subscription to observe end-to-end.
120
+ 4. **Keychain writes from a headless hook - ✅ proven.** The swap E2E's 4 concurrent subprocesses each wrote the live item via `security -i` (secret on stdin, never argv) with no prompt/hang; `init` parked backups headlessly too. (Residual: the live `Claude Code-credentials` ACL for the *fresh resumed claude* is mitigated by onboarding touching `security` interactively.)
121
+ 5. **Concurrent flocked swap - ✅ proven.** 4 concurrent processes racing the trigger → exactly **one** `flock`ed swap, one OAuth refresh, all end on B. Per-supervisor respawn is proven with the mock; N *real* supervisors respawning together needs real accounts + PTYs.
122
+ 6. **Model-aware trigger - ✅ proven.** Fable at 96% per-model cap (aggregate only 30/50) → swaps; Sonnet at 96% → no swap.
123
+
124
+ Owed before fully trusting in the wild: the live interactive-PTY SIGTERM/terminal test, and the two items that require a **second real subscription account** (SessionStart adoption, N real supervisors).
125
+ ```
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # tokenmaxxing
2
+
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
+
5
+ > **Scope:** Claude Code only, macOS and Linux. It pools **subscription** accounts (Pro/Max), not API keys.
6
+
7
+ ```
8
+ $ claude
9
+ …you work normally…
10
+ ↻ tokenmaxxing: switched to work@acme.com - resuming…
11
+ …same conversation, fresh quota…
12
+ ```
13
+
14
+ ## Why
15
+
16
+ A running `claude` holds its OAuth token in memory and a 429 does **not** make it re-read the credential store - so you can't hot-swap a live session. tokenmaxxing instead swaps the credential and **respawns** `claude --resume <id>` at a committed turn boundary (the transcript is already on disk, so nothing is lost). A thin `claude` supervisor on your PATH owns that respawn; everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through.
17
+
18
+ ## Install
19
+
20
+ Requires [Bun](https://bun.sh) and Claude Code, on macOS or Linux.
21
+
22
+ ```sh
23
+ bun add -g tokenmaxxing
24
+ tokenmaxxing init
25
+ ```
26
+
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:
28
+
29
+ ```sh
30
+ tokenmaxxing add # logs one in, in isolation, and pools it
31
+ claude # use claude as always
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ | command | what it does |
37
+ |---|---|
38
+ | `tokenmaxxing init` | import the current account + install supervisor & hooks |
39
+ | `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
40
+ | `tokenmaxxing ls` | list pooled accounts |
41
+ | `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
42
+ | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
43
+ | `tokenmaxxing rename <sel> <label>` · `rm <sel>` | manage the pool |
44
+ | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
45
+
46
+ ## How switching decides
47
+
48
+ Switching triggers at **95%** (configurable) on any of:
49
+
50
+ - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by a statusLine shim.
51
+ - **Per-model weekly cap** - the capable models (Fable, Opus) have their own tighter weekly limit that binds *before* the aggregate. tokenmaxxing reads it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
52
+
53
+ The 5% headroom is deliberate: it's the budget to reach a clean turn boundary and respawn before the wall.
54
+
55
+ ## Configuration
56
+
57
+ `~/.config/tokenmaxxing/config.json` (every field optional):
58
+
59
+ ```json
60
+ {
61
+ "threshold": 95,
62
+ "policy": {
63
+ "projectionMargin": 0,
64
+ "switchModels": ["fable", "opus"],
65
+ "usagePollTtlMs": 90000
66
+ }
67
+ }
68
+ ```
69
+
70
+ `projectionMargin` subtracts an EMA of per-turn Δ% for pre-emption; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh.
71
+
72
+ State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
73
+
74
+ ## Honest limitations
75
+
76
+ - **One cold turn.** The first turn after resuming on a new account re-uploads context once (prompt cache is org-scoped).
77
+ - **Respawn hiccup.** At the swap you see `claude` restart (~1–2s); anything typed in that split second is lost.
78
+ - **Shared blast radius.** All default-profile sessions share one live credential, so a swap moves them all together (each respawns its own session). A `flock` + re-check keeps racing hooks from burning two accounts.
79
+ - **Keychain ACL (macOS).** `init`/`add` touch the keychain interactively so the first `security` access isn't cold inside a headless hook.
80
+ - **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; tokenmaxxing's parked copies follow the same model.
81
+
82
+ ## How it's built
83
+
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.
85
+
86
+ ## License
87
+
88
+ MIT
package/package.json CHANGED
@@ -1,19 +1,40 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.1.0",
4
- "description": "tokenmaxxing CLI",
3
+ "version": "0.2.1",
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
+ "license": "MIT",
7
+ "author": "anaclumos",
8
+ "homepage": "https://github.com/anaclumos/tokenmaxxing",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/anaclumos/tokenmaxxing.git"
12
+ },
6
13
  "bin": {
7
- "tokenmaxxing": "./dist/index.js"
14
+ "tokenmaxxing": "./src/main.ts"
8
15
  },
9
16
  "files": [
10
- "dist"
17
+ "src",
18
+ "README.md",
19
+ "DESIGN.md"
11
20
  ],
21
+ "engines": {
22
+ "bun": ">=1.1.0"
23
+ },
24
+ "os": ["darwin", "linux"],
12
25
  "scripts": {
13
- "build": "bun build ./src/index.ts --outdir ./dist --target node",
14
- "dev": "bun run ./src/index.ts"
26
+ "dev": "bun run src/main.ts",
27
+ "test": "bun test",
28
+ "typecheck": "tsc --noEmit",
29
+ "prepublishOnly": "bun run typecheck && bun run test"
15
30
  },
16
31
  "devDependencies": {
17
- "@types/bun": "latest"
32
+ "@types/bun": "latest",
33
+ "typescript": "^5.6.0"
34
+ },
35
+ "dependencies": {
36
+ "es-toolkit": "^1.49.0",
37
+ "ky": "^2.0.2",
38
+ "zod": "^4.4.3"
18
39
  }
19
40
  }
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
+ }