tokenmaxxing 1.34.0 → 1.38.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 (54) hide show
  1. package/DESIGN.md +3 -5
  2. package/README.md +1 -1
  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/doctor.ts +13 -13
  8. package/src/cli/init.ts +29 -126
  9. package/src/cli/rename.ts +11 -45
  10. package/src/cli/render.ts +0 -7
  11. package/src/cli/rm.ts +24 -39
  12. package/src/cli/setuptoken.ts +16 -16
  13. package/src/cli/status.ts +95 -238
  14. package/src/cli/switch.ts +54 -46
  15. package/src/entries/codexstophook.ts +23 -23
  16. package/src/entries/codexsupervisor.ts +3 -3
  17. package/src/entries/sessionstart.ts +5 -3
  18. package/src/entries/statusline.ts +17 -19
  19. package/src/entries/stopfailurehook.ts +9 -7
  20. package/src/entries/stophook.ts +5 -4
  21. package/src/entries/subagentstatusline.ts +2 -7
  22. package/src/lib/atomic.ts +2 -8
  23. package/src/lib/claude.ts +320 -0
  24. package/src/lib/claudelock.ts +2 -3
  25. package/src/lib/codex.ts +345 -0
  26. package/src/lib/codexauth.ts +9 -7
  27. package/src/lib/codexoauth.ts +5 -11
  28. package/src/lib/codexpresence.ts +3 -19
  29. package/src/lib/codexusage.ts +17 -41
  30. package/src/lib/credstore.ts +3 -7
  31. package/src/lib/decide.ts +86 -174
  32. package/src/lib/http.ts +3 -6
  33. package/src/lib/install.ts +4 -7
  34. package/src/lib/oauth.ts +14 -12
  35. package/src/lib/paths.ts +15 -7
  36. package/src/lib/picker.ts +56 -37
  37. package/src/lib/proc.ts +4 -4
  38. package/src/lib/provider.ts +34 -0
  39. package/src/lib/sample.ts +33 -33
  40. package/src/lib/state.ts +51 -18
  41. package/src/lib/swap.ts +41 -38
  42. package/src/lib/types.ts +31 -51
  43. package/src/lib/usage.ts +28 -20
  44. package/src/main.ts +20 -20
  45. package/src/cli/codexadd.ts +0 -89
  46. package/src/cli/codexinit.ts +0 -120
  47. package/src/cli/codexrm.ts +0 -36
  48. package/src/cli/codexswitch.ts +0 -113
  49. package/src/cli/onboard.ts +0 -92
  50. package/src/lib/codexdecide.ts +0 -157
  51. package/src/lib/codexpick.ts +0 -80
  52. package/src/lib/codexsample.ts +0 -48
  53. package/src/lib/codexstate.ts +0 -22
  54. 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.34.0",
3
+ "version": "1.38.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);
package/src/cli/doctor.ts CHANGED
@@ -2,7 +2,7 @@ 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";
@@ -37,43 +37,43 @@ export async function cmdDoctor(): Promise<number> {
37
37
  check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
38
38
  check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
39
39
 
40
- const idx = loadAccounts();
40
+ const idx = loadAccounts(claudePool);
41
41
  check(idx.accounts.length > 0, "at least one account in the pool", "run `tokenmaxxing init`");
42
- check(!!idx.activeAccountUuid, "an active account is set");
42
+ check(!!idx.activeId, "an active account is set");
43
43
 
44
44
  const live = await readItem(liveTarget());
45
45
  check(!!live, "live credential readable");
46
46
 
47
- const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
47
+ const active = idx.accounts.find((a) => a.id === idx.activeId);
48
48
  if (live && active) {
49
49
  try {
50
50
  const identity = await blobIdentity(live);
51
- 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\``);
52
52
  else note("live credential identity unverifiable (access token expired)");
53
53
  } catch (e) {
54
- 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));
55
55
  }
56
56
  }
57
57
 
58
58
  for (const a of idx.accounts) {
59
- const parked = await readItem(parkedTarget(a.keychainItem));
60
- 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}\``);
61
61
  if (parked) {
62
62
  try {
63
63
  const identity = await blobIdentity(parked);
64
- if (identity) check(identity.accountUuid === a.accountUuid, `parked credential identity matches ${a.email}`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing auth ${a.label}\``);
65
- 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)`);
66
66
  } catch (e) {
67
- 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));
68
68
  }
69
69
  }
70
- 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`);
71
71
  }
72
72
 
