@cruxy/cli 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -275,7 +275,7 @@ branch on them:
275
275
  | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
276
276
  | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
277
277
  | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
278
- | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
278
+ | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_REQUEST_REJECTED`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
279
279
  | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`, `CRUXY_E_CHECKPOINT_FAILED` |
280
280
  | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
281
281
  | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
@@ -1,4 +1,4 @@
1
- import { ApiError, AuthError, BudgetExhaustedError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
1
+ import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
2
2
  import { scrubModelNames } from "../brand/index.js";
3
3
  import { CruxyError, ErrorCode } from "./types.js";
4
4
  /**
@@ -199,6 +199,91 @@ export function apiError(underlying) {
199
199
  meta: status ? { status } : undefined,
200
200
  });
201
201
  }
202
+ /**
203
+ * The gateway REJECTED what we sent (400/422) — it never reached a model.
204
+ *
205
+ * Everything else `classifyProviderError` produces says, in one wording or
206
+ * another, "wait and try again", because everything else IS a condition that
207
+ * passes. This one is not, and saying so is the entire point. The gateway parsed
208
+ * the request, found it invalid, and answered before any upstream call. The
209
+ * payload is built from this build's own code — the tool harness, the message
210
+ * shaping — so it is deterministic: a retry sends byte-identical content and
211
+ * earns a byte-identical refusal. There is no outage, nothing recovers on a
212
+ * clock, and no amount of patience helps.
213
+ *
214
+ * It follows that this is a DEFECT IN CRUXY and the only thing that fixes it is
215
+ * a code change. So the next steps say that instead of a soothing "retry in a
216
+ * moment", and they point at the issue tracker with a code to quote, because a
217
+ * report is the one action that actually moves this forward.
218
+ *
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.
224
+ */
225
+ export function apiRequestRejected(underlying) {
226
+ const status = underlying instanceof ApiError ? underlying.status : undefined;
227
+ const cause = scrubbedMessageOf(underlying);
228
+ const tool = toolNamedIn(cause);
229
+ return new CruxyError({
230
+ code: ErrorCode.ApiRequestRejected,
231
+ title: tool
232
+ ? `the provider rejected this request — the \`${tool}\` tool definition cruxy sent is invalid`
233
+ : "the provider rejected this request — cruxy sent something invalid",
234
+ cause,
235
+ nextSteps: [
236
+ // First, because it pre-empts the reflex the other API errors trained.
237
+ "retrying will NOT help: the same request would be sent again and refused identically",
238
+ "this is a defect in cruxy, not a provider outage — nothing recovers on its own",
239
+ tool
240
+ ? `report it at ${ISSUE_URL} with this code and the tool name \`${tool}\``
241
+ : `report it at ${ISSUE_URL} with this code and the cause line above`,
242
+ ],
243
+ underlying,
244
+ meta: {
245
+ ...(status !== undefined ? { status } : {}),
246
+ ...(tool !== undefined ? { tool } : {}),
247
+ },
248
+ });
249
+ }
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
+ }
202
287
  export function apiRateLimit(underlying) {
203
288
  const retryAfterMs = underlying instanceof RateLimitError ? underlying.retryAfterMs : undefined;
204
289
  return new CruxyError({
@@ -1433,6 +1518,14 @@ export function classifyProviderError(underlying) {
1433
1518
  if (underlying instanceof BudgetExhaustedError) {
1434
1519
  return budgetExhausted(underlying);
1435
1520
  }
1521
+ // Also before the `ApiError` base. Without this arm a rejected request lands
1522
+ // on the generic `apiError`, whose only next step is "retry in a moment; if it
1523
+ // persists, check the provider's status" — advice that is wrong twice over for
1524
+ // a 400: retrying re-sends the identical payload, and the provider's status
1525
+ // page has nothing to say about a request it correctly refused.
1526
+ if (underlying instanceof InvalidRequestError) {
1527
+ return apiRequestRejected(underlying);
1528
+ }
1436
1529
  if (underlying instanceof ApiError)
1437
1530
  return apiError(underlying);
1438
1531
  return null;
@@ -44,6 +44,18 @@ export const ErrorCode = {
44
44
  GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
45
45
  // api (exit 6)
46
46
  Api: "CRUXY_E_API",
47
+ /**
48
+ * The gateway REJECTED the request (400/422) rather than failing to serve it.
49
+ *
50
+ * A DISTINCT CODE from {@link Api} because the two are opposite kinds of fact
51
+ * and take opposite advice. `Api` covers a provider that could not serve a
52
+ * valid request — a condition, which passes, so "retry in a moment" is sound.
53
+ * This one covers a request the gateway parsed, judged invalid, and refused
54
+ * before any upstream call. The payload is deterministic: a retry sends
55
+ * identical bytes and earns an identical refusal. Telling someone to wait out
56
+ * a rejection is telling them to wait for a defect in cruxy to fix itself.
57
+ */
58
+ ApiRequestRejected: "CRUXY_E_API_REQUEST_REJECTED",
47
59
  ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
48
60
  ApiOverloaded: "CRUXY_E_API_OVERLOADED",
49
61
  BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
@@ -289,6 +301,11 @@ const EXIT_CODES = {
289
301
  [ErrorCode.GatewayUnreachable]: 5,
290
302
  [ErrorCode.GitPushFailed]: 5,
291
303
  [ErrorCode.Api]: 6,
304
+ // Still the API exit class: the failure arrived from the gateway, and a script
305
+ // wrapping cruxy should treat it the same way it treats any other API failure.
306
+ // The difference this code carries is what a HUMAN should do about it, which
307
+ // is the next steps, not the exit status.
308
+ [ErrorCode.ApiRequestRejected]: 6,
292
309
  [ErrorCode.ApiRateLimit]: 6,
293
310
  [ErrorCode.ApiOverloaded]: 6,
294
311
  [ErrorCode.BudgetExhausted]: 6,
@@ -5,34 +5,93 @@ import { resolveToolPath, toPosix } from "./paths.js";
5
5
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
6
  /** How many leading lines of a created file the approval preview shows. */
7
7
  const PREVIEW_LINES = 20;
8
- const HunkSchema = z.object({
8
+ /**
9
+ * ONE FLAT OPERATION SHAPE — deliberately flat, and it must stay that way.
10
+ *
11
+ * The provider rejects a tool schema nested 8 or more levels deep, and it
12
+ * rejects the entire REQUEST when any tool trips it: one over-deep schema kills
13
+ * every turn of every session, which is exactly how 1.7.0 died in the field.
14
+ * JSON Schema's own wrapper keys (`properties`, `items`, `anyOf`) are containers
15
+ * too, so every semantic level an author writes costs two, and a union costs two
16
+ * more on top. The previous shape — a discriminated union of operations, each
17
+ * carrying an array of hunk OBJECTS — rendered 11 levels deep.
18
+ *
19
+ * Neither lever alone was enough (both measured, see schema-depth.test.ts):
20
+ * keeping the union and flattening hunks still renders 8; dropping the union and
21
+ * keeping the hunk array renders 9. So both go. The discriminator survives as a
22
+ * plain `type` enum, and the per-variant field requirements the union used to
23
+ * encode are enforced by {@link refineOperation} below — `superRefine` is a
24
+ * runtime check that adds NO depth to the rendered schema, so the model still
25
+ * gets a precise rejection for a malformed operation, just from zod instead of
26
+ * from the schema's shape.
27
+ *
28
+ * The multi-hunk capability is NOT lost: a path may appear in as many `update`
29
+ * operations as it likes, and they apply in order against the running content —
30
+ * the same semantics the `hunks` array had, spelled one hunk per operation.
31
+ */
32
+ const OperationSchema = z
33
+ .object({
34
+ type: z
35
+ .enum(["update", "create", "delete"])
36
+ .describe('What to do: "update" replaces oldStr with newStr in an existing file; ' +
37
+ '"create" writes a new file from content; "delete" removes an existing file.'),
38
+ path: z.string().describe("Path to the file, relative to the root."),
9
39
  oldStr: z
10
40
  .string()
11
41
  .min(1)
12
- .describe("Exact text to replace; must occur exactly once in the file."),
13
- newStr: z.string().describe("Replacement text."),
14
- });
15
- const OperationSchema = z.discriminatedUnion("type", [
16
- z.object({
17
- type: z.literal("update"),
18
- path: z
19
- .string()
20
- .describe("Path to an existing file, relative to the root."),
21
- hunks: z
22
- .array(HunkSchema)
23
- .min(1)
24
- .describe("Edits applied in order; each oldStr must match exactly once."),
25
- }),
26
- z.object({
27
- type: z.literal("create"),
28
- path: z.string().describe("Path for a new file; must not already exist."),
29
- content: z.string().describe("Full UTF-8 contents of the new file."),
30
- }),
31
- z.object({
32
- type: z.literal("delete"),
33
- path: z.string().describe("Path to an existing file to delete."),
34
- }),
35
- ]);
42
+ .optional()
43
+ .describe("update only — exact text to replace; must occur exactly once in the " +
44
+ "file as it stands when this operation runs."),
45
+ newStr: z
46
+ .string()
47
+ .optional()
48
+ .describe("update only — the replacement text (may be empty to delete)."),
49
+ content: z
50
+ .string()
51
+ .optional()
52
+ .describe("create only — full UTF-8 contents of the new file."),
53
+ })
54
+ .superRefine(refineOperation);
55
+ /**
56
+ * The per-variant requirements the discriminated union used to express in the
57
+ * schema. Enforced here so a malformed operation is still rejected before
58
+ * `execute` runs, with a message naming the exact field.
59
+ */
60
+ function refineOperation(op, ctx) {
61
+ const needs = (field) => {
62
+ if (op[field] === undefined) {
63
+ ctx.addIssue({
64
+ code: z.ZodIssueCode.custom,
65
+ path: [field],
66
+ message: `"${field}" is required when type is "${op.type}"`,
67
+ });
68
+ }
69
+ };
70
+ const forbid = (field) => {
71
+ if (op[field] !== undefined) {
72
+ ctx.addIssue({
73
+ code: z.ZodIssueCode.custom,
74
+ path: [field],
75
+ message: `"${field}" is not allowed when type is "${op.type}"`,
76
+ });
77
+ }
78
+ };
79
+ if (op.type === "update") {
80
+ needs("oldStr");
81
+ needs("newStr");
82
+ forbid("content");
83
+ return;
84
+ }
85
+ if (op.type === "create") {
86
+ needs("content");
87
+ forbid("oldStr");
88
+ forbid("newStr");
89
+ return;
90
+ }
91
+ forbid("oldStr");
92
+ forbid("newStr");
93
+ forbid("content");
94
+ }
36
95
  const parameters = z.object({
37
96
  operations: z
38
97
  .array(OperationSchema)
@@ -50,14 +109,19 @@ export const applyPatchTool = {
50
109
  name: "apply_patch",
51
110
  description: "Apply multiple edits across one or more files in a single, atomic, reviewed change — preferred over many edit_file calls for multi-file or multi-hunk work. " +
52
111
  "Input is { operations: [...] } where each operation is one of: " +
53
- '{ "type":"update", "path", "hunks":[{ "oldStr", "newStr" }] } — replace each oldStr (which must match EXACTLY ONCE in the file, like edit_file; hunks apply in order) with newStr; ' +
112
+ '{ "type":"update", "path", "oldStr", "newStr" } — replace oldStr (which must match EXACTLY ONCE in the file, like edit_file) with newStr; ' +
54
113
  '{ "type":"create", "path", "content" } — create a new file (must not already exist); ' +
55
114
  '{ "type":"delete", "path" } — delete an existing file. ' +
115
+ "To make several edits to the SAME file, list several update operations with the same path: they apply in order, each one matching against the result of the previous. " +
116
+ "A path used by a create or a delete may appear only once. " +
56
117
  "The whole patch is validated before anything is written: if any operation is invalid, nothing is applied and the failing operation is reported.",
57
118
  parameters,
58
119
  async execute(input, ctx) {
59
- const planned = [];
60
- const seen = new Set();
120
+ // One track per path, in first-touch order. Repeated `update` operations on
121
+ // a path fold into its track, each hunk matching against the running content
122
+ // — so a file is still written exactly once, from one final byte string.
123
+ const tracks = new Map();
124
+ const order = [];
61
125
  for (let i = 0; i < input.operations.length; i++) {
62
126
  const op = input.operations[i];
63
127
  let abs;
@@ -67,18 +131,29 @@ export const applyPatchTool = {
67
131
  catch (err) {
68
132
  return { ok: false, error: opError(i, op, err.message) };
69
133
  }
70
- if (seen.has(abs)) {
71
- return {
72
- ok: false,
73
- error: opError(i, op, "duplicate path in patch"),
74
- };
134
+ const existing = tracks.get(abs);
135
+ if (existing) {
136
+ // Only an update chain may share a path. A create or a delete alongside
137
+ // anything else on the same path is an order-dependent muddle, and the
138
+ // shape that preceded this one couldn't express it either.
139
+ if (existing.kind !== "update" || op.type !== "update") {
140
+ return {
141
+ ok: false,
142
+ error: opError(i, op, `path already used by operation ${existing.firstOp + 1}; only repeated "update" operations may share a path`),
143
+ };
144
+ }
145
+ const failure = applyHunk(i, op, existing);
146
+ if (failure)
147
+ return { ok: false, error: failure };
148
+ continue;
75
149
  }
76
- seen.add(abs);
77
- const planResult = await planOp(i, op, abs, ctx);
78
- if (!planResult.ok)
79
- return planResult;
80
- planned.push(planResult.planned);
150
+ const opened = await openTrack(i, op, abs, ctx);
151
+ if (!opened.ok)
152
+ return opened;
153
+ tracks.set(abs, opened.track);
154
+ order.push(abs);
81
155
  }
156
+ const planned = order.map((abs) => toPlanned(tracks.get(abs)));
82
157
  // One approval for the whole patch — denial writes nothing.
83
158
  const decision = await ctx.requestApproval({
84
159
  kind: "patch",
@@ -114,27 +189,31 @@ export const applyPatchTool = {
114
189
  return { ok: true, output: `applied patch:\n${applied.join("\n")}` };
115
190
  },
116
191
  };
117
- /** Validate one operation against the filesystem and compute its final bytes. */
118
- async function planOp(i, op, abs, ctx) {
192
+ /** Validate the FIRST operation on a path and open its track. */
193
+ async function openTrack(i, op, abs, ctx) {
119
194
  // Forward-slash for model-facing output (the `applied` lines and error
120
195
  // messages), consistent with every other path tool — see {@link toPosix}.
121
196
  const rel = toPosix(path.relative(ctx.cwd, abs));
197
+ const base = { abs, rel, firstOp: i, hunks: [] };
122
198
  if (op.type === "create") {
123
199
  if (await exists(abs)) {
124
200
  return { ok: false, error: opError(i, op, "file already exists") };
125
201
  }
202
+ const content = op.content ?? "";
126
203
  return {
127
204
  ok: true,
128
- planned: { op: "create", abs, rel, content: op.content },
205
+ track: { ...base, kind: "create", content, eol: detectEol(content) },
129
206
  };
130
207
  }
131
208
  if (op.type === "delete") {
132
209
  if (!(await exists(abs))) {
133
210
  return { ok: false, error: opError(i, op, "file not found") };
134
211
  }
135
- return { ok: true, planned: { op: "delete", abs, rel } };
212
+ return {
213
+ ok: true,
214
+ track: { ...base, kind: "delete", content: "", eol: "\n" },
215
+ };
136
216
  }
137
- // update: read, then apply each hunk in order against the running content.
138
217
  let content;
139
218
  try {
140
219
  content = await fs.readFile(abs, "utf8");
@@ -145,34 +224,49 @@ async function planOp(i, op, abs, ctx) {
145
224
  }
146
225
  return { ok: false, error: opError(i, op, err.message) };
147
226
  }
148
- // Detect the file's line ending once, from the original bytes, so every hunk
149
- // re-encodes newStr to the same convention as content mutates across hunks.
150
- const fileEol = detectEol(content);
151
- for (let h = 0; h < op.hunks.length; h++) {
152
- const { oldStr, newStr } = op.hunks[h];
153
- const match = findMatch(content, oldStr);
154
- if (match.kind === "none") {
155
- return {
156
- ok: false,
157
- error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
158
- };
159
- }
160
- if (match.kind === "ambiguous") {
161
- return {
162
- ok: false,
163
- error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${match.count} matches${tierLabel(match.tier)})`),
164
- };
165
- }
166
- // Splice by offset so `$` patterns in newStr aren't interpreted.
167
- content =
168
- content.slice(0, match.start) +
169
- applyEol(newStr, fileEol) +
170
- content.slice(match.end);
171
- }
172
- return {
173
- ok: true,
174
- planned: { op: "update", abs, rel, content, hunks: op.hunks },
227
+ const track = {
228
+ ...base,
229
+ kind: "update",
230
+ content,
231
+ eol: detectEol(content),
175
232
  };
233
+ const failure = applyHunk(i, op, track);
234
+ return failure ? { ok: false, error: failure } : { ok: true, track };
235
+ }
236
+ /**
237
+ * Apply one update operation's hunk to its track's running content. Returns an
238
+ * error string on failure, or `undefined` on success (the track is mutated).
239
+ */
240
+ function applyHunk(i, op, track) {
241
+ const { oldStr, newStr } = op;
242
+ // Guaranteed present by `refineOperation`; re-checked so the narrowing is
243
+ // structural rather than a cast, and a schema regression fails loud.
244
+ if (oldStr === undefined || newStr === undefined) {
245
+ return opError(i, op, 'update requires both "oldStr" and "newStr"');
246
+ }
247
+ const match = findMatch(track.content, oldStr);
248
+ if (match.kind === "none") {
249
+ return opError(i, op, "oldStr not found");
250
+ }
251
+ if (match.kind === "ambiguous") {
252
+ return opError(i, op, `oldStr not unique (${match.count} matches${tierLabel(match.tier)})`);
253
+ }
254
+ // Splice by offset so `$` patterns in newStr aren't interpreted.
255
+ track.content =
256
+ track.content.slice(0, match.start) +
257
+ applyEol(newStr, track.eol) +
258
+ track.content.slice(match.end);
259
+ track.hunks.push({ oldStr, newStr });
260
+ return undefined;
261
+ }
262
+ /** Collapse a finished track into the single write it represents. */
263
+ function toPlanned(track) {
264
+ const { kind, abs, rel, content, hunks } = track;
265
+ if (kind === "delete")
266
+ return { op: "delete", abs, rel };
267
+ if (kind === "create")
268
+ return { op: "create", abs, rel, content };
269
+ return { op: "update", abs, rel, content, hunks };
176
270
  }
177
271
  /** Shape a planned op into its approval-preview form. */
178
272
  function toPreview(p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
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.5.0"
39
+ "@cruxy/sdk": "0.6.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"