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