thincoder 0.1.0 → 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.
package/src/session.mjs CHANGED
@@ -17,12 +17,14 @@ export function sessionPath(cwd) {
17
17
  /** 保存会话(同步:退出清理路径也能用) */
18
18
  export function saveSession(agent) {
19
19
  const data = {
20
- version: 1,
20
+ version: 2,
21
21
  cwd: agent.cwd,
22
- model: agent.provider.model,
22
+ activeProvider: agent.provider?.name,
23
23
  updatedAt: Date.now(),
24
24
  history: agent.history,
25
25
  tasks: agent.tasks ?? [],
26
+ planMode: agent.planMode ?? false,
27
+ goal: agent.goal ?? null,
26
28
  }
27
29
  const p = sessionPath(agent.cwd)
28
30
  mkdirSync(dirname(p), { recursive: true })
@@ -35,7 +37,8 @@ export function loadSession(cwd) {
35
37
  const p = sessionPath(cwd)
36
38
  if (!existsSync(p)) return null
37
39
  const data = JSON.parse(readFileSync(p, "utf8"))
38
- if (data?.version !== 1 || !Array.isArray(data.history)) return null
40
+ if (data?.version !== 1 && data?.version !== 2) return null
41
+ if (!Array.isArray(data.history)) return null
39
42
  return data
40
43
  } catch {
41
44
  return null
@@ -46,7 +49,7 @@ export function loadSession(cwd) {
46
49
  export function clearSession(cwd) {
47
50
  try {
48
51
  const p = sessionPath(cwd)
49
- if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 1, cwd, history: [], tasks: [] }), "utf8")
52
+ if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 2, cwd, history: [], tasks: [] }), "utf8")
50
53
  } catch {
51
54
  // 清不掉就算了,下次保存会覆盖
52
55
  }
