thincoder 0.8.2 → 0.8.3
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/README.md +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +55 -53
- package/src/context.mjs +59 -56
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +49 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +14 -14
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/tui/wizard.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wizard.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* wizard.mjs — first-launch config wizard
|
|
3
|
+
* Extracted from index.mjs: provider select → step-by-step input (name/baseURL/model/key/embedkey) → persist → then model picker.
|
|
4
|
+
* Accesses shared state and UI functions from the startTUI closure via ctx object.
|
|
5
5
|
* ctx: { agent, state, pushLine, pushLabel, render, persistRaw, openModelPicker }
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -9,13 +9,13 @@ import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
|
|
|
9
9
|
import { ansi, C } from "./ansi.mjs"
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Creates the wizard controller.
|
|
13
|
+
* Returns { startWizard, renderWizard, wizardChooseProvider, wizardSubmitText, cancelWizard, finishWizard }
|
|
14
14
|
*/
|
|
15
15
|
export function createWizard(ctx) {
|
|
16
16
|
const { agent, state, pushLine, pushLabel, render, persistRaw } = ctx
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/** Candidates for the menu step: existing providers (marked "no key" if missing), unadded presets, custom */
|
|
19
19
|
function wizardProviderItems() {
|
|
20
20
|
const items = []
|
|
21
21
|
for (const p of agent.providers) {
|
|
@@ -30,7 +30,7 @@ export function createWizard(ctx) {
|
|
|
30
30
|
return items
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/** Text step definitions: prompt + validation (returns true if valid, otherwise error message) */
|
|
34
34
|
const WIZARD_STEPS = {
|
|
35
35
|
name: {
|
|
36
36
|
prompt: "Name this provider (alphanumeric/-/_ e.g. my-openai)",
|
|
@@ -51,7 +51,7 @@ export function createWizard(ctx) {
|
|
|
51
51
|
},
|
|
52
52
|
embedkey: {
|
|
53
53
|
prompt: "Optional: embedding API key (SiliconFlow, for memory vector search; press Enter to skip)",
|
|
54
|
-
validate: () => true, //
|
|
54
|
+
validate: () => true, // skippable
|
|
55
55
|
},
|
|
56
56
|
}
|
|
57
57
|
const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
|
|
@@ -123,11 +123,11 @@ export function createWizard(ctx) {
|
|
|
123
123
|
|
|
124
124
|
function cancelWizard() {
|
|
125
125
|
state.wizard = null
|
|
126
|
-
pushLine("Skipped initial setup.
|
|
126
|
+
pushLine("Skipped initial setup. Use /model to add providers and configure API keys anytime.", C.dim)
|
|
127
127
|
render()
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
/**
|
|
130
|
+
/** Wizard complete: write provider (update if exists), set active, persist, then open model picker */
|
|
131
131
|
async function finishWizard() {
|
|
132
132
|
const f = state.wizard.fields
|
|
133
133
|
state.wizard = null
|
|
@@ -147,7 +147,7 @@ export function createWizard(ctx) {
|
|
|
147
147
|
agent.config.activeProvider = f.name
|
|
148
148
|
pushLabel(`❯ Setup`, ansi.bold + C.tool)
|
|
149
149
|
pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
|
|
150
|
-
// embedding key
|
|
150
|
+
// embedding key: if provided, enable vector search; if not, show how to enable later
|
|
151
151
|
if (f.embedkey) {
|
|
152
152
|
agent.config.embedding ??= {}
|
|
153
153
|
agent.config.embedding.apiKey = f.embedkey
|
package/src/tui.mjs
CHANGED
package/bin/thincoder.js
DELETED
package/src/tools/bash.mjs
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DESC,
|
|
3
|
-
sanitizeOutput,
|
|
4
|
-
truncate,
|
|
5
|
-
makeDecoder,
|
|
6
|
-
BASH_TIMEOUT_MS,
|
|
7
|
-
hasFileRedirection,
|
|
8
|
-
shellSegments,
|
|
9
|
-
isDestructiveGitSegment,
|
|
10
|
-
isDestructiveCommand,
|
|
11
|
-
insideGitRepo,
|
|
12
|
-
} from "./shared.mjs"
|
|
13
|
-
import { spawn, execFileSync } from "node:child_process"
|
|
14
|
-
|
|
15
|
-
/** 子进程环境变量白名单:只透传安全变量,隔离 API key 等敏感信息 */
|
|
16
|
-
const SAFE_ENV_KEYS = new Set([
|
|
17
|
-
"PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
|
18
|
-
"TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
|
|
19
|
-
"ComSpec", "PATHEXT", "SystemRoot", "windir",
|
|
20
|
-
"NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "OS",
|
|
21
|
-
"PYTHONIOENCODING", "GIT_EDITOR", "EDITOR", "VISUAL",
|
|
22
|
-
"GIT_PAGER", "PAGER", "TERM",
|
|
23
|
-
])
|
|
24
|
-
|
|
25
|
-
export const bashTool = {
|
|
26
|
-
name: "bash",
|
|
27
|
-
description: DESC("bash"),
|
|
28
|
-
parameters: {
|
|
29
|
-
type: "object",
|
|
30
|
-
properties: {
|
|
31
|
-
command: { type: "string", description: "Shell command to execute" },
|
|
32
|
-
timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
|
|
33
|
-
},
|
|
34
|
-
required: ["command"],
|
|
35
|
-
},
|
|
36
|
-
readonly: false,
|
|
37
|
-
async execute(args, ctx) {
|
|
38
|
-
// 安全预检:禁止 shell 重定向(> >> <)——应改用 write/edit/insert_after 工具
|
|
39
|
-
if (hasFileRedirection(args.command)) {
|
|
40
|
-
throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
|
|
41
|
-
}
|
|
42
|
-
// 安全预检:破坏性非 git 命令(rm -rf / DROP TABLE 等)直接拒绝
|
|
43
|
-
if (shellSegments(args.command).some(isDestructiveCommand)) {
|
|
44
|
-
throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
|
|
45
|
-
}
|
|
46
|
-
// 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
|
|
47
|
-
if (shellSegments(args.command).some(isDestructiveGitSegment)) {
|
|
48
|
-
if (!insideGitRepo(ctx.cwd)) {
|
|
49
|
-
throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
|
|
50
|
-
}
|
|
51
|
-
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
52
|
-
cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
53
|
-
}).trim()
|
|
54
|
-
if (status) {
|
|
55
|
-
throw new Error(
|
|
56
|
-
`Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
|
|
57
|
-
`(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return new Promise((resolve) => {
|
|
63
|
-
// detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
|
|
64
|
-
// Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
|
|
65
|
-
const killTree = () => {
|
|
66
|
-
if (process.platform === "win32") {
|
|
67
|
-
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
68
|
-
} else {
|
|
69
|
-
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
70
|
-
try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
// Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
|
|
74
|
-
// chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
|
|
75
|
-
// 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
|
|
76
|
-
// 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
|
|
77
|
-
const winCmd = process.platform === "win32"
|
|
78
|
-
const child = spawn(args.command, {
|
|
79
|
-
cwd: ctx.cwd,
|
|
80
|
-
shell: true,
|
|
81
|
-
windowsHide: true,
|
|
82
|
-
detached: process.platform !== "win32",
|
|
83
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
84
|
-
env: {
|
|
85
|
-
...Object.fromEntries(
|
|
86
|
-
Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
|
|
87
|
-
),
|
|
88
|
-
GIT_EDITOR: "true",
|
|
89
|
-
EDITOR: "true",
|
|
90
|
-
VISUAL: "true",
|
|
91
|
-
GIT_PAGER: "cat",
|
|
92
|
-
PAGER: "cat",
|
|
93
|
-
TERM: "dumb",
|
|
94
|
-
...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
95
|
-
},
|
|
96
|
-
})
|
|
97
|
-
// stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
|
|
98
|
-
// 且允许模型按 stderr 快速定位错误)
|
|
99
|
-
const outDecoder = makeDecoder()
|
|
100
|
-
const errDecoder = makeDecoder()
|
|
101
|
-
let outBuf = ""
|
|
102
|
-
let errBuf = ""
|
|
103
|
-
let truncatedNote = ""
|
|
104
|
-
|
|
105
|
-
const onStdout = (d) => {
|
|
106
|
-
const s = sanitizeOutput(outDecoder(d))
|
|
107
|
-
if (s) {
|
|
108
|
-
ctx.onOutput?.(s)
|
|
109
|
-
if (outBuf.length < 2_000_000) outBuf += s
|
|
110
|
-
else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
const onStderr = (d) => {
|
|
114
|
-
const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
|
|
115
|
-
if (errBuf.length < 2_000_000) errBuf += s
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
child.stdout.on("data", onStdout)
|
|
119
|
-
child.stderr.on("data", onStderr)
|
|
120
|
-
|
|
121
|
-
const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
|
|
122
|
-
if (ctx.signal) {
|
|
123
|
-
ctx.signal.addEventListener("abort", killTree, { once: true })
|
|
124
|
-
}
|
|
125
|
-
child.on("error", (error) => {
|
|
126
|
-
clearTimeout(timer)
|
|
127
|
-
resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
128
|
-
})
|
|
129
|
-
child.on("close", (code, signal) => {
|
|
130
|
-
clearTimeout(timer)
|
|
131
|
-
// 冲刷解码器尾部
|
|
132
|
-
outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
133
|
-
errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
134
|
-
const status = signal
|
|
135
|
-
? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
|
|
136
|
-
: `exit code ${code}`
|
|
137
|
-
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
138
|
-
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
139
|
-
parts.push(`(${status})`)
|
|
140
|
-
resolve(truncate(parts.join("\n\n") + truncatedNote))
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
},
|
|
144
|
-
}
|
package/src/tools/glob.mjs
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { DESC, truncate, resolveInCwd, globToRegex, IGNORED_DIRS } from "./shared.mjs"
|
|
2
|
-
import { readdir } from "node:fs/promises"
|
|
3
|
-
import { join } from "node:path"
|
|
4
|
-
|
|
5
|
-
export const globTool = {
|
|
6
|
-
name: "glob",
|
|
7
|
-
description: DESC("glob"),
|
|
8
|
-
parameters: {
|
|
9
|
-
type: "object",
|
|
10
|
-
properties: {
|
|
11
|
-
pattern: { type: "string", description: "Glob pattern" },
|
|
12
|
-
path: { type: "string", description: "Directory to search in (default cwd)" },
|
|
13
|
-
},
|
|
14
|
-
required: ["pattern"],
|
|
15
|
-
},
|
|
16
|
-
readonly: true,
|
|
17
|
-
async execute(args, ctx) {
|
|
18
|
-
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
19
|
-
const regex = globToRegex(args.pattern)
|
|
20
|
-
const results = []
|
|
21
|
-
for await (const relPath of walkFiles(base)) {
|
|
22
|
-
if (regex.test(relPath)) {
|
|
23
|
-
results.push(relPath)
|
|
24
|
-
if (results.length >= 1000) break
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
if (results.length === 0) return "(no matches)"
|
|
28
|
-
return truncate(results.sort().join("\n"))
|
|
29
|
-
},
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------- 内部实现
|
|
33
|
-
|
|
34
|
-
/** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS) */
|
|
35
|
-
async function* walkFiles(dir, rel = "") {
|
|
36
|
-
let entries
|
|
37
|
-
try {
|
|
38
|
-
entries = await readdir(dir, { withFileTypes: true })
|
|
39
|
-
} catch {
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
|
-
for (const e of entries) {
|
|
43
|
-
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
|
|
44
|
-
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
45
|
-
if (e.isDirectory()) {
|
|
46
|
-
yield* walkFiles(join(dir, e.name), relPath)
|
|
47
|
-
} else {
|
|
48
|
-
yield relPath
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/tools/grep.mjs
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { DESC, truncate, resolveInCwd, globToRegex, IGNORED_DIRS } from "./shared.mjs"
|
|
2
|
-
import { readFile, readdir } from "node:fs/promises"
|
|
3
|
-
import { stat, lstat } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
5
|
-
|
|
6
|
-
export const grepTool = {
|
|
7
|
-
name: "grep",
|
|
8
|
-
description: DESC("grep"),
|
|
9
|
-
parameters: {
|
|
10
|
-
type: "object",
|
|
11
|
-
properties: {
|
|
12
|
-
pattern: { type: "string", description: "Regular expression" },
|
|
13
|
-
path: { type: "string", description: "Directory or file to search (default cwd)" },
|
|
14
|
-
glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
|
|
15
|
-
before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
|
|
16
|
-
after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
|
|
17
|
-
},
|
|
18
|
-
required: ["pattern"],
|
|
19
|
-
},
|
|
20
|
-
readonly: true,
|
|
21
|
-
async execute(args, ctx) {
|
|
22
|
-
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
23
|
-
const regex = new RegExp(args.pattern)
|
|
24
|
-
const fileFilter = args.glob ? globToRegex(args.glob) : null
|
|
25
|
-
const before = Math.max(0, Math.floor(args.before ?? 0))
|
|
26
|
-
const after = Math.max(0, Math.floor(args.after ?? 0))
|
|
27
|
-
const wantCtx = before > 0 || after > 0
|
|
28
|
-
const hits = [] // { file, line(1-based), text }
|
|
29
|
-
const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
|
|
30
|
-
|
|
31
|
-
async function search(file) {
|
|
32
|
-
let content
|
|
33
|
-
try {
|
|
34
|
-
// 大文件保护:超 10MB 跳过,防 OOM
|
|
35
|
-
const fst = await stat(file)
|
|
36
|
-
if (fst.size > 10_000_000) return
|
|
37
|
-
content = await readFile(file, "utf8")
|
|
38
|
-
} catch {
|
|
39
|
-
return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
|
|
40
|
-
}
|
|
41
|
-
const lines = content.split("\n")
|
|
42
|
-
if (wantCtx) fileLines.set(file, lines)
|
|
43
|
-
for (let i = 0; i < lines.length; i++) {
|
|
44
|
-
if (regex.test(lines[i])) {
|
|
45
|
-
hits.push({ file, line: i + 1, text: lines[i] })
|
|
46
|
-
if (hits.length >= 200) return
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function walk(target) {
|
|
52
|
-
if (hits.length >= 200) return
|
|
53
|
-
// 用 lstat 不跟随符号链接——防 ./evil → /etc 时 grep 读遍 /etc
|
|
54
|
-
let s
|
|
55
|
-
try { s = await lstat(target) } catch { return }
|
|
56
|
-
if (!s.isDirectory()) {
|
|
57
|
-
if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
let entries
|
|
61
|
-
try {
|
|
62
|
-
entries = await readdir(target, { withFileTypes: true })
|
|
63
|
-
} catch {
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
for (const e of entries) {
|
|
67
|
-
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
|
|
68
|
-
await walk(join(target, e.name))
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
await walk(base)
|
|
73
|
-
if (hits.length === 0) return "(no matches)"
|
|
74
|
-
|
|
75
|
-
// 无上下文:保持原 path:line: content 格式
|
|
76
|
-
if (!wantCtx) {
|
|
77
|
-
return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
|
|
81
|
-
const fileMatched = new Map() // file -> Set<line>
|
|
82
|
-
for (const h of hits) {
|
|
83
|
-
if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
|
|
84
|
-
fileMatched.get(h.file).add(h.line)
|
|
85
|
-
}
|
|
86
|
-
const out = []
|
|
87
|
-
for (const [file, matchedLines] of fileMatched) {
|
|
88
|
-
const lines = fileLines.get(file) ?? []
|
|
89
|
-
const lineSet = new Set()
|
|
90
|
-
for (const ml of matchedLines) {
|
|
91
|
-
for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
|
|
92
|
-
}
|
|
93
|
-
for (const l of [...lineSet].sort((a, b) => a - b)) {
|
|
94
|
-
const sep = matchedLines.has(l) ? ":" : "-"
|
|
95
|
-
out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return truncate(out.join("\n"))
|
|
99
|
-
},
|
|
100
|
-
}
|
package/src/tools/ls.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { DESC, truncate, resolveInCwd } from "./shared.mjs"
|
|
2
|
-
import { readdir } from "node:fs/promises"
|
|
3
|
-
import { stat } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
5
|
-
|
|
6
|
-
export const lsTool = {
|
|
7
|
-
name: "ls",
|
|
8
|
-
description: DESC("ls"),
|
|
9
|
-
parameters: {
|
|
10
|
-
type: "object",
|
|
11
|
-
properties: {
|
|
12
|
-
path: { type: "string", description: "Directory path (default cwd)" },
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
readonly: true,
|
|
16
|
-
async execute(args, ctx) {
|
|
17
|
-
const abs = resolveInCwd(ctx, args.path ?? ".")
|
|
18
|
-
const entries = await readdir(abs, { withFileTypes: true })
|
|
19
|
-
const rows = await Promise.all(
|
|
20
|
-
entries.slice(0, 500).map(async (e) => {
|
|
21
|
-
const s = await stat(join(abs, e.name)).catch(() => null)
|
|
22
|
-
const isDir = e.isDirectory()
|
|
23
|
-
return {
|
|
24
|
-
dir: isDir,
|
|
25
|
-
name: e.name + (isDir ? "/" : ""),
|
|
26
|
-
size: s?.size ?? 0,
|
|
27
|
-
mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
|
|
28
|
-
}
|
|
29
|
-
}),
|
|
30
|
-
)
|
|
31
|
-
rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
|
|
32
|
-
if (rows.length === 0) return "(empty directory)"
|
|
33
|
-
const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
|
|
34
|
-
return truncate(out.join("\n"))
|
|
35
|
-
},
|
|
36
|
-
}
|
package/src/tools.mjs
DELETED
package/src/tui-render.mjs
DELETED