tokenmaxxing 1.33.0 → 1.35.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.
Files changed (48) hide show
  1. package/DESIGN.md +3 -5
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +23 -40
  5. package/src/cli/auth.ts +38 -52
  6. package/src/cli/check.ts +2 -1
  7. package/src/cli/cloudrun.ts +5 -15
  8. package/src/cli/cursorinit.ts +4 -8
  9. package/src/cli/doctor.ts +22 -36
  10. package/src/cli/init.ts +29 -126
  11. package/src/cli/rename.ts +14 -50
  12. package/src/cli/render.ts +1 -8
  13. package/src/cli/rm.ts +27 -44
  14. package/src/cli/setuptoken.ts +25 -38
  15. package/src/cli/status.ts +95 -238
  16. package/src/cli/switch.ts +54 -46
  17. package/src/entries/codexstophook.ts +22 -16
  18. package/src/entries/codexsupervisor.ts +3 -3
  19. package/src/entries/sessionstart.ts +3 -2
  20. package/src/entries/statusline.ts +15 -13
  21. package/src/entries/stopfailurehook.ts +7 -5
  22. package/src/entries/stophook.ts +3 -2
  23. package/src/lib/claude.ts +320 -0
  24. package/src/lib/codex.ts +353 -0
  25. package/src/lib/codexauth.ts +6 -0
  26. package/src/lib/codexpresence.ts +0 -10
  27. package/src/lib/codexusage.ts +14 -32
  28. package/src/lib/decide.ts +86 -174
  29. package/src/lib/oauth.ts +7 -0
  30. package/src/lib/paths.ts +14 -6
  31. package/src/lib/picker.ts +56 -37
  32. package/src/lib/provider.ts +34 -0
  33. package/src/lib/sample.ts +33 -33
  34. package/src/lib/state.ts +49 -16
  35. package/src/lib/swap.ts +41 -38
  36. package/src/lib/types.ts +17 -51
  37. package/src/lib/usage.ts +17 -4
  38. package/src/main.ts +33 -37
  39. package/src/cli/codexadd.ts +0 -89
  40. package/src/cli/codexinit.ts +0 -120
  41. package/src/cli/codexrm.ts +0 -37
  42. package/src/cli/codexswitch.ts +0 -113
  43. package/src/cli/onboard.ts +0 -92
  44. package/src/lib/codexdecide.ts +0 -157
  45. package/src/lib/codexpick.ts +0 -80
  46. package/src/lib/codexsample.ts +0 -48
  47. package/src/lib/codexstate.ts +0 -22
  48. package/src/lib/codexswap.ts +0 -70
package/DESIGN.md CHANGED
@@ -31,7 +31,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
31
31
  - Four `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
33
  - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
34
- - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, label, lastUsage, needsReauth, ...}` (window resets and the per-model rows live inside lastUsage).
34
+ - `accounts.json` - non-secret index `{id, label, email, tier, windows, lastUsageAt, needsReauth, oauthAccount, ...}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`).
35
35
  - `usage.json` - live usage, written by the statusLine shim.
36
36
  - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal, depleted-pool waits only).
37
37
  - `bin/claude` - the supervisor.
@@ -60,7 +60,7 @@ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed
60
60
  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 profile endpoint (`GET /api/oauth/profile`, matched on `account.uuid`), NOT the `accounts.json` active label and NOT the organization (team seats share one). 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.
61
61
  2. **Refresh B** - a blob Claude Code dead-cleared (empty tokens) is dead without a request. Resolve the owner of B's parked token through the profile endpoint first; a token that belongs to another account is never refreshed, because a refresh rotation revokes that account's live sessions, and B is marked `needs_reauth` instead. Then 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. On any other 4xx, skip B for this evaluation without marking it and try the next account.
62
62
  3. **Install B** - `security add-generic-password -U ... 'Claude Code-credentials' ...` with B's fresh (non-expired) `claudeAiOauth` JSON.
63
- 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.
63
+ 4. **Swap identity + mark B active** - atomically rewrite only the `oauthAccount` object in `~/.claude.json` (temp+rename) to B's, and write `activeId = B` in the SAME critical section, so a crash can't leave the installed credential and the active label pointing at different accounts.
64
64
  5. Do steps 1, 3, 4 inside Claude's own refresh locks - the primary `<credDir>/.oauth_refresh.lock` plus the legacy sibling `<realpath(credDir)>.lock` (`~/.claude.lock` by default), both mkdir-based proper-lockfile locks, binary-verified against 2.1.214 - so the writes can't collide with a token refresh. Contention fails the swap fast (claude itself gives up with `lock_timeout` rather than refreshing unlocked); the next check retries.
