tokenmaxxing 1.30.0 → 1.31.0

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