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/src/cli/switch.ts CHANGED
@@ -7,15 +7,24 @@ import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry,
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
8
  import { gatedFamilies } from "../lib/usage.ts";
9
9
  import { findAccount } from "./rename.ts";
10
- import { c, fmtReset } from "./render.ts";
10
+ import { c, emitError, emitJson, fmtReset } from "./render.ts";
11
11
  import type { Account } from "../lib/types.ts";
12
12
 
13
- export async function cmdSwitch(selector?: string): Promise<number> {
14
- const idx0 = loadAccounts();
15
- if (idx0.accounts.length < 2) {
16
- console.error(c.yellow("need at least 2 accounts to switch - add one with `tokenmaxxing add`"));
13
+ export async function cmdSwitch(selector?: 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; extra?: Record<string, unknown> } = {}): number => {
21
+ emitError({ json, message, paint: opts.paint, extra: withDeadGrants(opts.extra ?? {}) });
17
22
  return 1;
18
- }
23
+ };
24
+ const deadGrantMessage = (a: Account) => `${a.label}'s refresh token is dead - run \`tokenmaxxing auth ${a.label}\``;
25
+
26
+ const idx0 = loadAccounts();
27
+ if (idx0.accounts.length < 2) return fail("need at least 2 accounts to switch - add one with `tokenmaxxing add`", { paint: c.yellow });
19
28
  const cfg = loadConfig();
20
29
  const now = Date.now();
21
30
 
@@ -26,23 +35,29 @@ export async function cmdSwitch(selector?: string): Promise<number> {
26
35
  const claimedOrg = liveClaim?.organizationUuid ?? null;
27
36
  const drifted = claimed != null && claimed !== idx.activeAccountUuid;
28
37
 
29
- const swapTo = async (target: Account): Promise<number> => {
38
+ const swapTo = async (target: Account, reason: string, extra: Record<string, unknown> = {}): Promise<number> => {
30
39
  try {
31
40
  await performSwap(target);
32
41
  } catch (e) {
33
- if (e instanceof InvalidGrantError) { console.error(c.red(`${target.label}'s refresh token is dead - run \`tokenmaxxing auth ${target.label}\``)); return 1; }
42
+ if (e instanceof InvalidGrantError) {
43
+ deadGrants.push(target.label);
44
+ return fail(deadGrantMessage(target));
45
+ }
34
46
  throw e;
35
47
  }
36
- console.log(`${c.green("↻")} switched to ${c.bold(target.label)}`);
48
+ emit(`${c.green("↻")} switched to ${c.bold(target.label)}`, { switched: true, account: target.label, reason, ...extra });
37
49
  return 0;
38
50
  };
39
51
 
40
52
  if (selector) {
41
53
  const target = findAccount(idx.accounts, selector);
42
- if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
43
- if (target.accountUuid === idx.activeAccountUuid && !drifted) { console.log(`already on ${c.bold(target.label)}`); return 0; }
44
- if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - run \`tokenmaxxing auth ${target.label}\``)); return 1; }
45
- return swapTo(target);
54
+ if (!target) return fail(`no account matches "${selector}"`);
55
+ if (target.accountUuid === idx.activeAccountUuid && !drifted) {
56
+ emit(`already on ${c.bold(target.label)}`, { switched: false, account: target.label, reason: "already-on" });
57
+ return 0;
58
+ }
59
+ if (target.needsReauth) return fail(`${target.label} needs re-auth - run \`tokenmaxxing auth ${target.label}\``);
60
+ return swapTo(target, "selected");
46
61
  }
47
62
 
48
63
  const switchFamilies = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