package/src/skills.mjs ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * skills.mjs — 技能系统
3
+ * 从 .thincoder/skills/ 目录发现 .md 技能文件,
4
+ * 注入到 system prompt 供 agent 按需加载。
5
+ * 用 skill 工具激活指定技能,内容以 <skill-loaded> 包裹写入对话历史。
6
+ */
7
+
8
+ import { readFile, readdir, stat } from "node:fs/promises"
9
+ import { join } from "node:path"
10
+
11
+ /**
12
+ * 扫描 .thincoder/skills/ 目录,返回技能列表。
13
+ * 每个技能:{ name, path, description } — name 取文件名(去扩展名)。
14
+ * 目录不存在或无文件返回空数组。
15
+ */
16
+ export async function loadSkills(cwd) {
17
+ const dir = join(cwd, ".thincoder", "skills")
18
+ let entries
19
+ try {
20
+ entries = await readdir(dir)
21
+ } catch {
22
+ return []
23
+ }
24
+ const skills = []
25
+ for (const name of entries) {
26
+ if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // 与 readSkill 的名字校验一致,防"列得出、读不了"
27
+ const p = join(dir, name)
28
+ try {
29
+ const s = await stat(p)
30
+ if (!s.isFile()) continue
31
+ // 提取描述(前 400 字符里第一段非空、非标题行)
32
+ const head = await readFile(p, "utf8")
33
+ const body = head.slice(0, 400).split("\n")
34
+ let desc = ""
35
+ for (const line of body) {
36
+ const t = line.trim()
37
+ if (t && !t.startsWith("#") && !t.startsWith("---")) {
38
+ desc = t.slice(0, 120)
39
+ break
40
+ }
41
+ }
42
+ skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
43
+ } catch {
44
+ // 读失败跳过
45
+ }
46
+ }
47
+ return skills
48
+ }
49
+
50
+ /**
51
+ * 生成技能列表文本,注入 system prompt。
52
+ * 最多 3 个(占位少),超过则标 "... and N more"。
53
+ */
54
+ export function formatSkillListing(skills) {
55
+ if (skills.length === 0) return ""
56
+ const listed = skills.slice(0, 3)
57
+ const lines = listed.map((s) => `- **${s.name}**: ${s.description}`)
58
+ if (skills.length > 3) lines.push(` ... and ${skills.length - 3} more`)
59
+ return "Available skills (use the skill tool to load one):\n" + lines.join("\n")
60
+ }
61
+
62
+ /**
63
+ * 读取指定技能文件的完整内容。
64
+ * 返回文本,找不到返回 null。
65
+ */
66
+ export async function readSkill(cwd, name) {
67
+ // 安全检查:技能名只能是字母数字 + 连字符/下划线
68
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
69
+ const p = join(cwd, ".thincoder", "skills", `${name}.md`)
70
+ try {
71
+ return await readFile(p, "utf8")
72
+ } catch {
73
+ return null
74
+ }
75
+ }
@@ -0,0 +1,13 @@
1
+ Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.
2
+
3
+ Parameters:
4
+ - command (required): Shell command to execute
5
+ - timeout: Timeout in milliseconds (default 120000, max ~300000)
6
+
7
+ Notes:
8
+ - There is NO TTY — editors, pagers (vim, less), and interactive prompts WILL hang. Always pass non-interactive flags: `git commit -m`, `git --no-pager`, `-y`/`--yes` where applicable
9
+ - The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
10
+ - Output is capped at ~50000 chars; if you need more, redirect to a file and read it
11
+ - On Windows, use Unix shell syntax inside bash commands (Git Bash): forward slashes, `/dev/null` not `NUL`
12
+ - Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
13
+ - Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
@@ -0,0 +1,9 @@
1
+ Delete a file. Use when the agent created a temporary or junk file that should be cleaned up, or when the user explicitly asks to delete something. Refuses to delete git-tracked files as a safety measure — tracked files should be edited or removed via bash with explicit user confirmation.
2
+
3
+ Parameters:
4
+ - path (required): File path, relative to cwd or absolute
5
+
6
+ Notes:
7
+ - Untracked or non-git files are deleted immediately
8
+ - Tracked files require force=true (user must confirm separately)
9
+ - Directories must be removed with bash (rm -rf)
@@ -0,0 +1,12 @@
1
+ Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.
2
+
3
+ Parameters:
4
+ - path (required): File path
5
+ - old_string (required): Exact text to find and replace
6
+ - new_string (required): Replacement text
7
+ - replace_all: Replace all occurrences instead of just one (default false)
8
+
9
+ Notes:
10
+ - Prefer this over write for targeted edits — it's safer and keeps diffs small
11
+ - If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
12
+ - Never fabricate the old_string — copy it verbatim from the actual file using read first
@@ -0,0 +1,10 @@
1
+ Fetch a URL and return its content as text. HTML pages are stripped to readable text. Use after websearch to read full documents.
2
+
3
+ Parameters:
4
+ - url (required): http/https URL
5
+
6
+ Notes:
7
+ - Follows redirects automatically
8
+ - Timeout: 20 seconds
9
+ - HTML pages are converted to plain text (scripts, styles, navigation stripped)
10
+ - Non-HTML responses are returned as-is (truncated at ~50000 chars)
@@ -0,0 +1,11 @@
1
+ Show git diff (unified format). Use to see uncommitted changes, staged changes, or diff against a specific ref.
2
+
3
+ Parameters:
4
+ - staged: Show staged changes (default false, shows working tree diff)
5
+ - path: File or directory to diff (default all)
6
+ - ref: Compare against a ref (default HEAD)
7
+
8
+ Notes:
9
+ - Only works inside a git repository
10
+ - Output is standard unified diff — LLMs understand this natively
11
+ - If no changes, returns "(no changes)"
@@ -0,0 +1,10 @@
1
+ Show recent git commit history. Use to understand the project's recent changes, conventions, and pace.
2
+
3
+ Parameters:
4
+ - count: Number of commits to show (default 10)
5
+ - path: File or directory to show history for (default all)
6
+ - oneline: Compact one-line-per-commit format (default false)
7
+
8
+ Notes:
9
+ - Only works inside a git repository
10
+ - Output includes hash, author, date, and message
@@ -0,0 +1,8 @@
1
+ Show structured git status. Use to understand the current working tree state: what files are staged, modified, untracked, or conflicting.
2
+
3
+ Returns categorized lists: staged, unstaged, untracked, conflicts.
4
+
5
+ Notes:
6
+ - Only works inside a git repository
7
+ - Output is categorized for easy parsing by the agent
8
+ - No side effects — read-only
@@ -0,0 +1,11 @@
1
+ Find files by glob pattern (e.g. 'src/**/*.mjs'). Returns matching paths.
2
+
3
+ Parameters:
4
+ - pattern (required): Glob pattern — supports **, *, ?, and character classes
5
+ - path: Directory to search in (default cwd)
6
+
7
+ Notes:
8
+ - Skips node_modules, .git, dist, build, .turbo, coverage
9
+ - Results capped at 1000 matches
10
+ - Use this to discover file structure; use grep to search file contents
11
+ - Prefer patterns with a literal anchor (extension or subdirectory) over bare wildcards
@@ -0,0 +1,12 @@
1
+ Search file contents with a regex. Returns matching lines as path:line: content.
2
+
3
+ Parameters:
4
+ - pattern (required): JavaScript regular expression
5
+ - path: Directory or file to search (default cwd)
6
+ - glob: Only search files matching this glob (e.g. '*.mjs')
7
+
8
+ Notes:
9
+ - Skips node_modules, .git, dist, build, .turbo, coverage
10
+ - Results capped at 200 matches
11
+ - Binary/unreadable files are silently skipped
12
+ - Use this to find usages, definitions, patterns; use glob to find files by name
@@ -0,0 +1,9 @@
1
+ List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).
2
+
3
+ Parameters:
4
+ - path: Directory path (default cwd)
5
+
6
+ Notes:
7
+ - Shows first 500 entries
8
+ - Directories are prefixed with `/` and listed before files
9
+ - Use this for a quick overview; use glob when you have a specific file pattern in mind
@@ -0,0 +1,10 @@
1
+ Ask the user a question and wait for their response. Use when the task is ambiguous, you need a design decision, or you're stuck and need human judgment.
2
+
3
+ Parameters:
4
+ - question (required): The question to ask the user
5
+ - options: Array of single-choice options for the user to pick from (optional)
6
+
7
+ Notes:
8
+ - The agent loop pauses until the user answers
9
+ - The answer is injected as the next user message
10
+ - Use sparingly — prefer making reasonable decisions when possible
@@ -0,0 +1,10 @@
1
+ Read a text file. Returns numbered lines. Use offset/limit to page large files.
2
+
3
+ Parameters:
4
+ - path (required): File path, relative to cwd or absolute
5
+ - offset: 1-based line number to start reading from
6
+ - limit: Max lines to return (default 2000)
7
+
8
+ Notes:
9
+ - Always prefer this over `cat` or shell-based reading — it caps output and avoids large dumps
10
+ - Use offset for pagination when the file is large
@@ -0,0 +1,10 @@
1
+ Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.
2
+
3
+ Parameters:
4
+ - query (required): Search query
5
+ - limit: Max results (default 8)
6
+
7
+ Notes:
8
+ - Use this for information that is NOT in the local codebase — current docs, error messages, API references
9
+ - Follow up with `fetch` to read full pages from the results
10
+ - Results are scraped from Bing HTML — some formatting may be imperfect
@@ -0,0 +1,9 @@
1
+ Write content to a file. Creates parent directories; overwrites existing file.
2
+
3
+ Parameters:
4
+ - path (required): File path, relative to cwd or absolute
5
+ - content (required): Full content to write
6
+
7
+ Notes:
8
+ - This overwrites the entire file — use `edit` for targeted changes
9
+ - The file is atomic: it either writes completely or fails
package/src/tools.mjs CHANGED
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * tools.mjs — 内置工具集
3
- * read / write / edit / bash / glob / grep,零依赖实现。
3
+ * read / write / edit / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question,零依赖实现。
4
+ * 工具描述从 src/tools/*.md 加载(方便人读和修改)。
4
5
  * readonly 标记供 agent 调度:只读工具可并行,有副作用工具串行。
5
6
  */
