tokenmaxxing 1.43.0 → 1.45.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.
@@ -2,7 +2,7 @@ import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileS
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { escape } from "es-toolkit";
4
4
  import { z } from "zod";
5
- import { codexPaths, HOME, paths } from "./paths.ts";
5
+ import { codexPaths, codexStoreDirFor, HOME, paths } from "./paths.ts";
6
6
  import { writeFileAtomic } from "./atomic.ts";
7
7
  import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
8
8
  import { resolveRealClaude } from "./claudebin.ts";
@@ -165,6 +165,40 @@ export function uninstallCodexStopHook(): void {
165
165
  writeFileAtomic(codexPaths.hooksJson, JSON.stringify(next, null, 2) + "\n");
166
166
  }
167
167
 
168
+ export function codexStopHookGroupIndex(): number | null {
169
+ if (!existsSync(codexPaths.hooksJson)) return null;
170
+ let parsed: z.infer<typeof CodexHooksFileSchema>;
171
+ try {
172
+ parsed = CodexHooksFileSchema.parse(JSON.parse(readFileSync(codexPaths.hooksJson, "utf8")));
173
+ } catch {
174
+ return null;
175
+ }
176
+ const idx = parsed.hooks.Stop.findIndex((group) => group.hooks.some((hook) => (hook.command ?? "").includes(CODEX_STOP_HOOK_SUBCOMMAND)));
177
+ return idx >= 0 ? idx : null;
178
+ }
179
+
180
+ export function codexStoreHookTrust(accountId: string): "trusted" | "untrusted" | "unknown" {
181
+ const group = codexStopHookGroupIndex();
182
+ if (group == null) return "unknown";
183
+ const key = `${join(codexStoreDirFor(accountId), "hooks.json")}:stop:${group}:0`;
184
+ let text: string;
185
+ try {
186
+ text = readFileSync(join(codexPaths.home, "config.toml"), "utf8");
187
+ } catch {
188
+ return "unknown";
189
+ }
190
+ let inBlock = false;
191
+ for (const line of text.split("\n")) {
192
+ const trimmed = line.trim();
193
+ if (trimmed.startsWith("[")) {
194
+ inBlock = trimmed.startsWith("[hooks.state.") && trimmed.includes(key);
195
+ } else if (inBlock && trimmed.startsWith("trusted_hash")) {
196
+ return "trusted";
197
+ }
198
+ }
199
+ return "untrusted";
200
+ }
201
+
168
202
  export function codexSupervisorLink(): string {
169
203
  return join(paths.binDir, "codex");
170
204
  }
