tokenmaxxing 0.1.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.
- package/DESIGN.md +125 -0
- package/README.md +88 -0
- package/package.json +28 -7
- package/src/cli/add.ts +129 -0
- package/src/cli/doctor.ts +80 -0
- package/src/cli/init.ts +124 -0
- package/src/cli/ls.ts +24 -0
- package/src/cli/rename.ts +33 -0
- package/src/cli/render.ts +36 -0
- package/src/cli/rm.ts +28 -0
- package/src/cli/status.ts +99 -0
- package/src/cli/switch.ts +61 -0
- package/src/entries/sessionstart.ts +39 -0
- package/src/entries/statusline.ts +52 -0
- package/src/entries/stophook.ts +48 -0
- package/src/entries/supervisor.ts +202 -0
- package/src/lib/atomic.ts +24 -0
- package/src/lib/claudebin.ts +21 -0
- package/src/lib/claudejson.ts +40 -0
- package/src/lib/claudelock.ts +54 -0
- package/src/lib/credstore.ts +97 -0
- package/src/lib/decide.ts +149 -0
- package/src/lib/http.ts +19 -0
- package/src/lib/install.ts +87 -0
- package/src/lib/keychain.ts +84 -0
- package/src/lib/lock.ts +78 -0
- package/src/lib/log.ts +28 -0
- package/src/lib/oauth.ts +117 -0
- package/src/lib/paths.ts +90 -0
- package/src/lib/picker.ts +63 -0
- package/src/lib/sample.ts +144 -0
- package/src/lib/sessions.ts +27 -0
- package/src/lib/settings.ts +141 -0
- package/src/lib/state.ts +125 -0
- package/src/lib/swap.ts +143 -0
- package/src/lib/tty.ts +14 -0
- package/src/lib/types.ts +152 -0
- package/src/lib/usage.ts +216 -0
- package/src/main.ts +82 -0
- package/dist/index.js +0 -38
package/src/lib/oauth.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -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 };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Choose the best account to switch TO when the active one crosses threshold.
|
|
2
|
+
// Policy: exclude the current account and any that need reauth or are still
|
|
3
|
+
// rate-limited (usage >= threshold and not yet past resets_at). Among the rest,
|
|
4
|
+
// prefer lowest 7-day usage; tiebreak on soonest resets_at.
|
|
5
|
+
|
|
6
|
+
import { minBy, sortBy } from "es-toolkit";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { AccountSchema, type Account } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const PickCtxSchema = z.object({
|
|
11
|
+
now: z.number(),
|
|
12
|
+
threshold: z.number(),
|
|
13
|
+
currentAccountUuid: z.string().nullable(),
|
|
14
|
+
});
|
|
15
|
+
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
16
|
+
|
|
17
|
+
/** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
|
|
18
|
+
export function isExhausted(a: Account, ctx: PickCtx): boolean {
|
|
19
|
+
const u = a.lastUsage;
|
|
20
|
+
if (!u) return false;
|
|
21
|
+
const blocked = (w: { usedPercentage: number; resetsAt: number | null }) =>
|
|
22
|
+
w.usedPercentage >= ctx.threshold && (w.resetsAt == null || w.resetsAt > ctx.now);
|
|
23
|
+
return blocked(u.fiveHour) || blocked(u.sevenDay);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
27
|
+
const candidates = accounts.filter(
|
|
28
|
+
(a) =>
|
|
29
|
+
a.accountUuid !== ctx.currentAccountUuid &&
|
|
30
|
+
!a.needsReauth &&
|
|
31
|
+
!isExhausted(a, ctx),
|
|
32
|
+
);
|
|
33
|
+
if (candidates.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
// lowest 7-day usage first; tiebreak on soonest 5h reset.
|
|
36
|
+
return sortBy(candidates, [
|
|
37
|
+
(a) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
38
|
+
(a) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
39
|
+
])[0]!;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** When an account becomes usable again: the latest reset among its over-threshold
|
|
43
|
+
* windows (all must reset), or `now` if nothing is over. */
|
|
44
|
+
export function usableAt(a: Account, threshold: number, now: number): number {
|
|
45
|
+
const u = a.lastUsage;
|
|
46
|
+
if (!u) return now;
|
|
47
|
+
const blocking = [u.fiveHour, u.sevenDay]
|
|
48
|
+
.filter((w) => w.usedPercentage >= threshold && w.resetsAt != null)
|
|
49
|
+
.map((w) => w.resetsAt as number);
|
|
50
|
+
return blocking.length ? Math.max(...blocking) : now;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
|
|
54
|
+
export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
55
|
+
|
|
56
|
+
/** For the all-depleted case: the account (not current, not reauth) that becomes
|
|
57
|
+
* usable soonest. */
|
|
58
|
+
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
59
|
+
const mapped = accounts
|
|
60
|
+
.filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
|
|
61
|
+
.map((a) => ({ account: a, availableAt: usableAt(a, ctx.threshold, ctx.now) }));
|
|
62
|
+
return minBy(mapped, (x) => x.availableAt) ?? null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Live-sample a PARKED account's `/usage` without disturbing the live login.
|
|
2
|
+
// `/usage` is free (0 tokens), so `status` can show every account's real usage.
|
|
3
|
+
// We install the account's parked credential into a throwaway CLAUDE_CONFIG_DIR
|
|
4
|
+
// (the isolated credential claude reads for that dir), run `claude -p /usage`
|
|
5
|
+
// against it, then tear the item down.
|
|
6
|
+
//
|
|
7
|
+
// Contamination guards (the incident that motivated these):
|
|
8
|
+
// 1. probeUsage scrubs every ambient credential-override env var, so an
|
|
9
|
+
// inherited CLAUDE_CODE_OAUTH_TOKEN can't hijack the isolated probe.
|
|
10
|
+
// 2. before trusting a backup we verify (roles endpoint) that its token really
|
|
11
|
+
// belongs to this account - a mislabeled backup (drifted harvest) surfaces
|
|
12
|
+
// as an explicit error, never as another account's bars.
|
|
13
|
+
//
|
|
14
|
+
// Refresh tokens rotate single-use, so the one hazard is a rotation we fail to
|
|
15
|
+
// capture. Two guards: refresh an expiring token OURSELVES up front, and
|
|
16
|
+
// capture-before-delete the isolated item in case claude rotated it anyway.
|
|
17
|
+
//
|
|
18
|
+
// The caller MUST hold the tokenmaxxing flock so a parked refresh cannot collide
|
|
19
|
+
// with an in-flight performSwap refreshing the same account.
|
|
20
|
+
|
|
21
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
25
|
+
import { credItemFor, paths } from "./paths.ts";
|
|
26
|
+
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
27
|
+
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
28
|
+
import { FullUsageSchema, probeUsage } from "./usage.ts";
|
|
29
|
+
import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
/** Result of a live sample: the fresh usage, or why it could not be taken. */
|
|
32
|
+
export const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
33
|
+
z.object({ ok: z.literal(true), usage: FullUsageSchema }),
|
|
34
|
+
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
35
|
+
]);
|
|
36
|
+
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
37
|
+
|
|
38
|
+
/** Verify `creds` belongs to `account`; null on match, else the mismatch reason. */
|
|
39
|
+
async function identityMismatch(creds: OAuthCreds, account: Account): Promise<string | null> {
|
|
40
|
+
let org: RolesResponse;
|
|
41
|
+
try {
|
|
42
|
+
org = await fetchTokenOrg(creds.accessToken);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return `credential identity check failed: ${String((e as Error).message ?? e)}`;
|
|
45
|
+
}
|
|
46
|
+
if (org.organization_uuid === account.organizationUuid) return null;
|
|
47
|
+
return `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
|
|
52
|
+
* mislabeled credential it sets `account.needsReauth` in place (the caller
|
|
53
|
+
* persists accounts.json). Mutates only the passed object and keychain items.
|
|
54
|
+
*/
|
|
55
|
+
export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
|
|
56
|
+
const backup = parkedTarget(account.keychainItem);
|
|
57
|
+
const parkedRaw = await readItem(backup);
|
|
58
|
+
if (!parkedRaw) return { ok: false, reason: "no parked credential - re-add with `tokenmaxxing add`" };
|
|
59
|
+
|
|
60
|
+
let creds: OAuthCreds;
|
|
61
|
+
try {
|
|
62
|
+
creds = CredentialBlobSchema.parse(JSON.parse(parkedRaw)).claudeAiOauth;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return { ok: false, reason: `parked credential unreadable (${String((e as Error).message ?? e).slice(0, 80)}) - re-add with \`tokenmaxxing add\`` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Hand claude a token with comfortable headroom so it won't run its own refresh
|
|
68
|
+
// (which claude does within 120s of expiry). Refresh + persist ourselves first.
|
|
69
|
+
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
70
|
+
try {
|
|
71
|
+
creds = await refreshCredential(creds);
|
|
72
|
+
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e instanceof InvalidGrantError) {
|
|
75
|
+
account.needsReauth = true;
|
|
76
|
+
return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing add`" };
|
|
77
|
+
}
|
|
78
|
+
return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const mismatch = await identityMismatch(creds, account);
|
|
83
|
+
if (mismatch) {
|
|
84
|
+
account.needsReauth = true;
|
|
85
|
+
return { ok: false, reason: `${mismatch} - this account's own credential is gone; re-auth with \`tokenmaxxing add\`` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const dir = join(paths.sampleDir, credItemFor(account.accountUuid));
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
const isoTarget = isolatedTarget(dir);
|
|
92
|
+
const installed = JSON.stringify({ claudeAiOauth: creds });
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await writeItem(isoTarget, installed);
|
|
96
|
+
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
97
|
+
const usage = await probeUsage(dir);
|
|
98
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
99
|
+
} finally {
|
|
100
|
+
// capture-before-delete: never discard a rotation claude may have performed.
|
|
101
|
+
const afterIso = await readItem(isoTarget);
|
|
102
|
+
if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
103
|
+
await deleteItem(isoTarget);
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Live-sample the ACTIVE account off the live login, verifying the live
|
|
110
|
+
* credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
|
|
111
|
+
* keychain item. A drifted active label surfaces as an error, not another
|
|
112
|
+
* account's bars.
|
|
113
|
+
*/
|
|
114
|
+
export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
|
|
115
|
+
const liveRaw = await readItem(liveTarget());
|
|
116
|
+
if (!liveRaw) return { ok: false, reason: "no live credential - run `claude` and `/login`" };
|
|
117
|
+
let creds: OAuthCreds;
|
|
118
|
+
try {
|
|
119
|
+
creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { ok: false, reason: `live credential blob unreadable (${String((e as Error).message ?? e).slice(0, 80)})` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A running claude keeps the live token fresh; after long idle it may not have.
|
|
125
|
+
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
126
|
+
try {
|
|
127
|
+
await withClaudeRefreshLock(async () => {
|
|
128
|
+
const raw2 = (await readItem(liveTarget())) ?? liveRaw;
|
|
129
|
+
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
130
|
+
creds = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
|
|
131
|
+
if (creds !== current) await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e instanceof InvalidGrantError) return { ok: false, reason: "live refresh token dead - run `claude` and `/login`" };
|
|
135
|
+
return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const mismatch = await identityMismatch(creds, account);
|
|
140
|
+
if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
|
|
141
|
+
|
|
142
|
+
const usage = await probeUsage();
|
|
143
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
144
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Persist the flags a managed session was launched with, so any later relaunch
|
|
2
|
+
// of that session id (a fresh supervisor invocation, or the depleted-pool
|
|
3
|
+
// recovery in #20) re-applies them instead of dropping --dangerously-skip-
|
|
4
|
+
// permissions / --model / etc.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { paths } from "./paths.ts";
|
|
10
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
11
|
+
|
|
12
|
+
const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
|
|
13
|
+
|
|
14
|
+
function sessionFile(sid: string): string {
|
|
15
|
+
return join(paths.home, "sessions", `${sid}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveSessionFlags(sid: string, flags: string[], cwd: string): void {
|
|
19
|
+
mkdirSync(join(paths.home, "sessions"), { recursive: true });
|
|
20
|
+
writeFileAtomic(sessionFile(sid), JSON.stringify({ flags, cwd }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadSessionFlags(sid: string): string[] | null {
|
|
24
|
+
const f = sessionFile(sid);
|
|
25
|
+
if (!existsSync(f)) return null;
|
|
26
|
+
return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
|
|
27
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Idempotent merge of tokenmaxxing's three entries into the user-owned
|
|
2
|
+
// ~/.claude/settings.json: a statusLine shim, a Stop hook, a SessionStart hook.
|
|
3
|
+
// We APPEND to existing hook arrays and WRAP the existing statusLine - never clobber.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { paths } from "./paths.ts";
|
|
9
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
10
|
+
|
|
11
|
+
/** Absolute path to the installed tokenmaxxing binary the settings entries call. */
|
|
12
|
+
export function installedBin(): string {
|
|
13
|
+
return join(paths.binDir, "tokenmaxxing");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HookCmdSchema = z.looseObject({ type: z.string(), command: z.string() });
|
|
17
|
+
const HookGroupSchema = z.looseObject({ matcher: z.string().optional(), hooks: z.array(HookCmdSchema).default([]) });
|
|
18
|
+
const StatusLineSchema = z.looseObject({ type: z.string(), command: z.string() });
|
|
19
|
+
const SettingsSchema = z.looseObject({
|
|
20
|
+
statusLine: StatusLineSchema.optional(),
|
|
21
|
+
hooks: z.record(z.string(), z.array(HookGroupSchema)).optional(),
|
|
22
|
+
});
|
|
23
|
+
type Settings = z.infer<typeof SettingsSchema>;
|
|
24
|
+
type HookGroup = z.infer<typeof HookGroupSchema>;
|
|
25
|
+
|
|
26
|
+
const PRIOR_STATUSLINE_FILE = join(paths.home, "prior-statusline.json");
|
|
27
|
+
|
|
28
|
+
const SUBCMD = {
|
|
29
|
+
statusline: "__statusline",
|
|
30
|
+
stop: "__stop-hook",
|
|
31
|
+
sessionStart: "__session-start",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
function readSettings(): Settings {
|
|
35
|
+
if (!existsSync(paths.claudeSettings)) return {};
|
|
36
|
+
return SettingsSchema.parse(JSON.parse(readFileSync(paths.claudeSettings, "utf8")));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeSettings(s: Settings): void {
|
|
40
|
+
writeFileAtomic(paths.claudeSettings, JSON.stringify(s, null, 2) + "\n", 0o644);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True if a hook/statusline command string is one tokenmaxxing installed. */
|
|
44
|
+
export function isOurCommand(cmd: string | undefined): boolean {
|
|
45
|
+
if (!cmd) return false;
|
|
46
|
+
return (
|
|
47
|
+
cmd.includes(SUBCMD.statusline) ||
|
|
48
|
+
cmd.includes(SUBCMD.stop) ||
|
|
49
|
+
cmd.includes(SUBCMD.sessionStart) ||
|
|
50
|
+
// also match the installed bin path even if the subcommand text changes
|
|
51
|
+
cmd.includes(installedBin())
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ourHookGroup(sub: string): HookGroup {
|
|
56
|
+
return { hooks: [{ type: "command", command: `${JSON.stringify(installedBin())} ${sub}` }] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appendHook(s: Settings, event: string, sub: string): void {
|
|
60
|
+
s.hooks ??= {};
|
|
61
|
+
s.hooks[event] ??= [];
|
|
62
|
+
const arr = s.hooks[event]!;
|
|
63
|
+
const present = arr.some((g) => g.hooks?.some((h) => h.command?.includes(sub)));
|
|
64
|
+
if (!present) arr.push(ourHookGroup(sub));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function removeHook(s: Settings, event: string, sub: string): void {
|
|
68
|
+
const arr = s.hooks?.[event];
|
|
69
|
+
if (!arr) return;
|
|
70
|
+
s.hooks![event] = arr.filter((g) => !g.hooks?.some((h) => h.command?.includes(sub)));
|
|
71
|
+
if (s.hooks![event]!.length === 0) delete s.hooks![event];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const InstallResultSchema = z.object({ priorStatusLine: z.string().nullable() });
|
|
75
|
+
export type InstallResult = z.infer<typeof InstallResultSchema>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Install the three entries. Returns the prior statusLine command that was
|
|
79
|
+
* wrapped (stored to disk so the shim can chain to it and uninstall can restore).
|
|
80
|
+
*/
|
|
81
|
+
export function installSettings(): InstallResult {
|
|
82
|
+
const s = readSettings();
|
|
83
|
+
|
|
84
|
+
// ---- statusLine: capture prior (unless it's already ours), then wrap.
|
|
85
|
+
let prior: string | null;
|
|
86
|
+
if (s.statusLine && !isOurCommand(s.statusLine.command)) {
|
|
87
|
+
prior = s.statusLine.command;
|
|
88
|
+
writeFileAtomic(PRIOR_STATUSLINE_FILE, JSON.stringify({ command: prior }) + "\n", 0o644);
|
|
89
|
+
} else {
|
|
90
|
+
prior = readPriorStatusLine();
|
|
91
|
+
}
|
|
92
|
+
s.statusLine = {
|
|
93
|
+
type: "command",
|
|
94
|
+
command: `${JSON.stringify(installedBin())} ${SUBCMD.statusline}`,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ---- hooks: append ours if absent.
|
|
98
|
+
appendHook(s, "Stop", SUBCMD.stop);
|
|
99
|
+
appendHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
100
|
+
|
|
101
|
+
writeSettings(s);
|
|
102
|
+
return { priorStatusLine: prior };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Remove our three entries and restore the prior statusLine if we have it. */
|
|
106
|
+
export function uninstallSettings(): void {
|
|
107
|
+
const s = readSettings();
|
|
108
|
+
removeHook(s, "Stop", SUBCMD.stop);
|
|
109
|
+
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
110
|
+
if (s.statusLine && isOurCommand(s.statusLine.command)) {
|
|
111
|
+
const prior = readPriorStatusLine();
|
|
112
|
+
if (prior) s.statusLine = { type: "command", command: prior };
|
|
113
|
+
else delete s.statusLine;
|
|
114
|
+
}
|
|
115
|
+
writeSettings(s);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PriorStatusLineSchema = z.object({ command: z.string() });
|
|
119
|
+
|
|
120
|
+
export function readPriorStatusLine(): string | null {
|
|
121
|
+
if (!existsSync(PRIOR_STATUSLINE_FILE)) return null;
|
|
122
|
+
return PriorStatusLineSchema.parse(JSON.parse(readFileSync(PRIOR_STATUSLINE_FILE, "utf8"))).command;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SettingsCheckSchema = z.object({
|
|
126
|
+
statusLineOk: z.boolean(),
|
|
127
|
+
stopOk: z.boolean(),
|
|
128
|
+
sessionStartOk: z.boolean(),
|
|
129
|
+
});
|
|
130
|
+
export type SettingsCheck = z.infer<typeof SettingsCheckSchema>;
|
|
131
|
+
|
|
132
|
+
export function checkSettings(): SettingsCheck {
|
|
133
|
+
const s = readSettings();
|
|
134
|
+
const has = (event: string, sub: string) =>
|
|
135
|
+
!!s.hooks?.[event]?.some((g) => g.hooks?.some((h) => h.command?.includes(sub)));
|
|
136
|
+
return {
|
|
137
|
+
statusLineOk: isOurCommand(s.statusLine?.command),
|
|
138
|
+
stopOk: has("Stop", SUBCMD.stop),
|
|
139
|
+
sessionStartOk: has("SessionStart", SUBCMD.sessionStart),
|
|
140
|
+
};
|
|
141
|
+
}
|