tokenmaxxing 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/config.ts +200 -0
- package/src/lib/state.ts +3 -2
- package/src/main.ts +3 -0
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ claude # use claude as always
|
|
|
44
44
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
45
45
|
| `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
|
|
46
46
|
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
|
|
47
|
+
| `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
|
|
47
48
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
48
49
|
| `tokenmaxxing rename <sel> <label>` · `rm <sel>` | manage the pool |
|
|
49
50
|
| `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": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// `tokenmaxxing config` - inspect, edit, and housekeep config.json (user ask
|
|
2
|
+
// 2026-07-16). Operates on the SPARSE file: config.json holds only overrides
|
|
3
|
+
// and loadConfig merges defaults at read time, so baking defaults into the
|
|
4
|
+
// file would freeze future default changes. `tidy` is the housekeeper: it
|
|
5
|
+
// drops keys the schema no longer knows (e.g. the pre-0.7 flat `threshold`)
|
|
6
|
+
// and normalizes switchModels casing; get/set/unset only ever report them.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { isPlainObject } from "es-toolkit";
|
|
10
|
+
import { get, set, unset } from "es-toolkit/compat";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
|
|
13
|
+
import { ConfigFileSchema, loadConfig } from "../lib/state.ts";
|
|
14
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
15
|
+
import { c } from "./render.ts";
|
|
16
|
+
|
|
17
|
+
/** Hand-maintained mirror of ConfigFileSchema's dotted keys; an invariant test
|
|
18
|
+
* (test/config.test.ts) pins the two together so a new schema field cannot
|
|
19
|
+
* silently become invisible to get/set/tidy. */
|
|
20
|
+
export const KNOWN_KEYS = [
|
|
21
|
+
"thresholds.session",
|
|
22
|
+
"thresholds.weekly",
|
|
23
|
+
"claudeBin",
|
|
24
|
+
"codexBin",
|
|
25
|
+
"policy.projectionMargin",
|
|
26
|
+
"policy.greedySessionFloor",
|
|
27
|
+
"policy.switchModels",
|
|
28
|
+
"policy.usagePollTtlMs",
|
|
29
|
+
"policy.maxWaitMs",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
const RawFileSchema = z.record(z.string(), z.unknown());
|
|
33
|
+
|
|
34
|
+
function readRawFile(): Record<string, unknown> {
|
|
35
|
+
if (!existsSync(paths.configJson)) return {};
|
|
36
|
+
return RawFileSchema.parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeRawFile(input: { raw: Record<string, unknown> }): void {
|
|
40
|
+
writeFileAtomic(paths.configJson, JSON.stringify(input.raw, null, 2) + "\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Dotted keys in `obj` (two levels: this config nests exactly once). */
|
|
44
|
+
function dottedKeys(obj: Record<string, unknown>): string[] {
|
|
45
|
+
const keys: string[] = [];
|
|
46
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
47
|
+
if (isPlainObject(value)) {
|
|
48
|
+
for (const nested of Object.keys(value)) keys.push(`${key}.${nested}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
keys.push(key);
|
|
52
|
+
}
|
|
53
|
+
return keys;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unknownFileKeys(raw: Record<string, unknown>): string[] {
|
|
57
|
+
const known = new Set<string>(KNOWN_KEYS);
|
|
58
|
+
return dottedKeys(raw).filter((key) => !known.has(key));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function envSourceFor(key: string): string | null {
|
|
62
|
+
if (key === "claudeBin" && realClaudeBinFromEnv()) return "TOKENMAXXING_CLAUDE_BIN";
|
|
63
|
+
if (key === "codexBin" && realCodexBinFromEnv()) return "TOKENMAXXING_CODEX_BIN";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printEffective(): number {
|
|
68
|
+
const effective = loadConfig();
|
|
69
|
+
const raw = readRawFile();
|
|
70
|
+
console.log(c.dim(`config.json: ${paths.configJson}`));
|
|
71
|
+
for (const key of KNOWN_KEYS) {
|
|
72
|
+
const env = envSourceFor(key);
|
|
73
|
+
const source = env ? c.yellow(`env ${env}`) : get(raw, key) !== undefined ? c.green("file") : c.dim("default");
|
|
74
|
+
console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${source}`);
|
|
75
|
+
}
|
|
76
|
+
const unknown = unknownFileKeys(raw);
|
|
77
|
+
if (unknown.length > 0) {
|
|
78
|
+
console.log();
|
|
79
|
+
console.log(c.yellow(`unknown keys in the file (ignored by the loader): ${unknown.join(", ")}`));
|
|
80
|
+
console.log(c.dim("run `tokenmaxxing config tidy` to drop them"));
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cmdGet(key: string): number {
|
|
86
|
+
if (!KNOWN_KEYS.some((known) => known === key)) {
|
|
87
|
+
console.error(c.red(`unknown config key: ${key}`));
|
|
88
|
+
console.error(c.dim(`known keys: ${KNOWN_KEYS.join(", ")}`));
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
console.log(JSON.stringify(get(loadConfig(), key)));
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** JSON when it parses (numbers, booleans, arrays), else the literal string. */
|
|
96
|
+
function parseValue(text: string): unknown {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cmdSet(key: string, valueText: string): number {
|
|
105
|
+
if (!KNOWN_KEYS.some((known) => known === key)) {
|
|
106
|
+
console.error(c.red(`unknown config key: ${key}`));
|
|
107
|
+
console.error(c.dim(`known keys: ${KNOWN_KEYS.join(", ")}`));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const raw = readRawFile();
|
|
111
|
+
const next = structuredClone(raw);
|
|
112
|
+
set(next, key, parseValue(valueText));
|
|
113
|
+
const validated = ConfigFileSchema.safeParse(next);
|
|
114
|
+
if (!validated.success) {
|
|
115
|
+
console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
writeRawFile({ raw: next });
|
|
119
|
+
// Report the FILE-level change: with an env override in place, the effective
|
|
120
|
+
// value would not move, and an unchanged-looking arrow would misrepresent
|
|
121
|
+
// the write that just happened.
|
|
122
|
+
const beforeFile = get(raw, key);
|
|
123
|
+
console.log(
|
|
124
|
+
`${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
|
|
125
|
+
);
|
|
126
|
+
const env = envSourceFor(key);
|
|
127
|
+
if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Remove parents emptied by a deletion or a strip: the sparse file must not
|
|
132
|
+
* accumulate `{}` husks. */
|
|
133
|
+
function pruneEmptyParents(raw: Record<string, unknown>): void {
|
|
134
|
+
for (const [topKey, value] of Object.entries(raw)) {
|
|
135
|
+
if (isPlainObject(value) && Object.keys(value).length === 0) {
|
|
136
|
+
delete raw[topKey];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function cmdUnset(key: string): number {
|
|
142
|
+
if (!KNOWN_KEYS.some((known) => known === key)) {
|
|
143
|
+
console.error(c.red(`unknown config key: ${key}`));
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
const raw = readRawFile();
|
|
147
|
+
if (get(raw, key) === undefined) {
|
|
148
|
+
console.log(c.dim(`${key} has no file override (default already applies)`));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const next = structuredClone(raw);
|
|
152
|
+
unset(next, key);
|
|
153
|
+
pruneEmptyParents(next);
|
|
154
|
+
writeRawFile({ raw: next });
|
|
155
|
+
console.log(`${key} unset -> ${JSON.stringify(get(loadConfig(), key))} (default)`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function cmdTidy(): number {
|
|
160
|
+
const raw = readRawFile();
|
|
161
|
+
const dropped = unknownFileKeys(raw);
|
|
162
|
+
// ConfigFileSchema strips unknown keys at both levels; switchModels casing
|
|
163
|
+
// normalizes to what the loader would use anyway.
|
|
164
|
+
const parsed = ConfigFileSchema.parse(raw);
|
|
165
|
+
const next = RawFileSchema.parse(JSON.parse(JSON.stringify(parsed)));
|
|
166
|
+
const models = get(next, "policy.switchModels");
|
|
167
|
+
const normalized = Array.isArray(models) ? models.map((model) => String(model).toLowerCase()) : null;
|
|
168
|
+
const casingChanged = normalized != null && JSON.stringify(normalized) !== JSON.stringify(models);
|
|
169
|
+
if (normalized != null) set(next, "policy.switchModels", normalized);
|
|
170
|
+
pruneEmptyParents(next);
|
|
171
|
+
|
|
172
|
+
// Honest housekeeping: say exactly what changed, write only when something did.
|
|
173
|
+
if (JSON.stringify(next) === JSON.stringify(raw)) {
|
|
174
|
+
console.log(c.dim("nothing to tidy"));
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
writeRawFile({ raw: next });
|
|
178
|
+
if (dropped.length > 0) console.log(`dropped unknown keys: ${dropped.join(", ")}`);
|
|
179
|
+
if (casingChanged) console.log("normalized switchModels casing");
|
|
180
|
+
if (dropped.length === 0 && !casingChanged) console.log("canonicalized file layout (pruned empty sections / key order)");
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function cmdConfig(args: string[]): number {
|
|
185
|
+
const [sub, key, value] = args;
|
|
186
|
+
try {
|
|
187
|
+
if (sub === undefined) return printEffective();
|
|
188
|
+
if (sub === "get" && key !== undefined) return cmdGet(key);
|
|
189
|
+
if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value);
|
|
190
|
+
if (sub === "unset" && key !== undefined) return cmdUnset(key);
|
|
191
|
+
if (sub === "tidy") return cmdTidy();
|
|
192
|
+
} catch (e) {
|
|
193
|
+
// A corrupt config.json fails fast with a recovery step, not a stack trace.
|
|
194
|
+
console.error(c.red(`config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`));
|
|
195
|
+
console.error(c.dim(`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`));
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
console.error(c.red("usage: tokenmaxxing config [get <key> | set <key> <value> | unset <key> | tidy]"));
|
|
199
|
+
return 2;
|
|
200
|
+
}
|
package/src/lib/state.ts
CHANGED
|
@@ -31,8 +31,9 @@ const DEFAULT_CONFIG: Config = {
|
|
|
31
31
|
policy: { projectionMargin: 0, greedySessionFloor: 50, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
/** On-disk shape (all optional); validated via Zod, merged over defaults.
|
|
35
|
-
|
|
34
|
+
/** On-disk shape (all optional); validated via Zod, merged over defaults.
|
|
35
|
+
* Exported for `xx config`, which edits and housekeeps the sparse file. */
|
|
36
|
+
export const ConfigFileSchema = z
|
|
36
37
|
.object({
|
|
37
38
|
thresholds: z.object({ session: z.number(), weekly: z.number() }).partial(),
|
|
38
39
|
claudeBin: z.string(),
|
package/src/main.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { cmdRm } from "./cli/rm.ts";
|
|
|
23
23
|
import { cmdRename } from "./cli/rename.ts";
|
|
24
24
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
25
25
|
import { cmdCheck } from "./cli/check.ts";
|
|
26
|
+
import { cmdConfig } from "./cli/config.ts";
|
|
26
27
|
import { uninstallSupervisor } from "./lib/install.ts";
|
|
27
28
|
import { c } from "./cli/render.ts";
|
|
28
29
|
|
|
@@ -41,6 +42,7 @@ function printHelp(): void {
|
|
|
41
42
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
42
43
|
${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
|
|
43
44
|
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
45
|
+
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
44
46
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
45
47
|
${c.cyan("tokenmaxxing rename")} <sel> <label>
|
|
46
48
|
${c.cyan("tokenmaxxing rm")} <sel>
|
|
@@ -75,6 +77,7 @@ async function main(): Promise<number> {
|
|
|
75
77
|
case "--force": return cmdStatus(true); // bare `xx --force` → status --force
|
|
76
78
|
case "switch": return args[1] === "--codex" ? cmdCodexSwitch(args[2]) : cmdSwitch(args[1]);
|
|
77
79
|
case "check": return cmdCheck();
|
|
80
|
+
case "config": return cmdConfig(args.slice(1));
|
|
78
81
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
79
82
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
80
83
|
case "ls": return cmdLs();
|