tokenmaxxing 1.174.0 → 1.175.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with room for it whose weekly quota is closest to expiring unused, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session queues first come first served for a reset (at most eight sessions wait on one account; overflow waits for the next reset with room) and pauses with a countdown until its assigned reset, when that reset falls within twice `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
4
4
 
5
- > **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI and Muse Code as further switching pools; grok as a status-only pool. It pools **subscription** accounts (Pro/Max), not API keys.
5
+ > **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI and Muse Code as further switching pools; grok and Cursor as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
7
7
  ```
8
8
  $ claude
@@ -40,15 +40,16 @@ claude # use claude as always; each session gets its own accoun
40
40
  | `tokenmaxxing init --codex` | same for codex: log in the first account (isolated), install codex supervisor + Stop hook |
41
41
  | `tokenmaxxing init --pi` | verify and pin the real `pi` and install the `pi` supervisor, which runs pi sessions on pooled Claude and ChatGPT accounts that have a pi login |
42
42
  | `tokenmaxxing init --grok` | pool the first grok login, status-only: no supervisor, no switching |
43
+ | `tokenmaxxing init --cursor` | pool the Cursor CLI login, status-only: no supervisor, no switching |
43
44
  | `tokenmaxxing init --muse` | same for Muse Code: log in the first account (isolated), install the `muse` supervisor, which starts each session on the freshest account and resumes it on another near quota |
44
- | `tokenmaxxing add [--codex \| --grok \| --muse]` | register an additional account in that pool (isolated login, harvested once into its own store) |
45
- | `tokenmaxxing auth [--codex \| --grok \| --muse] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every account that is flagged or has no usable credential in its store, one by one |
45
+ | `tokenmaxxing add [--codex \| --grok \| --muse \| --cursor]` | register an additional account in that pool (isolated login, harvested once into its own store) |
46
+ | `tokenmaxxing auth [--codex \| --grok \| --muse \| --cursor] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every account that is flagged or has no usable credential in its store, one by one |
46
47
  | `tokenmaxxing auth --pi [--codex] [sel \| --all]` | log a pooled Claude account (or a codex account with `--codex`) into pi, isolated, and keep that login as the account's pi credential after an identity check; `--all` walks every account without a pi login |
47
48
  | `tokenmaxxing status [--cached]` | every pool: accounts with 5h / weekly / per-model usage bars, live session counts, exhausted-until-reset; `--cached` renders the stored figures without sampling |
48
49
  | `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 |
49
50
  | `tokenmaxxing check` | fold fresh tees, sample up to three stale accounts, and self-update a Bun global install once a day; the periodic timer runs this every tick |
50
51
  | `tokenmaxxing doctor` | verify the install: PATH order, `claudeBin`, the five settings entries, the timer, the hub service, the credential identity of every Codex store and of every Claude store whose access token is still fresh (an expiring Claude token is reported as unverifiable, not as a failure), codex hook trust, and shell aliases that shadow `claude` |
51
- | `tokenmaxxing rename [--codex \| --grok \| --muse] <sel> <label>` / `rm [...] <sel>` | manage a pool (one email can hold both a claude and a codex account); `rm` is refused for an account with a running supervised session |
52
+ | `tokenmaxxing rename [--codex \| --grok \| --muse \| --cursor] <sel> <label>` / `rm [...] <sel>` | manage a pool (one email can hold both a claude and a codex account); `rm` is refused for an account with a running supervised session |
52
53
  | `tokenmaxxing serve` | serve the CLIProxyAPI-compatible usage API on `http://localhost:8317` (`hub.port`) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows the quota of every pooled Claude and Codex account; the management key is the contents of `hub-key` in the state directory, minted on the first run, and `init` runs it as a background service beside the check timer. Details in [Usage hub](docs/content/docs/hub.mdx) |
53
54
  | `tokenmaxxing uninstall [--yes]` | print the targets, then remove the shims, settings entries, codex Stop hook, check timer, hub service, and rc PATH line (accounts/stores kept); refused without `--yes` when `HOME` is the login home |
54
55
  | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, and `check` (`ok` mirrors the exit code, failures add `error`) |
@@ -126,6 +127,7 @@ pi keeps its own credential and refreshes it itself, so each pooled account gets
126
127
  - **Keychain (macOS).** The first keychain access for a new store happens inside the interactive `init`, `add`, or `auth` run, where a prompt can be answered, not inside a headless hook.
127
128
  - **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
128
129
  - **The status-only grok pool does not switch.** grok `status` shows the weekly subscription credit window, and no grok session moves.
