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.
- package/DESIGN.md +2 -2
- package/README.md +4 -6
- package/package.json +5 -10
- 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/tokenmaxxing +0 -0
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Shared data model - Zod schemas are the single source of truth; TS types are
|
|
2
|
+
// inferred from them. Everything that crosses an external boundary (keychain
|
|
3
|
+
// blob, ~/.claude.json, hook/statusLine stdin, OAuth response, our own state
|
|
4
|
+
// files) is validated through these instead of hand-checked.
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/** OAuth object inside the keychain blob (`claudeAiOauth`). Loose: preserve any
|
|
9
|
+
* extra fields claude may add so a harvest→install round-trip is lossless. */
|
|
10
|
+
export const OAuthCredsSchema = z.looseObject({
|
|
11
|
+
accessToken: z.string(),
|
|
12
|
+
refreshToken: z.string(),
|
|
13
|
+
expiresAt: z.number(),
|
|
14
|
+
refreshTokenExpiresAt: z.number().optional(),
|
|
15
|
+
scopes: z.array(z.string()).default([]),
|
|
16
|
+
subscriptionType: z.string().optional(),
|
|
17
|
+
rateLimitTier: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
export type OAuthCreds = z.infer<typeof OAuthCredsSchema>;
|
|
20
|
+
|
|
21
|
+
/** The `Claude Code-credentials` keychain item. Loose: the live item also holds
|
|
22
|
+
* sibling state (e.g. per-MCP-server OAuth tokens), which we must preserve when
|
|
23
|
+
* swapping - we only ever replace `claudeAiOauth`. */
|
|
24
|
+
export const CredentialBlobSchema = z.looseObject({ claudeAiOauth: OAuthCredsSchema });
|
|
25
|
+
export type CredentialBlob = z.infer<typeof CredentialBlobSchema>;
|
|
26
|
+
|
|
27
|
+
/** The `oauthAccount` identity object in ~/.claude.json. Loose: preserve every
|
|
28
|
+
* key so we can reinstall it verbatim on activation. Only the three ids are
|
|
29
|
+
* required; real blobs carry many more fields (some null), so descriptive ones
|
|
30
|
+
* are `.nullish()` (string | null | undefined) to tolerate them. */
|
|
31
|
+
export const OAuthAccountSchema = z.looseObject({
|
|
32
|
+
accountUuid: z.string(),
|
|
33
|
+
emailAddress: z.string(),
|
|
34
|
+
organizationUuid: z.string(),
|
|
35
|
+
organizationName: z.string().nullish(),
|
|
36
|
+
seatTier: z.string().nullish(),
|
|
37
|
+
billingType: z.string().nullish(),
|
|
38
|
+
displayName: z.string().nullish(),
|
|
39
|
+
});
|
|
40
|
+
export type OAuthAccount = z.infer<typeof OAuthAccountSchema>;
|
|
41
|
+
|
|
42
|
+
export const UsageWindowSchema = z.object({
|
|
43
|
+
usedPercentage: z.number(),
|
|
44
|
+
resetsAt: z.number().nullable(),
|
|
45
|
+
});
|
|
46
|
+
export type UsageWindow = z.infer<typeof UsageWindowSchema>;
|
|
47
|
+
|
|
48
|
+
export const UsageWindowsSchema = z.object({
|
|
49
|
+
fiveHour: UsageWindowSchema,
|
|
50
|
+
sevenDay: UsageWindowSchema,
|
|
51
|
+
});
|
|
52
|
+
export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
|
|
53
|
+
|
|
54
|
+
export const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
|
|
55
|
+
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
|
56
|
+
|
|
57
|
+
/** usage.json - written by the statusLine shim, read by the Stop hook. Carries
|
|
58
|
+
* the two AGGREGATE windows (session=fiveHour, week-all-models=sevenDay) plus
|
|
59
|
+
* the active model. Per-model caps live in ModelUsageState (from `/usage`). */
|
|
60
|
+
export const UsageStateSchema = UsageWindowsSchema.extend({
|
|
61
|
+
org: z.string().nullable(),
|
|
62
|
+
ts: z.number(),
|
|
63
|
+
model: ModelInfoSchema.nullable().default(null),
|
|
64
|
+
});
|
|
65
|
+
export type UsageState = z.infer<typeof UsageStateSchema>;
|
|
66
|
+
|
|
67
|
+
/** model-usage.json - per-model weekly caps parsed from `claude -p '/usage'`,
|
|
68
|
+
* TTL-cached so we don't poll every turn. Keyed by model display name ("Fable"). */
|
|
69
|
+
export const ModelUsageStateSchema = z.object({
|
|
70
|
+
perModel: z.record(z.string(), UsageWindowSchema).default({}),
|
|
71
|
+
org: z.string().nullable(),
|
|
72
|
+
ts: z.number(),
|
|
73
|
+
});
|
|
74
|
+
export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
|
|
75
|
+
|
|
76
|
+
/** A parked account in the pool (accounts.json - NON-secret). */
|
|
77
|
+
export const AccountSchema = z.object({
|
|
78
|
+
accountUuid: z.string(),
|
|
79
|
+
email: z.string(),
|
|
80
|
+
organizationUuid: z.string(),
|
|
81
|
+
label: z.string(),
|
|
82
|
+
keychainItem: z.string(),
|
|
83
|
+
oauthAccount: OAuthAccountSchema,
|
|
84
|
+
addedAt: z.string(),
|
|
85
|
+
lastUsage: UsageWindowsSchema.optional(),
|
|
86
|
+
lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
|
|
87
|
+
needsReauth: z.boolean().optional(),
|
|
88
|
+
subscriptionType: z.string().optional(),
|
|
89
|
+
});
|
|
90
|
+
export type Account = z.infer<typeof AccountSchema>;
|
|
91
|
+
|
|
92
|
+
export const AccountsIndexSchema = z.object({
|
|
93
|
+
version: z.literal(1),
|
|
94
|
+
activeAccountUuid: z.string().nullable(),
|
|
95
|
+
accounts: z.array(AccountSchema).default([]),
|
|
96
|
+
});
|
|
97
|
+
export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
98
|
+
|
|
99
|
+
export const ConfigSchema = z.object({
|
|
100
|
+
threshold: z.number(),
|
|
101
|
+
claudeBin: z.string(),
|
|
102
|
+
policy: z.object({
|
|
103
|
+
projectionMargin: z.number(),
|
|
104
|
+
/** models whose PER-MODEL weekly cap should trigger a switch (display names, lowercased). */
|
|
105
|
+
switchModels: z.array(z.string()),
|
|
106
|
+
/** how long a `/usage` per-model poll stays fresh before we re-poll (ms). */
|
|
107
|
+
usagePollTtlMs: z.number(),
|
|
108
|
+
/** when every account is depleted, auto-wait for a reset only if it is within this window (ms). */
|
|
109
|
+
maxWaitMs: z.number(),
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
113
|
+
|
|
114
|
+
/** The hook -> supervisor respawn marker at respawn/<session-id>. */
|
|
115
|
+
export const RespawnMarkerSchema = z.object({
|
|
116
|
+
account: z.string(),
|
|
117
|
+
ts: z.number(),
|
|
118
|
+
/** when set, the supervisor waits until this epoch ms before relaunching. */
|
|
119
|
+
waitUntil: z.number().optional(),
|
|
120
|
+
});
|
|
121
|
+
export type RespawnMarker = z.infer<typeof RespawnMarkerSchema>;
|
|
122
|
+
|
|
123
|
+
/** rate_limits + model as they appear in statusLine stdin (epoch-seconds resets). */
|
|
124
|
+
export const RateLimitsStdinSchema = z.looseObject({
|
|
125
|
+
rate_limits: z
|
|
126
|
+
.looseObject({
|
|
127
|
+
five_hour: z.looseObject({ used_percentage: z.number(), resets_at: z.number().nullable().optional() }).optional(),
|
|
128
|
+
seven_day: z.looseObject({ used_percentage: z.number(), resets_at: z.number().nullable().optional() }).optional(),
|
|
129
|
+
})
|
|
130
|
+
.optional(),
|
|
131
|
+
model: z.looseObject({ id: z.string().optional(), display_name: z.string().optional() }).optional(),
|
|
132
|
+
organizationUuid: z.string().optional(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** Success body of the OAuth refresh grant. */
|
|
136
|
+
export const RefreshResponseSchema = z.looseObject({
|
|
137
|
+
access_token: z.string(),
|
|
138
|
+
refresh_token: z.string().optional(),
|
|
139
|
+
expires_in: z.number().optional(),
|
|
140
|
+
refresh_token_expires_in: z.number().optional(),
|
|
141
|
+
scope: z.string().optional(),
|
|
142
|
+
token_type: z.string().optional(),
|
|
143
|
+
});
|
|
144
|
+
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
|
|
145
|
+
|
|
146
|
+
/** Success body of GET /api/oauth/claude_cli/roles - the org a token ACTUALLY
|
|
147
|
+
* belongs to, independent of any stored label. */
|
|
148
|
+
export const RolesResponseSchema = z.looseObject({
|
|
149
|
+
organization_uuid: z.string(),
|
|
150
|
+
organization_name: z.string(),
|
|
151
|
+
});
|
|
152
|
+
export type RolesResponse = z.infer<typeof RolesResponseSchema>;
|
package/src/lib/usage.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Usage data from two sources: statusLine stdin (pushed every turn, aggregate
|
|
2
|
+
// windows only, epoch resets) and `claude -p '/usage'` (free, 0 tokens, all
|
|
3
|
+
// three limit kinds). `/usage` is a client-side command that renders the same
|
|
4
|
+
// figures claude's own usage screen shows; we run it in a throwaway
|
|
5
|
+
// CLAUDE_CONFIG_DIR to sample a parked account without disturbing the live login.
|
|
6
|
+
|
|
7
|
+
import { delay } from "es-toolkit";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { resolveRealClaude } from "./claudebin.ts";
|
|
10
|
+
import { log } from "./log.ts";
|
|
11
|
+
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
|
|
14
|
+
export function normalizeResetsAt(v: unknown): number | null {
|
|
15
|
+
const num = z.number().finite().safeParse(v);
|
|
16
|
+
if (num.success) {
|
|
17
|
+
return num.data < 1e12 ? Math.round(num.data * 1000) : Math.round(num.data);
|
|
18
|
+
}
|
|
19
|
+
const str = z.string().safeParse(v);
|
|
20
|
+
if (str.success && str.data.trim() !== "") {
|
|
21
|
+
const n = Number(str.data);
|
|
22
|
+
if (Number.isFinite(n)) return normalizeResetsAt(n);
|
|
23
|
+
const t = Date.parse(str.data);
|
|
24
|
+
return Number.isFinite(t) ? t : null;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const win = (w?: { used_percentage: number; resets_at?: number | null }): UsageWindow => ({
|
|
30
|
+
usedPercentage: w?.used_percentage ?? 0,
|
|
31
|
+
resetsAt: normalizeResetsAt(w?.resets_at),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Extract the two AGGREGATE windows from statusLine stdin. null if absent. */
|
|
35
|
+
export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
|
|
36
|
+
const parsed = RateLimitsStdinSchema.safeParse(obj);
|
|
37
|
+
if (!parsed.success) return null;
|
|
38
|
+
const rl = parsed.data.rate_limits;
|
|
39
|
+
if (!rl || (!rl.five_hour && !rl.seven_day)) return null;
|
|
40
|
+
return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Extract the active model from statusLine stdin. null if absent. */
|
|
44
|
+
export function parseStatusLineModel(obj: unknown): ModelInfo | null {
|
|
45
|
+
const parsed = RateLimitsStdinSchema.safeParse(obj);
|
|
46
|
+
if (!parsed.success) return null;
|
|
47
|
+
const m = parsed.data.model;
|
|
48
|
+
if (!m?.id && !m?.display_name) return null;
|
|
49
|
+
return { id: m?.id ?? m?.display_name ?? "", display: m?.display_name ?? m?.id ?? "" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const FullUsageSchema = z.object({
|
|
53
|
+
session: UsageWindowSchema,
|
|
54
|
+
weekAll: UsageWindowSchema,
|
|
55
|
+
perModel: z.record(z.string(), UsageWindowSchema),
|
|
56
|
+
});
|
|
57
|
+
export type FullUsage = z.infer<typeof FullUsageSchema>;
|
|
58
|
+
|
|
59
|
+
// ---- `/usage` text parsing -----------------------------------------------
|
|
60
|
+
|
|
61
|
+
const MONTHS: Record<string, number> = {
|
|
62
|
+
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The tz offset (wall-clock minus UTC, in ms) in `tz` at instant `utcMs`. */
|
|
66
|
+
function tzOffsetMs(utcMs: number, tz: string): number {
|
|
67
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
68
|
+
timeZone: tz,
|
|
69
|
+
hourCycle: "h23",
|
|
70
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
71
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
72
|
+
}).formatToParts(new Date(utcMs));
|
|
73
|
+
const p: Record<string, number> = {};
|
|
74
|
+
for (const part of parts) if (part.type !== "literal") p[part.type] = Number(part.value);
|
|
75
|
+
const asUTC = Date.UTC(p.year!, p.month! - 1, p.day!, p.hour!, p.minute!, p.second!);
|
|
76
|
+
return asUTC - utcMs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Interpret a wall-clock time in `tz` as an epoch (DST-correct via one refine). */
|
|
80
|
+
function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min: number, tz: string): number {
|
|
81
|
+
const guess = Date.UTC(y, mon, day, hour, min);
|
|
82
|
+
const off1 = tzOffsetMs(guess, tz);
|
|
83
|
+
const off2 = tzOffsetMs(guess - off1, tz);
|
|
84
|
+
return guess - off2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a `/usage` reset clock like `Jul 11 at 12pm (Asia/Seoul)` or
|
|
89
|
+
* `Jul 9 at 11:20pm (Asia/Seoul)` to epoch ms. The text carries no year, so we
|
|
90
|
+
* pick the year whose resulting instant is nearest `now` (resets are always days
|
|
91
|
+
* away, so the correct year wins by ~360 days). Returns null if unparseable.
|
|
92
|
+
*/
|
|
93
|
+
export function parseResetClock(clock: string, now = Date.now()): number | null {
|
|
94
|
+
const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
|
|
95
|
+
if (!m) return null;
|
|
96
|
+
const mon = MONTHS[m[1]!.slice(0, 3).toLowerCase()];
|
|
97
|
+
if (mon === undefined) return null;
|
|
98
|
+
const day = Number(m[2]);
|
|
99
|
+
let hour = Number(m[3]) % 12;
|
|
100
|
+
if (m[5]!.toLowerCase() === "p") hour += 12;
|
|
101
|
+
const min = m[4] ? Number(m[4]) : 0;
|
|
102
|
+
const tz = m[6]!.trim();
|
|
103
|
+
|
|
104
|
+
const baseYear = new Date(now).getUTCFullYear();
|
|
105
|
+
let best: number | null = null;
|
|
106
|
+
for (const y of [baseYear - 1, baseYear, baseYear + 1]) {
|
|
107
|
+
let epoch: number;
|
|
108
|
+
try {
|
|
109
|
+
epoch = zonedWallToEpoch(y, mon, day, hour, min, tz);
|
|
110
|
+
} catch {
|
|
111
|
+
return null; // invalid tz
|
|
112
|
+
}
|
|
113
|
+
if (best === null || Math.abs(epoch - now) < Math.abs(best - now)) best = epoch;
|
|
114
|
+
}
|
|
115
|
+
return best;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse `claude -p '/usage'` .result text into all three limit kinds:
|
|
120
|
+
* Current session: N% used · resets <clock> → session (5h)
|
|
121
|
+
* Current week (all models): N% used · resets … → weekAll (7d aggregate)
|
|
122
|
+
* Current week (<Model>): N% used · resets … → perModel[<Model>]
|
|
123
|
+
*/
|
|
124
|
+
export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
|
|
125
|
+
if (!text) return null;
|
|
126
|
+
// The reset clock is required in full (month day at h[:mm]am/pm (tz)) inside its
|
|
127
|
+
// optional group, so the lazy bridge is forced to find it when present yet the
|
|
128
|
+
// group cleanly skips a line that has no clock - and it never swallows a
|
|
129
|
+
// following "Current …" entry on a single line.
|
|
130
|
+
const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:[^\n]*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}\s+at\s+\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
|
|
131
|
+
let session: UsageWindow | null = null;
|
|
132
|
+
let weekAll: UsageWindow | null = null;
|
|
133
|
+
const perModel: Record<string, UsageWindow> = {};
|
|
134
|
+
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
|
|
135
|
+
const window: UsageWindow = { usedPercentage: Number(m[3]), resetsAt: m[4] ? parseResetClock(m[4], now) : null };
|
|
136
|
+
if (m[1]!.toLowerCase() === "session") session = window;
|
|
137
|
+
else if (/^all models$/i.test(m[2]!.trim())) weekAll = window;
|
|
138
|
+
else perModel[m[2]!.trim()] = window;
|
|
139
|
+
}
|
|
140
|
+
if (!session && !weekAll && Object.keys(perModel).length === 0) return null;
|
|
141
|
+
return {
|
|
142
|
+
session: session ?? { usedPercentage: 0, resetsAt: null },
|
|
143
|
+
weekAll: weekAll ?? { usedPercentage: 0, resetsAt: null },
|
|
144
|
+
perModel,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Aggregate windows only (session→5h, week-all→7d), for the cold-start fallback. */
|
|
149
|
+
export function parseUsageText(text: string, now = Date.now()): UsageWindows | null {
|
|
150
|
+
const f = parseUsageTextFull(text, now);
|
|
151
|
+
if (!f) return null;
|
|
152
|
+
return { fiveHour: f.session, sevenDay: f.weekAll };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Env-var identity/credential overrides the claude binary honors BEFORE its
|
|
156
|
+
* keychain lookup (verified 2.1.205). A probe MUST scrub every one of these or
|
|
157
|
+
* an ambient value silently meters the wrong account. */
|
|
158
|
+
const CRED_ENV_OVERRIDES = [
|
|
159
|
+
"ANTHROPIC_API_KEY",
|
|
160
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
161
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
162
|
+
"CLAUDE_CODE_SUBSCRIPTION_TYPE",
|
|
163
|
+
"CLAUDE_CODE_RATE_LIMIT_TIER",
|
|
164
|
+
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
|
|
165
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
166
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
167
|
+
] as const;
|
|
168
|
+
|
|
169
|
+
/** One `claude -p '/usage'` invocation → parsed usage, or null if it produced no
|
|
170
|
+
* limit lines (claude prints only a local-stats footer when its own usage fetch
|
|
171
|
+
* errors/throttles) or failed to run. */
|
|
172
|
+
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
173
|
+
let out: string;
|
|
174
|
+
try {
|
|
175
|
+
const p = Bun.spawn([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], {
|
|
176
|
+
env,
|
|
177
|
+
stdout: "pipe",
|
|
178
|
+
stderr: "pipe",
|
|
179
|
+
});
|
|
180
|
+
out = await new Response(p.stdout).text();
|
|
181
|
+
const errText = await new Response(p.stderr).text();
|
|
182
|
+
await p.exited;
|
|
183
|
+
if (p.exitCode !== 0) {
|
|
184
|
+
log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
log("usage.probe_failed", { err: String((e as Error).message ?? e) });
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const j = z.object({ result: z.string() }).safeParse((() => { try { return JSON.parse(out); } catch { return null; } })());
|
|
193
|
+
const full = parseUsageTextFull(j.success ? j.data.result : out, now);
|
|
194
|
+
if (!full) log("usage.probe_unparsed", { sample: out.trim().slice(0, 120) });
|
|
195
|
+
return full;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Run `claude -p '/usage'` (free, 0 tokens) and parse all three limit kinds.
|
|
200
|
+
* Pass `configDir` to sample a specific account (its CLAUDE_CONFIG_DIR); omit to
|
|
201
|
+
* sample the live account. All ambient credential overrides are scrubbed so the
|
|
202
|
+
* probe meters exactly the OAuth credential in the (possibly namespaced)
|
|
203
|
+
* keychain item. The empty-footer case (claude's own usage call throttled) is
|
|
204
|
+
* transient, so retry it a couple of times. Returns null if it never yields data.
|
|
205
|
+
*/
|
|
206
|
+
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
207
|
+
const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1" };
|
|
208
|
+
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
209
|
+
if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
|
|
210
|
+
|
|
211
|
+
for (let attempt = 0; ; attempt++) {
|
|
212
|
+
const full = await probeUsageOnce(env, now);
|
|
213
|
+
if (full || attempt >= 2) return full;
|
|
214
|
+
await delay(1500);
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Single multi-call binary. Behaves as the `claude` supervisor when invoked as
|
|
3
|
+
// `claude` (or `__supervise`), routes hook/statusLine subcommands, and otherwise
|
|
4
|
+
// dispatches the `tokenmaxxing` CLI.
|
|
5
|
+
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { runSupervisor } from "./entries/supervisor.ts";
|
|
8
|
+
import { runStatusline } from "./entries/statusline.ts";
|
|
9
|
+
import { runStopHook } from "./entries/stophook.ts";
|
|
10
|
+
import { runSessionStart } from "./entries/sessionstart.ts";
|
|
11
|
+
import { cmdInit } from "./cli/init.ts";
|
|
12
|
+
import { cmdAdd } from "./cli/add.ts";
|
|
13
|
+
import { cmdLs } from "./cli/ls.ts";
|
|
14
|
+
import { cmdStatus } from "./cli/status.ts";
|
|
15
|
+
import { cmdDoctor } from "./cli/doctor.ts";
|
|
16
|
+
import { cmdRm } from "./cli/rm.ts";
|
|
17
|
+
import { cmdRename } from "./cli/rename.ts";
|
|
18
|
+
import { cmdSwitch } from "./cli/switch.ts";
|
|
19
|
+
import { uninstallSupervisor } from "./lib/install.ts";
|
|
20
|
+
import { c } from "./cli/render.ts";
|
|
21
|
+
|
|
22
|
+
function printHelp(): void {
|
|
23
|
+
console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
|
|
24
|
+
|
|
25
|
+
${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
|
|
26
|
+
${c.cyan("tokenmaxxing switch")} [sel] switch now to the best (or a specific) account
|
|
27
|
+
${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
|
|
28
|
+
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
29
|
+
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
30
|
+
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
31
|
+
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
32
|
+
${c.cyan("tokenmaxxing rename")} <sel> <label>
|
|
33
|
+
${c.cyan("tokenmaxxing rm")} <sel>
|
|
34
|
+
${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
|
|
35
|
+
|
|
36
|
+
${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main(): Promise<number> {
|
|
40
|
+
if (process.platform !== "darwin" && process.platform !== "linux") {
|
|
41
|
+
console.error(`tokenmaxxing supports macOS and Linux only (this is ${process.platform})`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
const args = process.argv.slice(2);
|
|
45
|
+
const argv0 = basename(process.argv0 || process.argv[0] || "");
|
|
46
|
+
const sub = args[0];
|
|
47
|
+
|
|
48
|
+
// supervisor mode: invoked as `claude`, or explicit `__supervise`
|
|
49
|
+
if (argv0 === "claude" || sub === "__supervise") {
|
|
50
|
+
return runSupervisor(sub === "__supervise" ? args.slice(1) : args);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
switch (sub) {
|
|
54
|
+
case "__statusline": return runStatusline();
|
|
55
|
+
case "__stop-hook": return runStopHook();
|
|
56
|
+
case "__session-start": return runSessionStart();
|
|
57
|
+
case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
|
|
58
|
+
case "switch": return cmdSwitch(args[1]);
|
|
59
|
+
case "init": return cmdInit();
|
|
60
|
+
case "add": return cmdAdd();
|
|
61
|
+
case "ls": return cmdLs();
|
|
62
|
+
case "status": return cmdStatus();
|
|
63
|
+
case "doctor": return cmdDoctor();
|
|
64
|
+
case "rm": return cmdRm(args[1]);
|
|
65
|
+
case "rename": return cmdRename(args[1], args[2]);
|
|
66
|
+
case "uninstall":
|
|
67
|
+
uninstallSupervisor();
|
|
68
|
+
console.log("removed supervisor wrapper + settings entries (accounts/credentials kept)");
|
|
69
|
+
return 0;
|
|
70
|
+
case "help":
|
|
71
|
+
case "-h":
|
|
72
|
+
case "--help":
|
|
73
|
+
printHelp();
|
|
74
|
+
return 0;
|
|
75
|
+
default:
|
|
76
|
+
console.error(c.red(`unknown command: ${sub}`));
|
|
77
|
+
printHelp();
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.exit(await main());
|
package/dist/tokenmaxxing
DELETED
|
Binary file
|