73
73
  if (existsSync(paths.setupTokensJson)) {
74
74
  const setupTokens = loadSetupTokens().tokens;
75
75
  for (const a of idx.accounts) {
76
- const token = setupTokens.find((t) => t.accountUuid === a.accountUuid);
76
+ const token = setupTokens.find((t) => t.accountUuid === a.id);
77
77
  if (!token) warn(`no setup token stored for ${a.label} - run \`tokenmaxxing setup-token\` to mint one for Cursor Cloud`);
78
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`);
79
79
  }
package/src/cli/init.ts CHANGED
@@ -1,149 +1,52 @@
1
1
  import { mkdirSync } from "node:fs";
2
- import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
3
- import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
4
- import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
5
- import { withClaudeRefreshLock } from "../lib/claudelock.ts";
6
- import { loadAccounts, saveAccounts, loadConfig, pinBinOverride } from "../lib/state.ts";
7
2
  import { withLock } from "../lib/lock.ts";
8
- import { installSupervisor, shellRcPath, ensurePathInRc, managedShellRcSkipLines, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
9
- import { resolveVerifiedClaude } from "../lib/claudebin.ts";
10
- import { credItemFor, paths } from "../lib/paths.ts";
11
- import { CredentialBlobSchema, type Account } from "../lib/types.ts";
12
- import { c, claudeTierLabel } from "./render.ts";
3
+ import { paths } from "../lib/paths.ts";
4
+ import type { Provider } from "../lib/provider.ts";
5
+ import { loadAccounts, loadConfig, saveAccounts, upsertAccount } from "../lib/state.ts";
6
+ import { c, count } from "./render.ts";
13
7
 
14
- function ensurePathAhead(): void {
15
- const rc = shellRcPath();
16
- if (!rc) {
17
- console.log(c.yellow(`⚠ add to your shell rc: export PATH="${paths.binDir}:$PATH"`));
18
- return;
19
- }
20
- const outcome = ensurePathInRc(rc);
21
- if (outcome === "added") console.log(`${c.green("✓")} added ${paths.binDir} to PATH in ${rc} - restart your shell (or \`source ${rc}\`)`);
22
- else if (outcome === "skipped") {
23
- const hint = managedShellRcSkipLines();
24
- console.log(c.yellow(`⚠ ${hint.headline}`));
25
- console.log(c.yellow(` ${hint.detail}`));
26
- console.log(c.yellow(` ${hint.exportLine}`));
27
- } else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
28
- }
29
-
30
- function reportTimer(out: InstallOutcome): void {
31
- if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every ${out.checkIntervalS}s)`);
32
- else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
33
- }
34
-
35
- export function printUsage(): void {
8
+ export function printUsage(p: Provider): void {
36
9
  console.log();
37
10
  console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
38
- console.log(` ${c.cyan("claude")} use claude as always; it switches accounts near quota automatically`);
11
+ console.log(` ${c.cyan(p.name)} use ${p.name} as always; it switches accounts near quota automatically`);
39
12
  console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
40
- console.log(` ${c.cyan("xx add")} log in and pool another account`);
41
- console.log(` ${c.cyan("xx switch")} hop to the best account right now (the automatic switching needs no command)`);
13
+ console.log(` ${c.cyan(`xx add${p.flag}`)} log in and pool another account`);
14
+ console.log(` ${c.cyan(`xx switch${p.flag}`)} hop to the best account right now (the automatic switching needs no command)`);
42
15
  console.log(` ${c.cyan("xx help")} everything else`);
43
16
  }
44
17
 
45
- export async function cmdInit(): Promise<number> {
18
+ export async function cmdInit(p: Provider): Promise<number> {
46
19
  mkdirSync(paths.home, { recursive: true });
47
20
  loadConfig();
21
+ p.preflight();
48
22
 
49
- const existingIdx = loadAccounts();
50
- if (existingIdx.accounts.length > 0) {
51
- const out = installSupervisor();
52
- pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
53
- const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
54
- console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
55
- reportTimer(out);
56
- if (!out.pathAhead) ensurePathAhead();
23
+ const existing = loadAccounts(p.pool);
24
+ if (existing.accounts.length > 0) {
25
+ p.install();
26
+ const active = existing.accounts.find((a) => a.id === existing.activeId);
27
+ console.log(`${c.green("✓")} re-installed (pool already has ${count({ n: existing.accounts.length, noun: "account" })} - not re-importing)`);
57
28
  console.log(` active: ${c.bold(active?.label ?? "unknown")}`);
58
- printUsage();
29
+ printUsage(p);
59
30
  return 0;
60
31
  }
61
32
 
62
- if (isApiKeyMode()) {
63
- console.error(c.yellow("tokenmaxxing pools subscription accounts, but you're authed via API key / apiKeyHelper."));
64
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} with a Pro/Max account first, then re-run ${c.cyan("tokenmaxxing init")}.`);
65
- return 1;
66
- }
67
-
68
- const oauthAccount = readOAuthAccount();
69
- const liveRaw = await readItem(liveTarget());
70
- if (!oauthAccount || !liveRaw) {
71
- console.error(c.red("no active Claude subscription login found (missing oauthAccount or credential)."));
72
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
73
- return 1;
74
- }
33
+ const harvested = await p.importLive();
34
+ if (!harvested) return 1;
75
35
 
