@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 +1 -1
- package/dist/errors/constructors.js +94 -1
- package/dist/errors/types.js +17 -0
- package/dist/tools/file/apply-patch.js +164 -70
- package/package.json +2 -2
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;
|
package/dist/errors/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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", "
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
118
|
-
async function
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
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.
|
|
39
|
+
"@cruxy/sdk": "0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"optionalDependencies": {
|
|
42
42
|
"better-sqlite3": "^12.11.1"
|