tokenmaxxing 1.43.0 → 1.44.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/package.json +1 -1
- package/src/cli/auth.ts +1 -1
- package/src/cli/init.ts +6 -2
- package/src/cli/status.ts +17 -5
- package/src/lib/claude.ts +1 -0
- package/src/lib/codex.ts +1 -0
- package/src/lib/grok.ts +188 -0
- package/src/lib/opencodego.ts +186 -0
- package/src/lib/paths.ts +55 -0
- package/src/lib/provider.ts +2 -1
- package/src/lib/state.ts +6 -2
- package/src/lib/types.ts +2 -0
- package/src/main.ts +23 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/auth.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { usageNote } from "./add.ts";
|
|
|
8
8
|
import { c, count } from "./render.ts";
|
|
9
9
|
import type { Account, AccountsIndex } from "../lib/types.ts";
|
|
10
10
|
|
|
11
|
-
const AUTH_USAGE = "usage: tokenmaxxing auth [--codex] [<email|label|id> | --all]";
|
|
11
|
+
const AUTH_USAGE = "usage: tokenmaxxing auth [--codex | --grok | --opencode-go] [<email|label|id> | --all]";
|
|
12
12
|
|
|
13
13
|
const AuthPlanSchema = z.discriminatedUnion("kind", [
|
|
14
14
|
z.object({ kind: z.literal("usage") }),
|
package/src/cli/init.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { c, count } from "./render.ts";
|
|
|
8
8
|
export function printUsage(p: Provider): void {
|
|
9
9
|
console.log();
|
|
10
10
|
console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
|
|
11
|
-
|
|
11
|
+
if (p.statusOnly) {
|
|
12
|
+
console.log(` ${c.cyan(`xx status`)} view pooled ${p.name} accounts (status-only pool: no automatic switching yet)`);
|
|
13
|
+
} else {
|
|
14
|
+
console.log(` ${c.cyan(p.name)} use ${p.name} as always; it switches accounts near quota automatically`);
|
|
15
|
+
}
|
|
12
16
|
console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
|
|
13
17
|
console.log(` ${c.cyan(`xx add${p.flag}`)} log in and pool another account`);
|
|
14
18
|
console.log(` ${c.cyan(`xx switch${p.flag}`)} hop to the best account right now (the automatic switching needs no command)`);
|
|
@@ -37,7 +41,7 @@ export async function cmdInit(p: Provider): Promise<number> {
|
|
|
37
41
|
await harvested.park();
|
|
38
42
|
const idx = loadAccounts(p.pool);
|
|
39
43
|
const imported = upsertAccount(idx, harvested, p.mergeWindows);
|
|
40
|
-
idx.activeId = p.seats === "live" ? harvested.id : null;
|
|
44
|
+
idx.activeId = !p.statusOnly && p.seats === "live" ? harvested.id : null;
|
|
41
45
|
saveAccounts(p.pool, idx);
|
|
42
46
|
return imported;
|
|
43
47
|
});
|
package/src/cli/status.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { chunk, sortBy } from "es-toolkit";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { claude } from "../lib/claude.ts";
|
|
4
4
|
import { codex } from "../lib/codex.ts";
|
|
5
|
+
import { grok } from "../lib/grok.ts";
|
|
6
|
+
import { opencodeGo } from "../lib/opencodego.ts";
|
|
5
7
|
import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
|
|
6
8
|
import { withLock } from "../lib/lock.ts";
|
|
7
|
-
import { codexPool } from "../lib/paths.ts";
|
|
9
|
+
import { codexPool, grokPool, opencodeGoPool } from "../lib/paths.ts";
|
|
8
10
|
import { earliestReset, isExhausted, isSessionWindow, limitWindows, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
9
11
|
import type { Provider, SampleReport } from "../lib/provider.ts";
|
|
10
12
|
import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
|
|
@@ -52,7 +54,7 @@ const PoolReportSchema = z.object({
|
|
|
52
54
|
});
|
|
53
55
|
type PoolReport = z.infer<typeof PoolReportSchema>;
|
|
54
56
|
|
|
55
|
-
const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema });
|
|
57
|
+
const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema, grok: PoolReportSchema, opencodeGo: PoolReportSchema });
|
|
56
58
|
export type StatusReport = z.infer<typeof StatusReportSchema>;
|
|
57
59
|
|
|
58
60
|
function currentWindow(w: Window, now: number): WindowReport {
|
|
@@ -239,9 +241,11 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
|
|
|
239
241
|
const claudeReport = await collect(claude, cfg, now, cached);
|
|
240
242
|
if (!json) {
|
|
241
243
|
const codexPooled = loadAccounts(codexPool).accounts.length > 0;
|
|
244
|
+
const grokPooled = loadAccounts(grokPool).accounts.length > 0;
|
|
245
|
+
const opencodeGoPooled = loadAccounts(opencodeGoPool).accounts.length > 0;
|
|
242
246
|
if (claudeReport.accounts.length === 0) {
|
|
243
|
-
if (!codexPooled) {
|
|
244
|
-
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
|
|
247
|
+
if (!codexPooled && !grokPooled && !opencodeGoPooled) {
|
|
248
|
+
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`, `--opencode-go`)"));
|
|
245
249
|
return 0;
|
|
246
250
|
}
|
|
247
251
|
console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
|
|
@@ -252,13 +256,21 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
|
|
|
252
256
|
}
|
|
253
257
|
}
|
|
254
258
|
const codexReport = await collect(codex, cfg, now, cached);
|
|
259
|
+
const grokReport = await collect(grok, cfg, now, cached);
|
|
260
|
+
const opencodeGoReport = await collect(opencodeGo, cfg, now, cached);
|
|
255
261
|
if (json) {
|
|
256
|
-
const report: StatusReport = { now, claude: claudeReport, codex: codexReport };
|
|
262
|
+
const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, opencodeGo: opencodeGoReport };
|
|
257
263
|
emitJson({ ok: true, ...report });
|
|
258
264
|
return 0;
|
|
259
265
|
}
|
|
260
266
|
if (codexReport.accounts.length > 0) {
|
|
261
267
|
renderPool(codex, codexReport, `codex (${count({ n: codexReport.accounts.length, noun: "account" })})`, Date.now(), cfg.policy.usagePollTtlMs);
|
|
262
268
|
}
|
|
269
|
+
if (grokReport.accounts.length > 0) {
|
|
270
|
+
renderPool(grok, grokReport, `grok (${count({ n: grokReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
|
|
271
|
+
}
|
|
272
|
+
if (opencodeGoReport.accounts.length > 0) {
|
|
273
|
+
renderPool(opencodeGo, opencodeGoReport, `opencode-go (${count({ n: opencodeGoReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
|
|
274
|
+
}
|
|
263
275
|
return 0;
|
|
264
276
|
}
|
package/src/lib/claude.ts
CHANGED
package/src/lib/codex.ts
CHANGED
package/src/lib/grok.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
5
|
+
import { grokAuthJsonFor, grokPaths, grokPool, grokSeatFromEnv, grokStoreDirFor } from "./paths.ts";
|
|
6
|
+
import type { Observation, Provider, SampleReport } from "./provider.ts";
|
|
7
|
+
import { loadAccounts, loadConfig, pinBinOverride, type Harvest } from "./state.ts";
|
|
8
|
+
import type { Account } from "./types.ts";
|
|
9
|
+
import { c } from "../cli/render.ts";
|
|
10
|
+
|
|
11
|
+
const GrokAuthEntrySchema = z.looseObject({
|
|
12
|
+
user_id: z.string().optional(),
|
|
13
|
+
principal_id: z.string().optional(),
|
|
14
|
+
email: z.string().nullish(),
|
|
15
|
+
first_name: z.string().nullish(),
|
|
16
|
+
refresh_token: z.string().optional(),
|
|
17
|
+
expires_at: z.string().nullish(),
|
|
18
|
+
});
|
|
19
|
+
type GrokAuthEntry = z.infer<typeof GrokAuthEntrySchema>;
|
|
20
|
+
|
|
21
|
+
function parseGrokAuthFile(path: string): { key: string; entry: GrokAuthEntry }[] {
|
|
22
|
+
let raw: unknown;
|
|
23
|
+
try {
|
|
24
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const map = z.record(z.string(), z.unknown()).safeParse(raw);
|
|
29
|
+
if (!map.success) return [];
|
|
30
|
+
const out: { key: string; entry: GrokAuthEntry }[] = [];
|
|
31
|
+
for (const [key, value] of Object.entries(map.data)) {
|
|
32
|
+
const parsed = GrokAuthEntrySchema.safeParse(value);
|
|
33
|
+
if (parsed.success) out.push({ key, entry: parsed.data });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function identityOf(key: string, entry: GrokAuthEntry): { id: string; email: string | null } {
|
|
39
|
+
const id = entry.user_id ?? entry.principal_id ?? key;
|
|
40
|
+
return { id, email: entry.email ?? null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveRealGrok(): string {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
if (cfg.grokBin) {
|
|
46
|
+
if (!existsSync(cfg.grokBin)) throw new Error(`configured grokBin does not exist: ${cfg.grokBin} - fix config.json`);
|
|
47
|
+
return cfg.grokBin;
|
|
48
|
+
}
|
|
49
|
+
for (const d of (process.env.PATH ?? "").split(":")) {
|
|
50
|
+
if (!d) continue;
|
|
51
|
+
const cand = join(d, "grok");
|
|
52
|
+
try {
|
|
53
|
+
if (existsSync(cand)) return cand;
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new Error("could not locate the real `grok` binary (set grokBin in config.json)");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function liveId(): string | null {
|
|
62
|
+
return grokSeatFromEnv(loadAccounts(grokPool).accounts.map((a) => a.id));
|
|
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 --grok`" });
|
|
77
|
+
}
|
|
78
|
+
return reports;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function storeUsable(a: Account): Promise<boolean> {
|
|
82
|
+
try {
|
|
83
|
+
const entries = parseGrokAuthFile(grokAuthJsonFor(a.id));
|
|
84
|
+
return entries.some((e) => identityOf(e.key, e.entry).id === a.id && (e.entry.refresh_token ?? "") !== "");
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function removeCredentials(a: Account): Promise<void> {
|
|
91
|
+
rmSync(grokStoreDirFor(a.id), { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function harvestOf(key: string, entry: GrokAuthEntry): Harvest | null {
|
|
95
|
+
const { id, email } = identityOf(key, entry);
|
|
96
|
+
if (!id) return null;
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
email,
|
|
100
|
+
tier: null,
|
|
101
|
+
sample: null,
|
|
102
|
+
park: async () => {
|
|
103
|
+
mkdirSync(grokStoreDirFor(id), { recursive: true });
|
|
104
|
+
writeFileAtomic(grokAuthJsonFor(id), JSON.stringify({ [key]: entry }, null, 2), 0o600);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function login(): Promise<Harvest | null> {
|
|
110
|
+
const real = resolveRealGrok();
|
|
111
|
+
const onboardDir = grokPaths.onboardDir;
|
|
112
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
113
|
+
mkdirSync(onboardDir, { recursive: true });
|
|
114
|
+
const p = Bun.spawn([real, "login"], {
|
|
115
|
+
stdin: "inherit",
|
|
116
|
+
stdout: "inherit",
|
|
117
|
+
stderr: "inherit",
|
|
118
|
+
env: { ...process.env, GROK_HOME: onboardDir, TOKENMAXXING_PROBE: "1" },
|
|
119
|
+
});
|
|
120
|
+
await p.exited;
|
|
121
|
+
try {
|
|
122
|
+
if (p.exitCode !== 0) {
|
|
123
|
+
console.error(c.red("no grok login landed in the isolated home - nothing added."));
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const entries = parseGrokAuthFile(join(onboardDir, "auth.json"));
|
|
127
|
+
if (entries.length === 0) {
|
|
128
|
+
console.error(c.red("no grok login landed in the isolated home - nothing added."));
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return harvestOf(entries[0]!.key, entries[0]!.entry);
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function importLive(): Promise<Harvest | null> {
|
|
138
|
+
console.log(c.cyan("Opening an isolated grok login for your first pooled account - the login you already have stays as it is."));
|
|
139
|
+
console.log();
|
|
140
|
+
const live = parseGrokAuthFile(join(grokPaths.home, "auth.json"));
|
|
141
|
+
if (live.length > 0) {
|
|
142
|
+
console.log(c.dim(`found ${live.length} grok login(s) in ${grokPaths.home} - pooling the first; use \`tokenmaxxing add --grok\` for the rest.`));
|
|
143
|
+
const h = harvestOf(live[0]!.key, live[0]!.entry);
|
|
144
|
+
if (h) return h;
|
|
145
|
+
}
|
|
146
|
+
return login();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function preflight(): void {
|
|
150
|
+
const real = resolveRealGrok();
|
|
151
|
+
const p = Bun.spawnSync([real, "--version"], { stdout: "pipe", stderr: "pipe", timeout: 15_000 });
|
|
152
|
+
const out = (p.stdout?.toString() ?? "").trim().toLowerCase();
|
|
153
|
+
if (p.exitCode !== 0 || !out.includes("grok")) {
|
|
154
|
+
throw new Error(`grok binary failed verification: ${real}`);
|
|
155
|
+
}
|
|
156
|
+
pinBinOverride({ key: "grokBin", bin: real });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function install(): void {
|
|
160
|
+
console.log(c.dim("grok pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled grok accounts."));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const grok: Provider = {
|
|
164
|
+
name: "grok",
|
|
165
|
+
flag: " --grok",
|
|
166
|
+
pool: grokPool,
|
|
167
|
+
seats: "live",
|
|
168
|
+
waitsWhenDepleted: false,
|
|
169
|
+
statusOnly: true,
|
|
170
|
+
liveId,
|
|
171
|
+
presence,
|
|
172
|
+
gatedFamilies: () => null,
|
|
173
|
+
observeLive,
|
|
174
|
+
samplePool,
|
|
175
|
+
mergeWindows: (next) => next,
|
|
176
|
+
swap: async () => {
|
|
177
|
+
throw new Error("grok moves are not supported yet (status-only pool)");
|
|
178
|
+
},
|
|
179
|
+
classifySwapError: () => "fatal",
|
|
180
|
+
removeCredentials,
|
|
181
|
+
storeUsable,
|
|
182
|
+
login,
|
|
183
|
+
importLive,
|
|
184
|
+
preflight,
|
|
185
|
+
install,
|
|
186
|
+
loginStep: () => `Sign in in the browser session that opens (or run ${c.bold("grok login --device-auth")} on headless hosts first).`,
|
|
187
|
+
windowLabel: (name) => name.toLowerCase(),
|
|
188
|
+
};
|
|
@@ -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
|
@@ -52,6 +52,18 @@ export const codexPool = {
|
|
|
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")));
|
|
@@ -71,6 +83,41 @@ export function codexCredItemFor(accountId: string): string {
|
|
|
71
83
|
return `tokenmaxxing-codex-${accountId.slice(0, 8)}`;
|
|
72
84
|
}
|
|
73
85
|
|
|
86
|
+
const GROK_HOME_DEFAULT = join(HOME, ".grok");
|
|
87
|
+
|
|
88
|
+
export const grokPaths = {
|
|
89
|
+
home: env("GROK_HOME", GROK_HOME_DEFAULT),
|
|
90
|
+
storesDir: join(TM_HOME, "grok-stores"),
|
|
91
|
+
onboardDir: join(TM_HOME, "grok-onboard"),
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
export function grokStoreDirFor(accountId: string): string {
|
|
95
|
+
return join(grokPaths.storesDir, shortId(accountId));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function grokAuthJsonFor(accountId: string): string {
|
|
99
|
+
return join(grokStoreDirFor(accountId), "auth.json");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function grokSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
|
|
103
|
+
const home = env.GROK_HOME;
|
|
104
|
+
if (home == null || home === "") return null;
|
|
105
|
+
return accountIds.find((id) => grokStoreDirFor(id) === home) ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const opencodeGoPaths = {
|
|
109
|
+
storesDir: join(TM_HOME, "opencode-go-stores"),
|
|
110
|
+
onboardDir: join(TM_HOME, "opencode-go-onboard"),
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
export function opencodeGoStoreDirFor(accountId: string): string {
|
|
114
|
+
return join(opencodeGoPaths.storesDir, shortId(accountId));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function opencodeGoAuthJsonFor(accountId: string): string {
|
|
118
|
+
return join(opencodeGoStoreDirFor(accountId), "auth.json");
|
|
119
|
+
}
|
|
120
|
+
|
|
74
121
|
export const keychain = {
|
|
75
122
|
account: env("TOKENMAXXING_KEYCHAIN_ACCOUNT", process.env.USER ?? "unknown"),
|
|
76
123
|
} as const;
|
|
@@ -111,4 +158,12 @@ export function realCodexBinFromEnv(): string | undefined {
|
|
|
111
158
|
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
|
|
112
159
|
}
|
|
113
160
|
|
|
161
|
+
export function realGrokBinFromEnv(): string | undefined {
|
|
162
|
+
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_GROK_BIN);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function realOpencodeBinFromEnv(): string | undefined {
|
|
166
|
+
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_OPENCODE_BIN);
|
|
167
|
+
}
|
|
168
|
+
|
|
114
169
|
export { HOME };
|
package/src/lib/provider.ts
CHANGED
|
@@ -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),
|
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";
|
|
@@ -30,8 +32,11 @@ import { c, emitError } from "./cli/render.ts";
|
|
|
30
32
|
const JSON_FLAG = "--json";
|
|
31
33
|
const CACHED_FLAG = "--cached";
|
|
32
34
|
const CODEX_FLAG = "--codex";
|
|
35
|
+
const GROK_FLAG = "--grok";
|
|
36
|
+
const OPENCODE_GO_FLAG = "--opencode-go";
|
|
33
37
|
const JSON_COMMANDS = new Set(["status", "config", "check", "switch"]);
|
|
34
38
|
const CODEX_COMMANDS = new Set(["init", "add", "auth", "switch", "rm", "rename"]);
|
|
39
|
+
const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
|
|
35
40
|
|
|
36
41
|
function printHelp(): void {
|
|
37
42
|
console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
|
|
@@ -40,15 +45,17 @@ function printHelp(): void {
|
|
|
40
45
|
${c.cyan("tokenmaxxing check")} sample the account whose usage figure is oldest (run by the periodic timer)
|
|
41
46
|
${c.cyan("tokenmaxxing init")} log in the first account (isolated) + install supervisor & hooks
|
|
42
47
|
${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
|
|
48
|
+
${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
|
|
49
|
+
${c.cyan("tokenmaxxing init --opencode-go")} pool opencode-go API keys (status-only: no supervisor yet)
|
|
43
50
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
44
51
|
${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)
|
|
52
|
+
${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)
|
|
46
53
|
${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
|
|
47
54
|
${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
|
|
48
55
|
${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
|
|
49
56
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
50
|
-
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
51
|
-
${c.cyan("tokenmaxxing rm")} [--codex] <sel>
|
|
57
|
+
${c.cyan("tokenmaxxing rename")} [--codex | --grok | --opencode-go] <sel> <label>
|
|
58
|
+
${c.cyan("tokenmaxxing rm")} [--codex | --grok | --opencode-go] <sel>
|
|
52
59
|
${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
|
|
53
60
|
${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
61
|
${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
|
|
@@ -79,8 +86,13 @@ async function main(): Promise<number> {
|
|
|
79
86
|
jsonMode = argv.includes(JSON_FLAG);
|
|
80
87
|
const json = jsonMode;
|
|
81
88
|
const cached = argv.includes(CACHED_FLAG);
|
|
82
|
-
const
|
|
83
|
-
|
|
89
|
+
const providerFlags = [CODEX_FLAG, GROK_FLAG, OPENCODE_GO_FLAG].filter((f) => argv.includes(f));
|
|
90
|
+
if (providerFlags.length > 1) {
|
|
91
|
+
emitError({ json, message: `${providerFlags.join(" and ")} are mutually exclusive - pick one pool` });
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : argv.includes(OPENCODE_GO_FLAG) ? opencodeGo : claude;
|
|
95
|
+
const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== OPENCODE_GO_FLAG);
|
|
84
96
|
const sub = args[0];
|
|
85
97
|
|
|
86
98
|
if (cached && sub != null && sub !== "status") {
|
|
@@ -93,6 +105,12 @@ async function main(): Promise<number> {
|
|
|
93
105
|
return 2;
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
if ((provider === grok || provider === opencodeGo) && (sub == null || !STATUS_ONLY_COMMANDS.has(sub))) {
|
|
109
|
+
const flag = provider === grok ? GROK_FLAG : OPENCODE_GO_FLAG;
|
|
110
|
+
emitError({ json, message: `${flag} applies to ${[...STATUS_ONLY_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
if (json && sub != null && !JSON_COMMANDS.has(sub)) {
|
|
97
115
|
emitError({ json, message: `${sub} has no ${JSON_FLAG} form (${JSON_FLAG} applies to ${[...JSON_COMMANDS].join(", ")})` });
|
|
98
116
|
return 2;
|