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 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
 
@@ -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.10.0",
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.10.0",
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
- console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
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
- console.error(c.red(`check failed: ${detail}`));
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}`);
@@ -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
- console.error("usage: tokenmaxxing rm --codex <email|label|id>");
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
- console.error(c.red(`no codex account matches "${selector}"`));
19
+ emitError({ json, message: `no codex account matches "${selector}"` });
20
20
  return 1;
21
21
  }
22
22
  if (liveCodexAccountId() === account.accountId) {
23
- console.error(c.red(`${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.`));
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
- console.error(c.red(`${account.label} is running in a live codex session - close that session before removing it.`));
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
- console.log(`removed codex account ${c.bold(account.label)} from the pool (${index.accounts.length} left)`);
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
  }
@@ -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
- console.log(c.dim("no codex accounts yet - run `tokenmaxxing init --codex`"));
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
- console.error(c.red(`no codex account matches "${sel}"`));
32
- for (const account of index.accounts) console.error(c.dim(` ${account.label} (${account.accountId.slice(0, 8)})`));
33
- return 1;
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
- console.log(`already on ${c.bold(target.label)}`);
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
- console.error(c.red(`${target.label} is running in a live codex session - swapping onto it would break that session's credential`));
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
- console.error(c.red(`${target.label} needs re-auth - run \`codex login\` in an isolated home and \`tokenmaxxing add --codex\``));
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
- console.error(c.red(`${target.label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``));
52
- return 1;
62
+ deadGrants.push(target.label);
63
+ return fail(deadGrantMessage(target.label));
53
64
  }
54
65
  throw e;
55
66
  }
56
- console.log(`${c.green("✓")} switched codex to ${c.bold(target.label)} (takes effect on the next codex start)`);
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
- console.log(`already on the best codex account: ${c.bold(active?.label ?? "?")}`);
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
- console.log(c.yellow("no usable codex switch target (all at their bars, unmeasured, or needing reauth)"));
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) continue;
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
- console.log(`${c.green("✓")} switched codex to ${c.bold(best.label)} (takes effect on the next codex start)`);
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 printEffective(): number {
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 = envSourceFor(key);
64
- const source = env ? c.yellow(`env ${env}`) : get(raw, key) !== undefined ? c.green("file") : c.dim("default");
65
- console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${source}`);
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 (!KNOWN_KEYS.some((known) => known === key)) {
78
- console.error(c.red(`unknown config key: ${key}`));
79
- console.error(c.dim(`known keys: ${KNOWN_KEYS.join(", ")}`));
80
- return 1;
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(get(loadConfig(), key)));
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 (!KNOWN_KEYS.some((known) => known === key)) {
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
- console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
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
- console.error(c.red(`rejected: ${mergedCheck.detail}`));
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 (!KNOWN_KEYS.some((known) => known === key)) {
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
- console.log(c.dim(`${key} has no file override (default already applies)`));
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
- console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
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
- console.error(c.red(`rejected: ${mergedCheck.detail} (adjust the conflicting override before unsetting ${key})`));
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
- console.log(`${key} unset -> ${JSON.stringify(get(loadConfig(), key))} (default)`);
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
- if (JSON.stringify(next) === JSON.stringify(raw)) {
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
- console.error(c.red(`config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`));
191
- console.error(c.dim(`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`));
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
- console.error(c.red("usage: tokenmaxxing config [get <key> | set <key> <value> | unset <key> | tidy]"));
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
- let ok = true;
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
- console.log(`${cond ? c.green("✓") : c.red("✗")} ${label}${!cond && hint ? c.dim(` - ${hint}`) : ""}`);
22
- if (!cond) ok = false;
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 console.log(c.dim(` - live credential identity unverifiable (access token expired)`));
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 console.log(c.dim(` - ${a.email} identity unverifiable (access token expired)`));
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") console.log(c.yellow(`⚠ ${rc}: \`${s.line}\` shadows the supervised claude wrapper - launches through it skip tokenmaxxing`));
81
- else console.log(c.yellow(`⚠ ${rc}: alias \`${s.name}\` hardcodes a claude path and bypasses the supervisor - use plain \`claude\` in its body instead`));
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
  }