76
- let blob;
77
- try {
78
- blob = CredentialBlobSchema.parse(JSON.parse(liveRaw));
79
- } catch {
80
- console.error(c.red("the live credential is not a recognizable Claude OAuth blob."));
81
- return 1;
82
- }
83
-
84
- let creds = blob.claudeAiOauth;
85
- if (isDeadCredential(creds)) {
86
- console.error(c.red("the live credential was cleared after a failed refresh."));
87
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
88
- return 1;
89
- }
90
- if (isAccessTokenExpiring(creds)) {
91
- await withClaudeRefreshLock(async (lock) => {
92
- const raw2 = await readItem(liveTarget());
93
- if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
94
- const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
95
- creds = isAccessTokenExpiring(current) ? await refreshCredential(current) : current;
96
- if (creds === current) return;
97
- if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
98
- await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
99
- });
100
- }
101
- const identity = await fetchTokenIdentity(creds.accessToken);
102
- if (identity.accountUuid !== oauthAccount.accountUuid) {
103
- console.error(c.red(`the live credential belongs to ${describeIdentity(identity)}, but ~/.claude.json identifies ${oauthAccount.emailAddress} - identity drift.`));
104
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} to realign them, then re-run ${c.cyan("tokenmaxxing init")}.`);
105
- return 1;
106
- }
107
-
108
- const uuid = oauthAccount.accountUuid;
109
- const keychainItem = credItemFor(uuid);
110
- const account = await withLock(paths.lockFile, async () => {
111
- await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds }));
112
- const idx = loadAccounts();
113
- const existing = idx.accounts.find((a) => a.accountUuid === uuid);
114
- const imported: Account = {
115
- accountUuid: uuid,
116
- email: oauthAccount.emailAddress,
117
- organizationUuid: oauthAccount.organizationUuid,
118
- label: existing?.label ?? oauthAccount.emailAddress,
119
- keychainItem,
120
- oauthAccount,
121
- addedAt: existing?.addedAt ?? new Date().toISOString(),
122
- subscriptionType: blob.claudeAiOauth.subscriptionType,
123
- rateLimitTier: blob.claudeAiOauth.rateLimitTier,
124
- needsReauth: false,
125
- };
126
- if (existing) Object.assign(existing, imported);
127
- else idx.accounts.push(imported);
128
- idx.activeAccountUuid = uuid;
129
- saveAccounts(idx);
36
+ const account = await withLock(p.pool.lockFile, async () => {
37
+ await harvested.park();
38
+ const idx = loadAccounts(p.pool);
39
+ const imported = upsertAccount(idx, harvested, p.mergeWindows);
40
+ idx.activeId = harvested.id;
41
+ saveAccounts(p.pool, idx);
130
42
  return imported;
131
43
  });
132
44
 
133
- pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
134
-
135
- const out = installSupervisor();
136
-
137
- console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})`);
138
- console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
139
- reportTimer(out);
140
- if (!out.pathAhead) {
141
- console.log();
142
- ensurePathAhead();
143
- }
144
- const poolSize = loadAccounts().accounts.length;
45
+ console.log(`${c.green("✓")} imported current account → ${c.bold(account.label)} (${account.tier ?? "?"})`);
46
+ p.install();
47
+ const poolSize = loadAccounts(p.pool).accounts.length;
145
48
  console.log();
146
- console.log(` pool ready (${poolSize} account${poolSize === 1 ? "" : "s"})`);
147
- printUsage();
49
+ console.log(` pool ready (${count({ n: poolSize, noun: "account" })})`);
50
+ printUsage(p);
148
51
  return 0;
149
52
  }