fix: make hwpod workspace search node-native
This commit is contained in:
@@ -124,13 +124,15 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`
|
||||
| `node.inventory` | 查询 workspace/probe/io 能力 |
|
||||
| `workspace.ls` | 列目录 |
|
||||
| `workspace.cat` | 读文件 |
|
||||
| `workspace.rg` | 搜索文件内容 |
|
||||
| `workspace.rg` | 递归搜索文件内容,标准别名还有 `grep` / `search` |
|
||||
| `workspace.apply-patch` | 应用 Codex apply_patch envelope 源码补丁 |
|
||||
| `workspace.write` | 带 `expectedSha`、换行策略和 diff 摘要的整文件写入 |
|
||||
| `workspace.replace` | 带 `expectedSha`、唯一匹配检查和 diff 摘要的精确文本替换 |
|
||||
| `workspace.insert-after` | 按精确 anchor 插入文本,避免 PowerShell/cmd quoting |
|
||||
| `cmd.run` | 通用 host 命令透传,用于组合 Keil、serial-monitor、job status、下载、复位和临时维护动作 |
|
||||
|
||||
`workspace.rg` 是查找符号、结构体、函数签名和错误文本的标准入口。它在 node 侧直接做受限递归文本搜索,不依赖目标 host 预装 `rg`,并支持 `--context`、`--before-context`、`--after-context`、`--max-matches`、`--max-files`、`--max-bytes-per-file` 和 `--max-line-bytes`。输出会保留结构化 `matches`、`scannedFiles`、`truncated` 和 `limits`,用于在不爆炸输出的前提下追踪上下文。需要精准读整文件时才用 `workspace.cat`;查找 ARM-2D 头文件、函数原型或 Demo 入口时,优先用 `workspace.rg` / `workspace.search`,不要再把 `cat` 管道给 shell `grep`。
|
||||
|
||||
若历史实现中仍存在 `debug.*` 或 `io.*` 这类专用 op,它们只能作为兼容存量观察对象,不再作为新 case 或新 harness 业务的扩展方向。标准路径是:compiler 根据 spec 生成 `cmd.run` 的 `command` + `argv`,node 只负责在目标 host 上忠实执行并返回 stdout/stderr/exit code。
|
||||
|
||||
`hwpod-node` 是 HWPOD 的唯一受控节点执行器。第一阶段的业务扩展优先落在 `hwpod-compiler-cli` / `hwpod-cli`,通过已有文件读写 op 和 `cmd.run` 组合 Keil、serial-monitor 等成熟工具;不要因为某个 case 需要 build、job status、download、UART 或临时维护动作就新增专用 node op。`cmd.run` 的命令解析、PATH 稳定性和跨平台差异必须在 `hwpod-node` 本体内处理;当 Windows service、后台进程或 D601 host 的环境变量与交互 shell 不一致时,不得用 gateway shell、手工 PowerShell、预先手工创建 worktree 或其他旁路代替 `hwpod-node-ops -> hwpod-node`。D601/Windows 实地调试应使用 UniDesk SSH 透传 `D601:win` 观察进程、PATH、工具安装位置和日志,但修复必须回到 `hwpod-node` 源码、配置或启动入口,并用 `/v1/hwpod-node-ops` 原链路复测。只有 `cmd.run` 自身的执行语义、环境继承或可见性不满足真实 host 执行时,才修改 `hwpod-node`;否则优先改 compiler/CLI。
|
||||
@@ -180,6 +182,7 @@ bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml
|
||||
bun tools/hwpod-compiler-cli.ts compile --spec .hwlab/hwpod-spec.yaml --intent workspace.ls --args '{"path":"."}'
|
||||
bun tools/hwpod-cli.ts inspect --spec .hwlab/hwpod-spec.yaml --dry-run
|
||||
bun tools/hwpod-cli.ts workspace read --spec .hwlab/hwpod-spec.yaml projects/01_baseline/User/main.c
|
||||
bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml
|
||||
bun tools/hwpod-cli.ts workspace replace --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --find "old text" --replace "new text" --expected-sha <sha>
|
||||
bun tools/hwpod-cli.ts workspace insert-after --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --anchor "while (1)" --line " /* marker */"
|
||||
bun tools/hwpod-cli.ts download --spec .hwlab/hwpod-spec.yaml
|
||||
@@ -207,6 +210,7 @@ export HWLAB_RUNTIME_ENDPOINT_LOCKED=1
|
||||
bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml
|
||||
bun tools/hwpod-cli.ts inspect --spec .hwlab/hwpod-spec.yaml
|
||||
bun tools/hwpod-cli.ts workspace cat --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c
|
||||
bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml
|
||||
bun tools/hwpod-cli.ts build --spec .hwlab/hwpod-spec.yaml
|
||||
bun tools/hwpod-cli.ts download --spec .hwlab/hwpod-spec.yaml
|
||||
bun tools/hwpod-cli.ts job status <jobId> --spec .hwlab/hwpod-spec.yaml
|
||||
@@ -225,6 +229,7 @@ code agent 必须在自己的 AgentRun workspace 内调用同一套入口,例
|
||||
hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml
|
||||
hwpod inspect --spec .hwlab/hwpod-spec.yaml
|
||||
hwpod workspace cat --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c
|
||||
hwpod workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml
|
||||
hwpod workspace replace --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --find <old> --replace <new> --expected-sha <sha>
|
||||
hwpod build --spec .hwlab/hwpod-spec.yaml
|
||||
hwpod download --spec .hwlab/hwpod-spec.yaml
|
||||
|
||||
@@ -22,7 +22,8 @@ The HWPOD spec is code-agent-side state. The default path is `.hwlab/hwpod-spec.
|
||||
## Commands
|
||||
|
||||
- Inspect the HWPOD path: `hwpod inspect --spec .hwlab/hwpod-spec.yaml`.
|
||||
- Workspace read/edit: use `hwpod workspace ls|cat|read|write|replace|insert-after|apply-patch --spec .hwlab/hwpod-spec.yaml`. `read` is an alias for `cat`; `--file` and `--remote-path` are accepted as path aliases for file-oriented commands.
|
||||
- Workspace search/read/edit: use `hwpod workspace ls|cat|read|rg|grep|search|write|replace|insert-after|apply-patch --spec .hwlab/hwpod-spec.yaml`. `read` is an alias for `cat`; `grep` and `search` are aliases for `rg`; `--file` and `--remote-path` are accepted as path aliases for file-oriented commands.
|
||||
- For symbol lookup or ARM-2D header hunting, prefer `hwpod workspace rg <pattern> [path] --context 3 --spec .hwlab/hwpod-spec.yaml` instead of `cat | grep`.
|
||||
- Build/download/reset: use `hwpod build`, `hwpod download`, and `hwpod reset`; the compiler must translate these through existing file ops and `cmd.run` where possible.
|
||||
- Keil async status: after build/download returns a job id, use separate short `hwpod job status <jobId> --spec .hwlab/hwpod-spec.yaml` calls instead of shell polling wrappers such as `sleep &&`, `timeout`, `watch`, `head`, pipes, or loops.
|
||||
- UART read: use `hwpod uart read --spec .hwlab/hwpod-spec.yaml --port uart1`; the compiler runs the configured serial-monitor CLI through `cmd.run`.
|
||||
|
||||
@@ -11,6 +11,8 @@ This skill follows Skill(cli-spec). It is the standard management entry for the
|
||||
|
||||
`hwpod-ctl` is a peer of `hwpod-cli`, not a fallback task path. Use it to maintain `.hwlab/hwpod-spec.yaml`, validate the four HWPOD elements, bind a node, and run smoke or temporary maintenance actions.
|
||||
|
||||
`hwpod-ctl` does not replace `hwpod-cli` for task execution. When a task needs source lookup, use `hwpod workspace rg/search` for symbol hunting and `hwpod workspace cat` only for full file bodies; when a task needs build/download/UART/job status, hand it back to `hwpod-cli` and its compiler-backed plan path.
|
||||
|
||||
## Commands
|
||||
|
||||
- Initialize or repair local spec: `hwpod-ctl spec init --spec .hwlab/hwpod-spec.yaml --node <nodeId>`.
|
||||
|
||||
@@ -186,6 +186,27 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl
|
||||
assert.equal(read.payload.plan.ops[0].op, "workspace.cat");
|
||||
assert.equal(read.payload.plan.ops[0].args.path, "projects/01_baseline/User/main.c");
|
||||
|
||||
const rg = await runHwpodCli(["workspace", "rg", "--spec", specPath, "arm_2d_init", "projects/01_baseline/Middlewares/Arm-2D", "--context", "3", "--max-matches", "20", "--ignore-case", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(rg.exitCode, 0);
|
||||
assert.equal(rg.payload.plan.ops[0].op, "workspace.rg");
|
||||
assert.equal(rg.payload.plan.ops[0].args.pattern, "arm_2d_init");
|
||||
assert.equal(rg.payload.plan.ops[0].args.path, "projects/01_baseline/Middlewares/Arm-2D");
|
||||
assert.equal(rg.payload.plan.ops[0].args.context, 3);
|
||||
assert.equal(rg.payload.plan.ops[0].args.maxMatches, 20);
|
||||
assert.equal(rg.payload.plan.ops[0].args.ignoreCase, true);
|
||||
|
||||
const grepAlias = await runHwpodCli(["workspace", "grep", "--spec", specPath, "arm_2d_tile_t", "projects/01_baseline/Middlewares/Arm-2D", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(grepAlias.exitCode, 0);
|
||||
assert.equal(grepAlias.payload.plan.ops[0].op, "workspace.rg");
|
||||
assert.equal(grepAlias.payload.plan.ops[0].args.pattern, "arm_2d_tile_t");
|
||||
|
||||
const grepShortOptions = await runHwpodCli(["workspace", "grep", "--spec", specPath, "typedef struct", "projects/01_baseline/Middlewares/Arm-2D", "-B", "2", "-A5", "-i", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(grepShortOptions.exitCode, 0);
|
||||
assert.equal(grepShortOptions.payload.plan.ops[0].op, "workspace.rg");
|
||||
assert.equal(grepShortOptions.payload.plan.ops[0].args.beforeContext, 2);
|
||||
assert.equal(grepShortOptions.payload.plan.ops[0].args.afterContext, 5);
|
||||
assert.equal(grepShortOptions.payload.plan.ops[0].args.ignoreCase, true);
|
||||
|
||||
await runHwpodCtl(["spec", "set", "spec.ioProbe.uart.port", "COM9", "--spec", specPath], { now: () => NOW });
|
||||
await runHwpodCtl(["spec", "set", "spec.ioProbe.uart.baudrate", "115200", "--spec", specPath], { now: () => NOW });
|
||||
const uart = await runHwpodCli(["uart", "read", "--spec", specPath, "--port", "uart1", "--max-bytes", "512", "--dry-run"], { now: () => NOW });
|
||||
|
||||
@@ -30,6 +30,39 @@ test("hwpod-node executes minimal workspace node ops", async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-node searches workspace text without requiring host rg", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-rg-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "Middlewares", "Arm-2D"), { recursive: true });
|
||||
await mkdir(path.join(root, "Objects"), { recursive: true });
|
||||
await writeFile(path.join(root, "Middlewares", "Arm-2D", "arm_2d.c"), "static int before;\nvoid arm_2d_init(void) {\n}\nstatic int after;\n", "utf8");
|
||||
await writeFile(path.join(root, "Objects", "ignored.txt"), "arm_2d_init should not be scanned\n", "utf8");
|
||||
|
||||
const result = await executeHwpodNodeOpsPlan({
|
||||
contractVersion: "hwpod-node-ops-v1",
|
||||
planId: "hwpod_plan_rg",
|
||||
hwpodId: "hwpod-local",
|
||||
nodeId: "pc-host-1",
|
||||
ops: [{ opId: "op_rg", op: "workspace.rg", args: { workspacePath: root, pattern: "arm_2d_init", path: "Middlewares", context: 1 } }]
|
||||
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.results[0].ok, true);
|
||||
assert.equal(result.results[0].output.engine, "node-recursive-search");
|
||||
assert.equal(result.results[0].output.matchCount, 1);
|
||||
assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c-1-static int before"));
|
||||
assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c:2:void arm_2d_init"));
|
||||
assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c-3-}"));
|
||||
assert.equal(result.results[0].output.matches[0].line, 2);
|
||||
assert.equal(result.results[0].output.matches[0].before[0].line, 1);
|
||||
assert.equal(result.results[0].output.matches[0].after[0].line, 3);
|
||||
assert.equal(result.results[0].output.limits.beforeContext, 1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hwpod-node HTTP endpoint accepts hwpod-node-ops plans", async () => {
|
||||
const server = createHwpodNodeServer({ now: () => "2026-06-05T00:00:00.000Z", nodeId: "pc-host-1" });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
@@ -14,7 +14,7 @@ const COMPILER_NAME = "hwpod-compiler-cli";
|
||||
const CTL_NAME = "hwpod-ctl";
|
||||
const CLI_NAME = "hwpod-cli";
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "json", "noAuth", "sessionOnly", "wait"]);
|
||||
const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "ignoreCase", "json", "noAuth", "sessionOnly", "wait"]);
|
||||
const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000;
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||
@@ -188,6 +188,7 @@ function cliHelp() {
|
||||
"bun tools/hwpod-cli.ts workspace ls . --dry-run",
|
||||
"bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml",
|
||||
"bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml",
|
||||
"bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml",
|
||||
"cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason \"edit subject workspace through hwpod-node\"",
|
||||
"bun tools/hwpod-cli.ts workspace replace --path projects/01_baseline/User/main.c --find \"old text\" --replace \"new text\" --expected-sha <sha>",
|
||||
"bun tools/hwpod-cli.ts workspace insert-after --path projects/01_baseline/User/main.c --anchor \"while (1)\" --line \" /* marker */\"",
|
||||
@@ -245,6 +246,9 @@ function hwpodWorkspaceHelp(subcommand = "") {
|
||||
"hwpod workspace read <path> --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwpod workspace cat --file <path> --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwpod workspace cat --remote-path <path> --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwpod workspace rg <pattern> [path] --context 3 --max-matches 20 --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwpod workspace grep <pattern> [path] --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwpod workspace search <pattern> [path] --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwpod workspace write --path <path> --content <text> --expected-sha <sha>",
|
||||
"hwpod workspace replace --path <path> --find <text> --replace <text> --expected-sha <sha>",
|
||||
"hwpod workspace insert-after --path <path> --anchor <text> --line <text> --expected-sha <sha>",
|
||||
@@ -253,6 +257,8 @@ function hwpodWorkspaceHelp(subcommand = "") {
|
||||
],
|
||||
aliases: {
|
||||
read: "workspace.cat",
|
||||
grep: "workspace.rg",
|
||||
search: "workspace.rg",
|
||||
file: "--path",
|
||||
remotePath: "--path",
|
||||
patchContent: "--patch"
|
||||
@@ -424,7 +430,7 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
|
||||
const subcommand = parsed._[1] || "ls";
|
||||
if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } };
|
||||
if (subcommand === "cat" || subcommand === "read") return { intent: "workspace.cat", args: { path: requiredWorkspacePath(parsed, 2) } };
|
||||
if (subcommand === "rg") return { intent: "workspace.rg", args: { pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), path: text(parsed.path ?? parsed._[3] ?? "."), ignoreCase: parsed.ignoreCase === true } };
|
||||
if (subcommand === "rg" || subcommand === "grep" || subcommand === "search") return { intent: "workspace.rg", args: workspaceSearchArgs(parsed) };
|
||||
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? parsed.patchContent ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
|
||||
if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredWorkspacePath(parsed, 2), content: patchText(parsed.content ?? parsed.contentText ?? stdinText), contentBase64: text(parsed.contentBase64), expectedSha: text(parsed.expectedSha), lineEnding: text(parsed.lineEnding), finalNewline: parsed.finalNewline === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
|
||||
if (subcommand === "replace") return { intent: "workspace.replace", args: cleanTextEditArgs({ path: requiredWorkspacePath(parsed, 2), find: requiredRawString(parsed.find ?? parsed._[3], "find"), replace: rawString(parsed.replace ?? parsed.replacement ?? parsed._[4]), expectedSha: text(parsed.expectedSha), all: parsed.all === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
|
||||
@@ -453,6 +459,21 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
|
||||
throw cliError("unsupported_hwpod_command", `unsupported hwpod-cli command: ${command}`);
|
||||
}
|
||||
|
||||
function workspaceSearchArgs(parsed: ParsedArgs) {
|
||||
return clean({
|
||||
pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"),
|
||||
path: text(parsed.path ?? parsed._[3] ?? "."),
|
||||
ignoreCase: parsed.ignoreCase === true,
|
||||
beforeContext: numberValue(parsed.beforeContext),
|
||||
afterContext: numberValue(parsed.afterContext),
|
||||
context: numberValue(parsed.context),
|
||||
maxMatches: numberValue(parsed.maxMatches),
|
||||
maxFiles: numberValue(parsed.maxFiles),
|
||||
maxBytesPerFile: numberValue(parsed.maxBytesPerFile),
|
||||
maxLineBytes: numberValue(parsed.maxLineBytes)
|
||||
});
|
||||
}
|
||||
|
||||
function opsForIntent(intent: string, args: any, document: any) {
|
||||
const common = commonOpArgs(document);
|
||||
if (intent === "inspect") return [
|
||||
@@ -462,7 +483,7 @@ function opsForIntent(intent: string, args: any, document: any) {
|
||||
if (intent === "node.health" || intent === "node.version" || intent === "node.inventory") return [{ op: intent, args: common }];
|
||||
if (intent === "workspace.ls") return [{ op: "workspace.ls", args: { ...common, path: text(args.path) || "." } }];
|
||||
if (intent === "workspace.cat") return [{ op: "workspace.cat", args: { ...common, path: requiredText(args.path, "path") } }];
|
||||
if (intent === "workspace.rg") return [{ op: "workspace.rg", args: { ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true } }];
|
||||
if (intent === "workspace.rg") return [{ op: "workspace.rg", args: clean({ ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true, beforeContext: numberValue(args.beforeContext), afterContext: numberValue(args.afterContext), context: numberValue(args.context), maxMatches: numberValue(args.maxMatches), maxFiles: numberValue(args.maxFiles), maxBytesPerFile: numberValue(args.maxBytesPerFile), maxLineBytes: numberValue(args.maxLineBytes) }) }];
|
||||
if (intent === "workspace.apply-patch") return [{ op: "workspace.apply-patch", args: clean({ ...common, patch: patchText(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }];
|
||||
if (intent === "workspace.write") return [{ op: "workspace.write", args: clean({ ...common, path: requiredText(args.path, "path"), content: patchText(args.content), contentBase64: text(args.contentBase64), expectedSha: text(args.expectedSha), lineEnding: text(args.lineEnding), finalNewline: args.finalNewline === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }];
|
||||
if (intent === "workspace.replace") return [{ op: "workspace.replace", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), find: requiredRawString(args.find, "find"), replace: rawString(args.replace), expectedSha: text(args.expectedSha), all: args.all === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }];
|
||||
@@ -1019,6 +1040,11 @@ function parseOptions(argv: string[]): ParsedArgs {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if (["-i", "-A", "-B", "-C"].includes(token) || /^-[ABC]\d+$/u.test(token)) {
|
||||
applyShortSearchOption(parsed, token, argv[index + 1]);
|
||||
if (["-A", "-B", "-C"].includes(token)) index += 1;
|
||||
continue;
|
||||
}
|
||||
const [rawKey, rawInline] = token.slice(2).split(/=(.*)/su, 2);
|
||||
const key = camelCase(rawKey);
|
||||
if (BOOLEAN_OPTIONS.has(key)) {
|
||||
@@ -1040,6 +1066,19 @@ function parseOptions(argv: string[]): ParsedArgs {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function applyShortSearchOption(parsed: ParsedArgs, token: string, next: string | undefined) {
|
||||
if (token === "-i") {
|
||||
parsed.ignoreCase = true;
|
||||
return;
|
||||
}
|
||||
const flag = token[1];
|
||||
const inlineValue = token.length > 2 ? token.slice(2) : next;
|
||||
const value = numberValue(inlineValue);
|
||||
if (flag === "A") parsed.afterContext = value;
|
||||
if (flag === "B") parsed.beforeContext = value;
|
||||
if (flag === "C") parsed.context = value;
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown, name: string) {
|
||||
if (value === undefined || value === null || value === "") return {};
|
||||
if (isPlainObject(value)) return value;
|
||||
|
||||
+150
-4
@@ -379,10 +379,156 @@ async function workspaceCat(args: any) {
|
||||
async function workspaceRg(args: any) {
|
||||
const pattern = requiredText(args.pattern, "pattern");
|
||||
const cwd = workspaceRoot(args);
|
||||
const rgPath = text(args.path) || ".";
|
||||
const command = ["rg", "--line-number", "--color", "never", ...(args.ignoreCase === true ? ["--ignore-case"] : []), pattern, rgPath];
|
||||
const output = await spawnOutput(command, { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 10000 });
|
||||
return { cwd, command, ...output };
|
||||
const searchPath = text(args.path) || ".";
|
||||
const target = resolveWorkspacePath(args, searchPath);
|
||||
const flags = args.ignoreCase === true ? "iu" : "u";
|
||||
let matcher: RegExp;
|
||||
try {
|
||||
matcher = new RegExp(pattern, flags);
|
||||
} catch (error) {
|
||||
throw cliError("workspace_rg_invalid_pattern", `invalid workspace.rg pattern: ${pattern}`, { pattern, message: error?.message ?? String(error) });
|
||||
}
|
||||
|
||||
const maxFiles = numberValue(args.maxFiles) ?? 5000;
|
||||
const maxMatches = numberValue(args.maxMatches) ?? 200;
|
||||
const maxBytesPerFile = numberValue(args.maxBytesPerFile) ?? 1024 * 1024;
|
||||
const lineLimit = numberValue(args.maxLineBytes) ?? 600;
|
||||
const contextLines = Math.max(numberValue(args.context) ?? 0, 0);
|
||||
const beforeContext = Math.max(numberValue(args.beforeContext) ?? contextLines, 0);
|
||||
const afterContext = Math.max(numberValue(args.afterContext) ?? contextLines, 0);
|
||||
const matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }> = [];
|
||||
const state = { scannedFiles: 0, skippedFiles: 0, skippedDirectories: 0, truncated: false };
|
||||
|
||||
async function scanFile(filePath: string) {
|
||||
if (state.truncated || state.scannedFiles >= maxFiles || matches.length >= maxMatches) {
|
||||
state.truncated = true;
|
||||
return;
|
||||
}
|
||||
const info = await stat(filePath).catch(() => null);
|
||||
if (!info?.isFile?.()) {
|
||||
state.skippedFiles += 1;
|
||||
return;
|
||||
}
|
||||
if (info.size > maxBytesPerFile) {
|
||||
state.skippedFiles += 1;
|
||||
return;
|
||||
}
|
||||
const buffer = await readFile(filePath).catch(() => null);
|
||||
if (!buffer || buffer.includes(0)) {
|
||||
state.skippedFiles += 1;
|
||||
return;
|
||||
}
|
||||
state.scannedFiles += 1;
|
||||
const displayPath = workspaceDisplayPath(cwd, filePath);
|
||||
const lines = buffer.toString("utf8").split(/\r\n|\n|\r/u);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (matcher.test(lines[index])) {
|
||||
matches.push(workspaceSearchMatch(displayPath, lines, index, beforeContext, afterContext, lineLimit));
|
||||
if (matches.length >= maxMatches) {
|
||||
state.truncated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(directory: string) {
|
||||
if (state.truncated) return;
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) {
|
||||
state.skippedDirectories += 1;
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (state.truncated) return;
|
||||
const child = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (isWorkspaceSearchSkippedDirectory(entry.name)) {
|
||||
state.skippedDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
await walk(child);
|
||||
} else if (entry.isFile()) {
|
||||
await scanFile(child);
|
||||
} else {
|
||||
state.skippedFiles += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const info = await stat(target).catch(() => null);
|
||||
if (!info) throw cliError("workspace_rg_path_not_found", `workspace.rg path not found: ${searchPath}`, { workspacePath: cwd, path: searchPath, target });
|
||||
if (info.isDirectory()) await walk(target);
|
||||
else await scanFile(target);
|
||||
|
||||
const stdout = renderWorkspaceSearchStdout(matches);
|
||||
return {
|
||||
ok: true,
|
||||
engine: "node-recursive-search",
|
||||
cwd,
|
||||
path: searchPath,
|
||||
target,
|
||||
pattern,
|
||||
ignoreCase: args.ignoreCase === true,
|
||||
exitCode: 0,
|
||||
stdout,
|
||||
stderr: "",
|
||||
scannedFiles: state.scannedFiles,
|
||||
skippedFiles: state.skippedFiles,
|
||||
skippedDirectories: state.skippedDirectories,
|
||||
matchCount: matches.length,
|
||||
matches,
|
||||
truncated: state.truncated,
|
||||
limits: { maxFiles, maxMatches, maxBytesPerFile, maxLineBytes: lineLimit, beforeContext, afterContext }
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceSearchMatch(displayPath: string, lines: string[], index: number, beforeContext: number, afterContext: number, lineLimit: number) {
|
||||
const before = [];
|
||||
const after = [];
|
||||
for (let lineIndex = Math.max(0, index - beforeContext); lineIndex < index; lineIndex += 1) {
|
||||
before.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) });
|
||||
}
|
||||
for (let lineIndex = index + 1; lineIndex <= Math.min(lines.length - 1, index + afterContext); lineIndex += 1) {
|
||||
after.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) });
|
||||
}
|
||||
const match: { path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> } = {
|
||||
path: displayPath,
|
||||
line: index + 1,
|
||||
text: truncateText(lines[index], lineLimit)
|
||||
};
|
||||
if (before.length) match.before = before;
|
||||
if (after.length) match.after = after;
|
||||
return match;
|
||||
}
|
||||
|
||||
function renderWorkspaceSearchStdout(matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }>) {
|
||||
const lines: string[] = [];
|
||||
for (const match of matches) {
|
||||
for (const context of match.before ?? []) lines.push(`${match.path}-${context.line}-${context.text}`);
|
||||
lines.push(`${match.path}:${match.line}:${match.text}`);
|
||||
for (const context of match.after ?? []) lines.push(`${match.path}-${context.line}-${context.text}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function isWorkspaceSearchSkippedDirectory(name: string) {
|
||||
return new Set([".git", ".worktree", "node_modules", "Objects", "Listings", "Output", "build", "dist"]).has(name);
|
||||
}
|
||||
|
||||
function workspaceDisplayPath(root: string, filePath: string) {
|
||||
const relative = path.relative(root, filePath) || path.basename(filePath);
|
||||
return relative.replace(/[\\]+/gu, "/");
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxBytes: number) {
|
||||
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
||||
let output = "";
|
||||
for (const char of value) {
|
||||
if (Buffer.byteLength(`${output}${char}`, "utf8") > maxBytes) break;
|
||||
output += char;
|
||||
}
|
||||
return `${output}...<truncated>`;
|
||||
}
|
||||
|
||||
async function workspaceApplyPatch(args: any) {
|
||||
|
||||
Reference in New Issue
Block a user