tokenmaxxing 0.15.0 → 0.17.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 +1 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/cli/add.ts +16 -94
- package/src/cli/auth.ts +177 -0
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +3 -2
- package/src/cli/ls.ts +2 -2
- package/src/cli/onboard.ts +112 -0
- package/src/cli/rename.ts +41 -7
- package/src/cli/render.ts +13 -0
- package/src/cli/status.ts +15 -7
- package/src/cli/switch.ts +2 -2
- package/src/lib/codexusage.ts +10 -0
- package/src/lib/sample.ts +12 -2
- package/src/lib/types.ts +7 -0
- package/src/main.ts +5 -2
package/DESIGN.md
CHANGED
|
@@ -71,6 +71,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
71
71
|
- **`tokenmaxxing init` imports the account you're already on - automatically, no prompts, no re-login.** It reads the live `Claude Code-credentials` keychain blob plus the `oauthAccount` object in `~/.claude.json` (email, `organizationUuid`, `accountUuid`, plan tier) and writes them as **account #1** into tokenmaxxing's store (`tokenmaxxing-cred-<accountUuid[:8]>` + an `accounts.json` index entry). Nothing about your current session changes - that account stays active; it's now just also a registered pool member. After this one command you already have a working (single-account) pool. `init` also installs the supervisor + the three settings entries.
|
|
72
72
|
- If the current auth is API-key mode (`ANTHROPIC_API_KEY`/`apiKeyHelper`) rather than a subscription `/login`, there's no quota-poolable subscription credential to import - `init` says so and points you to `/login` first (per-token API billing isn't what tokenmaxxing pools).
|
|
73
73
|
- **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. This is the **only** time `CLAUDE_CONFIG_DIR` is ever used.
|
|
74
|
+
- **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose refresh token died (a needs-reauth account can never heal through a swap: the dead token is exactly what a swap would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (harvested `accountUuid` must match, else nothing changes) - the credential write and the needs-reauth clear happen in one flock critical section so a concurrent swap's harvest cannot clobber the fresh backup. Bare `auth` lists the pool with emails and asks which; `--all` walks every flagged account one by one.
|
|
74
75
|
- Both commands exercise `security` reads/writes interactively (where a macOS keychain ACL prompt is acceptable), so the first access never happens cold inside a headless hook.
|
|
75
76
|
|
|
76
77
|
---
|
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ claude # use claude as always
|
|
|
38
38
|
| `tokenmaxxing init --codex` | same for codex: import login, install codex supervisor + Stop hook |
|
|
39
39
|
| `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
|
|
40
40
|
| `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
|
|
41
|
+
| `tokenmaxxing auth [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 needs-reauth account one by one |
|
|
41
42
|
| `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
|
|
42
43
|
| `tokenmaxxing ls` | list pooled accounts |
|
|
43
44
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
@@ -45,7 +46,7 @@ claude # use claude as always
|
|
|
45
46
|
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
|
|
46
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
|
-
| `tokenmaxxing rename <sel> <label>` / `rm <sel>` | manage the pool |
|
|
49
|
+
| `tokenmaxxing rename [--codex] <sel> <label>` / `rm <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
|
|
49
50
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
|
50
51
|
|
|
51
52
|
## How switching decides
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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/add.ts
CHANGED
|
@@ -1,109 +1,32 @@
|
|
|
1
|
-
// `tokenmaxxing add` - register an ADDITIONAL account.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// credential + identity into the pool and deletes the temp dir + isolated
|
|
5
|
-
// credential. Your primary login is never touched.
|
|
1
|
+
// `tokenmaxxing add` - register an ADDITIONAL account. Drives one isolated
|
|
2
|
+
// login (see onboard.ts), then pools whatever account it landed on. Your
|
|
3
|
+
// primary login is never touched.
|
|
6
4
|
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { z } from "zod";
|
|
10
|
-
import { readItem, writeItem, deleteItem, parkedTarget, isolatedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
|
|
11
|
-
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
12
|
-
import { probeUsage } from "../lib/usage.ts";
|
|
13
|
-
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
14
5
|
import { withLock } from "../lib/lock.ts";
|
|
15
6
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
16
7
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
function identityReady(cjPath: string): boolean {
|
|
22
|
-
if (!existsSync(cjPath)) return false;
|
|
23
|
-
try {
|
|
24
|
-
const oauthAccount = JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount;
|
|
25
|
-
return z.object({ accountUuid: z.string().min(1) }).safeParse(oauthAccount).success;
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
8
|
+
import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
|
|
9
|
+
import { harvestIsolatedLogin } from "./onboard.ts";
|
|
10
|
+
import { type Account } from "../lib/types.ts";
|
|
11
|
+
import { c, claudeTierLabel, count } from "./render.ts";
|
|
30
12
|
|
|
31
13
|
export async function cmdAdd(): Promise<number> {
|
|
32
|
-
const onboardDir = paths.onboardDir;
|
|
33
|
-
rmSync(onboardDir, { recursive: true, force: true });
|
|
34
|
-
mkdirSync(onboardDir, { recursive: true });
|
|
35
|
-
const iso = isolatedTarget(onboardDir);
|
|
36
|
-
const cjPath = join(onboardDir, ".claude.json");
|
|
37
|
-
const real = resolveRealClaude();
|
|
38
|
-
|
|
39
14
|
console.log(c.cyan("Opening an isolated Claude login - your primary login is untouched."));
|
|
40
15
|
console.log(c.dim(`In the session that opens, run ${c.bold("/login")} with the account to add. It closes itself once you're in.`));
|
|
41
16
|
console.log();
|
|
42
17
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// only via the /login the user performs inside it.
|
|
47
|
-
const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
|
|
48
|
-
delete env.ANTHROPIC_API_KEY;
|
|
49
|
-
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
50
|
-
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
51
|
-
delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
52
|
-
const p = Bun.spawn([real], {
|
|
53
|
-
stdin: "inherit",
|
|
54
|
-
stdout: "inherit",
|
|
55
|
-
stderr: "inherit",
|
|
56
|
-
env,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Auto-exit (#17): watch for a completed login - identity written AND the
|
|
60
|
-
// isolated credential present - then SIGTERM claude. No manual /exit.
|
|
61
|
-
let exited = false;
|
|
62
|
-
const onExit = p.exited.then(() => { exited = true; });
|
|
63
|
-
while (!exited) {
|
|
64
|
-
await Bun.sleep(400);
|
|
65
|
-
if (identityReady(cjPath) && (await readItem(iso))) {
|
|
66
|
-
p.kill();
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
await p.exited;
|
|
71
|
-
await onExit;
|
|
72
|
-
restoreTermios(savedTermios);
|
|
73
|
-
|
|
74
|
-
const cleanup = async () => {
|
|
75
|
-
await deleteItem(iso);
|
|
76
|
-
rmSync(onboardDir, { recursive: true, force: true });
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const blobRaw = await readItem(iso);
|
|
80
|
-
if (!blobRaw || !identityReady(cjPath)) {
|
|
81
|
-
console.error(c.red("no login detected in the isolated session - nothing added."));
|
|
82
|
-
await cleanup();
|
|
83
|
-
return 1;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
let blob, oauthAccount;
|
|
87
|
-
try {
|
|
88
|
-
blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
|
|
89
|
-
oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
|
|
90
|
-
} catch {
|
|
91
|
-
console.error(c.red("could not parse the onboarded account's credential/identity."));
|
|
92
|
-
await cleanup();
|
|
93
|
-
return 1;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
|
|
97
|
-
console.log(c.dim("sampling usage..."));
|
|
98
|
-
const sampled = await probeUsage(onboardDir);
|
|
99
|
-
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
18
|
+
const harvested = await harvestIsolatedLogin();
|
|
19
|
+
if (!harvested) return 1;
|
|
20
|
+
const { blobRaw, blob, oauthAccount, sampled } = harvested;
|
|
100
21
|
|
|
101
22
|
const uuid = oauthAccount.accountUuid;
|
|
102
23
|
const keychainItem = credItemFor(uuid);
|
|
103
|
-
await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
|
|
104
24
|
|
|
105
|
-
// under the flock: a concurrent swap
|
|
25
|
+
// under the flock: a concurrent swap both harvests into parked items and
|
|
26
|
+
// writes the index, so the backup write and the index upsert must land in
|
|
27
|
+
// one critical section.
|
|
106
28
|
const { account, poolSize } = await withLock(paths.lockFile, async () => {
|
|
29
|
+
await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
|
|
107
30
|
const idx = loadAccounts();
|
|
108
31
|
const existing = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
109
32
|
const fresh: Account = {
|
|
@@ -115,6 +38,7 @@ export async function cmdAdd(): Promise<number> {
|
|
|
115
38
|
oauthAccount,
|
|
116
39
|
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
117
40
|
subscriptionType: blob.claudeAiOauth.subscriptionType,
|
|
41
|
+
rateLimitTier: blob.claudeAiOauth.rateLimitTier,
|
|
118
42
|
needsReauth: false,
|
|
119
43
|
lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
|
|
120
44
|
lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
|
|
@@ -126,10 +50,8 @@ export async function cmdAdd(): Promise<number> {
|
|
|
126
50
|
return { account: fresh, poolSize: idx.accounts.length };
|
|
127
51
|
});
|
|
128
52
|
|
|
129
|
-
await cleanup();
|
|
130
|
-
|
|
131
53
|
console.log();
|
|
132
54
|
const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
|
|
133
|
-
console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account
|
|
55
|
+
console.log(`${c.green("✓")} added ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})${usageNote} → pool now has ${count({ n: poolSize, noun: "account" })}`);
|
|
134
56
|
return 0;
|
|
135
57
|
}
|
package/src/cli/auth.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// `tokenmaxxing auth` - reauthenticate a pooled account in place. The dead
|
|
2
|
+
// refresh token cannot heal itself (a needs-reauth account can never win a
|
|
3
|
+
// swap), so this drives one isolated login per target and REQUIRES the login
|
|
4
|
+
// to land on the target account: the harvested identity must match, otherwise
|
|
5
|
+
// nothing changes. Bare `auth` lists the pool (emails shown) and asks which;
|
|
6
|
+
// `auth <sel>` targets one account, stating the email to sign in with;
|
|
7
|
+
// `auth --all` walks every needs-reauth account one by one.
|
|
8
|
+
|
|
9
|
+
import { partition } from "es-toolkit";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { withLock } from "../lib/lock.ts";
|
|
12
|
+
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
13
|
+
import { paths } from "../lib/paths.ts";
|
|
14
|
+
import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
|
|
15
|
+
import { findAccount } from "./rename.ts";
|
|
16
|
+
import { harvestIsolatedLogin } from "./onboard.ts";
|
|
17
|
+
import { c, claudeTierLabel, count } from "./render.ts";
|
|
18
|
+
import type { Account, AccountsIndex } from "../lib/types.ts";
|
|
19
|
+
|
|
20
|
+
const AUTH_USAGE = "usage: tokenmaxxing auth [<email|label|id> | --all]";
|
|
21
|
+
|
|
22
|
+
export const AuthPlanSchema = z.discriminatedUnion("kind", [
|
|
23
|
+
/** malformed argv: print AUTH_USAGE, exit 2. */
|
|
24
|
+
z.object({ kind: z.literal("usage") }),
|
|
25
|
+
/** unresolvable state (empty pool, unknown selector): exit 1. */
|
|
26
|
+
z.object({ kind: z.literal("error"), message: z.string() }),
|
|
27
|
+
/** bare `auth`: ask interactively. */
|
|
28
|
+
z.object({ kind: z.literal("pick") }),
|
|
29
|
+
z.object({ kind: z.literal("targets"), uuids: z.array(z.string()) }),
|
|
30
|
+
]);
|
|
31
|
+
export type AuthPlan = z.infer<typeof AuthPlanSchema>;
|
|
32
|
+
|
|
33
|
+
/** Resolve argv into a reauth plan (pure - unit-tested). Argv shape is
|
|
34
|
+
* validated before any pool state is consulted. */
|
|
35
|
+
export function planAuth(input: { accounts: Account[]; argv: string[] }): AuthPlan {
|
|
36
|
+
const all = input.argv.includes("--all");
|
|
37
|
+
const rest = input.argv.filter((a) => a !== "--all");
|
|
38
|
+
if ((all && rest.length > 0) || rest.length > 1) return { kind: "usage" };
|
|
39
|
+
if (input.accounts.length === 0) return { kind: "error", message: "no accounts in the pool - run `tokenmaxxing init` first" };
|
|
40
|
+
if (all) {
|
|
41
|
+
const flagged = input.accounts.filter((a) => a.needsReauth === true);
|
|
42
|
+
return { kind: "targets", uuids: flagged.map((a) => a.accountUuid) };
|
|
43
|
+
}
|
|
44
|
+
const selector = rest[0];
|
|
45
|
+
if (selector !== undefined) {
|
|
46
|
+
const found = findAccount(input.accounts, selector);
|
|
47
|
+
if (!found) return { kind: "error", message: `no claude account matches "${selector}"` };
|
|
48
|
+
return { kind: "targets", uuids: [found.accountUuid] };
|
|
49
|
+
}
|
|
50
|
+
return { kind: "pick" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Needs-reauth accounts first (they are why the user is here), pool order within. */
|
|
54
|
+
export function pickerOrder(accounts: Account[]): Account[] {
|
|
55
|
+
const [flagged, healthy] = partition(accounts, (a) => a.needsReauth === true);
|
|
56
|
+
return [...flagged, ...healthy];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function askWhichAccount(idx: AccountsIndex): Account | null {
|
|
60
|
+
const ordered = pickerOrder(idx.accounts);
|
|
61
|
+
console.log("which account do you want to reauthenticate?");
|
|
62
|
+
for (const [i, a] of ordered.entries()) {
|
|
63
|
+
const flags: string[] = [];
|
|
64
|
+
if (a.accountUuid === idx.activeAccountUuid) flags.push(c.green("active"));
|
|
65
|
+
if (a.needsReauth) flags.push(c.red("needs-reauth"));
|
|
66
|
+
const labelNote = a.label && a.label !== a.email ? ` (${a.label})` : "";
|
|
67
|
+
const tag = flags.length ? ` ${flags.join(" ")}` : "";
|
|
68
|
+
console.log(` ${i + 1}. ${c.bold(a.email)}${labelNote}${tag}`);
|
|
69
|
+
}
|
|
70
|
+
const answer = prompt("account (number, email, or label):")?.trim();
|
|
71
|
+
if (!answer) {
|
|
72
|
+
console.error(c.red("nothing selected"));
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const n = Number(answer);
|
|
76
|
+
const byNumber = Number.isInteger(n) && n >= 1 && n <= ordered.length ? ordered[n - 1] : undefined;
|
|
77
|
+
const chosen = byNumber ?? findAccount(ordered, answer);
|
|
78
|
+
if (!chosen) {
|
|
79
|
+
console.error(c.red(`no account matches "${answer}"`));
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return chosen;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function reauthOne(target: Account): Promise<boolean> {
|
|
86
|
+
console.log(c.cyan(`Reauthenticating ${c.bold(target.label)} - sign in as ${c.bold(target.email)}.`));
|
|
87
|
+
console.log(c.dim(`In the session that opens, run ${c.bold("/login")} with that account. It closes itself once you're in.`));
|
|
88
|
+
console.log();
|
|
89
|
+
|
|
90
|
+
const harvested = await harvestIsolatedLogin();
|
|
91
|
+
if (!harvested) return false;
|
|
92
|
+
const { blobRaw, blob, oauthAccount, sampled } = harvested;
|
|
93
|
+
|
|
94
|
+
if (oauthAccount.accountUuid !== target.accountUuid) {
|
|
95
|
+
console.error(
|
|
96
|
+
c.red(
|
|
97
|
+
`that login is ${c.bold(oauthAccount.emailAddress)}, but ${target.label} is ${c.bold(target.email)} - nothing changed. To pool it as its own account, run \`tokenmaxxing add\`.`,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// under the flock: a concurrent swap both harvests into parked items and
|
|
104
|
+
// writes the index, so the credential write and the needsReauth clear must
|
|
105
|
+
// land in one critical section (else a swap-away harvest of the live blob
|
|
106
|
+
// could overwrite this fresh backup right before it is marked healthy).
|
|
107
|
+
const isActive = await withLock(paths.lockFile, async () => {
|
|
108
|
+
await writeItem(parkedTarget(target.keychainItem), claudeAiOauthOnly(blobRaw));
|
|
109
|
+
const idx = loadAccounts();
|
|
110
|
+
const account = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
111
|
+
if (account) {
|
|
112
|
+
account.email = oauthAccount.emailAddress;
|
|
113
|
+
account.organizationUuid = oauthAccount.organizationUuid;
|
|
114
|
+
account.oauthAccount = oauthAccount;
|
|
115
|
+
account.subscriptionType = blob.claudeAiOauth.subscriptionType;
|
|
116
|
+
account.rateLimitTier = blob.claudeAiOauth.rateLimitTier;
|
|
117
|
+
account.needsReauth = false;
|
|
118
|
+
if (sampled) {
|
|
119
|
+
account.lastUsage = { fiveHour: sampled.session, sevenDay: sampled.weekAll };
|
|
120
|
+
if (Object.keys(sampled.perModel).length > 0) account.lastPerModel = sampled.perModel;
|
|
121
|
+
account.lastUsageAt = Date.now();
|
|
122
|
+
}
|
|
123
|
+
saveAccounts(idx);
|
|
124
|
+
}
|
|
125
|
+
return idx.activeAccountUuid === target.accountUuid;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
|
|
129
|
+
const tier = claudeTierLabel(blob.claudeAiOauth) ?? "?";
|
|
130
|
+
console.log(`${c.green("✓")} reauthed ${c.bold(oauthAccount.emailAddress)} (${tier})${usageNote}`);
|
|
131
|
+
if (isActive) {
|
|
132
|
+
console.log(c.dim("this account is the active one: the fresh credential is parked as its backup; the live session keeps its current token until the next swap."));
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function cmdAuth(argv: string[]): Promise<number> {
|
|
138
|
+
const idx = loadAccounts();
|
|
139
|
+
const plan = planAuth({ accounts: idx.accounts, argv });
|
|
140
|
+
if (plan.kind === "usage") {
|
|
141
|
+
console.error(AUTH_USAGE);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
if (plan.kind === "error") {
|
|
145
|
+
console.error(c.red(plan.message));
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const targets: Account[] = [];
|
|
150
|
+
if (plan.kind === "pick") {
|
|
151
|
+
const picked = askWhichAccount(idx);
|
|
152
|
+
if (!picked) return 1;
|
|
153
|
+
targets.push(picked);
|
|
154
|
+
} else {
|
|
155
|
+
for (const uuid of plan.uuids) {
|
|
156
|
+
const account = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
157
|
+
if (account) targets.push(account);
|
|
158
|
+
}
|
|
159
|
+
if (targets.length === 0) {
|
|
160
|
+
console.log(`${c.green("✓")} no account needs reauth`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let ok = 0;
|
|
166
|
+
for (const [i, target] of targets.entries()) {
|
|
167
|
+
console.log();
|
|
168
|
+
if (targets.length > 1) console.log(c.bold(`[${i + 1}/${targets.length}]`));
|
|
169
|
+
if (await reauthOne(target)) ok += 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (targets.length > 1) {
|
|
173
|
+
console.log();
|
|
174
|
+
console.log(`reauthed ${count({ n: ok, noun: "account" })} of ${targets.length}`);
|
|
175
|
+
}
|
|
176
|
+
return ok === targets.length ? 0 : 1;
|
|
177
|
+
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -60,17 +60,17 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
60
60
|
|
|
61
61
|
for (const a of idx.accounts) {
|
|
62
62
|
const parked = await readItem(parkedTarget(a.keychainItem));
|
|
63
|
-
check(!!parked, `parked credential present for ${a.email}`,
|
|
63
|
+
check(!!parked, `parked credential present for ${a.email}`, `run \`tokenmaxxing auth ${a.label}\``);
|
|
64
64
|
if (parked) {
|
|
65
65
|
try {
|
|
66
66
|
const org = await blobOrg(parked);
|
|
67
|
-
if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} -
|
|
67
|
+
if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - run \`tokenmaxxing auth ${a.label}\``);
|
|
68
68
|
else console.log(c.dim(` - ${a.email} identity unverifiable (access token expired)`));
|
|
69
69
|
} catch (e) {
|
|
70
70
|
check(false, `parked credential identity matches ${a.email}`, String((e as Error).message ?? e).slice(0, 100));
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
if (a.needsReauth) check(false, `${a.email} needs re-auth`,
|
|
73
|
+
if (a.needsReauth) check(false, `${a.email} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
const cfg = loadConfig();
|
package/src/cli/init.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, ty
|
|
|
10
10
|
import { resolveVerifiedClaude } from "../lib/claudebin.ts";
|
|
11
11
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
12
12
|
import { CredentialBlobSchema, type Account } from "../lib/types.ts";
|
|
13
|
-
import { c } from "./render.ts";
|
|
13
|
+
import { c, claudeTierLabel } from "./render.ts";
|
|
14
14
|
|
|
15
15
|
/** Put the supervisor bin dir on PATH via the user's shell rc (idempotent).
|
|
16
16
|
* Falls back to the manual instruction when the shell is unknown. */
|
|
@@ -121,6 +121,7 @@ export async function cmdInit(): Promise<number> {
|
|
|
121
121
|
oauthAccount,
|
|
122
122
|
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
123
123
|
subscriptionType: blob.claudeAiOauth.subscriptionType,
|
|
124
|
+
rateLimitTier: blob.claudeAiOauth.rateLimitTier,
|
|
124
125
|
needsReauth: false,
|
|
125
126
|
};
|
|
126
127
|
if (existing) Object.assign(existing, account);
|
|
@@ -134,7 +135,7 @@ export async function cmdInit(): Promise<number> {
|
|
|
134
135
|
|
|
135
136
|
const out = installSupervisor();
|
|
136
137
|
|
|
137
|
-
console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account
|
|
138
|
+
console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})`);
|
|
138
139
|
console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
|
|
139
140
|
reportTimer(out);
|
|
140
141
|
if (!out.pathAhead) {
|
package/src/cli/ls.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { loadAccounts } from "../lib/state.ts";
|
|
4
4
|
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
5
5
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
6
|
-
import { c } from "./render.ts";
|
|
6
|
+
import { c, claudeTierLabel } from "./render.ts";
|
|
7
7
|
|
|
8
8
|
export function cmdLs(): number {
|
|
9
9
|
const idx = loadAccounts();
|
|
@@ -19,7 +19,7 @@ export function cmdLs(): number {
|
|
|
19
19
|
const tag = flags.length ? ` ${flags.join(" ")}` : "";
|
|
20
20
|
const label = a.label || a.email;
|
|
21
21
|
console.log(`${marker} ${c.bold(label)}${tag}`);
|
|
22
|
-
console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)}, ${a
|
|
22
|
+
console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)}, ${claudeTierLabel(a) ?? "?"}, uuid ${a.accountUuid.slice(0, 8)}`)}`);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const codex = loadCodexAccounts();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// One isolated Claude login, harvested. Shared by `add` (register whatever
|
|
2
|
+
// account the login lands on) and `auth` (require it to match an existing
|
|
3
|
+
// account). Drives the login in a throwaway CLAUDE_CONFIG_DIR - the ONLY use
|
|
4
|
+
// of CLAUDE_CONFIG_DIR in the tool - auto-exits the moment the login lands,
|
|
5
|
+
// samples that account's usage, then returns the credential + identity. The
|
|
6
|
+
// temp dir and isolated credential are always destroyed before returning.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { readItem, deleteItem, isolatedTarget } from "../lib/credstore.ts";
|
|
12
|
+
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
13
|
+
import { probeUsage, FullUsageSchema } from "../lib/usage.ts";
|
|
14
|
+
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
15
|
+
import { paths } from "../lib/paths.ts";
|
|
16
|
+
import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
|
|
17
|
+
import { c } from "./render.ts";
|
|
18
|
+
|
|
19
|
+
export const HarvestedLoginSchema = z.object({
|
|
20
|
+
/** the raw isolated credential blob (park it via claudeAiOauthOnly). */
|
|
21
|
+
blobRaw: z.string(),
|
|
22
|
+
blob: CredentialBlobSchema,
|
|
23
|
+
oauthAccount: OAuthAccountSchema,
|
|
24
|
+
sampled: FullUsageSchema.nullable(),
|
|
25
|
+
});
|
|
26
|
+
export type HarvestedLogin = z.infer<typeof HarvestedLoginSchema>;
|
|
27
|
+
|
|
28
|
+
/** True once `/login` has written a usable identity into the onboard dir. */
|
|
29
|
+
function identityReady(cjPath: string): boolean {
|
|
30
|
+
if (!existsSync(cjPath)) return false;
|
|
31
|
+
try {
|
|
32
|
+
const oauthAccount = JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount;
|
|
33
|
+
return z.object({ accountUuid: z.string().min(1) }).safeParse(oauthAccount).success;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run one isolated login session and harvest it. The caller prints its own
|
|
41
|
+
* instructions (which account to sign in as) BEFORE calling. Prints progress
|
|
42
|
+
* and failure diagnostics; returns null when no usable login landed.
|
|
43
|
+
*/
|
|
44
|
+
export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
45
|
+
const onboardDir = paths.onboardDir;
|
|
46
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
47
|
+
mkdirSync(onboardDir, { recursive: true });
|
|
48
|
+
const iso = isolatedTarget(onboardDir);
|
|
49
|
+
const cjPath = join(onboardDir, ".claude.json");
|
|
50
|
+
const real = resolveRealClaude();
|
|
51
|
+
|
|
52
|
+
const savedTermios = saveTermios();
|
|
53
|
+
// Scrub the ambient credential/identity overrides claude honors BEFORE its
|
|
54
|
+
// keychain lookup (verified 2.1.205) - the onboard session must authenticate
|
|
55
|
+
// only via the /login the user performs inside it.
|
|
56
|
+
const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
|
|
57
|
+
delete env.ANTHROPIC_API_KEY;
|
|
58
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
59
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
60
|
+
delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
61
|
+
const p = Bun.spawn([real], {
|
|
62
|
+
stdin: "inherit",
|
|
63
|
+
stdout: "inherit",
|
|
64
|
+
stderr: "inherit",
|
|
65
|
+
env,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Auto-exit (#17): watch for a completed login - identity written AND the
|
|
69
|
+
// isolated credential present - then SIGTERM claude. No manual /exit.
|
|
70
|
+
let exited = false;
|
|
71
|
+
const onExit = p.exited.then(() => { exited = true; });
|
|
72
|
+
while (!exited) {
|
|
73
|
+
await Bun.sleep(400);
|
|
74
|
+
if (identityReady(cjPath) && (await readItem(iso))) {
|
|
75
|
+
p.kill();
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await p.exited;
|
|
80
|
+
await onExit;
|
|
81
|
+
restoreTermios(savedTermios);
|
|
82
|
+
|
|
83
|
+
const cleanup = async () => {
|
|
84
|
+
await deleteItem(iso);
|
|
85
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const blobRaw = await readItem(iso);
|
|
89
|
+
if (!blobRaw || !identityReady(cjPath)) {
|
|
90
|
+
console.error(c.red("no login detected in the isolated session - nothing changed."));
|
|
91
|
+
await cleanup();
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let blob, oauthAccount;
|
|
96
|
+
try {
|
|
97
|
+
blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
|
|
98
|
+
oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
|
|
99
|
+
} catch {
|
|
100
|
+
console.error(c.red("could not parse the onboarded account's credential/identity."));
|
|
101
|
+
await cleanup();
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
|
|
106
|
+
console.log(c.dim("sampling usage..."));
|
|
107
|
+
const sampled = await probeUsage(onboardDir);
|
|
108
|
+
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
109
|
+
|
|
110
|
+
await cleanup();
|
|
111
|
+
return { blobRaw, blob, oauthAccount, sampled };
|
|
112
|
+
}
|
package/src/cli/rename.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// `tokenmaxxing rename <selector> <new-label>` - relabel a pooled
|
|
1
|
+
// `tokenmaxxing rename [--codex] <selector> <new-label>` - relabel a pooled
|
|
2
|
+
// account. The pools are separate namespaces and one email can hold both a
|
|
3
|
+
// claude and a codex account, so the codex pool is targeted explicitly via
|
|
4
|
+
// `--codex` (mirroring `switch --codex`), never by searching both pools.
|
|
2
5
|
|
|
3
6
|
import { withLock } from "../lib/lock.ts";
|
|
4
|
-
import { paths } from "../lib/paths.ts";
|
|
7
|
+
import { codexPaths, paths } from "../lib/paths.ts";
|
|
5
8
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
9
|
+
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
6
10
|
import { c } from "./render.ts";
|
|
7
|
-
import type { Account } from "../lib/types.ts";
|
|
11
|
+
import type { Account, CodexAccount } from "../lib/types.ts";
|
|
8
12
|
|
|
9
|
-
/** Resolve
|
|
13
|
+
/** Resolve a claude account by email, label, or accountUuid prefix. */
|
|
10
14
|
export function findAccount(accounts: Account[], selector: string): Account | undefined {
|
|
11
15
|
const s = selector.toLowerCase();
|
|
12
16
|
return (
|
|
@@ -16,17 +20,47 @@ export function findAccount(accounts: Account[], selector: string): Account | un
|
|
|
16
20
|
);
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
/** Resolve a codex account by email, label, or accountId prefix. */
|
|
24
|
+
export function findCodexAccount(accounts: CodexAccount[], selector: string): CodexAccount | undefined {
|
|
25
|
+
const s = selector.toLowerCase();
|
|
26
|
+
return (
|
|
27
|
+
accounts.find((a) => a.email?.toLowerCase() === s) ??
|
|
28
|
+
accounts.find((a) => a.label.toLowerCase() === s) ??
|
|
29
|
+
accounts.find((a) => a.accountId.toLowerCase().startsWith(s))
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function renameCodexAccount(input: { selector: string; newLabel: string }): Promise<number> {
|
|
34
|
+
// under the codex flock: a concurrent codex swap's index write must not be clobbered.
|
|
35
|
+
return withLock(codexPaths.lockFile, async () => {
|
|
36
|
+
const index = loadCodexAccounts();
|
|
37
|
+
const account = findCodexAccount(index.accounts, input.selector);
|
|
38
|
+
if (!account) {
|
|
39
|
+
console.error(c.red(`no codex account matches "${input.selector}"`));
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
const old = account.label;
|
|
43
|
+
account.label = input.newLabel;
|
|
44
|
+
saveCodexAccounts({ index });
|
|
45
|
+
console.log(`renamed codex account ${c.dim(old)} → ${c.bold(input.newLabel)}`);
|
|
46
|
+
return 0;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function cmdRename(argv: string[]): Promise<number> {
|
|
51
|
+
const codex = argv.includes("--codex");
|
|
52
|
+
const [selector, newLabel] = argv.filter((a) => a !== "--codex");
|
|
20
53
|
if (!selector || !newLabel) {
|
|
21
|
-
console.error("usage: tokenmaxxing rename <email|label|
|
|
54
|
+
console.error("usage: tokenmaxxing rename [--codex] <email|label|id> <new-label>");
|
|
22
55
|
return 2;
|
|
23
56
|
}
|
|
57
|
+
if (codex) return renameCodexAccount({ selector, newLabel });
|
|
24
58
|
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
25
59
|
return withLock(paths.lockFile, async () => {
|
|
26
60
|
const idx = loadAccounts();
|
|
27
61
|
const a = findAccount(idx.accounts, selector);
|
|
28
62
|
if (!a) {
|
|
29
|
-
console.error(c.red(`no account matches "${selector}"`));
|
|
63
|
+
console.error(c.red(`no claude account matches "${selector}" (codex accounts rename via --codex)`));
|
|
30
64
|
return 1;
|
|
31
65
|
}
|
|
32
66
|
const old = a.label;
|
package/src/cli/render.ts
CHANGED
|
@@ -18,6 +18,19 @@ export function makeColors(enabled: boolean) {
|
|
|
18
18
|
|
|
19
19
|
export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
|
|
20
20
|
|
|
21
|
+
/** Plan label for a claude account, e.g. "max 20x": subscription name plus the
|
|
22
|
+
* multiplier segment of the rate-limit tier id ("default_claude_max_20x").
|
|
23
|
+
* Structural, never exact-string: the multiplier is any <digits>x segment, so
|
|
24
|
+
* tiers without one (e.g. "default_claude_zero") fall back to the bare
|
|
25
|
+
* subscription name, and an absent blob field falls back to null (unmeasured
|
|
26
|
+
* must not look like a tier). */
|
|
27
|
+
export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
|
|
28
|
+
const segments = input.rateLimitTier?.split("_") ?? [];
|
|
29
|
+
const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
|
|
30
|
+
if (input.subscriptionType == null) return multiplier ?? null;
|
|
31
|
+
return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
/** "1 account" / "3 accounts": counted nouns always pluralize properly. */
|
|
22
35
|
export function count(input: { n: number; noun: string }): string {
|
|
23
36
|
return `${input.n} ${input.noun}${input.n === 1 ? "" : "s"}`;
|
package/src/cli/status.ts
CHANGED
|
@@ -21,8 +21,8 @@ import { effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
|
|
|
21
21
|
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
22
22
|
import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
|
|
23
23
|
import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
24
|
-
import { isSessionWindow } from "../lib/codexusage.ts";
|
|
25
|
-
import { bar, c, count, fmtAgo, fmtReset } from "./render.ts";
|
|
24
|
+
import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
|
|
25
|
+
import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
|
|
26
26
|
import type { FullUsage } from "../lib/usage.ts";
|
|
27
27
|
import type { Config, CodexWindow, UsageWindow } from "../lib/types.ts";
|
|
28
28
|
|
|
@@ -48,10 +48,15 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
48
48
|
idx = loadAccounts();
|
|
49
49
|
const live = loadUsage();
|
|
50
50
|
const modelUsage = loadModelUsage();
|
|
51
|
-
const
|
|
51
|
+
const liveOAuth = readOAuthAccount();
|
|
52
|
+
const activeOrg = liveOAuth?.organizationUuid ?? null;
|
|
52
53
|
await Promise.all(
|
|
53
54
|
idx.accounts.map(async (a) => {
|
|
54
55
|
const isActive = a.accountUuid === idx.activeAccountUuid && activeOrg === a.organizationUuid;
|
|
56
|
+
// The tee path never opens the credential blob, so the active account's
|
|
57
|
+
// tier comes from the live oauthAccount instead - it names this very org
|
|
58
|
+
// (uuid-matched above), so the tier is attributed to its own identity.
|
|
59
|
+
if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
|
|
55
60
|
// Active account: prefer the free statusLine push (usage.json) so we never
|
|
56
61
|
// poll its own token, which is busy exactly when it matters. per-model
|
|
57
62
|
// comes from model-usage.json (also statusLine-driven).
|
|
@@ -126,12 +131,15 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
126
131
|
if (isExhausted(a, { now, thresholds: effectiveBars(cfg), currentAccountUuid: idx.activeAccountUuid, switchFamilies: cfg.policy.switchModels }))
|
|
127
132
|
badges.push(c.yellow("exhausted"));
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
const tier = claudeTierLabel(a);
|
|
135
|
+
console.log(`${marker} ${c.bold(a.label || a.email)}${tier ? ` ${c.dim(tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
|
|
136
|
+
// Chart label convention (user rule 2026-07-17): everything lowercase,
|
|
137
|
+
// model names short ("fable", "spark").
|
|
130
138
|
if (aggregate) {
|
|
131
139
|
row("5h", aggregate.fiveHour, false);
|
|
132
140
|
row("week", aggregate.sevenDay, true);
|
|
133
141
|
}
|
|
134
|
-
if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w, true);
|
|
142
|
+
if (perModel) for (const [name, w] of Object.entries(perModel)) row(name.toLowerCase(), w, true);
|
|
135
143
|
if (failed && outcome && !outcome.ok) {
|
|
136
144
|
const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""}, ` : "";
|
|
137
145
|
console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
|
|
@@ -199,13 +207,13 @@ async function renderCodexSection(input: {
|
|
|
199
207
|
if (active) badges.push(c.green("active"));
|
|
200
208
|
if (account.needsReauth) badges.push(c.red("needs-reauth"));
|
|
201
209
|
if (isCodexExhausted({ account, thresholds: effectiveBars(cfg), now })) badges.push(c.yellow("exhausted"));
|
|
202
|
-
console.log(`${marker} ${c.bold(account.label)}
|
|
210
|
+
console.log(`${marker} ${c.bold(account.label)}${account.planType ? ` ${c.dim(account.planType)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
|
|
203
211
|
|
|
204
212
|
const usage = account.lastUsage;
|
|
205
213
|
if (usage) {
|
|
206
214
|
for (const window of usage.aggregate) row(windowLabel(window), window, !isSessionWindow({ window }));
|
|
207
215
|
for (const [name, windows] of Object.entries(usage.perLimit)) {
|
|
208
|
-
for (const window of windows) row(name, window, !isSessionWindow({ window }));
|
|
216
|
+
for (const window of windows) row(codexLimitLabel({ limitName: name }), window, !isSessionWindow({ window }));
|
|
209
217
|
}
|
|
210
218
|
}
|
|
211
219
|
const outcome = outcomes.get(account.accountId);
|
package/src/cli/switch.ts
CHANGED
|
@@ -43,7 +43,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
43
43
|
try {
|
|
44
44
|
await performSwap(target);
|
|
45
45
|
} catch (e) {
|
|
46
|
-
if (e instanceof InvalidGrantError) { console.error(c.red(`${target.label}'s refresh token is dead -
|
|
46
|
+
if (e instanceof InvalidGrantError) { console.error(c.red(`${target.label}'s refresh token is dead - run \`tokenmaxxing auth ${target.label}\``)); return 1; }
|
|
47
47
|
throw e;
|
|
48
48
|
}
|
|
49
49
|
console.log(`${c.green("↻")} switched to ${c.bold(target.label)}`);
|
|
@@ -86,7 +86,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
86
86
|
// Either every account needs re-auth, or every account is blocked with no
|
|
87
87
|
// recoverable bound (unparsed reset clocks AND no sample time - see log).
|
|
88
88
|
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
89
|
-
if (reauth.length > 0) { console.error(c.yellow(`no switchable account -
|
|
89
|
+
if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
|
|
90
90
|
// never freeze a label drift behind a no-op (see header).
|
|
91
91
|
if (drifted && active) return swapTo(active);
|
|
92
92
|
console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
|
package/src/lib/codexusage.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { z } from "zod";
|
|
|
14
14
|
import { http, safeErrorDetail } from "./http.ts";
|
|
15
15
|
import { CodexUsageSchema, type CodexAuthJson, type CodexUsage, type CodexWindow } from "./types.ts";
|
|
16
16
|
import { codexIdentityOf } from "./codexauth.ts";
|
|
17
|
+
import { familyTokens } from "./usage.ts";
|
|
17
18
|
|
|
18
19
|
const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
|
|
19
20
|
const USAGE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_USAGE_URL) ?? "https://chatgpt.com/backend-api/wham/usage";
|
|
@@ -114,6 +115,15 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<C
|
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/** Quota-chart label for an additional_rate_limits row: the model family,
|
|
119
|
+
* lowercase ("GPT-5.3-Codex-Spark" -> "spark"). Wire names are versioned, so
|
|
120
|
+
* the label is derived structurally (last non-numeric token), never by exact
|
|
121
|
+
* string (user rule 2026-07-17: chart names are lowercase, e.g. "spark"). */
|
|
122
|
+
export function codexLimitLabel(input: { limitName: string }): string {
|
|
123
|
+
const tokens = familyTokens(input.limitName).filter((t) => Number.isNaN(Number(t)));
|
|
124
|
+
return tokens.at(-1) ?? input.limitName.trim().toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
const SESSION_WINDOW_MAX_S = 6 * 3600;
|
|
118
128
|
|
|
119
129
|
/** A window's screening bar: short windows (5h-class) screen at the session
|
package/src/lib/sample.ts
CHANGED
|
@@ -49,6 +49,14 @@ async function identityMismatch(creds: OAuthCreds, account: Account): Promise<st
|
|
|
49
49
|
return `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Stamp the blob's plan fields onto the account (caller persists). Runs only
|
|
53
|
+
* after the identity check passed, so a drifted credential can never write
|
|
54
|
+
* another account's tier. Absent blob fields keep the last-known values. */
|
|
55
|
+
function refreshPlanFields(account: Account, creds: OAuthCreds): void {
|
|
56
|
+
if (creds.subscriptionType != null) account.subscriptionType = creds.subscriptionType;
|
|
57
|
+
if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
/**
|
|
53
61
|
* Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
|
|
54
62
|
* mislabeled credential it sets `account.needsReauth` in place (the caller
|
|
@@ -60,13 +68,13 @@ async function identityMismatch(creds: OAuthCreds, account: Account): Promise<st
|
|
|
60
68
|
export async function probeParkedUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
|
|
61
69
|
const backup = parkedTarget(account.keychainItem);
|
|
62
70
|
const parkedRaw = await readItem(backup);
|
|
63
|
-
if (!parkedRaw) return { ok: false, reason: "no parked credential -
|
|
71
|
+
if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
|
|
64
72
|
|
|
65
73
|
let creds: OAuthCreds;
|
|
66
74
|
try {
|
|
67
75
|
creds = CredentialBlobSchema.parse(JSON.parse(parkedRaw)).claudeAiOauth;
|
|
68
76
|
} catch (e) {
|
|
69
|
-
return { ok: false, reason: `parked credential unreadable (${String((e as Error).message ?? e).slice(0, 80)}) -
|
|
77
|
+
return { ok: false, reason: `parked credential unreadable (${String((e as Error).message ?? e).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
// Hand claude a token with comfortable headroom so it won't run its own refresh
|
|
@@ -89,6 +97,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
89
97
|
account.needsReauth = true;
|
|
90
98
|
return { ok: false, reason: `${mismatch} - this account's own credential is gone; re-auth with \`tokenmaxxing add\`` };
|
|
91
99
|
}
|
|
100
|
+
refreshPlanFields(account, creds);
|
|
92
101
|
|
|
93
102
|
const dir = join(paths.sampleDir, credItemFor(account.accountUuid));
|
|
94
103
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -149,6 +158,7 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
|
|
|
149
158
|
|
|
150
159
|
const mismatch = await identityMismatch(creds, account);
|
|
151
160
|
if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
|
|
161
|
+
refreshPlanFields(account, creds);
|
|
152
162
|
|
|
153
163
|
const pingError = opts.ping ? await pingSession() : null;
|
|
154
164
|
const usage = await probeUsage();
|
package/src/lib/types.ts
CHANGED
|
@@ -36,6 +36,9 @@ export const OAuthAccountSchema = z.looseObject({
|
|
|
36
36
|
seatTier: z.string().nullish(),
|
|
37
37
|
billingType: z.string().nullish(),
|
|
38
38
|
displayName: z.string().nullish(),
|
|
39
|
+
/** rate-limit tier id ("default_claude_max_20x"), same value the credential
|
|
40
|
+
* blob carries; claude fills it in on profile fetch (live-verified 2.1.211). */
|
|
41
|
+
organizationRateLimitTier: z.string().nullish(),
|
|
39
42
|
});
|
|
40
43
|
export type OAuthAccount = z.infer<typeof OAuthAccountSchema>;
|
|
41
44
|
|
|
@@ -90,6 +93,10 @@ export const AccountSchema = z.object({
|
|
|
90
93
|
lastUsageAt: z.number().optional(),
|
|
91
94
|
needsReauth: z.boolean().optional(),
|
|
92
95
|
subscriptionType: z.string().optional(),
|
|
96
|
+
/** the blob's rate-limit tier id (e.g. "default_claude_max_20x"): the only
|
|
97
|
+
* field that distinguishes max 5x from max 20x (subscriptionType is just
|
|
98
|
+
* "max" for both). Refreshed on every verified sample. */
|
|
99
|
+
rateLimitTier: z.string().optional(),
|
|
93
100
|
});
|
|
94
101
|
export type Account = z.infer<typeof AccountSchema>;
|
|
95
102
|
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { runStopHook } from "./entries/stophook.ts";
|
|
|
10
10
|
import { runSessionStart } from "./entries/sessionstart.ts";
|
|
11
11
|
import { cmdInit } from "./cli/init.ts";
|
|
12
12
|
import { cmdAdd } from "./cli/add.ts";
|
|
13
|
+
import { cmdAuth } from "./cli/auth.ts";
|
|
13
14
|
import { cmdCodexAdd } from "./cli/codexadd.ts";
|
|
14
15
|
import { cmdCodexInit } from "./cli/codexinit.ts";
|
|
15
16
|
import { cmdCodexSwitch } from "./cli/codexswitch.ts";
|
|
@@ -37,6 +38,7 @@ function printHelp(): void {
|
|
|
37
38
|
${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
|
|
38
39
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
39
40
|
${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
|
|
41
|
+
${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)
|
|
40
42
|
${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
|
|
41
43
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
42
44
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
@@ -44,7 +46,7 @@ function printHelp(): void {
|
|
|
44
46
|
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
45
47
|
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
46
48
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
47
|
-
${c.cyan("tokenmaxxing rename")} <sel> <label>
|
|
49
|
+
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
48
50
|
${c.cyan("tokenmaxxing rm")} <sel>
|
|
49
51
|
${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
|
|
50
52
|
|
|
@@ -80,12 +82,13 @@ async function main(): Promise<number> {
|
|
|
80
82
|
case "config": return cmdConfig(args.slice(1));
|
|
81
83
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
82
84
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
85
|
+
case "auth": return cmdAuth(args.slice(1));
|
|
83
86
|
case "ls": return cmdLs();
|
|
84
87
|
case "status": return cmdStatus(args.includes("--force"));
|
|
85
88
|
case "watch": return cmdWatch(args[1]);
|
|
86
89
|
case "doctor": return cmdDoctor();
|
|
87
90
|
case "rm": return cmdRm(args[1]);
|
|
88
|
-
case "rename": return cmdRename(args
|
|
91
|
+
case "rename": return cmdRename(args.slice(1));
|
|
89
92
|
case "uninstall":
|
|
90
93
|
uninstallSupervisor();
|
|
91
94
|
console.log("removed supervisor wrapper + settings entries (accounts/credentials kept)");
|