@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.
- package/README.md +1 -1
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +62 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/login.js +18 -5
- package/dist/cli/commands/pr.js +10 -1
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +55 -5
- package/dist/cli/commands/sessions.js +156 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/credential-lifetime.js +42 -0
- package/dist/config/credentials.js +66 -0
- package/dist/config/schema.js +141 -9
- package/dist/constants.js +12 -2
- package/dist/errors/boundary.js +4 -4
- package/dist/errors/constructors.js +136 -57
- package/dist/errors/types.js +18 -0
- package/dist/index.js +27 -1
- package/dist/jobs/manager.js +269 -17
- package/dist/limits/cache.js +21 -5
- package/dist/mcp/client.js +16 -0
- package/dist/onboarding/flow.js +121 -6
- package/dist/onboarding/steps.js +112 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +106 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +71 -31
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +62 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +27 -0
- 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
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
|
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 =
|
|
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}
|
|
367
|
-
: `wait for ${windowPhrase} to
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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:
|
|
375
|
-
?
|
|
376
|
-
:
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/errors/types.js
CHANGED
|
@@ -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, {
|
|
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
|
});
|