tokenmaxxing 1.28.0 → 1.30.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 -1
- package/README.md +10 -5
- package/package.json +2 -2
- package/src/cli/cloudrun.ts +117 -0
- package/src/cli/cursorinit.ts +81 -0
- package/src/cli/doctor.ts +11 -1
- package/src/cli/rm.ts +5 -0
- package/src/cli/setuptoken.ts +160 -0
- package/src/cli/status.ts +69 -40
- package/src/lib/cloud.ts +176 -0
- package/src/lib/paths.ts +5 -0
- package/src/lib/setuptokens.ts +40 -0
- package/src/lib/usage.ts +7 -2
- package/src/main.ts +29 -11
- package/src/cli/ls.ts +0 -75
- package/src/cli/watch.ts +0 -46
package/DESIGN.md
CHANGED
|
@@ -105,7 +105,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
|
|
|
105
105
|
---
|
|
106
106
|
|
|
107
107
|
## 7. Scope
|
|
108
|
-
**v1:** `tokenmaxxing init` / `add` / `ls` / `status` / `doctor`; the supervisor; statusLine shim + Stop/SessionStart hooks; threshold swap with `flock` + reset-aware picker; platform credential store (macOS keychain / Linux 0600 files, one facade); auto-respawn across concurrent sessions. macOS + Linux.
|
|
108
|
+
**v1:** `tokenmaxxing init` / `add` / `ls` (removed in 1.30.0, `status --cached` covers it) / `status` / `doctor`; the supervisor; statusLine shim + Stop/SessionStart hooks; threshold swap with `flock` + reset-aware picker; platform credential store (macOS keychain / Linux 0600 files, one facade); auto-respawn across concurrent sessions. macOS + Linux.
|
|
109
109
|
|
|
110
110
|
**v2:** projected-threshold pre-emption; a `UserPromptSubmit` guard that respawns *before* a turn starts when already over; Windows.
|
|
111
111
|
|
|
@@ -115,6 +115,8 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
|
|
|
115
115
|
|
|
116
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.
|
|
117
117
|
|
|
118
|
+
**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
|
+
|
|
118
120
|
**Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap; Slack as a message bus; soft concurrency caps.
|
|
119
121
|
|
|
120
122
|
---
|
package/README.md
CHANGED
|
@@ -68,14 +68,15 @@ claude # use claude as always
|
|
|
68
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 |
|
|
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
|
-
| `tokenmaxxing
|
|
72
|
-
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
73
|
-
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30) |
|
|
71
|
+
| `tokenmaxxing status [--cached]` | accounts with 5h / weekly usage bars, active + exhausted-until-reset; `--cached` renders the stored figures without sampling |
|
|
74
72
|
| `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
|
|
75
73
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
76
74
|
| `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
|
|
77
75
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
|
78
|
-
|
|
|
76
|
+
| `tokenmaxxing setup-token [--print \| rm <label\|uuid>]` | Cursor Cloud only: mint one `claude setup-token` per pooled account (a browser sign-in each) and print the `TOKENMAXXING_TOKENS` secret value; `--print` prints the stored set, `rm` drops one |
|
|
77
|
+
| `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
|
|
78
|
+
| `tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"` | on a Cursor Cloud VM: run `claude -p` on a setup token, rotate to the next token on a usage limit |
|
|
79
|
+
| `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, `doctor`, `check`, `switch`, `rename`, `rm`, `uninstall`, `setup-token --print`, `cursor init`, and `cloud run` (`ok` mirrors the exit code, failures add `error`) |
|
|
79
80
|
|
|
80
81
|
## How switching decides
|
|
81
82
|
|
|
@@ -123,10 +124,14 @@ tokenmaxxing add --codex # log in another account, isolated - your primary lo
|
|
|
123
124
|
codex # use codex as always
|
|
124
125
|
```
|
|
125
126
|
|
|
126
|
-
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status
|
|
127
|
+
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status` (and `status --cached`) shows both pools.
|
|
127
128
|
|
|
128
129
|
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
|
|
129
130
|
|
|
131
|
+
## Cursor Cloud
|
|
132
|
+
|
|
133
|
+
A Cursor Cloud Agent can hand substantial work to Claude Code running on your own accounts. The VM has no keychain and no hot swap, so this path uses setup tokens instead. `tokenmaxxing setup-token` mints one `claude setup-token` per pooled account on your machine and prints the value for a user-scoped Runtime Secret named `TOKENMAXXING_TOKENS`; `tokenmaxxing cursor init` writes a `claude` project subagent and an `environment.json` install line into the repo; on the VM, the subagent forwards each task to `tokenmaxxing cloud run`, which runs `claude -p` on one token per thread and moves the thread to the next token when a run ends on a usage limit. Setup tokens are inference-only and report no usage percentages, so cloud rotation is reactive and the local switching engine never reads one. Details and limits in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
|
|
134
|
+
|
|
130
135
|
## Honest limitations
|
|
131
136
|
|
|
132
137
|
- **One cold turn.** The first turn on a new account re-uploads context once (prompt cache is org-scoped).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"DESIGN.md"
|
|
20
20
|
],
|
|
21
21
|
"engines": {
|
|
22
|
-
"bun": ">=1.
|
|
22
|
+
"bun": ">=1.3.6"
|
|
23
23
|
},
|
|
24
24
|
"os": [
|
|
25
25
|
"darwin",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { withLock } from "../lib/lock.ts";
|
|
2
|
+
import { paths } from "../lib/paths.ts";
|
|
3
|
+
import {
|
|
4
|
+
limitWallUntil,
|
|
5
|
+
loadCloudSessions,
|
|
6
|
+
loadCloudWalled,
|
|
7
|
+
newReservationKey,
|
|
8
|
+
pickToken,
|
|
9
|
+
readCloudTokens,
|
|
10
|
+
saveCloudSessions,
|
|
11
|
+
saveCloudWalled,
|
|
12
|
+
spawnCloudClaude,
|
|
13
|
+
transcriptBytes,
|
|
14
|
+
} from "../lib/cloud.ts";
|
|
15
|
+
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
16
|
+
|
|
17
|
+
const USAGE = "usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] \"<prompt>\" (the prompt is also read from stdin)";
|
|
18
|
+
|
|
19
|
+
function parseFlags(args: string[]): { session: string | null; maxTurns: number | null; prompt: string | null } | { error: string } {
|
|
20
|
+
let session: string | null = null;
|
|
21
|
+
let maxTurns: number | null = null;
|
|
22
|
+
const positional: string[] = [];
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const arg = args[i]!;
|
|
25
|
+
if (arg === "--session") {
|
|
26
|
+
const raw = args[++i];
|
|
27
|
+
if (raw == null || raw === "") return { error: "--session needs a session id" };
|
|
28
|
+
session = raw;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (arg === "--max-turns") {
|
|
32
|
+
const raw = args[++i];
|
|
33
|
+
maxTurns = Number(raw);
|
|
34
|
+
if (!Number.isInteger(maxTurns) || maxTurns < 1) return { error: `--max-turns needs a positive whole number, got: ${raw ?? "nothing"}` };
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
positional.push(arg);
|
|
38
|
+
}
|
|
39
|
+
return { session, maxTurns, prompt: positional.length > 0 ? positional.join(" ") : null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function cmdCloudRun(args: string[], json = false): Promise<number> {
|
|
43
|
+
const flags = parseFlags(args);
|
|
44
|
+
if ("error" in flags) {
|
|
45
|
+
emitError({ json, message: `${flags.error} - ${USAGE}` });
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
const prompt = flags.prompt ?? (process.stdin.isTTY ? null : await Bun.stdin.text());
|
|
49
|
+
if (prompt == null || prompt.trim() === "") {
|
|
50
|
+
emitError({ json, message: USAGE });
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
const tokens = readCloudTokens();
|
|
54
|
+
let sessionId = flags.session;
|
|
55
|
+
while (true) {
|
|
56
|
+
const reservation = newReservationKey();
|
|
57
|
+
const pick = await withLock(paths.cloudLockFile, () => {
|
|
58
|
+
const sessions = loadCloudSessions();
|
|
59
|
+
const picked = pickToken({ tokens, sessions, walled: loadCloudWalled(), sessionId, now: Date.now() });
|
|
60
|
+
if (picked.ok) saveCloudSessions({ ...sessions, [reservation]: picked.token.label });
|
|
61
|
+
return picked;
|
|
62
|
+
});
|
|
63
|
+
if (!pick.ok) {
|
|
64
|
+
emitError({
|
|
65
|
+
json,
|
|
66
|
+
message: `every token is at its limit - earliest ${fmtReset(pick.earliestReset)}`,
|
|
67
|
+
extra: { earliestReset: pick.earliestReset, session_id: sessionId },
|
|
68
|
+
});
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
const label = pick.token.label;
|
|
72
|
+
console.error(c.dim(`cloud run: ${label}${sessionId ? ` (resuming ${sessionId})` : ""}`));
|
|
73
|
+
const transcriptBefore = transcriptBytes(sessionId);
|
|
74
|
+
let spawned: Awaited<ReturnType<typeof spawnCloudClaude>>;
|
|
75
|
+
try {
|
|
76
|
+
spawned = await spawnCloudClaude({ token: pick.token.token, prompt, resume: sessionId, maxTurns: flags.maxTurns });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
await withLock(paths.cloudLockFile, () => {
|
|
79
|
+
const sessions = loadCloudSessions();
|
|
80
|
+
delete sessions[reservation];
|
|
81
|
+
saveCloudSessions(sessions);
|
|
82
|
+
});
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
const { exitCode, result } = spawned;
|
|
86
|
+
sessionId = result.session_id;
|
|
87
|
+
const failed = result.is_error || result.subtype !== "success";
|
|
88
|
+
const until = failed ? limitWallUntil({ sessionId: result.session_id, sinceBytes: transcriptBefore, now: Date.now() }) : null;
|
|
89
|
+
await withLock(paths.cloudLockFile, () => {
|
|
90
|
+
const sessions = loadCloudSessions();
|
|
91
|
+
delete sessions[reservation];
|
|
92
|
+
saveCloudSessions({ ...sessions, [result.session_id]: label });
|
|
93
|
+
if (until != null) saveCloudWalled({ ...loadCloudWalled(), [label]: until });
|
|
94
|
+
});
|
|
95
|
+
if (failed) {
|
|
96
|
+
if (until != null) {
|
|
97
|
+
console.error(c.yellow(`cloud run: ${label} hit its limit (${fmtReset(until)}) - resuming the session on the next token`));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const errors = result.subtype === "success" ? [result.result] : result.errors;
|
|
101
|
+
emitError({
|
|
102
|
+
json,
|
|
103
|
+
message: `claude exited ${exitCode ?? "on signal"} (${result.subtype}): ${errors.join("; ")}`,
|
|
104
|
+
notes: [`session ${result.session_id}`],
|
|
105
|
+
extra: { session_id: result.session_id, subtype: result.subtype, errors },
|
|
106
|
+
});
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
if (json) {
|
|
110
|
+
emitJson({ ok: true, session_id: result.session_id, result: result.result, num_turns: result.num_turns, total_cost_usd: result.total_cost_usd });
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
console.log(result.result);
|
|
114
|
+
console.log(`session ${result.session_id}`);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
5
|
+
import { c, emitError, emitJson } from "./render.ts";
|
|
6
|
+
|
|
7
|
+
export const CLOUD_INSTALL_LINE =
|
|
8
|
+
'curl -fsSL https://bun.sh/install | bash && curl -fsSL https://claude.ai/install.sh | bash && "$HOME/.bun/bin/bun" add -g tokenmaxxing && test -x "$HOME/.bun/bin/bun" && test -x "$HOME/.local/bin/claude" && test -e "$HOME/.bun/bin/tokenmaxxing"';
|
|
9
|
+
|
|
10
|
+
export const CLOUD_RUN_COMMAND = 'TOKENMAXXING_CLAUDE_BIN="$HOME/.local/bin/claude" "$HOME/.bun/bin/bun" "$HOME/.bun/bin/tokenmaxxing" cloud run';
|
|
11
|
+
|
|
12
|
+
export const RELAY_AGENT = `---
|
|
13
|
+
name: claude
|
|
14
|
+
description: Runs a task in Claude Code on the owner's subscription accounts. Use proactively for substantial implementation, debugging, or research work that should not consume the parent's context.
|
|
15
|
+
model: inherit
|
|
16
|
+
readonly: false
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
You are a thin forwarding wrapper around Claude Code.
|
|
20
|
+
|
|
21
|
+
Your only job is to forward the task to the runner and return its output. Do not do anything else.
|
|
22
|
+
|
|
23
|
+
Rules:
|
|
24
|
+
|
|
25
|
+
- Use exactly one shell call. Pass the task on stdin through a quoted heredoc. Pick the delimiter fresh for every call as \`TASK_\` followed by eight random letters and digits, and check that no line of the task equals it; a quoted heredoc ends only on a line that matches the delimiter exactly, so the task text can never become shell commands.
|
|
26
|
+
|
|
27
|
+
\`\`\`sh
|
|
28
|
+
${CLOUD_RUN_COMMAND} <<'TASK_k3v9qx2m'
|
|
29
|
+
<task>
|
|
30
|
+
TASK_k3v9qx2m
|
|
31
|
+
\`\`\`
|
|
32
|
+
|
|
33
|
+
- When the task continues earlier Claude Code work, add \`--session <id>\` after \`cloud run\` with the session id from the previous output.
|
|
34
|
+
- Preserve the task text as given. Do not inspect the repository, read files, run other commands, or summarize.
|
|
35
|
+
- Return the command's stdout exactly as-is, including the trailing \`session <id>\` line.
|
|
36
|
+
- If the command fails, return its stderr as-is.
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const ENVIRONMENT_JSON = JSON.stringify({ install: CLOUD_INSTALL_LINE }, null, 2) + "\n";
|
|
40
|
+
const ExistingEnvironmentSchema = z.looseObject({ user: z.string().optional() });
|
|
41
|
+
|
|
42
|
+
const REPO_FILE_MODE = 0o644;
|
|
43
|
+
const USAGE = "usage: tokenmaxxing cursor init [dir]";
|
|
44
|
+
|
|
45
|
+
export function cmdCursorInit(args: string[], json = false): number {
|
|
46
|
+
if (args.length > 1) {
|
|
47
|
+
emitError({ json, message: USAGE });
|
|
48
|
+
return 2;
|
|
49
|
+
}
|
|
50
|
+
const dir = resolve(args[0] ?? ".");
|
|
51
|
+
if (!existsSync(dir)) {
|
|
52
|
+
emitError({ json, message: `no such directory: ${dir}` });
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const agentFile = join(dir, ".cursor", "agents", "claude.md");
|
|
56
|
+
const environmentFile = join(dir, ".cursor", "environment.json");
|
|
57
|
+
const existing = existsSync(environmentFile) ? ExistingEnvironmentSchema.parse(Bun.JSONC.parse(readFileSync(environmentFile, "utf8"))) : null;
|
|
58
|
+
const agent = existsSync(agentFile) && readFileSync(agentFile, "utf8") === RELAY_AGENT ? "unchanged" : "written";
|
|
59
|
+
if (agent === "written") writeFileAtomic(agentFile, RELAY_AGENT, REPO_FILE_MODE);
|
|
60
|
+
const environment = existing == null ? "written" : "exists";
|
|
61
|
+
const warnings: string[] = [];
|
|
62
|
+
if (existing == null) {
|
|
63
|
+
writeFileAtomic(environmentFile, ENVIRONMENT_JSON, REPO_FILE_MODE);
|
|
64
|
+
} else if (existing.user === "root" || existing.user === "0") {
|
|
65
|
+
warnings.push(".cursor/environment.json sets user to root; Claude Code refuses --dangerously-skip-permissions under root, so every relay call fails until user is a non-root account");
|
|
66
|
+
}
|
|
67
|
+
if (json) {
|
|
68
|
+
emitJson({ ok: true, dir, agent, environment, install: CLOUD_INSTALL_LINE, warnings });
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
console.log(`${c.green("✓")} .cursor/agents/claude.md ${agent}`);
|
|
72
|
+
if (environment === "written") {
|
|
73
|
+
console.log(`${c.green("✓")} .cursor/environment.json written`);
|
|
74
|
+
} else {
|
|
75
|
+
console.log(c.yellow("⚠ .cursor/environment.json exists - left untouched; add this to its install command:"));
|
|
76
|
+
console.log(` ${CLOUD_INSTALL_LINE}`);
|
|
77
|
+
}
|
|
78
|
+
for (const warning of warnings) console.log(c.yellow(`⚠ ${warning}`));
|
|
79
|
+
console.log(c.dim("next: commit both files, add the TOKENMAXXING_TOKENS user-scoped Runtime Secret at cursor.com/dashboard/cloud-agents, and start a cloud agent on the repo"));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
|
7
7
|
import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
|
|
8
8
|
import { isAccessTokenExpiring, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
|
|
9
9
|
import { CredentialBlobSchema, type TokenIdentity } from "../lib/types.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { SETUP_TOKEN_STALE_MS, loadSetupTokens } from "../lib/setuptokens.ts";
|
|
11
|
+
import { c, emitJson, fmtAgo } from "./render.ts";
|
|
11
12
|
|
|
12
13
|
async function blobIdentity(raw: string): Promise<TokenIdentity | null> {
|
|
13
14
|
const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
|
|
@@ -77,6 +78,15 @@ export async function cmdDoctor(json = false): Promise<number> {
|
|
|
77
78
|
if (a.needsReauth) check(false, `${a.email} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
if (existsSync(paths.setupTokensJson)) {
|
|
82
|
+
const setupTokens = loadSetupTokens().tokens;
|
|
83
|
+
for (const a of idx.accounts) {
|
|
84
|
+
const token = setupTokens.find((t) => t.accountUuid === a.accountUuid);
|
|
85
|
+
if (!token) warn(`no setup token stored for ${a.label} - run \`tokenmaxxing setup-token\` to mint one for Cursor Cloud`);
|
|
86
|
+
else if (Date.now() - token.mintedAt > SETUP_TOKEN_STALE_MS) warn(`the setup token for ${a.label} was minted ${fmtAgo(token.mintedAt)} and expires a year after minting - \`tokenmaxxing setup-token rm ${a.label}\` then \`tokenmaxxing setup-token\` re-mints it`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
const cfg = loadConfig();
|
|
81
91
|
check(!!cfg.claudeBin && existsSync(cfg.claudeBin), "real claude binary resolved", "set claudeBin in config.json");
|
|
82
92
|
if (cfg.claudeBin && existsSync(cfg.claudeBin)) {
|
package/src/cli/rm.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "
|
|
|
4
4
|
import { fetchTokenIdentity } from "../lib/oauth.ts";
|
|
5
5
|
import { withLock } from "../lib/lock.ts";
|
|
6
6
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
7
|
+
import { loadSetupTokens, saveSetupTokens } from "../lib/setuptokens.ts";
|
|
7
8
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
8
9
|
import { CredentialBlobSchema } from "../lib/types.ts";
|
|
9
10
|
import { findAccount } from "./rename.ts";
|
|
@@ -43,6 +44,7 @@ export async function cmdRm(selector?: string, json = false): Promise<number> {
|
|
|
43
44
|
return 1;
|
|
44
45
|
}
|
|
45
46
|
}
|
|
47
|
+
const setupTokens = loadSetupTokens();
|
|
46
48
|
await deleteItem(parkedTarget(a.keychainItem));
|
|
47
49
|
const item = credItemFor(a.accountUuid);
|
|
48
50
|
for (const sampleDir of [join(paths.sampleDir, item), join(paths.sampleDir, `${item}-tick`)]) {
|
|
@@ -51,6 +53,9 @@ export async function cmdRm(selector?: string, json = false): Promise<number> {
|
|
|
51
53
|
}
|
|
52
54
|
idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
|
|
53
55
|
saveAccounts(idx);
|
|
56
|
+
if (setupTokens.tokens.some((t) => t.accountUuid === a.accountUuid)) {
|
|
57
|
+
saveSetupTokens({ ...setupTokens, tokens: setupTokens.tokens.filter((t) => t.accountUuid !== a.accountUuid) });
|
|
58
|
+
}
|
|
54
59
|
if (json) emitJson({ ok: true, pool: "claude", removed: a.label, remaining: idx.accounts.length });
|
|
55
60
|
else console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
|
|
56
61
|
return 0;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
4
|
+
import { withLock } from "../lib/lock.ts";
|
|
5
|
+
import { paths } from "../lib/paths.ts";
|
|
6
|
+
import { loadAccounts } from "../lib/state.ts";
|
|
7
|
+
import { scrubCredentialEnv } from "../lib/usage.ts";
|
|
8
|
+
import { CURSOR_SECRET_VALUE_CAP_BYTES, cursorSecretValue, loadSetupTokens, saveSetupTokens, type SetupToken } from "../lib/setuptokens.ts";
|
|
9
|
+
import type { Account } from "../lib/types.ts";
|
|
10
|
+
import { c, emitError, emitJson } from "./render.ts";
|
|
11
|
+
|
|
12
|
+
const TOKEN_BEGIN_MARKER = "Your OAuth token (valid for";
|
|
13
|
+
const TOKEN_END_MARKER = "Store this token securely";
|
|
14
|
+
const USAGE = "usage: tokenmaxxing setup-token [--print] | tokenmaxxing setup-token rm <label|uuid>";
|
|
15
|
+
|
|
16
|
+
export function extractSetupToken(output: string): string | null {
|
|
17
|
+
const lines = Bun.stripANSI(output).split("\n").map((line) => line.trim());
|
|
18
|
+
const end = lines.findLastIndex((line) => line.includes(TOKEN_END_MARKER));
|
|
19
|
+
if (end < 0) return null;
|
|
20
|
+
const begin = lines.slice(0, end).findLastIndex((line) => line.includes(TOKEN_BEGIN_MARKER));
|
|
21
|
+
if (begin < 0) return null;
|
|
22
|
+
const token = lines.slice(begin + 1, end).join("");
|
|
23
|
+
return token === "" ? null : token;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function mint(input: { real: string; item: string }): Promise<string | null> {
|
|
27
|
+
const configDir = join(paths.setupTokenDir, input.item);
|
|
28
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
29
|
+
mkdirSync(configDir, { recursive: true });
|
|
30
|
+
const env = scrubCredentialEnv({ ...process.env, CLAUDE_CONFIG_DIR: configDir });
|
|
31
|
+
const child = Bun.spawn([input.real, "setup-token"], { env, stdin: "inherit", stdout: "pipe", stderr: "inherit" });
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
let captured = "";
|
|
34
|
+
for await (const chunk of child.stdout) {
|
|
35
|
+
process.stdout.write(chunk);
|
|
36
|
+
captured += decoder.decode(chunk, { stream: true });
|
|
37
|
+
}
|
|
38
|
+
await child.exited;
|
|
39
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
40
|
+
if (child.exitCode !== 0) {
|
|
41
|
+
console.error(c.red(`claude setup-token exited ${child.exitCode ?? "on signal"} - nothing stored`));
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const captureToken = extractSetupToken(captured);
|
|
45
|
+
if (captureToken) return captureToken;
|
|
46
|
+
const pasted = prompt("the token line was not captured from the output above - paste the token here:")?.trim() ?? "";
|
|
47
|
+
if (pasted === "") {
|
|
48
|
+
console.error(c.red("no token pasted - nothing stored"));
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return pasted;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function currentTokens(tokens: SetupToken[], accounts: Account[]): { label: string; token: string }[] {
|
|
55
|
+
return accounts.flatMap((a) => {
|
|
56
|
+
const stored = tokens.find((t) => t.accountUuid === a.accountUuid);
|
|
57
|
+
return stored ? [{ label: a.label, token: stored.token }] : [];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function rmToken(selector: string, json: boolean): Promise<number> {
|
|
62
|
+
const outcome = await withLock(paths.lockFile, async (): Promise<{ error: string } | { kept: SetupToken[] }> => {
|
|
63
|
+
const store = loadSetupTokens();
|
|
64
|
+
const accounts = loadAccounts().accounts;
|
|
65
|
+
const pooled = accounts.find((a) => a.label === selector);
|
|
66
|
+
const orphan = (t: SetupToken) => !accounts.some((a) => a.accountUuid === t.accountUuid);
|
|
67
|
+
const targets = store.tokens.filter((t) =>
|
|
68
|
+
t.accountUuid === selector || (pooled ? t.accountUuid === pooled.accountUuid : t.label === selector && orphan(t)),
|
|
69
|
+
);
|
|
70
|
+
if (targets.length === 0) return { error: `no setup token stored for "${selector}"` };
|
|
71
|
+
if (targets.length > 1) return { error: `"${selector}" matches ${targets.length} stored tokens (${targets.map((t) => `${t.label} ${t.accountUuid}`).join(", ")}) - nothing removed` };
|
|
72
|
+
const kept = store.tokens.filter((t) => t !== targets[0]);
|
|
73
|
+
saveSetupTokens({ ...store, tokens: kept });
|
|
74
|
+
return { kept };
|
|
75
|
+
});
|
|
76
|
+
if ("error" in outcome) {
|
|
77
|
+
emitError({ json, message: outcome.error });
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const remaining = outcome.kept;
|
|
81
|
+
if (json) {
|
|
82
|
+
emitJson({ ok: true, removed: selector, remaining: remaining.map((t) => t.label) });
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
console.log(`removed the setup token for ${selector} (local only: Claude Code documents no revocation for setup tokens)`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function cmdSetupToken(args: string[], json = false): Promise<number> {
|
|
90
|
+
const [sub, label] = args;
|
|
91
|
+
if (sub === "rm" && label !== undefined && args.length === 2) return rmToken(label, json);
|
|
92
|
+
const print = sub === "--print" && args.length === 1;
|
|
93
|
+
if (args.length > 0 && !print) {
|
|
94
|
+
emitError({ json, message: USAGE });
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
if (json && !print) {
|
|
98
|
+
emitError({ json, message: "setup-token mints through a browser login flow and has no --json form; `setup-token --print --json` prints the stored set" });
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
let store = loadSetupTokens();
|
|
102
|
+
let idx = loadAccounts();
|
|
103
|
+
if (!print) {
|
|
104
|
+
if (idx.accounts.length === 0) {
|
|
105
|
+
emitError({ json, message: "no accounts in the pool - run `tokenmaxxing init` first" });
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
const real = resolveRealClaude();
|
|
109
|
+
const missing = idx.accounts.filter((a) => !store.tokens.some((t) => t.accountUuid === a.accountUuid));
|
|
110
|
+
if (missing.length === 0) console.log(c.dim("every pooled account already has a stored setup token (`setup-token rm <label>` drops one so it can be re-minted)"));
|
|
111
|
+
for (const a of missing) {
|
|
112
|
+
console.log();
|
|
113
|
+
console.log(`${c.bold(a.label)} - sign into this account in the browser (${a.email})`);
|
|
114
|
+
const token = await mint({ real, item: a.keychainItem });
|
|
115
|
+
if (!token) return 1;
|
|
116
|
+
const stored = await withLock(paths.lockFile, async () => {
|
|
117
|
+
if (!loadAccounts().accounts.some((x) => x.accountUuid === a.accountUuid)) return false;
|
|
118
|
+
const fresh = loadSetupTokens();
|
|
119
|
+
const tokens = fresh.tokens.filter((t) => t.accountUuid !== a.accountUuid);
|
|
120
|
+
saveSetupTokens({ ...fresh, tokens: [...tokens, { accountUuid: a.accountUuid, label: a.label, token, mintedAt: Date.now() }] });
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
if (!stored) {
|
|
124
|
+
console.log(c.yellow(`${a.label} left the pool during the login - token discarded`));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
console.log(`${c.green("✓")} stored the setup token for ${c.bold(a.label)} ${c.dim("(ownership not verified: an inference-only token cannot read the profile endpoint, so the label is the sign-in you chose)")}`);
|
|
128
|
+
}
|
|
129
|
+
store = loadSetupTokens();
|
|
130
|
+
idx = loadAccounts();
|
|
131
|
+
}
|
|
132
|
+
if (store.tokens.length === 0) {
|
|
133
|
+
emitError({ json, message: "no setup tokens stored - run `tokenmaxxing setup-token` to mint them" });
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const tokens = currentTokens(store.tokens, idx.accounts);
|
|
137
|
+
const orphaned = store.tokens
|
|
138
|
+
.filter((t) => !idx.accounts.some((a) => a.accountUuid === t.accountUuid))
|
|
139
|
+
.map((t) => ({ label: t.label, accountUuid: t.accountUuid }));
|
|
140
|
+
if (tokens.length === 0) {
|
|
141
|
+
emitError({ json, message: "no stored setup token belongs to a pooled account - run `tokenmaxxing setup-token` to mint them" });
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
const secret = cursorSecretValue(tokens);
|
|
145
|
+
const bytes = Buffer.byteLength(secret);
|
|
146
|
+
if (json) {
|
|
147
|
+
emitJson({ ok: true, tokens, orphaned, bytes, cap: CURSOR_SECRET_VALUE_CAP_BYTES });
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
console.log();
|
|
151
|
+
if (orphaned.length > 0) {
|
|
152
|
+
console.log(c.dim(`left out of the secret (no longer in the pool): ${orphaned.map((t) => `${t.label} (${t.accountUuid})`).join(", ")}; \`setup-token rm <label|uuid>\` drops one`));
|
|
153
|
+
}
|
|
154
|
+
console.log(`${c.bold("TOKENMAXXING_TOKENS")} ${c.dim("(user-scoped Runtime Secret at cursor.com/dashboard/cloud-agents)")}`);
|
|
155
|
+
console.log(secret);
|
|
156
|
+
if (bytes > CURSOR_SECRET_VALUE_CAP_BYTES) {
|
|
157
|
+
console.log(c.yellow(`⚠ ${bytes} bytes: over the ${CURSOR_SECRET_VALUE_CAP_BYTES}-byte value cap Cursor documents for API-created environment variables`));
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
package/src/cli/status.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
|
|
|
15
15
|
import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
|
|
16
16
|
|
|
17
17
|
const SampleReportSchema = z.discriminatedUnion("ok", [
|
|
18
|
-
z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe"]) }),
|
|
18
|
+
z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe", "cached"]) }),
|
|
19
19
|
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
20
20
|
]);
|
|
21
21
|
|
|
@@ -31,6 +31,7 @@ const ClaudeStatusAccountSchema = z.object({
|
|
|
31
31
|
usage: z.object({ fiveHour: UsageWindowSchema, week: UsageWindowSchema }).nullable(),
|
|
32
32
|
perModel: z.record(z.string(), UsageWindowSchema),
|
|
33
33
|
usageAt: z.number().nullable(),
|
|
34
|
+
perModelAt: z.number().nullable(),
|
|
34
35
|
sample: SampleReportSchema,
|
|
35
36
|
});
|
|
36
37
|
type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
|
|
@@ -83,11 +84,11 @@ function currentCodexWindow(w: CodexWindow, now: number): CodexWindow {
|
|
|
83
84
|
return { ...currentWindow(w, !isSessionWindow({ window: w }), now), windowSeconds: w.windowSeconds };
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
async function collectClaude(input: { cfg: Config; now: number }): Promise<StatusReport["claude"]> {
|
|
87
|
-
const { cfg, now } = input;
|
|
87
|
+
async function collectClaude(input: { cfg: Config; now: number; cached: boolean }): Promise<StatusReport["claude"]> {
|
|
88
|
+
const { cfg, now, cached } = input;
|
|
88
89
|
let idx = loadAccounts();
|
|
89
90
|
const samples = new Map<string, { outcome: SampleOutcome; viaTee: boolean }>();
|
|
90
|
-
if (idx.accounts.length > 0) {
|
|
91
|
+
if (idx.accounts.length > 0 && !cached) {
|
|
91
92
|
await withLock(paths.lockFile, async () => {
|
|
92
93
|
idx = loadAccounts();
|
|
93
94
|
console.error(c.dim("sampling live usage..."));
|
|
@@ -137,11 +138,11 @@ async function collectClaude(input: { cfg: Config; now: number }): Promise<Statu
|
|
|
137
138
|
|
|
138
139
|
const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
|
|
139
140
|
const bars = thresholdBars(cfg);
|
|
140
|
-
const liveAccount = readOAuthAccount()?.accountUuid ?? null;
|
|
141
|
+
const liveAccount = cached ? idx.activeAccountUuid : (readOAuthAccount()?.accountUuid ?? null);
|
|
141
142
|
const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
142
143
|
const accounts = ordered.map((a): ClaudeStatusAccount => {
|
|
143
|
-
const sampled = samples.get(a.accountUuid)
|
|
144
|
-
const usage = sampled
|
|
144
|
+
const sampled = samples.get(a.accountUuid);
|
|
145
|
+
const usage = sampled?.outcome.ok ? sampled.outcome.usage : undefined;
|
|
145
146
|
const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
|
|
146
147
|
const perModel = usage ? usage.perModel : (a.lastPerModel ?? {});
|
|
147
148
|
return {
|
|
@@ -156,7 +157,14 @@ async function collectClaude(input: { cfg: Config; now: number }): Promise<Statu
|
|
|
156
157
|
usage: aggregate ? { fiveHour: currentWindow(aggregate.fiveHour, false, now), week: currentWindow(aggregate.sevenDay, true, now) } : null,
|
|
157
158
|
perModel: Object.fromEntries(Object.entries(perModel).map(([name, w]) => [name, currentWindow(w, true, now)])),
|
|
158
159
|
usageAt: a.lastUsageAt ?? null,
|
|
159
|
-
|
|
160
|
+
perModelAt: a.lastPerModelAt ?? null,
|
|
161
|
+
sample: sampled
|
|
162
|
+
? sampled.outcome.ok
|
|
163
|
+
? { ok: true, source: sampled.viaTee ? "statusline" : "probe" }
|
|
164
|
+
: { ok: false, reason: sampled.outcome.reason }
|
|
165
|
+
: cached
|
|
166
|
+
? { ok: true, source: "cached" }
|
|
167
|
+
: { ok: false, reason: "not sampled" },
|
|
160
168
|
};
|
|
161
169
|
});
|
|
162
170
|
return {
|
|
@@ -167,34 +175,38 @@ async function collectClaude(input: { cfg: Config; now: number }): Promise<Statu
|
|
|
167
175
|
};
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
async function collectCodex(input: { cfg: Config; now: number }): Promise<StatusReport["codex"]> {
|
|
171
|
-
const { cfg, now } = input;
|
|
178
|
+
async function collectCodex(input: { cfg: Config; now: number; cached: boolean }): Promise<StatusReport["codex"]> {
|
|
179
|
+
const { cfg, now, cached } = input;
|
|
172
180
|
const bars = thresholdBars(cfg);
|
|
173
181
|
let index = loadCodexAccounts();
|
|
174
182
|
if (index.accounts.length === 0) return { bars, accounts: [] };
|
|
175
183
|
|
|
176
|
-
console.error(c.dim("sampling codex usage..."));
|
|
177
184
|
const outcomes = new Map<string, CodexSampleOutcome>();
|
|
178
185
|
let liveId: string | null = null;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (outcome.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
186
|
+
if (cached) {
|
|
187
|
+
liveId = index.activeAccountId;
|
|
188
|
+
} else {
|
|
189
|
+
console.error(c.dim("sampling codex usage..."));
|
|
190
|
+
await withLock(codexPaths.lockFile, async () => {
|
|
191
|
+
index = loadCodexAccounts();
|
|
192
|
+
liveId = liveCodexAccountId();
|
|
193
|
+
await Promise.all(
|
|
194
|
+
index.accounts.map(async (account) => {
|
|
195
|
+
const outcome = await sampleCodexAccount({ account, liveAccountId: liveId, now });
|
|
196
|
+
outcomes.set(account.accountId, outcome);
|
|
197
|
+
if (outcome.ok) {
|
|
198
|
+
account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit };
|
|
199
|
+
account.lastUsageAt = Date.now();
|
|
200
|
+
if (outcome.usage.email != null) account.email = outcome.usage.email;
|
|
201
|
+
if (outcome.usage.planType != null) account.planType = outcome.usage.planType;
|
|
202
|
+
} else if (outcome.deadGrant) {
|
|
203
|
+
account.needsReauth = true;
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
saveCodexAccounts({ index });
|
|
208
|
+
});
|
|
209
|
+
}
|
|
198
210
|
|
|
199
211
|
const ordered = sortBy(index.accounts, [
|
|
200
212
|
(a) => (a.needsReauth ? 1 : 0),
|
|
@@ -205,7 +217,7 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
|
|
|
205
217
|
},
|
|
206
218
|
]);
|
|
207
219
|
const accounts = ordered.map((account): CodexStatusAccount => {
|
|
208
|
-
const outcome = outcomes.get(account.accountId)
|
|
220
|
+
const outcome = outcomes.get(account.accountId);
|
|
209
221
|
const usage = account.lastUsage;
|
|
210
222
|
return {
|
|
211
223
|
label: account.label,
|
|
@@ -224,7 +236,13 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
|
|
|
224
236
|
}
|
|
225
237
|
: null,
|
|
226
238
|
usageAt: account.lastUsageAt ?? null,
|
|
227
|
-
sample: outcome
|
|
239
|
+
sample: outcome
|
|
240
|
+
? outcome.ok
|
|
241
|
+
? { ok: true, source: "probe" }
|
|
242
|
+
: { ok: false, reason: outcome.reason }
|
|
243
|
+
: cached
|
|
244
|
+
? { ok: true, source: "cached" }
|
|
245
|
+
: { ok: false, reason: "not sampled" },
|
|
228
246
|
};
|
|
229
247
|
});
|
|
230
248
|
return { bars, accounts };
|
|
@@ -298,6 +316,10 @@ function sampleFailedNotes(input: { cached: boolean; usageAt: number | null; rea
|
|
|
298
316
|
];
|
|
299
317
|
}
|
|
300
318
|
|
|
319
|
+
function cachedNote(usageAt: number | null, now: number): Note {
|
|
320
|
+
return { paint: c.dim, text: usageAt != null ? `cached ${fmtAgo(usageAt, now)}` : "never sampled" };
|
|
321
|
+
}
|
|
322
|
+
|
|
301
323
|
function headerLine(input: { active: boolean; needsReauth: boolean; exhausted: boolean; name: string; tier: string | null }): string {
|
|
302
324
|
const marker = input.active ? c.green("●") : c.dim("○");
|
|
303
325
|
const badges: string[] = [];
|
|
@@ -317,7 +339,11 @@ function codexCard(account: CodexStatusAccount, now: number): Card {
|
|
|
317
339
|
for (const window of windows) lines.push(usageRow(codexLimitLabel({ limitName: name }), window));
|
|
318
340
|
}
|
|
319
341
|
}
|
|
320
|
-
const notes = account.sample.ok
|
|
342
|
+
const notes = account.sample.ok
|
|
343
|
+
? account.sample.source === "cached"
|
|
344
|
+
? [cachedNote(account.usageAt, now)]
|
|
345
|
+
: []
|
|
346
|
+
: sampleFailedNotes({ cached: account.usage != null, usageAt: account.usageAt, reason: account.sample.reason, now });
|
|
321
347
|
return { lines, notes };
|
|
322
348
|
}
|
|
323
349
|
|
|
@@ -342,6 +368,11 @@ function claudeCard(a: ClaudeStatusAccount, now: number, staleAfterMs: number):
|
|
|
342
368
|
const age = a.usageAt != null ? fmtAgo(a.usageAt, now) : "age unknown";
|
|
343
369
|
notes.push({ paint: stale ? c.yellow : c.dim, text: `statusline tee ${age}${stale ? " (stale)" : ""}` });
|
|
344
370
|
}
|
|
371
|
+
if (a.sample.ok && a.sample.source === "cached") {
|
|
372
|
+
const note = cachedNote(a.usageAt, now);
|
|
373
|
+
const perModelAged = Object.keys(a.perModel).length > 0 && a.perModelAt !== a.usageAt;
|
|
374
|
+
notes.push(perModelAged ? { ...note, text: `${note.text}, per-model ${a.perModelAt != null ? fmtAgo(a.perModelAt, now) : "age unknown"}` } : note);
|
|
375
|
+
}
|
|
345
376
|
if (!a.sample.ok) {
|
|
346
377
|
notes.push(...sampleFailedNotes({ cached: a.usage != null || Object.keys(a.perModel).length > 0, usageAt: a.usageAt, reason: a.sample.reason, now }));
|
|
347
378
|
}
|
|
@@ -365,17 +396,15 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
|
|
|
365
396
|
renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
|
|
366
397
|
}
|
|
367
398
|
|
|
368
|
-
export async function cmdStatus(opts: { json?: boolean;
|
|
369
|
-
const { json = false } = opts;
|
|
399
|
+
export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {}): Promise<number> {
|
|
400
|
+
const { json = false, cached = false } = opts;
|
|
370
401
|
const cfg = loadConfig();
|
|
371
402
|
const now = Date.now();
|
|
372
|
-
const claude = await collectClaude({ cfg, now });
|
|
403
|
+
const claude = await collectClaude({ cfg, now, cached });
|
|
373
404
|
if (!json) {
|
|
374
|
-
|
|
375
|
-
const at = Date.now();
|
|
376
|
-
renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: at, staleAfterMs: cfg.policy.usagePollTtlMs });
|
|
405
|
+
renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: Date.now(), staleAfterMs: cfg.policy.usagePollTtlMs });
|
|
377
406
|
}
|
|
378
|
-
const codex = await collectCodex({ cfg, now });
|
|
407
|
+
const codex = await collectCodex({ cfg, now, cached });
|
|
379
408
|
if (json) {
|
|
380
409
|
const report: StatusReport = { now, claude, codex };
|
|
381
410
|
emitJson({ ok: true, ...report });
|
package/src/lib/cloud.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { minBy } from "es-toolkit";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { paths } from "./paths.ts";
|
|
6
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
7
|
+
import { UNMANAGED_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
8
|
+
import { pidExists } from "./proc.ts";
|
|
9
|
+
import { CloudTokenSchema, CloudTokensSchema, type CloudToken } from "./setuptokens.ts";
|
|
10
|
+
import { normalizeResetsAt, parseErrorBody, readTranscriptTail, scrubCredentialEnv } from "./usage.ts";
|
|
11
|
+
|
|
12
|
+
export const TOKENS_ENV = "TOKENMAXXING_TOKENS";
|
|
13
|
+
export const FALLBACK_WALL_MS = 5 * 3_600_000;
|
|
14
|
+
const RESERVATION_PREFIX = "pending:";
|
|
15
|
+
|
|
16
|
+
export function readCloudTokens(): CloudToken[] {
|
|
17
|
+
const raw = process.env[TOKENS_ENV];
|
|
18
|
+
if (raw == null || raw === "") {
|
|
19
|
+
throw new Error(`${TOKENS_ENV} is not set - add it as a user-scoped Runtime Secret with the value \`tokenmaxxing setup-token --print\` prints`);
|
|
20
|
+
}
|
|
21
|
+
let json: unknown;
|
|
22
|
+
try {
|
|
23
|
+
json = JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`${TOKENS_ENV} is not valid JSON`);
|
|
26
|
+
}
|
|
27
|
+
const parsed = CloudTokensSchema.safeParse(json);
|
|
28
|
+
if (!parsed.success) throw new Error(`${TOKENS_ENV} must be a non-empty JSON array of {label, token} objects`);
|
|
29
|
+
const labels = parsed.data.map((t) => t.label);
|
|
30
|
+
if (new Set(labels).size !== labels.length) throw new Error(`${TOKENS_ENV} carries a duplicate label`);
|
|
31
|
+
const reserved = labels.find((label) => label in {});
|
|
32
|
+
if (reserved != null) throw new Error(`${TOKENS_ENV} carries the label "${reserved}", which is a reserved object property name`);
|
|
33
|
+
return parsed.data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SessionsSchema = z.record(z.string(), z.string());
|
|
37
|
+
const WalledSchema = z.record(z.string(), z.number());
|
|
38
|
+
|
|
39
|
+
function loadState<T>(file: string, schema: z.ZodType<T>, empty: T): T {
|
|
40
|
+
if (!existsSync(file)) return empty;
|
|
41
|
+
let json: unknown;
|
|
42
|
+
try {
|
|
43
|
+
json = JSON.parse(readFileSync(file, "utf8"));
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`${file} is corrupt (unparsable JSON) - repair or remove the file`);
|
|
46
|
+
}
|
|
47
|
+
return schema.parse(json);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadCloudSessions(): Record<string, string> {
|
|
51
|
+
const sessions = loadState(paths.cloudSessionsJson, SessionsSchema, {});
|
|
52
|
+
for (const key of Object.keys(sessions)) {
|
|
53
|
+
if (!key.startsWith(RESERVATION_PREFIX)) continue;
|
|
54
|
+
const pid = Number(key.slice(RESERVATION_PREFIX.length).split(":")[0]);
|
|
55
|
+
if (!Number.isInteger(pid) || !pidExists(pid)) delete sessions[key];
|
|
56
|
+
}
|
|
57
|
+
return sessions;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function saveCloudSessions(sessions: Record<string, string>): void {
|
|
61
|
+
writeFileAtomic(paths.cloudSessionsJson, JSON.stringify(SessionsSchema.parse(sessions), null, 2) + "\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function newReservationKey(): string {
|
|
65
|
+
return `${RESERVATION_PREFIX}${process.pid}:${crypto.randomUUID().slice(0, 8)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function loadCloudWalled(): Record<string, number> {
|
|
69
|
+
return loadState(paths.cloudWalledJson, WalledSchema, {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function saveCloudWalled(walled: Record<string, number>): void {
|
|
73
|
+
writeFileAtomic(paths.cloudWalledJson, JSON.stringify(WalledSchema.parse(walled), null, 2) + "\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PickSchema = z.discriminatedUnion("ok", [
|
|
77
|
+
z.object({ ok: z.literal(true), token: CloudTokenSchema }),
|
|
78
|
+
z.object({ ok: z.literal(false), earliestReset: z.number() }),
|
|
79
|
+
]);
|
|
80
|
+
export type Pick = z.infer<typeof PickSchema>;
|
|
81
|
+
|
|
82
|
+
export function pickToken(input: {
|
|
83
|
+
tokens: CloudToken[];
|
|
84
|
+
sessions: Record<string, string>;
|
|
85
|
+
walled: Record<string, number>;
|
|
86
|
+
sessionId: string | null;
|
|
87
|
+
now: number;
|
|
88
|
+
}): Pick {
|
|
89
|
+
const walled = new Map(Object.entries(input.walled));
|
|
90
|
+
const sessions = new Map(Object.entries(input.sessions));
|
|
91
|
+
const open = (label: string) => (walled.get(label) ?? 0) <= input.now;
|
|
92
|
+
if (input.sessionId != null) {
|
|
93
|
+
const pinned = sessions.get(input.sessionId);
|
|
94
|
+
const token = input.tokens.find((t) => t.label === pinned);
|
|
95
|
+
if (token && open(token.label)) return { ok: true, token };
|
|
96
|
+
}
|
|
97
|
+
const candidates = input.tokens.filter((t) => open(t.label));
|
|
98
|
+
if (candidates.length === 0) {
|
|
99
|
+
return { ok: false, earliestReset: Math.min(...input.tokens.map((t) => walled.get(t.label) ?? input.now)) };
|
|
100
|
+
}
|
|
101
|
+
const load = new Map<string, number>();
|
|
102
|
+
for (const label of sessions.values()) load.set(label, (load.get(label) ?? 0) + 1);
|
|
103
|
+
return { ok: true, token: minBy(candidates, (t) => load.get(t.label) ?? 0) ?? candidates[0]! };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ResultBase = {
|
|
107
|
+
type: z.literal("result"),
|
|
108
|
+
session_id: z.string(),
|
|
109
|
+
is_error: z.boolean(),
|
|
110
|
+
num_turns: z.number(),
|
|
111
|
+
total_cost_usd: z.number(),
|
|
112
|
+
};
|
|
113
|
+
const CloudResultSchema = z.discriminatedUnion("subtype", [
|
|
114
|
+
z.looseObject({ ...ResultBase, subtype: z.literal("success"), result: z.string(), api_error_status: z.number().nullish() }),
|
|
115
|
+
z.looseObject({
|
|
116
|
+
...ResultBase,
|
|
117
|
+
subtype: z.enum(["error_max_turns", "error_during_execution", "error_max_budget_usd", "error_max_structured_output_retries"]),
|
|
118
|
+
errors: z.array(z.string()),
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
export type CloudResult = z.infer<typeof CloudResultSchema>;
|
|
122
|
+
|
|
123
|
+
export async function spawnCloudClaude(input: {
|
|
124
|
+
token: string;
|
|
125
|
+
prompt: string;
|
|
126
|
+
resume: string | null;
|
|
127
|
+
maxTurns: number | null;
|
|
128
|
+
}): Promise<{ exitCode: number | null; result: CloudResult }> {
|
|
129
|
+
const args = [
|
|
130
|
+
"-p", "--output-format", "json", "--dangerously-skip-permissions",
|
|
131
|
+
...(input.resume ? ["--resume", input.resume] : []),
|
|
132
|
+
...(input.maxTurns != null ? ["--max-turns", String(input.maxTurns)] : []),
|
|
133
|
+
"--", input.prompt,
|
|
134
|
+
];
|
|
135
|
+
const env: Record<string, string> = { ...scrubCredentialEnv({ ...process.env, [UNMANAGED_ENV]: "1" }), CLAUDE_CODE_OAUTH_TOKEN: input.token };
|
|
136
|
+
delete env[TOKENS_ENV];
|
|
137
|
+
const child = Bun.spawn([resolveRealClaude(), ...args], { env, stdin: "ignore", stdout: "pipe", stderr: "inherit" });
|
|
138
|
+
const stdout = await new Response(child.stdout).text();
|
|
139
|
+
await child.exited;
|
|
140
|
+
let json: unknown;
|
|
141
|
+
try {
|
|
142
|
+
json = JSON.parse(stdout);
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error(`claude exited ${child.exitCode ?? "on signal"} without a JSON result: ${stdout.trim().slice(0, 200)}`);
|
|
145
|
+
}
|
|
146
|
+
const parsed = CloudResultSchema.safeParse(json);
|
|
147
|
+
if (!parsed.success) {
|
|
148
|
+
throw new Error(`claude exited ${child.exitCode ?? "on signal"} with a result that does not match the documented result message: ${stdout.trim().slice(0, 200)}`);
|
|
149
|
+
}
|
|
150
|
+
return { exitCode: child.exitCode, result: parsed.data };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function cloudTranscriptPath(sessionId: string): string | null {
|
|
154
|
+
const projects = join(paths.claudeDir, "projects");
|
|
155
|
+
if (!existsSync(projects)) return null;
|
|
156
|
+
for (const dir of readdirSync(projects)) {
|
|
157
|
+
const candidate = join(projects, dir, `${sessionId}.jsonl`);
|
|
158
|
+
if (existsSync(candidate)) return candidate;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function transcriptBytes(sessionId: string | null): number {
|
|
164
|
+
const transcript = sessionId ? cloudTranscriptPath(sessionId) : null;
|
|
165
|
+
return transcript ? statSync(transcript).size : 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function limitWallUntil(input: { sessionId: string; sinceBytes: number; now: number }): number | null {
|
|
169
|
+
const transcript = cloudTranscriptPath(input.sessionId);
|
|
170
|
+
const rows = transcript ? readTranscriptTail(transcript, statSync(transcript).size - input.sinceBytes + 1) : [];
|
|
171
|
+
const row = rows.findLast((r) => r.isApiErrorMessage === true);
|
|
172
|
+
if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
|
|
173
|
+
if ((row.quotaLimits?.rateLimitType ?? "") === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
|
|
174
|
+
const resetsAt = row.quotaLimits?.resetsAt != null ? normalizeResetsAt(row.quotaLimits.resetsAt) : null;
|
|
175
|
+
return resetsAt != null && resetsAt > input.now ? resetsAt : input.now + FALLBACK_WALL_MS;
|
|
176
|
+
}
|
package/src/lib/paths.ts
CHANGED
|
@@ -28,6 +28,11 @@ export const paths = {
|
|
|
28
28
|
onboardDir: join(TM_HOME, "onboard"),
|
|
29
29
|
sampleDir: join(TM_HOME, "sample"),
|
|
30
30
|
credsDir: join(TM_HOME, "creds"),
|
|
31
|
+
setupTokensJson: join(TM_HOME, "setup-tokens.json"),
|
|
32
|
+
setupTokenDir: join(TM_HOME, "setup-token"),
|
|
33
|
+
cloudSessionsJson: join(TM_HOME, "cloud", "sessions.json"),
|
|
34
|
+
cloudWalledJson: join(TM_HOME, "cloud", "walled.json"),
|
|
35
|
+
cloudLockFile: join(TM_HOME, "cloud", "lock"),
|
|
31
36
|
|
|
32
37
|
claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
|
|
33
38
|
claudeSettings: env(
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { paths } from "./paths.ts";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
5
|
+
|
|
6
|
+
export const CURSOR_SECRET_VALUE_CAP_BYTES = 4096;
|
|
7
|
+
export const SETUP_TOKEN_STALE_MS = 335 * 86_400_000;
|
|
8
|
+
|
|
9
|
+
const SetupTokenSchema = z.object({ accountUuid: z.string().min(1), label: z.string().min(1), token: z.string().min(1), mintedAt: z.number() });
|
|
10
|
+
export type SetupToken = z.infer<typeof SetupTokenSchema>;
|
|
11
|
+
|
|
12
|
+
const SetupTokensFileSchema = z.object({ version: z.literal(1), tokens: z.array(SetupTokenSchema).default([]) });
|
|
13
|
+
export type SetupTokensFile = z.infer<typeof SetupTokensFileSchema>;
|
|
14
|
+
|
|
15
|
+
export const CloudTokenSchema = z.object({ label: z.string().min(1), token: z.string().min(1) });
|
|
16
|
+
export type CloudToken = z.infer<typeof CloudTokenSchema>;
|
|
17
|
+
export const CloudTokensSchema = z.array(CloudTokenSchema).min(1);
|
|
18
|
+
|
|
19
|
+
export function loadSetupTokens(): SetupTokensFile {
|
|
20
|
+
if (!existsSync(paths.setupTokensJson)) return { version: 1, tokens: [] };
|
|
21
|
+
let json: unknown;
|
|
22
|
+
try {
|
|
23
|
+
json = JSON.parse(readFileSync(paths.setupTokensJson, "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`${paths.setupTokensJson} is corrupt (unparsable JSON) - refusing to treat a damaged token store as empty; repair or remove the file`);
|
|
26
|
+
}
|
|
27
|
+
const parsed = SetupTokensFileSchema.safeParse(json);
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
throw new Error(`${paths.setupTokensJson} does not match the setup-token schema - repair or remove the file`);
|
|
30
|
+
}
|
|
31
|
+
return parsed.data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function saveSetupTokens(file: SetupTokensFile): void {
|
|
35
|
+
writeFileAtomic(paths.setupTokensJson, JSON.stringify(SetupTokensFileSchema.parse(file), null, 2) + "\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cursorSecretValue(tokens: CloudToken[]): string {
|
|
39
|
+
return JSON.stringify(CloudTokensSchema.parse(tokens));
|
|
40
|
+
}
|
package/src/lib/usage.ts
CHANGED
|
@@ -332,9 +332,14 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
332
332
|
|
|
333
333
|
const PROBE_RETRY_DELAYS_MS = [2000, 5000];
|
|
334
334
|
|
|
335
|
+
export function scrubCredentialEnv(env: Record<string, string>): Record<string, string> {
|
|
336
|
+
const scrubbed = { ...env };
|
|
337
|
+
for (const k of CRED_ENV_OVERRIDES) delete scrubbed[k];
|
|
338
|
+
return scrubbed;
|
|
339
|
+
}
|
|
340
|
+
|
|
335
341
|
function probeEnv(configDir?: string): Record<string, string> {
|
|
336
|
-
const env
|
|
337
|
-
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
342
|
+
const env = scrubCredentialEnv({ ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) });
|
|
338
343
|
if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
|
|
339
344
|
return env;
|
|
340
345
|
}
|
package/src/main.ts
CHANGED
|
@@ -15,9 +15,7 @@ import { cmdCodexInit } from "./cli/codexinit.ts";
|
|
|
15
15
|
import { cmdCodexSwitch } from "./cli/codexswitch.ts";
|
|
16
16
|
import { runCodexSupervisor } from "./entries/codexsupervisor.ts";
|
|
17
17
|
import { runCodexStopHook } from "./entries/codexstophook.ts";
|
|
18
|
-
import { cmdLs } from "./cli/ls.ts";
|
|
19
18
|
import { cmdStatus } from "./cli/status.ts";
|
|
20
|
-
import { cmdWatch } from "./cli/watch.ts";
|
|
21
19
|
import { cmdDoctor } from "./cli/doctor.ts";
|
|
22
20
|
import { cmdRm } from "./cli/rm.ts";
|
|
23
21
|
import { cmdCodexRm } from "./cli/codexrm.ts";
|
|
@@ -25,10 +23,14 @@ import { cmdRename } from "./cli/rename.ts";
|
|
|
25
23
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
26
24
|
import { cmdCheck } from "./cli/check.ts";
|
|
27
25
|
import { cmdConfig } from "./cli/config.ts";
|
|
26
|
+
import { cmdSetupToken } from "./cli/setuptoken.ts";
|
|
27
|
+
import { cmdCloudRun } from "./cli/cloudrun.ts";
|
|
28
|
+
import { cmdCursorInit } from "./cli/cursorinit.ts";
|
|
28
29
|
import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
|
|
29
30
|
import { c, emitError, emitJson } from "./cli/render.ts";
|
|
30
31
|
|
|
31
32
|
const JSON_FLAG = "--json";
|
|
33
|
+
const CACHED_FLAG = "--cached";
|
|
32
34
|
const INTERACTIVE_COMMANDS = new Set(["init", "add", "auth"]);
|
|
33
35
|
|
|
34
36
|
function printHelp(): void {
|
|
@@ -43,16 +45,17 @@ function printHelp(): void {
|
|
|
43
45
|
${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
|
|
44
46
|
${c.cyan("tokenmaxxing auth")} [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every needs-reauth account, one by one)
|
|
45
47
|
${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
|
|
46
|
-
${c.cyan("tokenmaxxing
|
|
47
|
-
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
48
|
-
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120)
|
|
48
|
+
${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
|
|
49
49
|
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
50
50
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
51
51
|
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
52
52
|
${c.cyan("tokenmaxxing rm")} [--codex] <sel>
|
|
53
53
|
${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
|
|
54
|
+
${c.cyan("tokenmaxxing setup-token")} [--print | rm <label|uuid>] Cursor Cloud only: mint one \`claude setup-token\` per pooled account (browser sign-in each) and print the TOKENMAXXING_TOKENS secret value; ${c.cyan("--print")} prints the stored set, ${c.cyan("rm")} drops one
|
|
55
|
+
${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
|
|
56
|
+
${c.cyan("tokenmaxxing cloud run")} [--session <id>] [--max-turns <n>] "<prompt>" on a Cursor Cloud VM: run claude -p on a setup token from TOKENMAXXING_TOKENS, rotate to the next token on a usage limit
|
|
54
57
|
|
|
55
|
-
${c.cyan("--json")} print one JSON document on stdout instead of text (status,
|
|
58
|
+
${c.cyan("--json")} print one JSON document on stdout instead of text (status, config, doctor, check, switch, rename, rm, uninstall, setup-token --print, cursor init, cloud run); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
|
|
56
59
|
|
|
57
60
|
${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
|
|
58
61
|
}
|
|
@@ -76,9 +79,15 @@ async function main(): Promise<number> {
|
|
|
76
79
|
|
|
77
80
|
jsonMode = argv.includes(JSON_FLAG);
|
|
78
81
|
const json = jsonMode;
|
|
79
|
-
const
|
|
82
|
+
const cached = argv.includes(CACHED_FLAG);
|
|
83
|
+
const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG);
|
|
80
84
|
const sub = args[0];
|
|
81
85
|
|
|
86
|
+
if (cached && sub != null && sub !== "status") {
|
|
87
|
+
emitError({ json, message: `${CACHED_FLAG} applies to status only, not ${sub}` });
|
|
88
|
+
return 2;
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
if (json && sub != null && INTERACTIVE_COMMANDS.has(sub)) {
|
|
83
92
|
emitError({ json, message: `${sub} is interactive (it runs a login flow) and has no --json form` });
|
|
84
93
|
return 2;
|
|
@@ -106,10 +115,10 @@ async function main(): Promise<number> {
|
|
|
106
115
|
case "status": {
|
|
107
116
|
const extra = args[1];
|
|
108
117
|
if (extra != null) {
|
|
109
|
-
emitError({ json, message: `unknown status option: ${extra} (status takes
|
|
118
|
+
emitError({ json, message: `unknown status option: ${extra} (status takes only ${CACHED_FLAG})` });
|
|
110
119
|
return 2;
|
|
111
120
|
}
|
|
112
|
-
return cmdStatus({ json });
|
|
121
|
+
return cmdStatus({ json, cached });
|
|
113
122
|
}
|
|
114
123
|
case "switch": {
|
|
115
124
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
@@ -126,14 +135,23 @@ async function main(): Promise<number> {
|
|
|
126
135
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
127
136
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
128
137
|
case "auth": return cmdAuth(args.slice(1));
|
|
129
|
-
case "ls": return cmdLs(json);
|
|
130
|
-
case "watch": return cmdWatch(args[1], json);
|
|
131
138
|
case "doctor": return cmdDoctor(json);
|
|
132
139
|
case "rm": {
|
|
133
140
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
134
141
|
return args.includes("--codex") ? cmdCodexRm(rest[0], json) : cmdRm(rest[0], json);
|
|
135
142
|
}
|
|
136
143
|
case "rename": return cmdRename(args.slice(1), json);
|
|
144
|
+
case "setup-token": return cmdSetupToken(args.slice(1), json);
|
|
145
|
+
case "cursor": {
|
|
146
|
+
if (args[1] === "init") return cmdCursorInit(args.slice(2), json);
|
|
147
|
+
emitError({ json, message: "usage: tokenmaxxing cursor init [dir]" });
|
|
148
|
+
return 2;
|
|
149
|
+
}
|
|
150
|
+
case "cloud": {
|
|
151
|
+
if (args[1] === "run") return cmdCloudRun(args.slice(2), json);
|
|
152
|
+
emitError({ json, message: 'usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"' });
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
137
155
|
case "uninstall": {
|
|
138
156
|
const out = uninstallSupervisor();
|
|
139
157
|
const removed = [
|
package/src/cli/ls.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { loadAccounts } from "../lib/state.ts";
|
|
2
|
-
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
3
|
-
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
4
|
-
import { c, claudeTierLabel, emitJson } from "./render.ts";
|
|
5
|
-
|
|
6
|
-
export function cmdLs(json = false): number {
|
|
7
|
-
const idx = loadAccounts();
|
|
8
|
-
|
|
9
|
-
if (json) {
|
|
10
|
-
const codex = loadCodexAccounts();
|
|
11
|
-
const liveId = codex.accounts.length > 0 ? liveCodexAccountId() : null;
|
|
12
|
-
emitJson({
|
|
13
|
-
ok: true,
|
|
14
|
-
claude: {
|
|
15
|
-
activeAccountUuid: idx.activeAccountUuid,
|
|
16
|
-
accounts: idx.accounts.map((a) => ({
|
|
17
|
-
label: a.label,
|
|
18
|
-
email: a.email,
|
|
19
|
-
accountUuid: a.accountUuid,
|
|
20
|
-
organizationUuid: a.organizationUuid,
|
|
21
|
-
tier: claudeTierLabel(a),
|
|
22
|
-
active: a.accountUuid === idx.activeAccountUuid,
|
|
23
|
-
needsReauth: a.needsReauth === true,
|
|
24
|
-
addedAt: a.addedAt,
|
|
25
|
-
})),
|
|
26
|
-
},
|
|
27
|
-
codex: {
|
|
28
|
-
activeAccountId: liveId,
|
|
29
|
-
accounts: codex.accounts.map((account) => ({
|
|
30
|
-
label: account.label,
|
|
31
|
-
email: account.email,
|
|
32
|
-
accountId: account.accountId,
|
|
33
|
-
planType: account.planType,
|
|
34
|
-
active: account.accountId === liveId,
|
|
35
|
-
needsReauth: account.needsReauth === true,
|
|
36
|
-
addedAt: account.addedAt,
|
|
37
|
-
})),
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
return 0;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (idx.accounts.length === 0) {
|
|
44
|
-
console.log(c.dim("no accounts yet - run `tokenmaxxing init` then `tokenmaxxing add`"));
|
|
45
|
-
}
|
|
46
|
-
for (const a of idx.accounts) {
|
|
47
|
-
const active = a.accountUuid === idx.activeAccountUuid;
|
|
48
|
-
const marker = active ? c.green("●") : c.dim("○");
|
|
49
|
-
const flags: string[] = [];
|
|
50
|
-
if (active) flags.push(c.green("active"));
|
|
51
|
-
if (a.needsReauth) flags.push(c.red("needs-reauth"));
|
|
52
|
-
const tag = flags.length ? ` ${flags.join(" ")}` : "";
|
|
53
|
-
const label = a.label || a.email;
|
|
54
|
-
console.log(`${marker} ${c.bold(label)}${tag}`);
|
|
55
|
-
console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)}, ${claudeTierLabel(a) ?? "?"}, uuid ${a.accountUuid.slice(0, 8)}`)}`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const codex = loadCodexAccounts();
|
|
59
|
-
if (codex.accounts.length > 0) {
|
|
60
|
-
const liveId = liveCodexAccountId();
|
|
61
|
-
console.log();
|
|
62
|
-
console.log(c.dim("codex"));
|
|
63
|
-
for (const account of codex.accounts) {
|
|
64
|
-
const active = account.accountId === liveId;
|
|
65
|
-
const marker = active ? c.green("●") : c.dim("○");
|
|
66
|
-
const flags: string[] = [];
|
|
67
|
-
if (active) flags.push(c.green("active"));
|
|
68
|
-
if (account.needsReauth) flags.push(c.red("needs-reauth"));
|
|
69
|
-
const tag = flags.length ? ` ${flags.join(" ")}` : "";
|
|
70
|
-
console.log(`${marker} ${c.bold(account.label)}${tag}`);
|
|
71
|
-
console.log(` ${c.dim(`${account.planType ?? "?"}, id ${account.accountId.slice(0, 8)}`)}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return 0;
|
|
75
|
-
}
|
package/src/cli/watch.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { clamp, delay } from "es-toolkit";
|
|
2
|
-
import { loadAccounts } from "../lib/state.ts";
|
|
3
|
-
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
4
|
-
import { cmdStatus } from "./status.ts";
|
|
5
|
-
import { c, emitError, emitJson } from "./render.ts";
|
|
6
|
-
|
|
7
|
-
const DEFAULT_INTERVAL_S = 120;
|
|
8
|
-
const MIN_INTERVAL_S = 30;
|
|
9
|
-
const MAX_INTERVAL_S = 86_400;
|
|
10
|
-
|
|
11
|
-
export function resolveWatchInterval(arg?: string): number | null {
|
|
12
|
-
if (arg === undefined) return DEFAULT_INTERVAL_S;
|
|
13
|
-
const n = Number(arg);
|
|
14
|
-
if (!Number.isFinite(n) || n <= 0) return null;
|
|
15
|
-
return clamp(n, MIN_INTERVAL_S, MAX_INTERVAL_S);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const CLEAR = "\x1b[H\x1b[2J\x1b[3J";
|
|
19
|
-
|
|
20
|
-
export async function cmdWatch(intervalArg?: string, json = false): Promise<number> {
|
|
21
|
-
const intervalS = resolveWatchInterval(intervalArg);
|
|
22
|
-
if (intervalS === null) {
|
|
23
|
-
emitError({ json, message: `watch interval must be a positive number of seconds, got: ${intervalArg}` });
|
|
24
|
-
return 2;
|
|
25
|
-
}
|
|
26
|
-
if (loadAccounts().accounts.length === 0 && loadCodexAccounts().accounts.length === 0) return cmdStatus({ json });
|
|
27
|
-
|
|
28
|
-
const paintHeader = () => {
|
|
29
|
-
process.stdout.write(process.stdout.isTTY ? CLEAR : "\n");
|
|
30
|
-
console.log(c.dim(`watch: every ${intervalS}s, ${new Date().toLocaleTimeString()}, ctrl-c to quit`));
|
|
31
|
-
};
|
|
32
|
-
while (true) {
|
|
33
|
-
try {
|
|
34
|
-
await cmdStatus(json ? { json } : { preRender: paintHeader });
|
|
35
|
-
} catch (e) {
|
|
36
|
-
const message = `status failed this tick: ${e instanceof Error ? e.message : String(e)}`;
|
|
37
|
-
if (json) {
|
|
38
|
-
emitJson({ ok: false, error: message });
|
|
39
|
-
} else {
|
|
40
|
-
paintHeader();
|
|
41
|
-
console.error(c.red(message));
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
await delay(intervalS * 1000);
|
|
45
|
-
}
|
|
46
|
-
}
|