s-app-scoder-agent 0.0.0-stage → 0.3.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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Session persistence: save agent state (messages, steps, build) to
3
+ * <workdir>/.scoder/sessions/<id>.json after every step so runs can be resumed.
4
+ */
5
+ import { mkdirSync, writeFileSync, renameSync, readFileSync, readdirSync, existsSync, rmSync, statSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ const SESSIONS_DIR = ".scoder/sessions";
8
+ export function newSessionId(now = new Date()) {
9
+ const pad = (n) => String(n).padStart(2, "0");
10
+ const y = now.getFullYear();
11
+ const mo = pad(now.getMonth() + 1);
12
+ const d = pad(now.getDate());
13
+ const h = pad(now.getHours());
14
+ const mi = pad(now.getMinutes());
15
+ const s = pad(now.getSeconds());
16
+ const rand = Math.floor(Math.random() * 0xffff).toString(16).padStart(4, "0");
17
+ return `${y}${mo}${d}-${h}${mi}${s}-${rand}`;
18
+ }
19
+ function sessionsDir(workdir) {
20
+ return resolve(workdir, SESSIONS_DIR);
21
+ }
22
+ /** Atomic write: write to .tmp then rename. */
23
+ function atomicWrite(path, content) {
24
+ const tmp = `${path}.tmp`;
25
+ writeFileSync(tmp, content, "utf8");
26
+ renameSync(tmp, path);
27
+ }
28
+ export class SessionStore {
29
+ id;
30
+ filePath;
31
+ enabled;
32
+ _workdir;
33
+ constructor(workdir, enabled, id = newSessionId()) {
34
+ this.id = id;
35
+ this._workdir = workdir;
36
+ this.enabled = enabled;
37
+ this.filePath = join(sessionsDir(workdir), `${id}.json`);
38
+ }
39
+ save(data, createdAt) {
40
+ if (!this.enabled)
41
+ return;
42
+ try {
43
+ mkdirSync(sessionsDir(this._workdir), { recursive: true });
44
+ const now = new Date().toISOString();
45
+ const full = {
46
+ ...data,
47
+ id: this.id,
48
+ createdAt: createdAt ?? now,
49
+ updatedAt: now,
50
+ };
51
+ atomicWrite(this.filePath, JSON.stringify(full, null, 2));
52
+ }
53
+ catch (err) {
54
+ // Never crash the agent because of a logging/persistence failure
55
+ console.error(`⚠ session save failed: ${err.message}`);
56
+ }
57
+ }
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // Load / list / prune
61
+ // ---------------------------------------------------------------------------
62
+ export function loadSession(workdir, id) {
63
+ const path = join(sessionsDir(workdir), `${id}.json`);
64
+ if (!existsSync(path))
65
+ return null;
66
+ try {
67
+ return JSON.parse(readFileSync(path, "utf8"));
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ export function listSessions(workdir) {
74
+ const dir = sessionsDir(workdir);
75
+ if (!existsSync(dir))
76
+ return [];
77
+ const out = [];
78
+ for (const f of readdirSync(dir).sort().reverse()) {
79
+ if (!f.endsWith(".json"))
80
+ continue;
81
+ try {
82
+ const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
83
+ out.push({
84
+ id: s.id,
85
+ task: (s.task ?? "").slice(0, 60),
86
+ steps: s.steps ?? 0,
87
+ updatedAt: s.updatedAt ?? "",
88
+ });
89
+ }
90
+ catch {
91
+ // skip malformed
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * Delete session files older than `days` (default 7).
98
+ * Completed sessions (lastBuild.success === true) are removed; sessions that
99
+ * did NOT finish successfully are kept longer — they may still be worth
100
+ * resuming. Returns count removed.
101
+ */
102
+ export function pruneSessions(workdir, days = 7) {
103
+ const dir = sessionsDir(workdir);
104
+ if (!existsSync(dir))
105
+ return 0;
106
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
107
+ let removed = 0;
108
+ for (const f of readdirSync(dir)) {
109
+ if (!f.endsWith(".json"))
110
+ continue;
111
+ const full = join(dir, f);
112
+ try {
113
+ const st = statSync(full);
114
+ if (st.mtimeMs >= cutoff)
115
+ continue;
116
+ const s = JSON.parse(readFileSync(full, "utf8"));
117
+ const completed = s.lastBuild?.success === true;
118
+ if (completed) {
119
+ rmSync(full);
120
+ removed++;
121
+ }
122
+ }
123
+ catch {
124
+ // malformed JSON — prune it (unreadable session is worthless)
125
+ try {
126
+ rmSync(full);
127
+ removed++;
128
+ }
129
+ catch { /* ignore */ }
130
+ }
131
+ }
132
+ return removed;
133
+ }
134
+ /** Resolve "last" or an explicit id to a SessionFile, or null. */
135
+ export function resolveSessionId(workdir, idOrLast) {
136
+ if (idOrLast === "last") {
137
+ const all = listSessions(workdir);
138
+ if (all.length === 0)
139
+ return null;
140
+ return loadSession(workdir, all[0].id);
141
+ }
142
+ return loadSession(workdir, idOrLast);
143
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,356 @@
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 {
29
+ // workdir must exist; keep lexical fallback
30
+ }
31
+ // Walk up to the nearest existing ancestor, then verify containment.
32
+ let probe = abs;
33
+ const missing = [];
34
+ for (;;) {
35
+ try {
36
+ const real = realpathSync(probe);
37
+ const reconstructed = real + missing.map((s) => `/${s}`).join("");
38
+ const rel = relative(realWorkdir, reconstructed.length > 0 ? reconstructed : real);
39
+ if (rel.startsWith("..") || isAbsolute(rel)) {
40
+ throw new Error(`Path escapes working directory: ${p}`);
41
+ }
42
+ return abs;
43
+ }
44
+ catch (err) {
45
+ if (err.code === "ENOENT" && probe !== dirname(probe)) {
46
+ missing.push(basename(probe));
47
+ probe = dirname(probe);
48
+ continue;
49
+ }
50
+ // realpath failed for another reason (e.g. escaped) — rethrow escape errors
51
+ throw err;
52
+ }
53
+ }
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Tool implementations
57
+ // ---------------------------------------------------------------------------
58
+ /** Turn a sync tool step into a ToolResult, converting thrown errors (e.g. path escapes) into structured results. */
59
+ function toResult(fn) {
60
+ try {
61
+ return fn();
62
+ }
63
+ catch (err) {
64
+ return { ok: false, output: `ERROR: ${err.message}` };
65
+ }
66
+ }
67
+ function toolReadFile(workdir, args) {
68
+ return toResult(() => {
69
+ const abs = safePath(workdir, args.path);
70
+ const st = statSync(abs);
71
+ if (st.isDirectory()) {
72
+ return { ok: false, output: `ERROR: ${args.path} is a directory — use list_files instead.` };
73
+ }
74
+ if (st.size > MAX_FILE_SIZE_BYTES) {
75
+ return {
76
+ ok: false,
77
+ 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.`,
78
+ };
79
+ }
80
+ const content = readFileSync(abs, "utf8");
81
+ const lines = content.split("\n");
82
+ const numbered = lines.map((l, i) => `${i + 1}: ${l}`).join("\n");
83
+ return { ok: true, output: clamp(numbered) };
84
+ });
85
+ }
86
+ /** Shared permission/diff gate for file writes. Returns null = approved. */
87
+ async function gateWrite(ctx, path, oldContent, newContent) {
88
+ const mode = ctx.permissionMode ?? "auto";
89
+ const diff = unifiedDiff(path, oldContent, newContent);
90
+ if (mode === "readonly") {
91
+ if (diff.text)
92
+ ui.printDiff(diff.text);
93
+ return {
94
+ ok: false,
95
+ output: `ERROR: readonly mode — write to ${path} denied. The diff above is shown for review only; nothing was written.`,
96
+ };
97
+ }
98
+ if (mode === "ask" && ctx.permissions) {
99
+ if (diff.text)
100
+ ui.printDiff(diff.text);
101
+ const allowed = await ctx.permissions.confirmEdit(path, diff.text);
102
+ if (!allowed) {
103
+ return { ok: false, output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.` };
104
+ }
105
+ }
106
+ else if (diff.text && !diff.truncated) {
107
+ ui.printDiff(diff.text);
108
+ }
109
+ else if (diff.truncated) {
110
+ ui.diffSummary(path, diff.added, diff.removed);
111
+ }
112
+ return null;
113
+ }
114
+ async function toolWriteFile(ctx, args) {
115
+ let abs;
116
+ try {
117
+ abs = safePath(ctx.workdir, args.path);
118
+ }
119
+ catch (err) {
120
+ return { ok: false, output: `ERROR: ${err.message}` };
121
+ }
122
+ let oldContent = null;
123
+ try {
124
+ oldContent = readFileSync(abs, "utf8");
125
+ }
126
+ catch {
127
+ // new file
128
+ }
129
+ const gate = await gateWrite(ctx, args.path, oldContent, args.content);
130
+ if (gate)
131
+ return gate;
132
+ mkdirSync(dirname(abs), { recursive: true });
133
+ writeFileSync(abs, args.content, "utf8");
134
+ return { ok: true, output: `Wrote ${args.content.split("\n").length} lines to ${args.path}` };
135
+ }
136
+ async function toolEditFile(ctx, args) {
137
+ let abs;
138
+ try {
139
+ abs = safePath(ctx.workdir, args.path);
140
+ }
141
+ catch (err) {
142
+ return { ok: false, output: `ERROR: ${err.message}` };
143
+ }
144
+ const content = readFileSync(abs, "utf8");
145
+ if (!content.includes(args.old_string)) {
146
+ return { ok: false, output: `old_string not found in ${args.path}. Read the file again and retry with exact text.` };
147
+ }
148
+ const count = content.split(args.old_string).length - 1;
149
+ if (count > 1) {
150
+ return { ok: false, output: `old_string matches ${count} locations in ${args.path}; include more surrounding context to make it unique.` };
151
+ }
152
+ // Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
153
+ // which would corrupt literal text like "$&" in the new content.
154
+ const newContent = content.replace(args.old_string, () => args.new_string);
155
+ const gate = await gateWrite(ctx, args.path, content, newContent);
156
+ if (gate)
157
+ return gate;
158
+ writeFileSync(abs, newContent, "utf8");
159
+ return { ok: true, output: `Edited ${args.path}` };
160
+ }
161
+ function toolListFiles(workdir, args) {
162
+ return toResult(() => {
163
+ const root = safePath(workdir, args.path ?? ".");
164
+ const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
165
+ const out = [];
166
+ const walk = (dir, depth) => {
167
+ if (depth > 4 || out.length > 500)
168
+ return;
169
+ let entries;
170
+ try {
171
+ entries = readdirSync(dir).sort();
172
+ }
173
+ catch {
174
+ return;
175
+ }
176
+ for (const e of entries) {
177
+ if (skip.has(e))
178
+ continue;
179
+ const full = join(dir, e);
180
+ let st;
181
+ try {
182
+ st = statSync(full);
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ const rel = relative(workdir, full);
188
+ if (st.isDirectory()) {
189
+ out.push(`${rel}/`);
190
+ walk(full, depth + 1);
191
+ }
192
+ else {
193
+ out.push(`${rel} (${st.size}b)`);
194
+ }
195
+ if (out.length > 500) {
196
+ out.push("... [truncated]");
197
+ return;
198
+ }
199
+ }
200
+ };
201
+ walk(root, 0);
202
+ return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(empty directory)" };
203
+ });
204
+ }
205
+ /**
206
+ * Run a shell command asynchronously (async spawn keeps the event loop free,
207
+ * so streaming output renders and SIGINT is handled promptly).
208
+ * Kills the child on timeout or external abort (SIGINT via ctx.signal).
209
+ */
210
+ export async function toolRunCommand(workdir, args, signal) {
211
+ const timeoutMs = Math.min(Math.max(args.timeout_secs ?? 120, 1), 600) * 1000;
212
+ return new Promise((resolve) => {
213
+ let settled = false;
214
+ const finish = (result) => {
215
+ if (settled)
216
+ return;
217
+ settled = true;
218
+ clearTimeout(timer);
219
+ signal?.removeEventListener("abort", onAbort);
220
+ resolve(result);
221
+ };
222
+ const child = spawn("/bin/sh", ["-c", args.command], {
223
+ cwd: workdir,
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ });
226
+ let stdout = "";
227
+ let stderr = "";
228
+ let killedReason = null;
229
+ const killChild = (reason) => {
230
+ killedReason = reason;
231
+ try {
232
+ child.kill("SIGTERM");
233
+ }
234
+ catch { /* already dead */ }
235
+ // SIGKILL fallback if the process ignores SIGTERM
236
+ setTimeout(() => {
237
+ try {
238
+ if (child.exitCode === null)
239
+ child.kill("SIGKILL");
240
+ }
241
+ catch { /* already dead */ }
242
+ }, 5_000).unref();
243
+ };
244
+ const timer = setTimeout(() => killChild("timeout"), timeoutMs);
245
+ const onAbort = () => killChild("aborted");
246
+ if (signal?.aborted)
247
+ onAbort();
248
+ else
249
+ signal?.addEventListener("abort", onAbort, { once: true });
250
+ child.stdout?.on("data", (c) => { stdout += c.toString("utf8"); });
251
+ child.stderr?.on("data", (c) => { stderr += c.toString("utf8"); });
252
+ child.on("error", (err) => {
253
+ finish({ ok: false, output: clamp(`$ ${args.command}\n(spawn error) ${err.message}`) });
254
+ });
255
+ child.on("close", (code) => {
256
+ if (killedReason === "timeout") {
257
+ finish({
258
+ ok: false,
259
+ 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}`),
260
+ });
261
+ return;
262
+ }
263
+ if (killedReason === "aborted") {
264
+ finish({
265
+ ok: false,
266
+ output: clamp(`$ ${args.command}\n(interrupted by user)\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`),
267
+ });
268
+ return;
269
+ }
270
+ const status = code ?? -1;
271
+ if (status === 0) {
272
+ finish({ ok: true, output: clamp(`$ ${args.command}\n(exit 0)\n${stdout}`) });
273
+ }
274
+ else {
275
+ finish({
276
+ ok: false,
277
+ output: clamp(`$ ${args.command}\n(exit ${status})\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`),
278
+ });
279
+ }
280
+ });
281
+ });
282
+ }
283
+ const obj = (properties, required) => ({
284
+ type: "object",
285
+ properties,
286
+ required,
287
+ additionalProperties: false,
288
+ });
289
+ const str = (description) => ({ type: "string", description });
290
+ export const AGENT_TOOLS = [
291
+ {
292
+ spec: {
293
+ type: "function",
294
+ function: {
295
+ name: "list_files",
296
+ description: "List files and directories recursively under a path in the working directory.",
297
+ parameters: obj({ path: str("Relative path, defaults to '.'") }, []),
298
+ },
299
+ },
300
+ run: (ctx, args) => toolListFiles(ctx.workdir, args),
301
+ },
302
+ {
303
+ spec: {
304
+ type: "function",
305
+ function: {
306
+ name: "read_file",
307
+ description: "Read a text file. Returns content with 1-based line numbers.",
308
+ parameters: obj({ path: str("Relative file path") }, ["path"]),
309
+ },
310
+ },
311
+ run: (ctx, args) => toolReadFile(ctx.workdir, args),
312
+ },
313
+ {
314
+ spec: {
315
+ type: "function",
316
+ function: {
317
+ name: "write_file",
318
+ description: "Create or overwrite a file with the given full content. Parent dirs are created automatically.",
319
+ parameters: obj({ path: str("Relative file path"), content: str("Full file content") }, ["path", "content"]),
320
+ },
321
+ },
322
+ run: (ctx, args) => toolWriteFile(ctx, args),
323
+ },
324
+ {
325
+ spec: {
326
+ type: "function",
327
+ function: {
328
+ name: "edit_file",
329
+ description: "Replace an exact unique substring in a file. old_string must appear exactly once; include enough surrounding context.",
330
+ parameters: obj({
331
+ path: str("Relative file path"),
332
+ old_string: str("Exact text to replace"),
333
+ new_string: str("Replacement text"),
334
+ }, ["path", "old_string", "new_string"]),
335
+ },
336
+ },
337
+ run: (ctx, args) => toolEditFile(ctx, args),
338
+ },
339
+ {
340
+ spec: {
341
+ type: "function",
342
+ function: {
343
+ name: "run_command",
344
+ description: "Run a shell command (/bin/sh -c) in the working directory. Use for builds, tests, git, installing deps, etc.",
345
+ parameters: obj({
346
+ command: str("Shell command to run"),
347
+ timeout_secs: str("Timeout in seconds (default 120, max 600)"),
348
+ }, ["command"]),
349
+ },
350
+ },
351
+ run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal),
352
+ },
353
+ ];
354
+ export function getTool(name) {
355
+ return AGENT_TOOLS.find((t) => t.spec.function.name === name);
356
+ }
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
+ }
@@ -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,51 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.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
+ "start": "node dist/index.js",
13
+ "dev": "tsc --watch",
14
+ "typecheck": "tsc --noEmit",
15
+ "lint": "eslint src/",
16
+ "coverage": "npm run build && node --test --experimental-test-coverage test/*.test.mjs",
17
+ "test": "npm run build && node --test test/*.test.mjs && node test/run-e2e.mjs && node test/run-mcp-test.mjs"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "cli",
24
+ "agent",
25
+ "ai",
26
+ "coding-agent",
27
+ "llm"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/nbhson/app-scoder-agent.git"
32
+ },
33
+ "homepage": "https://github.com/nbhson/app-scoder-agent#readme",
34
+ "bugs": "https://github.com/nbhson/app-scoder-agent/issues",
35
+ "license": "MIT",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "devDependencies": {
45
+ "@eslint/js": "^10.0.1",
46
+ "@types/node": "^22.0.0",
47
+ "eslint": "^10.13.0",
48
+ "typescript": "^5.6.0",
49
+ "typescript-eslint": "^8.72.1"
50
+ }
6
51
  }