@cruxy/cli 1.8.1 → 1.10.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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +62 -9
  4. package/dist/approval/classify.js +170 -40
  5. package/dist/approval/prompt.js +52 -6
  6. package/dist/approval/service.js +1 -11
  7. package/dist/budget/session-budget.js +10 -1
  8. package/dist/checkpoint/coverage.js +147 -4
  9. package/dist/cli/commands/limits.js +76 -0
  10. package/dist/cli/commands/login.js +18 -5
  11. package/dist/cli/commands/pr.js +10 -1
  12. package/dist/cli/commands/rollback.js +10 -2
  13. package/dist/cli/commands/run.js +55 -5
  14. package/dist/cli/commands/sessions.js +156 -0
  15. package/dist/cli/program.js +4 -0
  16. package/dist/cli/repl.js +25 -0
  17. package/dist/cli/session-factory.js +31 -10
  18. package/dist/config/credential-lifetime.js +42 -0
  19. package/dist/config/credentials.js +66 -0
  20. package/dist/config/schema.js +141 -9
  21. package/dist/constants.js +12 -2
  22. package/dist/errors/boundary.js +4 -4
  23. package/dist/errors/constructors.js +136 -57
  24. package/dist/errors/types.js +18 -0
  25. package/dist/index.js +27 -1
  26. package/dist/jobs/manager.js +269 -17
  27. package/dist/limits/cache.js +21 -5
  28. package/dist/mcp/client.js +16 -0
  29. package/dist/onboarding/flow.js +121 -6
  30. package/dist/onboarding/steps.js +112 -0
  31. package/dist/render/limits-report.js +213 -0
  32. package/dist/render/limits-view.js +125 -0
  33. package/dist/sandbox/service.js +9 -0
  34. package/dist/sandbox/types.js +15 -0
  35. package/dist/session/index.js +3 -1
  36. package/dist/session/list.js +20 -6
  37. package/dist/session/log.js +120 -21
  38. package/dist/session/prune.js +106 -0
  39. package/dist/session/resume.js +5 -0
  40. package/dist/subagent/orchestrator.js +71 -31
  41. package/dist/subagent/spawn-tool.js +11 -4
  42. package/dist/tools/schema-depth.js +18 -0
  43. package/dist/tui/limits-panel.js +62 -30
  44. package/dist/usage/collect.js +20 -1
  45. package/dist/usage/summary.js +48 -1
  46. package/dist/usage/types.js +27 -0
  47. package/package.json +2 -2
@@ -1,5 +1,9 @@
1
- import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
1
+ import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, ToolSchemaRejectedError, } 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
@@ -216,16 +245,32 @@ export function apiError(underlying) {
216
245
  * moment", and they point at the issue tracker with a code to quote, because a
217
246
  * report is the one action that actually moves this forward.
218
247
  *
219
- * The gateway's message names the offending tool when a tool is at fault (its
220
- * validator emits `tool "<name>": ...`), and that name is the single most useful
221
- * token in the whole error it turns "cruxy is broken" into a filed issue
222
- * someone can act on. It is lifted out of the SCRUBBED message, never the raw
223
- * one, so the U.8 gag can never be undone by this path.
248
+ * WHEN A TOOL IS AT FAULT the gateway now says so in fields rather than in
249
+ * prose. A schema over one of its caps answers `invalid_tool_schema` carrying
250
+ * `tool`, `bound` and `limit`, which the SDK reads onto
251
+ * {@link ToolSchemaRejectedError} so the tool name, the cap that was hit and
252
+ * the number to fit under are all read off the structured half of a contract
253
+ * whose `code` MAY NOT change, rather than parsed back out of English that MAY.
254
+ *
255
+ * That name is the single most useful token in the whole error: it turns "cruxy
256
+ * is broken" into a filed issue someone can act on, and `bound`/`limit` turn the
257
+ * report into one a fix can start from. All three ride in `meta` for exactly
258
+ * that reason, and all three are optional — the gateway omits them when no cap
259
+ * was reached, and an older one omits them entirely.
260
+ *
261
+ * The name is still passed through the model-name scrub before it is shown. It
262
+ * is our own harness's function name and cannot plausibly be an upstream model
263
+ * id, but the U.8 gag holding BY CONSTRUCTION on every path out of here is worth
264
+ * more than the one call it costs — this used to be true only because the name
265
+ * was cut out of an already-scrubbed string.
224
266
  */