130
+ - **The status-only Cursor pool does not switch, and reads only a file login.** Cursor `status` shows the included usage of the monthly billing cycle. The Cursor CLI keeps its login in the keychain on macOS, which tokenmaxxing does not read, so the pool works where the login is `auth.json` (Linux).
129
131
  - **A Muse Code move stops the running turn.** `muse resume` takes no prompt, so a turn that was running when the move landed has to be sent again; `muse exec` and `muse serve` keep the account they started on.
130
132
 
131
133
  ## How it's built
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.174.0",
3
+ "version": "1.175.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
@@ -13,7 +13,7 @@ import { c, count } from "./render.ts";
13
13
  import type { Account, AccountsIndex } from "../lib/types.ts";
14
14
 
15
15
  const AUTH_USAGE =
16
- "usage: tokenmaxxing auth [--codex | --grok | --muse] [<email|label|id> | --all]\n tokenmaxxing auth [--codex | --grok | --muse] --emails <email>...\n tokenmaxxing auth --pi [--codex] [<email|label|id> | --all]";
16
+ "usage: tokenmaxxing auth [--codex | --grok | --muse | --cursor] [<email|label|id> | --all]\n tokenmaxxing auth [--codex | --grok | --muse | --cursor] --emails <email>...\n tokenmaxxing auth --pi [--codex] [<email|label|id> | --all]";
17
17
  const EMAILS_FLAG = "--emails";
18
18
 
19
19
  export type AuthPlan = { kind: "usage" } | { kind: "error"; message: string } | { kind: "pick" } | { kind: "targets"; ids: string[] };
