s-app-scoder-agent 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +368 -2
- package/dist/agent.js +501 -0
- package/dist/build.js +41 -0
- package/dist/config.js +117 -0
- package/dist/constants.js +69 -0
- package/dist/context.js +44 -0
- package/dist/cost.js +141 -0
- package/dist/diff.js +156 -0
- package/dist/index.js +314 -0
- package/dist/llm.js +276 -0
- package/dist/logger.js +78 -0
- package/dist/mcp.js +189 -0
- package/dist/permissions.js +298 -0
- package/dist/provider-anthropic.js +332 -0
- package/dist/retry.js +53 -0
- package/dist/session.js +143 -0
- package/dist/tools.js +361 -0
- package/dist/ui.js +96 -0
- package/dist/version.js +18 -0
- package/package.json +51 -4
package/dist/tools.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { resolve, dirname, basename, join, relative, isAbsolute } from "node:path";
|
|
4
|
+
import { unifiedDiff } from "./diff.js";
|
|
5
|
+
import * as ui from "./ui.js";
|
|
6
|
+
const MAX_OUTPUT_CHARS = 20_000;
|
|
7
|
+
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // refuse to read files > 2MB
|
|
8
|
+
function clamp(s, max = MAX_OUTPUT_CHARS) {
|
|
9
|
+
if (s.length <= max)
|
|
10
|
+
return s;
|
|
11
|
+
return s.slice(0, max) + `\n... [truncated, ${s.length - max} more chars]`;
|
|
12
|
+
}
|
|
13
|
+
/** Resolve a user-supplied path inside workdir; reject escapes and symlink traversal. */
|
|
14
|
+
function safePath(workdir, p) {
|
|
15
|
+
const abs = isAbsolute(p) ? resolve(p) : resolve(workdir, p);
|
|
16
|
+
// Lexical containment first (cheap): reject obvious ../ escapes.
|
|
17
|
+
const relLex = relative(workdir, abs);
|
|
18
|
+
if (relLex.startsWith("..") || isAbsolute(relLex)) {
|
|
19
|
+
throw new Error(`Path escapes working directory: ${p}`);
|
|
20
|
+
}
|
|
21
|
+
// Realpath containment: resolve symlinks so a link inside workdir pointing
|
|
22
|
+
// outside (e.g. evil-link -> /etc) cannot be used to read/write outside.
|
|
23
|
+
// macOS may present /tmp as /private/tmp — resolve workdir the same way.
|
|
24
|
+
let realWorkdir = workdir;
|
|
25
|
+
try {
|
|
26
|
+
realWorkdir = realpathSync(workdir);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
// workdir must exist; keep lexical fallback for a transient ENOENT,
|
|
30
|
+
// but surface unexpected failures instead of silently swallowing them.
|
|
31
|
+
const code = err.code;
|
|
32
|
+
if (code !== "ENOENT") {
|
|
33
|
+
throw new Error(`Cannot resolve working directory "${workdir}": ${err.message}`, { cause: err });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Walk up to the nearest existing ancestor, then verify containment.
|
|
37
|
+
let probe = abs;
|
|
38
|
+
const missing = [];
|
|
39
|
+
for (;;) {
|
|
40
|
+
try {
|
|
41
|
+
const real = realpathSync(probe);
|
|
42
|
+
const reconstructed = real + missing.map((s) => `/${s}`).join("");
|
|
43
|
+
const rel = relative(realWorkdir, reconstructed.length > 0 ? reconstructed : real);
|
|
44
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
45
|
+
throw new Error(`Path escapes working directory: ${p}`);
|
|
46
|
+
}
|
|
47
|
+
return abs;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "ENOENT" && probe !== dirname(probe)) {
|
|
51
|
+
missing.push(basename(probe));
|
|
52
|
+
probe = dirname(probe);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// realpath failed for another reason (e.g. escaped) — rethrow escape errors
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Tool implementations
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/** Turn a sync tool step into a ToolResult, converting thrown errors (e.g. path escapes) into structured results. */
|
|
64
|
+
function toResult(fn) {
|
|
65
|
+
try {
|
|
66
|
+
return fn();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function toolReadFile(workdir, args) {
|
|
73
|
+
return toResult(() => {
|
|
74
|
+
const abs = safePath(workdir, args.path);
|
|
75
|
+
const st = statSync(abs);
|
|
76
|
+
if (st.isDirectory()) {
|
|
77
|
+
return { ok: false, output: `ERROR: ${args.path} is a directory — use list_files instead.` };
|
|
78
|
+
}
|
|
79
|
+
if (st.size > MAX_FILE_SIZE_BYTES) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
output: `ERROR: ${args.path} is ${(st.size / 1024 / 1024).toFixed(1)}MB (limit ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB). Use run_command with grep/head/tail to inspect specific parts.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const content = readFileSync(abs, "utf8");
|
|
86
|
+
const lines = content.split("\n");
|
|
87
|
+
const numbered = lines.map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
88
|
+
return { ok: true, output: clamp(numbered) };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Shared permission/diff gate for file writes. Returns null = approved. */
|
|
92
|
+
async function gateWrite(ctx, path, oldContent, newContent) {
|
|
93
|
+
const mode = ctx.permissionMode ?? "auto";
|
|
94
|
+
const diff = unifiedDiff(path, oldContent, newContent);
|
|
95
|
+
if (mode === "readonly") {
|
|
96
|
+
if (diff.text)
|
|
97
|
+
ui.printDiff(diff.text);
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
output: `ERROR: readonly mode — write to ${path} denied. The diff above is shown for review only; nothing was written.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (mode === "ask" && ctx.permissions) {
|
|
104
|
+
if (diff.text)
|
|
105
|
+
ui.printDiff(diff.text);
|
|
106
|
+
const allowed = await ctx.permissions.confirmEdit(path, diff.text);
|
|
107
|
+
if (!allowed) {
|
|
108
|
+
return { ok: false, output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (diff.text && !diff.truncated) {
|
|
112
|
+
ui.printDiff(diff.text);
|
|
113
|
+
}
|
|
114
|
+
else if (diff.truncated) {
|
|
115
|
+
ui.diffSummary(path, diff.added, diff.removed);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
async function toolWriteFile(ctx, args) {
|
|
120
|
+
let abs;
|
|
121
|
+
try {
|
|
122
|
+
abs = safePath(ctx.workdir, args.path);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
126
|
+
}
|
|
127
|
+
let oldContent = null;
|
|
128
|
+
try {
|
|
129
|
+
oldContent = readFileSync(abs, "utf8");
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// new file
|
|
133
|
+
}
|
|
134
|
+
const gate = await gateWrite(ctx, args.path, oldContent, args.content);
|
|
135
|
+
if (gate)
|
|
136
|
+
return gate;
|
|
137
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
138
|
+
writeFileSync(abs, args.content, "utf8");
|
|
139
|
+
return { ok: true, output: `Wrote ${args.content.split("\n").length} lines to ${args.path}` };
|
|
140
|
+
}
|
|
141
|
+
async function toolEditFile(ctx, args) {
|
|
142
|
+
let abs;
|
|
143
|
+
try {
|
|
144
|
+
abs = safePath(ctx.workdir, args.path);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
148
|
+
}
|
|
149
|
+
const content = readFileSync(abs, "utf8");
|
|
150
|
+
if (!content.includes(args.old_string)) {
|
|
151
|
+
return { ok: false, output: `old_string not found in ${args.path}. Read the file again and retry with exact text.` };
|
|
152
|
+
}
|
|
153
|
+
const count = content.split(args.old_string).length - 1;
|
|
154
|
+
if (count > 1) {
|
|
155
|
+
return { ok: false, output: `old_string matches ${count} locations in ${args.path}; include more surrounding context to make it unique.` };
|
|
156
|
+
}
|
|
157
|
+
// Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
|
|
158
|
+
// which would corrupt literal text like "$&" in the new content.
|
|
159
|
+
const newContent = content.replace(args.old_string, () => args.new_string);
|
|
160
|
+
const gate = await gateWrite(ctx, args.path, content, newContent);
|
|
161
|
+
if (gate)
|
|
162
|
+
return gate;
|
|
163
|
+
writeFileSync(abs, newContent, "utf8");
|
|
164
|
+
return { ok: true, output: `Edited ${args.path}` };
|
|
165
|
+
}
|
|
166
|
+
function toolListFiles(workdir, args) {
|
|
167
|
+
return toResult(() => {
|
|
168
|
+
const root = safePath(workdir, args.path ?? ".");
|
|
169
|
+
const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
170
|
+
const out = [];
|
|
171
|
+
const walk = (dir, depth) => {
|
|
172
|
+
if (depth > 4 || out.length > 500)
|
|
173
|
+
return;
|
|
174
|
+
let entries;
|
|
175
|
+
try {
|
|
176
|
+
entries = readdirSync(dir).sort();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const e of entries) {
|
|
182
|
+
if (skip.has(e))
|
|
183
|
+
continue;
|
|
184
|
+
const full = join(dir, e);
|
|
185
|
+
let st;
|
|
186
|
+
try {
|
|
187
|
+
st = statSync(full);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const rel = relative(workdir, full);
|
|
193
|
+
if (st.isDirectory()) {
|
|
194
|
+
out.push(`${rel}/`);
|
|
195
|
+
walk(full, depth + 1);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
out.push(`${rel} (${st.size}b)`);
|
|
199
|
+
}
|
|
200
|
+
if (out.length > 500) {
|
|
201
|
+
out.push("... [truncated]");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
walk(root, 0);
|
|
207
|
+
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(empty directory)" };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Run a shell command asynchronously (async spawn keeps the event loop free,
|
|
212
|
+
* so streaming output renders and SIGINT is handled promptly).
|
|
213
|
+
* Kills the child on timeout or external abort (SIGINT via ctx.signal).
|
|
214
|
+
*/
|
|
215
|
+
export async function toolRunCommand(workdir, args, signal) {
|
|
216
|
+
const timeoutMs = Math.min(Math.max(args.timeout_secs ?? 120, 1), 600) * 1000;
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let settled = false;
|
|
219
|
+
const finish = (result) => {
|
|
220
|
+
if (settled)
|
|
221
|
+
return;
|
|
222
|
+
settled = true;
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
signal?.removeEventListener("abort", onAbort);
|
|
225
|
+
resolve(result);
|
|
226
|
+
};
|
|
227
|
+
const child = spawn("/bin/sh", ["-c", args.command], {
|
|
228
|
+
cwd: workdir,
|
|
229
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
230
|
+
});
|
|
231
|
+
let stdout = "";
|
|
232
|
+
let stderr = "";
|
|
233
|
+
let killedReason = null;
|
|
234
|
+
const killChild = (reason) => {
|
|
235
|
+
killedReason = reason;
|
|
236
|
+
try {
|
|
237
|
+
child.kill("SIGTERM");
|
|
238
|
+
}
|
|
239
|
+
catch { /* already dead */ }
|
|
240
|
+
// SIGKILL fallback if the process ignores SIGTERM
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
try {
|
|
243
|
+
if (child.exitCode === null)
|
|
244
|
+
child.kill("SIGKILL");
|
|
245
|
+
}
|
|
246
|
+
catch { /* already dead */ }
|
|
247
|
+
}, 5_000).unref();
|
|
248
|
+
};
|
|
249
|
+
const timer = setTimeout(() => killChild("timeout"), timeoutMs);
|
|
250
|
+
const onAbort = () => killChild("aborted");
|
|
251
|
+
if (signal?.aborted)
|
|
252
|
+
onAbort();
|
|
253
|
+
else
|
|
254
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
255
|
+
child.stdout?.on("data", (c) => { stdout += c.toString("utf8"); });
|
|
256
|
+
child.stderr?.on("data", (c) => { stderr += c.toString("utf8"); });
|
|
257
|
+
child.on("error", (err) => {
|
|
258
|
+
finish({ ok: false, output: clamp(`$ ${args.command}\n(spawn error) ${err.message}`) });
|
|
259
|
+
});
|
|
260
|
+
child.on("close", (code) => {
|
|
261
|
+
if (killedReason === "timeout") {
|
|
262
|
+
finish({
|
|
263
|
+
ok: false,
|
|
264
|
+
output: clamp(`$ ${args.command}\n(command timed out after ${Math.round(timeoutMs / 1000)}s and was killed)\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`),
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (killedReason === "aborted") {
|
|
269
|
+
finish({
|
|
270
|
+
ok: false,
|
|
271
|
+
output: clamp(`$ ${args.command}\n(interrupted by user)\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`),
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const status = code ?? -1;
|
|
276
|
+
if (status === 0) {
|
|
277
|
+
finish({ ok: true, output: clamp(`$ ${args.command}\n(exit 0)\n${stdout}`) });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
finish({
|
|
281
|
+
ok: false,
|
|
282
|
+
output: clamp(`$ ${args.command}\n(exit ${status})\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const obj = (properties, required) => ({
|
|
289
|
+
type: "object",
|
|
290
|
+
properties,
|
|
291
|
+
required,
|
|
292
|
+
additionalProperties: false,
|
|
293
|
+
});
|
|
294
|
+
const str = (description) => ({ type: "string", description });
|
|
295
|
+
export const AGENT_TOOLS = [
|
|
296
|
+
{
|
|
297
|
+
spec: {
|
|
298
|
+
type: "function",
|
|
299
|
+
function: {
|
|
300
|
+
name: "list_files",
|
|
301
|
+
description: "List files and directories recursively under a path in the working directory.",
|
|
302
|
+
parameters: obj({ path: str("Relative path, defaults to '.'") }, []),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
run: (ctx, args) => toolListFiles(ctx.workdir, args),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
spec: {
|
|
309
|
+
type: "function",
|
|
310
|
+
function: {
|
|
311
|
+
name: "read_file",
|
|
312
|
+
description: "Read a text file. Returns content with 1-based line numbers.",
|
|
313
|
+
parameters: obj({ path: str("Relative file path") }, ["path"]),
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
run: (ctx, args) => toolReadFile(ctx.workdir, args),
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
spec: {
|
|
320
|
+
type: "function",
|
|
321
|
+
function: {
|
|
322
|
+
name: "write_file",
|
|
323
|
+
description: "Create or overwrite a file with the given full content. Parent dirs are created automatically.",
|
|
324
|
+
parameters: obj({ path: str("Relative file path"), content: str("Full file content") }, ["path", "content"]),
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
run: (ctx, args) => toolWriteFile(ctx, args),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
spec: {
|
|
331
|
+
type: "function",
|
|
332
|
+
function: {
|
|
333
|
+
name: "edit_file",
|
|
334
|
+
description: "Replace an exact unique substring in a file. old_string must appear exactly once; include enough surrounding context.",
|
|
335
|
+
parameters: obj({
|
|
336
|
+
path: str("Relative file path"),
|
|
337
|
+
old_string: str("Exact text to replace"),
|
|
338
|
+
new_string: str("Replacement text"),
|
|
339
|
+
}, ["path", "old_string", "new_string"]),
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
run: (ctx, args) => toolEditFile(ctx, args),
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
spec: {
|
|
346
|
+
type: "function",
|
|
347
|
+
function: {
|
|
348
|
+
name: "run_command",
|
|
349
|
+
description: "Run a shell command (/bin/sh -c) in the working directory. Use for builds, tests, git, installing deps, etc.",
|
|
350
|
+
parameters: obj({
|
|
351
|
+
command: str("Shell command to run"),
|
|
352
|
+
timeout_secs: str("Timeout in seconds (default 120, max 600)"),
|
|
353
|
+
}, ["command"]),
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal),
|
|
357
|
+
},
|
|
358
|
+
];
|
|
359
|
+
export function getTool(name) {
|
|
360
|
+
return AGENT_TOOLS.find((t) => t.spec.function.name === name);
|
|
361
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
const RESET = "\x1b[0m";
|
|
3
|
+
const BOLD = "\x1b[1m";
|
|
4
|
+
const DIM = "\x1b[2m";
|
|
5
|
+
const GREEN = "\x1b[32m";
|
|
6
|
+
const YELLOW = "\x1b[33m";
|
|
7
|
+
const RED = "\x1b[31m";
|
|
8
|
+
const CYAN = "\x1b[36m";
|
|
9
|
+
const BLUE = "\x1b[34m";
|
|
10
|
+
function ts() {
|
|
11
|
+
return new Date().toISOString().slice(11, 23);
|
|
12
|
+
}
|
|
13
|
+
export function info(msg) {
|
|
14
|
+
console.log(`${DIM}[${ts()}]${RESET} ${msg}`);
|
|
15
|
+
}
|
|
16
|
+
export function step(stepNum, total, msg) {
|
|
17
|
+
console.log(`${BLUE}[${stepNum}/${total}]${RESET} ${BOLD}${msg}${RESET}`);
|
|
18
|
+
}
|
|
19
|
+
export function success(msg) {
|
|
20
|
+
console.log(`${GREEN}✓${RESET} ${GREEN}${msg}${RESET}`);
|
|
21
|
+
}
|
|
22
|
+
export function warn(msg) {
|
|
23
|
+
console.log(`${YELLOW}⚠${RESET} ${YELLOW}${msg}${RESET}`);
|
|
24
|
+
}
|
|
25
|
+
export function error(msg) {
|
|
26
|
+
console.error(`${RED}✗${RESET} ${RED}${msg}${RESET}`);
|
|
27
|
+
}
|
|
28
|
+
export function llm(prefix, content) {
|
|
29
|
+
console.log(`${CYAN}${prefix}:${RESET} ${content.slice(0, 300)}${content.length > 300 ? " ..." : ""}`);
|
|
30
|
+
}
|
|
31
|
+
export function toolCall(name, args) {
|
|
32
|
+
const a = Object.entries(args)
|
|
33
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)?.slice(0, 80)}`)
|
|
34
|
+
.join(" ");
|
|
35
|
+
console.log(`${CYAN}→ ${name}(${a})${RESET}`);
|
|
36
|
+
}
|
|
37
|
+
export function toolResult(name, ok, output) {
|
|
38
|
+
const preview = output.split("\n")[0]?.slice(0, 120) ?? "";
|
|
39
|
+
const icon = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
|
40
|
+
console.log(`${icon} ${CYAN}${name}${RESET} → ${preview}`);
|
|
41
|
+
}
|
|
42
|
+
export function buildStatus(success, output) {
|
|
43
|
+
const icon = success ? `${GREEN}✓ BUILD SUCCESS` : `${RED}✗ BUILD FAILED`;
|
|
44
|
+
console.log(`\n${icon}${RESET}\n${output.split("\n").slice(-10).join("\n")}\n`);
|
|
45
|
+
}
|
|
46
|
+
export function header(msg) {
|
|
47
|
+
console.log(`\n${BOLD}${CYAN}── ${msg} ${"─".repeat(Math.max(2, 60 - msg.length))}${RESET}`);
|
|
48
|
+
}
|
|
49
|
+
// ── Streaming helpers ────────────────────────────────────────────────────────
|
|
50
|
+
let _streamLine = "";
|
|
51
|
+
let _streamColor = CYAN;
|
|
52
|
+
let _streamPrefix = "";
|
|
53
|
+
/** Start a streaming line. Call llmStreamChunk() for each delta, then llmStreamEnd(). */
|
|
54
|
+
export function llmStreamStart(prefix = "assistant", color = CYAN) {
|
|
55
|
+
_streamLine = "";
|
|
56
|
+
_streamColor = color;
|
|
57
|
+
_streamPrefix = prefix;
|
|
58
|
+
process.stdout.write(`${color}${prefix}:${RESET} `);
|
|
59
|
+
}
|
|
60
|
+
/** Append a text delta to the current streaming line. */
|
|
61
|
+
export function llmStreamChunk(delta) {
|
|
62
|
+
_streamLine += delta;
|
|
63
|
+
process.stdout.write(delta);
|
|
64
|
+
}
|
|
65
|
+
/** Finish the streaming line (append suffix + newline). */
|
|
66
|
+
export function llmStreamEnd(suffix = "") {
|
|
67
|
+
const line = _streamLine;
|
|
68
|
+
_streamLine = "";
|
|
69
|
+
process.stdout.write(`${suffix}\n`);
|
|
70
|
+
return line;
|
|
71
|
+
}
|
|
72
|
+
// ── Diff rendering ───────────────────────────────────────────────────────────
|
|
73
|
+
/** Print a unified diff with colors (+ green, - red, @@ cyan). */
|
|
74
|
+
export function printDiff(diffText) {
|
|
75
|
+
for (const line of diffText.split("\n")) {
|
|
76
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
77
|
+
console.log(`${BOLD}${line}${RESET}`);
|
|
78
|
+
}
|
|
79
|
+
else if (line.startsWith("+")) {
|
|
80
|
+
console.log(`${GREEN}${line}${RESET}`);
|
|
81
|
+
}
|
|
82
|
+
else if (line.startsWith("-")) {
|
|
83
|
+
console.log(`${RED}${line}${RESET}`);
|
|
84
|
+
}
|
|
85
|
+
else if (line.startsWith("@@")) {
|
|
86
|
+
console.log(`${CYAN}${line}${RESET}`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log(line);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Print a one-line diff summary: "✎ +12 -3 lines → src/foo.ts". */
|
|
94
|
+
export function diffSummary(path, added, removed) {
|
|
95
|
+
console.log(`${CYAN}✎${RESET} ${BOLD}${path}${RESET} ${GREEN}+${added}${RESET} ${RED}-${removed}${RESET} lines`);
|
|
96
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the package version and client name.
|
|
3
|
+
* Reads package.json at runtime (works both from src/ during dev and from
|
|
4
|
+
* dist/ in the published build), falling back to "0.0.0" for bundled builds.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
10
|
+
export const CLIENT_NAME = "app-scoder-agent";
|
|
11
|
+
export function getVersion() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s-app-scoder-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Autonomous coding agent CLI - similar to opencode/cline/claude. Runs tasks until build succeeds.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"scoder": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"prepare": "npm run build",
|
|
13
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"lint": "eslint src/",
|
|
18
|
+
"coverage": "npm run build && node --test --experimental-test-coverage test/*.test.mjs",
|
|
19
|
+
"test": "npm run build && node --test test/*.test.mjs && node test/run-e2e.mjs && node test/run-mcp-test.mjs"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"cli",
|
|
26
|
+
"agent",
|
|
27
|
+
"ai",
|
|
28
|
+
"coding-agent",
|
|
29
|
+
"llm"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/nbhson/app-scoder-agent.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/nbhson/app-scoder-agent#readme",
|
|
36
|
+
"bugs": "https://github.com/nbhson/app-scoder-agent/issues",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@eslint/js": "^10.0.1",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"eslint": "^10.13.0",
|
|
50
|
+
"typescript": "^5.6.0",
|
|
51
|
+
"typescript-eslint": "^8.72.1"
|
|
52
|
+
}
|
|
53
|
+
}
|