@@ -60,10 +75,15 @@ export async function cmdSwitch(selector?: string): Promise<number> {
60
75
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
61
76
  null;
62
77
  if (active != null && currentWins(active, cur.accounts, everyone)) {
63
- if (drifted) return swapTo(active);
78
+ if (drifted) return swapTo(active, "drift-reconciled");
64
79
  const expiry = weeklyExpiry(active, now);
65
80
  const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
66
- console.log(`already on the best account: ${c.bold(active.label)}${why}`);
81
+ emit(`already on the best account: ${c.bold(active.label)}${why}`, {
82
+ switched: false,
83
+ account: active.label,
84
+ reason: "current-wins",
85
+ weeklyResetsAt: Number.isFinite(expiry) ? expiry : null,
86
+ });
67
87
  return 0;
68
88
  }
69
89
  const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
@@ -72,37 +92,53 @@ export async function cmdSwitch(selector?: string): Promise<number> {
72
92
  await performSwap(best);
73
93
  } catch (e) {
74
94
  if (e instanceof InvalidGrantError) {
75
- console.error(c.red(`${best.label}'s refresh token is dead - run \`tokenmaxxing auth ${best.label}\``));
95
+ deadGrants.push(best.label);
96
+ if (!json) console.error(c.red(deadGrantMessage(best)));
76
97
  continue;
77
98
  }
78
99
  throw e;
79
100
  }
80
- console.log(`${c.green("↻")} switched to ${c.bold(best.label)}`);
101
+ emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
81
102
  return 0;
82
103
  }
83
104
 
84
105
  const fresh = loadAccounts();
85
106
  const earliest = pickEarliestReset(fresh.accounts, everyoneIn(fresh.accounts));
107
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
86
108
  if (!earliest) {
87
- const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
88
- if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
109
+ if (reauth.length > 0) {
110
+ return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
111
+ paint: c.yellow,
112
+ extra: { reauthNeeded: reauth },
113
+ });
114
+ }
89
115
  const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
90
- if (drifted && freshActive) return swapTo(freshActive);
91
- console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
116
+ if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
117
+ emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
118
+ switched: false,
119
+ account: freshActive?.label ?? null,
120
+ reason: "unknown-resets",
121
+ });
92
122
  return 0;
93
123
  }
94
- const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
95
124
  const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
125
+ const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
96
126
  if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
97
- const msg = earliest.availableAt <= now
127
+ const msg = availableAt == null
98
128
  ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
99
- : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})${reauthNote}`;
100
- console.log(c.yellow(msg));
129
+ : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
130
+ emit(c.yellow(msg), {
131
+ switched: false,
132
+ account: earliest.account.label,
133
+ reason: availableAt == null ? "no-target" : "all-at-limit",
134
+ availableAt,
135
+ reauthNeeded: reauth,
136
+ });
101
137
  return 0;
102
138
  }
103
- const code = await swapTo(earliest.account);
104
- if (code === 0 && earliest.availableAt > now) {
105
- console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(earliest.availableAt, now)})${reauthNote}`));
139
+ const code = await swapTo(earliest.account, "earliest-reset", { availableAt, reauthNeeded: reauth });
140
+ if (code === 0 && availableAt != null && !json) {
141
+ console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
106
142
  }
107
143
  return code;
108
144
  });
package/src/cli/watch.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { clamp, delay } from "es-toolkit";
2
2
  import { loadAccounts } from "../lib/state.ts";
3
+ import { loadCodexAccounts } from "../lib/codexstate.ts";
3
4
  import { cmdStatus } from "./status.ts";
4
- import { c } from "./render.ts";
5
+ import { c, emitError, emitJson } from "./render.ts";
5
6
 
6
7
  const DEFAULT_INTERVAL_S = 120;
7
8
  const MIN_INTERVAL_S = 30;
@@ -16,13 +17,13 @@ export function resolveWatchInterval(arg?: string): number | null {
16
17
 
17
18
  const CLEAR = "\x1b[H\x1b[2J\x1b[3J";
18
19
 
19
- export async function cmdWatch(intervalArg?: string): Promise<number> {
20
+ export async function cmdWatch(intervalArg?: string, json = false): Promise<number> {
20
21
  const intervalS = resolveWatchInterval(intervalArg);
21
22
  if (intervalS === null) {
22
- console.error(c.red(`watch interval must be a positive number of seconds, got: ${intervalArg}`));
23
+ emitError({ json, message: `watch interval must be a positive number of seconds, got: ${intervalArg}` });
23
24
  return 2;
24
25
  }
25
- if (loadAccounts().accounts.length === 0) return cmdStatus();
26
+ if (loadAccounts().accounts.length === 0 && loadCodexAccounts().accounts.length === 0) return cmdStatus({ json });
26
27
 
27
28
  const paintHeader = () => {
28
29
  process.stdout.write(process.stdout.isTTY ? CLEAR : "\n");
@@ -30,10 +31,15 @@ export async function cmdWatch(intervalArg?: string): Promise<number> {
30
31
  };
31
32
  while (true) {
32
33
  try {
33
- await cmdStatus(false, paintHeader);
34
+ await cmdStatus(json ? { json } : { preRender: paintHeader });
34
35
  } catch (e) {
35
- paintHeader();
36
- console.error(c.red(`status failed this tick: ${e instanceof Error ? e.message : String(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
+ }
37
43
  }
38
44
  await delay(intervalS * 1000);
39
45
  }
@@ -151,7 +151,7 @@ export function createTokenmaxxingMcpServer(): McpServer {
151
151
  inputSchema: {},
152
152
  },
153
153
  async () => {
154
- const cap = await captureCli(() => cmdStatus(false));
154
+ const cap = await captureCli(() => cmdStatus());
155
155
  return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
156
156
  },
157
157
  );
package/src/main.ts CHANGED
@@ -26,7 +26,10 @@ import { cmdSwitch } from "./cli/switch.ts";
26
26
  import { cmdCheck } from "./cli/check.ts";
27
27
  import { cmdConfig } from "./cli/config.ts";
28
28
  import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
29
- import { c } from "./cli/render.ts";
29
+ import { c, emitError, emitJson } from "./cli/render.ts";
30
+
31
+ const JSON_FLAG = "--json";
32
+ const INTERACTIVE_COMMANDS = new Set(["init", "add", "auth"]);
30
33
 
31
34
  function printHelp(): void {
32
35
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
@@ -50,30 +53,45 @@ function printHelp(): void {
50
53
  ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
51
54
  ${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
52
55
 
56
+ ${c.cyan("--json")} print one JSON document on stdout instead of text (status, ls, config, doctor, check, switch, rename, rm, uninstall; one per tick for watch); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
57
+
53
58
  ${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
54
59
  }
55
60
 
61
+ let jsonMode = false;
62
+
56
63
  async function main(): Promise<number> {
57
64
  if (process.platform !== "darwin" && process.platform !== "linux") {
58
65
  console.error(`tokenmaxxing supports macOS and Linux only (this is ${process.platform})`);
59
66
  return 1;
60
67
  }
61
- const args = process.argv.slice(2);
68
+ const argv = process.argv.slice(2);
62
69
  const argv0 = basename(process.argv0 || process.argv[0] || "");
63
- const sub = args[0];
64
70
 
65
- if (argv0 === "claude" || sub === "__supervise") {
66
- return runSupervisor(sub === "__supervise" ? args.slice(1) : args);
71
+ if (argv0 === "claude" || argv[0] === "__supervise") {
72
+ return runSupervisor(argv[0] === "__supervise" ? argv.slice(1) : argv);
67
73
  }
68
- if (argv0 === "codex" || sub === "__supervise-codex") {
69
- return runCodexSupervisor({ argv: sub === "__supervise-codex" ? args.slice(1) : args });
74
+ if (argv0 === "codex" || argv[0] === "__supervise-codex") {
75
+ return runCodexSupervisor({ argv: argv[0] === "__supervise-codex" ? argv.slice(1) : argv });
70
76
  }
71
77
 
78
+ jsonMode = argv.includes(JSON_FLAG);
79
+ const json = jsonMode;
80
+ const args = argv.filter((a) => a !== JSON_FLAG);
81
+ const sub = args[0];
82
+
83
+ if (json && sub != null && INTERACTIVE_COMMANDS.has(sub)) {
84
+ emitError({ json, message: `${sub} is interactive (it runs a login flow) and has no --json form` });
85
+ return 2;
86
+ }
72
87
  if (!(sub != null && sub.startsWith("__")) && !process.env.TOKENMAXXING_PROBE) {
73
88
  const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
74
89
  const ambient = nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
75
90
  if (ambient != null) {
76
- console.error(c.red(`CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): claude uses a namespaced credential store there that tokenmaxxing does not manage - unset it (or run from a clean shell) and retry.`));
91
+ emitError({
92
+ json,
93
+ message: `CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): claude uses a namespaced credential store there that tokenmaxxing does not manage - unset it (or run from a clean shell) and retry.`,
94
+ });
77
95
  return 1;
78
96
  }
79
97
  }
@@ -85,26 +103,26 @@ async function main(): Promise<number> {
85
103
  case "__stop-failure-hook": return runStopFailureHook();
86
104
  case "__session-start": return runSessionStart();
87
105
  case "__codex-stop-hook": return runCodexStopHook();
88
- case undefined: return cmdStatus();
89
- case "--force": return cmdStatus(true);
106
+ case undefined: return cmdStatus({ json });
107
+ case "--force": return cmdStatus({ force: true, json });
90
108
  case "switch": {
91
109
  const rest = args.slice(1).filter((a) => a !== "--codex");
92
- return args.includes("--codex") ? cmdCodexSwitch(rest[0]) : cmdSwitch(rest[0]);
110
+ return args.includes("--codex") ? cmdCodexSwitch(rest[0], json) : cmdSwitch(rest[0], json);
93
111
  }
94
- case "check": return cmdCheck(args.slice(1));
95
- case "config": return cmdConfig(args.slice(1));
112
+ case "check": return cmdCheck(args.slice(1), json);
113
+ case "config": return cmdConfig(args.slice(1), json);
96
114
  case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
97
115
  case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
98
116
  case "auth": return cmdAuth(args.slice(1));
99
- case "ls": return cmdLs();
100
- case "status": return cmdStatus(args.includes("--force"));
101
- case "watch": return cmdWatch(args[1]);
102
- case "doctor": return cmdDoctor();
117
+ case "ls": return cmdLs(json);
118
+ case "status": return cmdStatus({ force: args.includes("--force"), json });
119
+ case "watch": return cmdWatch(args[1], json);
120
+ case "doctor": return cmdDoctor(json);
103
121
  case "rm": {
104
122
  const rest = args.slice(1).filter((a) => a !== "--codex");
105
- return args.includes("--codex") ? cmdCodexRm(rest[0]) : cmdRm(rest[0]);
123
+ return args.includes("--codex") ? cmdCodexRm(rest[0], json) : cmdRm(rest[0], json);
106
124
  }
107
- case "rename": return cmdRename(args.slice(1));
125
+ case "rename": return cmdRename(args.slice(1), json);
108
126
  case "uninstall": {
109
127
  const out = uninstallSupervisor();
110
128
  const removed = [
@@ -113,6 +131,10 @@ async function main(): Promise<number> {
113
131
  ...(out.timerDeactivated ? ["check timer"] : []),
114
132
  ...(out.pathLineRemoved ? ["rc PATH line"] : []),
115
133
  ];
134
+ if (json) {
135
+ emitJson({ ok: true, removed, timerDeactivated: out.timerDeactivated, pathLineRemoved: out.pathLineRemoved });
136
+ return 0;
137
+ }
116
138
  console.log(`removed ${removed.join(", ")}`);
117
139
  if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
118
140
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
@@ -125,8 +147,8 @@ async function main(): Promise<number> {
125
147
  printHelp();
126
148
  return 0;
127
149
  default:
128
- console.error(c.red(`unknown command: ${sub}`));
129
- printHelp();
150
+ emitError({ json, message: `unknown command: ${sub}` });
151
+ if (!json) printHelp();
130
152
  return 2;
131
153
  }
132
154
  }
@@ -134,6 +156,6 @@ async function main(): Promise<number> {
134
156
  try {
135
157
  process.exit(await main());
136
158
  } catch (e) {
137
- console.error(c.red(e instanceof Error ? e.message : String(e)));
159
+ emitError({ json: jsonMode, message: e instanceof Error ? e.message : String(e) });
138
160
  process.exit(1);
139
161
  }