tokenmaxxing 0.2.0 → 0.2.1

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.
@@ -0,0 +1,149 @@
1
+ // Shared switch decision used by both the Stop hook and the SessionStart hook.
2
+ // Cheap pre-check off the lock; the authoritative re-check + swap under the flock.
3
+ //
4
+ // Two limit families are checked, both metered against the CURRENTLY-active org:
5
+ // 1. AGGREGATE windows (session=five_hour, week-all=seven_day) from statusLine.
6
+ // 2. PER-MODEL weekly cap (e.g. "week (Fable)") - only when the active model is
7
+ // capacity-constrained (config policy.switchModels). This isn't in statusLine,
8
+ // so we read it from `claude -p '/usage'`, TTL-cached (not every turn).
9
+ //
10
+ // The org guard is load-bearing: right after a respawn, usage.json still reflects
11
+ // the OLD account, so org != activeOrg → we correctly do nothing until fresh usage
12
+ // for the new account arrives.
13
+
14
+ import { z } from "zod";
15
+ import { withLock } from "./lock.ts";
16
+ import { paths } from "./paths.ts";
17
+ import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage } from "./state.ts";
18
+ import { readOAuthAccount } from "./claudejson.ts";
19
+ import { chooseAndSwap, performSwap } from "./swap.ts";
20
+ import { pickEarliestReset, usableAt } from "./picker.ts";
21
+ import { InvalidGrantError } from "./oauth.ts";
22
+ import { probeUsage } from "./usage.ts";
23
+ import { log } from "./log.ts";
24
+ import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
25
+
26
+ const SwapDecisionSchema = z.object({
27
+ swapped: z.boolean(),
28
+ account: AccountSchema.nullable(),
29
+ reason: z.string(),
30
+ /** set when every account is depleted: epoch ms the chosen account recovers. */
31
+ waitUntil: z.number().optional(),
32
+ });
33
+ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
34
+
35
+ /** Cold-start aggregate fallback when statusLine hasn't written usage.json yet. */
36
+ async function probeAggregate(org: string | null): Promise<UsageState | null> {
37
+ const full = await probeUsage();
38
+ if (!full) return null;
39
+ return { fiveHour: full.session, sevenDay: full.weekAll, org, ts: Date.now(), model: null };
40
+ }
41
+
42
+ /** Ensure a fresh-enough per-model cache for the active org; poll `/usage` if stale. */
43
+ async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsageState | null> {
44
+ const cached = loadModelUsage();
45
+ const fresh = cached && cached.org === org && Date.now() - cached.ts < cfg.policy.usagePollTtlMs;
46
+ if (fresh) return cached;
47
+ const full = await probeUsage();
48
+ if (!full) return cached; // keep stale on poll failure
49
+ const state: ModelUsageState = { perModel: full.perModel, org, ts: Date.now() };
50
+ saveModelUsage(state);
51
+ return state;
52
+ }
53
+
54
+ function capForModel(mu: ModelUsageState | null, display: string): UsageWindow | undefined {
55
+ if (!mu) return undefined;
56
+ const key = Object.keys(mu.perModel).find((k) => k.toLowerCase() === display.toLowerCase());
57
+ return key ? mu.perModel[key] : undefined;
58
+ }
59
+
60
+ /** True if the active account is over the floor on ANY applicable limit. */
61
+ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number): boolean {
62
+ if (!u || !org || u.org !== org) return false;
63
+ if (u.fiveHour.usedPercentage >= floor || u.sevenDay.usedPercentage >= floor) return true;
64
+ const display = u.model?.display;
65
+ if (display && cfg.policy.switchModels.includes(display.toLowerCase()) && mu && mu.org === org) {
66
+ const cap = capForModel(mu, display);
67
+ if (cap && cap.usedPercentage >= floor) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /** Does the active model warrant a per-model `/usage` poll? */
73
+ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
74
+ const display = u?.model?.display;
75
+ return !!display && cfg.policy.switchModels.includes(display.toLowerCase());
76
+ }
77
+
78
+ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecision> {
79
+ const cfg = loadConfig();
80
+ const floor = cfg.threshold - cfg.policy.projectionMargin;
81
+ const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
82
+
83
+ let usage = loadUsage();
84
+ if (!usage && activeOrg) usage = await probeAggregate(activeOrg);
85
+
86
+ // per-model poll (TTL-cached) only when on a capacity-constrained model
87
+ const mu = needsPerModel(usage, cfg) ? await ensurePerModel(cfg, activeOrg) : null;
88
+
89
+ // cheap pre-check off the lock - the common case exits here.
90
+ if (!isOver(usage, mu, activeOrg, cfg, floor)) {
91
+ return { swapped: false, account: null, reason: "under-threshold-or-stale" };
92
+ }
93
+
94
+ return withLock(paths.lockFile, async () => {
95
+ const idx = loadAccounts();
96
+ const org2 = readOAuthAccount()?.organizationUuid ?? null;
97
+ const u2 = loadUsage() ?? usage;
98
+ const mu2 = needsPerModel(u2, cfg) ? loadModelUsage() ?? mu : null;
99
+
100
+ // record the active account's aggregate usage so the picker + `status` see it.
101
+ if (u2 && org2 && u2.org === org2) {
102
+ const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
103
+ if (active) {
104
+ active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
105
+ // Snapshot per-model caps too, so they still show after we switch away.
106
+ if (mu2 && mu2.org === org2) active.lastPerModel = mu2.perModel;
107
+ saveAccounts(idx);
108
+ }
109
+ }
110
+
111
+ if (!isOver(u2, mu2, org2, cfg, floor)) {
112
+ return { swapped: false, account: null, reason: "raced-already-swapped" };
113
+ }
114
+
115
+ const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
116
+ if (landed) return { swapped: true, account: landed, reason: "swapped" };
117
+
118
+ // Every account is depleted. Wait for whichever recovers soonest (including the
119
+ // current one), if that reset is within the auto-wait window.
120
+ const fresh = loadAccounts();
121
+ const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid };
122
+ const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
123
+ const currentAt = current ? usableAt(current, cfg.threshold, now) : Number.POSITIVE_INFINITY;
124
+ const other = pickEarliestReset(fresh.accounts, ctx);
125
+
126
+ let target: Account | null = null;
127
+ let waitUntil = Number.POSITIVE_INFINITY;
128
+ if (other && other.availableAt < currentAt) { target = other.account; waitUntil = other.availableAt; }
129
+ else if (current) { target = current; waitUntil = currentAt; }
130
+ else if (other) { target = other.account; waitUntil = other.availableAt; }
131
+
132
+ if (!target || waitUntil - now > cfg.policy.maxWaitMs) {
133
+ log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
134
+ return { swapped: false, account: null, reason: "all-depleted" };
135
+ }
136
+
137
+ const isCurrent = target.accountUuid === fresh.activeAccountUuid;
138
+ if (!isCurrent) {
139
+ try {
140
+ await performSwap(target);
141
+ } catch (e) {
142
+ if (e instanceof InvalidGrantError) return { swapped: false, account: null, reason: "all-depleted" };
143
+ throw e;
144
+ }
145
+ }
146
+ log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
147
+ return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
148
+ });
149
+ }
@@ -0,0 +1,19 @@
1
+ // Shared HTTP client. Sampling every account at once, or swapping right at a
2
+ // limit, can trip an endpoint's burst throttle, so GETs retry a bounded number
3
+ // of times, honoring the server's Retry-After (capped). throwHttpErrors is off
4
+ // so callers read the body and shape their own fail-fast error; retry still runs.
5
+
6
+ import ky from "ky";
7
+
8
+ export const http = ky.create({
9
+ timeout: 15_000,
10
+ throwHttpErrors: false,
11
+ retry: {
12
+ limit: 2,
13
+ methods: ["get"],
14
+ statusCodes: [429, 500, 502, 503, 504],
15
+ afterStatusCodes: [429, 503],
16
+ maxRetryAfter: 10_000,
17
+ backoffLimit: 3000,
18
+ },
19
+ });
@@ -0,0 +1,87 @@
1
+ // Install/uninstall the on-PATH `claude` supervisor wrapper + settings entries.
2
+ // The wrapper is a 2-line `exec … __supervise "$@"` shim so dispatch never
3
+ // depends on argv0 semantics.
4
+
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { z } from "zod";
8
+ import { HOME, paths } from "./paths.ts";
9
+ import { writeFileAtomic } from "./atomic.ts";
10
+ import { installedBin, installSettings, uninstallSettings } from "./settings.ts";
11
+ import { resolveRealClaude } from "./claudebin.ts";
12
+
13
+ const InstallOutcomeSchema = z.object({
14
+ claudeWrapper: z.string(),
15
+ installedBin: z.string(),
16
+ priorStatusLine: z.string().nullable(),
17
+ pathAhead: z.boolean(),
18
+ });
19
+ export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
20
+
21
+ /** True if our binDir comes before the real claude's dir on PATH. */
22
+ export function isBinDirAhead(): boolean {
23
+ const dirs = (process.env.PATH ?? "").split(":");
24
+ const ourIdx = dirs.indexOf(paths.binDir);
25
+ if (ourIdx < 0) return false;
26
+ try {
27
+ const realDir = dirname(resolveRealClaude());
28
+ const realIdx = dirs.indexOf(realDir);
29
+ return realIdx < 0 || ourIdx < realIdx;
30
+ } catch {
31
+ return ourIdx >= 0;
32
+ }
33
+ }
34
+
35
+ export function installSupervisor(): InstallOutcome {
36
+ mkdirSync(paths.binDir, { recursive: true });
37
+ const target = installedBin(); // binDir/tokenmaxxing
38
+ // Resolve the entry through the global-bin symlink (bun add -g links
39
+ // ~/.bun/bin/tokenmaxxing → the package's src/main.ts) so the shim points
40
+ // into the installed package tree, where its imports resolve.
41
+ const entry = realpathSync(Bun.main);
42
+ writeFileAtomic(target, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} run ${JSON.stringify(entry)} "$@"\n`, 0o755);
43
+
44
+ // the on-PATH `claude` wrapper
45
+ writeFileAtomic(paths.supervisorLink, `#!/bin/sh\nexec ${JSON.stringify(target)} __supervise "$@"\n`, 0o755);
46
+ // the `xx` short alias → tokenmaxxing
47
+ writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
48
+
49
+ const { priorStatusLine } = installSettings();
50
+ return {
51
+ claudeWrapper: paths.supervisorLink,
52
+ installedBin: target,
53
+ priorStatusLine,
54
+ pathAhead: isBinDirAhead(),
55
+ };
56
+ }
57
+
58
+ /** The rc file of the user's login shell, or null when the shell is unknown.
59
+ * Overridable for hermetic tests. */
60
+ export function shellRcPath(): string | null {
61
+ const override = process.env.TOKENMAXXING_SHELL_RC;
62
+ if (override && override.length > 0) return override;
63
+ const shell = basename(process.env.SHELL ?? "");
64
+ if (shell === "zsh") return join(process.env.ZDOTDIR || HOME, ".zshrc");
65
+ if (shell === "bash") return join(HOME, ".bashrc");
66
+ return null;
67
+ }
68
+
69
+ const PATH_LINE_MARK = "# tokenmaxxing PATH";
70
+
71
+ /** Idempotently append the supervisor-bin PATH line to `rc` (created if absent).
72
+ * A pre-existing hand-added line for the bin dir also counts as present. */
73
+ export function ensurePathInRc(rc: string): "added" | "present" {
74
+ const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
75
+ const current = existsSync(rc) ? readFileSync(rc, "utf8") : "";
76
+ if (current.includes(PATH_LINE_MARK) || current.includes(`${paths.binDir}:`) || current.includes(`${dir}:`)) return "present";
77
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
78
+ appendFileSync(rc, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
79
+ return "added";
80
+ }
81
+
82
+ export function uninstallSupervisor(): void {
83
+ uninstallSettings();
84
+ for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
85
+ if (existsSync(f)) rmSync(f, { force: true });
86
+ }
87
+ }
@@ -0,0 +1,84 @@
1
+ // macOS login-keychain generic-password I/O, ps-safe. The darwin backend of
2
+ // credstore.ts - construct targets there, not here.
3
+ // READ : `security find-generic-password -w` → secret only ever in stdout.
4
+ // WRITE : pipe an `add-generic-password -U … -w <secret>` line into `security -i`
5
+ // over STDIN - the secret never appears in any process's argv (verified).
6
+ // The service/account are non-secret and may sit in argv.
7
+
8
+ import { z } from "zod";
9
+
10
+ const KeychainTargetSchema = z.object({ service: z.string(), account: z.string() });
11
+ export type KeychainTarget = z.infer<typeof KeychainTargetSchema>;
12
+
13
+ const SECURITY = "/usr/bin/security";
14
+ // security(1) interactive mode has a ~4KB line buffer; above this we must use argv.
15
+ const INTERACTIVE_MAX = 3800;
16
+
17
+ /** Double-quote + backslash-escape for security(1)'s interactive tokenizer. */
18
+ function quoteDouble(s: string): string {
19
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
20
+ }
21
+
22
+ /** Wrap a value for the `-i` line. Single-quote when possible (blob has no `'`). */
23
+ function quoteValue(s: string): string {
24
+ return s.includes("'") ? quoteDouble(s) : `'${s}'`;
25
+ }
26
+
27
+ /** Read an item's password blob. Returns null if the item does not exist. */
28
+ export async function readItem(t: KeychainTarget): Promise<string | null> {
29
+ const p = Bun.spawn([SECURITY, "find-generic-password", "-s", t.service, "-a", t.account, "-w"], {
30
+ stdout: "pipe",
31
+ stderr: "ignore",
32
+ });
33
+ const out = await new Response(p.stdout).text();
34
+ await p.exited;
35
+ if (p.exitCode !== 0) return null; // 44 = not found
36
+ return out.replace(/\n$/, ""); // security appends exactly one trailing newline
37
+ }
38
+
39
+ /** ps-safe write: the command line + secret arrive on stdin, never in argv.
40
+ * Bounded by security(1)'s ~4KB interactive line buffer. */
41
+ async function writeViaInteractive(t: KeychainTarget, secret: string): Promise<void> {
42
+ const line =
43
+ `add-generic-password -U -a ${quoteDouble(t.account)} -s ${quoteDouble(t.service)} ` +
44
+ `-w ${quoteValue(secret)}\n`;
45
+ const p = Bun.spawn([SECURITY, "-i"], {
46
+ stdin: new TextEncoder().encode(line),
47
+ stdout: "ignore",
48
+ stderr: "pipe",
49
+ });
50
+ const err = await new Response(p.stderr).text();
51
+ await p.exited;
52
+ if (p.exitCode !== 0) throw new Error(`keychain write (interactive) failed (exit ${p.exitCode}): ${err.trim()}`);
53
+ }
54
+
55
+ /** Fallback for blobs over the interactive line limit. The secret is briefly
56
+ * visible in `ps` for this one short-lived process - used only when the payload
57
+ * (e.g. a live blob with lots of MCP OAuth state) exceeds ~4KB. */
58
+ async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
59
+ const p = Bun.spawn([SECURITY, "add-generic-password", "-U", "-a", t.account, "-s", t.service, "-w", secret], {
60
+ stdout: "ignore",
61
+ stderr: "pipe",
62
+ });
63
+ const err = await new Response(p.stderr).text();
64
+ await p.exited;
65
+ if (p.exitCode !== 0) throw new Error(`keychain write (argv) failed (exit ${p.exitCode}): ${err.trim()}`);
66
+ }
67
+
68
+ /** Create-or-update an item (`-U`) with `secret` as its password. Prefers the
69
+ * ps-safe stdin path; falls back to argv only when the blob exceeds the ~4KB
70
+ * interactive line limit. Throws on failure. */
71
+ export async function writeItem(t: KeychainTarget, secret: string): Promise<void> {
72
+ if (secret.length <= INTERACTIVE_MAX) return writeViaInteractive(t, secret);
73
+ return writeViaArgv(t, secret);
74
+ }
75
+
76
+ /** Delete an item. Returns true if it existed and was removed. */
77
+ export async function deleteItem(t: KeychainTarget): Promise<boolean> {
78
+ const p = Bun.spawn([SECURITY, "delete-generic-password", "-s", t.service, "-a", t.account], {
79
+ stdout: "ignore",
80
+ stderr: "ignore",
81
+ });
82
+ await p.exited;
83
+ return p.exitCode === 0;
84
+ }
@@ -0,0 +1,78 @@
1
+ // Cross-process advisory locking via flock(2) through bun:ffi on a real file
2
+ // descriptor (macOS ships no flock(1) binary, and one codepath serves both
3
+ // platforms). The lock is released when we close the fd (explicitly or on
4
+ // process exit).
5
+
6
+ import { closeSync, mkdirSync, openSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { dlopen, FFIType, suffix } from "bun:ffi";
9
+
10
+ // sys/file.h (identical on darwin + linux): LOCK_SH=1 LOCK_EX=2 LOCK_NB=4 LOCK_UN=8
11
+ const LOCK_EX = 2;
12
+ const LOCK_UN = 8;
13
+
14
+ let _flock: ((fd: number, op: number) => number) | null = null;
15
+
16
+ function flockFn(): (fd: number, op: number) => number {
17
+ if (_flock) return _flock;
18
+ // darwin: flock(2) is in libSystem; the bare name resolves via the dyld shared
19
+ // cache even though no physical .dylib exists on disk (verified 2026-07-08).
20
+ // linux: glibc's libc.so.6 (verified in-container 2026-07-09, arm64).
21
+ const candidates =
22
+ process.platform === "darwin"
23
+ ? ["libSystem.B.dylib", "/usr/lib/libSystem.B.dylib", `libc.${suffix}`]
24
+ : [`libc.so.6`, `libc.${suffix}`];
25
+ let lib: ReturnType<typeof dlopen> | null = null;
26
+ let lastErr: unknown;
27
+ for (const path of candidates) {
28
+ try {
29
+ lib = dlopen(path, {
30
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
31
+ });
32
+ break;
33
+ } catch (e) {
34
+ lastErr = e;
35
+ }
36
+ }
37
+ if (!lib) throw new Error(`could not load flock(2): ${String(lastErr)}`);
38
+ const sym = lib.symbols.flock as unknown as (fd: number, op: number) => number;
39
+ _flock = sym;
40
+ return _flock;
41
+ }
42
+
43
+ /**
44
+ * Acquire an exclusive advisory lock on `lockPath`, blocking until available.
45
+ * Returns a handle whose release() drops the lock. The lock is process-scoped
46
+ * (held via an open fd) so racing hooks in separate processes serialize.
47
+ */
48
+ export function acquireLock(lockPath: string): { release: () => void } {
49
+ mkdirSync(dirname(lockPath), { recursive: true });
50
+ const fd = openSync(lockPath, "a", 0o600);
51
+ const flock = flockFn();
52
+ const rc = flock(fd, LOCK_EX);
53
+ if (rc !== 0) {
54
+ closeSync(fd);
55
+ throw new Error(`flock LOCK_EX failed on ${lockPath} (rc=${rc})`);
56
+ }
57
+ let released = false;
58
+ const release = () => {
59
+ if (released) return;
60
+ released = true;
61
+ try {
62
+ flock(fd, LOCK_UN);
63
+ } finally {
64
+ closeSync(fd);
65
+ }
66
+ };
67
+ return { release };
68
+ }
69
+
70
+ /** Run `fn` while holding `lockPath`; always releases, even on throw. */
71
+ export async function withLock<T>(lockPath: string, fn: () => Promise<T> | T): Promise<T> {
72
+ const held = acquireLock(lockPath);
73
+ try {
74
+ return await fn();
75
+ } finally {
76
+ held.release();
77
+ }
78
+ }
package/src/lib/log.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Append-only logging. NEVER logs secret material - callers must pass only
2
+ // non-secret context (account uuids/emails, percentages, status strings).
3
+
4
+ import { appendFileSync, mkdirSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { z } from "zod";
7
+ import { paths } from "./paths.ts";
8
+
9
+ /** Redact anything that looks like a token so an accidental pass-through can't leak. */
10
+ export function redact(s: string): string {
11
+ return s
12
+ // JWT-ish / long opaque tokens
13
+ .replace(/\b(sk-ant-[A-Za-z0-9._-]{6,})/g, "sk-ant-***")
14
+ .replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}…(${m.length})`);
15
+ }
16
+
17
+ export function log(event: string, fields: Record<string, unknown> = {}): void {
18
+ try {
19
+ mkdirSync(dirname(paths.logFile), { recursive: true });
20
+ const parts = Object.entries(fields).map(([k, v]) => {
21
+ const str = z.string().safeParse(v);
22
+ return `${k}=${redact(str.success ? str.data : JSON.stringify(v))}`;
23
+ });
24
+ appendFileSync(paths.logFile, `${new Date().toISOString()} ${event} ${parts.join(" ")}\n`);
25
+ } catch {
26
+ // logging must never throw into a hook / supervisor path
27
+ }
28
+ }
@@ -0,0 +1,117 @@
1
+ // The two OAuth calls tokenmaxxing makes:
2
+ // 1. the refresh_token grant that turns a parked account's (possibly expired)
3
+ // access token into a fresh one before we install it. Verified against
4
+ // Claude Code 2.1.204: POST platform.claude.com, JSON body, public PKCE
5
+ // client (no secret), no auth/beta headers on this call.
6
+ // 2. the roles lookup that reports which org a token ACTUALLY belongs to.
7
+ // Endpoint string extracted from the Claude Code 2.1.205 binary; verified
8
+ // live to answer HTTP 200 with plain Bearer auth (with or without the
9
+ // oauth beta header - we send it to match the CLI's OAuth convention).
10
+
11
+ import { http } from "./http.ts";
12
+ import { RefreshResponseSchema, RolesResponseSchema, type OAuthCreds, type RolesResponse } from "./types.ts";
13
+
14
+ const TOKEN_URL = process.env.TOKENMAXXING_OAUTH_TOKEN_URL ?? "https://platform.claude.com/v1/oauth/token";
15
+ const CLIENT_ID = process.env.TOKENMAXXING_OAUTH_CLIENT_ID ?? "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
16
+ const ROLES_URL = process.env.TOKENMAXXING_OAUTH_ROLES_URL ?? "https://api.anthropic.com/api/oauth/claude_cli/roles";
17
+
18
+ const DEFAULT_SCOPES = [
19
+ "user:profile",
20
+ "user:inference",
21
+ "user:sessions:claude_code",
22
+ "user:mcp_servers",
23
+ "user:file_upload",
24
+ ];
25
+
26
+ /** Refresh token is dead / revoked - caller should mark needs_reauth and skip. */
27
+ export class InvalidGrantError extends Error {
28
+ constructor(public readonly detail: string) {
29
+ super(`invalid_grant: ${detail}`);
30
+ this.name = "InvalidGrantError";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Exchange `creds.refreshToken` for a fresh access token. Returns a NEW OAuthCreds
36
+ * with the fresh access token, the rotated refresh token (or the old one if the
37
+ * server omitted it), and recomputed absolute expiries. Non-token fields are
38
+ * preserved. Throws InvalidGrantError on a dead refresh token.
39
+ */
40
+ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
41
+ const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
42
+ const body = {
43
+ grant_type: "refresh_token",
44
+ refresh_token: creds.refreshToken,
45
+ client_id: CLIENT_ID,
46
+ scope,
47
+ };
48
+
49
+ let res: Response;
50
+ try {
51
+ res = await http.post(TOKEN_URL, {
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify(body),
54
+ });
55
+ } catch (e) {
56
+ throw new Error(`token endpoint unreachable: ${String((e as Error).message ?? e)}`);
57
+ }
58
+
59
+ const text = await res.text();
60
+ if (!res.ok) {
61
+ // invalid_grant → dead refresh token; anything else is transient/unknown.
62
+ if (res.status === 400 && /invalid_grant/.test(text)) {
63
+ throw new InvalidGrantError(text.slice(0, 200));
64
+ }
65
+ throw new Error(`token refresh failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
66
+ }
67
+
68
+ const parsed = RefreshResponseSchema.safeParse((() => {
69
+ try { return JSON.parse(text); } catch { return null; }
70
+ })());
71
+ if (!parsed.success) {
72
+ throw new Error(`token endpoint returned unexpected body: ${text.slice(0, 120)}`);
73
+ }
74
+ const json = parsed.data;
75
+
76
+ const expiresIn = json.expires_in ?? 8 * 3600;
77
+ const refreshExpiresIn = json.refresh_token_expires_in;
78
+
79
+ return {
80
+ ...creds,
81
+ accessToken: json.access_token,
82
+ refreshToken: json.refresh_token ?? creds.refreshToken, // server may omit → reuse
83
+ expiresAt: now + expiresIn * 1000,
84
+ refreshTokenExpiresAt:
85
+ refreshExpiresIn != null ? now + refreshExpiresIn * 1000 : creds.refreshTokenExpiresAt,
86
+ scopes: json.scope ? json.scope.split(" ").filter(Boolean) : creds.scopes,
87
+ };
88
+ }
89
+
90
+ /** True if the access token is within `skewMs` of expiry (or already expired). */
91
+ export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now = Date.now()): boolean {
92
+ return !creds.expiresAt || creds.expiresAt - now <= skewMs;
93
+ }
94
+
95
+ /**
96
+ * Ask the API which org `accessToken` ACTUALLY belongs to. Stored labels
97
+ * (accounts.json, oauthAccount) can drift from the credential they describe;
98
+ * the token itself cannot lie. Requires a non-expired access token. Read-only:
99
+ * never rotates anything.
100
+ */
101
+ export async function fetchTokenOrg(accessToken: string): Promise<RolesResponse> {
102
+ let res: Response;
103
+ try {
104
+ res = await http.get(ROLES_URL, {
105
+ headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
106
+ });
107
+ } catch (e) {
108
+ throw new Error(`roles endpoint unreachable: ${String((e as Error).message ?? e)}`);
109
+ }
110
+ const text = await res.text();
111
+ if (!res.ok) throw new Error(`roles check failed (HTTP ${res.status}): ${text.slice(0, 140)}`);
112
+ const parsed = RolesResponseSchema.safeParse((() => {
113
+ try { return JSON.parse(text); } catch { return null; }
114
+ })());
115
+ if (!parsed.success) throw new Error(`roles endpoint returned unexpected body: ${text.slice(0, 120)}`);
116
+ return parsed.data;
117
+ }
@@ -0,0 +1,90 @@
1
+ // Central path resolution. EVERY externally-observable path is overridable via an
2
+ // env var so the whole tool can run hermetically in tests without touching the
3
+ // user's real ~/.config, ~/.claude, or login keychain.
4
+
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ const HOME = homedir();
9
+
10
+ function env(name: string, fallback: string): string {
11
+ const v = process.env[name];
12
+ return v && v.length > 0 ? v : fallback;
13
+ }
14
+
15
+ /** Root of all tokenmaxxing config + state. Default ~/.config/tokenmaxxing. */
16
+ export const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
17
+
18
+ export const paths = {
19
+ home: TM_HOME,
20
+ configJson: join(TM_HOME, "config.json"),
21
+ accountsJson: join(TM_HOME, "accounts.json"),
22
+ usageJson: join(TM_HOME, "usage.json"),
23
+ modelUsageJson: join(TM_HOME, "model-usage.json"),
24
+ respawnDir: join(TM_HOME, "respawn"),
25
+ binDir: join(TM_HOME, "bin"),
26
+ supervisorLink: join(TM_HOME, "bin", "claude"),
27
+ lockFile: join(TM_HOME, "lock"),
28
+ logFile: join(TM_HOME, "tokenmaxxing.log"),
29
+ onboardDir: join(TM_HOME, "onboard"),
30
+ sampleDir: join(TM_HOME, "sample"),
31
+ /** linux only: parked credential .json files (0700 dir, 0600 files). */
32
+ credsDir: join(TM_HOME, "creds"),
33
+
34
+ /** ~/.claude.json - holds the active `oauthAccount` identity object. */
35
+ claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
36
+ /** ~/.claude/settings.json - user-owned; we merge three entries into it. */
37
+ claudeSettings: env(
38
+ "TOKENMAXXING_CLAUDE_SETTINGS",
39
+ join(env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")), "settings.json"),
40
+ ),
41
+ /** ~/.claude - for the credential-refresh lock and projects/ transcripts. */
42
+ claudeDir: env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")),
43
+ } as const;
44
+
45
+ /** Claude's own credential-refresh lock (verified path filled from facts). */
46
+ export function claudeLockPath(): string {
47
+ return env("TOKENMAXXING_CLAUDE_LOCK", join(HOME, ".claude.lock"));
48
+ }
49
+
50
+ /** The macOS login-keychain generic-password the live `claude` reads. */
51
+ export const keychain = {
52
+ service: env("TOKENMAXXING_KEYCHAIN_SERVICE", "Claude Code-credentials"),
53
+ account: env("TOKENMAXXING_KEYCHAIN_ACCOUNT", process.env.USER ?? "unknown"),
54
+ } as const;
55
+
56
+ /** Per-account parked credential item name: tokenmaxxing-cred-<accountUuid[:8]>. */
57
+ export function credItemFor(accountUuid: string): string {
58
+ return `tokenmaxxing-cred-${accountUuid.slice(0, 8)}`;
59
+ }
60
+
61
+ /**
62
+ * The dir whose `.credentials.json` is claude's live credential on linux -
63
+ * mirrors claude's own resolution (verified 2.1.205 `Wde()`): the
64
+ * CLAUDE_SECURESTORAGE_CONFIG_DIR override is checked FIRST when defined
65
+ * (defined-but-empty falls to ~/.claude, NFC-normalized), else the config dir.
66
+ */
67
+ export function credDir(): string {
68
+ const secure = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
69
+ if (secure !== undefined) return (secure || join(HOME, ".claude")).normalize("NFC");
70
+ return paths.claudeDir;
71
+ }
72
+
73
+ /**
74
+ * The keychain service claude uses when CLAUDE_CONFIG_DIR is set:
75
+ * `Claude Code-credentials-<first 8 hex of sha256(NFC(raw dir string))>`.
76
+ * Hash is over the RAW string, so the dir must be byte-stable.
77
+ */
78
+ export function namespacedCredService(configDirRaw: string): string {
79
+ const h = new Bun.CryptoHasher("sha256");
80
+ h.update(configDirRaw.normalize("NFC"));
81
+ return `Claude Code-credentials-${h.digest("hex").slice(0, 8)}`;
82
+ }
83
+
84
+ /** Resolve the REAL claude binary (never our shim). Order: explicit env, config, PATH scan. */
85
+ export function realClaudeBinFromEnv(): string | undefined {
86
+ const v = process.env.TOKENMAXXING_CLAUDE_BIN;
87
+ return v && v.length > 0 ? v : undefined;
88
+ }
89
+
90
+ export { HOME };