@zhongqian97-code/ecode 0.2.6 → 0.3.2

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.
Files changed (2) hide show
  1. package/dist/index.js +1404 -128
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,10 +3,11 @@ const _ew=process.emitWarning.bind(process);process.emitWarning=function(w,...a)
3
3
 
4
4
  // src/index.ts
5
5
  import { createRequire } from "module";
6
- import { resolve, dirname as dirname2 } from "path";
6
+ import { resolve as resolve4, dirname as dirname6 } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import React4 from "react";
9
9
  import { render } from "ink";
10
+ import { readFileSync as readFileSync2 } from "fs";
10
11
 
11
12
  // src/config.ts
12
13
  import { existsSync, readFileSync } from "fs";
@@ -74,7 +75,10 @@ function loadConfig() {
74
75
  try {
75
76
  const raw = readFileSync(configPath, "utf-8");
76
77
  fileConfig = JSON.parse(raw);
77
- } catch {
78
+ } catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ process.stderr.write(`[ecode] warning: ${configPath} parse failed (${msg}), falling back to defaults/env
81
+ `);
78
82
  }
79
83
  }
80
84
  return {
@@ -96,7 +100,7 @@ function loadConfig() {
96
100
 
97
101
  // src/ui/App.tsx
98
102
  import { useState as useState3, useCallback, useRef as useRef2, useEffect as useEffect3, useMemo } from "react";
99
- import { Box as Box5, useInput as useInput2, useStdout, useStdin } from "ink";
103
+ import { Box as Box6, useInput as useInput2, useStdout, useStdin } from "ink";
100
104
 
101
105
  // src/llm.ts
102
106
  import OpenAI from "openai";
@@ -120,7 +124,7 @@ function createLLMClient(config2) {
120
124
  * @param messages 完整的对话历史,包含 user/assistant/tool 所有轮次
121
125
  * @param tools 可选的工具列表,不传或传空数组时不附加 tools 字段
122
126
  */
123
- stream(messages, tools) {
127
+ stream(messages, tools, signal) {
124
128
  return {
125
129
  [Symbol.asyncIterator]: async function* () {
126
130
  const requestParams = {
@@ -133,7 +137,8 @@ function createLLMClient(config2) {
133
137
  requestParams.tools = tools;
134
138
  }
135
139
  const response = await openai.chat.completions.create(
136
- requestParams
140
+ requestParams,
141
+ signal ? { signal } : void 0
137
142
  );
138
143
  const tcAccumulator = /* @__PURE__ */ new Map();
139
144
  let reasoningAccumulator = "";
@@ -226,15 +231,111 @@ var DEFAULT_DANGER_LIST = [
226
231
  "curl -X DELETE",
227
232
  "wget --delete-after"
228
233
  ];
234
+ var INDIRECT_EXEC_LIST = [
235
+ "xargs",
236
+ "python -c",
237
+ "python3 -c",
238
+ "node -e",
239
+ "perl -e",
240
+ "ruby -e"
241
+ ];
242
+ function hasFindExec(cmd) {
243
+ return /^find(\s|$)/.test(cmd) && /\s-exec(\s|$)/.test(cmd);
244
+ }
229
245
  function matchesEntry(cmd, entry) {
230
246
  return cmd === entry || cmd.startsWith(entry + " ");
231
247
  }
232
- function classifyCommand(cmd, dangerPatterns) {
248
+ function splitByControlOps(cmd) {
249
+ const segments = [];
250
+ let current = "";
251
+ let inSingle = false;
252
+ let inDouble = false;
253
+ let i = 0;
254
+ while (i < cmd.length) {
255
+ const ch = cmd[i];
256
+ if (ch === "'" && !inDouble) {
257
+ inSingle = !inSingle;
258
+ current += ch;
259
+ i++;
260
+ continue;
261
+ }
262
+ if (ch === '"' && !inSingle) {
263
+ inDouble = !inDouble;
264
+ current += ch;
265
+ i++;
266
+ continue;
267
+ }
268
+ if (inSingle || inDouble) {
269
+ if (inDouble && ch === "\\" && i + 1 < cmd.length) {
270
+ current += ch + cmd[i + 1];
271
+ i += 2;
272
+ } else {
273
+ current += ch;
274
+ i++;
275
+ }
276
+ continue;
277
+ }
278
+ if (ch === "&" && cmd[i + 1] === "&") {
279
+ segments.push(current.trim());
280
+ current = "";
281
+ i += 2;
282
+ continue;
283
+ }
284
+ if (ch === "|" && cmd[i + 1] === "|") {
285
+ segments.push(current.trim());
286
+ current = "";
287
+ i += 2;
288
+ continue;
289
+ }
290
+ if (ch === ";" || ch === "|") {
291
+ segments.push(current.trim());
292
+ current = "";
293
+ i++;
294
+ continue;
295
+ }
296
+ current += ch;
297
+ i++;
298
+ }
299
+ segments.push(current.trim());
300
+ return segments.filter((s) => s.length > 0);
301
+ }
302
+ function stripShellWrapper(cmd) {
303
+ const trimmed = cmd.trim();
304
+ const dq = trimmed.match(/^(?:bash|sh)\s+-c\s+"((?:[^"\\]|\\.)*)"/);
305
+ if (dq) return dq[1];
306
+ const sq = trimmed.match(/^(?:bash|sh)\s+-c\s+'([^']*)'/);
307
+ if (sq) return sq[1];
308
+ const nq = trimmed.match(/^(?:bash|sh)\s+-c\s+(\S+)/);
309
+ if (nq) return nq[1];
310
+ return trimmed;
311
+ }
312
+ function classifyCommand(cmd, dangerPatterns, _depth = 0) {
233
313
  const trimmed = cmd.trim();
234
314
  const patterns = dangerPatterns ?? DEFAULT_DANGER_LIST;
315
+ if (_depth < 5) {
316
+ const inner = stripShellWrapper(trimmed);
317
+ if (inner !== trimmed) {
318
+ const innerClass = classifyCommand(inner.trim(), dangerPatterns, _depth + 1);
319
+ if (innerClass === "danger") return "danger";
320
+ return "normal";
321
+ }
322
+ }
323
+ const segments = splitByControlOps(trimmed);
324
+ if (segments.length > 1) {
325
+ for (const seg of segments) {
326
+ if (classifyCommand(seg, dangerPatterns, _depth + 1) === "danger") {
327
+ return "danger";
328
+ }
329
+ }
330
+ return "normal";
331
+ }
235
332
  for (const entry of patterns) {
236
333
  if (matchesEntry(trimmed, entry)) return "danger";
237
334
  }
335
+ for (const entry of INDIRECT_EXEC_LIST) {
336
+ if (matchesEntry(trimmed, entry)) return "danger";
337
+ }
338
+ if (hasFindExec(trimmed)) return "danger";
238
339
  for (const entry of ALLOWLIST) {
239
340
  if (matchesEntry(trimmed, entry)) return "allow";
240
341
  }
@@ -245,18 +346,671 @@ function classifyCommand(cmd, dangerPatterns) {
245
346
  import { exec } from "child_process";
246
347
  var DEFAULT_TIMEOUT_MS = 3e4;
247
348
  function executeBash(cmd, timeoutMs = DEFAULT_TIMEOUT_MS) {
248
- return new Promise((resolve2) => {
349
+ return new Promise((resolve5) => {
249
350
  exec(cmd, { timeout: timeoutMs }, (err, stdout, stderr) => {
250
351
  if (err) {
251
352
  const exitCode = err.code ?? 1;
252
- resolve2({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
353
+ resolve5({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode });
253
354
  } else {
254
- resolve2({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 });
355
+ resolve5({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 });
255
356
  }
256
357
  });
257
358
  });
258
359
  }
259
360
 
361
+ // src/tools/read.ts
362
+ import * as fs from "fs/promises";
363
+ var READ_TOOL = {
364
+ type: "function",
365
+ function: {
366
+ name: "read",
367
+ description: "\u8BFB\u53D6\u6587\u4EF6\u5185\u5BB9\u3002\u4F7F\u7528 offset\uFF080-based \u884C\u7D22\u5F15\uFF09\u548C limit \u53EF\u8BFB\u53D6\u6307\u5B9A\u884C\u8303\u56F4\uFF0C\u9002\u5408\u5927\u6587\u4EF6\u5C40\u90E8\u9605\u8BFB\u3002",
368
+ parameters: {
369
+ type: "object",
370
+ properties: {
371
+ path: { type: "string", description: "\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\u6216\u76F8\u5BF9\u8DEF\u5F84\u3002" },
372
+ offset: { type: "number", description: "\u8D77\u59CB\u884C\u7D22\u5F15\uFF080-based\uFF0C\u9ED8\u8BA4 0\uFF09\u3002" },
373
+ limit: { type: "number", description: "\u6700\u591A\u8FD4\u56DE\u7684\u884C\u6570\u3002" }
374
+ },
375
+ required: ["path"]
376
+ }
377
+ }
378
+ };
379
+ async function readFile2(params) {
380
+ const { path: path7, offset = 0, limit } = params;
381
+ let raw;
382
+ try {
383
+ raw = await fs.readFile(path7, "utf8");
384
+ } catch (err) {
385
+ const msg = err instanceof Error ? err.message : String(err);
386
+ return `Error reading ${path7}: ${msg}`;
387
+ }
388
+ const lines = raw.split("\n");
389
+ const sliced = limit !== void 0 ? lines.slice(offset, offset + limit) : lines.slice(offset);
390
+ return sliced.map((line, i) => `${String(offset + i + 1).padStart(4)} ${line}`).join("\n");
391
+ }
392
+
393
+ // src/tools/write.ts
394
+ import * as fs2 from "fs/promises";
395
+ import * as path from "path";
396
+ var WRITE_TOOL = {
397
+ type: "function",
398
+ function: {
399
+ name: "write",
400
+ description: "\u5C06\u5185\u5BB9\u5199\u5165\u6587\u4EF6\uFF08\u8986\u76D6\u6A21\u5F0F\uFF09\uFF0C\u81EA\u52A8\u521B\u5EFA\u7236\u76EE\u5F55\u3002\u5199\u5165\u524D\u8BF7\u5148\u7528 read \u786E\u8BA4\u73B0\u6709\u5185\u5BB9\u3002",
401
+ parameters: {
402
+ type: "object",
403
+ properties: {
404
+ path: { type: "string", description: "\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\u6216\u76F8\u5BF9\u8DEF\u5F84\u3002" },
405
+ content: { type: "string", description: "\u8981\u5199\u5165\u7684\u5B8C\u6574\u5185\u5BB9\u3002" }
406
+ },
407
+ required: ["path", "content"]
408
+ }
409
+ }
410
+ };
411
+ async function writeFile2(params) {
412
+ const { path: filePath, content } = params;
413
+ try {
414
+ await fs2.mkdir(path.dirname(filePath), { recursive: true });
415
+ await fs2.writeFile(filePath, content, "utf8");
416
+ return `Written ${filePath}`;
417
+ } catch (err) {
418
+ const msg = err instanceof Error ? err.message : String(err);
419
+ return `Error writing ${filePath}: ${msg}`;
420
+ }
421
+ }
422
+
423
+ // src/tools/edit.ts
424
+ import * as fs3 from "fs/promises";
425
+ var EDIT_TOOL = {
426
+ type: "function",
427
+ function: {
428
+ name: "edit",
429
+ description: "\u5728\u6587\u4EF6\u4E2D\u7CBE\u786E\u66FF\u6362\u4E00\u6BB5\u5B57\u7B26\u4E32\u3002old_string \u5728\u6587\u4EF6\u4E2D\u5FC5\u987B\u552F\u4E00\u51FA\u73B0\u2014\u2014\u82E5\u6709\u6B67\u4E49\u8BF7\u63D0\u4F9B\u66F4\u591A\u4E0A\u4E0B\u6587\u3002",
430
+ parameters: {
431
+ type: "object",
432
+ properties: {
433
+ path: { type: "string", description: "\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\u6216\u76F8\u5BF9\u8DEF\u5F84\u3002" },
434
+ old_string: { type: "string", description: "\u8981\u67E5\u627E\u7684\u7CBE\u786E\u5B57\u7B26\u4E32\uFF08\u5728\u6587\u4EF6\u4E2D\u5FC5\u987B\u552F\u4E00\uFF09\u3002" },
435
+ new_string: { type: "string", description: "\u66FF\u6362\u540E\u7684\u5B57\u7B26\u4E32\u3002" }
436
+ },
437
+ required: ["path", "old_string", "new_string"]
438
+ }
439
+ }
440
+ };
441
+ async function editFile(params) {
442
+ const { path: path7, old_string, new_string } = params;
443
+ let content;
444
+ try {
445
+ content = await fs3.readFile(path7, "utf8");
446
+ } catch (err) {
447
+ const msg = err instanceof Error ? err.message : String(err);
448
+ return `Error reading ${path7}: ${msg}`;
449
+ }
450
+ const count = countOccurrences(content, old_string);
451
+ if (count === 0) {
452
+ return `Error: old_string not found in ${path7}`;
453
+ }
454
+ if (count > 1) {
455
+ return `Error: old_string appears ${count} times in ${path7} (ambiguous \u2014 add more context)`;
456
+ }
457
+ const updated = content.replace(old_string, new_string);
458
+ try {
459
+ await fs3.writeFile(path7, updated, "utf8");
460
+ return `Edited ${path7}`;
461
+ } catch (err) {
462
+ const msg = err instanceof Error ? err.message : String(err);
463
+ return `Error writing ${path7}: ${msg}`;
464
+ }
465
+ }
466
+ function countOccurrences(haystack, needle) {
467
+ if (needle === "") return 0;
468
+ let count = 0;
469
+ let pos = 0;
470
+ while ((pos = haystack.indexOf(needle, pos)) !== -1) {
471
+ count++;
472
+ pos += needle.length;
473
+ }
474
+ return count;
475
+ }
476
+
477
+ // src/tools/glob.ts
478
+ import * as fs4 from "fs/promises";
479
+ import * as path2 from "path";
480
+ var GLOB_TOOL = {
481
+ type: "function",
482
+ function: {
483
+ name: "glob",
484
+ description: "Find files matching a glob pattern. Returns relative paths (one per line). Default excludes node_modules, .git and hidden dirs; pass includeHidden=true to include them.",
485
+ parameters: {
486
+ type: "object",
487
+ properties: {
488
+ pattern: { type: "string", description: "Glob pattern, e.g. '**/*.ts', 'src/**', '*.md'" },
489
+ cwd: { type: "string", description: "Base directory to search in (default: current working directory)" },
490
+ includeHidden: { type: "boolean", description: "If true, include hidden files/dirs (starting with '.')" },
491
+ limit: { type: "number", description: "Max number of results to return (default 100)" }
492
+ },
493
+ required: ["pattern"]
494
+ }
495
+ }
496
+ };
497
+ function globToRegex(pattern) {
498
+ const hasSlash = pattern.includes("/");
499
+ const DSS = "\0DSS\0";
500
+ const DS = "\0DS\0";
501
+ const SS = "\0SS\0";
502
+ const QQ = "\0QQ\0";
503
+ let p = pattern.replace(/\*\*\//g, DSS).replace(/\*\*/g, DS).replace(/\*/g, SS).replace(/\?/g, QQ);
504
+ p = p.replace(/[.+^${}()|[\]\\]/g, "\\$&");
505
+ p = p.replace(new RegExp(DSS, "g"), "(.*/)?").replace(new RegExp(DS, "g"), ".*").replace(new RegExp(SS, "g"), "[^/]*").replace(new RegExp(QQ, "g"), "[^/]");
506
+ if (!hasSlash) {
507
+ }
508
+ return new RegExp(`^${p}$`);
509
+ }
510
+ async function walk(dir, cwd, regex, includeHidden, results, total, limit) {
511
+ let entries;
512
+ try {
513
+ entries = await fs4.readdir(dir, { withFileTypes: true });
514
+ } catch {
515
+ return;
516
+ }
517
+ entries.sort((a, b) => a.name.localeCompare(b.name));
518
+ for (const entry of entries) {
519
+ const name = entry.name;
520
+ if (name === "node_modules") continue;
521
+ if (!includeHidden && name.startsWith(".")) continue;
522
+ const abs = path2.join(dir, name);
523
+ const rel = path2.relative(cwd, abs);
524
+ if (entry.isDirectory()) {
525
+ await walk(abs, cwd, regex, includeHidden, results, total, limit);
526
+ } else if (entry.isFile()) {
527
+ const relPosix = rel.split(path2.sep).join("/");
528
+ if (regex.test(relPosix)) {
529
+ total.count++;
530
+ if (results.length < limit) {
531
+ results.push(relPosix);
532
+ }
533
+ }
534
+ }
535
+ }
536
+ }
537
+ async function globFiles(params) {
538
+ const {
539
+ pattern,
540
+ cwd = process.cwd(),
541
+ includeHidden = false,
542
+ limit = 100
543
+ } = params;
544
+ try {
545
+ const stat5 = await fs4.stat(cwd);
546
+ if (!stat5.isDirectory()) {
547
+ return `Error: ${cwd} is not a directory`;
548
+ }
549
+ } catch (err) {
550
+ const msg = err instanceof Error ? err.message : String(err);
551
+ return `Error: ${msg}`;
552
+ }
553
+ const regex = globToRegex(pattern);
554
+ const results = [];
555
+ const total = { count: 0 };
556
+ await walk(cwd, cwd, regex, includeHidden, results, total, limit);
557
+ if (results.length === 0) {
558
+ return `No files found matching pattern: ${pattern}`;
559
+ }
560
+ results.sort();
561
+ let output = results.join("\n");
562
+ if (total.count > limit) {
563
+ output += `
564
+ ... (truncated, ${total.count} total matches)`;
565
+ }
566
+ return output;
567
+ }
568
+
569
+ // src/tools/grep.ts
570
+ import * as fs5 from "fs/promises";
571
+ import * as path3 from "path";
572
+ var GREP_TOOL = {
573
+ type: "function",
574
+ function: {
575
+ name: "grep",
576
+ description: "\u5728\u6587\u4EF6\u4E2D\u641C\u7D22\u6B63\u5219\u8868\u8FBE\u5F0F\u6A21\u5F0F\u3002\u8FD4\u56DE file:linenum:content \u683C\u5F0F\uFF0Ccontext \u884C\u7528 -- \u5206\u9694\u3002",
577
+ parameters: {
578
+ type: "object",
579
+ properties: {
580
+ pattern: { type: "string", description: "\u6B63\u5219\u8868\u8FBE\u5F0F\u6A21\u5F0F\u3002" },
581
+ path: { type: "string", description: "\u641C\u7D22\u7684\u6587\u4EF6\u6216\u76EE\u5F55\u8DEF\u5F84\uFF08\u9ED8\u8BA4\u5F53\u524D\u76EE\u5F55\uFF09\u3002" },
582
+ cwd: { type: "string", description: "\u5DE5\u4F5C\u76EE\u5F55\uFF08\u9ED8\u8BA4 process.cwd()\uFF09\u3002" },
583
+ context: { type: "number", description: "\u5339\u914D\u524D\u540E\u7684\u4E0A\u4E0B\u6587\u884C\u6570\uFF08\u9ED8\u8BA4 0\uFF09\u3002" },
584
+ includeHidden: { type: "boolean", description: "\u662F\u5426\u5305\u542B\u9690\u85CF\u76EE\u5F55\uFF08\u9ED8\u8BA4 false\uFF09\u3002" },
585
+ limit: { type: "number", description: "\u6700\u5927\u8FD4\u56DE\u5339\u914D\u6570\uFF08\u9ED8\u8BA4 50\uFF09\u3002" }
586
+ },
587
+ required: ["pattern"]
588
+ }
589
+ }
590
+ };
591
+ async function searchFile(absFile, displayFile, regex, matches, totalCount, limit) {
592
+ let text;
593
+ try {
594
+ text = await fs5.readFile(absFile, "utf8");
595
+ } catch {
596
+ return;
597
+ }
598
+ const lines = text.split("\n");
599
+ const lastIdx = lines.length - 1;
600
+ for (let i = 0; i < lines.length; i++) {
601
+ if (i === lastIdx && lines[i] === "") continue;
602
+ if (regex.test(lines[i])) {
603
+ totalCount.count++;
604
+ if (matches.length < limit) {
605
+ matches.push({ file: displayFile, line: i + 1, content: lines[i] });
606
+ }
607
+ }
608
+ }
609
+ }
610
+ async function walkDir(dir, cwd, regex, includeHidden, matches, totalCount, limit) {
611
+ let entries;
612
+ try {
613
+ entries = await fs5.readdir(dir, { withFileTypes: true });
614
+ } catch {
615
+ return;
616
+ }
617
+ entries.sort((a, b) => a.name.localeCompare(b.name));
618
+ for (const entry of entries) {
619
+ const name = entry.name;
620
+ if (name === "node_modules") continue;
621
+ if (!includeHidden && name.startsWith(".")) continue;
622
+ const abs = path3.join(dir, name);
623
+ if (entry.isDirectory()) {
624
+ await walkDir(abs, cwd, regex, includeHidden, matches, totalCount, limit);
625
+ } else if (entry.isFile()) {
626
+ const rel = path3.relative(cwd, abs).split(path3.sep).join("/");
627
+ await searchFile(abs, rel, regex, matches, totalCount, limit);
628
+ }
629
+ }
630
+ }
631
+ async function renderWithContext(entries, contextLines) {
632
+ const outputLines = [];
633
+ for (const { absFile, displayFile, matches: fileMatches } of entries) {
634
+ let allLines = [];
635
+ try {
636
+ const text = await fs5.readFile(absFile, "utf8");
637
+ allLines = text.split("\n");
638
+ } catch {
639
+ }
640
+ if (allLines.length === 0) {
641
+ for (const m of fileMatches) {
642
+ outputLines.push(`${displayFile}:${m.line}:${m.content}`);
643
+ }
644
+ continue;
645
+ }
646
+ const ranges = [];
647
+ for (const m of fileMatches) {
648
+ const start = Math.max(0, m.line - 1 - contextLines);
649
+ const end = Math.min(allLines.length - 1, m.line - 1 + contextLines);
650
+ const last = ranges[ranges.length - 1];
651
+ if (last && start <= last.end + 1) {
652
+ last.end = Math.max(last.end, end);
653
+ } else {
654
+ ranges.push({ start, end });
655
+ }
656
+ }
657
+ for (let ri = 0; ri < ranges.length; ri++) {
658
+ if (ri > 0) outputLines.push("--");
659
+ const { start, end } = ranges[ri];
660
+ for (let i = start; i <= end; i++) {
661
+ if (i === allLines.length - 1 && allLines[i] === "") continue;
662
+ outputLines.push(`${displayFile}:${i + 1}:${allLines[i]}`);
663
+ }
664
+ }
665
+ }
666
+ return outputLines;
667
+ }
668
+ async function grepFiles(params) {
669
+ const {
670
+ pattern,
671
+ path: searchPath = ".",
672
+ cwd = process.cwd(),
673
+ context = 0,
674
+ includeHidden = false,
675
+ limit = 50
676
+ } = params;
677
+ let regex;
678
+ try {
679
+ regex = new RegExp(pattern);
680
+ } catch (err) {
681
+ const msg = err instanceof Error ? err.message : String(err);
682
+ return `Error: Invalid regex: ${msg}`;
683
+ }
684
+ const target = path3.isAbsolute(searchPath) ? searchPath : path3.resolve(cwd, searchPath);
685
+ let stat5;
686
+ try {
687
+ stat5 = await fs5.stat(target);
688
+ } catch (err) {
689
+ const msg = err instanceof Error ? err.message : String(err);
690
+ return `Error: ${msg}`;
691
+ }
692
+ const matches = [];
693
+ const totalCount = { count: 0 };
694
+ if (stat5.isFile()) {
695
+ await searchFile(target, searchPath, regex, matches, totalCount, limit);
696
+ } else if (stat5.isDirectory()) {
697
+ await walkDir(target, target, regex, includeHidden, matches, totalCount, limit);
698
+ } else {
699
+ return `Error: ${target} is neither a file nor a directory`;
700
+ }
701
+ if (matches.length === 0 && totalCount.count === 0) {
702
+ return "No matches found";
703
+ }
704
+ const matchesByFile = /* @__PURE__ */ new Map();
705
+ for (const m of matches) {
706
+ let group = matchesByFile.get(m.file);
707
+ if (!group) {
708
+ group = [];
709
+ matchesByFile.set(m.file, group);
710
+ }
711
+ group.push(m);
712
+ }
713
+ let outputLines;
714
+ if (context > 0) {
715
+ const baseDir = stat5.isDirectory() ? target : path3.dirname(target);
716
+ const entries = Array.from(matchesByFile.entries()).map(([displayFile, fileMatches]) => {
717
+ const absFile = path3.isAbsolute(displayFile) ? displayFile : path3.resolve(baseDir, displayFile);
718
+ return { absFile, displayFile, matches: fileMatches };
719
+ });
720
+ outputLines = await renderWithContext(entries, context);
721
+ } else {
722
+ outputLines = [];
723
+ for (const m of matches) {
724
+ outputLines.push(`${m.file}:${m.line}:${m.content}`);
725
+ }
726
+ }
727
+ let output = outputLines.join("\n");
728
+ if (totalCount.count > limit) {
729
+ const extra = totalCount.count - limit;
730
+ output += `
731
+ ... (truncated, ${extra} more matches)`;
732
+ }
733
+ return output;
734
+ }
735
+
736
+ // src/tools/apply_patch.ts
737
+ import * as fs6 from "fs/promises";
738
+ import * as path4 from "path";
739
+ var APPLY_PATCH_TOOL = {
740
+ type: "function",
741
+ function: {
742
+ name: "apply_patch",
743
+ description: "\u5C06 unified diff \u683C\u5F0F\u7684 patch \u5E94\u7528\u5230\u6587\u4EF6\u3002\u652F\u6301 context \u4E0A\u4E0B\u6587\u884C\u6A21\u7CCA\u5B9A\u4F4D\uFF08\u5141\u8BB8\u5C11\u91CF\u884C\u53F7\u504F\u79FB\uFF09\u3002",
744
+ parameters: {
745
+ type: "object",
746
+ properties: {
747
+ patch: {
748
+ type: "string",
749
+ description: "Unified diff \u683C\u5F0F\u7684 patch \u5185\u5BB9\u3002"
750
+ },
751
+ file: {
752
+ type: "string",
753
+ description: "\u76EE\u6807\u6587\u4EF6\u8DEF\u5F84\uFF08\u53EF\u9009\uFF0C\u82E5 patch \u4E2D\u5305\u542B --- / +++ \u5934\u5219\u4ECE\u4E2D\u89E3\u6790\uFF09\u3002"
754
+ },
755
+ cwd: {
756
+ type: "string",
757
+ description: "\u5DE5\u4F5C\u76EE\u5F55\uFF08\u9ED8\u8BA4 process.cwd()\uFF09\u3002"
758
+ },
759
+ dryRun: {
760
+ type: "boolean",
761
+ description: "\u82E5\u4E3A true\uFF0C\u4EC5\u6821\u9A8C patch \u662F\u5426\u80FD\u5E94\u7528\uFF0C\u4E0D\u4FEE\u6539\u6587\u4EF6\uFF08\u9ED8\u8BA4 false\uFF09\u3002"
762
+ }
763
+ },
764
+ required: ["patch"]
765
+ }
766
+ }
767
+ };
768
+ function parseFilePath(patch) {
769
+ for (const line of patch.split("\n")) {
770
+ if (line.startsWith("+++ ")) {
771
+ let p = line.slice(4).trim();
772
+ if (p.startsWith("b/")) p = p.slice(2);
773
+ if (p === "/dev/null" || p === "") return null;
774
+ return p;
775
+ }
776
+ }
777
+ return null;
778
+ }
779
+ function parseHunkHeader(header) {
780
+ const m = header.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+\d+(?:,\d+)?\s+@@/);
781
+ if (!m) return null;
782
+ const oldStart = parseInt(m[1], 10) - 1;
783
+ const oldCount = m[2] !== void 0 ? parseInt(m[2], 10) : 1;
784
+ return { oldStart, oldCount };
785
+ }
786
+ function parseHunks(patch) {
787
+ const rawLines = patch.split("\n");
788
+ const hunks = [];
789
+ let current = null;
790
+ for (const raw of rawLines) {
791
+ if (raw.startsWith("diff --git ")) continue;
792
+ if (raw.startsWith("--- ") || raw.startsWith("+++ ")) continue;
793
+ if (raw.startsWith("\\ ")) continue;
794
+ if (raw.startsWith("@@ ")) {
795
+ const parsed = parseHunkHeader(raw);
796
+ if (!parsed) continue;
797
+ current = {
798
+ header: raw,
799
+ oldStart: parsed.oldStart,
800
+ oldCount: parsed.oldCount,
801
+ lines: []
802
+ };
803
+ hunks.push(current);
804
+ continue;
805
+ }
806
+ if (current === null) continue;
807
+ if (raw.startsWith("+")) {
808
+ current.lines.push({ kind: "add", content: raw.slice(1) });
809
+ } else if (raw.startsWith("-")) {
810
+ current.lines.push({ kind: "remove", content: raw.slice(1) });
811
+ } else if (raw.startsWith(" ")) {
812
+ current.lines.push({ kind: "context", content: raw.slice(1) });
813
+ }
814
+ }
815
+ return hunks;
816
+ }
817
+ var FUZZY_RADIUS = 3;
818
+ function fuzzyFind(fileLines, hunk, suggestedStart) {
819
+ const expected = hunk.lines.filter((l) => l.kind === "context" || l.kind === "remove").map((l) => l.content);
820
+ if (expected.length === 0) {
821
+ return Math.max(0, Math.min(suggestedStart, fileLines.length));
822
+ }
823
+ const lo = Math.max(0, suggestedStart - FUZZY_RADIUS);
824
+ const hi = Math.min(
825
+ fileLines.length - expected.length,
826
+ suggestedStart + FUZZY_RADIUS
827
+ );
828
+ for (let start = lo; start <= hi; start++) {
829
+ let match = true;
830
+ for (let i = 0; i < expected.length; i++) {
831
+ if (fileLines[start + i] !== expected[i]) {
832
+ match = false;
833
+ break;
834
+ }
835
+ }
836
+ if (match) return start;
837
+ }
838
+ return null;
839
+ }
840
+ function applyHunk(fileLines, hunk, actualStart) {
841
+ const oldLineCount = hunk.lines.filter(
842
+ (l) => l.kind === "context" || l.kind === "remove"
843
+ ).length;
844
+ const newBlock = [];
845
+ for (const hl of hunk.lines) {
846
+ if (hl.kind === "context" || hl.kind === "add") {
847
+ newBlock.push(hl.content);
848
+ }
849
+ }
850
+ return [
851
+ ...fileLines.slice(0, actualStart),
852
+ ...newBlock,
853
+ ...fileLines.slice(actualStart + oldLineCount)
854
+ ];
855
+ }
856
+ async function applyPatch(params) {
857
+ const { patch, file: fileParam, cwd = process.cwd(), dryRun = false } = params;
858
+ let filePath;
859
+ if (fileParam) {
860
+ filePath = path4.isAbsolute(fileParam) ? fileParam : path4.resolve(cwd, fileParam);
861
+ } else {
862
+ const parsed = parseFilePath(patch);
863
+ if (!parsed) {
864
+ return "Error: no target file path found in patch headers (--- / +++ missing) and no 'file' param provided";
865
+ }
866
+ filePath = path4.isAbsolute(parsed) ? parsed : path4.resolve(cwd, parsed);
867
+ }
868
+ let originalContent;
869
+ try {
870
+ originalContent = await fs6.readFile(filePath, "utf8");
871
+ } catch (err) {
872
+ const msg = err instanceof Error ? err.message : String(err);
873
+ return `Error: ${msg}`;
874
+ }
875
+ const hasTrailingNewline = originalContent.endsWith("\n");
876
+ const rawLines = originalContent.split("\n");
877
+ let fileLines = hasTrailingNewline ? rawLines.slice(0, -1) : rawLines;
878
+ const hunks = parseHunks(patch);
879
+ if (hunks.length === 0) {
880
+ return `Error: no valid hunks found in patch`;
881
+ }
882
+ let lineOffset = 0;
883
+ for (const hunk of hunks) {
884
+ const suggestedStart = hunk.oldStart + lineOffset;
885
+ const actualStart = fuzzyFind(fileLines, hunk, suggestedStart);
886
+ if (actualStart === null) {
887
+ return `Error: hunk ${hunk.header} does not apply (context mismatch)`;
888
+ }
889
+ const addCount = hunk.lines.filter((l) => l.kind === "add").length;
890
+ const removeCount = hunk.lines.filter((l) => l.kind === "remove").length;
891
+ fileLines = applyHunk(fileLines, hunk, actualStart);
892
+ lineOffset += addCount - removeCount;
893
+ }
894
+ const displayPath = path4.relative(cwd, filePath) || filePath;
895
+ if (dryRun) {
896
+ return `Patch applies cleanly to ${displayPath} (dry run)`;
897
+ }
898
+ const newContent = fileLines.join("\n") + (hasTrailingNewline ? "\n" : "");
899
+ try {
900
+ await fs6.writeFile(filePath, newContent, "utf8");
901
+ } catch (err) {
902
+ const msg = err instanceof Error ? err.message : String(err);
903
+ return `Error: failed to write file: ${msg}`;
904
+ }
905
+ return `Applied ${hunks.length} hunk(s) to ${displayPath}`;
906
+ }
907
+
908
+ // src/tools/todo.ts
909
+ var TODO_TOOL = {
910
+ type: "function",
911
+ function: {
912
+ name: "todo",
913
+ description: "\u7BA1\u7406\u5F53\u524D\u4F1A\u8BDD\u7684\u4EFB\u52A1\u6E05\u5355\u3002op=read \u8BFB\u53D6\uFF0Cop=write \u5168\u91CF\u8986\u5199\uFF0Cop=update \u66F4\u65B0\u5355\u9879\u3002",
914
+ parameters: {
915
+ type: "object",
916
+ properties: {
917
+ op: {
918
+ type: "string",
919
+ enum: ["read", "write", "update"],
920
+ description: "\u64CD\u4F5C\u7C7B\u578B\u3002"
921
+ },
922
+ todos: {
923
+ type: "array",
924
+ description: "write \u64CD\u4F5C\uFF1A\u5168\u91CF\u66FF\u6362\u7684\u4EFB\u52A1\u5217\u8868\u3002",
925
+ items: {
926
+ type: "object",
927
+ properties: {
928
+ id: { type: "string" },
929
+ content: { type: "string" },
930
+ status: {
931
+ type: "string",
932
+ enum: ["pending", "in_progress", "completed", "cancelled"]
933
+ }
934
+ },
935
+ required: ["id", "content", "status"]
936
+ }
937
+ },
938
+ id: {
939
+ type: "string",
940
+ description: "update \u64CD\u4F5C\uFF1A\u76EE\u6807\u4EFB\u52A1\u7684\u552F\u4E00 id\u3002"
941
+ },
942
+ status: {
943
+ type: "string",
944
+ enum: ["pending", "in_progress", "completed", "cancelled"],
945
+ description: "update \u64CD\u4F5C\uFF1A\u65B0\u72B6\u6001\uFF08\u53EF\u9009\uFF09\u3002"
946
+ },
947
+ content: {
948
+ type: "string",
949
+ description: "update \u64CD\u4F5C\uFF1A\u65B0\u5185\u5BB9\uFF08\u53EF\u9009\uFF09\u3002"
950
+ }
951
+ },
952
+ required: ["op"]
953
+ }
954
+ }
955
+ };
956
+ var _todos = [];
957
+ var STATUS_SYMBOL = {
958
+ pending: "[ ]",
959
+ in_progress: "[~]",
960
+ completed: "[x]",
961
+ cancelled: "[-]"
962
+ };
963
+ function opRead() {
964
+ if (_todos.length === 0) {
965
+ return "(no todos)";
966
+ }
967
+ return _todos.map((item) => `- ${STATUS_SYMBOL[item.status]} ${item.id}: ${item.content}`).join("\n");
968
+ }
969
+ function opWrite(params) {
970
+ if (params.todos === void 0) {
971
+ return "Error: write operation requires 'todos' parameter";
972
+ }
973
+ _todos = params.todos.map((t) => ({ ...t }));
974
+ return `Wrote ${_todos.length} todos.`;
975
+ }
976
+ function opUpdate(params) {
977
+ if (params.id === void 0) {
978
+ return "Error: update operation requires 'id' parameter";
979
+ }
980
+ if (params.status === void 0 && params.content === void 0) {
981
+ return "Error: update operation requires at least 'status' or 'content' parameter";
982
+ }
983
+ const idx = _todos.findIndex((t) => t.id === params.id);
984
+ if (idx === -1) {
985
+ return `Error: todo '${params.id}' not found`;
986
+ }
987
+ const updated = {
988
+ ..._todos[idx],
989
+ ...params.status !== void 0 ? { status: params.status } : {},
990
+ ...params.content !== void 0 ? { content: params.content } : {}
991
+ };
992
+ _todos = [
993
+ ..._todos.slice(0, idx),
994
+ updated,
995
+ ..._todos.slice(idx + 1)
996
+ ];
997
+ return `Updated todo ${params.id}.`;
998
+ }
999
+ function todo(params) {
1000
+ switch (params.op) {
1001
+ case "read":
1002
+ return opRead();
1003
+ case "write":
1004
+ return opWrite(params);
1005
+ case "update":
1006
+ return opUpdate(params);
1007
+ default: {
1008
+ const unknownOp = params.op;
1009
+ return `Error: unknown op '${unknownOp}'`;
1010
+ }
1011
+ }
1012
+ }
1013
+
260
1014
  // src/repl.ts
261
1015
  var SKIP_MESSAGE = "Command skipped by user.";
262
1016
  var BASH_TOOL = {
@@ -301,12 +1055,12 @@ Proceed? (y/n) `);
301
1055
  }
302
1056
 
303
1057
  // src/logger.ts
304
- import * as fs from "fs";
305
- import * as path from "path";
1058
+ import * as fs7 from "fs";
1059
+ import * as path5 from "path";
306
1060
  function createLogger(logDir, sessionStart) {
307
- fs.mkdirSync(logDir, { recursive: true });
1061
+ fs7.mkdirSync(logDir, { recursive: true });
308
1062
  const filename = sessionStart.toISOString().replace(/:/g, "-").replace(/\..+/, "") + ".jsonl";
309
- const filePath = path.join(logDir, filename);
1063
+ const filePath = path5.join(logDir, filename);
310
1064
  return {
311
1065
  filePath,
312
1066
  /**
@@ -320,7 +1074,7 @@ function createLogger(logDir, sessionStart) {
320
1074
  */
321
1075
  append(entry) {
322
1076
  try {
323
- fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
1077
+ fs7.appendFileSync(filePath, JSON.stringify(entry) + "\n");
324
1078
  } catch (err) {
325
1079
  process.stderr.write(`[logger] Failed to write log entry: ${err}
326
1080
  `);
@@ -359,7 +1113,168 @@ function handleSkillInput(input, registry2) {
359
1113
  const content = args ? `${skill.body}
360
1114
 
361
1115
  ${args}` : skill.body;
362
- return { type: "skill", message: { role: "user", content } };
1116
+ return { type: "skill", message: { role: "user", content }, skill };
1117
+ }
1118
+
1119
+ // src/skills/executor.ts
1120
+ import { exec as exec2 } from "child_process";
1121
+ import { dirname as dirname4 } from "path";
1122
+ import { promisify } from "util";
1123
+
1124
+ // src/skills/loader.ts
1125
+ import { readFile as readFile6, readdir as readdir3, stat as stat3 } from "fs/promises";
1126
+ import { join as join5, dirname as dirname3, basename, resolve as resolve3, sep as sep3 } from "path";
1127
+ function parseFrontmatter(content) {
1128
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
1129
+ const match = FRONTMATTER_RE.exec(content);
1130
+ if (!match) {
1131
+ return { data: {}, body: content };
1132
+ }
1133
+ const rawFrontmatter = match[1];
1134
+ const body = match[2] ?? "";
1135
+ const data = {};
1136
+ for (const line of rawFrontmatter.split(/\r?\n/)) {
1137
+ const colonIdx = line.indexOf(":");
1138
+ if (colonIdx === -1) continue;
1139
+ const key = line.slice(0, colonIdx).trim();
1140
+ const value = line.slice(colonIdx + 1).trim();
1141
+ if (key) {
1142
+ data[key] = value;
1143
+ }
1144
+ }
1145
+ return { data, body };
1146
+ }
1147
+ function isTrustedSkillPath(skillDir, trustedDirs) {
1148
+ const normalized = resolve3(skillDir);
1149
+ for (const trusted of trustedDirs) {
1150
+ const normalizedTrusted = resolve3(trusted);
1151
+ if (normalized === normalizedTrusted || normalized.startsWith(normalizedTrusted + sep3)) {
1152
+ return true;
1153
+ }
1154
+ }
1155
+ return false;
1156
+ }
1157
+ async function fileExists(filePath) {
1158
+ try {
1159
+ await stat3(filePath);
1160
+ return true;
1161
+ } catch {
1162
+ return false;
1163
+ }
1164
+ }
1165
+ async function loadTools(skillDir) {
1166
+ const toolsJsonPath = join5(skillDir, "tools.json");
1167
+ if (!await fileExists(toolsJsonPath)) return [];
1168
+ let raw;
1169
+ try {
1170
+ raw = await readFile6(toolsJsonPath, "utf-8");
1171
+ } catch {
1172
+ return [];
1173
+ }
1174
+ let parsed;
1175
+ try {
1176
+ parsed = JSON.parse(raw);
1177
+ } catch {
1178
+ return [];
1179
+ }
1180
+ if (!Array.isArray(parsed)) return [];
1181
+ const tools = [];
1182
+ for (const item of parsed) {
1183
+ if (typeof item === "object" && item !== null && typeof item["name"] === "string" && typeof item["description"] === "string") {
1184
+ const entry = item;
1185
+ tools.push({
1186
+ name: entry["name"],
1187
+ description: entry["description"],
1188
+ parameters: entry["parameters"] ?? {},
1189
+ scriptPath: join5(skillDir, `${entry["name"]}.sh`)
1190
+ });
1191
+ }
1192
+ }
1193
+ return tools;
1194
+ }
1195
+ async function loadSkillFile(skillMdPath) {
1196
+ const content = await readFile6(skillMdPath, "utf-8");
1197
+ const { data, body } = parseFrontmatter(content);
1198
+ const skillDir = dirname3(skillMdPath);
1199
+ const dirName = basename(skillDir);
1200
+ const [tools, hasPreScript, hasPostScript] = await Promise.all([
1201
+ loadTools(skillDir),
1202
+ fileExists(join5(skillDir, "pre.sh")),
1203
+ fileExists(join5(skillDir, "post.sh"))
1204
+ ]);
1205
+ return {
1206
+ name: data["name"] ?? dirName,
1207
+ description: data["description"] ?? "",
1208
+ body,
1209
+ source: skillMdPath,
1210
+ tools,
1211
+ preScript: hasPreScript ? join5(skillDir, "pre.sh") : null,
1212
+ postScript: hasPostScript ? join5(skillDir, "post.sh") : null
1213
+ };
1214
+ }
1215
+ async function loadSkillsFromDir(dir) {
1216
+ let entries;
1217
+ try {
1218
+ entries = await readdir3(dir);
1219
+ } catch {
1220
+ return [];
1221
+ }
1222
+ const skills = [];
1223
+ for (const entry of entries) {
1224
+ const entryPath = join5(dir, entry);
1225
+ let entryStat;
1226
+ try {
1227
+ entryStat = await stat3(entryPath);
1228
+ } catch {
1229
+ continue;
1230
+ }
1231
+ if (!entryStat.isDirectory()) continue;
1232
+ const skillMdPath = join5(entryPath, "SKILL.md");
1233
+ try {
1234
+ await stat3(skillMdPath);
1235
+ } catch {
1236
+ continue;
1237
+ }
1238
+ const skill = await loadSkillFile(skillMdPath);
1239
+ skills.push(skill);
1240
+ }
1241
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
1242
+ }
1243
+
1244
+ // src/skills/executor.ts
1245
+ var execAsync = promisify(exec2);
1246
+ var SecurityError = class extends Error {
1247
+ constructor(message) {
1248
+ super(message);
1249
+ this.name = "SecurityError";
1250
+ }
1251
+ };
1252
+ async function runScript(scriptPath, args) {
1253
+ const quotedArgs = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
1254
+ const cmd = quotedArgs ? `${scriptPath} ${quotedArgs}` : scriptPath;
1255
+ const { stdout } = await execAsync(cmd);
1256
+ return stdout;
1257
+ }
1258
+ async function executePreScript(skill, trustedDirs) {
1259
+ if (!skill.preScript) {
1260
+ throw new Error("No pre script configured for this skill");
1261
+ }
1262
+ const skillDir = dirname4(skill.source);
1263
+ if (!isTrustedSkillPath(skillDir, trustedDirs)) {
1264
+ throw new SecurityError(
1265
+ `Untrusted skill path: ${skillDir} is not in trusted dirs`
1266
+ );
1267
+ }
1268
+ return runScript(skill.preScript, []);
1269
+ }
1270
+ async function executeSkillTool(tool, args, trustedDirs) {
1271
+ const scriptDir = dirname4(tool.scriptPath);
1272
+ if (!isTrustedSkillPath(scriptDir, trustedDirs)) {
1273
+ throw new SecurityError(
1274
+ `Untrusted tool script path: ${tool.scriptPath}`
1275
+ );
1276
+ }
1277
+ return runScript(tool.scriptPath, [JSON.stringify(args)]);
363
1278
  }
364
1279
 
365
1280
  // src/ui/StatusBar.tsx
@@ -822,9 +1737,9 @@ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-run
822
1737
  function SkillAutocomplete({
823
1738
  suggestions,
824
1739
  selectedIndex,
825
- isOpen: isOpen2
1740
+ isOpen: isOpen3
826
1741
  }) {
827
- if (!isOpen2 || suggestions.length === 0) {
1742
+ if (!isOpen3 || suggestions.length === 0) {
828
1743
  return /* @__PURE__ */ jsx4(Fragment2, {});
829
1744
  }
830
1745
  return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", children: suggestions.map((skill, idx) => {
@@ -843,6 +1758,27 @@ function SkillAutocomplete({
843
1758
  }) });
844
1759
  }
845
1760
 
1761
+ // src/ui/FileAutocomplete.tsx
1762
+ import { Box as Box5, Text as Text5 } from "ink";
1763
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1764
+ function FileAutocomplete({
1765
+ suggestions,
1766
+ selectedIndex,
1767
+ isOpen: isOpen3
1768
+ }) {
1769
+ if (!isOpen3 || suggestions.length === 0) {
1770
+ return /* @__PURE__ */ jsx5(Fragment3, {});
1771
+ }
1772
+ return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", children: suggestions.map((entry, idx) => {
1773
+ const selected = idx === selectedIndex;
1774
+ const label = entry.isDir ? entry.path + "/" : entry.path;
1775
+ return /* @__PURE__ */ jsxs5(Box5, { children: [
1776
+ /* @__PURE__ */ jsx5(Text5, { color: selected ? "yellow" : void 0, bold: selected, children: selected ? "> " : " " }),
1777
+ /* @__PURE__ */ jsx5(Text5, { color: selected ? "yellow" : void 0, bold: selected, children: label })
1778
+ ] }, entry.path);
1779
+ }) });
1780
+ }
1781
+
846
1782
  // src/ui/autocompleteLogic.ts
847
1783
  function getInitialState() {
848
1784
  return { query: "", selectedIndex: 0, dismissed: false };
@@ -870,6 +1806,138 @@ function dismiss(state) {
870
1806
  return { ...state, dismissed: true };
871
1807
  }
872
1808
 
1809
+ // src/ui/fileCompletion.ts
1810
+ import * as fs8 from "fs/promises";
1811
+ import * as path6 from "path";
1812
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
1813
+ function isHidden(name) {
1814
+ return name.startsWith(".");
1815
+ }
1816
+ async function walkDir2(dir, root, results, maxResults) {
1817
+ if (results.length >= maxResults) return;
1818
+ let entries;
1819
+ try {
1820
+ entries = await fs8.readdir(dir, { withFileTypes: true });
1821
+ } catch {
1822
+ return;
1823
+ }
1824
+ for (const entry of entries) {
1825
+ if (results.length >= maxResults) return;
1826
+ if (SKIP_DIRS.has(entry.name) || isHidden(entry.name)) continue;
1827
+ const relPath = path6.relative(root, path6.join(dir, entry.name));
1828
+ if (entry.isDirectory()) {
1829
+ results.push({ path: relPath, isDir: true });
1830
+ await walkDir2(path6.join(dir, entry.name), root, results, maxResults);
1831
+ } else {
1832
+ results.push({ path: relPath, isDir: false });
1833
+ }
1834
+ }
1835
+ }
1836
+ async function listFilesForQuery(query, cwd, maxResults = 50) {
1837
+ if (path6.isAbsolute(query)) {
1838
+ return listAbsolute(query, maxResults);
1839
+ }
1840
+ const all = [];
1841
+ await walkDir2(cwd, cwd, all, maxResults * 4);
1842
+ const filtered = query ? all.filter((e) => e.path.includes(query)) : all;
1843
+ return filtered.slice(0, maxResults);
1844
+ }
1845
+ async function listAbsolute(query, maxResults) {
1846
+ let dir = query;
1847
+ let filter = "";
1848
+ try {
1849
+ const stat5 = await fs8.stat(dir);
1850
+ if (!stat5.isDirectory()) {
1851
+ filter = path6.basename(dir);
1852
+ dir = path6.dirname(dir);
1853
+ }
1854
+ } catch {
1855
+ filter = path6.basename(dir);
1856
+ dir = path6.dirname(dir);
1857
+ }
1858
+ let entries;
1859
+ try {
1860
+ entries = await fs8.readdir(dir, { withFileTypes: true });
1861
+ } catch {
1862
+ return [];
1863
+ }
1864
+ const results = [];
1865
+ for (const entry of entries) {
1866
+ if (SKIP_DIRS.has(entry.name) || isHidden(entry.name)) continue;
1867
+ if (filter && !entry.name.includes(filter)) continue;
1868
+ results.push({
1869
+ path: path6.join(dir, entry.name),
1870
+ isDir: entry.isDirectory()
1871
+ });
1872
+ if (results.length >= maxResults) break;
1873
+ }
1874
+ return results;
1875
+ }
1876
+ function extractAtQuery(text) {
1877
+ const lastAt = text.lastIndexOf("@");
1878
+ if (lastAt === -1) return null;
1879
+ const after = text.slice(lastAt + 1);
1880
+ const spaceIdx = after.indexOf(" ");
1881
+ if (spaceIdx !== -1) return null;
1882
+ return after;
1883
+ }
1884
+ async function expandFileRefs(text, cwd) {
1885
+ const atPattern = /@([\w./\-]+)/g;
1886
+ const replacements = [];
1887
+ let match;
1888
+ atPattern.lastIndex = 0;
1889
+ while ((match = atPattern.exec(text)) !== null) {
1890
+ const filePath = match[1];
1891
+ const fullPath = path6.isAbsolute(filePath) ? filePath : path6.join(cwd, filePath);
1892
+ let replacement;
1893
+ try {
1894
+ const content = await fs8.readFile(fullPath, "utf8");
1895
+ replacement = `\`\`\`
1896
+ // @${filePath}
1897
+ ${content}
1898
+ \`\`\``;
1899
+ } catch {
1900
+ replacement = `@${filePath} (not found)`;
1901
+ }
1902
+ replacements.push({ start: match.index, end: match.index + match[0].length, replacement });
1903
+ }
1904
+ if (replacements.length === 0) return text;
1905
+ let result = text;
1906
+ for (let i = replacements.length - 1; i >= 0; i--) {
1907
+ const { start, end, replacement } = replacements[i];
1908
+ result = result.slice(0, start) + replacement + result.slice(end);
1909
+ }
1910
+ return result;
1911
+ }
1912
+
1913
+ // src/ui/fileAutocompleteLogic.ts
1914
+ function getInitialState2() {
1915
+ return { query: "", selectedIndex: 0, dismissed: false };
1916
+ }
1917
+ function handleInputChange2(_state, newText) {
1918
+ return { query: newText, selectedIndex: 0, dismissed: false };
1919
+ }
1920
+ function isOpen2(state, suggestions) {
1921
+ if (state.dismissed) return false;
1922
+ if (suggestions.length === 0) return false;
1923
+ return extractAtQuery(state.query) !== null;
1924
+ }
1925
+ function moveUp2(state, count) {
1926
+ return { ...state, selectedIndex: (state.selectedIndex - 1 + count) % count };
1927
+ }
1928
+ function moveDown2(state, count) {
1929
+ return { ...state, selectedIndex: (state.selectedIndex + 1) % count };
1930
+ }
1931
+ function dismiss2(state) {
1932
+ return { ...state, dismissed: true };
1933
+ }
1934
+ function confirmSelection(inputText, selectedPath) {
1935
+ const lastAt = inputText.lastIndexOf("@");
1936
+ if (lastAt === -1) return inputText;
1937
+ const prefix = inputText.slice(0, lastAt);
1938
+ return `${prefix}@${selectedPath} `;
1939
+ }
1940
+
873
1941
  // src/ui/mouseInput.ts
874
1942
  var SGR_MOUSE_RE = /^\x1b\[<(\d+);\d+;\d+[Mm]/;
875
1943
  function parseMouseScroll(data) {
@@ -892,12 +1960,12 @@ function parseMouseScroll(data) {
892
1960
  }
893
1961
 
894
1962
  // src/ui/App.tsx
895
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
896
- function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry: registry2, llmClient }) {
1963
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1964
+ function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry: registry2, trustedSkillDirs: trustedSkillDirs2 = [], initialMessages: initialMessages2 = [], llmClient }) {
897
1965
  const { stdout } = useStdout();
898
1966
  const { stdin } = useStdin();
899
1967
  const historyMaxHeight = Math.max(5, (stdout?.rows ?? 24) - 4);
900
- const [messages, setMessages] = useState3([]);
1968
+ const [messages, setMessages] = useState3(initialMessages2);
901
1969
  const [status, setStatus] = useState3("idle");
902
1970
  const contextLimit = getContextLimit(config2.model, config2.contextLimit);
903
1971
  const [tokenUsage, setTokenUsage] = useState3({
@@ -924,9 +1992,15 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
924
1992
  const totalLinesRef = useRef2(totalLines);
925
1993
  totalLinesRef.current = totalLines;
926
1994
  const pendingConfirmRef = useRef2(null);
1995
+ const abortControllerRef = useRef2(null);
927
1996
  const llmRef = useRef2(llmClient ?? createLLMClient(config2));
928
1997
  const inputRef = useRef2(null);
1998
+ const [skillTools, setSkillTools] = useState3([]);
1999
+ const skillToolsRef = useRef2([]);
2000
+ skillToolsRef.current = skillTools;
929
2001
  const [acState, setAcState] = useState3(getInitialState());
2002
+ const [fileAcState, setFileAcState] = useState3(getInitialState2());
2003
+ const [fileSuggestions, setFileSuggestions] = useState3([]);
930
2004
  const loggerRef = useRef2(null);
931
2005
  const loggedCountRef = useRef2(0);
932
2006
  useEffect3(() => {
@@ -969,7 +2043,49 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
969
2043
  stdin.off("data", onMouseData);
970
2044
  };
971
2045
  }, [stdin, stdout]);
2046
+ useEffect3(() => {
2047
+ const atFragment = extractAtQuery(fileAcState.query);
2048
+ if (atFragment === null) {
2049
+ setFileSuggestions([]);
2050
+ return;
2051
+ }
2052
+ let cancelled = false;
2053
+ listFilesForQuery(atFragment, process.cwd()).then((entries) => {
2054
+ if (!cancelled) setFileSuggestions(entries);
2055
+ }).catch(() => {
2056
+ });
2057
+ return () => {
2058
+ cancelled = true;
2059
+ };
2060
+ }, [fileAcState.query]);
972
2061
  useInput2((input, key) => {
2062
+ if (key.escape && status === "thinking" && abortControllerRef.current) {
2063
+ abortControllerRef.current.abort();
2064
+ return;
2065
+ }
2066
+ const fileOpen = isOpen2(fileAcState, fileSuggestions);
2067
+ if (fileOpen) {
2068
+ if (key.upArrow) {
2069
+ setFileAcState((prev) => moveUp2(prev, fileSuggestions.length));
2070
+ return;
2071
+ }
2072
+ if (key.downArrow) {
2073
+ setFileAcState((prev) => moveDown2(prev, fileSuggestions.length));
2074
+ return;
2075
+ }
2076
+ if (key.escape) {
2077
+ setFileAcState((prev) => dismiss2(prev));
2078
+ return;
2079
+ }
2080
+ if (key.tab) {
2081
+ const selected = fileSuggestions[fileAcState.selectedIndex];
2082
+ if (selected) {
2083
+ const newText = confirmSelection(fileAcState.query, selected.path);
2084
+ inputRef.current?.fill(newText);
2085
+ }
2086
+ return;
2087
+ }
2088
+ }
973
2089
  const skillList = registry2?.list() ?? [];
974
2090
  const suggestions = computeSuggestions(skillList, acState);
975
2091
  const open = isOpen(acState, suggestions);
@@ -1041,10 +2157,10 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1041
2157
  }
1042
2158
  });
1043
2159
  const confirm = useCallback((prompt) => {
1044
- return new Promise((resolve2) => {
2160
+ return new Promise((resolve5) => {
1045
2161
  setStatus("awaiting_confirm");
1046
2162
  setConfirmPrompt(prompt);
1047
- pendingConfirmRef.current = { resolve: resolve2 };
2163
+ pendingConfirmRef.current = { resolve: resolve5 };
1048
2164
  });
1049
2165
  }, []);
1050
2166
  const runLlmLoop = useCallback(
@@ -1059,44 +2175,69 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1059
2175
  // autoMode=true 时自动同意 normal 级工具调用,无需用户确认
1060
2176
  autoApproveNormal: autoMode2
1061
2177
  };
2178
+ const abortController = new AbortController();
2179
+ abortControllerRef.current = abortController;
1062
2180
  let currentMessages = history;
1063
2181
  let continueLoop = true;
1064
- while (continueLoop) {
2182
+ let wasAborted = false;
2183
+ while (continueLoop && !wasAborted) {
1065
2184
  continueLoop = false;
1066
2185
  setStatus("thinking");
1067
2186
  let assistantText = "";
1068
2187
  let assistantReasoning;
1069
2188
  const toolCalls = [];
1070
- for await (const chunk of llmRef.current.stream(currentMessages, [BASH_TOOL])) {
1071
- if (chunk.text) {
1072
- assistantText += chunk.text;
1073
- setMessages((prev) => {
1074
- const last = prev[prev.length - 1];
1075
- if (last?.role === "assistant" && !last.tool_calls) {
1076
- return [...prev.slice(0, -1), { ...last, content: assistantText }];
2189
+ const dynamicTools = skillToolsRef.current.map((t) => ({
2190
+ type: "function",
2191
+ function: { name: t.name, description: t.description, parameters: t.parameters }
2192
+ }));
2193
+ try {
2194
+ for await (const chunk of llmRef.current.stream(currentMessages, [BASH_TOOL, READ_TOOL, WRITE_TOOL, EDIT_TOOL, GLOB_TOOL, GREP_TOOL, APPLY_PATCH_TOOL, TODO_TOOL, ...dynamicTools], abortController.signal)) {
2195
+ if (chunk.text) {
2196
+ assistantText += chunk.text;
2197
+ setMessages((prev) => {
2198
+ const last = prev[prev.length - 1];
2199
+ if (last?.role === "assistant" && !last.tool_calls) {
2200
+ return [...prev.slice(0, -1), { ...last, content: assistantText }];
2201
+ }
2202
+ return [...prev, { role: "assistant", content: assistantText }];
2203
+ });
2204
+ }
2205
+ if (chunk.done) {
2206
+ if (chunk.toolCalls) toolCalls.push(...chunk.toolCalls);
2207
+ if (chunk.reasoning) assistantReasoning = chunk.reasoning;
2208
+ if (chunk.usage) {
2209
+ setTokenUsage({
2210
+ used: chunk.usage.totalTokens,
2211
+ estimated: false,
2212
+ limit: getContextLimit(config2.model, config2.contextLimit)
2213
+ });
1077
2214
  }
1078
- return [...prev, { role: "assistant", content: assistantText }];
1079
- });
2215
+ } else {
2216
+ const estimatedUsed = Math.floor(
2217
+ currentMessages.reduce(
2218
+ (acc, m) => acc + (typeof m.content === "string" ? m.content.length : 0),
2219
+ 0
2220
+ ) * 0.25 + assistantText.length * 0.25
2221
+ );
2222
+ setTokenUsage((prev) => ({ ...prev, used: estimatedUsed, estimated: true }));
2223
+ }
1080
2224
  }
1081
- if (chunk.done) {
1082
- if (chunk.toolCalls) toolCalls.push(...chunk.toolCalls);
1083
- if (chunk.reasoning) assistantReasoning = chunk.reasoning;
1084
- if (chunk.usage) {
1085
- setTokenUsage({
1086
- used: chunk.usage.totalTokens,
1087
- estimated: false,
1088
- limit: getContextLimit(config2.model, config2.contextLimit)
2225
+ } catch (err) {
2226
+ if (err instanceof Error && err.name === "AbortError") {
2227
+ if (assistantText) {
2228
+ setMessages((prev) => {
2229
+ const last = prev[prev.length - 1];
2230
+ const partial = { role: "assistant", content: assistantText };
2231
+ return last?.role === "assistant" && !last.tool_calls ? [...prev.slice(0, -1), partial] : [...prev, partial];
1089
2232
  });
1090
2233
  }
1091
- } else {
1092
- const estimatedUsed = Math.floor(
1093
- currentMessages.reduce(
1094
- (acc, m) => acc + (typeof m.content === "string" ? m.content.length : 0),
1095
- 0
1096
- ) * 0.25 + assistantText.length * 0.25
1097
- );
1098
- setTokenUsage((prev) => ({ ...prev, used: estimatedUsed, estimated: true }));
2234
+ wasAborted = true;
2235
+ setStatus("idle");
2236
+ setToolName(void 0);
2237
+ abortControllerRef.current = null;
2238
+ return;
1099
2239
  }
2240
+ throw err;
1100
2241
  }
1101
2242
  if (toolCalls.length > 0) {
1102
2243
  const assistantMsg = {
@@ -1119,6 +2260,8 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1119
2260
  currentMessages = [...currentMessages, assistantMsg];
1120
2261
  setStatus("tool_calling");
1121
2262
  for (const tc of toolCalls) {
2263
+ setToolName(tc.name);
2264
+ let toolResult;
1122
2265
  if (tc.name === "bash") {
1123
2266
  let args;
1124
2267
  try {
@@ -1126,16 +2269,48 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1126
2269
  } catch {
1127
2270
  args = { command: "" };
1128
2271
  }
1129
- setToolName(tc.name);
1130
- const toolResult = await handleBashTool(args.command, deps);
1131
- const toolMsg = {
1132
- role: "tool",
1133
- tool_call_id: tc.id,
1134
- content: toolResult
1135
- };
1136
- setMessages((prev) => [...prev, toolMsg]);
1137
- currentMessages = [...currentMessages, toolMsg];
2272
+ toolResult = await handleBashTool(args.command, deps);
2273
+ } else if (tc.name === "read") {
2274
+ const args = JSON.parse(tc.arguments);
2275
+ toolResult = await readFile2(args);
2276
+ } else if (tc.name === "write") {
2277
+ const args = JSON.parse(tc.arguments);
2278
+ toolResult = await writeFile2(args);
2279
+ } else if (tc.name === "edit") {
2280
+ const args = JSON.parse(tc.arguments);
2281
+ toolResult = await editFile(args);
2282
+ } else if (tc.name === "glob") {
2283
+ const args = JSON.parse(tc.arguments);
2284
+ toolResult = await globFiles(args);
2285
+ } else if (tc.name === "grep") {
2286
+ const args = JSON.parse(tc.arguments);
2287
+ toolResult = await grepFiles(args);
2288
+ } else if (tc.name === "apply_patch") {
2289
+ const args = JSON.parse(tc.arguments);
2290
+ toolResult = await applyPatch(args);
2291
+ } else if (tc.name === "todo") {
2292
+ const args = JSON.parse(tc.arguments);
2293
+ toolResult = todo(args);
2294
+ } else {
2295
+ const skillTool = skillToolsRef.current.find((t) => t.name === tc.name);
2296
+ if (skillTool) {
2297
+ let toolArgs = {};
2298
+ try {
2299
+ toolArgs = JSON.parse(tc.arguments);
2300
+ } catch {
2301
+ }
2302
+ toolResult = await executeSkillTool(skillTool, toolArgs, trustedSkillDirs2);
2303
+ } else {
2304
+ toolResult = `Unknown tool: ${tc.name}`;
2305
+ }
1138
2306
  }
2307
+ const toolMsg = {
2308
+ role: "tool",
2309
+ tool_call_id: tc.id,
2310
+ content: toolResult
2311
+ };
2312
+ setMessages((prev) => [...prev, toolMsg]);
2313
+ currentMessages = [...currentMessages, toolMsg];
1139
2314
  }
1140
2315
  continueLoop = true;
1141
2316
  } else {
@@ -1165,11 +2340,12 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1165
2340
  }
1166
2341
  setStatus("idle");
1167
2342
  setToolName(void 0);
2343
+ abortControllerRef.current = null;
1168
2344
  },
1169
2345
  [confirm, config2.dangerousPatterns, autoMode2]
1170
2346
  );
1171
2347
  const handleSubmit = useCallback(
1172
- (text) => {
2348
+ async (text) => {
1173
2349
  const trimmed = text.trim();
1174
2350
  if (status === "awaiting_confirm") {
1175
2351
  const pending = pendingConfirmRef.current;
@@ -1195,6 +2371,18 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1195
2371
  return;
1196
2372
  }
1197
2373
  if (skillResult.type === "skill") {
2374
+ const { skill } = skillResult;
2375
+ setSkillTools(skill.tools);
2376
+ if (skill.preScript) {
2377
+ try {
2378
+ await executePreScript(skill, trustedSkillDirs2);
2379
+ } catch (preErr) {
2380
+ setMessages((prev) => [
2381
+ ...prev,
2382
+ { role: "assistant", content: `[pre.sh error] ${String(preErr)}` }
2383
+ ]);
2384
+ }
2385
+ }
1198
2386
  const nextMessages2 = [...messages, skillResult.message];
1199
2387
  setMessages(nextMessages2);
1200
2388
  runLlmLoop(nextMessages2).catch((err) => {
@@ -1208,7 +2396,12 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1208
2396
  return;
1209
2397
  }
1210
2398
  }
1211
- const userMsg = { role: "user", content: trimmed };
2399
+ let content = trimmed;
2400
+ try {
2401
+ content = await expandFileRefs(trimmed, process.cwd());
2402
+ } catch {
2403
+ }
2404
+ const userMsg = { role: "user", content };
1212
2405
  const nextMessages = [...messages, userMsg];
1213
2406
  setMessages(nextMessages);
1214
2407
  runLlmLoop(nextMessages).catch((err) => {
@@ -1216,7 +2409,6 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1216
2409
  setToolName(void 0);
1217
2410
  setMessages((prev) => [
1218
2411
  ...prev,
1219
- // 错误消息前缀 "[error]" 便于用户识别和日志筛选
1220
2412
  { role: "assistant", content: `[error] ${String(err)}` }
1221
2413
  ]);
1222
2414
  });
@@ -1232,12 +2424,13 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1232
2424
  }
1233
2425
  if (status !== "awaiting_confirm") {
1234
2426
  setAcState((prev) => handleInputChange(prev, text));
2427
+ setFileAcState((prev) => handleInputChange2(prev, text));
1235
2428
  }
1236
2429
  }, [status]);
1237
2430
  const skillSuggestions = registry2 ? computeSuggestions(registry2.list(), acState) : [];
1238
2431
  const acOpen = isOpen(acState, skillSuggestions);
1239
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", height: "100%", children: [
1240
- /* @__PURE__ */ jsx5(
2432
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: "100%", children: [
2433
+ /* @__PURE__ */ jsx6(Box6, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-end", children: /* @__PURE__ */ jsx6(
1241
2434
  ConversationHistory,
1242
2435
  {
1243
2436
  messages,
@@ -1246,8 +2439,8 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1246
2439
  terminalWidth: stdout?.columns,
1247
2440
  scrollOffset
1248
2441
  }
1249
- ),
1250
- /* @__PURE__ */ jsx5(
2442
+ ) }),
2443
+ /* @__PURE__ */ jsx6(
1251
2444
  StatusBar,
1252
2445
  {
1253
2446
  status,
@@ -1257,7 +2450,7 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1257
2450
  tokenUsage
1258
2451
  }
1259
2452
  ),
1260
- /* @__PURE__ */ jsx5(
2453
+ /* @__PURE__ */ jsx6(
1261
2454
  SkillAutocomplete,
1262
2455
  {
1263
2456
  suggestions: skillSuggestions,
@@ -1265,7 +2458,15 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1265
2458
  isOpen: acOpen
1266
2459
  }
1267
2460
  ),
1268
- /* @__PURE__ */ jsx5(
2461
+ /* @__PURE__ */ jsx6(
2462
+ FileAutocomplete,
2463
+ {
2464
+ suggestions: fileSuggestions,
2465
+ selectedIndex: fileAcState.selectedIndex,
2466
+ isOpen: isOpen2(fileAcState, fileSuggestions)
2467
+ }
2468
+ ),
2469
+ /* @__PURE__ */ jsx6(
1269
2470
  Input_default,
1270
2471
  {
1271
2472
  ref: inputRef,
@@ -1278,6 +2479,64 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
1278
2479
  ] });
1279
2480
  }
1280
2481
 
2482
+ // src/replay.ts
2483
+ var KNOWN_ROLES = /* @__PURE__ */ new Set(["user", "assistant", "tool", "system"]);
2484
+ function entryToMessage(entry) {
2485
+ const { role, content } = entry;
2486
+ if (typeof role !== "string" || !KNOWN_ROLES.has(role)) return null;
2487
+ if (role === "user" || role === "system") {
2488
+ return { role, content: content ?? "" };
2489
+ }
2490
+ if (role === "assistant") {
2491
+ const msg = {
2492
+ role: "assistant",
2493
+ content: content ?? null
2494
+ };
2495
+ if (Array.isArray(entry.tool_calls)) {
2496
+ msg.tool_calls = entry.tool_calls;
2497
+ }
2498
+ return msg;
2499
+ }
2500
+ if (role === "tool") {
2501
+ if (typeof entry.tool_call_id !== "string") return null;
2502
+ return {
2503
+ role: "tool",
2504
+ tool_call_id: entry.tool_call_id,
2505
+ content: content ?? ""
2506
+ };
2507
+ }
2508
+ return null;
2509
+ }
2510
+ function parseReplayLog(content) {
2511
+ const messages = [];
2512
+ for (const line of content.split("\n")) {
2513
+ const trimmed = line.trim();
2514
+ if (!trimmed) continue;
2515
+ let entry;
2516
+ try {
2517
+ entry = JSON.parse(trimmed);
2518
+ } catch {
2519
+ continue;
2520
+ }
2521
+ const msg = entryToMessage(entry);
2522
+ if (msg) messages.push(msg);
2523
+ }
2524
+ return messages;
2525
+ }
2526
+ function truncateToTurn(messages, turn) {
2527
+ if (turn <= 0) return [];
2528
+ let userCount = 0;
2529
+ for (let i = 0; i < messages.length; i++) {
2530
+ if (messages[i].role === "user") {
2531
+ userCount++;
2532
+ if (userCount > turn) {
2533
+ return messages.slice(0, i);
2534
+ }
2535
+ }
2536
+ }
2537
+ return messages.slice();
2538
+ }
2539
+
1281
2540
  // src/skills/registry.ts
1282
2541
  var SkillRegistry = class {
1283
2542
  skills = /* @__PURE__ */ new Map();
@@ -1301,67 +2560,32 @@ var SkillRegistry = class {
1301
2560
  }
1302
2561
  };
1303
2562
 
1304
- // src/skills/loader.ts
1305
- import { readFile, readdir, stat } from "fs/promises";
1306
- import { join as join3, dirname, basename } from "path";
1307
- function parseFrontmatter(content) {
1308
- const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
1309
- const match = FRONTMATTER_RE.exec(content);
1310
- if (!match) {
1311
- return { data: {}, body: content };
1312
- }
1313
- const rawFrontmatter = match[1];
1314
- const body = match[2] ?? "";
1315
- const data = {};
1316
- for (const line of rawFrontmatter.split(/\r?\n/)) {
1317
- const colonIdx = line.indexOf(":");
1318
- if (colonIdx === -1) continue;
1319
- const key = line.slice(0, colonIdx).trim();
1320
- const value = line.slice(colonIdx + 1).trim();
1321
- if (key) {
1322
- data[key] = value;
1323
- }
1324
- }
1325
- return { data, body };
2563
+ // src/pipe.ts
2564
+ function emit(out, event) {
2565
+ out.write(JSON.stringify(event) + "\n");
1326
2566
  }
1327
- async function loadSkillFile(skillMdPath) {
1328
- const content = await readFile(skillMdPath, "utf-8");
1329
- const { data, body } = parseFrontmatter(content);
1330
- const dirName = basename(dirname(skillMdPath));
1331
- return {
1332
- name: data["name"] ?? dirName,
1333
- description: data["description"] ?? "",
1334
- body,
1335
- source: skillMdPath
1336
- };
1337
- }
1338
- async function loadSkillsFromDir(dir) {
1339
- let entries;
1340
- try {
1341
- entries = await readdir(dir);
1342
- } catch {
1343
- return [];
1344
- }
1345
- const skills = [];
1346
- for (const entry of entries) {
1347
- const entryPath = join3(dir, entry);
1348
- let entryStat;
1349
- try {
1350
- entryStat = await stat(entryPath);
1351
- } catch {
1352
- continue;
2567
+ async function runPipe(prompt, llm, out = process.stdout) {
2568
+ const messages = [{ role: "user", content: prompt }];
2569
+ for await (const chunk of llm.stream(messages)) {
2570
+ if (chunk.text) {
2571
+ emit(out, { type: "chunk", text: chunk.text });
1353
2572
  }
1354
- if (!entryStat.isDirectory()) continue;
1355
- const skillMdPath = join3(entryPath, "SKILL.md");
1356
- try {
1357
- await stat(skillMdPath);
1358
- } catch {
1359
- continue;
2573
+ if (chunk.done) {
2574
+ const doneEvent = chunk.usage ? { type: "done", usage: chunk.usage } : { type: "done" };
2575
+ emit(out, doneEvent);
1360
2576
  }
1361
- const skill = await loadSkillFile(skillMdPath);
1362
- skills.push(skill);
1363
2577
  }
1364
- return skills.sort((a, b) => a.name.localeCompare(b.name));
2578
+ }
2579
+ function readStdin() {
2580
+ return new Promise((resolve5, reject) => {
2581
+ let data = "";
2582
+ process.stdin.setEncoding("utf-8");
2583
+ process.stdin.on("data", (chunk) => {
2584
+ data += chunk;
2585
+ });
2586
+ process.stdin.on("end", () => resolve5(data.trim()));
2587
+ process.stdin.on("error", reject);
2588
+ });
1365
2589
  }
1366
2590
 
1367
2591
  // src/index.ts
@@ -1371,6 +2595,9 @@ var VERSION = version;
1371
2595
  var rawArgs = process.argv.slice(2);
1372
2596
  var autoMode = false;
1373
2597
  var cliLogDir;
2598
+ var replayFile;
2599
+ var forkSpec;
2600
+ var pipeMode = false;
1374
2601
  for (let i = 0; i < rawArgs.length; i++) {
1375
2602
  const arg = rawArgs[i];
1376
2603
  if (arg === "-v" || arg === "--version") {
@@ -1387,6 +2614,30 @@ for (let i = 0; i < rawArgs.length; i++) {
1387
2614
  i++;
1388
2615
  }
1389
2616
  }
2617
+ if (arg === "--replay") {
2618
+ const next = rawArgs[i + 1];
2619
+ if (next && !next.startsWith("-")) {
2620
+ replayFile = next;
2621
+ i++;
2622
+ }
2623
+ }
2624
+ if (arg === "--pipe") {
2625
+ pipeMode = true;
2626
+ }
2627
+ if (arg === "--fork") {
2628
+ const next = rawArgs[i + 1];
2629
+ if (next && !next.startsWith("-")) {
2630
+ const colonIdx = next.lastIndexOf(":");
2631
+ if (colonIdx > 0) {
2632
+ const file = next.slice(0, colonIdx);
2633
+ const turn = parseInt(next.slice(colonIdx + 1), 10);
2634
+ if (!isNaN(turn)) {
2635
+ forkSpec = { file, turn };
2636
+ }
2637
+ }
2638
+ i++;
2639
+ }
2640
+ }
1390
2641
  }
1391
2642
  var config = loadConfig();
1392
2643
  var finalConfig = cliLogDir ? { ...config, logDir: cliLogDir } : config;
@@ -1396,15 +2647,40 @@ if (!finalConfig.apiKey) {
1396
2647
  );
1397
2648
  process.exit(1);
1398
2649
  }
1399
- var __dirname = dirname2(fileURLToPath(import.meta.url));
1400
- var builtinSkillsDir = resolve(__dirname, "../skills");
1401
- var userSkillsDir = resolve(process.env.HOME ?? "~", ".ecode/skills");
1402
- var projectSkillsDir = resolve(process.cwd(), ".ecode/skills");
2650
+ var initialMessages = [];
2651
+ if (replayFile) {
2652
+ try {
2653
+ const raw = readFileSync2(replayFile, "utf-8");
2654
+ initialMessages = parseReplayLog(raw);
2655
+ } catch (err) {
2656
+ console.error(`Error reading replay file: ${err}`);
2657
+ process.exit(1);
2658
+ }
2659
+ } else if (forkSpec) {
2660
+ try {
2661
+ const raw = readFileSync2(forkSpec.file, "utf-8");
2662
+ initialMessages = truncateToTurn(parseReplayLog(raw), forkSpec.turn);
2663
+ } catch (err) {
2664
+ console.error(`Error reading fork file: ${err}`);
2665
+ process.exit(1);
2666
+ }
2667
+ }
2668
+ var __dirname = dirname6(fileURLToPath(import.meta.url));
2669
+ var builtinSkillsDir = resolve4(__dirname, "../skills");
2670
+ var userSkillsDir = resolve4(process.env.HOME ?? "~", ".ecode/skills");
2671
+ var projectSkillsDir = resolve4(process.cwd(), ".ecode/skills");
1403
2672
  var registry = new SkillRegistry();
1404
2673
  for (const dir of [builtinSkillsDir, userSkillsDir, projectSkillsDir]) {
1405
2674
  const skills = await loadSkillsFromDir(dir);
1406
2675
  for (const skill of skills) registry.register(skill);
1407
2676
  }
2677
+ var trustedSkillDirs = [builtinSkillsDir, userSkillsDir, projectSkillsDir];
2678
+ if (pipeMode) {
2679
+ const prompt = await readStdin();
2680
+ const llm = createLLMClient(finalConfig);
2681
+ await runPipe(prompt, llm);
2682
+ process.exit(0);
2683
+ }
1408
2684
  if (process.stdout.isTTY) {
1409
2685
  process.stdout.write("\x1B[?1049h");
1410
2686
  const exitAltScreen = () => process.stdout.write("\x1B[?1049l");
@@ -1422,4 +2698,4 @@ if (process.stdout.isTTY) {
1422
2698
  process.exit(0);
1423
2699
  });
1424
2700
  }
1425
- render(React4.createElement(App, { config: finalConfig, version: VERSION, autoMode, registry }));
2701
+ render(React4.createElement(App, { config: finalConfig, version: VERSION, autoMode, registry, trustedSkillDirs, initialMessages }));