6
7
 
7
- import { spawn } from "node:child_process"
8
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"
9
- import { dirname, join, resolve } from "node:path"
8
+ import { spawn, execFileSync } from "node:child_process"
9
+ import { mkdir, readFile, readdir, stat, writeFile, unlink } from "node:fs/promises"
10
+ import { readFileSync, existsSync } from "node:fs"
11
+ import { dirname, join, resolve, relative } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url))
15
+ const DESC = (name) => readFileSync(join(__dirname, "tools", `${name}.md`), "utf8")
10
16
 
11
17
  const MAX_READ_LINES = 2000
12
18
  const MAX_OUTPUT_CHARS = 50_000
@@ -48,8 +54,7 @@ function resolveInCwd(ctx, p) {
48
54
 
49
55
  const readTool = {
50
56
  name: "read",
51
- description:
52
- "Read a text file. Returns numbered lines. Use offset/limit to page large files.",
57
+ description: DESC("read"),
53
58
  parameters: {
54
59
  type: "object",
55
60
  properties: {
@@ -77,7 +82,7 @@ const readTool = {
77
82
 
78
83
  const writeTool = {
79
84
  name: "write",
80
- description: "Write content to a file. Creates parent directories; overwrites existing file.",
85
+ description: DESC("write"),
81
86
  parameters: {
82
87
  type: "object",
83
88
  properties: {
@@ -99,8 +104,7 @@ const writeTool = {
99
104
 
100
105
  const editTool = {
101
106
  name: "edit",
102
- description:
103
- "Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.",
107
+ description: DESC("edit"),
104
108
  parameters: {
105
109
  type: "object",
106
110
  properties: {
@@ -134,8 +138,7 @@ const editTool = {
134
138
 
135
139
  const bashTool = {
136
140
  name: "bash",
137
- description:
138
- "Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.",
141
+ description: DESC("bash"),
139
142
  parameters: {
140
143
  type: "object",
141
144
  properties: {
@@ -210,6 +213,10 @@ const bashTool = {
210
213
  child.stderr.on("data", onData)
211
214
 
212
215
  const timer = setTimeout(() => child.kill(), args.timeout ?? BASH_TIMEOUT_MS)
216
+ // 用户中止:杀进程
217
+ if (ctx.signal) {
218
+ ctx.signal.addEventListener("abort", () => child.kill(), { once: true })
219
+ }
213
220
  child.on("error", (error) => {
214
221
  clearTimeout(timer)
215
222
  resolve(truncate(`Command failed: ${error.message}\n${out}`))
@@ -218,7 +225,7 @@ const bashTool = {
218
225
  clearTimeout(timer)
219
226
  out += sanitizeOutput(feed(Buffer.alloc(0), true)) // 最终判定 + 冲刷解码器尾部
220
227
  const suffix = signal
221
- ? "\n(killed: timeout)"
228
+ ? `\n(killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"})`
222
229
  : code !== 0
223
230
  ? `\n(exit code ${code})`
224
231
  : ""
@@ -232,7 +239,7 @@ const bashTool = {
232
239
 
233
240
  const globTool = {
234
241
  name: "glob",
235
- description: "Find files by glob pattern (e.g. 'src/**/*.mjs'). Returns matching paths.",
242
+ description: DESC("glob"),
236
243
  parameters: {
237
244
  type: "object",
238
245
  properties: {
@@ -295,7 +302,7 @@ function globToRegex(pattern) {
295
302
 
296
303
  const grepTool = {
297
304
  name: "grep",
298
- description: "Search file contents with a regex. Returns matching lines as path:line: content.",
305
+ description: DESC("grep"),
299
306
  parameters: {
300
307
  type: "object",
301
308
  properties: {
@@ -357,8 +364,7 @@ const grepTool = {
357
364
 
358
365
  const websearchTool = {
359
366
  name: "websearch",
360
- description:
361
- "Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.",
367
+ description: DESC("websearch"),
362
368
  parameters: {
363
369
  type: "object",
364
370
  properties: {
@@ -422,8 +428,7 @@ function stripTags(html) {
422
428
 
423
429
  const lsTool = {
424
430
  name: "ls",
425
- description:
426
- "List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).",
431
+ description: DESC("ls"),
427
432
  parameters: {
428
433
  type: "object",
429
434
  properties: {
@@ -457,8 +462,7 @@ const lsTool = {
457
462
 
458
463
  const fetchTool = {
459
464
  name: "fetch",
460
- description:
461
- "Fetch a URL and return its content as text (HTML pages are stripped to readable text). Use after websearch to read full documents.",
465
+ description: DESC("fetch"),
462
466
  parameters: {
463
467
  type: "object",
464
468
  properties: {
@@ -511,3 +515,170 @@ function htmlToText(html) {
511
515
  }
512
516
 
513
517
  export const builtinTools = [readTool, writeTool, editTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
518
+
519
+ // ---------------------------------------------------------------- delete
520
+
521
+ const deleteTool = {
522
+ name: "delete",
523
+ description: DESC("delete"),
524
+ parameters: {
525
+ type: "object",
526
+ properties: {
527
+ path: { type: "string", description: "File path (relative to cwd or absolute)" },
528
+ force: { type: "boolean", description: "Allow deleting git-tracked files (default false)" },
529
+ },
530
+ required: ["path"],
531
+ },
532
+ readonly: false,
533
+ async execute(args, ctx) {
534
+ const abs = resolveInCwd(ctx, args.path)
535
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`)
536
+ const s = await stat(abs)
537
+ if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
538
+ // git 跟踪文件拒绝直接删除(安全网);未跟踪的放行
539
+ // 用解析后的相对路径(统一正斜杠),防反斜杠/非常规路径绕过 ls-files 匹配
540
+ const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
541
+ let tracked = false
542
+ try {
543
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
544
+ tracked = true
545
+ } catch {
546
+ // 未跟踪 / 非 git 仓库
547
+ }
548
+ if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
549
+ await unlink(abs)
550
+ return `Deleted ${abs}`
551
+ },
552
+ }
553
+
554
+ // ---------------------------------------------------------------- git_diff
555
+
556
+ const gitDiffTool = {
557
+ name: "git_diff",
558
+ description: DESC("git_diff"),
559
+ parameters: {
560
+ type: "object",
561
+ properties: {
562
+ staged: { type: "boolean", description: "Show staged changes (default false)" },
563
+ path: { type: "string", description: "File or directory to diff (default all)" },
564
+ ref: { type: "string", description: "Compare against this ref (default HEAD)" },
565
+ },
566
+ },
567
+ readonly: true,
568
+ execute(args, ctx) {
569
+ const ref = args.ref ?? "HEAD"
570
+ const flags = args.staged ? ["--staged"] : []
571
+ const paths = args.path ? [args.path] : []
572
+ const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
573
+ return truncate(out || "(no changes)")
574
+ },
575
+ }
576
+
577
+ // ---------------------------------------------------------------- git_status
578
+
579
+ const gitStatusTool = {
580
+ name: "git_status",
581
+ description: DESC("git_status"),
582
+ parameters: {
583
+ type: "object",
584
+ properties: {},
585
+ },
586
+ readonly: true,
587
+ execute(_args, ctx) {
588
+ const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
589
+ if (!porcelain) return "(clean — no changes)"
590
+
591
+ const staged = []
592
+ const unstaged = []
593
+ const untracked = []
594
+ const conflicts = []
595
+ for (const line of porcelain.split("\n")) {
596
+ if (!line) continue
597
+ // porcelain: XY path — 2 状态字符 + 空格 + 文件路径(部分环境只 1 空格)
598
+ // 去掉可能的 CR(execFileSync 在某些 Windows git 下会残留 \r 在行末但不在换行符中)
599
+ const clean = line.replace(/\r/g, "")
600
+ // 尝试匹配 "XY path" 或 "XY path"(可变间距)
601
+ const m = clean.match(/^(..?)\s+(.+)$/)
602
+ if (!m) continue
603
+ const [, status, file] = m
604
+ const idx = status[0] ?? " "
605
+ const wt = status[1] ?? " "
606
+ if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
607
+ conflicts.push(file)
608
+ } else if (idx === "?" && wt === "?") {
609
+ untracked.push(file)
610
+ } else {
611
+ if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
612
+ if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
613
+ }
614
+ }
615
+ const parts = []
616
+ if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
617
+ if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
618
+ if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
619
+ if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
620
+ return truncate(parts.join("\n\n"))
621
+ },
622
+ }
623
+
624
+ // ---------------------------------------------------------------- git_log
625
+
626
+ const gitLogTool = {
627
+ name: "git_log",
628
+ description: DESC("git_log"),
629
+ parameters: {
630
+ type: "object",
631
+ properties: {
632
+ count: { type: "number", description: "Number of commits (default 10)" },
633
+ path: { type: "string", description: "File or directory (default all)" },
634
+ oneline: { type: "boolean", description: "One-line-per-commit format (default false)" },
635
+ },
636
+ },
637
+ readonly: true,
638
+ execute(args, ctx) {
639
+ const n = args.count ?? 10
640
+ const isOneline = args.oneline
641
+ const cmdArgs = isOneline
642
+ ? ["log", "-" + n, "--oneline"]
643
+ : ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
644
+ if (args.path) cmdArgs.push("--", args.path)
645
+ const out = runGit(ctx.cwd, cmdArgs)
646
+ return truncate(out || "(no commits)")
647
+ },
648
+ }
649
+
650
+ // ---------------------------------------------------------------- question
651
+
652
+ const questionTool = {
653
+ name: "question",
654
+ description: DESC("question"),
655
+ parameters: {
656
+ type: "object",
657
+ properties: {
658
+ question: { type: "string", description: "The question to ask the user" },
659
+ options: {
660
+ type: "array",
661
+ items: { type: "string" },
662
+ description: "Single-choice options for the user to pick from (optional)",
663
+ },
664
+ },
665
+ required: ["question"],
666
+ },
667
+ readonly: true,
668
+ async execute(args, ctx) {
669
+ if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
670
+ return ctx.onQuestion(args.question, args.options ?? [])
671
+ },
672
+ }
673
+
674
+ /** 执行 git 命令;非 git 仓库 / git 不可用时返回空字符串 */
675
+ function runGit(cwd, cmdArgs) {
676
+ try {
677
+ return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
678
+ } catch {
679
+ return ""
680
+ }
681
+ }
682
+
683
+ export { deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool }
684
+ builtinTools.push(deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool)