@cruxy/cli 1.8.1 → 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
  });
@@ -6,7 +6,7 @@ import { logger } from "../../utils/logger.js";
6
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";
@@ -388,13 +388,26 @@ export async function executeRun(promptParts, opts) {
388
388
  // The cache is still attached without a key so the panel can say "not signed
389
389
  // in" — a fact worth stating, and one that costs no request to establish.
390
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;
391
395
  const limits = new LimitsCache(limitsKey === undefined
392
396
  ? undefined
393
397
  : (signal) => new LimitsClient({
394
398
  apiKey: limitsKey,
395
399
  gatewayUrl: config.cruxy.gatewayUrl,
396
- }).read(signal));
400
+ }).read(signal), { credentialExpiresAt });
397
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());
398
411
  // The SAME cache backs the session's weighted-token budget (P10 track 3), so
399
412
  // the rail's headroom bar and `/budget`'s server line are one reading rather
400
413
  // than two probes that can disagree — and so admission control at the fan-out
@@ -535,3 +548,24 @@ function printRunUsage(record) {
535
548
  const t = themeForColor(shouldUseColor(process.stdout));
536
549
  logger.print(renderSummary(summarizeRuns([record]), t));
537
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";
@@ -1,8 +1,8 @@
1
- import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
1
+ import { AuthError, DeviceLoginClient, NetworkError, createProvider, } from "@cruxy/sdk";
2
2
  import { themeForColor } from "../theme/index.js";
3
- import { resolveApiKey, writeCredential } from "../config/index.js";
3
+ import { apiKeyEnvVar, readCredential, resolveApiKey, writeCredential, writeCredentialWithMeta, } from "../config/index.js";
4
4
  import { newOnboardingState, readOnboardingState, writeOnboardingState, } from "./detect.js";
5
- import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
5
+ import { acquireKeyStep, deviceLoginStep, firstWinStep, scaffoldStep, } from "./steps.js";
6
6
  /**
7
7
  * Orchestrate the onboarding steps (U.6) — resumable and idempotent. The key
8
8
  * step is skipped when a key already resolves; the completion marker is written
@@ -17,14 +17,27 @@ export async function runOnboarding(opts) {
17
17
  let apiKey = deps.resolveApiKey(provider);
18
18
  // ── key (mandatory; skipped if already resolvable unless forceKey) ─────────
19
19
  if (!apiKey || opts.forceKey) {
20
- const result = await acquireKeyStep(io, deps, provider);
20
+ warnAboutOverwrite(io, deps);
21
+ // Device login is the default; paste stays reachable behind `--paste`, and
22
+ // is the only path when there is no device flow to run (a non-cruxy
23
+ // provider, whose keys these gateway routes know nothing about).
24
+ const useDevice = !opts.preferPaste && deps.deviceLogin !== undefined;
25
+ const result = useDevice
26
+ ? await deviceLoginStep(io, deps, provider)
27
+ : await acquireKeyStep(io, deps, provider);
21
28
  if (result.status === "aborted") {
22
29
  return { completed: false, aborted: true };
23
30
  }
24
31
  if (result.status !== "ok") {
25
- // Failed (unreachable / rejected) — surface guidance, no marker.
32
+ // Failed (unreachable / rejected / declined) — surface guidance, no marker.
26
33
  if (result.message)
27
34
  io.write(`${t.muted(result.message)}\n`);
35
+ if (useDevice) {
36
+ // The paste path exists for exactly the cases the device flow cannot
37
+ // serve, and someone who has just watched it fail is precisely who needs
38
+ // to know it is there.
39
+ io.write(t.muted("if you can't approve in a browser, `cruxy login --paste` takes a key directly.\n"));
40
+ }
28
41
  return { completed: false, aborted: false };
29
42
  }
30
43
  apiKey = result.apiKey;
@@ -46,14 +59,53 @@ export async function runOnboarding(opts) {
46
59
  io.write(`${t.success(t.strong(`${t.glyph.success} all set`))} — happy hacking.\n`);
47
60
  return { completed: true, aborted: false, apiKey };
48
61
  }
62
+ /**
63
+ * Say, BEFORE anything is overwritten, the two things a login silently does.
64
+ *
65
+ * Both are facts a user cannot see and would otherwise discover the hard way:
66
+ * that this replaces a saved credential with no undo, and that an exported key
67
+ * will go on winning over whatever is saved here, so a perfectly successful
68
+ * login can change nothing at all about the next request.
69
+ */
70
+ function warnAboutOverwrite(io, deps) {
71
+ const t = themeForColor(io.color);
72
+ const status = deps.credentialStatus?.();
73
+ if (!status)
74
+ return;
75
+ if (status.storedKey) {
76
+ io.write(t.muted("\nthis replaces the credential currently saved in ~/.cruxy — there is no undo.\n"));
77
+ }
78
+ if (status.shadowingEnvVar) {
79
+ io.write(t.warning(`\n${status.shadowingEnvVar} is set in your environment, and the environment always wins over the saved credential.\n`) +
80
+ t.muted(`whatever you sign in with will be saved, but your requests will keep using ${status.shadowingEnvVar} until you unset it.\n`));
81
+ }
82
+ }
49
83
  /**
50
84
  * Build the production {@link OnboardingDeps}: live gateway validation, the
51
85
  * credentials store, real state persistence, and a wall-clock timestamp.
52
86
  */
53
87
  export function createDefaultDeps(opts) {
88
+ const provider = opts.config.model.provider;
89
+ // ONLY ON THE CRUXY PROVIDER, on the same reasoning `run.ts` uses to gate the
90
+ // limits probe: `/device/*` are cruxy gateway routes, and a
91
+ // bring-your-own-provider setup has no notion of them. Pointing them at
92
+ // someone else's base URL would be a request to a stranger — so there is no
93
+ // device flow to offer, and the key step falls through to the paste prompt.
94
+ const deviceLogin = provider === "cruxy"
95
+ ? (io) => runDeviceLogin(opts.config, io)
96
+ : undefined;
54
97
  return {
55
- validateKey: (provider, apiKey) => validateKeyLive(provider, apiKey, opts.config),
98
+ validateKey: (p, apiKey) => validateKeyLive(p, apiKey, opts.config),
56
99
  writeCredential,
100
+ writeCredentialWithMeta,
101
+ ...(deviceLogin ? { deviceLogin } : {}),
102
+ credentialStatus: () => {
103
+ const envVar = apiKeyEnvVar(provider);
104
+ return {
105
+ storedKey: readCredential(provider) !== undefined,
106
+ ...(process.env[envVar] ? { shadowingEnvVar: envVar } : {}),
107
+ };
108
+ },
57
109
  resolveApiKey,
58
110
  readState: () => readOnboardingState(),
59
111
  writeState: (state) => writeOnboardingState(state),
@@ -62,6 +114,69 @@ export function createDefaultDeps(opts) {
62
114
  now: () => new Date().toISOString(),
63
115
  };
64
116
  }
117
+ /**
118
+ * Drive one real device login against the gateway.
119
+ *
120
+ * Everything that can go wrong on the wire collapses to `unreachable` here,
121
+ * because from the user's seat there is one answer to all of it — try again —
122
+ * and the flow's own outcomes (denied, expired, invalid) are the states that
123
+ * genuinely differ. The credential is returned, never written: persisting is the
124
+ * step's job, so there is exactly one place that decides what lands in the store.
125
+ */
126
+ async function runDeviceLogin(config, io) {
127
+ const client = new DeviceLoginClient({ gatewayUrl: config.cruxy.gatewayUrl });
128
+ let session;
129
+ try {
130
+ session = await client.start();
131
+ }
132
+ catch (err) {
133
+ return { status: "unreachable", message: unreachableMessage(err) };
134
+ }
135
+ io.prompt({
136
+ userCode: session.userCode,
137
+ verificationUri: session.verificationUri,
138
+ ...(session.verificationUriComplete !== undefined
139
+ ? { verificationUriComplete: session.verificationUriComplete }
140
+ : {}),
141
+ expiresInMs: session.expiresInMs,
142
+ });
143
+ let outcome;
144
+ try {
145
+ outcome = await client.poll(session, {
146
+ onProgress: (p) => io.waiting({
147
+ remainingMs: p.remainingMs,
148
+ ...(p.throttled !== undefined ? { throttled: p.throttled } : {}),
149
+ }),
150
+ });
151
+ }
152
+ catch (err) {
153
+ return { status: "unreachable", message: unreachableMessage(err) };
154
+ }
155
+ switch (outcome.status) {
156
+ case "approved":
157
+ return {
158
+ status: "ok",
159
+ apiKey: outcome.credential.accessToken,
160
+ ...(outcome.credential.expiresAt !== undefined
161
+ ? { expiresAt: outcome.credential.expiresAt }
162
+ : {}),
163
+ ...(outcome.credential.keyId !== undefined
164
+ ? { keyId: outcome.credential.keyId }
165
+ : {}),
166
+ };
167
+ case "denied":
168
+ return { status: "denied" };
169
+ case "expired":
170
+ return { status: "expired" };
171
+ case "invalid":
172
+ return { status: "invalid" };
173
+ }
174
+ }
175
+ function unreachableMessage(err) {
176
+ return err instanceof NetworkError
177
+ ? "couldn't reach the gateway — check your connection and run `cruxy login` again"
178
+ : "the gateway answered unexpectedly — run `cruxy login` to try again";
179
+ }
65
180
  /**
66
181
  * Validate a key with one cheap live call: start a 1-token stream and look at the
67
182
  * first event. `AuthError` ⇒ invalid (bad key), `NetworkError` ⇒ unreachable;
@@ -8,6 +8,118 @@ import { loadProjectInstructions, scaffoldProjectInstructions, } from "../config
8
8
  */
9
9
  const MAX_KEY_ATTEMPTS = 3;
10
10
  const c = (io) => themeForColor(io.color);
11
+ /**
12
+ * Sign in by approving in a browser — the default path.
13
+ *
14
+ * WHY IT IS THE DEFAULT, and why it is not merely a nicer prompt: a pasted key
15
+ * is minted through the admin issue-key route, which cannot produce a
16
+ * subscription credential — asking for one there is explicitly refused. So every
17
+ * pasted CLI key lands in the metered `apikey` bucket with no token pool, while
18
+ * the same human's web chat and desktop draw on their plan. One human, two
19
+ * budgets. The device flow mints through the login issuer, which is the only
20
+ * path to the subscription bucket, so the credential it produces draws on the
21
+ * pool the user already has.
22
+ *
23
+ * THE KEY IS NOT RE-VALIDATED. The gateway minted it seconds ago and returned it
24
+ * over the same connection; a `validateKey` call here would spend a real,
25
+ * billable request to re-prove a fact we were just told — against the very pool
26
+ * this step exists to establish. The paste path validates because there a key is
27
+ * an unverified claim by the user; here it is the gateway's own answer.
28
+ *
29
+ * Every outcome is reported, never thrown: a denial and an expiry are the flow
30
+ * working correctly, and both leave the existing credential untouched.
31
+ */
32
+ export async function deviceLoginStep(io, deps, provider) {
33
+ const col = c(io);
34
+ if (!deps.deviceLogin)
35
+ return { status: "skipped" };
36
+ const outcome = await deps.deviceLogin(deviceIO(io));
37
+ switch (outcome.status) {
38
+ case "ok": {
39
+ deps.writeCredentialWithMeta(provider, outcome.apiKey, {
40
+ ...(outcome.expiresAt !== undefined
41
+ ? { expiresAt: outcome.expiresAt }
42
+ : {}),
43
+ ...(outcome.keyId !== undefined ? { keyId: outcome.keyId } : {}),
44
+ source: "device",
45
+ });
46
+ io.write(`${col.success(col.glyph.success)} signed in — credential saved to ~/.cruxy\n`);
47
+ if (outcome.expiresAt) {
48
+ io.write(col.muted(` it expires on ${formatExpiry(outcome.expiresAt)}; run \`cruxy login\` again before then.\n`));
49
+ }
50
+ return {
51
+ status: "ok",
52
+ apiKey: outcome.apiKey,
53
+ ...(outcome.expiresAt !== undefined
54
+ ? { expiresAt: outcome.expiresAt }
55
+ : {}),
56
+ ...(outcome.keyId !== undefined ? { keyId: outcome.keyId } : {}),
57
+ };
58
+ }
59
+ case "denied":
60
+ io.write(`${col.danger(col.glyph.failure)} the sign-in was declined in the browser.\n`);
61
+ return { status: "failed", message: "sign-in declined" };
62
+ case "expired":
63
+ io.write(`${col.danger(col.glyph.failure)} the code expired before it was approved.\n`);
64
+ return {
65
+ status: "failed",
66
+ message: "the code expired — run `cruxy login` to get a new one",
67
+ };
68
+ case "invalid":
69
+ // The gateway collapses four causes into one code on purpose and tells us
70
+ // nothing about which; all four are answered by starting over.
71
+ io.write(`${col.danger(col.glyph.failure)} that sign-in could not be completed.\n`);
72
+ return {
73
+ status: "failed",
74
+ message: "sign-in could not be completed — run `cruxy login` to retry",
75
+ };
76
+ case "unreachable":
77
+ io.write(`${col.danger(col.glyph.failure)} couldn't reach the gateway to sign in.\n`);
78
+ return { status: "failed", message: outcome.message };
79
+ }
80
+ }
81
+ /** Render the device flow's two messages onto the onboarding IO. */
82
+ function deviceIO(io) {
83
+ const col = c(io);
84
+ let lastNote = "";
85
+ return {
86
+ prompt: (session) => {
87
+ const link = session.verificationUriComplete ?? session.verificationUri;
88
+ io.write(`\nTo sign in, open ${col.accent(link)}\n` +
89
+ `and enter the code ${col.strong(session.userCode)}\n\n` +
90
+ col.muted(`waiting for approval (expires in ${humanDuration(session.expiresInMs)})…\n`));
91
+ },
92
+ waiting: (info) => {
93
+ // Only the throttle is worth saying out loud, and only once: a line per
94
+ // poll would scroll a quiet wait off the screen, and "still waiting" adds
95
+ // nothing to the "waiting for approval" already on it.
96
+ if (!info.throttled || lastNote === "throttled")
97
+ return;
98
+ lastNote = "throttled";
99
+ io.write(col.muted(" the gateway asked us to slow down; still waiting…\n"));
100
+ },
101
+ };
102
+ }
103
+ /** "2 days", "9 minutes" — coarse on purpose; this is a reassurance, not a timer. */
104
+ function humanDuration(ms) {
105
+ const minutes = Math.round(ms / 60_000);
106
+ if (minutes < 1)
107
+ return "less than a minute";
108
+ if (minutes < 60)
109
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
110
+ const hours = Math.round(minutes / 60);
111
+ if (hours < 24)
112
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
113
+ const days = Math.round(hours / 24);
114
+ return `${days} day${days === 1 ? "" : "s"}`;
115
+ }
116
+ /** The date part of an RFC 3339 expiry, or the raw string if it will not parse. */
117
+ function formatExpiry(expiresAt) {
118
+ const at = Date.parse(expiresAt);
119
+ if (Number.isNaN(at))
120
+ return expiresAt;
121
+ return new Date(at).toISOString().slice(0, 10);
122
+ }
11
123
  /**
12
124
  * Acquire and persist a provider key: print the create-key URL, read it masked,
13
125
  * validate it live, and **only then** write it to the credentials store. Loops on
@@ -241,6 +241,15 @@ export function limitsPanelLines(theme, state, now = Date.now()) {
241
241
  switch (state.reason) {
242
242
  case "unauthenticated":
243
243
  return [theme.muted("not signed in"), theme.muted("run cruxy login")];
244
+ case "expired":
245
+ // The same remedy as "not signed in", but a different fact — and the
246
+ // fact is the point. "Sign-in expired" tells someone their setup was
247
+ // right and simply aged out; "not signed in" invites them to go looking
248
+ // for what they configured wrong.
249
+ return [
250
+ theme.warning("sign-in expired"),
251
+ theme.muted("run cruxy login"),
252
+ ];
244
253
  case "unsupported":
245
254
  // The gateway answered — it simply has no limits to report. Sending this
246
255
  // user to debug their network would be the wrong errand entirely.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "undici": "^6.21.0",
37
37
  "zod": "^3.23.8",
38
38
  "zod-to-json-schema": "^3.23.5",
39
- "@cruxy/sdk": "0.6.0"
39
+ "@cruxy/sdk": "0.7.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"