65
65
 
66
66
  ### 3.5 Multiple concurrent sessions
@@ -111,9 +111,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
111
111
 
112
112
  **Removed (1.23.0):** the programmatic SDK surface, the stdio MCP server, and the Agent Plugin that shipped from 0.11.0 through 1.22.0. The CLI, the hooks, and the supervisor are the only surfaces.
113
113
 
114
- **Later:** tool-agnostic picker.
115
-
116
- **Shipped since (0.13.0):** Codex as a second pool, parallel state (`codex-accounts.json`, `codex-creds/`, own flock), same pace-pressure policy. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`). Sibling sessions left on ANY non-live account are reconciled cross-session (owner decisions 2026-07-20; a non-live session cannot refresh cross-account and would wedge at token expiry, healthy or not): the deciding actor drops a reconcile marker addressed to the sibling's supervisor, and the sibling's own Stop hook promotes it into a respawn onto the live account at its next turn boundary - the only safe respawn point - while a blocked live seat never receives a signal.
114
+ **Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, `codex-creds/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows). Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`). Sibling sessions left on ANY non-live account are reconciled cross-session (owner decisions 2026-07-20; a non-live session cannot refresh cross-account and would wedge at token expiry, healthy or not): the deciding actor drops a reconcile marker addressed to the sibling's supervisor, and the sibling's own Stop hook promotes it into a respawn onto the live account at its next turn boundary - the only safe respawn point - while a blocked live seat never receives a signal.
117
115
 
118
116
  **Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no hot swap, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
119
117
 
package/README.md CHANGED
@@ -65,7 +65,7 @@ claude # use claude as always
65
65
  | `tokenmaxxing init --codex` | same for codex: import login, install codex supervisor + Stop hook |
66
66
  | `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