@@ -0,0 +1,186 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { writeFileAtomic } from "./atomic.ts";
6
+ import { opencodeGoAuthJsonFor, opencodeGoPaths, opencodeGoPool, opencodeGoStoreDirFor } from "./paths.ts";
7
+ import type { Observation, Provider, SampleReport } from "./provider.ts";
8
+ import { loadConfig, pinBinOverride, type Harvest } from "./state.ts";
9
+ import type { Account } from "./types.ts";
10
+ import { c } from "../cli/render.ts";
11
+
12
+ const OpencodeAuthEntrySchema = z.looseObject({ type: z.string(), key: z.string().optional() });
13
+ const OpencodeAuthFileSchema = z.record(z.string(), z.unknown());
14
+
15
+ function dataHome(): string {
16
+ return process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME.length > 0 ? process.env.XDG_DATA_HOME : join(homedir(), ".local", "share");
17
+ }
18
+
19
+ function liveAuthPath(): string {
20
+ return join(dataHome(), "opencode", "auth.json");
21
+ }
22
+
23
+ function parseAuthFile(path: string): { provider: string; key: string } | null {
24
+ let raw: unknown;
25
+ try {
26
+ raw = JSON.parse(readFileSync(path, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ const map = OpencodeAuthFileSchema.safeParse(raw);
31
+ if (!map.success) return null;
32
+ const entry = OpencodeAuthEntrySchema.safeParse(map.data["opencode-go"]);
33
+ if (!entry.success || entry.data.type !== "api" || !entry.data.key) return null;
34
+ return { provider: "opencode-go", key: entry.data.key };
35
+ }
36
+
37
+ function idOfKey(key: string): string {
38
+ const h = new Bun.CryptoHasher("sha256");
39
+ h.update(`opencode-go:${key}`);
40
+ return `opencode-go-${h.digest("hex")}`;
41
+ }
42
+
43
+ function resolveRealOpencode(): string {
44
+ const cfg = loadConfig();
45
+ if (cfg.opencodeBin) {
46
+ if (!existsSync(cfg.opencodeBin)) throw new Error(`configured opencodeBin does not exist: ${cfg.opencodeBin} - fix config.json`);
47
+ return cfg.opencodeBin;
48
+ }
49
+ for (const d of (process.env.PATH ?? "").split(":")) {
50
+ if (!d) continue;
51
+ const cand = join(d, "opencode");
52
+ try {
53
+ if (existsSync(cand)) return cand;
54
+ } catch {
55
+ continue;
56
+ }
57
+ }
58
+ throw new Error("could not locate the real `opencode` binary (set opencodeBin in config.json)");
59
+ }
60
+
61
+ function liveId(): string | null {
62
+ return null;
63
+ }
64
+
65
+ function presence(): Map<string, number> {
66
+ return new Map();
67
+ }
68
+
69
+ async function observeLive(account: Account): Promise<Observation | null> {
70
+ return account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
71
+ }
72
+
73
+ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport>> {
74
+ const reports = new Map<string, SampleReport>();
75
+ for (const a of accounts) {
76
+ reports.set(a.id, (await storeUsable(a)) ? { ok: true, source: "probe" } : { ok: false, reason: "no usable credential in this account's store - run `tokenmaxxing auth --opencode-go`" });
77
+ }
78
+ return reports;
79
+ }
80
+
81
+ async function storeUsable(a: Account): Promise<boolean> {
82
+ try {
83
+ const found = parseAuthFile(opencodeGoAuthJsonFor(a.id));
84
+ return found != null && idOfKey(found.key) === a.id;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async function removeCredentials(a: Account): Promise<void> {
91
+ rmSync(opencodeGoStoreDirFor(a.id), { recursive: true, force: true });
92
+ }
93
+
94
+ function harvestOf(key: string): Harvest {
95
+ const id = idOfKey(key);
96
+ return {
97
+ id,
98
+ email: null,
99
+ tier: "go",
100
+ sample: null,
101
+ park: async () => {
102
+ mkdirSync(opencodeGoStoreDirFor(id), { recursive: true });
103
+ writeFileAtomic(opencodeGoAuthJsonFor(id), JSON.stringify({ "opencode-go": { type: "api", key } }, null, 2), 0o600);
104
+ },
105
+ };
106
+ }
107
+
108
+ async function login(): Promise<Harvest | null> {
109
+ const real = resolveRealOpencode();
110
+ const onboardDir = opencodeGoPaths.onboardDir;
111
+ rmSync(onboardDir, { recursive: true, force: true });
112
+ mkdirSync(onboardDir, { recursive: true });
113
+ const p = Bun.spawn([real, "providers", "login", "-p", "opencode-go"], {
114
+ stdin: "inherit",
115
+ stdout: "inherit",
116
+ stderr: "inherit",
117
+ env: { ...process.env, XDG_DATA_HOME: onboardDir, TOKENMAXXING_PROBE: "1" },
118
+ });
119
+ await p.exited;
120
+ try {
121
+ if (p.exitCode !== 0) {
122
+ console.error(c.red("no opencode-go login landed in the isolated home - nothing added."));
123
+ return null;
124
+ }
125
+ const found = parseAuthFile(join(onboardDir, "opencode", "auth.json"));
126
+ if (!found) {
127
+ console.error(c.red("no opencode-go login landed in the isolated home - nothing added."));
128
+ return null;
129
+ }
130
+ return harvestOf(found.key);
131
+ } finally {
132
+ rmSync(onboardDir, { recursive: true, force: true });
133
+ }
134
+ }
135
+
136
+ async function importLive(): Promise<Harvest | null> {
137
+ console.log(c.cyan("Pooling your opencode-go API key - your existing opencode auth stays as it is."));
138
+ console.log();
139
+ const found = parseAuthFile(liveAuthPath());
140
+ if (found) {
141
+ console.log(c.dim(`found an opencode-go credential in ${liveAuthPath()} - pooling it; use \`tokenmaxxing add --opencode-go\` for more keys.`));
142
+ return harvestOf(found.key);
143
+ }
144
+ return login();
145
+ }
146
+
147
+ function preflight(): void {
148
+ const real = resolveRealOpencode();
149
+ const p = Bun.spawnSync([real, "--version"], { stdout: "pipe", stderr: "pipe", timeout: 15_000 });
150
+ const out = (p.stdout?.toString() ?? "").trim();
151
+ if (p.exitCode !== 0 || out === "") {
152
+ throw new Error(`opencode binary failed verification: ${real}`);
153
+ }
154
+ pinBinOverride({ key: "opencodeBin", bin: real });
155
+ }
156
+
157
+ function install(): void {
158
+ console.log(c.dim("opencode-go pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled keys."));
159
+ }
160
+
161
+ export const opencodeGo: Provider = {
162
+ name: "opencode-go",
163
+ flag: " --opencode-go",
164
+ pool: opencodeGoPool,
165
+ seats: "live",
166
+ waitsWhenDepleted: false,
167
+ statusOnly: true,
168
+ liveId,
169
+ presence,
170
+ gatedFamilies: () => null,
171
+ observeLive,
172
+ samplePool,
173
+ mergeWindows: (next) => next,
174
+ swap: async () => {
175
+ throw new Error("opencode-go moves are not supported yet (status-only pool)");
176
+ },
177
+ classifySwapError: () => "fatal",
178
+ removeCredentials,
179
+ storeUsable,
180
+ login,
181
+ importLive,
182
+ preflight,
183
+ install,
184
+ loginStep: () => `Paste the opencode-go API key from ${c.bold("opencode.ai/zen")} when opencode asks for it.`,
185
+ windowLabel: (name) => name.toLowerCase(),
186
+ };
package/src/lib/paths.ts CHANGED
@@ -48,10 +48,22 @@ export const claudePool = {
48
48
 
49
49
  export const codexPool = {
50
50
  accountsJson: join(TM_HOME, "codex-accounts.json"),
51
- lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
51
+ lastSwapJson: null,
52
52
  lockFile: join(TM_HOME, "codex-lock"),
53
53
  } as const;
54
54
 
55
+ export const grokPool = {
56
+ accountsJson: join(TM_HOME, "grok-accounts.json"),
57
+ lastSwapJson: null,
58
+ lockFile: join(TM_HOME, "grok-lock"),
59
+ } as const;
60
+
61
+ export const opencodeGoPool = {
62
+ accountsJson: join(TM_HOME, "opencode-go-accounts.json"),
63
+ lastSwapJson: null,
64
+ lockFile: join(TM_HOME, "opencode-go-lock"),
65
+ } as const;
66
+
55
67
  export type PoolPaths = { accountsJson: string; lastSwapJson: string | null; lockFile: string };
56
68
 
57
69
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
@@ -60,15 +72,59 @@ export const codexPaths = {
60
72
  home: CODEX_HOME,
61
73
  authJson: join(CODEX_HOME, "auth.json"),
62
74
  hooksJson: join(CODEX_HOME, "hooks.json"),
63
- credsDir: join(TM_HOME, "codex-creds"),
75
+ storesDir: join(TM_HOME, "codex-stores"),
64
76
  onboardDir: join(TM_HOME, "codex-onboard"),
65
77
  respawnDir: join(TM_HOME, "codex-respawn"),
66
78
  presenceDir: join(TM_HOME, "codex-live"),
67
- reconcileDir: join(TM_HOME, "codex-reconcile"),
68
79
  } as const;
69
80
 
70
- export function codexCredItemFor(accountId: string): string {
71
- return `tokenmaxxing-codex-${accountId.slice(0, 8)}`;
81
+ export function codexStoreDirFor(accountId: string): string {
82
+ return join(codexPaths.storesDir, shortId(accountId));
83
+ }
84
+
85
+ export function codexAuthJsonFor(accountId: string): string {
86
+ return join(codexStoreDirFor(accountId), "auth.json");
87
+ }
88
+
89
+ export function codexSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
90
+ const home = env.CODEX_HOME;
91
+ if (home == null || home === "") return null;
92
+ return accountIds.find((id) => codexStoreDirFor(id) === home) ?? null;
93
+ }
94
+
95
+ const GROK_HOME_DEFAULT = join(HOME, ".grok");
96
+
97
+ export const grokPaths = {
98
+ home: env("GROK_HOME", GROK_HOME_DEFAULT),
99
+ storesDir: join(TM_HOME, "grok-stores"),
100
+ onboardDir: join(TM_HOME, "grok-onboard"),
101
+ } as const;
102
+
103
+ export function grokStoreDirFor(accountId: string): string {
104
+ return join(grokPaths.storesDir, shortId(accountId));
105
+ }
106
+
107
+ export function grokAuthJsonFor(accountId: string): string {
108
+ return join(grokStoreDirFor(accountId), "auth.json");
109
+ }
110
+
111
+ export function grokSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
112
+ const home = env.GROK_HOME;
113
+ if (home == null || home === "") return null;
114
+ return accountIds.find((id) => grokStoreDirFor(id) === home) ?? null;
115
+ }
116
+
117
+ export const opencodeGoPaths = {
118
+ storesDir: join(TM_HOME, "opencode-go-stores"),
119
+ onboardDir: join(TM_HOME, "opencode-go-onboard"),
120
+ } as const;
121
+
122
+ export function opencodeGoStoreDirFor(accountId: string): string {
123
+ return join(opencodeGoPaths.storesDir, shortId(accountId));
124
+ }
125
+
126
+ export function opencodeGoAuthJsonFor(accountId: string): string {
127
+ return join(opencodeGoStoreDirFor(accountId), "auth.json");
72
128
  }
73
129
 
74
130
  export const keychain = {
@@ -111,4 +167,12 @@ export function realCodexBinFromEnv(): string | undefined {
111
167
  return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
112
168
  }
113
169
 
170
+ export function realGrokBinFromEnv(): string | undefined {
171
+ return EnvOverrideSchema.parse(process.env.TOKENMAXXING_GROK_BIN);
172
+ }
173
+
174
+ export function realOpencodeBinFromEnv(): string | undefined {
175
+ return EnvOverrideSchema.parse(process.env.TOKENMAXXING_OPENCODE_BIN);
176
+ }
177
+
114
178
  export { HOME };
@@ -9,11 +9,12 @@ export type SampleReport = { ok: true; source: "statusline" | "probe" } | { ok:
9
9
  export type SwapFailure = "dead-grant" | "skip" | "fatal";
10
10
 
11
11
  export type Provider = {
12
- name: "claude" | "codex";
12
+ name: "claude" | "codex" | "grok" | "opencode-go";
13
13
  flag: string;
14
14
  pool: PoolPaths;
15
15
  seats: "shared" | "live";
16
16
  waitsWhenDepleted: boolean;
17
+ statusOnly: boolean;
17
18
  liveId(): string | null;
18
19
  presence(): Map<string, number>;
19
20
  gatedFamilies(cfg: Config): string[] | null;
package/src/lib/state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
2
2
  import { isEqual } from "es-toolkit";
3
3
  import { z } from "zod";
4
- import { paths, realClaudeBinFromEnv, realCodexBinFromEnv, usageJsonFor, type PoolPaths } from "./paths.ts";
4
+ import { paths, realClaudeBinFromEnv, realCodexBinFromEnv, realGrokBinFromEnv, realOpencodeBinFromEnv, usageJsonFor, type PoolPaths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
@@ -35,10 +35,14 @@ export function loadConfig(): Config {
35
35
  if (envBin) cfg.claudeBin = envBin;
36
36
  const envCodexBin = realCodexBinFromEnv();
37
37
  if (envCodexBin) cfg.codexBin = envCodexBin;
38
+ const envGrokBin = realGrokBinFromEnv();
39
+ if (envGrokBin) cfg.grokBin = envGrokBin;
40
+ const envOpencodeBin = realOpencodeBinFromEnv();
41
+ if (envOpencodeBin) cfg.opencodeBin = envOpencodeBin;
38
42
  return cfg;
39
43
  }
40
44
 
41
- export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: string }): void {
45
+ export function pinBinOverride(input: { key: "claudeBin" | "codexBin" | "grokBin" | "opencodeBin"; bin: string }): void {
42
46
  let raw: Record<string, unknown> = {};
43
47
  if (existsSync(paths.configJson)) {
44
48
  raw = z.record(z.string(), z.unknown()).parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
package/src/lib/types.ts CHANGED
@@ -110,6 +110,8 @@ export const ConfigSchema = z
110
110
  .prefault({}),
111
111
  claudeBin: z.string().default(""),
112
112
  codexBin: z.string().default(""),
113
+ grokBin: z.string().default(""),
114
+ opencodeBin: z.string().default(""),
113
115
  policy: z
114
116
  .object({
115
117
  projectionMargin: z.number().min(0).max(100).default(0),
@@ -227,13 +229,8 @@ export const CodexStopStdinSchema = z.looseObject({
227
229
  });
228
230
 
229
231
  export const CodexRespawnMarkerSchema = z.object({
230
- account: z.string(),
231
- sessionId: z.string().nullable(),
232
- ts: z.number(),
233
- });
234
-
235
- export const CodexReconcileMarkerSchema = z.object({
236
232
  accountId: z.string(),
233
+ sessionId: z.string().nullable(),
237
234
  ts: z.number(),
238
235
  });
239
236
 
package/src/main.ts CHANGED
@@ -11,6 +11,8 @@ import { runCodexSupervisor } from "./entries/codexsupervisor.ts";
11
11
  import { runCodexStopHook } from "./entries/codexstophook.ts";
12
12
  import { claude } from "./lib/claude.ts";
13
13
  import { codex } from "./lib/codex.ts";
14
+ import { grok } from "./lib/grok.ts";
15
+ import { opencodeGo } from "./lib/opencodego.ts";
14
16
  import { cmdInit } from "./cli/init.ts";
15
17
  import { cmdAdd } from "./cli/add.ts";
16
18
  import { cmdAuth } from "./cli/auth.ts";
@@ -18,7 +20,6 @@ import { cmdStatus } from "./cli/status.ts";
18
20
  import { cmdDoctor } from "./cli/doctor.ts";
19
21
  import { cmdRm } from "./cli/rm.ts";
20
22
  import { cmdRename } from "./cli/rename.ts";
21
- import { cmdSwitch } from "./cli/switch.ts";
22
23
  import { cmdCheck } from "./cli/check.ts";
23
24
  import { cmdConfig } from "./cli/config.ts";
24
25
  import { cmdSetupToken } from "./cli/setuptoken.ts";
@@ -30,8 +31,11 @@ import { c, emitError } from "./cli/render.ts";
30
31
  const JSON_FLAG = "--json";
31
32
  const CACHED_FLAG = "--cached";
32
33
  const CODEX_FLAG = "--codex";
33
- const JSON_COMMANDS = new Set(["status", "config", "check", "switch"]);
34
- const CODEX_COMMANDS = new Set(["init", "add", "auth", "switch", "rm", "rename"]);
34
+ const GROK_FLAG = "--grok";
35
+ const OPENCODE_GO_FLAG = "--opencode-go";
36
+ const JSON_COMMANDS = new Set(["status", "config", "check"]);
37
+ const CODEX_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
38
+ const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
35
39
 
36
40
  function printHelp(): void {
37
41
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
@@ -39,22 +43,23 @@ function printHelp(): void {
39
43
  ${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
40
44
  ${c.cyan("tokenmaxxing check")} sample the account whose usage figure is oldest (run by the periodic timer)
41
45
  ${c.cyan("tokenmaxxing init")} log in the first account (isolated) + install supervisor & hooks
42
- ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
46
+ ${c.cyan("tokenmaxxing init --codex")} same for codex: log in the first account, isolated, install codex supervisor + Stop hook
47
+ ${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
48
+ ${c.cyan("tokenmaxxing init --opencode-go")} pool opencode-go API keys (status-only: no supervisor yet)
43
49
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
44
50
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
45
- ${c.cyan("tokenmaxxing auth")} [--codex] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
46
- ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
51
+ ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --opencode-go] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
47
52
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
48
53
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
49
54
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
50
- ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
51
- ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
55
+ ${c.cyan("tokenmaxxing rename")} [--codex | --grok | --opencode-go] <sel> <label>
56
+ ${c.cyan("tokenmaxxing rm")} [--codex | --grok | --opencode-go] <sel>
52
57
  ${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
53
58
  ${c.cyan("tokenmaxxing setup-token")} [--print | rm <label|uuid>] Cursor Cloud only: mint one \`claude setup-token\` per pooled account (browser sign-in each) and print the TOKENMAXXING_TOKENS secret value; ${c.cyan("--print")} prints the stored set, ${c.cyan("rm")} drops one
54
59
  ${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
55
60
  ${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
56
61
 
57
- ${c.cyan("--json")} print one JSON document on stdout instead of text (status, config, check, switch); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
62
+ ${c.cyan("--json")} print one JSON document on stdout instead of text (status, config, check); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
58
63
 
59
64
  ${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
60
65
  }
@@ -79,8 +84,13 @@ async function main(): Promise<number> {
79
84
  jsonMode = argv.includes(JSON_FLAG);
80
85
  const json = jsonMode;
81
86
  const cached = argv.includes(CACHED_FLAG);
82
- const provider = argv.includes(CODEX_FLAG) ? codex : claude;
83
- const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== CODEX_FLAG);
87
+ const providerFlags = [CODEX_FLAG, GROK_FLAG, OPENCODE_GO_FLAG].filter((f) => argv.includes(f));
88
+ if (providerFlags.length > 1) {
89
+ emitError({ json, message: `${providerFlags.join(" and ")} are mutually exclusive - pick one pool` });
90
+ return 2;
91
+ }
92
+ const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : argv.includes(OPENCODE_GO_FLAG) ? opencodeGo : claude;
93
+ const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== OPENCODE_GO_FLAG);
84
94
  const sub = args[0];
85
95
 
86
96
  if (cached && sub != null && sub !== "status") {
@@ -93,6 +103,12 @@ async function main(): Promise<number> {
93
103
  return 2;
94
104
  }
95
105
 
106
+ if ((provider === grok || provider === opencodeGo) && (sub == null || !STATUS_ONLY_COMMANDS.has(sub))) {
107
+ const flag = provider === grok ? GROK_FLAG : OPENCODE_GO_FLAG;
108
+ emitError({ json, message: `${flag} applies to ${[...STATUS_ONLY_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
109
+ return 2;
110
+ }
111
+
96
112
  if (json && sub != null && !JSON_COMMANDS.has(sub)) {
97
113
  emitError({ json, message: `${sub} has no ${JSON_FLAG} form (${JSON_FLAG} applies to ${[...JSON_COMMANDS].join(", ")})` });
98
114
  return 2;
@@ -113,13 +129,6 @@ async function main(): Promise<number> {
113
129
  }
114
130
  return cmdStatus({ json, cached });
115
131
  }
116
- case "switch": {
117
- if (provider !== codex) {
118
- emitError({ json, message: "Claude account placement is per session at launch; manual switching is available with `tokenmaxxing switch --codex`." });
119
- return 2;
120
- }
121
- return cmdSwitch(args[1], json);
122
- }
123
132
  case "check": {
124
133
  if (args.length > 1) {
125
134
  emitError({ json, message: `unknown check option: ${args[1]} (check takes no options; the timer runs a plain check every tick)` });
@@ -156,7 +165,7 @@ async function main(): Promise<number> {
156
165
  console.log(`removed ${removed.join(", ")}`);
157
166
  if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
158
167
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
159
- console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
168
+ console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
160
169
  return 0;
161
170
  }
162
171
  case "help":
package/src/cli/switch.ts DELETED
@@ -1,116 +0,0 @@
1
- import { withLock } from "../lib/lock.ts";
2
- import { codex } from "../lib/codex.ts";
3
- import { loadAccounts, loadConfig } from "../lib/state.ts";
4
- import { currentWins, pickBest, thresholdBars, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
5
- import { findAccount } from "./rename.ts";
6
- import { log } from "../lib/log.ts";
7
- import { c, emitError, emitJson, fmtReset } from "./render.ts";
8
- import type { Account } from "../lib/types.ts";
9
-
10
- const SWITCH_MARGIN = 1.2;
11
-
12
- export async function cmdSwitch(selector?: string, json = false): Promise<number> {
13
- const p = codex;
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 ?? {}) });
22
- return 1;
23
- };
24
- const reauthHint = (a: Account) => `run \`tokenmaxxing auth${p.flag} ${a.label}\``;
25
- const deadGrantMessage = (a: Account) => `${a.label}'s refresh token is dead - ${reauthHint(a)}`;
26
- const switched = (target: Account) => `${c.green("↻")} switched to ${c.bold(target.label)} (takes effect on the next codex start)`;
27
-
28
- const idx0 = loadAccounts(p.pool);
29
- if (idx0.accounts.length < 2) return fail(`need at least 2 accounts to switch - add one with \`tokenmaxxing add${p.flag}\``, { paint: c.yellow });
30
- const cfg = loadConfig();
31
- const now = Date.now();
32
-
33
- return withLock(p.pool.lockFile, async () => {
34
- const idx = loadAccounts(p.pool);
35
- const claimed = p.liveId();
36
- const drifted = claimed != null && claimed !== idx.activeId;
37
- const present = p.presence();
38
-
39
- const swapTo = async (target: Account, reason: string, extra: Record<string, unknown> = {}): Promise<number> => {
40
- try {
41
- await p.swap(target);
42
- } catch (e) {
43
- if (p.classifySwapError(e) === "dead-grant") {
44
- deadGrants.push(target.label);
45
- return fail(deadGrantMessage(target));
46
- }
47
- throw e;
48
- }
49
- log("switch.manual", { account: target.id.slice(0, 8), reason });
50
- emit(switched(target), { switched: true, account: target.label, reason, ...extra });
51
- return 0;
52
- };
53
-
54
- if (selector) {
55
- const target = findAccount(idx.accounts, selector);
56
- if (!target) return fail(`no account matches "${selector}"`);
57
- if (target.id === idx.activeId && !drifted) {
58
- emit(`already on ${c.bold(target.label)}`, { switched: false, account: target.label, reason: "already-on" });
59
- return 0;
60
- }
61
- if (target.id !== claimed && present.has(target.id)) {
62
- return fail(`${target.label} is running in a live ${p.name} session - swapping onto it would break that session's credential`);
63
- }
64
- if (target.needsReauth) return fail(`${target.label} needs re-auth - ${reauthHint(target)}`);
65
- return swapTo(target, "selected");
66
- }
67
-
68
- const everyone: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: p.gatedFamilies(cfg), seats: null };
69
- const rejected = new Set<string>();
70
- const candidatesOf = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (a.id === claimed || !present.has(a.id)));
71
- while (true) {
72
- const cur = loadAccounts(p.pool);
73
- const pool = candidatesOf(cur.accounts);
74
- const active =
75
- (claimed != null ? cur.accounts.find((a) => a.id === claimed) : null) ??
76
- cur.accounts.find((a) => a.id === cur.activeId) ??
77
- null;
78
- if (active != null && currentWins(active, pool, everyone, SWITCH_MARGIN)) {
79
- if (drifted) return swapTo(active, "drift-reconciled");
80
- const expiry = weeklyExpiry(active, now);
81
- const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
82
- emit(`already on the best account: ${c.bold(active.label)}${why}`, {
83
- switched: false,
84
- account: active.label,
85
- reason: "current-wins",
86
- weeklyResetsAt: Number.isFinite(expiry) ? expiry : null,
87
- });
88
- return 0;
89
- }
90
- const best = pickBest(pool, { ...everyone, currentId: active?.id ?? null });
91
- if (!best) break;
92
- try {
93
- await p.swap(best);
94
- } catch (e) {
95
- rejected.add(best.id);
96
- const kind = p.classifySwapError(e);
97
- if (kind === "dead-grant") {
98
- deadGrants.push(best.label);
99
- if (!json) console.error(c.red(deadGrantMessage(best)));
100
- continue;
101
- }
102
- if (kind === "skip") {
103
- if (!json) console.error(c.yellow(`${best.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
104
- continue;
105
- }
106
- throw e;
107
- }
108
- log("switch.manual", { account: best.id.slice(0, 8), reason: "best" });
109
- emit(switched(best), { switched: true, account: best.label, reason: "best" });
110
- return 0;
111
- }
112
-
113
- const reauth = loadAccounts(p.pool).accounts.filter((a) => a.needsReauth).map((a) => a.label);
114
- return fail(`no usable ${p.name} switch target (all at their bars, unmeasured, or needing reauth)`, { paint: c.yellow, extra: { reauthNeeded: reauth } });
115
- });
116
- }