@cruxy/cli 1.8.0 → 1.9.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.
@@ -5,15 +5,27 @@ import { logger } from "../../utils/logger.js";
5
5
  import { loadConfig } from "../../config/index.js";
6
6
  import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
7
7
  /**
8
- * `cruxy login` — set or replace the API key on demand (U.6). Runs the key step
9
- * of onboarding (always, even if a key already resolves — this is how you
10
- * re-key / switch), validates it live, and persists it to the credentials store.
8
+ * `cruxy login` — sign in and persist a credential on demand (U.6). Runs the key
9
+ * step of onboarding (always, even if a key already resolves — this is how you
10
+ * re-key / switch) and persists the result to the credentials store.
11
11
  * Non-interactive invocations fail loud rather than hang.
12
+ *
13
+ * THE DEFAULT IS THE DEVICE FLOW: approve in a browser, and the gateway mints a
14
+ * subscription-bucket credential that draws on the plan pool the same human's
15
+ * web chat and desktop already use. A pasted key cannot be one — the admin
16
+ * issue-key route it comes from refuses to produce that kind — so the paste path
17
+ * is kept as a fallback rather than the front door.
18
+ *
19
+ * `--paste` keeps that fallback reachable, because the device flow cannot serve
20
+ * every case: an air-gapped machine has no browser to approve in, and someone
21
+ * deliberately using a long-lived admin key does not want a 90-day device
22
+ * credential written over it.
12
23
  */