67
67
  | `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
68
- | `tokenmaxxing auth [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every needs-reauth account one by one |
68
+ | `tokenmaxxing auth [--codex] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every needs-reauth account one by one; `--codex` does the same for the codex pool |
69
69
  | `tokenmaxxing switch [sel]` | switch the claude pool: bare picks the best account (no-op when the current one wins), a selector targets one |
70
70
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
71
71
  | `tokenmaxxing status [--cached]` | accounts with 5h / weekly usage bars, active + exhausted-until-reset; `--cached` renders the stored figures without sampling |
@@ -76,7 +76,7 @@ claude # use claude as always
76
76
  | `tokenmaxxing setup-token [--print \| rm <label\|uuid>]` | Cursor Cloud only: mint one `claude setup-token` per pooled account (a browser sign-in each) and print the `TOKENMAXXING_TOKENS` secret value; `--print` prints the stored set, `rm` drops one |
77
77
  | `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
78
78
  | `tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"` | on a Cursor Cloud VM: run `claude -p` on a setup token, rotate to the next token on a usage limit |
79
- | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, `doctor`, `check`, `switch`, `rename`, `rm`, `uninstall`, `setup-token --print`, `cursor init`, and `cloud run` (`ok` mirrors the exit code, failures add `error`) |
79
+ | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, `check`, and `switch` (`ok` mirrors the exit code, failures add `error`) |
80
80
 
81
81
  ## How switching decides
82
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
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",
package/src/cli/add.ts CHANGED
@@ -1,51 +1,34 @@
1
1
  import { withLock } from "../lib/lock.ts";
2
- import { loadAccounts, saveAccounts } from "../lib/state.ts";
3
- import { credItemFor, paths } from "../lib/paths.ts";
4
- import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
5
- import { harvestIsolatedLogin } from "./onboard.ts";
6
- import { type Account } from "../lib/types.ts";
7
- import { keepRows } from "../lib/usage.ts";
8
- import { c, claudeTierLabel, count } from "./render.ts";
2
+ import type { Provider } from "../lib/provider.ts";
3
+ import { loadAccounts, saveAccounts, upsertAccount } from "../lib/state.ts";
4
+ import { sessionWindow, weeklyWindow } from "../lib/picker.ts";
5
+ import { c, count } from "./render.ts";
6
+ import type { Account } from "../lib/types.ts";
9
7
 
10
- export async function cmdAdd(): Promise<number> {
11
- console.log(c.cyan("Opening an isolated Claude login - your primary login is untouched."));
12
- 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.`));
8
+ export function usageNote(account: Account): string {
9
+ const session = sessionWindow(account);
10
+ const week = weeklyWindow(account);
11
+ return session && week ? ` (session ${session.usedPercentage}% / week ${week.usedPercentage}%)` : "";
12
+ }
13
+
14
+ export async function cmdAdd(p: Provider): Promise<number> {
15
+ console.log(c.cyan(`Opening an isolated ${p.name} login - your primary login is untouched.`));
16
+ console.log(c.dim(p.loginStep("the account to add")));
13
17
  console.log();
14
18
 
15
- const harvested = await harvestIsolatedLogin();
19
+ const harvested = await p.login();
16
20
  if (!harvested) return 1;
17
- const { blobRaw, blob, oauthAccount, sampled } = harvested;
18
-
19
- const uuid = oauthAccount.accountUuid;
20
- const keychainItem = credItemFor(uuid);
21
21
 
22
- const { account, poolSize } = await withLock(paths.lockFile, async () => {
23
- await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw));
24
- const idx = loadAccounts();
25
- const existing = idx.accounts.find((a) => a.accountUuid === uuid);
26
- const sampledAt = Date.now();
27
- const fresh: Account = {
28
- accountUuid: uuid,
29
- email: oauthAccount.emailAddress,
30
- organizationUuid: oauthAccount.organizationUuid,
31
- label: existing?.label ?? oauthAccount.emailAddress,
32
- keychainItem,
33
- oauthAccount,
34
- addedAt: existing?.addedAt ?? new Date().toISOString(),
35
- subscriptionType: blob.claudeAiOauth.subscriptionType,
36
- rateLimitTier: blob.claudeAiOauth.rateLimitTier,
37
- needsReauth: false,
38
- lastUsage: sampled ? keepRows(sampled, existing?.lastUsage, sampledAt) : existing?.lastUsage,
39
- lastUsageAt: sampled ? sampledAt : existing?.lastUsageAt,
40
- };
41
- if (existing) Object.assign(existing, fresh);
42
- else idx.accounts.push(fresh);
43
- saveAccounts(idx);
44
- return { account: fresh, poolSize: idx.accounts.length };
22
+ const { account, poolSize } = await withLock(p.pool.lockFile, async () => {
23
+ await harvested.park();
24
+ const idx = loadAccounts(p.pool);
25
+ const account = upsertAccount(idx, harvested, p.mergeWindows);
26
+ saveAccounts(p.pool, idx);
27
+ return { account, poolSize: idx.accounts.length };
45
28
  });
46
29
 
47
30
  console.log();
48
- const usageNote = sampled ? ` (session ${sampled.fiveHour.usedPercentage}% / week ${sampled.sevenDay.usedPercentage}%)` : "";
49
- console.log(`${c.green("✓")} added ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})${usageNote} → pool now has ${count({ n: poolSize, noun: "account" })}`);
31
+ const note = harvested.sample ? usageNote(account) : "";
32
+ console.log(`${c.green("✓")} added ${c.bold(account.label)} (${account.tier ?? "?"})${note} → pool now has ${count({ n: poolSize, noun: "account" })}`);
50
33
  return 0;
51
34
  }
package/src/cli/auth.ts CHANGED
@@ -1,39 +1,37 @@
1
1
  import { partition } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { withLock } from "../lib/lock.ts";
4
- import { loadAccounts, saveAccounts } from "../lib/state.ts";
5
- import { paths } from "../lib/paths.ts";
6
- import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
4
+ import type { Provider } from "../lib/provider.ts";
5
+ import { loadAccounts, saveAccounts, upsertAccount } from "../lib/state.ts";
7
6
  import { findAccount } from "./rename.ts";
8
- import { harvestIsolatedLogin } from "./onboard.ts";
9
- import { keepRows } from "../lib/usage.ts";
10
- import { c, claudeTierLabel, count } from "./render.ts";
7
+ import { usageNote } from "./add.ts";
8
+ import { c, count } from "./render.ts";
11
9
  import type { Account, AccountsIndex } from "../lib/types.ts";
12
10
 
13
- const AUTH_USAGE = "usage: tokenmaxxing auth [<email|label|id> | --all]";
11
+ const AUTH_USAGE = "usage: tokenmaxxing auth [--codex] [<email|label|id> | --all]";
14
12
 
15
13
  const AuthPlanSchema = z.discriminatedUnion("kind", [
16
14
  z.object({ kind: z.literal("usage") }),
17
15
  z.object({ kind: z.literal("error"), message: z.string() }),
18
16
  z.object({ kind: z.literal("pick") }),
19
- z.object({ kind: z.literal("targets"), uuids: z.array(z.string()) }),
17
+ z.object({ kind: z.literal("targets"), ids: z.array(z.string()) }),
20
18
  ]);
21
19
  export type AuthPlan = z.infer<typeof AuthPlanSchema>;
22
20
 
23
- export function planAuth(input: { accounts: Account[]; argv: string[] }): AuthPlan {
21
+ export function planAuth(input: { p: Provider; accounts: Account[]; argv: string[] }): AuthPlan {
24
22
  const all = input.argv.includes("--all");
25
23
  const rest = input.argv.filter((a) => a !== "--all");
26
24
  if ((all && rest.length > 0) || rest.length > 1) return { kind: "usage" };
27
- if (input.accounts.length === 0) return { kind: "error", message: "no accounts in the pool - run `tokenmaxxing init` first" };
25
+ if (input.accounts.length === 0) return { kind: "error", message: `no accounts in the pool - run \`tokenmaxxing init${input.p.flag}\` first` };
28
26
  if (all) {
29
27
  const flagged = input.accounts.filter((a) => a.needsReauth === true);
30
- return { kind: "targets", uuids: flagged.map((a) => a.accountUuid) };
28
+ return { kind: "targets", ids: flagged.map((a) => a.id) };
31
29
  }