package/src/cli/rename.ts CHANGED
@@ -16,7 +16,7 @@ export function findAccount(accounts: Account[], selector: string): Account | un
16
16
  export async function cmdRename(p: Provider, argv: string[]): Promise<number> {
17
17
  const [selector, newLabel] = argv;
18
18
  if (!selector || !newLabel) {
19
- emitError({ message: "usage: tokenmaxxing rename [--codex | --grok | --muse] <email|label|id> <new-label>", paint: plain });
19
+ emitError({ message: "usage: tokenmaxxing rename [--codex | --grok | --muse | --cursor] <email|label|id> <new-label>", paint: plain });
20
20
  return 2;
21
21
  }
22
22
  return withLock(p.pool.lockFile, async () => {
package/src/cli/status.ts CHANGED
@@ -3,16 +3,19 @@ import { creditLeft, keySessions, loadApiKeys } from "../lib/apikeys.ts";
3
3
  import { claude } from "../lib/claude.ts";
4
4
  import { codex } from "../lib/codex.ts";
5
5
  import { grok } from "../lib/grok.ts";
6
+ import { cursor } from "../lib/cursor.ts";
6
7
  import { muse } from "../lib/muse.ts";
7
8
  import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
8
9
  import { withLock } from "../lib/lock.ts";
9
- import { codexPool, grokPool, musePool } from "../lib/paths.ts";
10
+ import { codexPool, cursorPool, grokPool, musePool } from "../lib/paths.ts";
10
11
  import { barFor, bufferedUsed, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
11
12
  import type { Provider, SampleReport } from "../lib/provider.ts";
12
13
  import { usd } from "./key.ts";
13
14
  import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
14
15
  import type { Account, AccountThresholds, Bars, Config, Thresholds, Window } from "../lib/types.ts";
15
16
 
17
+ const WEEK_S = 7 * 86400;
18
+
16
19
  export type WindowReport = { usedPercentage: number; bufferedPercentage: number; resetsAt: number | null; windowSeconds: number | null };
17
20
 
18
21
  export type UsageReport = { fiveHour: WindowReport | null; week: WindowReport | null; limits: (WindowReport & { name: string })[] };
@@ -53,7 +56,7 @@ type StatusApiKey = {
53
56
  sessions: number;
54
57
  };
55
58
 
56
- export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; muse: PoolReport; apiKeys?: StatusApiKey[] };
59
+ export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; muse: PoolReport; cursor: PoolReport; apiKeys?: StatusApiKey[] };
57
60
 
58
61
  function apiKeyReport(): StatusApiKey[] {
59
62
  const sessions = keySessions();
@@ -89,7 +92,7 @@ function currentWindow(a: Account, w: Window, bars: Bars | null, now: number): W
89
92
  return {
90
93
  usedPercentage: liveUsed(w, now),
91
94
  bufferedPercentage: bars == null ? liveUsed(w, now) : bufferedUsed(a, w, bars, now),
92
- resetsAt: isSessionWindow(w) ? (passed ? null : w.resetsAt) : nextWeeklyReset(w.resetsAt, now),
95
+ resetsAt: isSessionWindow(w) || (w.windowSeconds ?? 0) > WEEK_S ? (passed ? null : w.resetsAt) : nextWeeklyReset(w.resetsAt, now),
93
96
  windowSeconds: w.windowSeconds,
94
97
  };
95
98
  }
@@ -161,6 +164,7 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
161
164
  if (a.lastUsageAt != null && (stored.lastUsageAt == null || a.lastUsageAt > stored.lastUsageAt)) {
162
165
  landWindows(stored, p.mergeWindows(a.windows, stored.windows), a.lastUsageAt, bars);
163
166
  stored.hasCredits = a.hasCredits;
167
+ if (a.email != null && stored.email == null && stored.label === stored.id.slice(0, 8)) stored.label = a.email;
164
168
  if (a.email != null) stored.email = a.email;
165
169
  if (a.tier != null) stored.tier = a.tier;
166
170
  dirty = true;
@@ -267,7 +271,7 @@ function card(p: Provider, a: StatusAccount, now: number, staleAfterMs: number):
267
271
  const lines = [headerLine({ ...a, name: a.label })];
268
272
  if (a.usage) {
269
273
  if (a.usage.fiveHour) lines.push(usageRow(`${Math.round((a.usage.fiveHour.windowSeconds ?? 0) / 3600)}h`, a.usage.fiveHour));
270
- if (a.usage.week) lines.push(usageRow("week", a.usage.week));
274
+ if (a.usage.week) lines.push(usageRow((a.usage.week.windowSeconds ?? 0) > WEEK_S ? "month" : "week", a.usage.week));
271
275
  for (const w of a.usage.limits) lines.push(usageRow(p.windowLabel(w.name), w));
272
276
  }
273
277
  const notes: Note[] = [];
@@ -308,9 +312,10 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
308
312
  const codexPooled = loadAccounts(codexPool).accounts.length > 0;
309
313
  const grokPooled = loadAccounts(grokPool).accounts.length > 0;
310
314
  const musePooled = loadAccounts(musePool).accounts.length > 0;
315
+ const cursorPooled = loadAccounts(cursorPool).accounts.length > 0;
311
316
  if (claudeReport.accounts.length === 0) {
312
- if (!codexPooled && !grokPooled && !musePooled && apiKeys.length === 0) {
313
- console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`, `--muse`)"));
317
+ if (!codexPooled && !grokPooled && !musePooled && !cursorPooled && apiKeys.length === 0) {
318
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`, `--muse`, `--cursor`)"));
314
319
  return 0;
315
320
  }
316
321
  console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
@@ -324,8 +329,9 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
324
329
  const codexReport = await collect(codex, cfg, now, cached);
325
330
  const grokReport = await collect(grok, cfg, now, cached);
326
331
  const museReport = await collect(muse, cfg, now, cached);
332
+ const cursorReport = await collect(cursor, cfg, now, cached);
327
333
  if (json) {
328
- const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, muse: museReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
334
+ const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, muse: museReport, cursor: cursorReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
329
335
  emitJson({ ok: true, ...report });
330
336
  return 0;
331
337
  }
@@ -338,5 +344,8 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
338
344
  if (museReport.accounts.length > 0) {
339
345
  renderPool(muse, museReport, `muse (${count({ n: museReport.accounts.length, noun: "account" })})`, Date.now(), cfg.policy.usagePollTtlMs);
340
346
  }
347
+ if (cursorReport.accounts.length > 0) {
348
+ renderPool(cursor, cursorReport, `cursor (${count({ n: cursorReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
349
+ }
341
350
  return 0;
342
351
  }
@@ -0,0 +1,185 @@
1
+ import { dirname, join } from "node:path";
2
+ import { z } from "zod";
3
+ import { writeFileAtomic } from "./atomic.ts";
4
+ import { http, safeErrorDetail } from "./http.ts";
5
+ import { errorMessage } from "./log.ts";
6
+ import { cursorAuthJsonFor, cursorPaths, cursorPool, cursorStoreDirFor, env } from "./paths.ts";
7
+ import type { SampleReport } from "./provider.ts";
8
+ import { readJsonFile, type Harvest } from "./state.ts";
9
+ import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
10
+ import { ErrnoSchema, JsonTextSchema, type Account, type Window } from "./types.ts";
11
+ import { c } from "../cli/render.ts";
12
+
13
+ const CursorAuthSchema = z.looseObject({ accessToken: z.string().optional() });
14
+ type CursorAuth = z.infer<typeof CursorAuthSchema>;
15
+
16
+ const CliConfigSchema = z.looseObject({
17
+ authInfo: z.looseObject({ authId: z.string().optional(), email: z.string().nullish() }).optional(),
18
+ });
19
+
20
+ const JwtPayloadSchema = z.looseObject({ sub: z.string().min(1) });
21
+
22
+ function readOptional<T>(path: string, schema: z.ZodType<T>): T | null {
23
+ try {
24
+ return readJsonFile(path, schema);
25
+ } catch (e) {
26
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
27
+ throw e;
28
+ }
29
+ }
30
+
31
+ function tokenSubject(token: string): string | null {
32
+ const payload = token.split(".")[1] ?? "";
33
+ return JwtPayloadSchema.safeParse(JsonTextSchema.safeParse(Buffer.from(payload, "base64url").toString("utf8")).data).data?.sub ?? null;
34
+ }
35
+
36
+ // The CLI caches the signed-in user next to auth.json; it is the only local copy of the email, because the token carries none.
37
+ function cachedEmail(authPath: string, subject: string): string | null {
38
+ let config: z.infer<typeof CliConfigSchema> | null;
39
+ try {
40
+ config = readOptional(join(dirname(authPath), "cli-config.json"), CliConfigSchema);
41
+ } catch {
42
+ return null;
43
+ }
44
+ const info = config?.authInfo;
45
+ if (info?.authId !== subject) return null;
46
+ const email = info.email?.trim().toLowerCase() ?? "";
47
+ return email === "" ? null : email;
48
+ }
49
+
50
+ const harvestTokens = new WeakMap<Harvest, string>();
51
+
52
+ function readAuth(path: string): AuthEntry[] {
53
+ const file: CursorAuth | null = readOptional(path, CursorAuthSchema);
54
+ const token = file?.accessToken ?? "";
55
+ if (file == null || token === "") return [];
56
+ const subject = tokenSubject(token);
57
+ if (subject == null) return [{ id: "", usable: false, harvest: null }];
58
+ const id = new Bun.CryptoHasher("sha256").update(subject).digest("hex");
59
+ const harvest: Harvest = {
60
+ id,
61
+ email: cachedEmail(path, subject),
62
+ tier: null,
63
+ sample: null,
64
+ park: async () => {
65
+ writeFileAtomic(cursorAuthJsonFor(id), JSON.stringify(file, null, 2), 0o600);
66
+ },
67
+ };
68
+ harvestTokens.set(harvest, token);
69
+ return [{ id, usable: true, harvest }];
70
+ }
71
+
72
+ const EpochMsSchema = z.coerce.number();
73
+
74
+ const PeriodUsageSchema = z.looseObject({
75
+ billingCycleStart: EpochMsSchema,
76
+ billingCycleEnd: EpochMsSchema,
77
+ planUsage: z
78
+ .looseObject({
79
+ includedSpend: z.number().default(0),
80
+ limit: z.number().default(0),
81
+ totalPercentUsed: z.number().optional(),
82
+ apiPercentUsed: z.number().optional(),
83
+ })
84
+ .optional(),
85
+ });
86
+
87
+ const PlanInfoSchema = z.looseObject({ planInfo: z.looseObject({ planName: z.string().optional() }).optional() });
88
+
89
+ const MeSchema = z.looseObject({ email: z.string().optional() });
90
+
91
+ function dashboardUrl(method: string): string {
92
+ return `${env("TOKENMAXXING_CURSOR_API_URL", "https://api2.cursor.sh")}/aiserver.v1.DashboardService/${method}`;
93
+ }
94
+
95
+ async function dashboard(token: string, method: string): Promise<Response> {
96
+ return http.post(dashboardUrl(method), { headers: { Authorization: `Bearer ${token}`, "Connect-Protocol-Version": "1" }, json: {} });
97
+ }
98
+
99
+ async function dashboardField<T>(token: string, method: string, schema: z.ZodType<T>): Promise<T | null> {
100
+ try {
101
+ const res = await dashboard(token, method);
102
+ return res.ok ? (schema.safeParse(JsonTextSchema.safeParse(await res.text()).data).data ?? null) : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ async function readCursorUsage(account: Account, now: number): Promise<SampleReport> {
109
+ let token: string;
110
+ try {
111
+ token = readOptional(cursorAuthJsonFor(account.id), CursorAuthSchema)?.accessToken ?? "";
112
+ } catch (e) {
113
+ return { ok: false, reason: `this account's store is unreadable (${errorMessage(e)}) - run \`tokenmaxxing auth --cursor\`` };
114
+ }
115
+ if (token === "") return { ok: false, reason: "no accessToken in this account's store - run `tokenmaxxing auth --cursor`" };
116
+ let res: Response;
117
+ let text: string;
118
+ const plan = dashboardField(token, "GetPlanInfo", PlanInfoSchema);
119
+ const me = account.email == null ? dashboardField(token, "GetMe", MeSchema) : null;
120
+ try {
121
+ res = await dashboard(token, "GetCurrentPeriodUsage");
122
+ text = await res.text();
123
+ } catch (e) {
124
+ return { ok: false, reason: `Cursor usage read failed: endpoint unreachable: ${errorMessage(e)}` };
125
+ }
126
+ if (res.status === 401 || res.status === 403) {
127
+ account.needsReauth = true;
128
+ return { ok: false, reason: `Cursor rejected the stored login (HTTP ${res.status}) - run \`tokenmaxxing auth --cursor\`` };
129
+ }
130
+ if (!res.ok) return { ok: false, reason: `Cursor usage read failed: HTTP ${res.status}: ${safeErrorDetail({ text })}` };
131
+ const parsed = PeriodUsageSchema.safeParse(JsonTextSchema.safeParse(text).data);
132
+ if (!parsed.success) return { ok: false, reason: "Cursor usage read failed: endpoint returned an unexpected body shape (withheld)" };
133
+ const { billingCycleStart, billingCycleEnd, planUsage } = parsed.data;
134
+ const total = planUsage?.totalPercentUsed ?? (planUsage != null && planUsage.limit > 0 ? Math.min(100, (planUsage.includedSpend / planUsage.limit) * 100) : null);
135
+ if (total == null) return { ok: false, reason: "Cursor reports no included-usage limit for this account" };
136
+ const cycle = { resetsAt: billingCycleEnd, windowSeconds: billingCycleEnd > billingCycleStart ? Math.round((billingCycleEnd - billingCycleStart) / 1000) : null, sampledAt: now };
137
+ const windows: Window[] = [{ name: null, usedPercentage: total, ...cycle }];
138
+ if (planUsage?.apiPercentUsed != null) windows.push({ name: "API", usedPercentage: planUsage.apiPercentUsed, ...cycle });
139
+ account.windows = windows;
140
+ account.lastUsageAt = now;
141
+ const planName = (await plan)?.planInfo?.planName?.trim().toLowerCase();
142
+ if (planName) account.tier = planName;
143
+ const email = (await me)?.email?.trim().toLowerCase();
144
+ if (email) account.email = email;
145
+ return { ok: true, source: "probe" };
146
+ }
147
+
148
+ async function identify(harvest: Harvest): Promise<void> {
149
+ const token = harvestTokens.get(harvest);
150
+ if (harvest.email != null || token == null) return;
151
+ const email = (await dashboardField(token, "GetMe", MeSchema))?.email?.trim().toLowerCase();
152
+ if (email) harvest.email = email;
153
+ }
154
+
155
+ async function samplePool(accounts: Account[], now: number): Promise<Map<string, SampleReport>> {
156
+ const reports = new Map<string, SampleReport>();
157
+ await Promise.all(accounts.map(async (account) => reports.set(account.id, await readCursorUsage(account, now))));
158
+ return reports;
159
+ }
160
+
161
+ export const cursor = statusOnlyProvider({
162
+ name: "cursor",
163
+ flag: " --cursor",
164
+ pool: cursorPool,
165
+ binName: "cursor-agent",
166
+ binKey: "cursorBin",
167
+ storeDirFor: cursorStoreDirFor,
168
+ authJsonFor: cursorAuthJsonFor,
169
+ homeEnv: "XDG_CONFIG_HOME",
170
+ loginArgs: ["login"],
171
+ onboardAuthRel: "cursor/auth.json",
172
+ liveAuthPath: () => cursorPaths.authJson,
173
+ readAuth,
174
+ liveId: () => null,
175
+ presence: () => new Map(),
176
+ versionOk: (out) => /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/.test(out.trim()),
177
+ importIntro: "Opening an isolated Cursor login for your first pooled account - the login you already have stays as it is.",
178
+ foundLive: () => `found the Cursor login in ${cursorPaths.authJson} - pooling it; use \`tokenmaxxing add --cursor\` for more.`,
179
+ install: () => {
180
+ console.log(c.dim("cursor pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled Cursor accounts."));
181
+ },
182
+ loginStep: () => `Approve the login in the browser that ${c.bold("cursor-agent login")} opens.`,
183
+ samplePool,
184
+ identify,
185
+ });
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { paths } from "./paths.ts";
4
4
  import { pidExists } from "./proc.ts";
5
5
 
6
- const POOLS = ["claude", "codex", "pi", "grok", "muse"] as const;
6
+ const POOLS = ["claude", "codex", "pi", "grok", "muse", "cursor"] as const;
7
7
 
8
8
  export type OnboardPool = (typeof POOLS)[number];
9
9
 
package/src/lib/paths.ts CHANGED
@@ -63,6 +63,11 @@ export const musePool = {
63
63
  lockFile: join(TM_HOME, "muse-lock"),
64
64
  } as const;
65
65
 
66
+ export const cursorPool = {
67
+ accountsJson: join(TM_HOME, "cursor-accounts.json"),
68
+ lockFile: join(TM_HOME, "cursor-lock"),
69
+ } as const;
70
+
66
71
  export type PoolPaths = { accountsJson: string; lockFile: string };
67
72
 
68
73
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
@@ -161,6 +166,19 @@ export function museSeatFromEnv(accountIds: string[], env: Record<string, string
161
166
  return accountIds.find((id) => museStoreDirFor(id) === home) ?? null;
162
167
  }
163
168
 
169
+ export const cursorPaths = {
170
+ authJson: join(env("XDG_CONFIG_HOME", join(HOME, ".config")), "cursor", "auth.json"),
171
+ storesDir: join(TM_HOME, "cursor-stores"),
172
+ } as const;
173
+
174
+ export function cursorStoreDirFor(accountId: string): string {
175
+ return join(cursorPaths.storesDir, shortId(accountId));
176
+ }
177
+
178
+ export function cursorAuthJsonFor(accountId: string): string {
179
+ return join(cursorStoreDirFor(accountId), "auth.json");
180
+ }
181
+
164
182
  export function grokSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
165
183
  const home = env.GROK_HOME;
166
184
  if (home == null || home === "") return null;
@@ -26,7 +26,7 @@ export class StoreUnusableError extends Error {
26
26
  }
27
27
 
28
28
  export type Provider = {
29
- name: "claude" | "codex" | "grok" | "muse";
29
+ name: "claude" | "codex" | "grok" | "muse" | "cursor";
30
30
  flag: string;
31
31
  pool: PoolPaths;
32
32
  seats: "shared" | "live";
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, realpathSync, rmSync, statSync, type Dirent }
2
2
  import { basename, join, sep } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { errorMessage, log } from "./log.ts";
5
- import { codexPaths, grokPaths, musePaths, paths, piPaths } from "./paths.ts";
5
+ import { codexPaths, cursorPaths, grokPaths, musePaths, paths, piPaths } from "./paths.ts";
6
6
  import { stagingTarget, writeFileAtomic } from "./atomic.ts";
7
7
  import { presencePid } from "./presence.ts";
8
8
  import { readJsonFile } from "./state.ts";
@@ -44,7 +44,7 @@ function recordLiveSession(sid: string, current: string): void {
44
44
 
45
45
  const TMP_GRACE_MS = 3600 * 1000;
46
46
 
47
- const SWEEP_PARENTS = [paths.storesDir, codexPaths.storesDir, grokPaths.storesDir, musePaths.storesDir, join(piPaths.storesDir, "claude"), join(piPaths.storesDir, "codex"), paths.costDir];
47
+ const SWEEP_PARENTS = [paths.storesDir, codexPaths.storesDir, grokPaths.storesDir, musePaths.storesDir, cursorPaths.storesDir, join(piPaths.storesDir, "claude"), join(piPaths.storesDir, "codex"), paths.costDir];
48
48
 
49
49
  function listDir(dir: string, root: string): Dirent[] {
50
50
  if (!existsSync(dir)) return [];
package/src/lib/state.ts CHANGED
@@ -35,11 +35,12 @@ export function loadConfig(): Config {
35
35
  cfg.codexBin = optionalEnv("TOKENMAXXING_CODEX_BIN") ?? cfg.codexBin;
36
36
  cfg.grokBin = optionalEnv("TOKENMAXXING_GROK_BIN") ?? cfg.grokBin;
37
37
  cfg.museBin = optionalEnv("TOKENMAXXING_MUSE_BIN") ?? cfg.museBin;
38
+ cfg.cursorBin = optionalEnv("TOKENMAXXING_CURSOR_BIN") ?? cfg.cursorBin;
38
39
  cfg.piBin = optionalEnv("TOKENMAXXING_PI_BIN") ?? cfg.piBin;
39
40
  return cfg;
40
41
  }
41
42
 
42
- export type BinKey = "claudeBin" | "codexBin" | "grokBin" | "museBin" | "piBin";
43
+ export type BinKey = "claudeBin" | "codexBin" | "grokBin" | "museBin" | "cursorBin" | "piBin";
43
44
 
44
45
  export function pinBinOverride(input: { key: BinKey; bin: string }): void {
45
46
  const raw: Record<string, unknown> = existsSync(paths.configJson) ? readJsonFile(paths.configJson, z.record(z.string(), z.unknown())) : {};
@@ -15,7 +15,7 @@ export type StatusOnlySpec = {
15
15
  flag: string;
16
16
  pool: PoolPaths;
17
17
  binName: string;
18
- binKey: "grokBin" | "museBin";
18
+ binKey: "grokBin" | "museBin" | "cursorBin";
19
19
  storeDirFor: (accountId: string) => string;
20
20
  authJsonFor: (accountId: string) => string;
21
21
  homeEnv: string;
@@ -31,6 +31,7 @@ export type StatusOnlySpec = {
31
31
  install: () => void;
32
32
  loginStep: () => string;
33
33
  samplePool: Provider["samplePool"];
34
+ identify?: (harvest: Harvest) => Promise<void>;
34
35
  };
35
36
 
36
37
  // An entry with no identity (a Muse Code provider without an email) has no harvest; the first one that has one is pooled.
@@ -62,6 +63,7 @@ export function statusOnlyProvider(spec: StatusOnlySpec): Provider {
62
63
  try {
63
64
  const harvest = p.exitCode === 0 ? firstHarvest(spec.readAuth(join(onboardDir, spec.onboardAuthRel))) : null;
64
65
  if (!harvest) console.error(c.red(`no ${spec.name} login landed in the isolated home - nothing added.`));
66
+ else await spec.identify?.(harvest);
65
67
  return harvest;
66
68
  } finally {
67
69
  rmSync(onboardDir, { recursive: true, force: true });
@@ -75,6 +77,7 @@ export function statusOnlyProvider(spec: StatusOnlySpec): Provider {
75
77
  const harvest = live[0]?.harvest;
76
78
  if (!harvest) return login();
77
79
  console.log(c.dim(spec.foundLive(live.length)));
80
+ await spec.identify?.(harvest);
78
81
  return harvest;
79
82
  }
80
83
 
package/src/lib/types.ts CHANGED
@@ -117,6 +117,7 @@ export const ConfigSchema = z
117
117
  codexBin: z.string().default(""),
118
118
  grokBin: z.string().default(""),
119
119
  museBin: z.string().default(""),
120
+ cursorBin: z.string().default(""),
120
121
  piBin: z.string().default(""),
121
122
  policy: z
122
123
  .object({
package/src/main.ts CHANGED
@@ -14,6 +14,7 @@ import { runMuseSupervisor } from "./entries/musesupervisor.ts";
14
14
  import { claude } from "./lib/claude.ts";
15
15
  import { codex } from "./lib/codex.ts";
16
16
  import { grok } from "./lib/grok.ts";
17
+ import { cursor } from "./lib/cursor.ts";
17
18
  import { muse } from "./lib/muse.ts";
18
19
  import { cmdInit, cmdInitPi } from "./cli/init.ts";
19
20
  import { cmdAdd } from "./cli/add.ts";
@@ -39,6 +40,7 @@ const YES_FLAG = "--yes";
39
40
  const CODEX_FLAG = "--codex";
40
41
  const GROK_FLAG = "--grok";
41
42
  const MUSE_FLAG = "--muse";
43
+ const CURSOR_FLAG = "--cursor";
42
44
  const PI_FLAG = "--pi";
43
45
  const JSON_COMMANDS = new Set(["status", "config", "check"]);
44
46
  const CODEX_COMMANDS = new Set(["init", "add", "auth", "emails", "rm", "rename", "seat"]);
@@ -55,17 +57,18 @@ function printHelp(): void {
55
57
  ${c.cyan("tokenmaxxing init --pi")} install the pi supervisor: pi sessions run on pooled Claude or ChatGPT accounts that are logged into pi
56
58
  ${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
57
59
  ${c.cyan("tokenmaxxing init --muse")} same for Muse Code: log in the first account, isolated, install the muse supervisor
60
+ ${c.cyan("tokenmaxxing init --cursor")} pool Cursor CLI logins (status-only: no supervisor yet)
58
61
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
59
- ${c.cyan("tokenmaxxing add")} --codex | --grok | --muse same for the codex, grok, or muse pool
60
- ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --muse] [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)
61
- ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --muse] --emails <email>... log in and pool each listed email, skipping the ones already pooled (for moving to a new host: paste the output of ${c.cyan("emails")} from the old one)
62
- ${c.cyan("tokenmaxxing emails")} [--codex | --grok | --muse] print every pooled account's email, one per line, to copy into ${c.cyan("auth --emails")} on another host
62
+ ${c.cyan("tokenmaxxing add")} --codex | --grok | --muse | --cursor same for the codex, grok, muse, or cursor pool
63
+ ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --muse | --cursor] [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)
64
+ ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --muse | --cursor] --emails <email>... log in and pool each listed email, skipping the ones already pooled (for moving to a new host: paste the output of ${c.cyan("emails")} from the old one)
65
+ ${c.cyan("tokenmaxxing emails")} [--codex | --grok | --muse | --cursor] print every pooled account's email, one per line, to copy into ${c.cyan("auth --emails")} on another host
63
66
  ${c.cyan("tokenmaxxing auth --pi")} [--codex] [sel | --all] log a pooled Claude account (or a codex account with --codex) into pi, isolated (bare = pick from a list; --all = every account without a pi login)
64
67
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
65
68
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
66
69
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
67
- ${c.cyan("tokenmaxxing rename")} [--codex | --grok | --muse] <sel> <label>
68
- ${c.cyan("tokenmaxxing rm")} [--codex | --grok | --muse] <sel>
70
+ ${c.cyan("tokenmaxxing rename")} [--codex | --grok | --muse | --cursor] <sel> <label>
71
+ ${c.cyan("tokenmaxxing rm")} [--codex | --grok | --muse | --cursor] <sel>
69
72
  ${c.cyan("tokenmaxxing key add")} <label> [credit-usd] [workspace-id] store an Anthropic or OpenRouter (sk-or-...) API key read from stdin or the prompt; Claude sessions run on it only while every pooled Claude account is at its limit (workspace-id: for a key that spans several workspaces; an OpenRouter key takes neither argument and reads its credit from OpenRouter)
70
73
  ${c.cyan("tokenmaxxing key credit")} <label> <usd> set the key's balance as the Console shows it and restart its spend count (an OpenRouter key takes no amount: key credit <label> clears its refusal)
71
74
  ${c.cyan("tokenmaxxing key rm")} <label> delete the key and its stored secret (refused while a session runs on it)
@@ -108,14 +111,14 @@ async function main(): Promise<number> {
108
111
  const json = jsonMode;
109
112
  const cached = argv.includes(CACHED_FLAG);
110
113
  const yes = argv.includes(YES_FLAG);
111
- const providerFlags = [CODEX_FLAG, GROK_FLAG, MUSE_FLAG].filter((f) => argv.includes(f));
114
+ const providerFlags = [CODEX_FLAG, GROK_FLAG, MUSE_FLAG, CURSOR_FLAG].filter((f) => argv.includes(f));
112
115
  if (providerFlags.length > 1) {
113
116
  emitError({ json, message: `${providerFlags.join(" and ")} are mutually exclusive - pick one pool` });
114
117
  return 2;
115
118
  }
116
- const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : argv.includes(MUSE_FLAG) ? muse : claude;
119
+ const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : argv.includes(MUSE_FLAG) ? muse : argv.includes(CURSOR_FLAG) ? cursor : claude;
117
120
  const pi = argv.includes(PI_FLAG);
118
- const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== YES_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== MUSE_FLAG && a !== PI_FLAG);
121
+ const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== YES_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== MUSE_FLAG && a !== CURSOR_FLAG && a !== PI_FLAG);
119
122
  const sub = args[0];
120
123
 
121
124
  if (pi && sub !== "init" && sub !== "auth") {
@@ -143,7 +146,7 @@ async function main(): Promise<number> {
143
146
  return 2;
144
147
  }
145
148
 
146
- if ((provider === grok || provider === muse) && (sub == null || !POOL_COMMANDS.has(sub))) {
149
+ if ((provider === grok || provider === muse || provider === cursor) && (sub == null || !POOL_COMMANDS.has(sub))) {
147
150
  emitError({ json, message: `${provider.flag.trim()} applies to ${[...POOL_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
148
151
  return 2;
149
152
  }
@@ -215,7 +218,7 @@ async function main(): Promise<number> {
215
218
  if (out.timer === "still-loaded") console.log(c.yellow(`⚠ the check job may still be loaded - run: ${deactivationHint(CHECK_JOB)}`));
216
219
  if (out.hub === "still-loaded") console.log(c.yellow(`⚠ the usage hub job may still be loaded - run: ${deactivationHint(HUB_JOB)}`));
217
220
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
218
- console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/; muse: muse-stores/), plus api-keys.json and the API keys in api-keys/ - remove accounts with \`xx rm\` and keys with \`xx key rm\` to delete their credentials`);
221
+ console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/; muse: muse-stores/; cursor: cursor-stores/), plus api-keys.json and the API keys in api-keys/ - remove accounts with \`xx rm\` and keys with \`xx key rm\` to delete their credentials`);
219
222
  return 0;
220
223
  }
221
224
  case "help":