13
24
  export function loginCommand() {
14
25
  return new Command("login")
15
- .description("set or replace your API key (validated, saved to ~/.cruxy)")
16
- .action(async () => {
26
+ .description("set or replace your cruxy credential (saved to ~/.cruxy)")
27
+ .option("--paste", "paste an existing API key instead of approving in a browser")
28
+ .action(async (opts) => {
17
29
  const t = themeForColor(shouldUseColor(process.stdout));
18
30
  if (!process.stdin.isTTY) {
19
31
  logger.print(t.muted("cruxy login is interactive — run it in a terminal, or export your key as an environment variable."));
@@ -27,6 +39,7 @@ export function loginCommand() {
27
39
  forceKey: true,
28
40
  offerFirstWin: false,
29
41
  offerScaffold: false,
42
+ preferPaste: opts.paste === true,
30
43
  io: defaultOnboardingIO(),
31
44
  deps: createDefaultDeps({ config, cwd: process.cwd() }),
32
45
  });
@@ -3,10 +3,10 @@ import { randomUUID } from "node:crypto";
3
3
  import { LimitsClient } from "@cruxy/sdk";
4
4
  import { LimitsCache } from "../../limits/index.js";
5
5
  import { logger } from "../../utils/logger.js";
6
- import { SessionLog, listSessions, resumeById, resumePicker, shortId, } from "../../session/index.js";
6
+ import { SessionLog, listSessions, loadResume, resolveSessionId, resumePicker, shortId, } from "../../session/index.js";
7
7
  /** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
8
8
  const SIDEBAR_SESSIONS = 10;
9
- import { globalDir, loadConfig, resolveApiKey } from "../../config/index.js";
9
+ import { classifyCredentialLifetime, globalDir, loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
10
10
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
11
11
  import { createRenderer } from "../../render/index.js";
12
12
  import { themeForColor } from "../../theme/index.js";
@@ -56,6 +56,56 @@ export async function executeRun(promptParts, opts) {
56
56
  const t = themeForColor(shouldUseColor(process.stdout));
57
57
  const invokedAs = opts.commandName ?? "cruxy run";
58
58
  const interactive = prompt === "";
59
+ // ORDER OF COMPLAINTS (#172 item 3). Three things can refuse this run before
60
+ // it starts, and they are checked most-specific first:
61
+ //
62
+ // 1. a bad `--root` (the workspace cannot even be built)
63
+ // 2. a bad `--resume <id>` (the session named does not exist)
64
+ // 3. no prompt, and no TTY (nothing to do, no way to be asked)
65
+ //
66
+ // The terminal check USED to come first, which meant
67
+ // `cruxy --resume no-such-id < /dev/null` was told to add a message — advice
68
+ // that, followed, only surfaces the real error one run later. `program.ts`
69
+ // deliberately routes `--resume` past the guidance banner so a bad id fails
70
+ // loud; the guard sitting in front of it undid exactly that.
71
+ //
72
+ // The tradeoff this reordering accepts: `--root` errors now precede the
73
+ // terminal message too, because id validation needs `primaryRoot` and so the
74
+ // workspace has to be built first. That is the right way round — a
75
+ // non-existent root is a more specific complaint than "needs a terminal", and
76
+ // it was already the first thing to fail on every OTHER path.
77
+ //
78
+ // The guard is NOT simply moved below resume resolution instead. That would
79
+ // put `loadConfig` and the missing-key check in front of it, so a keyless
80
+ // non-TTY run would be told to authenticate rather than that it has nothing
81
+ // to do — trading one misleading diagnostic for another.
82
+ // Declared workspace roots (C.26). This is the ONE place `run` reads the
83
+ // process working directory — the invocation directory is the base for
84
+ // resolving `--root` paths and the sole root when none are declared. Every
85
+ // subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
86
+ // re-reads the invocation directory, so nothing can silently split-brain to
87
+ // a different dir than the roots the tools see (the guard test pins this).
88
+ const invocationCwd = process.cwd();
89
+ // No --root → a trivial single-root workspace (byte-identical to pre-C.26).
90
+ // --root builds a genuine multi-root Workspace and fails fast HERE — a
91
+ // missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
92
+ // onboarding or the session starts, never a half-built session.
93
+ const workspace = opts.root.length
94
+ ? await buildWorkspace(opts.root, { cwd: invocationCwd })
95
+ : sessionWorkspace(invocationCwd);
96
+ const primaryRoot = workspace.primary().absPath;
97
+ // VALIDATION ONLY — deliberately not the load. `resolveSessionId` answers
98
+ // "does this id name a session?" from one meta line per file; the replay that
99
+ // actually restores the conversation happens far below, once we know the run
100
+ // is going to proceed. A valid `--resume <id> < /dev/null` with no message is
101
+ // still going to be turned away by the guard just below, and it should not
102
+ // have paid for a full `replaySession` to get there.
103
+ //
104
+ // Bare `--resume` (no id) cannot be validated here and is not: the picker
105
+ // needs a terminal, so it stays below the guard where it has always been.
106
+ const resumeTarget = typeof opts.resume === "string" && opts.resume !== ""
107
+ ? resolveSessionId(primaryRoot, opts.resume)
108
+ : null;
59
109
  // No prompt and stdin isn't a terminal: there's no way to read input and
60
110
  // nothing to do — fail fast instead of hanging on a line that never comes.
61
111
  //
@@ -78,21 +128,6 @@ export async function executeRun(promptParts, opts) {
78
128
  const loaded = loadConfig();
79
129
  const { config, sources } = loaded;
80
130
  let apiKey = resolveApiKey(config.model.provider);
81
- // Declared workspace roots (C.26). This is the ONE place `run` reads the
82
- // process working directory — the invocation directory is the base for
83
- // resolving `--root` paths and the sole root when none are declared. Every
84
- // subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
85
- // re-reads the invocation directory, so nothing can silently split-brain to
86
- // a different dir than the roots the tools see (the guard test pins this).
87
- const invocationCwd = process.cwd();
88
- // No --root → a trivial single-root workspace (byte-identical to pre-C.26).
89
- // --root builds a genuine multi-root Workspace and fails fast HERE — a
90
- // missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
91
- // onboarding or the session starts, never a half-built session.
92
- const workspace = opts.root.length
93
- ? await buildWorkspace(opts.root, { cwd: invocationCwd })
94
- : sessionWorkspace(invocationCwd);
95
- const primaryRoot = workspace.primary().absPath;
96
131
  logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
97
132
  logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
98
133
  // Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
@@ -147,10 +182,12 @@ export async function executeRun(promptParts, opts) {
147
182
  // a repo moved would be worse than saying so plainly.
148
183
  let resumed = null;
149
184
  if (opts.resume !== undefined && opts.resume !== false) {
150
- resumed =
151
- typeof opts.resume === "string" && opts.resume !== ""
152
- ? resumeById(primaryRoot, opts.resume)
153
- : await resumePicker(primaryRoot, { logger });
185
+ // `resumeTarget` is the id already validated above the guard; loading it is
186
+ // all that is left. Bare `--resume` resolves here, where the TTY it needs is
187
+ // guaranteed — the guard above turned away every run that lacked one.
188
+ resumed = resumeTarget
189
+ ? loadResume(resumeTarget, primaryRoot)
190
+ : await resumePicker(primaryRoot, { logger });
154
191
  if (resumed) {
155
192
  for (const warning of resumed.warnings)
156
193
  logger.warn(warning);
@@ -351,13 +388,26 @@ export async function executeRun(promptParts, opts) {
351
388
  // The cache is still attached without a key so the panel can say "not signed
352
389
  // in" — a fact worth stating, and one that costs no request to establish.
353
390
  const limitsKey = config.model.provider === "cruxy" ? apiKey : undefined;
391
+ // The stored expiry of the credential in use, read lazily. It is what lets a
392
+ // 401 be reported as "your sign-in expired" instead of "your key is wrong" —
393
+ // the gateway refuses both identically and will not say which.
394
+ const credentialExpiresAt = () => readCredentialMeta(config.model.provider)?.expiresAt;
354
395
  const limits = new LimitsCache(limitsKey === undefined
355
396
  ? undefined
356
397
  : (signal) => new LimitsClient({
357
398
  apiKey: limitsKey,
358
399
  gatewayUrl: config.cruxy.gatewayUrl,
359
- }).read(signal));
400
+ }).read(signal), { credentialExpiresAt });
360
401
  tui?.attachLimits(limits);
402
+ // Say it BEFORE the session starts, while a re-login is a 30-second detour
403
+ // rather than an interruption. A device credential is minted with a finite
404
+ // lifetime and the gateway echoes it back precisely so a client can do this;
405
+ // the alternative is the user meeting their expiry mid-turn, as a 401.
406
+ //
407
+ // Only when we actually know: an absent or unparseable expiry classifies as
408
+ // `unknown` and says nothing, which is the right answer for every pasted key
409
+ // and every credential stored before expiries were recorded.
410
+ noteCredentialExpiry(credentialExpiresAt());
361
411
  // The SAME cache backs the session's weighted-token budget (P10 track 3), so
362
412
  // the rail's headroom bar and `/budget`'s server line are one reading rather
363
413
  // than two probes that can disagree — and so admission control at the fan-out
@@ -498,3 +548,24 @@ function printRunUsage(record) {
498
548
  const t = themeForColor(shouldUseColor(process.stdout));
499
549
  logger.print(renderSummary(summarizeRuns([record]), t));
500
550
  }
551
+ /**
552
+ * Warn, once per run and before the session starts, that the stored credential
553
+ * is about to expire.
554
+ *
555
+ * ONLY IN THE `expiring` WINDOW. `live` is silent because a warning 80 days out
556
+ * is noise that trains people to ignore the line, and `expired` is silent here
557
+ * because a request is about to fail with a far better message than a preamble
558
+ * could give — `authExpired` names the date and the fix. `unknown` is silent
559
+ * because it is the honest state for every pasted key and every credential
560
+ * stored before expiries were recorded, and nagging those users about a lifetime
561
+ * nobody ever stated is exactly the false alarm this whole distinction exists to
562
+ * avoid.
563
+ */
564
+ function noteCredentialExpiry(expiresAt) {
565
+ const lifetime = classifyCredentialLifetime(expiresAt);
566
+ if (lifetime.state !== "expiring")
567
+ return;
568
+ const t = themeForColor(shouldUseColor(process.stdout));
569
+ const days = Math.max(1, Math.round(lifetime.msRemaining / 86_400_000));
570
+ logger.print(t.warning(`your cruxy sign-in expires in ${days} day${days === 1 ? "" : "s"}`) + t.muted(" — run `cruxy login` to renew it"));
571
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * How close a stored credential is to the end of its life — a pure function of a
3
+ * timestamp and a clock.
4
+ *
5
+ * IT LIVES IN ITS OWN MODULE, AND IMPORTS NOTHING, so that `errors/` can reach
6
+ * it. `config/credentials.ts` imports `errors/` (for the unprotected-store
7
+ * refusal), so an error constructor importing the credentials store back would
8
+ * close a cycle. The rule this module needs to state is not about the store at
9
+ * all — it is arithmetic on one timestamp — so it sits below both and each side
10
+ * imports it directly.
11
+ *
12
+ * ONE CLASSIFIER, because every caller that acts on expiry must reach the same
13
+ * verdict from the same value: the 401 classifier deciding whether to say "your
14
+ * key is wrong" or "your login expired", the pre-session nudge deciding whether
15
+ * to warn, and the limits panel deciding what to render. Three private
16
+ * `Date.parse` comparisons would be three chances to disagree about whether a
17
+ * credential is dead.
18
+ */
19
+ /**
20
+ * How long before expiry a credential is worth mentioning.
21
+ *
22
+ * Seven days against the gateway's 90-day device-key lifetime: long enough that
23
+ * someone who uses cruxy weekly sees the notice at least once before anything
24
+ * breaks, short enough that it is not background noise for the other 83 days.
25
+ */
26
+ export const CREDENTIAL_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000;
27
+ export function classifyCredentialLifetime(expiresAt, now = Date.now(), warnWithinMs = CREDENTIAL_EXPIRY_WARNING_MS) {
28
+ if (expiresAt === undefined || expiresAt === "")
29
+ return { state: "unknown" };
30
+ const at = Date.parse(expiresAt);
31
+ // A timestamp this build cannot read is a thing we do not know, not a thing
32
+ // that has happened.
33
+ if (Number.isNaN(at))
34
+ return { state: "unknown" };
35
+ const msRemaining = at - now;
36
+ if (msRemaining <= 0)
37
+ return { state: "expired", expiresAt };
38
+ if (msRemaining <= warnWithinMs) {
39
+ return { state: "expiring", expiresAt, msRemaining };
40
+ }
41
+ return { state: "live", expiresAt, msRemaining };
42
+ }
@@ -3,8 +3,13 @@ import { dirname, join } from "node:path";
3
3
  import { CREDENTIALS_FILE_NAME } from "../constants.js";
4
4
  import { credentialsUnprotected } from "../errors/index.js";
5
5
  import { logger } from "../utils/logger.js";
6
+ import { classifyCredentialLifetime, } from "./credential-lifetime.js";
6
7
  import { enforceOwnerOnly, isOwnerOnly } from "./owner-only.js";
7
8
  import { globalDir } from "./paths.js";
9
+ // Re-exported so `config/index.js` remains the one import site for credential
10
+ // concerns; the classifier itself lives in a leaf module that imports nothing,
11
+ // because `errors/` needs it too and this file imports `errors/`.
12
+ export { classifyCredentialLifetime, CREDENTIAL_EXPIRY_WARNING_MS, } from "./credential-lifetime.js";
8
13
  /**
9
14
  * The credentials store (U.6) — the one place a provider API key is persisted.
10
15
  * It lives **outside** `config.json` on purpose: config is secret-free by design
@@ -89,15 +94,76 @@ export function writeMcpCredential(ref, token, file = credentialsPath()) {
89
94
  store.mcp[ref] = token;
90
95
  }, "mcp");
91
96
  }
97
+ /**
98
+ * What we know about the stored key for `provider`, or `undefined`.
99
+ *
100
+ * Tolerant of a store written by a newer build or corrupted by hand: an entry
101
+ * that is not an object, or whose fields are the wrong type, reads as absent
102
+ * rather than throwing. This backs an expiry warning, and a warning that can
103
+ * crash the CLI is worse than no warning. Never throws.
104
+ */
105
+ export function readCredentialMeta(provider, file = credentialsPath()) {
106
+ const store = readStore(file);
107
+ const raw = store?.keyMeta?.[provider];
108
+ if (raw === null || typeof raw !== "object")
109
+ return undefined;
110
+ const entry = raw;
111
+ const meta = {};
112
+ if (typeof entry.expiresAt === "string" && entry.expiresAt !== "") {
113
+ meta.expiresAt = entry.expiresAt;
114
+ }
115
+ if (typeof entry.keyId === "string" && entry.keyId !== "") {
116
+ meta.keyId = entry.keyId;
117
+ }
118
+ if (entry.source === "device" || entry.source === "paste") {
119
+ meta.source = entry.source;
120
+ }
121
+ return meta;
122
+ }
92
123
  /**
93
124
  * Persist `key` for `provider`, merging into any existing store. Written
94
125
  * owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
126
+ *
127
+ * A bare write CLEARS any metadata held for that provider, because a write with
128
+ * nothing to say about the key's lifetime is a statement that we do not know it.
129
+ * The alternative — leaving whatever was there — is the one way this store can
130
+ * lie: paste an eternal admin key over a slot a device login used, and the
131
+ * stale 90-day expiry would have us announce that a perfectly good credential
132
+ * had died. Key and metadata therefore only ever move together, through
133
+ * {@link writeCredentialWithMeta}.
95
134
  */
96
135
  export function writeCredential(provider, key, file = credentialsPath()) {
136
+ writeCredentialWithMeta(provider, key, {}, file);
137
+ }
138
+ /** Persist `key` for `provider` together with what is known about it. */
139
+ export function writeCredentialWithMeta(provider, key, meta, file = credentialsPath()) {
97
140
  writeInto(file, (store) => {
98
141
  store.keys[provider] = key;
142
+ const entry = {};
143
+ if (meta.expiresAt)
144
+ entry.expiresAt = meta.expiresAt;
145
+ if (meta.keyId)
146
+ entry.keyId = meta.keyId;
147
+ if (meta.source)
148
+ entry.source = meta.source;
149
+ if (Object.keys(entry).length === 0) {
150
+ // Nothing known. Drop any prior entry rather than keeping a fact about
151
+ // a key that no longer exists in this slot.
152
+ if (store.keyMeta) {
153
+ delete store.keyMeta[provider];
154
+ if (Object.keys(store.keyMeta).length === 0)
155
+ delete store.keyMeta;
156
+ }
157
+ return;
158
+ }
159
+ store.keyMeta ??= {};
160
+ store.keyMeta[provider] = entry;
99
161
  }, "provider");
100
162
  }
163
+ /** The lifetime of the credential currently stored for `provider`. */
164
+ export function credentialLifetime(provider, now = Date.now(), file = credentialsPath()) {
165
+ return classifyCredentialLifetime(readCredentialMeta(provider, file)?.expiresAt, now);
166
+ }
101
167
  /**
102
168
  * Merge `mutate` into the store and persist it owner-only. The one write path
103
169
  * shared by every credential namespace, and the single place the owner-only
@@ -1,5 +1,5 @@
1
1
  import { CommanderError } from "commander";
2
- import { classifyProviderError, internal, usageError } from "./constructors.js";
2
+ import { classifyProviderError, internal, usageError, } from "./constructors.js";
3
3
  import { shouldUseColor, terminalFormatter } from "./format.js";
4
4
  import { CruxyError } from "./types.js";
5
5
  /**
@@ -16,13 +16,13 @@ import { CruxyError } from "./types.js";
16
16
  * - a known provider/transport error → its mapped code;
17
17
  * - anything else → CRUXY_E_INTERNAL, preserving the original as `underlying`.
18
18
  */
19
- export function fromUnknown(err) {
19
+ export function fromUnknown(err, ctx = {}) {
20
20
  if (err instanceof CruxyError)
21
21
  return err;
22
22
  if (err instanceof CommanderError) {
23
23
  return usageError(stripErrorPrefix(err.message));
24
24
  }
25
- return classifyProviderError(err) ?? internal(err);
25
+ return classifyProviderError(err, ctx) ?? internal(err);
26
26
  }
27
27
  /** A Commander "error" that's actually success (`--help`, `--version`). */
28
28
  export function isCommanderSuccess(err) {
@@ -34,7 +34,7 @@ export function isCommanderSuccess(err) {
34
34
  * touching the real process.
35
35
  */
36
36
  export function handleFatal(err, opts = {}) {
37
- const cruxy = fromUnknown(err);
37
+ const cruxy = fromUnknown(err, opts.context ?? {});
38
38
  const verbose = opts.verbose ?? isVerbose();
39
39
  const color = opts.color ?? shouldUseColor();
40
40
  const formatter = opts.formatter ?? terminalFormatter;
@@ -1,5 +1,9 @@
1
1
  import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
2
2
  import { scrubModelNames } from "../brand/index.js";
3
+ // Imported from the leaf module DIRECTLY, not via `config/index.js`:
4
+ // `config/credentials.ts` imports this file, so going through the barrel would
5
+ // close an import cycle. `credential-lifetime.ts` imports nothing.
6
+ import { classifyCredentialLifetime } from "../config/credential-lifetime.js";
3
7
  import { CruxyError, ErrorCode } from "./types.js";
4
8
  /**
5
9
  * Helper constructors for {@link CruxyError}. Each encodes the title, the human
@@ -137,11 +141,36 @@ export function authInvalid(underlying) {
137
141
  cause: scrubbedMessageOf(underlying),
138
142
  nextSteps: [
139
143
  "verify your API key is correct and active",
144
+ "run `cruxy login` to sign in again and replace it",
140
145
  "re-export the key and try again",
141
146
  ],
142
147
  underlying,
143
148
  });
144
149
  }
150
+ /**
151
+ * The credential expired. The 401 that surfaced it is byte-identical to the one
152
+ * a wrong key produces (see {@link ErrorCode.AuthExpired}); what separates them
153
+ * is the expiry this CLI stored when the key was minted, so this constructor is
154
+ * only ever reached by a caller that checked it.
155
+ *
156
+ * `expiresAt` is echoed because "it expired" invites "when?", and a user who
157
+ * sees a date three weeks past recognises immediately that this is not a key
158
+ * they typed wrong today.
159
+ */
160
+ export function authExpired(expiresAt, underlying) {
161
+ const when = expiresAt ? ` on ${expiresAt}` : "";
162
+ return new CruxyError({
163
+ code: ErrorCode.AuthExpired,
164
+ title: "your cruxy login has expired",
165
+ cause: `the stored credential reached the end of its lifetime${when}, and the gateway now refuses it`,
166
+ nextSteps: [
167
+ "run `cruxy login` to sign in again — it mints a fresh credential",
168
+ "or export a long-lived key as CRUXY_API_KEY (the environment always wins over the store)",
169
+ ],
170
+ underlying,
171
+ meta: expiresAt !== undefined ? { expiresAt } : undefined,
172
+ });
173
+ }
145
174
  /**
146
175
  * A credential could not be persisted with owner-only permissions, so it was
147
176
  * NOT written (C.27c). Chiefly a Windows case: the store's ACL could not be
@@ -360,25 +389,61 @@ export function budgetExhausted(underlying) {
360
389
  if (err?.miraAvailable) {
361
390
  nextSteps.push("switch to the mira tier, which stays available: `/model mira`");
362
391
  }
392
+ // WHICH CEILING BOUND. The gateway renders three of them through one body,
393
+ // separated only by `code`, so this is the one place that can tell them apart —
394
+ // and they take genuinely different advice. "Move to a higher plan" is right
395
+ // for the subscription pool and WRONG for a per-key cap: a device-login
396
+ // credential carries its own monthly ceiling stamped on it at mint, and no
397
+ // plan a user buys will raise it. Saying so anyway sends someone to spend
398
+ // money on something that cannot fix their problem.
399
+ const ceiling = err?.apiType;
400
+ const isKeyCap = ceiling === "key_spend_cap_exceeded";
401
+ const isWorkspaceCap = ceiling === "workspace_spend_cap_exceeded";
363
402
  const window = err?.window === "burst" ? "burst" : err?.window;
364
- const windowPhrase = window ? `your ${window} budget window` : "your budget";
403
+ const windowPhrase = isKeyCap
404
+ ? "this key's monthly spend cap"
405
+ : isWorkspaceCap
406
+ ? "the workspace's monthly spend cap"
407
+ : window
408
+ ? `your ${window} budget window`
409
+ : "your budget";
410
+ // "resets" for a cap (a calendar ceiling that refills on the 1st) and
411
+ // "recovers" for the pool (a window that trickles back) — each the verb that
412
+ // is actually true of the ceiling being described.
413
+ const verb = isKeyCap || isWorkspaceCap ? "reset" : "recover";
365
414
  nextSteps.push(waitMs !== undefined && waitMs > 0
366
- ? `wait ~${humanWait(waitMs)} — ${windowPhrase} recovers then`
367
- : `wait for ${windowPhrase} to recover`);
368
- // Only where it is true. A subscription pool is not something a user can add
369
- // to; a plan change is the only lever, and it is a different action from
370
- // "top up" with a different place to do it.
371
- nextSteps.push("or move to a higher plan for a larger allowance");
415
+ ? `wait ~${humanWait(waitMs)} — ${windowPhrase} ${verb}s then`
416
+ : `wait for ${windowPhrase} to ${verb}`);
417
+ if (isKeyCap) {
418
+ // Deliberately NOT "run `cruxy login` for a fresh key". A new credential
419
+ // would carry a new counter, which is cap evasion dressed up as
420
+ // troubleshooting and the cap exists to bound a leaked key.
421
+ nextSteps.push("or ask an org admin to raise this key's spend cap — a plan upgrade does not lift a per-key ceiling");
422
+ }
423
+ else if (isWorkspaceCap) {
424
+ nextSteps.push("or ask an org admin to raise the workspace's spend cap — a plan upgrade does not lift it");
425
+ }
426
+ else {
427
+ // Only where it is true. A subscription pool is not something a user can add
428
+ // to; a plan change is the only lever, and it is a different action from
429
+ // "top up" with a different place to do it.
430
+ nextSteps.push("or move to a higher plan for a larger allowance");
431
+ }
372
432
  return new CruxyError({
373
433
  code: ErrorCode.BudgetExhausted,
374
- title: window
375
- ? `your ${window} Cruxy budget is exhausted`
376
- : "your Cruxy budget is exhausted",
434
+ title: isKeyCap
435
+ ? "this API key's monthly spend cap is reached"
436
+ : isWorkspaceCap
437
+ ? "this workspace's monthly spend cap is reached"
438
+ : window
439
+ ? `your ${window} Cruxy budget is exhausted`
440
+ : "your Cruxy budget is exhausted",
377
441
  cause: scrubbedMessageOf(underlying),
378
442
  nextSteps,
379
443
  underlying,
380
444
  meta: err
381
445
  ? {
446
+ ...(ceiling !== undefined ? { ceiling } : {}),
382
447
  ...(err.window !== undefined ? { window: err.window } : {}),
383
448
  ...(err.resetAt !== undefined ? { resetAt: err.resetAt } : {}),
384
449
  ...(err.miraAvailable !== undefined
@@ -1502,9 +1567,18 @@ export function agentIncomplete(info) {
1502
1567
  * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
1503
1568
  * subclasses before the `ApiError` base.
1504
1569
  */
1505
- export function classifyProviderError(underlying) {
1506
- if (underlying instanceof AuthError)
1507
- return authInvalid(underlying);
1570
+ export function classifyProviderError(underlying, ctx = {}) {
1571
+ if (underlying instanceof AuthError) {
1572
+ // An expired credential and a wrong one produce the SAME 401 — the auth gate
1573
+ // refuses both without saying which, so that a prober cannot learn a key was
1574
+ // ever valid. The expiry we recorded at login is the only evidence there is,
1575
+ // and without it a user whose 90-day device login simply ran out is told to
1576
+ // go check a key they never mistyped.
1577
+ const lifetime = classifyCredentialLifetime(ctx.credentialExpiresAt?.(), ctx.now);
1578
+ return lifetime.state === "expired"
1579
+ ? authExpired(lifetime.expiresAt, underlying)
1580
+ : authInvalid(underlying);
1581
+ }
1508
1582
  if (underlying instanceof RateLimitError)
1509
1583
  return apiRateLimit(underlying);
1510
1584
  if (underlying instanceof OverloadedError)
@@ -32,6 +32,23 @@ export const ErrorCode = {
32
32
  // auth (exit 4)
33
33
  AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
34
34
  AuthInvalid: "CRUXY_E_AUTH_INVALID",
35
+ /**
36
+ * The stored credential's own lifetime has run out.
37
+ *
38
+ * A DISTINCT CODE FROM {@link AuthInvalid} because the two take opposite
39
+ * advice. "Your key is wrong" sends someone to check what they pasted; this
40
+ * one is a key that was right, worked for months, and reached the expiry the
41
+ * server stamped on it at login — the fix is to log in again, and telling that
42
+ * user to double-check their key sends them looking for a mistake they did not
43
+ * make.
44
+ *
45
+ * IT IS DECIDED CLIENT-SIDE, and it has to be. An expired key falls out of the
46
+ * auth gate's query exactly as a revoked or fabricated one does, and answers
47
+ * with the same bare 401 — deliberately, so a prober cannot learn that a key
48
+ * was ever valid. The server will never say "expired", so the only evidence is
49
+ * the expiry we stored when the credential was minted.
50
+ */
51
+ AuthExpired: "CRUXY_E_AUTH_EXPIRED",
35
52
  ForgeAuth: "CRUXY_E_FORGE_AUTH",
36
53
  /** A credential could not be persisted with owner-only permissions (C.27c) —
37
54
  * e.g. on Windows the store's ACL could not be restricted to the current user
@@ -296,6 +313,7 @@ const EXIT_CODES = {
296
313
  [ErrorCode.ConfigInvalid]: 3,
297
314
  [ErrorCode.AuthMissingKey]: 4,
298
315
  [ErrorCode.AuthInvalid]: 4,
316
+ [ErrorCode.AuthExpired]: 4,
299
317
  [ErrorCode.ForgeAuth]: 4,
300
318
  [ErrorCode.CredentialsUnprotected]: 4,
301
319
  [ErrorCode.GatewayUnreachable]: 5,
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildProgram } from "./cli/program.js";
3
+ import { loadConfig, readCredentialMeta } from "./config/index.js";
3
4
  import { handleFatal, isCommanderSuccess, isVerbose } from "./errors/index.js";
4
5
  async function main() {
5
6
  const program = buildProgram();
@@ -12,5 +13,30 @@ main().catch((err) => {
12
13
  // with the raw stack shown only under --verbose.
13
14
  if (isCommanderSuccess(err))
14
15
  process.exit(0);
15
- handleFatal(err, { verbose: isVerbose() });
16
+ handleFatal(err, {
17
+ verbose: isVerbose(),
18
+ context: {
19
+ // Resolved LAZILY — the classifier calls this only when the failure turns
20
+ // out to be a 401, so a run that fails any other way never reads config or
21
+ // the credentials store.
22
+ //
23
+ // It is needed because the gateway refuses an expired credential with
24
+ // exactly the 401 a wrong one gets, and deliberately will not say which.
25
+ // The expiry recorded at login is the only thing that can turn "your key
26
+ // is wrong" into "your login expired, run `cruxy login`".
27
+ //
28
+ // Wrapped, because this runs while we are already reporting a failure: an
29
+ // unreadable config or store here must not replace the user's real error
30
+ // with a crash inside the error handler.
31
+ credentialExpiresAt: () => {
32
+ try {
33
+ const { config } = loadConfig();
34
+ return readCredentialMeta(config.model.provider)?.expiresAt;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ },
40
+ },
41
+ });
16
42
  });
@@ -1,3 +1,4 @@
1
+ import { classifyCredentialLifetime } from "../config/credential-lifetime.js";
1
2
  import { reduceLimits } from "./reduce.js";
2
3
  /**
3
4
  * How long a probe's answer is treated as current. The pool moves when the user
@@ -9,6 +10,7 @@ const MIN_INTERVAL_MS = 15_000;
9
10
  export class LimitsCache {
10
11
  probe;
11
12
  now;
13
+ credentialExpiresAt;
12
14
  minIntervalMs;
13
15
  state;
14
16
  /** The refresh in flight, so concurrent triggers share one request. */
@@ -23,6 +25,7 @@ export class LimitsCache {
23
25
  probe, opts = {}) {
24
26
  this.probe = probe;
25
27
  this.now = opts.now ?? Date.now;
28
+ this.credentialExpiresAt = opts.credentialExpiresAt;
26
29
  this.minIntervalMs = opts.minIntervalMs ?? MIN_INTERVAL_MS;
27
30
  this.state = probe
28
31
  ? { status: "pending" }
@@ -76,12 +79,15 @@ export class LimitsCache {
76
79
  // moment ago; only report an error when there is nothing else to say.
77
80
  if (this.state.status === "ready")
78
81
  return;
79
- this.state = { status: "error", reason: classify(err) };
82
+ this.state = {
83
+ status: "error",
84
+ reason: classify(err, this.credentialExpiresAt, this.now()),
85
+ };
80
86
  }
81
87
  }
82
88
  }
83
89
  /**
84
- * Why the probe failed, in the three distinctions a user can act on.
90
+ * Why the probe failed, in the distinctions a user can act on.
85
91
  *
86
92
  * Matched on the SDK's error class names rather than `instanceof`, so this stays
87
93
  * a pure classification with no import of the transport into a module the TUI
@@ -89,11 +95,21 @@ export class LimitsCache {
89
95
  * serve `/limits` at all — an older deployment, or a base URL pointed somewhere
90
96
  * else entirely — and telling that user "you are offline" would send them
91
97
  * debugging a network that is working fine.
98
+ *
99
+ * A 401 SPLITS ON EVIDENCE THE SERVER DOES NOT PROVIDE. The auth gate refuses an
100
+ * expired credential with exactly the 401 a wrong one gets, and says nothing
101
+ * about which — deliberately, so a prober cannot learn that a key was ever
102
+ * valid. So the split is made from the expiry the CLI recorded at login, and
103
+ * `expired` is claimed ONLY when that stored timestamp has actually passed:
104
+ * absence, or a timestamp this build cannot parse, leaves the answer as
105
+ * `unauthenticated` rather than guessing at a lifetime nobody stated.
92
106
  */
93
- function classify(err) {
107
+ function classify(err, credentialExpiresAt, now) {
94
108
  const e = err;
95
- if (e?.name === "AuthError")
96
- return "unauthenticated";
109
+ if (e?.name === "AuthError") {
110
+ const lifetime = classifyCredentialLifetime(credentialExpiresAt?.(), now);
111
+ return lifetime.state === "expired" ? "expired" : "unauthenticated";
112
+ }
97
113
  if (e?.status === 404 || e?.status === 501)
98
114
  return "unsupported";
99
115
  return "unreachable";