tokenmaxxing 1.29.0 → 1.31.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 +2 -2
- package/README.md +4 -6
- package/package.json +1 -1
- package/src/cli/config.ts +14 -231
- package/src/cli/status.ts +69 -40
- package/src/lib/state.ts +12 -77
- package/src/lib/types.ts +22 -11
- package/src/main.ts +13 -12
- package/src/cli/ls.ts +0 -75
- package/src/cli/watch.ts +0 -46
package/DESIGN.md
CHANGED
|
@@ -30,7 +30,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
|
|
|
30
30
|
- A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`), or a shell function - you invoke it identically.
|
|
31
31
|
- Four `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `SessionStart` hook.
|
|
32
32
|
- **`~/.config/tokenmaxxing/`** - the single home for config and state:
|
|
33
|
-
- `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults
|
|
33
|
+
- `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
|
|
34
34
|
- `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, label, lastUsage, lastPerModel, needsReauth, ...}` (window resets live inside lastUsage).
|
|
35
35
|
- `usage.json` - live usage, written by the statusLine shim.
|
|
36
36
|
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal, depleted-pool waits only).
|
|
@@ -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
|
|
package/README.md
CHANGED
|
@@ -68,17 +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
|
|
73
|
-
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30) |
|
|
74
|
-
| `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
|
|
71
|
+
| `tokenmaxxing status [--cached]` | accounts with 5h / weekly usage bars, active + exhausted-until-reset; `--cached` renders the stored figures without sampling |
|
|
72
|
+
| `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
|
|
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 |
|
|
79
77
|
| `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
|
|
80
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 |
|
|
81
|
-
| `--json` | machine-readable output: one JSON document on stdout for `status`, `
|
|
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`) |
|
|
82
80
|
|
|
83
81
|
## How switching decides
|
|
84
82
|
|
|
@@ -126,7 +124,7 @@ tokenmaxxing add --codex # log in another account, isolated - your primary lo
|
|
|
126
124
|
codex # use codex as always
|
|
127
125
|
```
|
|
128
126
|
|
|
129
|
-
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.
|
|
130
128
|
|
|
131
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.
|
|
132
130
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/config.ts
CHANGED
|
@@ -1,242 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
|
|
6
|
-
import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
|
|
7
|
-
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
1
|
+
import { paths } from "../lib/paths.ts";
|
|
2
|
+
import { loadConfig } from "../lib/state.ts";
|
|
3
|
+
import type { Config } from "../lib/types.ts";
|
|
8
4
|
import { c, emitError, emitJson } from "./render.ts";
|
|
9
5
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"codexBin",
|
|
15
|
-
"policy.projectionMargin",
|
|
16
|
-
"policy.switchModels",
|
|
17
|
-
"policy.usagePollTtlMs",
|
|
18
|
-
"policy.maxWaitMs",
|
|
19
|
-
"policy.checkIntervalMs",
|
|
20
|
-
] as const;
|
|
21
|
-
|
|
22
|
-
const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
|
|
23
|
-
|
|
24
|
-
const RawFileSchema = z.record(z.string(), z.unknown());
|
|
25
|
-
|
|
26
|
-
function readRawFile(): Record<string, unknown> {
|
|
27
|
-
if (!existsSync(paths.configJson)) return {};
|
|
28
|
-
return RawFileSchema.parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function writeRawFile(input: { raw: Record<string, unknown> }): void {
|
|
32
|
-
writeFileAtomic(paths.configJson, JSON.stringify(input.raw, null, 2) + "\n");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function dottedKeys(obj: Record<string, unknown>): string[] {
|
|
36
|
-
const keys: string[] = [];
|
|
37
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
38
|
-
if (isPlainObject(value)) {
|
|
39
|
-
for (const nested of Object.keys(value)) keys.push(`${key}.${nested}`);
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
keys.push(key);
|
|
43
|
-
}
|
|
44
|
-
return keys;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function unknownFileKeys(raw: Record<string, unknown>): string[] {
|
|
48
|
-
const known = new Set<string>(KNOWN_KEYS);
|
|
49
|
-
return dottedKeys(raw).filter((key) => !known.has(key));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function isKnownKey(key: string): boolean {
|
|
53
|
-
return KNOWN_KEYS.some((known) => known === key);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function envSourceFor(key: string): string | null {
|
|
57
|
-
if (key === "claudeBin" && realClaudeBinFromEnv()) return "TOKENMAXXING_CLAUDE_BIN";
|
|
58
|
-
if (key === "codexBin" && realCodexBinFromEnv()) return "TOKENMAXXING_CODEX_BIN";
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function sourceOf(input: { key: string; raw: Record<string, unknown> }): { source: "env" | "file" | "default"; env: string | null } {
|
|
63
|
-
const env = envSourceFor(input.key);
|
|
64
|
-
if (env) return { source: "env", env };
|
|
65
|
-
return { source: get(input.raw, input.key) !== undefined ? "file" : "default", env: null };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function unknownKey(input: { key: string; json: boolean }): number {
|
|
69
|
-
emitError({
|
|
70
|
-
json: input.json,
|
|
71
|
-
message: `unknown config key: ${input.key}`,
|
|
72
|
-
notes: [`known keys: ${KNOWN_KEYS.join(", ")}`],
|
|
73
|
-
extra: { knownKeys: KNOWN_KEYS },
|
|
74
|
-
});
|
|
75
|
-
return 1;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function printEffective(json: boolean): number {
|
|
79
|
-
const effective = loadConfig();
|
|
80
|
-
const raw = readRawFile();
|
|
81
|
-
const unknown = unknownFileKeys(raw);
|
|
82
|
-
if (json) {
|
|
83
|
-
const sources: Record<string, string> = {};
|
|
84
|
-
const envOverrides: Record<string, string> = {};
|
|
85
|
-
for (const key of KNOWN_KEYS) {
|
|
86
|
-
const { source, env } = sourceOf({ key, raw });
|
|
87
|
-
sources[key] = source;
|
|
88
|
-
if (env) envOverrides[key] = env;
|
|
89
|
-
}
|
|
90
|
-
emitJson({ ok: true, path: paths.configJson, config: effective, sources, envOverrides, unknownKeys: unknown });
|
|
91
|
-
return 0;
|
|
92
|
-
}
|
|
93
|
-
console.log(c.dim(`config.json: ${paths.configJson}`));
|
|
94
|
-
for (const key of KNOWN_KEYS) {
|
|
95
|
-
const { source, env } = sourceOf({ key, raw });
|
|
96
|
-
const painted = source === "env" ? c.yellow(`env ${env}`) : source === "file" ? c.green("file") : c.dim("default");
|
|
97
|
-
console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${painted}`);
|
|
98
|
-
}
|
|
99
|
-
if (unknown.length > 0) {
|
|
100
|
-
console.log();
|
|
101
|
-
console.log(c.yellow(`unknown keys in the file (ignored by the loader): ${unknown.join(", ")}`));
|
|
102
|
-
console.log(c.dim("run `tokenmaxxing config tidy` to drop them"));
|
|
103
|
-
}
|
|
104
|
-
return 0;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function cmdGet(key: string, json: boolean): number {
|
|
108
|
-
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
109
|
-
const value = get(loadConfig(), key);
|
|
110
|
-
if (json) {
|
|
111
|
-
emitJson({ ok: true, key, value, source: sourceOf({ key, raw: readRawFile() }).source });
|
|
112
|
-
return 0;
|
|
6
|
+
export function cmdConfig(args: string[], json = false): number {
|
|
7
|
+
if (args.length > 0) {
|
|
8
|
+
emitError({ json, message: `unknown config option: ${args[0]} (config takes no options; edit ${paths.configJson} in an editor)` });
|
|
9
|
+
return 2;
|
|
113
10
|
}
|
|
114
|
-
|
|
115
|
-
return 0;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function parseValue(text: string): unknown {
|
|
11
|
+
let effective: Config;
|
|
119
12
|
try {
|
|
120
|
-
|
|
121
|
-
} catch {
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function cmdSet(key: string, valueText: string, json: boolean): number {
|
|
127
|
-
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
128
|
-
const raw = readRawFile();
|
|
129
|
-
const next = structuredClone(raw);
|
|
130
|
-
set(next, key, parseValue(valueText));
|
|
131
|
-
const validated = ConfigFileSchema.safeParse(next);
|
|
132
|
-
if (!validated.success) {
|
|
133
|
-
emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
|
|
134
|
-
return 1;
|
|
135
|
-
}
|
|
136
|
-
const mergedCheck = mergeConfigFile(validated.data);
|
|
137
|
-
if (!mergedCheck.ok) {
|
|
138
|
-
emitError({ json, message: `rejected: ${mergedCheck.detail}` });
|
|
139
|
-
return 1;
|
|
140
|
-
}
|
|
141
|
-
writeRawFile({ raw: next });
|
|
142
|
-
const beforeFile = get(raw, key);
|
|
143
|
-
const env = envSourceFor(key);
|
|
144
|
-
if (json) {
|
|
145
|
-
emitJson({ ok: true, key, before: beforeFile === undefined ? null : beforeFile, after: get(next, key), envOverride: env });
|
|
146
|
-
return 0;
|
|
147
|
-
}
|
|
148
|
-
console.log(
|
|
149
|
-
`${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
|
|
150
|
-
);
|
|
151
|
-
if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
|
|
152
|
-
if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
|
|
153
|
-
return 0;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function pruneEmptyParents(raw: Record<string, unknown>): void {
|
|
157
|
-
for (const [topKey, value] of Object.entries(raw)) {
|
|
158
|
-
if (isPlainObject(value) && Object.keys(value).length === 0) {
|
|
159
|
-
delete raw[topKey];
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function cmdUnset(key: string, json: boolean): number {
|
|
165
|
-
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
166
|
-
const raw = readRawFile();
|
|
167
|
-
if (get(raw, key) === undefined) {
|
|
168
|
-
if (json) emitJson({ ok: true, key, unset: false, value: get(loadConfig(), key) });
|
|
169
|
-
else console.log(c.dim(`${key} has no file override (default already applies)`));
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
const next = structuredClone(raw);
|
|
173
|
-
unset(next, key);
|
|
174
|
-
pruneEmptyParents(next);
|
|
175
|
-
const validated = ConfigFileSchema.safeParse(next);
|
|
176
|
-
if (!validated.success) {
|
|
177
|
-
emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
|
|
178
|
-
return 1;
|
|
179
|
-
}
|
|
180
|
-
const mergedCheck = mergeConfigFile(validated.data);
|
|
181
|
-
if (!mergedCheck.ok) {
|
|
182
|
-
emitError({ json, message: `rejected: ${mergedCheck.detail} (adjust the conflicting override before unsetting ${key})` });
|
|
13
|
+
effective = loadConfig();
|
|
14
|
+
} catch (e) {
|
|
15
|
+
emitError({ json, message: e instanceof Error ? e.message : String(e), extra: { path: paths.configJson } });
|
|
183
16
|
return 1;
|
|
184
17
|
}
|
|
185
|
-
writeRawFile({ raw: next });
|
|
186
|
-
const value = get(loadConfig(), key);
|
|
187
18
|
if (json) {
|
|
188
|
-
emitJson({ ok: true,
|
|
189
|
-
return 0;
|
|
190
|
-
}
|
|
191
|
-
console.log(`${key} unset -> ${JSON.stringify(value)} (default)`);
|
|
192
|
-
if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
|
|
193
|
-
return 0;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function cmdTidy(json: boolean): number {
|
|
197
|
-
const raw = readRawFile();
|
|
198
|
-
const dropped = unknownFileKeys(raw);
|
|
199
|
-
const parsed = ConfigFileSchema.parse(raw);
|
|
200
|
-
const next = RawFileSchema.parse(JSON.parse(JSON.stringify(parsed)));
|
|
201
|
-
const models = get(next, "policy.switchModels");
|
|
202
|
-
const normalized = Array.isArray(models) ? models.map((model) => String(model).toLowerCase()) : null;
|
|
203
|
-
const casingChanged = normalized != null && JSON.stringify(normalized) !== JSON.stringify(models);
|
|
204
|
-
if (normalized != null) set(next, "policy.switchModels", normalized);
|
|
205
|
-
pruneEmptyParents(next);
|
|
206
|
-
|
|
207
|
-
const changed = JSON.stringify(next) !== JSON.stringify(raw);
|
|
208
|
-
if (changed) writeRawFile({ raw: next });
|
|
209
|
-
if (json) {
|
|
210
|
-
emitJson({ ok: true, changed, droppedKeys: dropped, casingNormalized: casingChanged });
|
|
211
|
-
return 0;
|
|
212
|
-
}
|
|
213
|
-
if (!changed) {
|
|
214
|
-
console.log(c.dim("nothing to tidy"));
|
|
19
|
+
emitJson({ ok: true, path: paths.configJson, effective });
|
|
215
20
|
return 0;
|
|
216
21
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (dropped.length === 0 && !casingChanged) console.log("canonicalized file layout (pruned empty sections / key order)");
|
|
22
|
+
console.log(c.dim(`config.json: ${paths.configJson}`));
|
|
23
|
+
console.log(JSON.stringify(effective, null, 2));
|
|
220
24
|
return 0;
|
|
221
25
|
}
|
|
222
|
-
|
|
223
|
-
export function cmdConfig(args: string[], json = false): number {
|
|
224
|
-
const [sub, key, value] = args;
|
|
225
|
-
try {
|
|
226
|
-
if (sub === undefined) return printEffective(json);
|
|
227
|
-
if (sub === "get" && key !== undefined) return cmdGet(key, json);
|
|
228
|
-
if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value, json);
|
|
229
|
-
if (sub === "unset" && key !== undefined) return cmdUnset(key, json);
|
|
230
|
-
if (sub === "tidy") return cmdTidy(json);
|
|
231
|
-
} catch (e) {
|
|
232
|
-
emitError({
|
|
233
|
-
json,
|
|
234
|
-
message: `config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`,
|
|
235
|
-
notes: [`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`],
|
|
236
|
-
extra: { path: paths.configJson },
|
|
237
|
-
});
|
|
238
|
-
return 1;
|
|
239
|
-
}
|
|
240
|
-
emitError({ json, message: "usage: tokenmaxxing config [get <key> | set <key> <value> | unset <key> | tidy]" });
|
|
241
|
-
return 2;
|
|
242
|
-
}
|
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/state.ts
CHANGED
|
@@ -15,91 +15,26 @@ import {
|
|
|
15
15
|
type UsageState,
|
|
16
16
|
} from "./types.ts";
|
|
17
17
|
|
|
18
|
-
const DEFAULT_CONFIG: Config = {
|
|
19
|
-
thresholds: { session: 90, weekly: 98 },
|
|
20
|
-
claudeBin: "",
|
|
21
|
-
codexBin: "",
|
|
22
|
-
policy: {
|
|
23
|
-
projectionMargin: 0,
|
|
24
|
-
switchModels: ["fable"],
|
|
25
|
-
usagePollTtlMs: 90_000,
|
|
26
|
-
maxWaitMs: 3_600_000,
|
|
27
|
-
checkIntervalMs: 60_000,
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const PercentSchema = z.number().min(0).max(100);
|
|
32
|
-
|
|
33
|
-
export const ConfigFileSchema = z
|
|
34
|
-
.object({
|
|
35
|
-
thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
36
|
-
claudeBin: z.string(),
|
|
37
|
-
codexBin: z.string(),
|
|
38
|
-
policy: z
|
|
39
|
-
.object({
|
|
40
|
-
projectionMargin: PercentSchema,
|
|
41
|
-
switchModels: z.array(z.string()),
|
|
42
|
-
usagePollTtlMs: z.number().int().positive(),
|
|
43
|
-
maxWaitMs: z.number().int().positive(),
|
|
44
|
-
checkIntervalMs: z.number().int().min(10_000),
|
|
45
|
-
})
|
|
46
|
-
.partial(),
|
|
47
|
-
})
|
|
48
|
-
.partial();
|
|
49
|
-
|
|
50
|
-
const MergeOutcomeSchema = z.union([
|
|
51
|
-
z.object({ ok: z.literal(true), config: ConfigSchema }),
|
|
52
|
-
z.object({ ok: z.literal(false), detail: z.string() }),
|
|
53
|
-
]);
|
|
54
|
-
export type MergeOutcome = z.infer<typeof MergeOutcomeSchema>;
|
|
55
|
-
|
|
56
|
-
export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutcome {
|
|
57
|
-
const cfg: Config = {
|
|
58
|
-
...DEFAULT_CONFIG,
|
|
59
|
-
thresholds: { ...DEFAULT_CONFIG.thresholds },
|
|
60
|
-
policy: { ...DEFAULT_CONFIG.policy },
|
|
61
|
-
};
|
|
62
|
-
cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
|
|
63
|
-
cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
|
|
64
|
-
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
65
|
-
cfg.codexBin = p.codexBin ?? cfg.codexBin;
|
|
66
|
-
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
|
67
|
-
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
68
|
-
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
69
|
-
cfg.policy.checkIntervalMs = p.policy?.checkIntervalMs ?? cfg.policy.checkIntervalMs;
|
|
70
|
-
if (p.policy?.switchModels) {
|
|
71
|
-
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
72
|
-
}
|
|
73
|
-
const envBin = realClaudeBinFromEnv();
|
|
74
|
-
if (envBin) cfg.claudeBin = envBin;
|
|
75
|
-
const envCodexBin = realCodexBinFromEnv();
|
|
76
|
-
if (envCodexBin) cfg.codexBin = envCodexBin;
|
|
77
|
-
const merged = ConfigSchema.safeParse(cfg);
|
|
78
|
-
if (!merged.success) {
|
|
79
|
-
return { ok: false, detail: merged.error.issues.map((issue) => issue.message).join("; ") };
|
|
80
|
-
}
|
|
81
|
-
return { ok: true, config: merged.data };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
18
|
export function loadConfig(): Config {
|
|
85
|
-
let
|
|
19
|
+
let raw: unknown = {};
|
|
86
20
|
if (existsSync(paths.configJson)) {
|
|
87
|
-
let raw: unknown;
|
|
88
21
|
try {
|
|
89
22
|
raw = JSON.parse(readFileSync(paths.configJson, "utf8"));
|
|
90
23
|
} catch {
|
|
91
24
|
throw new Error(`${paths.configJson} is corrupt (unparsable JSON) - fix or remove it`);
|
|
92
25
|
}
|
|
93
|
-
const parsed = ConfigFileSchema.safeParse(raw);
|
|
94
|
-
if (!parsed.success) {
|
|
95
|
-
const fields = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
|
|
96
|
-
throw new Error(`${paths.configJson} has wrong-typed values (${fields}) - fix or remove them`);
|
|
97
|
-
}
|
|
98
|
-
fileData = parsed.data;
|
|
99
26
|
}
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
|
|
27
|
+
const parsed = ConfigSchema.safeParse(raw);
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
const fields = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
|
|
30
|
+
throw new Error(`${paths.configJson} is invalid (${fields}) - fix or remove the offending values`);
|
|
31
|
+
}
|
|
32
|
+
const cfg = parsed.data;
|
|
33
|
+
const envBin = realClaudeBinFromEnv();
|
|
34
|
+
if (envBin) cfg.claudeBin = envBin;
|
|
35
|
+
const envCodexBin = realCodexBinFromEnv();
|
|
36
|
+
if (envCodexBin) cfg.codexBin = envCodexBin;
|
|
37
|
+
return cfg;
|
|
103
38
|
}
|
|
104
39
|
|
|
105
40
|
export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: string }): void {
|
package/src/lib/types.ts
CHANGED
|
@@ -101,19 +101,30 @@ export type Thresholds = z.infer<typeof ThresholdsSchema>;
|
|
|
101
101
|
|
|
102
102
|
export const ConfigSchema = z
|
|
103
103
|
.object({
|
|
104
|
-
thresholds:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
thresholds: z
|
|
105
|
+
.object({
|
|
106
|
+
session: z.number().min(0).max(100).default(90),
|
|
107
|
+
weekly: z.number().min(0).max(100).default(98),
|
|
108
|
+
})
|
|
109
|
+
.prefault({}),
|
|
110
|
+
claudeBin: z.string().default(""),
|
|
111
|
+
codexBin: z.string().default(""),
|
|
112
|
+
policy: z
|
|
113
|
+
.object({
|
|
114
|
+
projectionMargin: z.number().min(0).max(100).default(0),
|
|
115
|
+
switchModels: z
|
|
116
|
+
.array(z.string())
|
|
117
|
+
.default(["fable"])
|
|
118
|
+
.transform((models) => models.map((model) => model.toLowerCase())),
|
|
119
|
+
usagePollTtlMs: z.number().int().positive().default(90_000),
|
|
120
|
+
maxWaitMs: z.number().int().positive().default(3_600_000),
|
|
121
|
+
checkIntervalMs: z.number().int().min(10_000).default(60_000),
|
|
122
|
+
})
|
|
123
|
+
.prefault({}),
|
|
114
124
|
})
|
|
115
125
|
.refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
|
|
116
|
-
|
|
126
|
+
path: ["policy", "projectionMargin"],
|
|
127
|
+
message: "must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
|
|
117
128
|
});
|
|
118
129
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
119
130
|
|
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";
|
|
@@ -32,6 +30,7 @@ import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
|
|
|
32
30
|
import { c, emitError, emitJson } from "./cli/render.ts";
|
|
33
31
|
|
|
34
32
|
const JSON_FLAG = "--json";
|
|
33
|
+
const CACHED_FLAG = "--cached";
|
|
35
34
|
const INTERACTIVE_COMMANDS = new Set(["init", "add", "auth"]);
|
|
36
35
|
|
|
37
36
|
function printHelp(): void {
|
|
@@ -46,10 +45,8 @@ function printHelp(): void {
|
|
|
46
45
|
${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
|
|
47
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)
|
|
48
47
|
${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
|
|
49
|
-
${c.cyan("tokenmaxxing
|
|
50
|
-
${c.cyan("tokenmaxxing
|
|
51
|
-
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120)
|
|
52
|
-
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
48
|
+
${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
|
|
49
|
+
${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
|
|
53
50
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
54
51
|
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
55
52
|
${c.cyan("tokenmaxxing rm")} [--codex] <sel>
|
|
@@ -58,7 +55,7 @@ function printHelp(): void {
|
|
|
58
55
|
${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
|
|
59
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
|
|
60
57
|
|
|
61
|
-
${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")}
|
|
62
59
|
|
|
63
60
|
${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
|
|
64
61
|
}
|
|
@@ -82,9 +79,15 @@ async function main(): Promise<number> {
|
|
|
82
79
|
|
|
83
80
|
jsonMode = argv.includes(JSON_FLAG);
|
|
84
81
|
const json = jsonMode;
|
|
85
|
-
const
|
|
82
|
+
const cached = argv.includes(CACHED_FLAG);
|
|
83
|
+
const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG);
|
|
86
84
|
const sub = args[0];
|
|
87
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
|
+
|
|
88
91
|
if (json && sub != null && INTERACTIVE_COMMANDS.has(sub)) {
|
|
89
92
|
emitError({ json, message: `${sub} is interactive (it runs a login flow) and has no --json form` });
|
|
90
93
|
return 2;
|
|
@@ -112,10 +115,10 @@ async function main(): Promise<number> {
|
|
|
112
115
|
case "status": {
|
|
113
116
|
const extra = args[1];
|
|
114
117
|
if (extra != null) {
|
|
115
|
-
emitError({ json, message: `unknown status option: ${extra} (status takes
|
|
118
|
+
emitError({ json, message: `unknown status option: ${extra} (status takes only ${CACHED_FLAG})` });
|
|
116
119
|
return 2;
|
|
117
120
|
}
|
|
118
|
-
return cmdStatus({ json });
|
|
121
|
+
return cmdStatus({ json, cached });
|
|
119
122
|
}
|
|
120
123
|
case "switch": {
|
|
121
124
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
@@ -132,8 +135,6 @@ async function main(): Promise<number> {
|
|
|
132
135
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
133
136
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
134
137
|
case "auth": return cmdAuth(args.slice(1));
|
|
135
|
-
case "ls": return cmdLs(json);
|
|
136
|
-
case "watch": return cmdWatch(args[1], json);
|
|
137
138
|
case "doctor": return cmdDoctor(json);
|
|
138
139
|
case "rm": {
|
|
139
140
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|
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
|
-
}
|