s-app-scoder-agent 0.3.0 → 0.5.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/dist/tools.js CHANGED
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { resolve, dirname, basename, join, relative, isAbsolute } from "node:path";
4
4
  import { unifiedDiff } from "./diff.js";
5
5
  import * as ui from "./ui.js";
6
+ import { toolWebFetch } from "./webfetch.js";
6
7
  const MAX_OUTPUT_CHARS = 20_000;
7
8
  const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // refuse to read files > 2MB
8
9
  function clamp(s, max = MAX_OUTPUT_CHARS) {
@@ -25,8 +26,13 @@ function safePath(workdir, p) {
25
26
  try {
26
27
  realWorkdir = realpathSync(workdir);
27
28
  }
28
- catch {
29
- // workdir must exist; keep lexical fallback
29
+ catch (err) {
30
+ // workdir must exist; keep lexical fallback for a transient ENOENT,
31
+ // but surface unexpected failures instead of silently swallowing them.
32
+ const code = err.code;
33
+ if (code !== "ENOENT") {
34
+ throw new Error(`Cannot resolve working directory "${workdir}": ${err.message}`, { cause: err });
35
+ }
30
36
  }
31
37
  // Walk up to the nearest existing ancestor, then verify containment.
32
38
  let probe = abs;
@@ -206,8 +212,10 @@ function toolListFiles(workdir, args) {
206
212
  * Run a shell command asynchronously (async spawn keeps the event loop free,
207
213
  * so streaming output renders and SIGINT is handled promptly).
208
214
  * Kills the child on timeout or external abort (SIGINT via ctx.signal).
215
+ * When `onOutput` is provided, stdout/stderr chunks are forwarded live as they
216
+ * arrive (in addition to being collected into the final ToolResult).
209
217
  */
210
- export async function toolRunCommand(workdir, args, signal) {
218
+ export async function toolRunCommand(workdir, args, signal, onOutput) {
211
219
  const timeoutMs = Math.min(Math.max(args.timeout_secs ?? 120, 1), 600) * 1000;
212
220
  return new Promise((resolve) => {
213
221
  let settled = false;
@@ -247,8 +255,16 @@ export async function toolRunCommand(workdir, args, signal) {
247
255
  onAbort();
248
256
  else
249
257
  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"); });
258
+ child.stdout?.on("data", (c) => {
259
+ const s = c.toString("utf8");
260
+ stdout += s;
261
+ onOutput?.("stdout", s);
262
+ });
263
+ child.stderr?.on("data", (c) => {
264
+ const s = c.toString("utf8");
265
+ stderr += s;
266
+ onOutput?.("stderr", s);
267
+ });
252
268
  child.on("error", (err) => {
253
269
  finish({ ok: false, output: clamp(`$ ${args.command}\n(spawn error) ${err.message}`) });
254
270
  });
@@ -348,7 +364,28 @@ export const AGENT_TOOLS = [
348
364
  }, ["command"]),
349
365
  },
350
366
  },
351
- run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal),
367
+ run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal, ctx.onToolOutput ? (stream, delta) => ctx.onToolOutput?.("run_command", stream, delta) : undefined),
368
+ },
369
+ {
370
+ spec: {
371
+ type: "function",
372
+ function: {
373
+ name: "web_fetch",
374
+ description: "Fetch a public HTTP(S) URL and return its content as readable plain text " +
375
+ "(HTML converted to text, JSON pretty-printed). WHEN TO USE: the task depends on external, " +
376
+ "possibly outdated knowledge — library/API documentation, an error message or comment " +
377
+ "containing a URL, current package versions or changelogs, standards/specs. " +
378
+ "WHEN NOT TO: the answer is already in the working directory or in the user's request; " +
379
+ "you need a search engine (this tool fetches exact URLs only, it cannot search); " +
380
+ "the content is a binary file (images, archives, PDFs are refused). " +
381
+ "Private/internal addresses (localhost, 10.x, 192.168.x, 127.x) are blocked.",
382
+ parameters: obj({
383
+ url: str("Absolute http(s) URL to fetch"),
384
+ max_chars: str("Max characters of extracted content to return (default 20000, max 100000)"),
385
+ }, ["url"]),
386
+ },
387
+ },
388
+ run: (ctx, args) => toolWebFetch(ctx, args),
352
389
  },
353
390
  ];
354
391
  export function getTool(name) {
package/dist/ui.js CHANGED
@@ -1,12 +1,14 @@
1
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";
2
+ // Honor NO_COLOR and non-TTY output: drop ANSI codes so logs/CI stay clean.
3
+ const COLOR = process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
4
+ const RESET = COLOR ? "\x1b[0m" : "";
5
+ const BOLD = COLOR ? "\x1b[1m" : "";
6
+ const DIM = COLOR ? "\x1b[2m" : "";
7
+ const GREEN = COLOR ? "\x1b[32m" : "";
8
+ const YELLOW = COLOR ? "\x1b[33m" : "";
9
+ const RED = COLOR ? "\x1b[31m" : "";
10
+ const CYAN = COLOR ? "\x1b[36m" : "";
11
+ const BLUE = COLOR ? "\x1b[34m" : "";
10
12
  function ts() {
11
13
  return new Date().toISOString().slice(11, 23);
12
14
  }
@@ -14,7 +16,7 @@ export function info(msg) {
14
16
  console.log(`${DIM}[${ts()}]${RESET} ${msg}`);
15
17
  }
16
18
  export function step(stepNum, total, msg) {
17
- console.log(`${BLUE}[${stepNum}/${total}]${RESET} ${BOLD}${msg}${RESET}`);
19
+ console.log(`${BLUE}[${String(stepNum).padStart(2)}/${total}]${RESET} ${BOLD}▸ ${msg}${RESET}`);
18
20
  }
19
21
  export function success(msg) {
20
22
  console.log(`${GREEN}✓${RESET} ${GREEN}${msg}${RESET}`);
@@ -37,7 +39,7 @@ export function toolCall(name, args) {
37
39
  export function toolResult(name, ok, output) {
38
40
  const preview = output.split("\n")[0]?.slice(0, 120) ?? "";
39
41
  const icon = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
40
- console.log(`${icon} ${CYAN}${name}${RESET} → ${preview}`);
42
+ console.log(`${icon} ${CYAN}${name}${RESET} ${DIM}→${RESET} ${preview}`);
41
43
  }
42
44
  export function buildStatus(success, output) {
43
45
  const icon = success ? `${GREEN}✓ BUILD SUCCESS` : `${RED}✗ BUILD FAILED`;
@@ -46,6 +48,25 @@ export function buildStatus(success, output) {
46
48
  export function header(msg) {
47
49
  console.log(`\n${BOLD}${CYAN}── ${msg} ${"─".repeat(Math.max(2, 60 - msg.length))}${RESET}`);
48
50
  }
51
+ /** Startup banner: a clean key/value box (plain-text fallback when piped). */
52
+ export function banner(title, rows) {
53
+ const keyWidth = Math.max(8, ...rows.map(([k]) => k.length));
54
+ const inner = Math.max(title.length + 4, ...rows.map(([, v]) => keyWidth + Math.max(...String(v).split("\n").map((l) => l.length)) + 1));
55
+ console.log(`${BOLD}${CYAN}╭─ ${title} ${"─".repeat(Math.max(1, inner - title.length - 3))}╮${RESET}`);
56
+ for (const [key, value] of rows) {
57
+ const lines = String(value).split("\n");
58
+ for (let i = 0; i < lines.length; i++) {
59
+ const k = (i === 0 ? key : "").padEnd(keyWidth);
60
+ let v = lines[i];
61
+ const maxV = inner - keyWidth - 1;
62
+ if (v.length > maxV)
63
+ v = v.slice(0, maxV - 1) + "…";
64
+ const pad = Math.max(0, inner - 1 - keyWidth - v.length);
65
+ console.log(`${BOLD}${CYAN}│${RESET} ${DIM}${k}${RESET}${v}${" ".repeat(pad)}${BOLD}${CYAN}│${RESET}`);
66
+ }
67
+ }
68
+ console.log(`${BOLD}${CYAN}╰${"─".repeat(inner)}╯${RESET}`);
69
+ }
49
70
  // ── Streaming helpers ────────────────────────────────────────────────────────
50
71
  let _streamLine = "";
51
72
  let _streamColor = CYAN;
@@ -69,6 +90,51 @@ export function llmStreamEnd(suffix = "") {
69
90
  process.stdout.write(`${suffix}\n`);
70
91
  return line;
71
92
  }
93
+ // ── Tool output streaming (live run_command output) ─────────────────────────
94
+ const TOOL_STREAM_MAX_CHARS = 20_000;
95
+ let _toolStreamOpen = false;
96
+ let _toolStreamLineOpen = false; // last chunk did not end with "\n"
97
+ let _toolStreamChars = 0;
98
+ /** Begin a live tool-output block. Output is printed dimmed, capped at 20k chars. */
99
+ export function toolStreamStart() {
100
+ if (_toolStreamOpen)
101
+ return;
102
+ _toolStreamOpen = true;
103
+ _toolStreamLineOpen = false;
104
+ _toolStreamChars = 0;
105
+ process.stdout.write(`${DIM}┌ output${RESET}`);
106
+ }
107
+ /** Append a tool-output delta to the live block. */
108
+ export function toolStreamChunk(delta) {
109
+ if (!_toolStreamOpen)
110
+ return;
111
+ if (_toolStreamChars >= TOOL_STREAM_MAX_CHARS)
112
+ return;
113
+ _toolStreamChars += delta.length;
114
+ if (_toolStreamChars >= TOOL_STREAM_MAX_CHARS) {
115
+ // Include the tail of this chunk up to the cap, then stop rendering live.
116
+ const keep = delta.slice(0, Math.max(0, delta.length - (_toolStreamChars - TOOL_STREAM_MAX_CHARS)));
117
+ if (keep.length > 0) {
118
+ process.stdout.write(`${DIM}${keep}${RESET}`);
119
+ _toolStreamLineOpen = !keep.endsWith("\n");
120
+ }
121
+ process.stdout.write(`\n${DIM}│ (live output truncated at ${TOOL_STREAM_MAX_CHARS} chars — full output in the result)${RESET}`);
122
+ return;
123
+ }
124
+ process.stdout.write(`\n${DIM}│ ${delta.replace(/\n/g, `\n${DIM}│ ${RESET}`)}${RESET}`);
125
+ _toolStreamLineOpen = !delta.endsWith("\n");
126
+ }
127
+ /** Finish the live tool-output block (closes the partial last line). */
128
+ export function toolStreamEnd() {
129
+ if (!_toolStreamOpen)
130
+ return;
131
+ _toolStreamOpen = false;
132
+ process.stdout.write(_toolStreamLineOpen ? `\n${DIM}└${RESET}\n` : `${DIM}└${RESET}\n`);
133
+ }
134
+ /** True while a live tool-output block is open. */
135
+ export function isToolStreamOpen() {
136
+ return _toolStreamOpen;
137
+ }
72
138
  // ── Diff rendering ───────────────────────────────────────────────────────────
73
139
  /** Print a unified diff with colors (+ green, - red, @@ cyan). */
74
140
  export function printDiff(diffText) {
@@ -0,0 +1,251 @@
1
+ import { isIP } from "node:net";
2
+ import { lookup } from "node:dns/promises";
3
+ /**
4
+ * web_fetch — read-only HTTP(S) fetch with SSRF protection.
5
+ *
6
+ * - Only http/https; follows up to 5 redirects, validating every hop.
7
+ * - Private/loopback/link-local addresses are blocked by default
8
+ * (env SCODER_WEB_ALLOW_PRIVATE=1 lifts the block for local dev/testing).
9
+ * - HTML is converted to plain text; JSON is pretty-printed; binary is refused.
10
+ * - Output is capped so a huge page cannot flood the LLM context.
11
+ */
12
+ const FETCH_TIMEOUT_MS = 30_000;
13
+ const MAX_BODY_BYTES = 512 * 1024;
14
+ const MAX_REDIRECTS = 5;
15
+ const DEFAULT_MAX_CHARS = 20_000;
16
+ function allowPrivate() {
17
+ return process.env.SCODER_WEB_ALLOW_PRIVATE === "1";
18
+ }
19
+ /** True if the address is private/loopback/link-local (IPv4 + IPv6). */
20
+ function isPrivateAddress(ip) {
21
+ const v = isIP(ip);
22
+ if (v === 4) {
23
+ const parts = ip.split(".").map(Number);
24
+ const a = parts[0];
25
+ const b = parts[1];
26
+ if (a === 10 || a === 127 || a === 0)
27
+ return true;
28
+ if (a === 172 && b >= 16 && b <= 31)
29
+ return true;
30
+ if (a === 192 && b === 168)
31
+ return true;
32
+ if (a === 169 && b === 254)
33
+ return true; // link-local
34
+ if (a === 100 && b >= 64 && b <= 127)
35
+ return true; // CGNAT
36
+ return false;
37
+ }
38
+ if (v === 6) {
39
+ const lower = ip.toLowerCase();
40
+ if (lower === "::1" || lower === "::")
41
+ return true;
42
+ if (lower.startsWith("fe80"))
43
+ return true; // link-local
44
+ if (/^f[cd][0-9a-f]{2}:/.test(lower))
45
+ return true; // unique local fc00::/7
46
+ // IPv4-mapped ::ffff:10.0.0.1
47
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
48
+ if (mapped)
49
+ return isPrivateAddress(mapped[1]);
50
+ return false;
51
+ }
52
+ return true; // not parseable -> treat as private (fail closed)
53
+ }
54
+ /** Resolve hostname and refuse private addresses (unless explicitly allowed). */
55
+ async function assertPublicHost(hostname) {
56
+ if (allowPrivate())
57
+ return;
58
+ let addresses;
59
+ try {
60
+ addresses = await lookup(hostname, { all: true, verbatim: true });
61
+ }
62
+ catch (err) {
63
+ throw new Error(`DNS lookup failed for "${hostname}": ${err.message}`, { cause: err });
64
+ }
65
+ if (addresses.length === 0) {
66
+ throw new Error(`DNS lookup returned no addresses for "${hostname}"`);
67
+ }
68
+ for (const addr of addresses) {
69
+ if (isPrivateAddress(addr.address)) {
70
+ throw new Error(`Blocked: "${hostname}" resolves to a private/loopback address (${addr.address}). ` +
71
+ `web_fetch only reaches public hosts (set SCODER_WEB_ALLOW_PRIVATE=1 to allow local testing).`);
72
+ }
73
+ }
74
+ }
75
+ /** Decode the HTML entities that actually appear in practice. */
76
+ function decodeEntities(s) {
77
+ const named = {
78
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "–", mdash: "—",
79
+ hellip: "…", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", copy: "©", reg: "®",
80
+ };
81
+ return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
82
+ if (body.startsWith("#x") || body.startsWith("#X")) {
83
+ const code = parseInt(body.slice(2), 16);
84
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
85
+ }
86
+ if (body.startsWith("#")) {
87
+ const code = parseInt(body.slice(1), 10);
88
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
89
+ }
90
+ return named[body.toLowerCase()] ?? whole;
91
+ });
92
+ }
93
+ /** Convert HTML to readable plain text (scripts/styles stripped, block tags → newlines). */
94
+ export function htmlToText(html) {
95
+ const out = html
96
+ .replace(/<script[\s\S]*?<\/script\s*>/gi, " ")
97
+ .replace(/<style[\s\S]*?<\/style\s*>/gi, " ")
98
+ .replace(/<noscript[\s\S]*?<\/noscript\s*>/gi, " ")
99
+ .replace(/<!--[\s\S]*?-->/g, " ")
100
+ .replace(/<(br|\/p|\/div|\/section|\/article|\/header|\/footer|\/h[1-6]|\/li|\/tr|\/pre|\/blockquote)[^>]*>/gi, "\n")
101
+ .replace(/<[^>]+>/g, " ")
102
+ .replace(/[ \t]+/g, " ")
103
+ .replace(/ *\n */g, "\n")
104
+ .replace(/\n{3,}/g, "\n\n")
105
+ .trim();
106
+ return decodeEntities(out);
107
+ }
108
+ /** Extract the <title> of an HTML document, if present. */
109
+ function extractTitle(html) {
110
+ const m = html.match(/<title[^>]*>([\s\S]*?)<\/title\s*>/i);
111
+ if (!m)
112
+ return null;
113
+ const t = decodeEntities(m[1]).replace(/\s+/g, " ").trim();
114
+ return t ? t.slice(0, 200) : null;
115
+ }
116
+ /** Read the response body as text, capped at MAX_BODY_BYTES, then cancel the stream. */
117
+ async function readBodyCapped(res) {
118
+ const reader = res.body?.getReader();
119
+ if (!reader)
120
+ return { text: "", truncated: false };
121
+ const decoder = new TextDecoder("utf-8");
122
+ let text = "";
123
+ let received = 0;
124
+ while (true) {
125
+ const { done, value } = await reader.read();
126
+ if (done)
127
+ break;
128
+ received += value.byteLength;
129
+ text += decoder.decode(value, { stream: true });
130
+ if (received >= MAX_BODY_BYTES) {
131
+ try {
132
+ await reader.cancel();
133
+ }
134
+ catch { /* already closed */ }
135
+ return { text: text + decoder.decode(), truncated: true };
136
+ }
137
+ }
138
+ return { text: text + decoder.decode(), truncated: false };
139
+ }
140
+ /** Implementation of the web_fetch tool. Also used by tests via the registry. */
141
+ export async function toolWebFetch(ctx, args) {
142
+ const maxChars = Math.min(Math.max(args.max_chars ?? DEFAULT_MAX_CHARS, 500), 100_000);
143
+ let url;
144
+ try {
145
+ url = new URL(args.url);
146
+ }
147
+ catch {
148
+ return { ok: false, output: `ERROR: invalid URL: ${args.url}` };
149
+ }
150
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
151
+ return { ok: false, output: `ERROR: only http/https URLs are supported (got ${url.protocol})` };
152
+ }
153
+ // Combined timeout + external abort (SIGINT) — manual composition for Node 18 compat
154
+ const controller = new AbortController();
155
+ const timer = setTimeout(() => controller.abort(new Error(`timeout after ${FETCH_TIMEOUT_MS / 1000}s`)), FETCH_TIMEOUT_MS);
156
+ const onExternalAbort = () => controller.abort(new Error("interrupted by user"));
157
+ if (ctx.signal?.aborted)
158
+ onExternalAbort();
159
+ else
160
+ ctx.signal?.addEventListener("abort", onExternalAbort, { once: true });
161
+ const signal = controller.signal;
162
+ let current = url;
163
+ try {
164
+ // Redirect loop with per-hop SSRF validation
165
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
166
+ if (signal.aborted) {
167
+ return { ok: false, output: `ERROR: fetch aborted (${signal.reason?.message ?? "aborted"})` };
168
+ }
169
+ await assertPublicHost(current.hostname);
170
+ const res = await fetch(current, {
171
+ method: "GET",
172
+ redirect: "manual",
173
+ headers: {
174
+ "User-Agent": "scoder-agent (+https://github.com/nbhson/app-scoder-agent)",
175
+ Accept: "text/html, application/json, text/plain;q=0.9, */*;q=0.1",
176
+ },
177
+ signal,
178
+ });
179
+ // Follow redirects manually so every hop is validated against SSRF
180
+ if (res.status >= 300 && res.status < 400) {
181
+ const loc = res.headers.get("location");
182
+ try {
183
+ await res.body?.cancel();
184
+ }
185
+ catch { /* ignore */ }
186
+ if (!loc) {
187
+ return { ok: false, output: `ERROR: ${res.status} redirect without Location header at ${current.href}` };
188
+ }
189
+ if (hop === MAX_REDIRECTS) {
190
+ return { ok: false, output: `ERROR: too many redirects (>${MAX_REDIRECTS}) fetching ${args.url}` };
191
+ }
192
+ current = new URL(loc, current);
193
+ continue;
194
+ }
195
+ if (!res.ok) {
196
+ try {
197
+ await res.body?.cancel();
198
+ }
199
+ catch { /* ignore */ }
200
+ return { ok: false, output: `ERROR: HTTP ${res.status} ${res.statusText} for ${current.href}` };
201
+ }
202
+ const contentType = res.headers.get("content-type") ?? "";
203
+ if (/^(image|audio|video|font|application\/(octet-stream|zip|gzip|pdf|wasm))/i.test(contentType)) {
204
+ try {
205
+ await res.body?.cancel();
206
+ }
207
+ catch { /* ignore */ }
208
+ return { ok: false, output: `ERROR: unsupported content-type "${contentType}" — web_fetch returns text only (html/json/plain).` };
209
+ }
210
+ const { text: body, truncated } = await readBodyCapped(res);
211
+ let text;
212
+ if (/json/i.test(contentType)) {
213
+ text = prettyJson(body) ?? body;
214
+ }
215
+ else if (/html/i.test(contentType) || /^\s*</.test(body)) {
216
+ text = htmlToText(body);
217
+ }
218
+ else {
219
+ text = body;
220
+ }
221
+ const title = /text\/html/i.test(contentType) ? extractTitle(body) : null;
222
+ const meta = [
223
+ `HTTP ${res.status} ${res.statusText || ""}`.trimEnd(),
224
+ contentType || "unknown content-type",
225
+ truncated ? `body truncated at ${MAX_BODY_BYTES / 1024}KB` : null,
226
+ ].filter(Boolean).join(", ");
227
+ const out = `${text.slice(0, maxChars)}${text.length > maxChars ? `\n... [truncated, ${text.length - maxChars} more chars]` : ""}`;
228
+ return {
229
+ ok: true,
230
+ output: `${title ? `Title: ${title}\n` : ""}${current.href}\n(${meta})\n\n${out}`,
231
+ };
232
+ }
233
+ // unreachable
234
+ return { ok: false, output: "ERROR: redirect loop" };
235
+ }
236
+ catch (err) {
237
+ return { ok: false, output: `ERROR: fetch failed for ${current.href}: ${err.message}` };
238
+ }
239
+ finally {
240
+ clearTimeout(timer);
241
+ ctx.signal?.removeEventListener("abort", onExternalAbort);
242
+ }
243
+ }
244
+ function prettyJson(body) {
245
+ try {
246
+ return JSON.stringify(JSON.parse(body), null, 2);
247
+ }
248
+ catch {
249
+ return undefined;
250
+ }
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Autonomous coding agent CLI - similar to opencode/cline/claude. Runs tasks until build succeeds.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,6 +9,8 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc",
12
+ "prepare": "npm run build",
13
+ "prepublishOnly": "npm run lint && npm run typecheck && npm test",
12
14
  "start": "node dist/index.js",
13
15
  "dev": "tsc --watch",
14
16
  "typecheck": "tsc --noEmit",
@@ -48,4 +50,4 @@
48
50
  "typescript": "^5.6.0",
49
51
  "typescript-eslint": "^8.72.1"
50
52
  }
51
- }
53
+ }