225
267
  export function apiRequestRejected(underlying) {
226
268
  const status = underlying instanceof ApiError ? underlying.status : undefined;
227
269
  const cause = scrubbedMessageOf(underlying);
228
- const tool = toolNamedIn(cause);
270
+ const rejected = underlying instanceof ToolSchemaRejectedError ? underlying : undefined;
271
+ const tool = rejected?.tool === undefined ? undefined : scrubModelNames(rejected.tool);
272
+ const bound = rejected?.bound;
273
+ const limit = rejected?.limit;
229
274
  return new CruxyError({
230
275
  code: ErrorCode.ApiRequestRejected,
231
276
  title: tool
@@ -244,46 +289,11 @@ export function apiRequestRejected(underlying) {
244
289
  meta: {
245
290
  ...(status !== undefined ? { status } : {}),
246
291
  ...(tool !== undefined ? { tool } : {}),
292
+ ...(bound !== undefined ? { bound } : {}),
293
+ ...(limit !== undefined ? { limit } : {}),
247
294
  },
248
295
  });
249
296
  }
250
- /**
251
- * The tool name in a gateway rejection, if it named one.
252
- *
253
- * The gateway's tool-schema validator prefixes its complaint with the offending
254
- * function — `tool "apply_patch": parameters nests deeper than 8 levels` — so
255
- * one quoted token after the word `tool` is the whole pattern. Anything else
256
- * yields `undefined` and the caller falls back to generic wording: a WRONG tool
257
- * name in a bug report is worse than none, so this never guesses.
258
- *
259
- * ── TEMPORARY COUPLING, AND IT IS THE WRONG KIND ────────────────────────────
260
- *
261
- * This reads the gateway's `error` MESSAGE, and the gateway's own contract
262
- * (cruxy-ai/api, `internal/httpx/errcode.go`) says the message MAY change while
263
- * the `code` MAY NOT. So this parses the half that is explicitly allowed to move
264
- * under us — the exact coupling the code/message split exists to prevent.
265
- *
266
- * It is deliberate and bounded: today the code is the generic `invalid_request`,
267
- * shared with bad JSON, a missing field and an unknown model, so the message is
268
- * the ONLY thing distinguishing "this build's tool harness is permanently
269
- * unusable" from "this one request was malformed". The tool name is the single
270
- * most actionable token in the error and it is worth having; a regex that fails
271
- * closed is the cheapest way to have it.
272
- *
273
- * Failing closed is what makes the risk acceptable. If the gateway rewords, this
274
- * returns `undefined`, the caller drops to generic wording, and the error is
275
- * still correct — less specific, never wrong. Nothing downstream branches on it.
276
- *
277
- * The real fix is server-side and filed as cruxy-ai/api#183: a distinct 400 code
278
- * for harness-bound rejections (the `invalid_schema` precedent already exists
279
- * for `response_format`), with the tool name as a STRUCTURED FIELD rather than a
280
- * message prefix. When that lands, match on the code, read the field, and delete
281
- * this function — do not "improve" the regex.
282
- */
283
- function toolNamedIn(message) {
284
- const match = /\btool "([^"]+)"/.exec(message ?? "");
285
- return match?.[1];
286
- }
287
297
  export function apiRateLimit(underlying) {
288
298
  const retryAfterMs = underlying instanceof RateLimitError ? underlying.retryAfterMs : undefined;
289
299
  return new CruxyError({
@@ -360,25 +370,61 @@ export function budgetExhausted(underlying) {
360
370
  if (err?.miraAvailable) {
361
371
  nextSteps.push("switch to the mira tier, which stays available: `/model mira`");
362
372
  }
373
+ // WHICH CEILING BOUND. The gateway renders three of them through one body,
374
+ // separated only by `code`, so this is the one place that can tell them apart —
375
+ // and they take genuinely different advice. "Move to a higher plan" is right
376
+ // for the subscription pool and WRONG for a per-key cap: a device-login
377
+ // credential carries its own monthly ceiling stamped on it at mint, and no
378
+ // plan a user buys will raise it. Saying so anyway sends someone to spend
379
+ // money on something that cannot fix their problem.
380
+ const ceiling = err?.apiType;
381
+ const isKeyCap = ceiling === "key_spend_cap_exceeded";
382
+ const isWorkspaceCap = ceiling === "workspace_spend_cap_exceeded";
363
383
  const window = err?.window === "burst" ? "burst" : err?.window;
364
- const windowPhrase = window ? `your ${window} budget window` : "your budget";
384
+ const windowPhrase = isKeyCap
385
+ ? "this key's monthly spend cap"
386
+ : isWorkspaceCap
387
+ ? "the workspace's monthly spend cap"
388
+ : window
389
+ ? `your ${window} budget window`
390
+ : "your budget";
391
+ // "resets" for a cap (a calendar ceiling that refills on the 1st) and
392
+ // "recovers" for the pool (a window that trickles back) — each the verb that
393
+ // is actually true of the ceiling being described.
394
+ const verb = isKeyCap || isWorkspaceCap ? "reset" : "recover";
365
395
  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");
396
+ ? `wait ~${humanWait(waitMs)} — ${windowPhrase} ${verb}s then`
397
+ : `wait for ${windowPhrase} to ${verb}`);
398
+ if (isKeyCap) {
399
+ // Deliberately NOT "run `cruxy login` for a fresh key". A new credential
400
+ // would carry a new counter, which is cap evasion dressed up as
401
+ // troubleshooting and the cap exists to bound a leaked key.
402
+ nextSteps.push("or ask an org admin to raise this key's spend cap — a plan upgrade does not lift a per-key ceiling");
403
+ }
404
+ else if (isWorkspaceCap) {
405
+ nextSteps.push("or ask an org admin to raise the workspace's spend cap — a plan upgrade does not lift it");
406
+ }
407
+ else {
408
+ // Only where it is true. A subscription pool is not something a user can add
409
+ // to; a plan change is the only lever, and it is a different action from
410
+ // "top up" with a different place to do it.
411
+ nextSteps.push("or move to a higher plan for a larger allowance");
412
+ }
372
413
  return new CruxyError({
373
414
  code: ErrorCode.BudgetExhausted,
374
- title: window
375
- ? `your ${window} Cruxy budget is exhausted`
376
- : "your Cruxy budget is exhausted",
415
+ title: isKeyCap
416
+ ? "this API key's monthly spend cap is reached"
417
+ : isWorkspaceCap
418
+ ? "this workspace's monthly spend cap is reached"
419
+ : window
420
+ ? `your ${window} Cruxy budget is exhausted`
421
+ : "your Cruxy budget is exhausted",
377
422
  cause: scrubbedMessageOf(underlying),
378
423
  nextSteps,
379
424
  underlying,
380
425
  meta: err
381
426
  ? {
427
+ ...(ceiling !== undefined ? { ceiling } : {}),
382
428
  ...(err.window !== undefined ? { window: err.window } : {}),
383
429
  ...(err.resetAt !== undefined ? { resetAt: err.resetAt } : {}),
384
430
  ...(err.miraAvailable !== undefined
@@ -1502,9 +1548,18 @@ export function agentIncomplete(info) {
1502
1548
  * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
1503
1549
  * subclasses before the `ApiError` base.
1504
1550
  */
1505
- export function classifyProviderError(underlying) {
1506
- if (underlying instanceof AuthError)
1507
- return authInvalid(underlying);
1551
+ export function classifyProviderError(underlying, ctx = {}) {
1552
+ if (underlying instanceof AuthError) {
1553
+ // An expired credential and a wrong one produce the SAME 401 — the auth gate
1554
+ // refuses both without saying which, so that a prober cannot learn a key was
1555
+ // ever valid. The expiry we recorded at login is the only evidence there is,
1556
+ // and without it a user whose 90-day device login simply ran out is told to
1557
+ // go check a key they never mistyped.
1558
+ const lifetime = classifyCredentialLifetime(ctx.credentialExpiresAt?.(), ctx.now);
1559
+ return lifetime.state === "expired"
1560
+ ? authExpired(lifetime.expiresAt, underlying)
1561
+ : authInvalid(underlying);
1562
+ }
1508
1563
  if (underlying instanceof RateLimitError)
1509
1564
  return apiRateLimit(underlying);
1510
1565
  if (underlying instanceof OverloadedError)
@@ -1530,3 +1585,27 @@ export function classifyProviderError(underlying) {
1530
1585
  return apiError(underlying);
1531
1586
  return null;
1532
1587
  }
1588
+ /**
1589
+ * The typed pool denial behind an error, or `null` if it is not one.
1590
+ *
1591
+ * LIVES HERE BECAUSE IT HAS TWO CALLERS (cli#245). It was written for the
1592
+ * fan-out seam (cli#243) and is needed byte-for-byte by the background-job
1593
+ * executor, which flattened the same 429 into a message string and lost
1594
+ * `window`, `resetAt` and `miraAvailable` — the three fields that make a denial
1595
+ * actionable, and the last of which is the only one that unblocks someone now.
1596
+ * A second copy of this in `jobs/` would be two places that decide what a pool
1597
+ * denial is, and they would disagree the first time the SDK grows a class.
1598
+ *
1599
+ * Two shapes reach here and both are the same fact: the raw SDK
1600
+ * `BudgetExhaustedError` from the run's own request, and — when a caller that
1601
+ * already converted one re-throws — the {@link CruxyError} it produced. Mapping
1602
+ * goes through {@link classifyProviderError} so there is still ONE place that
1603
+ * knows which SDK class means what.
1604
+ */
1605
+ export function poolDenial(err) {
1606
+ if (CruxyError.is(err)) {
1607
+ return err.code === ErrorCode.BudgetExhausted ? err : null;
1608
+ }
1609
+ const typed = classifyProviderError(err);
1610
+ return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
1611
+ }
@@ -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
  });