32
30
  const selector = rest[0];
33
31
  if (selector !== undefined) {
34
32
  const found = findAccount(input.accounts, selector);
35
- if (!found) return { kind: "error", message: `no claude account matches "${selector}"` };
36
- return { kind: "targets", uuids: [found.accountUuid] };
33
+ if (!found) return { kind: "error", message: `no ${input.p.name} account matches "${selector}"` };
34
+ return { kind: "targets", ids: [found.id] };
37
35
  }
38
36
  return { kind: "pick" };
39
37
  }
@@ -48,11 +46,11 @@ function askWhichAccount(idx: AccountsIndex): Account | null {
48
46
  console.log("which account do you want to reauthenticate?");
49
47
  for (const [i, a] of ordered.entries()) {
50
48
  const flags: string[] = [];
51
- if (a.accountUuid === idx.activeAccountUuid) flags.push(c.green("active"));
49
+ if (a.id === idx.activeId) flags.push(c.green("active"));
52
50
  if (a.needsReauth) flags.push(c.red("needs-reauth"));
53
- const labelNote = a.label && a.label !== a.email ? ` (${a.label})` : "";
51
+ const labelNote = a.email != null && a.label !== a.email ? ` (${a.label})` : "";
54
52
  const tag = flags.length ? ` ${flags.join(" ")}` : "";
55
- console.log(` ${i + 1}. ${c.bold(a.email)}${labelNote}${tag}`);
53
+ console.log(` ${i + 1}. ${c.bold(a.email ?? a.label)}${labelNote}${tag}`);
56
54
  }
57
55
  const answer = prompt("account (number, email, or label):")?.trim();
58
56
  if (!answer) {
@@ -69,59 +67,47 @@ function askWhichAccount(idx: AccountsIndex): Account | null {
69
67
  return chosen;
70
68
  }
71
69
 
72
- async function reauthOne(target: Account): Promise<boolean> {
73
- console.log(c.cyan(`Reauthenticating ${c.bold(target.label)} - sign in as ${c.bold(target.email)}.`));
74
- console.log(c.dim(`In the session that opens, run ${c.bold("/login")} with that account. It closes itself once you're in.`));
70
+ async function reauthOne(p: Provider, target: Account): Promise<boolean> {
71
+ console.log(c.cyan(`Reauthenticating ${c.bold(target.label)} - sign in as ${c.bold(target.email ?? target.label)}.`));
72
+ console.log(c.dim(p.loginStep("that account")));
75
73
  console.log();
76
74
 
77
- const harvested = await harvestIsolatedLogin();
75
+ const harvested = await p.login();
78
76
  if (!harvested) return false;
79
- const { blobRaw, blob, oauthAccount, sampled } = harvested;
80
77
 
81
- if (oauthAccount.accountUuid !== target.accountUuid) {
78
+ if (harvested.id !== target.id) {
82
79
  console.error(
83
80
  c.red(
84
- `that login is ${c.bold(oauthAccount.emailAddress)}, but ${target.label} is ${c.bold(target.email)} - nothing changed. To pool it as its own account, run \`tokenmaxxing add\`.`,
81
+ `that login is ${c.bold(harvested.email ?? harvested.id.slice(0, 8))}, but ${target.label} is ${c.bold(target.email ?? target.id.slice(0, 8))} - nothing changed. To pool it as its own account, run \`tokenmaxxing add${p.flag}\`.`,
85
82
  ),
86
83
  );
87
84
  return false;
88
85
  }
89
86
 
90
- const isActive = await withLock(paths.lockFile, async () => {
91
- const idx = loadAccounts();
92
- const account = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
93
- if (!account) {
94
- console.error(c.red(`${target.label} was removed from the pool while the login was open - nothing written; re-add it with \`tokenmaxxing add\` if wanted`));
87
+ const result = await withLock(p.pool.lockFile, async () => {
88
+ const idx = loadAccounts(p.pool);
89
+ if (!idx.accounts.some((a) => a.id === target.id)) {
90
+ console.error(c.red(`${target.label} was removed from the pool while the login was open - nothing written; re-add it with \`tokenmaxxing add${p.flag}\` if wanted`));
95
91
  return null;
96
92
  }
97
- await writeItem(parkedTarget(target.keychainItem), claudeAiOauthOnly(blobRaw));
98
- account.email = oauthAccount.emailAddress;
99
- account.organizationUuid = oauthAccount.organizationUuid;
100
- account.oauthAccount = oauthAccount;
101
- account.subscriptionType = blob.claudeAiOauth.subscriptionType;
102
- account.rateLimitTier = blob.claudeAiOauth.rateLimitTier;
103
- account.needsReauth = false;
104
- if (sampled) {
105
- account.lastUsageAt = Date.now();
106
- account.lastUsage = keepRows(sampled, account.lastUsage, account.lastUsageAt);
107
- }
108
- saveAccounts(idx);
109
- return idx.activeAccountUuid === target.accountUuid;
93
+ await harvested.park();
94
+ const account = upsertAccount(idx, harvested, p.mergeWindows);
95
+ saveAccounts(p.pool, idx);
96
+ return { account, isActive: idx.activeId === target.id };
110
97
  });
111
- if (isActive === null) return false;
98
+ if (result === null) return false;
112
99
 
113
- const usageNote = sampled ? ` (session ${sampled.fiveHour.usedPercentage}% / week ${sampled.sevenDay.usedPercentage}%)` : "";
114
- const tier = claudeTierLabel(blob.claudeAiOauth) ?? "?";
115
- console.log(`${c.green("✓")} reauthed ${c.bold(oauthAccount.emailAddress)} (${tier})${usageNote}`);
116
- if (isActive) {
100
+ const note = harvested.sample ? usageNote(result.account) : "";
101
+ console.log(`${c.green("✓")} reauthed ${c.bold(result.account.email ?? result.account.label)} (${result.account.tier ?? "?"})${note}`);
102
+ if (result.isActive) {
117
103
  console.log(c.dim("this account is the active one: the fresh credential is parked as its backup; the live session keeps its current token until the next swap."));
118
104
  }
119
105
  return true;
120
106
  }
121
107
 
122
- export async function cmdAuth(argv: string[]): Promise<number> {
123
- const idx = loadAccounts();
124
- const plan = planAuth({ accounts: idx.accounts, argv });
108
+ export async function cmdAuth(p: Provider, argv: string[]): Promise<number> {
109
+ const idx = loadAccounts(p.pool);
110
+ const plan = planAuth({ p, accounts: idx.accounts, argv });
125
111
  if (plan.kind === "usage") {
126
112
  console.error(AUTH_USAGE);
127
113
  return 2;
@@ -137,8 +123,8 @@ export async function cmdAuth(argv: string[]): Promise<number> {
137
123
  if (!picked) return 1;
138
124
  targets.push(picked);
139
125
  } else {
140
- for (const uuid of plan.uuids) {
141
- const account = idx.accounts.find((a) => a.accountUuid === uuid);
126
+ for (const id of plan.ids) {
127
+ const account = idx.accounts.find((a) => a.id === id);
142
128
  if (account) targets.push(account);
143
129
  }
144
130
  if (targets.length === 0) {
@@ -151,7 +137,7 @@ export async function cmdAuth(argv: string[]): Promise<number> {
151
137
  for (const [i, target] of targets.entries()) {
152
138
  console.log();
153
139
  if (targets.length > 1) console.log(c.bold(`[${i + 1}/${targets.length}]`));
154
- if (await reauthOne(target)) ok += 1;
140
+ if (await reauthOne(p, target)) ok += 1;
155
141
  }
156
142
 
157
143
  if (targets.length > 1) {
package/src/cli/check.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { claude } from "../lib/claude.ts";
1
2
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
2
3
  import { sampleOldestParked } from "../lib/sample.ts";
3
4
  import { loadConfig } from "../lib/state.ts";
@@ -8,7 +9,7 @@ export async function cmdCheck(json = false): Promise<number> {
8
9
  const now = Date.now();
9
10
  let d;
10
11
  try {
11
- d = await evaluateAndMaybeSwap(now);
12
+ d = await evaluateAndMaybeSwap(claude, now);
12
13
  await sampleOldestParked(loadConfig());
13
14
  } catch (e) {
14
15
  const detail = e instanceof Error ? e.message : String(e);
@@ -12,7 +12,7 @@ import {
12
12
  spawnCloudClaude,
13
13
  transcriptBytes,
14
14
  } from "../lib/cloud.ts";
15
- import { c, emitError, emitJson, fmtReset } from "./render.ts";
15
+ import { c, emitError, fmtReset } from "./render.ts";
16
16
 
17
17
  const USAGE = "usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] \"<prompt>\" (the prompt is also read from stdin)";
18
18
 
@@ -39,15 +39,15 @@ function parseFlags(args: string[]): { session: string | null; maxTurns: number
39
39
  return { session, maxTurns, prompt: positional.length > 0 ? positional.join(" ") : null };
40
40
  }
41
41
 
42
- export async function cmdCloudRun(args: string[], json = false): Promise<number> {
42
+ export async function cmdCloudRun(args: string[]): Promise<number> {
43
43
  const flags = parseFlags(args);
44
44
  if ("error" in flags) {
45
- emitError({ json, message: `${flags.error} - ${USAGE}` });
45
+ emitError({ message: `${flags.error} - ${USAGE}` });
46
46
  return 2;
47
47
  }
48
48
  const prompt = flags.prompt ?? (process.stdin.isTTY ? null : await Bun.stdin.text());
49
49
  if (prompt == null || prompt.trim() === "") {
50
- emitError({ json, message: USAGE });
50
+ emitError({ message: USAGE });
51
51
  return 2;
52
52
  }
53
53
  const tokens = readCloudTokens();
@@ -61,11 +61,7 @@ export async function cmdCloudRun(args: string[], json = false): Promise<number>
61
61
  return picked;
62
62
  });
63
63
  if (!pick.ok) {
64
- emitError({
65
- json,
66
- message: `every token is at its limit - earliest ${fmtReset(pick.earliestReset)}`,
67
- extra: { earliestReset: pick.earliestReset, session_id: sessionId },
68
- });
64
+ emitError({ message: `every token is at its limit - earliest ${fmtReset(pick.earliestReset)}` });
69
65
  return 1;
70
66
  }
71
67
  const label = pick.token.label;
@@ -99,17 +95,11 @@ export async function cmdCloudRun(args: string[], json = false): Promise<number>
99
95
  }
100
96
  const errors = result.subtype === "success" ? [result.result] : result.errors;
101
97
  emitError({
102
- json,
103
98
  message: `claude exited ${exitCode ?? "on signal"} (${result.subtype}): ${errors.join("; ")}`,
104
99
  notes: [`session ${result.session_id}`],
105
- extra: { session_id: result.session_id, subtype: result.subtype, errors },
106
100
  });
107
101
  return 1;
108
102
  }
109
- if (json) {
110
- emitJson({ ok: true, session_id: result.session_id, result: result.result, num_turns: result.num_turns, total_cost_usd: result.total_cost_usd });
111
- return 0;
112
- }
113
103
  console.log(result.result);
114
104
  console.log(`session ${result.session_id}`);
115
105
  return 0;
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { writeFileAtomic } from "../lib/atomic.ts";
5
- import { c, emitError, emitJson } from "./render.ts";
5
+ import { c, emitError } from "./render.ts";
6
6
 
7
7
  export const CLOUD_INSTALL_LINE =
8
8
  'curl -fsSL https://bun.sh/install | bash && curl -fsSL https://claude.ai/install.sh | bash && "$HOME/.bun/bin/bun" add -g tokenmaxxing && test -x "$HOME/.bun/bin/bun" && test -x "$HOME/.local/bin/claude" && test -e "$HOME/.bun/bin/tokenmaxxing"';
@@ -42,14 +42,14 @@ const ExistingEnvironmentSchema = z.looseObject({ user: z.string().optional() })
42
42
  const REPO_FILE_MODE = 0o644;
43
43
  const USAGE = "usage: tokenmaxxing cursor init [dir]";
44
44
 
45
- export function cmdCursorInit(args: string[], json = false): number {
45
+ export function cmdCursorInit(args: string[]): number {
46
46
  if (args.length > 1) {
47
- emitError({ json, message: USAGE });
47
+ emitError({ message: USAGE });
48
48
  return 2;
49
49
  }
50
50
  const dir = resolve(args[0] ?? ".");
51
51
  if (!existsSync(dir)) {
52
- emitError({ json, message: `no such directory: ${dir}` });
52
+ emitError({ message: `no such directory: ${dir}` });
53
53
  return 1;
54
54
  }
55
55
  const agentFile = join(dir, ".cursor", "agents", "claude.md");
@@ -64,10 +64,6 @@ export function cmdCursorInit(args: string[], json = false): number {
64
64
  } else if (existing.user === "root" || existing.user === "0") {
65
65
  warnings.push(".cursor/environment.json sets user to root; Claude Code refuses --dangerously-skip-permissions under root, so every relay call fails until user is a non-root account");
66
66
  }
67
- if (json) {
68
- emitJson({ ok: true, dir, agent, environment, install: CLOUD_INSTALL_LINE, warnings });
69
- return 0;
70
- }
71
67
  console.log(`${c.green("✓")} .cursor/agents/claude.md ${agent}`);
72
68
  if (environment === "written") {
73
69
  console.log(`${c.green("✓")} .cursor/environment.json written`);
package/src/cli/doctor.ts CHANGED
@@ -2,13 +2,13 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { verifyRealClaude } from "../lib/claudebin.ts";
3
3
  import { checkSettings, installedBin } from "../lib/settings.ts";
4
4
  import { checkTimerHealthy, findClaudeShadowers, isBinDirAhead, shellRcPath, timerActivationHint } from "../lib/install.ts";
5
- import { paths } from "../lib/paths.ts";
5
+ import { claudePool, credItemFor, paths } from "../lib/paths.ts";
6
6
  import { loadAccounts, loadConfig } from "../lib/state.ts";
7
7
  import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
8
8
  import { isAccessTokenExpiring, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
9
9
  import { CredentialBlobSchema, type TokenIdentity } from "../lib/types.ts";
10
10
  import { SETUP_TOKEN_STALE_MS, loadSetupTokens } from "../lib/setuptokens.ts";
11
- import { c, emitJson, fmtAgo } from "./render.ts";
11
+ import { c, fmtAgo } from "./render.ts";
12
12
 
13
13
  async function blobIdentity(raw: string): Promise<TokenIdentity | null> {
14
14
  const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
@@ -16,22 +16,14 @@ async function blobIdentity(raw: string): Promise<TokenIdentity | null> {
16
16
  return fetchTokenIdentity(creds.accessToken);
17
17
  }
18
18
 
19
- export async function cmdDoctor(json = false): Promise<number> {
20
- const checks: { ok: boolean; label: string; hint: string | null }[] = [];
21
- const notes: string[] = [];
22
- const warnings: string[] = [];
19
+ export async function cmdDoctor(): Promise<number> {
20
+ let failed = 0;
23
21
  const check = (cond: boolean, label: string, hint?: string) => {
24
- checks.push({ ok: cond, label, hint: cond ? null : (hint ?? null) });
25
- if (!json) console.log(`${cond ? c.green("✓") : c.red("✗")} ${label}${!cond && hint ? c.dim(` - ${hint}`) : ""}`);
26
- };
27
- const note = (text: string) => {
28
- notes.push(text);
29
- if (!json) console.log(c.dim(` - ${text}`));
30
- };
31
- const warn = (text: string) => {
32
- warnings.push(text);
33
- if (!json) console.log(c.yellow(`⚠ ${text}`));
22
+ if (!cond) failed++;
23
+ console.log(`${cond ? c.green("✓") : c.red("✗")} ${label}${!cond && hint ? c.dim(` - ${hint}`) : ""}`);
34
24
  };
25
+ const note = (text: string) => console.log(c.dim(` - ${text}`));
26
+ const warn = (text: string) => console.log(c.yellow(`⚠ ${text}`));
35
27
 
36
28
  check(existsSync(paths.supervisorLink), "claude supervisor wrapper present", "run `tokenmaxxing init`");
37
29
  check(existsSync(installedBin()), "tokenmaxxing binary installed", "run `tokenmaxxing init`");
@@ -45,43 +37,43 @@ export async function cmdDoctor(json = false): Promise<number> {
45
37
  check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
46
38
  check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
47
39
 
48
- const idx = loadAccounts();
40
+ const idx = loadAccounts(claudePool);
49
41
  check(idx.accounts.length > 0, "at least one account in the pool", "run `tokenmaxxing init`");
50
- check(!!idx.activeAccountUuid, "an active account is set");
42
+ check(!!idx.activeId, "an active account is set");
51
43
 
52
44
  const live = await readItem(liveTarget());
53
45
  check(!!live, "live credential readable");
54
46
 
55
- const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
47
+ const active = idx.accounts.find((a) => a.id === idx.activeId);
56
48
  if (live && active) {
57
49
  try {
58
50
  const identity = await blobIdentity(live);
59
- if (identity) check(identity.accountUuid === active.accountUuid, `live credential identity matches active (${active.email})`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing switch\``);
51
+ if (identity) check(identity.accountUuid === active.id, `live credential identity matches active (${active.label})`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing switch\``);
60
52
  else note("live credential identity unverifiable (access token expired)");
61
53
  } catch (e) {
62
- check(false, `live credential identity matches active (${active.email})`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
54
+ check(false, `live credential identity matches active (${active.label})`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
63
55
  }
64
56
  }
65
57
 
66
58
  for (const a of idx.accounts) {
67
- const parked = await readItem(parkedTarget(a.keychainItem));
68
- check(!!parked, `parked credential present for ${a.email}`, `run \`tokenmaxxing auth ${a.label}\``);
59
+ const parked = await readItem(parkedTarget(credItemFor(a.id)));
60
+ check(!!parked, `parked credential present for ${a.label}`, `run \`tokenmaxxing auth ${a.label}\``);
69
61
  if (parked) {
70
62
  try {
71
63
  const identity = await blobIdentity(parked);
72
- if (identity) check(identity.accountUuid === a.accountUuid, `parked credential identity matches ${a.email}`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing auth ${a.label}\``);
73
- else note(`${a.email} identity unverifiable (access token expired)`);
64
+ if (identity) check(identity.accountUuid === a.id, `parked credential identity matches ${a.label}`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing auth ${a.label}\``);
65
+ else note(`${a.label} identity unverifiable (access token expired)`);
74
66
  } catch (e) {
75
- check(false, `parked credential identity matches ${a.email}`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
67
+ check(false, `parked credential identity matches ${a.label}`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
76
68
  }
77
69
  }
78
- if (a.needsReauth) check(false, `${a.email} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
70
+ if (a.needsReauth) check(false, `${a.label} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
79
71
  }
80
72
 
81
73
  if (existsSync(paths.setupTokensJson)) {
82
74
  const setupTokens = loadSetupTokens().tokens;
83
75
  for (const a of idx.accounts) {
84
- const token = setupTokens.find((t) => t.accountUuid === a.accountUuid);
76
+ const token = setupTokens.find((t) => t.accountUuid === a.id);
85
77
  if (!token) warn(`no setup token stored for ${a.label} - run \`tokenmaxxing setup-token\` to mint one for Cursor Cloud`);
86
78
  else if (Date.now() - token.mintedAt > SETUP_TOKEN_STALE_MS) warn(`the setup token for ${a.label} was minted ${fmtAgo(token.mintedAt)} and expires a year after minting - \`tokenmaxxing setup-token rm ${a.label}\` then \`tokenmaxxing setup-token\` re-mints it`);
87
79
  }
@@ -102,13 +94,7 @@ export async function cmdDoctor(json = false): Promise<number> {
102
94
  }
103
95
  }
104
96
 
105
- const failed = checks.filter((entry) => !entry.ok).length;
106
- const ok = failed === 0;
107
- if (json) {
108
- emitJson(ok ? { ok, checks, notes, warnings } : { ok, error: `issues found - ${failed} of ${checks.length} checks failed`, checks, notes, warnings });
109
- return ok ? 0 : 1;
110
- }
111
97
  console.log();
112
- console.log(ok ? c.green("all good ✓") : c.yellow("issues found - see above"));
113
- return ok ? 0 : 1;
98
+ console.log(failed === 0 ? c.green("all good ✓") : c.yellow("issues found - see above"));
99
+ return failed === 0 ? 0 : 1;
114
100
  }