@efoo/ccprofile 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,7 +73,7 @@ Repeat with `ccprofile add personal` etc. Different terminals in different direc
73
73
  | `ccprofile unlink [dir]` | Remove the managed block (deletes `.envrc` if nothing else remains) |
74
74
  | `ccprofile token <name>` | Print the stored token to stdout (for scripting — handle with care) |
75
75
  | `ccprofile remove <name>` | Delete the profile and its Keychain entry |
76
- | `ccprofile doctor [dir]` | Diagnose provider overrides, stale/missing active token env, expiry, token liveness, broken links. `--offline` skips the server probe |
76
+ | `ccprofile doctor [dir]` | Diagnose provider overrides, stale/missing active token env, expiry, token liveness, usage limits (a minimal real inference per profile — fable first, haiku fallback), broken links. `--model <alias>` pins the probe model; `--offline` skips all server probes |
77
77
  | `ccprofile completion <shell>` | Print a completion script for fish, zsh, or bash |
78
78
 
79
79
  ## Shell completion
@@ -120,7 +120,7 @@ ccprofile is built on `claude setup-token`, whose long-lived tokens are **delibe
120
120
 
121
121
  - **No account identity introspection.** The token cannot answer "whose token is this?" — the OAuth profile endpoint rejects it (`user:profile` scope missing, see [#11985](https://github.com/anthropics/claude-code/issues/11985)). The `--email` you record is a self-declared label, not verified.
122
122
  *Verify identity once, at registration time:* make sure the browser is logged into the intended claude.ai account before `claude setup-token`, then send a couple of prompts from a linked directory and confirm on claude.ai (web) that the intended account's usage moved.
123
- - **`/status` → Usage tab shows no plan utilization** in token-authenticated sessions (same scope restriction). Check usage on claude.ai instead.
123
+ - **`/status` → Usage tab shows no plan utilization** in token-authenticated sessions (same scope restriction — the usage endpoint also requires `user:profile`). Check usage on claude.ai, or run `ccprofile doctor`: it detects exhausted usage limits the only way these tokens allow, by sending one minimal real inference per profile (fable first; on a fable limit it retries with haiku to tell "fable's separate budget exhausted" from "subscription window exhausted"). The probe consumes a negligible amount of quota and starts the 5-hour window of an idle profile; use `--offline` if you don't want that.
124
124
  - **Remote Control is unavailable** in linked directories. Claude Code treats `ANTHROPIC_AUTH_TOKEN` sessions as API-key authentication, while Remote Control requires claude.ai subscription authentication.
125
125
  - **Tokens last up to 1 year but can die earlier** (password change, logout-all). The recorded expiry is a hint, not a guarantee — `ccprofile doctor` probes the server and tells live tokens apart from revoked ones.
126
126
  - **Routing only applies to shell-launched processes.** direnv activates the token when a hooked shell enters the directory; apps launched outside a hooked shell (GUI launchers) bypass it.
@@ -7,22 +7,16 @@ import { daysRemaining, loadConfig } from "../lib/config.js";
7
7
  import { parseLinkedProfile } from "../lib/envrc.js";
8
8
  import { Keychain } from "../lib/keychain.js";
9
9
  import { probeToken } from "../lib/probe.js";
10
- import { bold, fail, ok, warn } from "../lib/format.js";
11
- /**
12
- * Env vars that outrank ccprofile's managed ANTHROPIC_AUTH_TOKEN in Claude
13
- * Code's documented authentication precedence. If any is set, ccprofile
14
- * routing is bypassed before the token is considered.
15
- */
16
- const OVERRIDING_ENV_VARS = [
17
- "CLAUDE_CODE_USE_BEDROCK",
18
- "CLAUDE_CODE_USE_VERTEX",
19
- "CLAUDE_CODE_USE_FOUNDRY",
20
- ];
10
+ import { DEFAULT_PROBE_MODEL, FALLBACK_PROBE_MODEL, OVERRIDING_ENV_VARS, probeUsage, } from "../lib/usage.js";
11
+ import { bold, dim, fail, green, ok, red, stripAnsi, warn, yellow } from "../lib/format.js";
21
12
  export async function doctorCommand(argv) {
22
13
  const { values, positionals } = parseArgs({
23
14
  args: argv,
24
15
  allowPositionals: true,
25
- options: { offline: { type: "boolean", default: false } },
16
+ options: {
17
+ offline: { type: "boolean", default: false },
18
+ model: { type: "string" },
19
+ },
26
20
  });
27
21
  const dir = resolve(positionals[0] ?? process.cwd());
28
22
  let problems = 0;
@@ -31,23 +25,31 @@ export async function doctorCommand(argv) {
31
25
  console.log(fail("Not macOS: ccprofile's Keychain backend is unavailable on this platform."));
32
26
  return 1;
33
27
  }
34
- console.log(ok("Platform: macOS (Keychain backend available)"));
35
28
  const claude = spawnSync("claude", ["--version"], { stdio: "pipe" });
36
- if (claude.error || claude.status !== 0) {
37
- console.log(warn("`claude` CLI not found. `ccprofile add` cannot launch setup-token for you."));
38
- warnings += 1;
39
- }
40
- else {
41
- console.log(ok(`Claude Code: ${claude.stdout.toString().trim()}`));
42
- }
29
+ const claudeAvailable = !claude.error && claude.status === 0;
43
30
  const direnv = spawnSync("direnv", ["version"], { stdio: "pipe" });
44
- if (direnv.error || direnv.status !== 0) {
45
- console.log(fail("direnv not found. Linked directories will not activate tokens automatically."));
46
- console.log(" Install: brew install direnv / fish hook: direnv hook fish | source");
47
- problems += 1;
31
+ const direnvAvailable = !direnv.error && direnv.status === 0;
32
+ if (claudeAvailable && direnvAvailable) {
33
+ const claudeVersion = claude.stdout.toString().trim().split(" ")[0] ?? "";
34
+ console.log(ok(`macOS Keychain · Claude Code ${claudeVersion} · direnv ${direnv.stdout.toString().trim()}`));
48
35
  }
49
36
  else {
50
- console.log(ok(`direnv: ${direnv.stdout.toString().trim()}`));
37
+ console.log(ok("Platform: macOS (Keychain backend available)"));
38
+ if (claudeAvailable) {
39
+ console.log(ok(`Claude Code: ${claude.stdout.toString().trim()}`));
40
+ }
41
+ else {
42
+ console.log(warn("`claude` CLI not found. `ccprofile add` cannot launch setup-token for you."));
43
+ warnings += 1;
44
+ }
45
+ if (direnvAvailable) {
46
+ console.log(ok(`direnv: ${direnv.stdout.toString().trim()}`));
47
+ }
48
+ else {
49
+ console.log(fail("direnv not found. Linked directories will not activate tokens automatically."));
50
+ console.log(" Install: brew install direnv / fish hook: direnv hook fish | source");
51
+ problems += 1;
52
+ }
51
53
  }
52
54
  for (const envVar of OVERRIDING_ENV_VARS) {
53
55
  if (process.env[envVar] !== undefined) {
@@ -72,72 +74,66 @@ export async function doctorCommand(argv) {
72
74
  }
73
75
  const config = loadConfig();
74
76
  const keychain = new Keychain();
75
- for (const [name, profile] of Object.entries(config.profiles)) {
76
- const present = await keychain.hasEntry(profile.keychain.service, profile.keychain.account);
77
- if (!present) {
78
- console.log(fail(`Profile "${name}": Keychain entry missing. Re-run: ccprofile add ${name} --force`));
79
- problems += 1;
80
- continue;
81
- }
82
- const days = daysRemaining(profile.expiresAt);
83
- if (days < 0) {
84
- console.log(fail(`Profile "${name}": token recorded as expired ${-days}d ago. Re-issue with claude setup-token.`));
85
- problems += 1;
86
- }
87
- else if (days <= config.settings.expiryWarningDays) {
88
- console.log(warn(`Profile "${name}": token expires in ${days}d.`));
89
- warnings += 1;
90
- }
91
- else {
92
- console.log(ok(`Profile "${name}": token stored, expires in ${days}d.`));
93
- }
94
- if (values.offline)
95
- continue;
96
- const token = await keychain.getToken(profile.keychain.service, profile.keychain.account);
97
- if (token === null)
98
- continue;
99
- const live = await probeToken(token);
100
- if (live.status === "alive") {
101
- if (live.email !== undefined && profile.email !== undefined && live.email !== profile.email) {
102
- console.log(fail(`Profile "${name}": server says the token belongs to ${live.email}, but config records ${profile.email}.`));
103
- problems += 1;
104
- }
105
- else {
106
- console.log(ok(`Profile "${name}": token is live on the server${live.email ? ` (account: ${live.email})` : ""}.`));
77
+ const profiles = Object.entries(config.profiles);
78
+ if (profiles.length > 0) {
79
+ const ctx = {
80
+ keychain,
81
+ expiryWarningDays: config.settings.expiryWarningDays,
82
+ offline: values.offline,
83
+ claudeAvailable,
84
+ probeModel: values.model ?? DEFAULT_PROBE_MODEL,
85
+ };
86
+ // All profiles are checked concurrently (each profile's own steps stay
87
+ // sequential so the fable→haiku cascade works); rows print in config
88
+ // order as they resolve, making wall time the slowest profile, not the sum.
89
+ const running = profiles.map(([name, profile]) => checkProfile(name, profile, ctx));
90
+ const widths = columnWidths(profiles.map(([name]) => name));
91
+ console.log();
92
+ console.log(dim(renderCells(HEADER, widths)));
93
+ const spinner = startSpinner(values.offline || !claudeAvailable
94
+ ? "checking profiles…"
95
+ : `probing ${profiles.length} profile(s) — one tiny real request each…`);
96
+ for (const pending of running) {
97
+ const row = await pending;
98
+ spinner.clear();
99
+ console.log(renderCells([bold(row.name), row.token, row.expires, row.fable, row.others, row.note], widths));
100
+ for (const detail of row.details) {
101
+ console.log(` ${dim("↳")} ${detail}`);
107
102
  }
103
+ problems += row.problems;
104
+ warnings += row.warnings;
108
105
  }
109
- else if (live.status === "invalid") {
110
- console.log(fail(`Profile "${name}": token rejected by the server (revoked or expired early). Re-issue with: ccprofile add ${name} --force`));
111
- problems += 1;
112
- }
113
- else {
114
- console.log(warn(`Profile "${name}": liveness check inconclusive (${live.detail}). Use --offline to skip.`));
115
- warnings += 1;
116
- }
106
+ spinner.stop();
117
107
  }
118
108
  const envrcPath = join(dir, ".envrc");
119
109
  if (existsSync(envrcPath)) {
110
+ console.log();
120
111
  const linked = parseLinkedProfile(readFileSync(envrcPath, "utf8"));
121
112
  if (linked === null) {
122
113
  console.log(ok(`${envrcPath} exists but has no ccprofile block (not managed here).`));
123
114
  }
124
115
  else if (config.profiles[linked]) {
125
- console.log(ok(`${bold(dir)} is linked to profile "${linked}".`));
126
- if (dir === resolve(process.cwd())) {
116
+ if (dir !== resolve(process.cwd())) {
117
+ console.log(ok(`${bold(dir)} profile "${linked}"`));
118
+ }
119
+ else {
127
120
  const linkedProfile = config.profiles[linked];
128
121
  const exportedToken = process.env.ANTHROPIC_AUTH_TOKEN;
129
122
  if (exportedToken === undefined) {
130
- console.log(fail("Current shell does not export ANTHROPIC_AUTH_TOKEN. Run: direnv reload"));
123
+ console.log(fail(`This directory → profile "${linked}", but ANTHROPIC_AUTH_TOKEN is not exported in this shell. Run: direnv reload`));
131
124
  problems += 1;
132
125
  }
133
126
  else {
134
127
  const linkedToken = await keychain.getToken(linkedProfile.keychain.service, linkedProfile.keychain.account);
135
128
  if (linkedToken !== null && exportedToken !== linkedToken) {
136
- console.log(fail(`Current shell exports a different ANTHROPIC_AUTH_TOKEN than profile "${linked}". Run: direnv reload, then restart Claude Code.`));
129
+ console.log(fail(`This directory profile "${linked}", but this shell exports a different ANTHROPIC_AUTH_TOKEN. Run: direnv reload, then restart Claude Code.`));
137
130
  problems += 1;
138
131
  }
139
132
  else if (linkedToken !== null) {
140
- console.log(ok(`Current shell exports profile "${linked}" token via ANTHROPIC_AUTH_TOKEN.`));
133
+ console.log(ok(`This directory profile "${linked}" (ANTHROPIC_AUTH_TOKEN exported)`));
134
+ }
135
+ else {
136
+ console.log(ok(`This directory → profile "${linked}"`));
141
137
  }
142
138
  }
143
139
  }
@@ -155,3 +151,220 @@ export async function doctorCommand(argv) {
155
151
  console.log(ok(`No problems found (${warnings} warning(s)).`));
156
152
  return 0;
157
153
  }
154
+ const HEADER = ["PROFILE", "TOKEN", "EXPIRES", "FABLE", "OTHERS", "NOTE"];
155
+ // The cell vocabulary is fixed, so column widths are known before any check
156
+ // resolves — rows can stream in as profiles finish, without a barrier.
157
+ const TOKEN_CELLS = {
158
+ stored: green("✓ stored"),
159
+ live: green("✓ live"),
160
+ missing: red("✗ missing"),
161
+ revoked: red("✗ revoked"),
162
+ mismatch: red("✗ mismatch"),
163
+ unknown: yellow("? unknown"),
164
+ };
165
+ const PROBE_CELLS = {
166
+ ok: green("✓ ok"),
167
+ limit: yellow("⚠ limit"),
168
+ rejected: red("✗"),
169
+ unknown: yellow("?"),
170
+ none: dim("-"),
171
+ };
172
+ function columnWidths(names) {
173
+ const vocab = (cells) => Object.values(cells).map((c) => stripAnsi(c).length);
174
+ return [
175
+ Math.max("PROFILE".length, ...names.map((n) => n.length)),
176
+ Math.max("TOKEN".length, ...vocab(TOKEN_CELLS)),
177
+ "EXPIRES".length,
178
+ Math.max("FABLE".length, ...vocab(PROBE_CELLS)),
179
+ Math.max("OTHERS".length, ...vocab(PROBE_CELLS)),
180
+ 0, // NOTE is last and stays unpadded
181
+ ];
182
+ }
183
+ function renderCells(cells, widths) {
184
+ return cells
185
+ .map((cell, i) => {
186
+ const width = widths[i] ?? 0;
187
+ return cell + " ".repeat(Math.max(0, width - stripAnsi(cell).length));
188
+ })
189
+ .join(" ")
190
+ .trimEnd();
191
+ }
192
+ function expiresCell(days, warningDays) {
193
+ const text = `${days}d`;
194
+ if (days < 0)
195
+ return red(text);
196
+ if (days <= warningDays)
197
+ return yellow(text);
198
+ return text;
199
+ }
200
+ /**
201
+ * Minimal dependency-free progress indicator for the parallel probe wait.
202
+ * Renders only on a TTY (piped/CI output stays clean). clear() erases the
203
+ * spinner line so a result row can print; the interval repaints it below.
204
+ */
205
+ function startSpinner(text) {
206
+ if (!process.stdout.isTTY) {
207
+ return { clear: () => { }, stop: () => { } };
208
+ }
209
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
210
+ let i = 0;
211
+ const paint = () => {
212
+ process.stdout.write(`\r\u001B[2K${dim(`${frames[i % frames.length] ?? ""} ${text}`)}`);
213
+ i += 1;
214
+ };
215
+ paint();
216
+ const timer = setInterval(paint, 100);
217
+ const clear = () => {
218
+ process.stdout.write("\r\u001B[2K");
219
+ };
220
+ return {
221
+ clear,
222
+ stop: () => {
223
+ clearInterval(timer);
224
+ clear();
225
+ },
226
+ };
227
+ }
228
+ /**
229
+ * Runs every check for one profile and returns a table row instead of
230
+ * printing, so all profiles can run concurrently without interleaving
231
+ * output. Detail lines carry remediation/diagnostic text that does not fit
232
+ * a cell. Must never reject: doctor prints rows in config order while later
233
+ * profiles are still running, so a rejection before its turn would be an
234
+ * unhandled promise rejection.
235
+ */
236
+ async function checkProfile(name, profile, ctx) {
237
+ const row = {
238
+ name,
239
+ token: PROBE_CELLS.none,
240
+ expires: PROBE_CELLS.none,
241
+ fable: PROBE_CELLS.none,
242
+ others: PROBE_CELLS.none,
243
+ note: "",
244
+ details: [],
245
+ problems: 0,
246
+ warnings: 0,
247
+ };
248
+ try {
249
+ const present = await ctx.keychain.hasEntry(profile.keychain.service, profile.keychain.account);
250
+ if (!present) {
251
+ row.token = TOKEN_CELLS.missing;
252
+ row.problems += 1;
253
+ row.details.push(`Keychain entry missing. Re-run: ccprofile add ${name} --force`);
254
+ return row;
255
+ }
256
+ row.token = TOKEN_CELLS.stored;
257
+ const days = daysRemaining(profile.expiresAt);
258
+ row.expires = expiresCell(days, ctx.expiryWarningDays);
259
+ if (days < 0) {
260
+ row.problems += 1;
261
+ row.details.push(`Token recorded as expired ${-days}d ago. Re-issue with claude setup-token.`);
262
+ }
263
+ else if (days <= ctx.expiryWarningDays) {
264
+ row.warnings += 1;
265
+ }
266
+ if (ctx.offline)
267
+ return row;
268
+ const token = await ctx.keychain.getToken(profile.keychain.service, profile.keychain.account);
269
+ if (token === null)
270
+ return row;
271
+ const live = await probeToken(token);
272
+ if (live.status === "alive") {
273
+ if (live.email !== undefined && profile.email !== undefined && live.email !== profile.email) {
274
+ row.token = TOKEN_CELLS.mismatch;
275
+ row.problems += 1;
276
+ row.details.push(`Server says the token belongs to ${live.email}, but config records ${profile.email}.`);
277
+ }
278
+ else {
279
+ row.token = TOKEN_CELLS.live;
280
+ }
281
+ if (ctx.claudeAvailable) {
282
+ await appendUsageProbe(row, token, ctx.probeModel);
283
+ }
284
+ }
285
+ else if (live.status === "invalid") {
286
+ row.token = TOKEN_CELLS.revoked;
287
+ row.problems += 1;
288
+ row.details.push(`Token rejected by the server (revoked or expired early). Re-issue with: ccprofile add ${name} --force`);
289
+ }
290
+ else {
291
+ row.token = TOKEN_CELLS.unknown;
292
+ row.warnings += 1;
293
+ row.details.push(`Liveness check inconclusive (${live.detail}). Use --offline to skip.`);
294
+ }
295
+ }
296
+ catch (error) {
297
+ row.problems += 1;
298
+ row.details.push(`Check failed (${error instanceof Error ? error.message : String(error)}).`);
299
+ }
300
+ return row;
301
+ }
302
+ /**
303
+ * Fills the FABLE/OTHERS cells via real inference. fable is probed first by
304
+ * default; on a fable limit, a haiku probe distinguishes "fable's separate
305
+ * budget is exhausted" from "the whole subscription window is exhausted".
306
+ * The two probes stay sequential within a profile (the cascade depends on
307
+ * the first result); concurrency happens across profiles. Limits count as
308
+ * warnings, not problems: doctor's exit code reflects configuration health,
309
+ * and a rate-limited profile is configured correctly.
310
+ */
311
+ async function appendUsageProbe(row, token, model) {
312
+ const pinnedIsFable = /fable/i.test(model);
313
+ const setProbedCell = (cell) => {
314
+ if (pinnedIsFable)
315
+ row.fable = cell;
316
+ else
317
+ row.others = cell;
318
+ };
319
+ const usage = await probeUsage(token, model);
320
+ if (usage.status === "usable") {
321
+ if (pinnedIsFable) {
322
+ row.fable = PROBE_CELLS.ok;
323
+ // fable draws from the shared pool too, so fable OK implies the rest.
324
+ row.others = PROBE_CELLS.ok;
325
+ }
326
+ else {
327
+ row.others = PROBE_CELLS.ok;
328
+ row.note = `probed ${model}`;
329
+ }
330
+ return;
331
+ }
332
+ if (usage.status === "limited") {
333
+ row.warnings += 1;
334
+ const resets = usage.resetsAt === undefined ? "" : ` (resets ${usage.resetsAt.toLocaleString()})`;
335
+ if (!pinnedIsFable) {
336
+ row.others = PROBE_CELLS.limit;
337
+ row.note = `${model} usage limit reached${resets}`;
338
+ return;
339
+ }
340
+ row.fable = PROBE_CELLS.limit;
341
+ const fallback = await probeUsage(token, FALLBACK_PROBE_MODEL);
342
+ if (fallback.status === "usable") {
343
+ row.others = PROBE_CELLS.ok;
344
+ row.note = `${FALLBACK_PROBE_MODEL}/sonnet/opus available${resets}`;
345
+ }
346
+ else if (fallback.status === "limited") {
347
+ row.others = PROBE_CELLS.limit;
348
+ row.note =
349
+ fallback.resetsAt === undefined
350
+ ? `subscription window exhausted${resets}`
351
+ : `subscription window exhausted (resets ${fallback.resetsAt.toLocaleString()})`;
352
+ }
353
+ else {
354
+ row.others = PROBE_CELLS.unknown;
355
+ row.note = `${FALLBACK_PROBE_MODEL} probe inconclusive`;
356
+ row.details.push(`${FALLBACK_PROBE_MODEL} probe: ${fallback.detail}`);
357
+ }
358
+ return;
359
+ }
360
+ if (usage.status === "invalid") {
361
+ setProbedCell(PROBE_CELLS.rejected);
362
+ row.problems += 1;
363
+ row.details.push(`Inference probe rejected the token (${usage.detail}). Re-issue with: ccprofile add ${row.name} --force`);
364
+ return;
365
+ }
366
+ setProbedCell(PROBE_CELLS.unknown);
367
+ row.warnings += 1;
368
+ row.note = "usage probe inconclusive";
369
+ row.details.push(`Usage probe (${model}): ${usage.detail}`);
370
+ }
package/dist/index.js CHANGED
@@ -23,7 +23,9 @@ ${bold("Commands")}
23
23
  token <name> Print the stored token (for scripting; handle with care)
24
24
  remove <name> [--force] Delete a profile and its Keychain entry
25
25
  doctor [dir] Diagnose overriding env vars, expiry, token liveness,
26
- --offline and broken links; --offline skips the server probe
26
+ --offline usage limits (real inference probe), and broken links
27
+ --model <alias> --offline skips probes; --model pins the probe model
28
+ (default: fable, then haiku to isolate fable limits)
27
29
  completion <shell> Print a completion script (fish, zsh, bash)
28
30
 
29
31
  ${bold("Typical flow")}
@@ -12,7 +12,7 @@ export const cyan = (s) => paint("36", s);
12
12
  export const ok = (s) => `${green("✓")} ${s}`;
13
13
  export const warn = (s) => `${yellow("⚠")} ${s}`;
14
14
  export const fail = (s) => `${red("✗")} ${s}`;
15
- function stripAnsi(s) {
15
+ export function stripAnsi(s) {
16
16
  return s.replaceAll(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), "");
17
17
  }
18
18
  export function table(rows) {
@@ -0,0 +1,156 @@
1
+ import { spawn } from "node:child_process";
2
+ import { tmpdir } from "node:os";
3
+ /**
4
+ * fable has its own budget (~50% of the plan, 5h/1-week windows) separate
5
+ * from the pool shared by haiku/sonnet/opus, and fable usage also counts
6
+ * against the shared pool. Probing fable first therefore answers for
7
+ * everything: fable OK ⇒ all models OK; fable limited ⇒ probe the cheapest
8
+ * shared-pool model to tell "fable-only exhausted" from "window exhausted".
9
+ */
10
+ export const DEFAULT_PROBE_MODEL = "fable";
11
+ export const FALLBACK_PROBE_MODEL = "haiku";
12
+ const PROBE_TIMEOUT_MS = 120_000;
13
+ /**
14
+ * Env vars that outrank ANTHROPIC_AUTH_TOKEN in Claude Code's documented
15
+ * authentication precedence. doctor flags them; the probe strips them so the
16
+ * spawned `claude -p` authenticates as the profile under test.
17
+ */
18
+ export const OVERRIDING_ENV_VARS = [
19
+ "CLAUDE_CODE_USE_BEDROCK",
20
+ "CLAUDE_CODE_USE_VERTEX",
21
+ "CLAUDE_CODE_USE_FOUNDRY",
22
+ ];
23
+ export const defaultProbeRunner = (cmd, args, opts) => new Promise((resolve) => {
24
+ const child = spawn(cmd, args, {
25
+ env: opts.env,
26
+ cwd: opts.cwd,
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ });
29
+ let stdout = "";
30
+ let stderr = "";
31
+ let timedOut = false;
32
+ const timer = setTimeout(() => {
33
+ timedOut = true;
34
+ child.kill("SIGKILL");
35
+ }, opts.timeoutMs);
36
+ child.stdout.on("data", (d) => (stdout += d.toString()));
37
+ child.stderr.on("data", (d) => (stderr += d.toString()));
38
+ child.on("error", (error) => {
39
+ clearTimeout(timer);
40
+ resolve({ code: null, stdout, stderr, timedOut, spawnError: error.message });
41
+ });
42
+ child.on("close", (code) => {
43
+ clearTimeout(timer);
44
+ resolve({ code, stdout, stderr, timedOut });
45
+ });
46
+ });
47
+ /**
48
+ * Everything here trims the request to near-zero cost and isolates it from
49
+ * the invoking user's Claude Code customizations: a one-word turn with a
50
+ * replacement system prompt, no tools, no MCP servers, no settings (hooks,
51
+ * plugins, apiKeyHelper), no session file, run from tmpdir so no project
52
+ * CLAUDE.md is discovered. --fallback-model must never be added: it would
53
+ * silently succeed on another model and mask the limit being probed for.
54
+ */
55
+ function probeArgs(model) {
56
+ return [
57
+ "--print", "ping",
58
+ "--model", model,
59
+ "--effort", "low",
60
+ "--system-prompt", 'Reply with exactly "ok".',
61
+ "--tools", "",
62
+ "--setting-sources", "",
63
+ "--strict-mcp-config",
64
+ "--no-session-persistence",
65
+ "--output-format", "json",
66
+ ];
67
+ }
68
+ function probeEnv(token) {
69
+ const env = { ...process.env, ANTHROPIC_AUTH_TOKEN: token };
70
+ delete env.ANTHROPIC_API_KEY;
71
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
72
+ delete env.ANTHROPIC_MODEL;
73
+ for (const name of OVERRIDING_ENV_VARS)
74
+ delete env[name];
75
+ return env;
76
+ }
77
+ // Claude Code's error strings are not a stable API; match loosely and fall
78
+ // back to "unknown" rather than misclassifying. Checked against the legacy
79
+ // print-mode message ("Claude AI usage limit reached|<epoch>"), the current
80
+ // prose ("You've reached your usage limit..."), and API rate_limit_error.
81
+ const LIMIT_RE = /usage limit|rate.?limit|limit (?:reached|exceeded|resets|will reset)|exceeded.*limit|out of (?:usage|credits)|hit your.*limit|\b429\b/i;
82
+ const AUTH_RE = /authentication_error|invalid (?:bearer|oauth|api key)|oauth token|\b401\b|\/login\b/i;
83
+ /** Legacy print-mode limit messages carry a reset time as "|<unix epoch>". */
84
+ function parseResetEpoch(text) {
85
+ const digits = /\|(\d{10,13})\b/.exec(text)?.[1];
86
+ if (digits === undefined)
87
+ return undefined;
88
+ const n = Number(digits);
89
+ return new Date(digits.length >= 13 ? n : n * 1000);
90
+ }
91
+ function summarize(text) {
92
+ const line = text
93
+ .replaceAll(/\u001B\[[0-9;]*m/g, "")
94
+ .split("\n")
95
+ .map((l) => l.trim())
96
+ .find((l) => l.length > 0);
97
+ return (line ?? "").slice(0, 200);
98
+ }
99
+ function isRecord(v) {
100
+ return typeof v === "object" && v !== null;
101
+ }
102
+ /**
103
+ * `--output-format json` emits a single result object in some Claude Code
104
+ * versions and an array of messages (init/assistant/result) in others
105
+ * (observed with 2.1.201). The result message carries `is_error`, the final
106
+ * text, and — on API rejections — a structured `api_error_status` (e.g. 429),
107
+ * which beats string matching.
108
+ */
109
+ function extractResultMessage(stdout) {
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(stdout);
113
+ }
114
+ catch {
115
+ // Startup failures print plain text before any JSON is emitted.
116
+ return {};
117
+ }
118
+ const items = Array.isArray(parsed) ? parsed : [parsed];
119
+ const message = items.find((i) => isRecord(i) && i.type === "result") ??
120
+ items.find((i) => isRecord(i) && "is_error" in i);
121
+ if (message === undefined)
122
+ return {};
123
+ return {
124
+ isError: typeof message.is_error === "boolean" ? message.is_error : undefined,
125
+ resultText: typeof message.result === "string" ? message.result : undefined,
126
+ apiErrorStatus: typeof message.api_error_status === "number" ? message.api_error_status : undefined,
127
+ };
128
+ }
129
+ export function classifyProbeOutput(exec) {
130
+ if (exec.spawnError !== undefined) {
131
+ return { status: "unknown", detail: exec.spawnError };
132
+ }
133
+ if (exec.timedOut) {
134
+ return { status: "unknown", detail: `probe timed out after ${PROBE_TIMEOUT_MS / 1000}s` };
135
+ }
136
+ const { isError, resultText, apiErrorStatus } = extractResultMessage(exec.stdout);
137
+ if (exec.code === 0 && isError !== true)
138
+ return { status: "usable" };
139
+ const combined = [resultText ?? exec.stdout, exec.stderr].join("\n");
140
+ const detail = summarize(combined) || `claude exited with code ${exec.code}`;
141
+ if (apiErrorStatus === 429 || LIMIT_RE.test(combined)) {
142
+ return { status: "limited", detail, resetsAt: parseResetEpoch(combined) };
143
+ }
144
+ if (apiErrorStatus === 401 || AUTH_RE.test(combined)) {
145
+ return { status: "invalid", detail };
146
+ }
147
+ return { status: "unknown", detail };
148
+ }
149
+ export async function probeUsage(token, model, run = defaultProbeRunner) {
150
+ const exec = await run("claude", probeArgs(model), {
151
+ env: probeEnv(token),
152
+ cwd: tmpdir(),
153
+ timeoutMs: PROBE_TIMEOUT_MS,
154
+ });
155
+ return classifyProbeOutput(exec);
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efoo/ccprofile",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Per-directory Claude Code account routing via ANTHROPIC_AUTH_TOKEN, direnv, and macOS Keychain",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.4",