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.
- package/DESIGN.md +3 -5
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/add.ts +23 -40
- package/src/cli/auth.ts +38 -52
- package/src/cli/check.ts +2 -1
- package/src/cli/doctor.ts +13 -13
- package/src/cli/init.ts +29 -126
- package/src/cli/rename.ts +11 -45
- package/src/cli/render.ts +0 -7
- package/src/cli/rm.ts +24 -39
- package/src/cli/setuptoken.ts +16 -16
- package/src/cli/status.ts +95 -238
- package/src/cli/switch.ts +54 -46
- package/src/entries/codexstophook.ts +23 -23
- package/src/entries/codexsupervisor.ts +3 -3
- package/src/entries/sessionstart.ts +5 -3
- package/src/entries/statusline.ts +17 -19
- package/src/entries/stopfailurehook.ts +9 -7
- package/src/entries/stophook.ts +5 -4
- package/src/entries/subagentstatusline.ts +2 -7
- package/src/lib/atomic.ts +2 -8
- package/src/lib/claude.ts +320 -0
- package/src/lib/claudelock.ts +2 -3
- package/src/lib/codex.ts +345 -0
- package/src/lib/codexauth.ts +9 -7
- package/src/lib/codexoauth.ts +5 -11
- package/src/lib/codexpresence.ts +3 -19
- package/src/lib/codexusage.ts +17 -41
- package/src/lib/credstore.ts +3 -7
- package/src/lib/decide.ts +86 -174
- package/src/lib/http.ts +3 -6
- package/src/lib/install.ts +4 -7
- package/src/lib/oauth.ts +14 -12
- package/src/lib/paths.ts +15 -7
- package/src/lib/picker.ts +56 -37
- package/src/lib/proc.ts +4 -4
- package/src/lib/provider.ts +34 -0
- package/src/lib/sample.ts +33 -33
- package/src/lib/state.ts +51 -18
- package/src/lib/swap.ts +41 -38
- package/src/lib/types.ts +31 -51
- package/src/lib/usage.ts +28 -20
- package/src/main.ts +20 -20
- package/src/cli/codexadd.ts +0 -89
- package/src/cli/codexinit.ts +0 -120
- package/src/cli/codexrm.ts +0 -36
- package/src/cli/codexswitch.ts +0 -113
- package/src/cli/onboard.ts +0 -92
- package/src/lib/codexdecide.ts +0 -157
- package/src/lib/codexpick.ts +0 -80
- package/src/lib/codexsample.ts +0 -48
- package/src/lib/codexstate.ts +0 -22
- 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 `{
|
|
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 `
|
|
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
|
-
**
|
|
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.
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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(
|
|
23
|
-
await
|
|
24
|
-
const idx = loadAccounts();
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
49
|
-
console.log(`${c.green("✓")} added ${c.bold(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 {
|
|
5
|
-
import {
|
|
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 {
|
|
9
|
-
import {
|
|
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"),
|
|
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:
|
|
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",
|
|
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
|
|
36
|
-
return { kind: "targets",
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
75
|
+
const harvested = await p.login();
|
|
78
76
|
if (!harvested) return false;
|
|
79
|
-
const { blobRaw, blob, oauthAccount, sampled } = harvested;
|
|
80
77
|
|
|
81
|
-
if (
|
|
78
|
+
if (harvested.id !== target.id) {
|
|
82
79
|
console.error(
|
|
83
80
|
c.red(
|
|
84
|
-
`that login is ${c.bold(
|
|
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
|
|
91
|
-
const idx = loadAccounts();
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
98
|
-
account
|
|
99
|
-
|
|
100
|
-
account.
|
|
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 (
|
|
98
|
+
if (result === null) return false;
|
|
112
99
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
141
|
-
const account = idx.accounts.find((a) => a.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
-
check(!!parked, `parked credential present for ${a.
|
|
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.
|
|
65
|
-
else note(`${a.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
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(
|
|
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(
|
|
41
|
-
console.log(` ${c.cyan(
|
|
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
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
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 (${
|
|
147
|
-
printUsage();
|
|
49
|
+
console.log(` pool ready (${count({ n: poolSize, noun: "account" })})`);
|
|
50
|
+
printUsage(p);
|
|
148
51
|
return 0;
|
|
149
52
|
}
|