tokenmaxxing 1.10.0 → 1.11.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/README.md +1 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/pool-status/SKILL.md +2 -2
- package/agent-plugin/skills/pool-status/references/commands.md +1 -0
- package/package.json +1 -1
- package/src/cli/check.ts +17 -4
- package/src/cli/codexrm.ts +8 -7
- package/src/cli/codexswitch.ts +48 -18
- package/src/cli/config.ts +86 -42
- package/src/cli/doctor.ts +25 -9
- package/src/cli/ls.ts +37 -2
- package/src/cli/rename.ts +17 -14
- package/src/cli/render.ts +20 -0
- package/src/cli/rm.ts +12 -8
- package/src/cli/status.ts +239 -110
- package/src/cli/switch.ts +64 -28
- package/src/cli/watch.ts +13 -7
- package/src/entries/mcp.ts +1 -1
- package/src/main.ts +44 -22
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ claude # use claude as always
|
|
|
76
76
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
77
77
|
| `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) |
|
|
78
78
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
|
79
|
+
| `--json` | machine-readable output: one JSON document on stdout for `status`, `ls`, `config`, `doctor`, `check`, `switch`, `rename`, `rm`, `uninstall`, and one per tick for `watch` (`ok` mirrors the exit code, failures add `error`) |
|
|
79
80
|
|
|
80
81
|
## How switching decides
|
|
81
82
|
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "tokenmaxxing",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.11.0",
|
|
5
5
|
"description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "anaclumos",
|
|
@@ -10,7 +10,7 @@ description: Read the Claude and Codex account pool safely (list, usage bars, wa
|
|
|
10
10
|
- `pool_ls` for labels, active marker, needs-reauth
|
|
11
11
|
- `pool_status` for 5h / weekly / per-model bars (free `/usage` path)
|
|
12
12
|
|
|
13
|
-
If MCP is unavailable, run `tokenmaxxing ls` or `tokenmaxxing status` (alias `xx`). Never add `--force`.
|
|
13
|
+
If MCP is unavailable, run `tokenmaxxing ls --json` or `tokenmaxxing status --json` (alias `xx`) and parse the document. Never add `--force`.
|
|
14
14
|
|
|
15
15
|
## Hard stops
|
|
16
16
|
|
|
@@ -22,6 +22,6 @@ If MCP is unavailable, run `tokenmaxxing ls` or `tokenmaxxing status` (alias `xx
|
|
|
22
22
|
|
|
23
23
|
- Active Claude usage often comes from the statusLine tee; parked accounts are probed in isolation.
|
|
24
24
|
- `watch` re-renders status on an interval and never force-pings.
|
|
25
|
-
- Hermetic agents: set `TOKENMAXXING_HOME` to a throwaway directory.
|
|
25
|
+
- Hermetic agents: set `TOKENMAXXING_HOME` to a throwaway directory. That isolates state files only: `init`, `uninstall`, and the codex hook install still write settings.json, codex `hooks.json`, the shell rc, and the timer unit under `HOME`, so never run them from an agent.
|
|
26
26
|
|
|
27
27
|
See [references/commands.md](references/commands.md).
|
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
- `tokenmaxxing status --force`: DENIED for agents without explicit user approval. Meters every account.
|
|
5
5
|
- `tokenmaxxing ls`: compact list.
|
|
6
6
|
- `tokenmaxxing watch [seconds]`: live re-render (default 120), never `--force`.
|
|
7
|
+
- `--json` on any of these: one JSON document on stdout (`ok` mirrors the exit code); parse it instead of scraping the bars. `watch --json` prints one document per tick and never exits on its own.
|
|
7
8
|
|
|
8
9
|
Docs: `docs/content/docs/commands.mdx`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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/check.ts
CHANGED
|
@@ -2,15 +2,16 @@ import { CHECK_DELAY_FLOOR_MS, checkDelayMs, evaluateAndMaybeSwap } from "../lib
|
|
|
2
2
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
3
3
|
import { log } from "../lib/log.ts";
|
|
4
4
|
import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
|
|
5
|
-
import { c, fmtReset } from "./render.ts";
|
|
5
|
+
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
6
6
|
|
|
7
7
|
const TICK_SLACK_MS = CHECK_DELAY_FLOOR_MS / 2;
|
|
8
8
|
|
|
9
|
-
export async function cmdCheck(args: string[] = []): Promise<number> {
|
|
9
|
+
export async function cmdCheck(args: string[] = [], json = false): Promise<number> {
|
|
10
10
|
const now = Date.now();
|
|
11
11
|
const dueAt = args.includes("--if-due") ? loadNextCheckDueAt(now) : null;
|
|
12
12
|
if (dueAt != null && now + TICK_SLACK_MS < dueAt) {
|
|
13
|
-
|
|
13
|
+
if (json) emitJson({ ok: true, due: false, nextCheckAt: dueAt });
|
|
14
|
+
else console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
|
|
14
15
|
return 0;
|
|
15
16
|
}
|
|
16
17
|
let d;
|
|
@@ -19,11 +20,23 @@ export async function cmdCheck(args: string[] = []): Promise<number> {
|
|
|
19
20
|
} catch (e) {
|
|
20
21
|
const detail = e instanceof Error ? e.message : String(e);
|
|
21
22
|
log("check.error", { err: detail });
|
|
22
|
-
|
|
23
|
+
emitError({ json, message: `check failed: ${detail}` });
|
|
23
24
|
return 1;
|
|
24
25
|
}
|
|
25
26
|
const delayMs = checkDelayMs({ cfg: loadConfig(), org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
|
|
26
27
|
saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
|
|
28
|
+
if (json) {
|
|
29
|
+
emitJson({
|
|
30
|
+
ok: true,
|
|
31
|
+
due: true,
|
|
32
|
+
swapped: d.swapped,
|
|
33
|
+
account: d.account?.label ?? null,
|
|
34
|
+
reason: d.reason,
|
|
35
|
+
waitUntil: d.waitUntil ?? null,
|
|
36
|
+
nextCheckAt: now + delayMs,
|
|
37
|
+
});
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
27
40
|
const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
|
|
28
41
|
if (d.swapped && d.account) {
|
|
29
42
|
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
|
package/src/cli/codexrm.ts
CHANGED
|
@@ -5,32 +5,33 @@ import { deleteParkedCodexAuth } from "../lib/codexauth.ts";
|
|
|
5
5
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
6
6
|
import { presentCodexAccountIds } from "../lib/codexpresence.ts";
|
|
7
7
|
import { findCodexAccount } from "./rename.ts";
|
|
8
|
-
import { c } from "./render.ts";
|
|
8
|
+
import { c, emitError, emitJson, plain } from "./render.ts";
|
|
9
9
|
|
|
10
|
-
export async function cmdCodexRm(selector?: string): Promise<number> {
|
|
10
|
+
export async function cmdCodexRm(selector?: string, json = false): Promise<number> {
|
|
11
11
|
if (!selector) {
|
|
12
|
-
|
|
12
|
+
emitError({ json, message: "usage: tokenmaxxing rm --codex <email|label|id>", paint: plain });
|
|
13
13
|
return 2;
|
|
14
14
|
}
|
|
15
15
|
return withLock(codexPaths.lockFile, async () => {
|
|
16
16
|
const index = loadCodexAccounts();
|
|
17
17
|
const account = findCodexAccount(index.accounts, selector);
|
|
18
18
|
if (!account) {
|
|
19
|
-
|
|
19
|
+
emitError({ json, message: `no codex account matches "${selector}"` });
|
|
20
20
|
return 1;
|
|
21
21
|
}
|
|
22
22
|
if (liveCodexAccountId() === account.accountId) {
|
|
23
|
-
|
|
23
|
+
emitError({ json, message: `${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.` });
|
|
24
24
|
return 1;
|
|
25
25
|
}
|
|
26
26
|
if (presentCodexAccountIds().has(account.accountId)) {
|
|
27
|
-
|
|
27
|
+
emitError({ json, message: `${account.label} is running in a live codex session - close that session before removing it.` });
|
|
28
28
|
return 1;
|
|
29
29
|
}
|
|
30
30
|
deleteParkedCodexAuth({ credFile: account.credFile });
|
|
31
31
|
index.accounts = index.accounts.filter((x) => x.accountId !== account.accountId);
|
|
32
32
|
saveCodexAccounts({ index });
|
|
33
|
-
|
|
33
|
+
if (json) emitJson({ ok: true, pool: "codex", removed: account.label, remaining: index.accounts.length });
|
|
34
|
+
else console.log(`removed codex account ${c.bold(account.label)} from the pool (${index.accounts.length} left)`);
|
|
34
35
|
return 0;
|
|
35
36
|
});
|
|
36
37
|
}
|
package/src/cli/codexswitch.ts
CHANGED
|
@@ -8,9 +8,20 @@ import { CodexInvalidGrantError } from "../lib/codexoauth.ts";
|
|
|
8
8
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
9
9
|
import { presentCodexAccountIds, targetableCodexAccounts } from "../lib/codexpresence.ts";
|
|
10
10
|
import { terminalBars } from "../lib/picker.ts";
|
|
11
|
-
import { c } from "./render.ts";
|
|
11
|
+
import { c, emitError, emitJson } from "./render.ts";
|
|
12
12
|
|
|
13
|
-
export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
13
|
+
export async function cmdCodexSwitch(sel?: string, json = false): Promise<number> {
|
|
14
|
+
const deadGrants: string[] = [];
|
|
15
|
+
const withDeadGrants = (report: Record<string, unknown>) => (deadGrants.length > 0 ? { ...report, deadGrants } : report);
|
|
16
|
+
const emit = (text: string, report: Record<string, unknown>): void => {
|
|
17
|
+
if (json) emitJson({ ok: true, ...withDeadGrants(report) });
|
|
18
|
+
else console.log(text);
|
|
19
|
+
};
|
|
20
|
+
const fail = (message: string, opts: { paint?: (s: string) => string; notes?: string[]; extra?: Record<string, unknown> } = {}): number => {
|
|
21
|
+
emitError({ json, message, paint: opts.paint, notes: opts.notes, extra: withDeadGrants(opts.extra ?? {}) });
|
|
22
|
+
return 1;
|
|
23
|
+
};
|
|
24
|
+
const deadGrantMessage = (label: string) => `${label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``;
|
|
14
25
|
const cfg = loadConfig();
|
|
15
26
|
const bars = terminalBars(cfg);
|
|
16
27
|
const now = Date.now();
|
|
@@ -18,7 +29,8 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
18
29
|
return withLock(codexPaths.lockFile, async () => {
|
|
19
30
|
const index = loadCodexAccounts();
|
|
20
31
|
if (index.accounts.length === 0) {
|
|
21
|
-
|
|
32
|
+
if (json) emitJson({ ok: false, error: "no codex accounts yet - run `tokenmaxxing init --codex`" });
|
|
33
|
+
else console.log(c.dim("no codex accounts yet - run `tokenmaxxing init --codex`"));
|
|
22
34
|
return 1;
|
|
23
35
|
}
|
|
24
36
|
const currentId = liveCodexAccountId();
|
|
@@ -28,32 +40,35 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
28
40
|
(account) => account.label === sel || account.email === sel || account.accountId.startsWith(sel),
|
|
29
41
|
);
|
|
30
42
|
if (!target) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
return fail(`no codex account matches "${sel}"`, {
|
|
44
|
+
notes: index.accounts.map((account) => ` ${account.label} (${account.accountId.slice(0, 8)})`),
|
|
45
|
+
extra: { accounts: index.accounts.map((account) => account.label) },
|
|
46
|
+
});
|
|
34
47
|
}
|
|
35
48
|
if (target.accountId === currentId) {
|
|
36
|
-
|
|
49
|
+
emit(`already on ${c.bold(target.label)}`, { switched: false, account: target.label, reason: "already-on" });
|
|
37
50
|
return 0;
|
|
38
51
|
}
|
|
39
52
|
if (presentCodexAccountIds().has(target.accountId)) {
|
|
40
|
-
|
|
41
|
-
return 1;
|
|
53
|
+
return fail(`${target.label} is running in a live codex session - swapping onto it would break that session's credential`);
|
|
42
54
|
}
|
|
43
55
|
if (target.needsReauth) {
|
|
44
|
-
|
|
45
|
-
return 1;
|
|
56
|
+
return fail(`${target.label} needs re-auth - run \`codex login\` in an isolated home and \`tokenmaxxing add --codex\``);
|
|
46
57
|
}
|
|
47
58
|
try {
|
|
48
59
|
await performCodexSwap({ target });
|
|
49
60
|
} catch (e) {
|
|
50
61
|
if (e instanceof CodexInvalidGrantError) {
|
|
51
|
-
|
|
52
|
-
return
|
|
62
|
+
deadGrants.push(target.label);
|
|
63
|
+
return fail(deadGrantMessage(target.label));
|
|
53
64
|
}
|
|
54
65
|
throw e;
|
|
55
66
|
}
|
|
56
|
-
|
|
67
|
+
emit(`${c.green("✓")} switched codex to ${c.bold(target.label)} (takes effect on the next codex start)`, {
|
|
68
|
+
switched: true,
|
|
69
|
+
account: target.label,
|
|
70
|
+
reason: "selected",
|
|
71
|
+
});
|
|
57
72
|
return 0;
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -62,21 +77,36 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
62
77
|
const candidates = targetableCodexAccounts({ accounts: current.accounts, activeAccountId: currentId });
|
|
63
78
|
const active = candidates.find((account) => account.accountId === currentId) ?? null;
|
|
64
79
|
if (codexCurrentWins({ active, accounts: candidates, thresholds: bars, now })) {
|
|
65
|
-
|
|
80
|
+
emit(`already on the best codex account: ${c.bold(active?.label ?? "?")}`, {
|
|
81
|
+
switched: false,
|
|
82
|
+
account: active?.label ?? null,
|
|
83
|
+
reason: "current-wins",
|
|
84
|
+
});
|
|
66
85
|
return 0;
|
|
67
86
|
}
|
|
68
87
|
const best = pickBestCodex({ accounts: candidates, thresholds: bars, now, currentAccountId: currentId });
|
|
69
88
|
if (!best) {
|
|
70
|
-
|
|
89
|
+
const message = "no usable codex switch target (all at their bars, unmeasured, or needing reauth)";
|
|
90
|
+
const reauthNeeded = current.accounts.filter((account) => account.needsReauth).map((account) => account.label);
|
|
91
|
+
if (json) return fail(message, { extra: { reauthNeeded } });
|
|
92
|
+
console.log(c.yellow(message));
|
|
71
93
|
return 1;
|
|
72
94
|
}
|
|
73
95
|
try {
|
|
74
96
|
await performCodexSwap({ target: best });
|
|
75
97
|
} catch (e) {
|
|
76
|
-
if (e instanceof CodexInvalidGrantError)
|
|
98
|
+
if (e instanceof CodexInvalidGrantError) {
|
|
99
|
+
deadGrants.push(best.label);
|
|
100
|
+
if (!json) console.error(c.red(deadGrantMessage(best.label)));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
77
103
|
throw e;
|
|
78
104
|
}
|
|
79
|
-
|
|
105
|
+
emit(`${c.green("✓")} switched codex to ${c.bold(best.label)} (takes effect on the next codex start)`, {
|
|
106
|
+
switched: true,
|
|
107
|
+
account: best.label,
|
|
108
|
+
reason: "best",
|
|
109
|
+
});
|
|
80
110
|
return 0;
|
|
81
111
|
}
|
|
82
112
|
});
|
package/src/cli/config.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
|
|
6
6
|
import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
|
|
7
7
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
8
|
-
import { c } from "./render.ts";
|
|
8
|
+
import { c, emitError, emitJson } from "./render.ts";
|
|
9
9
|
|
|
10
10
|
export const KNOWN_KEYS = [
|
|
11
11
|
"thresholds.session",
|
|
@@ -49,22 +49,53 @@ function unknownFileKeys(raw: Record<string, unknown>): string[] {
|
|
|
49
49
|
return dottedKeys(raw).filter((key) => !known.has(key));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function isKnownKey(key: string): boolean {
|
|
53
|
+
return KNOWN_KEYS.some((known) => known === key);
|
|
54
|
+
}
|
|
55
|
+
|
|
52
56
|
function envSourceFor(key: string): string | null {
|
|
53
57
|
if (key === "claudeBin" && realClaudeBinFromEnv()) return "TOKENMAXXING_CLAUDE_BIN";
|
|
54
58
|
if (key === "codexBin" && realCodexBinFromEnv()) return "TOKENMAXXING_CODEX_BIN";
|
|
55
59
|
return null;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
function
|
|
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 {
|
|
59
79
|
const effective = loadConfig();
|
|
60
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
|
+
}
|
|
61
93
|
console.log(c.dim(`config.json: ${paths.configJson}`));
|
|
62
94
|
for (const key of KNOWN_KEYS) {
|
|
63
|
-
const env =
|
|
64
|
-
const
|
|
65
|
-
console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${
|
|
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}`);
|
|
66
98
|
}
|
|
67
|
-
const unknown = unknownFileKeys(raw);
|
|
68
99
|
if (unknown.length > 0) {
|
|
69
100
|
console.log();
|
|
70
101
|
console.log(c.yellow(`unknown keys in the file (ignored by the loader): ${unknown.join(", ")}`));
|
|
@@ -73,13 +104,14 @@ function printEffective(): number {
|
|
|
73
104
|
return 0;
|
|
74
105
|
}
|
|
75
106
|
|
|
76
|
-
function cmdGet(key: string): number {
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
|
81
113
|
}
|
|
82
|
-
console.log(JSON.stringify(
|
|
114
|
+
console.log(JSON.stringify(value));
|
|
83
115
|
return 0;
|
|
84
116
|
}
|
|
85
117
|
|
|
@@ -91,31 +123,31 @@ function parseValue(text: string): unknown {
|
|
|
91
123
|
}
|
|
92
124
|
}
|
|
93
125
|
|
|
94
|
-
function cmdSet(key: string, valueText: string): number {
|
|
95
|
-
if (!
|
|
96
|
-
console.error(c.red(`unknown config key: ${key}`));
|
|
97
|
-
console.error(c.dim(`known keys: ${KNOWN_KEYS.join(", ")}`));
|
|
98
|
-
return 1;
|
|
99
|
-
}
|
|
126
|
+
function cmdSet(key: string, valueText: string, json: boolean): number {
|
|
127
|
+
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
100
128
|
const raw = readRawFile();
|
|
101
129
|
const next = structuredClone(raw);
|
|
102
130
|
set(next, key, parseValue(valueText));
|
|
103
131
|
const validated = ConfigFileSchema.safeParse(next);
|
|
104
132
|
if (!validated.success) {
|
|
105
|
-
|
|
133
|
+
emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
|
|
106
134
|
return 1;
|
|
107
135
|
}
|
|
108
136
|
const mergedCheck = mergeConfigFile(validated.data);
|
|
109
137
|
if (!mergedCheck.ok) {
|
|
110
|
-
|
|
138
|
+
emitError({ json, message: `rejected: ${mergedCheck.detail}` });
|
|
111
139
|
return 1;
|
|
112
140
|
}
|
|
113
141
|
writeRawFile({ raw: next });
|
|
114
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
|
+
}
|
|
115
148
|
console.log(
|
|
116
149
|
`${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
|
|
117
150
|
);
|
|
118
|
-
const env = envSourceFor(key);
|
|
119
151
|
if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
|
|
120
152
|
return 0;
|
|
121
153
|
}
|
|
@@ -128,14 +160,12 @@ function pruneEmptyParents(raw: Record<string, unknown>): void {
|
|
|
128
160
|
}
|
|
129
161
|
}
|
|
130
162
|
|
|
131
|
-
function cmdUnset(key: string): number {
|
|
132
|
-
if (!
|
|
133
|
-
console.error(c.red(`unknown config key: ${key}`));
|
|
134
|
-
return 1;
|
|
135
|
-
}
|
|
163
|
+
function cmdUnset(key: string, json: boolean): number {
|
|
164
|
+
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
136
165
|
const raw = readRawFile();
|
|
137
166
|
if (get(raw, key) === undefined) {
|
|
138
|
-
|
|
167
|
+
if (json) emitJson({ ok: true, key, unset: false, value: get(loadConfig(), key) });
|
|
168
|
+
else console.log(c.dim(`${key} has no file override (default already applies)`));
|
|
139
169
|
return 0;
|
|
140
170
|
}
|
|
141
171
|
const next = structuredClone(raw);
|
|
@@ -143,20 +173,25 @@ function cmdUnset(key: string): number {
|
|
|
143
173
|
pruneEmptyParents(next);
|
|
144
174
|
const validated = ConfigFileSchema.safeParse(next);
|
|
145
175
|
if (!validated.success) {
|
|
146
|
-
|
|
176
|
+
emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
|
|
147
177
|
return 1;
|
|
148
178
|
}
|
|
149
179
|
const mergedCheck = mergeConfigFile(validated.data);
|
|
150
180
|
if (!mergedCheck.ok) {
|
|
151
|
-
|
|
181
|
+
emitError({ json, message: `rejected: ${mergedCheck.detail} (adjust the conflicting override before unsetting ${key})` });
|
|
152
182
|
return 1;
|
|
153
183
|
}
|
|
154
184
|
writeRawFile({ raw: next });
|
|
155
|
-
|
|
185
|
+
const value = get(loadConfig(), key);
|
|
186
|
+
if (json) {
|
|
187
|
+
emitJson({ ok: true, key, unset: true, value });
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
console.log(`${key} unset -> ${JSON.stringify(value)} (default)`);
|
|
156
191
|
return 0;
|
|
157
192
|
}
|
|
158
193
|
|
|
159
|
-
function cmdTidy(): number {
|
|
194
|
+
function cmdTidy(json: boolean): number {
|
|
160
195
|
const raw = readRawFile();
|
|
161
196
|
const dropped = unknownFileKeys(raw);
|
|
162
197
|
const parsed = ConfigFileSchema.parse(raw);
|
|
@@ -167,30 +202,39 @@ function cmdTidy(): number {
|
|
|
167
202
|
if (normalized != null) set(next, "policy.switchModels", normalized);
|
|
168
203
|
pruneEmptyParents(next);
|
|
169
204
|
|
|
170
|
-
|
|
205
|
+
const changed = JSON.stringify(next) !== JSON.stringify(raw);
|
|
206
|
+
if (changed) writeRawFile({ raw: next });
|
|
207
|
+
if (json) {
|
|
208
|
+
emitJson({ ok: true, changed, droppedKeys: dropped, casingNormalized: casingChanged });
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
if (!changed) {
|
|
171
212
|
console.log(c.dim("nothing to tidy"));
|
|
172
213
|
return 0;
|
|
173
214
|
}
|
|
174
|
-
writeRawFile({ raw: next });
|
|
175
215
|
if (dropped.length > 0) console.log(`dropped unknown keys: ${dropped.join(", ")}`);
|
|
176
216
|
if (casingChanged) console.log("normalized switchModels casing");
|
|
177
217
|
if (dropped.length === 0 && !casingChanged) console.log("canonicalized file layout (pruned empty sections / key order)");
|
|
178
218
|
return 0;
|
|
179
219
|
}
|
|
180
220
|
|
|
181
|
-
export function cmdConfig(args: string[]): number {
|
|
221
|
+
export function cmdConfig(args: string[], json = false): number {
|
|
182
222
|
const [sub, key, value] = args;
|
|
183
223
|
try {
|
|
184
|
-
if (sub === undefined) return printEffective();
|
|
185
|
-
if (sub === "get" && key !== undefined) return cmdGet(key);
|
|
186
|
-
if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value);
|
|
187
|
-
if (sub === "unset" && key !== undefined) return cmdUnset(key);
|
|
188
|
-
if (sub === "tidy") return cmdTidy();
|
|
224
|
+
if (sub === undefined) return printEffective(json);
|
|
225
|
+
if (sub === "get" && key !== undefined) return cmdGet(key, json);
|
|
226
|
+
if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value, json);
|
|
227
|
+
if (sub === "unset" && key !== undefined) return cmdUnset(key, json);
|
|
228
|
+
if (sub === "tidy") return cmdTidy(json);
|
|
189
229
|
} catch (e) {
|
|
190
|
-
|
|
191
|
-
|
|
230
|
+
emitError({
|
|
231
|
+
json,
|
|
232
|
+
message: `config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`,
|
|
233
|
+
notes: [`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`],
|
|
234
|
+
extra: { path: paths.configJson },
|
|
235
|
+
});
|
|
192
236
|
return 1;
|
|
193
237
|
}
|
|
194
|
-
|
|
238
|
+
emitError({ json, message: "usage: tokenmaxxing config [get <key> | set <key> <value> | unset <key> | tidy]" });
|
|
195
239
|
return 2;
|
|
196
240
|
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
|
7
7
|
import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
|
|
8
8
|
import { isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
|
|
9
9
|
import { CredentialBlobSchema, type RolesResponse } from "../lib/types.ts";
|
|
10
|
-
import { c } from "./render.ts";
|
|
10
|
+
import { c, emitJson } from "./render.ts";
|
|
11
11
|
|
|
12
12
|
async function blobOrg(raw: string): Promise<RolesResponse | null> {
|
|
13
13
|
const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
|
|
@@ -15,11 +15,21 @@ async function blobOrg(raw: string): Promise<RolesResponse | null> {
|
|
|
15
15
|
return fetchTokenOrg(creds.accessToken);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export async function cmdDoctor(): Promise<number> {
|
|
19
|
-
|
|
18
|
+
export async function cmdDoctor(json = false): Promise<number> {
|
|
19
|
+
const checks: { ok: boolean; label: string; hint: string | null }[] = [];
|
|
20
|
+
const notes: string[] = [];
|
|
21
|
+
const warnings: string[] = [];
|
|
20
22
|
const check = (cond: boolean, label: string, hint?: string) => {
|
|
21
|
-
|
|
22
|
-
if (!cond)
|
|
23
|
+
checks.push({ ok: cond, label, hint: cond ? null : (hint ?? null) });
|
|
24
|
+
if (!json) console.log(`${cond ? c.green("✓") : c.red("✗")} ${label}${!cond && hint ? c.dim(` - ${hint}`) : ""}`);
|
|
25
|
+
};
|
|
26
|
+
const note = (text: string) => {
|
|
27
|
+
notes.push(text);
|
|
28
|
+
if (!json) console.log(c.dim(` - ${text}`));
|
|
29
|
+
};
|
|
30
|
+
const warn = (text: string) => {
|
|
31
|
+
warnings.push(text);
|
|
32
|
+
if (!json) console.log(c.yellow(`⚠ ${text}`));
|
|
23
33
|
};
|
|
24
34
|
|
|
25
35
|
check(existsSync(paths.supervisorLink), "claude supervisor wrapper present", "run `tokenmaxxing init`");
|
|
@@ -46,7 +56,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
46
56
|
try {
|
|
47
57
|
const org = await blobOrg(live);
|
|
48
58
|
if (org) check(org.organization_uuid === active.organizationUuid, `live credential identity matches active (${active.email})`, `token belongs to ${org.organization_name} - run \`tokenmaxxing switch\``);
|
|
49
|
-
else
|
|
59
|
+
else note("live credential identity unverifiable (access token expired)");
|
|
50
60
|
} catch (e) {
|
|
51
61
|
check(false, `live credential identity matches active (${active.email})`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
|
|
52
62
|
}
|
|
@@ -59,7 +69,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
59
69
|
try {
|
|
60
70
|
const org = await blobOrg(parked);
|
|
61
71
|
if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - run \`tokenmaxxing auth ${a.label}\``);
|
|
62
|
-
else
|
|
72
|
+
else note(`${a.email} identity unverifiable (access token expired)`);
|
|
63
73
|
} catch (e) {
|
|
64
74
|
check(false, `parked credential identity matches ${a.email}`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
|
|
65
75
|
}
|
|
@@ -77,11 +87,17 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
77
87
|
const rc = shellRcPath();
|
|
78
88
|
if (rc && existsSync(rc)) {
|
|
79
89
|
for (const s of findClaudeShadowers(readFileSync(rc, "utf8"))) {
|
|
80
|
-
if (s.kind === "shadow")
|
|
81
|
-
else
|
|
90
|
+
if (s.kind === "shadow") warn(`${rc}: ${s.line.startsWith("alias ") ? "alias" : "function"} \`claude\` shadows the supervised claude wrapper - launches through it skip tokenmaxxing`);
|
|
91
|
+
else warn(`${rc}: alias \`${s.name}\` hardcodes a claude path and bypasses the supervisor - use plain \`claude\` in its body instead`);
|
|
82
92
|
}
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
const failed = checks.filter((entry) => !entry.ok).length;
|
|
96
|
+
const ok = failed === 0;
|
|
97
|
+
if (json) {
|
|
98
|
+
emitJson(ok ? { ok, checks, notes, warnings } : { ok, error: `issues found - ${failed} of ${checks.length} checks failed`, checks, notes, warnings });
|
|
99
|
+
return ok ? 0 : 1;
|
|
100
|
+
}
|
|
85
101
|
console.log();
|
|
86
102
|
console.log(ok ? c.green("all good ✓") : c.yellow("issues found - see above"));
|
|
87
103
|
return ok ? 0 : 1;
|
package/src/cli/ls.ts
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import { loadAccounts } from "../lib/state.ts";
|
|
2
2
|
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
3
3
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
4
|
-
import { c, claudeTierLabel } from "./render.ts";
|
|
4
|
+
import { c, claudeTierLabel, emitJson } from "./render.ts";
|
|
5
5
|
|
|
6
|
-
export function cmdLs(): number {
|
|
6
|
+
export function cmdLs(json = false): number {
|
|
7
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
|
+
|
|
8
43
|
if (idx.accounts.length === 0) {
|
|
9
44
|
console.log(c.dim("no accounts yet - run `tokenmaxxing init` then `tokenmaxxing add`"));
|
|
10
45
|
}
|