s-app-scoder-agent 0.9.3 → 0.9.7
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 +615 -638
- package/dist/{guardrails.js → agent/guardrails.js} +1 -1
- package/dist/agent/interaction.js +70 -0
- package/dist/agent/mcp-bridge.js +136 -0
- package/dist/agent/prompt-context.js +83 -0
- package/dist/agent/run-loop.js +318 -0
- package/dist/agent/session-persistence.js +61 -0
- package/dist/agent/tool-executor.js +158 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/verification.js +99 -0
- package/dist/agent.js +239 -1002
- package/dist/cli/args.js +230 -0
- package/dist/cli/init.js +79 -0
- package/dist/cli/main.js +211 -0
- package/dist/cli/mcp-command.js +185 -0
- package/dist/cli/repl.js +77 -0
- package/dist/cli/run-once.js +26 -0
- package/dist/{tui/commands.js → commands/definitions.js} +6 -16
- package/dist/commands/registry.js +260 -0
- package/dist/{config.js → config/config.js} +1 -1
- package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
- package/dist/{prompt.js → context/prompt.js} +111 -9
- package/dist/{skills.js → context/skills.js} +41 -2
- package/dist/index.js +5 -798
- package/dist/io/console-reporter.js +24 -0
- package/dist/io/reporter.js +1 -0
- package/dist/mcp/connection.js +227 -0
- package/dist/mcp/env.js +8 -0
- package/dist/{mcp.js → mcp/manager.js} +3 -515
- package/dist/mcp/sse.js +28 -0
- package/dist/mcp/transport-http.js +140 -0
- package/dist/mcp/transport-stdio.js +98 -0
- package/dist/mcp/types.js +11 -0
- package/dist/media/image.js +149 -0
- package/dist/{server.js → server/server.js} +1 -1
- package/dist/{cost.js → session/cost.js} +1 -1
- package/dist/{session.js → session/session.js} +1 -1
- package/dist/{version.js → shared/version.js} +1 -1
- package/dist/tools/apply-patch.js +62 -0
- package/dist/tools/edit-file.js +29 -0
- package/dist/tools/file-edit-common.js +124 -0
- package/dist/tools/list-files.js +64 -0
- package/dist/tools/path-safety.js +75 -0
- package/dist/tools/read-file.js +64 -0
- package/dist/tools/registry.js +297 -0
- package/dist/tools/run-command.js +192 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/search.js +237 -0
- package/dist/tools/task-tool.js +48 -0
- package/dist/tools/types.js +1 -0
- package/dist/tools/write-file.js +34 -0
- package/dist/tui/app.js +59 -532
- package/dist/tui/ask-user-modal.js +2 -1
- package/dist/tui/at-complete.js +2 -93
- package/dist/tui/charset.js +41 -0
- package/dist/tui/chat-views.js +72 -16
- package/dist/tui/chrome.js +11 -5
- package/dist/tui/hooks/use-command-host.js +26 -0
- package/dist/tui/hooks/use-event-queue.js +69 -0
- package/dist/tui/hooks/use-keybinds.js +87 -0
- package/dist/tui/hooks/use-scroll.js +38 -0
- package/dist/tui/hooks/use-session-resume.js +47 -0
- package/dist/tui/hooks/use-submit.js +106 -0
- package/dist/tui/image.js +2 -149
- package/dist/tui/input.js +1 -1
- package/dist/tui/markdown/ast.js +5 -0
- package/dist/tui/markdown/diagram.js +239 -0
- package/dist/tui/markdown/inline-render.js +24 -0
- package/dist/tui/markdown/inline.js +93 -0
- package/dist/tui/markdown/parse.js +139 -0
- package/dist/tui/markdown/render.js +146 -0
- package/dist/tui/markdown/table.js +106 -0
- package/dist/tui/markdown.js +32 -379
- package/dist/tui/permission-modal.js +2 -1
- package/dist/tui/selectors.js +157 -0
- package/dist/tui/session-picker.js +4 -3
- package/dist/tui/sidebar.js +130 -15
- package/dist/tui/state.js +47 -118
- package/dist/tui/text-width.js +139 -0
- package/dist/tui/theme.js +7 -2
- package/dist/{build.js → workspace/build.js} +1 -1
- package/dist/workspace/file-index.js +93 -0
- package/package.json +5 -1
- package/dist/tools.js +0 -1223
- package/dist/tui/command-registry.js +0 -477
- package/dist/tui/review.js +0 -64
- package/dist/tui/transcript.js +0 -145
- /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
- /package/dist/{agents.js → context/agents.js} +0 -0
- /package/dist/{compaction.js → context/compaction.js} +0 -0
- /package/dist/{context.js → context/context.js} +0 -0
- /package/dist/{memory.js → context/memory.js} +0 -0
- /package/dist/{repo-map.js → context/repo-map.js} +0 -0
- /package/dist/{ui.js → io/terminal.js} +0 -0
- /package/dist/{llm.js → llm/llm.js} +0 -0
- /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
- /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
- /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
- /package/dist/{retry.js → llm/retry.js} +0 -0
- /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
- /package/dist/{permissions.js → security/permissions.js} +0 -0
- /package/dist/{sandbox.js → security/sandbox.js} +0 -0
- /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
- /package/dist/{logger.js → session/logger.js} +0 -0
- /package/dist/{steering.js → session/steering.js} +0 -0
- /package/dist/{constants.js → shared/constants.js} +0 -0
- /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
- /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
- /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
- /package/dist/{diff.js → workspace/diff.js} +0 -0
- /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
- /package/dist/{watch.js → workspace/watch.js} +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { safePath, clamp, toPosInt, toResult } from "./path-safety.js";
|
|
5
|
+
const SEARCH_SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
6
|
+
const MAX_SEARCH_HITS = 50;
|
|
7
|
+
/** Walk files under root (bounded depth, skip noise dirs), collect relative paths. */
|
|
8
|
+
function walkFiles(root, cb, depth = 8) {
|
|
9
|
+
if (depth <= 0)
|
|
10
|
+
return;
|
|
11
|
+
let entries;
|
|
12
|
+
try {
|
|
13
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
if (SEARCH_SKIP.has(e.name))
|
|
20
|
+
continue;
|
|
21
|
+
const abs = join(root, e.name);
|
|
22
|
+
if (e.isDirectory())
|
|
23
|
+
walkFiles(abs, cb, depth - 1);
|
|
24
|
+
else if (e.isFile())
|
|
25
|
+
cb(e.name, abs);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** ripgrep if on PATH; else grep -rn; else JS line-scan fallback. Returns formatted hits. */
|
|
29
|
+
export function toolGrep(workdir, args) {
|
|
30
|
+
return toResult(() => {
|
|
31
|
+
const pattern = String(args.pattern ?? "").trim();
|
|
32
|
+
if (!pattern)
|
|
33
|
+
return { ok: false, output: "ERROR: pattern is required." };
|
|
34
|
+
const root = safePath(workdir, args.path ?? ".");
|
|
35
|
+
// v2: context lines + hit cap — before/after look like ripgrep -B/-A.
|
|
36
|
+
const ctxBefore = Math.min(toPosInt(args.context_before, 0), 20);
|
|
37
|
+
const ctxAfter = Math.min(toPosInt(args.context_after, 0), 20);
|
|
38
|
+
const maxResults = Math.min(toPosInt(args.max_results, MAX_SEARCH_HITS), 100);
|
|
39
|
+
// Fast path: ripgrep (respects .gitignore, fast, JSON output). Skip
|
|
40
|
+
// common noise dirs even when the project has no .gitignore.
|
|
41
|
+
const rgArgs = [
|
|
42
|
+
"--json", "--max-count", "5", "-e", pattern, root,
|
|
43
|
+
// rg globs match the printed (absolute) path — bare dir name excludes it
|
|
44
|
+
// anywhere in the tree; ** forms do NOT match absolute-path matches.
|
|
45
|
+
"--glob", "!node_modules", "--glob", "!.git", "--glob", "!dist",
|
|
46
|
+
"--glob", "!build", "--glob", "!.next", "--glob", "!__pycache__",
|
|
47
|
+
"--glob", "!.venv",
|
|
48
|
+
];
|
|
49
|
+
if (ctxBefore > 0)
|
|
50
|
+
rgArgs.push("-B", String(ctxBefore));
|
|
51
|
+
if (ctxAfter > 0)
|
|
52
|
+
rgArgs.push("-A", String(ctxAfter));
|
|
53
|
+
if (args.include)
|
|
54
|
+
rgArgs.push("-g", args.include);
|
|
55
|
+
if (args.type)
|
|
56
|
+
rgArgs.push("--type", String(args.type));
|
|
57
|
+
const rg = spawnSync("rg", rgArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
58
|
+
const lines = [];
|
|
59
|
+
let count = 0;
|
|
60
|
+
if ((rg.status ?? 2) === 0 || rg.stdout) {
|
|
61
|
+
let prevLine = -1;
|
|
62
|
+
let prevRel = "";
|
|
63
|
+
for (const line of rg.stdout.split("\n")) {
|
|
64
|
+
if (!line)
|
|
65
|
+
continue;
|
|
66
|
+
if (count >= maxResults)
|
|
67
|
+
break;
|
|
68
|
+
try {
|
|
69
|
+
const j = JSON.parse(line);
|
|
70
|
+
if (!j.data || (j.type !== "match" && j.type !== "context"))
|
|
71
|
+
continue;
|
|
72
|
+
const rel = relative(workdir, j.data.path?.text ?? "").split(sep).join("/");
|
|
73
|
+
const ln = j.data.line_number ?? 0;
|
|
74
|
+
const text = (j.data.lines?.text ?? "").trimEnd().slice(0, 200);
|
|
75
|
+
// GNU-style: match lines keep ':' separators, context lines use '-'.
|
|
76
|
+
if (rel !== prevRel || (prevLine >= 0 && ln > prevLine + 1)) {
|
|
77
|
+
if (lines.length > 0)
|
|
78
|
+
lines.push("--");
|
|
79
|
+
}
|
|
80
|
+
lines.push(`${rel}${j.type === "match" ? ":" : "-"}${ln}${j.type === "match" ? ":" : "-"} ${text}`);
|
|
81
|
+
if (j.type === "match")
|
|
82
|
+
count++;
|
|
83
|
+
prevLine = ln;
|
|
84
|
+
prevRel = rel;
|
|
85
|
+
}
|
|
86
|
+
catch { /* skip malformed */ }
|
|
87
|
+
}
|
|
88
|
+
if ((rg.status ?? 2) <= 1) {
|
|
89
|
+
return { ok: true, output: lines.length > 0 ? clamp(lines.join("\n")) : "(no matches)" };
|
|
90
|
+
}
|
|
91
|
+
// status >= 2 → rg error; fall through to grep fallback.
|
|
92
|
+
}
|
|
93
|
+
// Fallback: grep -rn (portable) — output already path:line:text.
|
|
94
|
+
const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
|
|
95
|
+
if (ctxBefore > 0)
|
|
96
|
+
gArgs.push("-B", String(ctxBefore));
|
|
97
|
+
if (ctxAfter > 0)
|
|
98
|
+
gArgs.push("-A", String(ctxAfter));
|
|
99
|
+
gArgs.push(root);
|
|
100
|
+
const g = spawnSync("grep", gArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
101
|
+
if ((g.status ?? 2) <= 1) {
|
|
102
|
+
const hits = (g.stdout ?? "").split("\n").filter(Boolean);
|
|
103
|
+
const out = [];
|
|
104
|
+
let matches = 0;
|
|
105
|
+
for (const l of hits) {
|
|
106
|
+
// Context lines GNU-separated with '-'; matches with ':'.
|
|
107
|
+
const isMatch = /^[^:]*:\d+:/.test(l);
|
|
108
|
+
if (isMatch && matches >= maxResults)
|
|
109
|
+
continue;
|
|
110
|
+
if (isMatch)
|
|
111
|
+
matches++;
|
|
112
|
+
out.push(l.startsWith(root) ? relative(workdir, root).split(sep).join("/") + l.slice(root.length) : l);
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(no matches)" };
|
|
115
|
+
}
|
|
116
|
+
// rg vắng mặt + grep vắng mặt/lỗi → scan JS thuần (luôn chạy được, không cần binary).
|
|
117
|
+
return jsGrepFallback(workdir, root, pattern, args.include, ctxBefore, ctxAfter, maxResults);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fallback cuối: scan dòng thuần JS (không cần rg/grep). Kém tối ưu hơn rg
|
|
122
|
+
* (không tôn trọng .gitignore ngoài SEARCH_SKIP, không lazy) nhưng luôn chạy
|
|
123
|
+
* — quan trọng trên Windows máy chưa cài ripgrep. Pattern là regex JS; regex
|
|
124
|
+
* không hợp lệ → coi như literal substring.
|
|
125
|
+
*/
|
|
126
|
+
function jsGrepFallback(workdir, root, pattern, include, ctxBefore = 0, ctxAfter = 0, maxResults = MAX_SEARCH_HITS) {
|
|
127
|
+
let rx;
|
|
128
|
+
try {
|
|
129
|
+
rx = new RegExp(pattern);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
rx = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
133
|
+
}
|
|
134
|
+
const includeRx = include
|
|
135
|
+
? globToRegExp(include.includes("/") ? include : `**/${include}`)
|
|
136
|
+
: null;
|
|
137
|
+
const hits = [];
|
|
138
|
+
let matches = 0;
|
|
139
|
+
walkFiles(root, (_name, abs) => {
|
|
140
|
+
if (matches >= maxResults)
|
|
141
|
+
return;
|
|
142
|
+
const rel = relative(workdir, abs).split(sep).join("/");
|
|
143
|
+
if (includeRx && !includeRx.test(rel))
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
const st = statSync(abs);
|
|
147
|
+
if (st.size > 2 * 1024 * 1024)
|
|
148
|
+
return; // skip file quá lớn
|
|
149
|
+
const buf = readFileSync(abs);
|
|
150
|
+
if (buf.includes(0))
|
|
151
|
+
return; // skip binary (NUL byte)
|
|
152
|
+
const lines = buf.toString("utf8").split("\n");
|
|
153
|
+
// Collect matching line numbers, then emit merged windows with context.
|
|
154
|
+
const matchIdx = [];
|
|
155
|
+
for (let i = 0; i < lines.length && matchIdx.length < 5; i++) {
|
|
156
|
+
if (rx.test(lines[i]))
|
|
157
|
+
matchIdx.push(i);
|
|
158
|
+
}
|
|
159
|
+
if (matchIdx.length === 0)
|
|
160
|
+
return;
|
|
161
|
+
// Merge overlapping windows.
|
|
162
|
+
const windows = [];
|
|
163
|
+
for (const i of matchIdx) {
|
|
164
|
+
const lo = Math.max(0, i - ctxBefore), hi = Math.min(lines.length - 1, i + ctxAfter);
|
|
165
|
+
const last = windows[windows.length - 1];
|
|
166
|
+
if (last && lo <= last[1] + 1)
|
|
167
|
+
last[1] = Math.max(last[1], hi);
|
|
168
|
+
else
|
|
169
|
+
windows.push([lo, hi]);
|
|
170
|
+
}
|
|
171
|
+
for (const [lo, hi] of windows) {
|
|
172
|
+
if (hits.length > 0 && hits[hits.length - 1] !== "--")
|
|
173
|
+
hits.push("--");
|
|
174
|
+
for (let i = lo; i <= hi; i++) {
|
|
175
|
+
const isMatch = matchIdx.includes(i);
|
|
176
|
+
if (isMatch && matches >= maxResults)
|
|
177
|
+
break;
|
|
178
|
+
hits.push(`${rel}${isMatch ? ":" : "-"}${i + 1}${isMatch ? ":" : "-"} ${lines[i].trimEnd().slice(0, 200)}`);
|
|
179
|
+
if (isMatch)
|
|
180
|
+
matches++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* unreadable — skip */ }
|
|
185
|
+
});
|
|
186
|
+
return { ok: true, output: hits.length > 0 ? clamp(hits.join("\n")) : "(no matches)" };
|
|
187
|
+
}
|
|
188
|
+
/** Mini glob: supports **, *, ?, {a,b}. Implemented over walkFiles (no deps). */
|
|
189
|
+
export function globToRegExp(pattern) {
|
|
190
|
+
let rx = "";
|
|
191
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
192
|
+
const c = pattern[i];
|
|
193
|
+
if (c === "*") {
|
|
194
|
+
if (pattern[i + 1] === "*") {
|
|
195
|
+
rx += ".*";
|
|
196
|
+
i++;
|
|
197
|
+
if (pattern[i + 1] === "/")
|
|
198
|
+
i++; // `**/` also matches zero dirs
|
|
199
|
+
}
|
|
200
|
+
else
|
|
201
|
+
rx += "[^/]*";
|
|
202
|
+
}
|
|
203
|
+
else if (c === "?")
|
|
204
|
+
rx += "[^/]";
|
|
205
|
+
else if (c === "{") {
|
|
206
|
+
// {a,b} → (a|b)
|
|
207
|
+
const end = pattern.indexOf("}", i);
|
|
208
|
+
const inner = pattern.slice(i + 1, end).replace(/,/g, "|");
|
|
209
|
+
rx += `(${inner})`;
|
|
210
|
+
i = end;
|
|
211
|
+
}
|
|
212
|
+
else
|
|
213
|
+
rx += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
214
|
+
}
|
|
215
|
+
return new RegExp(`^${rx}$`);
|
|
216
|
+
}
|
|
217
|
+
export function toolGlob(workdir, args) {
|
|
218
|
+
return toResult(() => {
|
|
219
|
+
const pattern = String(args.pattern ?? "").trim();
|
|
220
|
+
if (!pattern)
|
|
221
|
+
return { ok: false, output: "ERROR: pattern is required (e.g. 'src/**/*.ts')." };
|
|
222
|
+
const root = safePath(workdir, args.path ?? ".");
|
|
223
|
+
const rx = globToRegExp(pattern);
|
|
224
|
+
const out = [];
|
|
225
|
+
walkFiles(root, (_name, abs) => {
|
|
226
|
+
// Pattern dùng '/' — normalize separator của OS (Windows trả '\') để khớp.
|
|
227
|
+
const rel = relative(workdir, abs).split(sep).join("/");
|
|
228
|
+
if (rx.test(rel) && out.length < MAX_SEARCH_HITS * 4)
|
|
229
|
+
out.push(rel);
|
|
230
|
+
});
|
|
231
|
+
out.sort();
|
|
232
|
+
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(no files match)" };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Formatter post-edit (I-11)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { obj, str } from "./schemas.js";
|
|
2
|
+
export const taskTool = {
|
|
3
|
+
spec: {
|
|
4
|
+
type: "function",
|
|
5
|
+
function: {
|
|
6
|
+
name: "task",
|
|
7
|
+
description: "Delegate self-contained subtasks to subagents that run their own agent loop with their own context. " +
|
|
8
|
+
"WHEN TO USE: broad codebase exploration (find where X lives, summarize module Y) so the main " +
|
|
9
|
+
"conversation stays lean, or independent subtasks that need many steps. " +
|
|
10
|
+
"SINGLE: pass description + prompt. BATCH: pass tasks[] to run up to 8 subtasks concurrently " +
|
|
11
|
+
"(bounded at 4 in flight) and get one merged result. " +
|
|
12
|
+
"Every prompt must be self-contained (the subagent sees nothing else); each returns a summary. " +
|
|
13
|
+
"Prefer this over reading dozens of files yourself.",
|
|
14
|
+
parameters: obj({
|
|
15
|
+
description: str("One-line summary of the subtask (single mode; shown in the UI)"),
|
|
16
|
+
prompt: str("Self-contained instructions for the subagent (single mode)"),
|
|
17
|
+
agent: str("Optional agent name from the available agents (default: explore for research, build for changes)"),
|
|
18
|
+
tasks: {
|
|
19
|
+
type: "array",
|
|
20
|
+
description: "Batch mode: up to 8 subtasks run concurrently. When set, description/prompt are ignored.",
|
|
21
|
+
items: obj({
|
|
22
|
+
description: str("One-line summary of this subtask (shown in the UI)"),
|
|
23
|
+
prompt: str("Self-contained instructions for this subagent"),
|
|
24
|
+
agent: str("Optional agent name for this subtask"),
|
|
25
|
+
}, ["description", "prompt"]),
|
|
26
|
+
},
|
|
27
|
+
}, []),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
run: (ctx, args) => {
|
|
31
|
+
if (!ctx.taskRunner)
|
|
32
|
+
return { ok: false, output: "ERROR: task tool not wired (no runner in context)." };
|
|
33
|
+
const a = args;
|
|
34
|
+
if (Array.isArray(a.tasks) && a.tasks.length > 0) {
|
|
35
|
+
if (a.tasks.length > 8) {
|
|
36
|
+
return { ok: false, output: `ERROR: too many tasks (${a.tasks.length}); split into batches of at most 8.` };
|
|
37
|
+
}
|
|
38
|
+
const invalid = a.tasks.findIndex((t) => !t || typeof t.prompt !== "string" || !t.prompt.trim());
|
|
39
|
+
if (invalid >= 0)
|
|
40
|
+
return { ok: false, output: `ERROR: tasks[${invalid}] is missing a non-empty prompt.` };
|
|
41
|
+
return ctx.taskRunner(ctx, { tasks: a.tasks });
|
|
42
|
+
}
|
|
43
|
+
if (typeof a.prompt !== "string" || !a.prompt.trim()) {
|
|
44
|
+
return { ok: false, output: "ERROR: task requires a non-empty `prompt` (single mode) or a non-empty `tasks` array (batch mode)." };
|
|
45
|
+
}
|
|
46
|
+
return ctx.taskRunner(ctx, a);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { unifiedDiff } from "../workspace/diff.js";
|
|
4
|
+
import { safePath } from "./path-safety.js";
|
|
5
|
+
import { gateWrite, toToolDiff, formatFile } from "./file-edit-common.js";
|
|
6
|
+
export async function toolWriteFile(ctx, args) {
|
|
7
|
+
let abs;
|
|
8
|
+
try {
|
|
9
|
+
abs = safePath(ctx.workdir, args.path);
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
13
|
+
}
|
|
14
|
+
let oldContent = null;
|
|
15
|
+
try {
|
|
16
|
+
oldContent = readFileSync(abs, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// new file
|
|
20
|
+
}
|
|
21
|
+
const diff = unifiedDiff(args.path, oldContent, args.content);
|
|
22
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
23
|
+
if (gate)
|
|
24
|
+
return gate;
|
|
25
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
26
|
+
writeFileSync(abs, args.content, "utf8");
|
|
27
|
+
const fmtNote = formatFile(ctx, abs);
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
output: `Wrote ${args.content.split("\n").length} lines to ${args.path}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
31
|
+
diff: toToolDiff(args.path, diff),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** v2: whitespace-insensitive regex for edit_file fuzzy fallback. */
|