fix: smooth hwpod harness commands

This commit is contained in:
Codex Agent
2026-06-06 22:14:10 +08:00
parent fc7ed2c3ab
commit b73cf2d019
3 changed files with 191 additions and 29 deletions
+7 -3
View File
@@ -105,7 +105,7 @@ spec:
- `spec.nodeBinding.nodeId` 必须存在。
- `spec.workspace.path` 必须存在。
Keil MDK 装配规则:当 `spec.workspace.toolchain``keil-mdk``keil``mdk``uv4` 时,`hwpod-compiler-cli` 可以直接从 `hwpod-spec` 生成 `debug.build` / `debug.download` 的 node-side command。`spec.workspace.keilProject``spec.workspace.projectPath` 指向 `.uvprojx`,相对路径按 `spec.workspace.path` 解析;`spec.workspace.keilTarget``spec.workspace.targetName` 指向 Keil target。`spec.debugProbe.probeUid` 用于固定 DAP-Link/CMSIS-DAP 选择,`spec.debugProbe.programBackend` 默认按 Keil UV4 工程链路下载。下载前若存在 `probeUid`compiler 会先生成 Keil probe-binding 写回命令,再生成 `program --program-backend keil` 命令,以保持旧 host profile 中 probe 绑定与 Keil 下载语义在新 hwpod 概念系统内收敛。自动生成的 Keil 下载计划必须拆成独立 `cmd.run` 步骤,并使`command` + `argv` 传递参数;`probeName`、Windows 路径和 target 名称必须作为独立 argv 元素,不得依赖 `cmd.exe /c`、PowerShell 或 `&&` 拼接来保真带空格参数。
Keil MDK 装配规则:当 `spec.workspace.toolchain``keil-mdk``keil``mdk``uv4` 时,`hwpod-compiler-cli` 可以直接从 `hwpod-spec` 生成 `debug.build` / `debug.download` 的 node-side command。`spec.workspace.keilProject``spec.workspace.projectPath` 指向 `.uvprojx`,相对路径按 `spec.workspace.path` 解析;`spec.workspace.keilTarget``spec.workspace.targetName` 指向 Keil target。`spec.debugProbe.probeUid` 用于固定 DAP-Link/CMSIS-DAP 选择,`spec.debugProbe.programBackend` 默认按 Keil UV4 工程链路下载。标准下载语义必须由 compiler 生成 Keil `flash` 命令,让 build+program 留在同一个 Keil 异步 job 中;不得依赖预先存在的 artifact、手工 uvoptx 状态或旧 profile 副作用。只有 `spec.debugProbe.autoBindUvoptx` / `autoBindProbe` 显式打开时,compiler 才生成 probe-binding 写回步骤。自动生成的 Keil 计划必须使用已有 `cmd.run` op,并用 `command` + `argv` 传递参数;`probeName`、Windows 路径和 target 名称必须作为独立 argv 元素,不得依赖 `cmd.exe /c`、PowerShell 或 `&&` 拼接来保真带空格参数。Keil 长任务默认异步启动,后续用 `hwpod-cli job status <jobId>` 查询;该入口仍编译为 `cmd.run` 调已有 Keil CLI,不新增 node op。
`spec.workspace.buildCommand``spec.debugProbe.downloadCommand``spec.debugProbe.resetCommand` 仍是调试覆盖出口;标准 case 和 Code Agent runner 不应依赖这些字段长期手写 Keil 主流程。旧 `device-host-cli` / device pod profile 不再作为 compiler 输入权威,相关工程、target、probe 和下载 backend 信息必须迁入 `hwpod-spec`
@@ -133,11 +133,11 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`
| `io.uart.jsonrpc` | 通过 UART 做 JSON-RPC |
| `cmd.run` | 临时维护命令透传,仅用于 `hwpod-ctl` 或早期薄 node 调试 |
`hwpod-node` 是 HWPOD 的唯一受控节点执行器。`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` 原链路复测。
`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` 原链路复测。
Windows subject worktree 文本修改优先使用 `workspace.apply-patch``workspace.replace``workspace.insert-after``workspace.write`,不要把 `cmd.run` 的 PowerShell/cmd quoting 当成标准编辑路径。文本编辑结果必须返回编辑前后 SHA、文件字节数、换行类型、diff 摘要和 dry-run 状态;`apply-patch` 匹配失败时必须返回 normalized preview、候选行号、CRLF/LF 统计、文件 SHA/bytes 和 hwpod-node implementation version,便于判断是上下文不匹配、CRLF 问题还是远端实现版本问题。`workspace.apply-patch` 应保留原文件换行风格,尤其不能把 CRLF subject 文件静默改写成 LF。
UART read 由薄 `hwpod-node` 绑定节点本地 `serial-monitor` CLI 执行。默认绑定路径是 `~/.agents/skills/serial-monitor`,也可通过 `HWPOD_SERIAL_MONITOR_DIR` / `HWPOD_SERIAL_MONITOR_COMMAND` 或 plan args 覆盖;`spec.ioProbe.uart.port``spec.ioProbe.uart.baudrate` 是物理串口和波特率权威。`io.uart.read` 只能在 `serial-monitor monitor status` 显示正在监控同一 COM/baud 时返回数据;未监控、串口不匹配或工具失败时必须返回结构化 blocker details,至少包含请求 port、解析出的物理 port/baud、workspacePath、平台、node version、monitor status、可用 ports 和启动建议。不得静默伪造空读数,也不得要求 Agent 改走手写 PowerShell/串口脚本旁路。
UART read 默认由 compiler 把 `hwpod-cli uart read` 编译为已有 `cmd.run` 内的一段短序列,调用节点本地 `serial-monitor` CLI 的 `monitor start``fetch --session-only`。两步必须在同一个 `cmd.run` 内顺序执行:`monitor start` 的 JSON 若返回 `success:false`,该 `cmd.run` 必须以非 0 退出并停止,不得继续 fetch 旧会话数据。默认绑定路径是 `C:\Users\liang\.agents\skills\serial-monitor`,也可通过 `spec.tooling.serialMonitorDir``spec.ioProbe.serialMonitorDir` 或 CLI 参数覆盖;`spec.ioProbe.uart.port``spec.ioProbe.uart.baudrate` 是物理串口和波特率权威。串口采集失败时必须把 serial-monitor 的 stdout/stderr、exit code 和 command argv 原样留在 `cmd.run` result 中;不得静默伪造空读数,也不得要求 Agent 改走手写 PowerShell/串口脚本旁路。
标准 plan 形态:
@@ -179,8 +179,12 @@ bun tools/hwpod-ctl.ts spec init --spec .hwlab/hwpod-spec.yaml
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 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
bun tools/hwpod-cli.ts job status <jobId> --spec .hwlab/hwpod-spec.yaml
bun tools/hwpod-cli.ts uart read --spec .hwlab/hwpod-spec.yaml --port uart1
bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678
```
+32 -10
View File
@@ -106,19 +106,24 @@ test("hwpod-compiler-cli generates Keil build and download commands from structu
const download = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
assert.equal(download.exitCode, 0);
assert.deepEqual(download.payload.plan.ops.map((op: any) => op.op), ["cmd.run", "cmd.run"]);
assert.equal(download.payload.plan.ops[0].args.step, "keil-probe-binding");
assert.deepEqual(download.payload.plan.ops.map((op: any) => op.op), ["cmd.run"]);
assert.equal(download.payload.plan.ops[0].args.step, "keil-flash");
assert.equal(download.payload.plan.ops[0].args.command, "py");
assert.deepEqual(download.payload.plan.ops[0].args.argv.slice(0, 6), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "project", "probe-binding", "set", "-p"]);
assert.equal(download.payload.plan.ops[0].args.argv[download.payload.plan.ops[0].args.argv.indexOf("--probe-name") + 1], "CherryUSB MicroLink CMSIS-DAP");
assert.equal(download.payload.plan.ops[0].args.argv[download.payload.plan.ops[0].args.argv.indexOf("--probe-uid") + 1], "3FD750C63E342E24");
assert.deepEqual(download.payload.plan.ops[0].args.argv.slice(0, 5), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "flash", "-p", "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvprojx"]);
assert.deepEqual(download.payload.plan.ops[0].args.argv.slice(-8), ["-m", "daplink", "--program-backend", "keil", "-u", "3FD750C63E342E24", "-t", "USART"]);
assert.equal(download.payload.plan.ops[0].args.commandBinding.action, "download");
assert.equal(download.payload.plan.ops[0].args.commandBinding.keilCommand, "flash");
assert.equal(download.payload.plan.ops[0].args.commandBinding.programBackend, "keil");
assert.equal(download.payload.plan.ops[1].args.step, "keil-program");
assert.equal(download.payload.plan.ops[1].args.command, "py");
assert.deepEqual(download.payload.plan.ops[1].args.argv.slice(0, 5), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "program", "-p", "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvprojx"]);
assert.deepEqual(download.payload.plan.ops[1].args.argv.slice(-8), ["-m", "daplink", "--program-backend", "keil", "-u", "3FD750C63E342E24", "-t", "USART"]);
assert.match(download.payload.plan.ops[0].args.commandLine, /--probe-name "CherryUSB MicroLink CMSIS-DAP"/u);
assert.equal(download.payload.plan.ops[0].args.commandBinding.autoBindUvoptx, false);
assert.match(download.payload.plan.ops[0].args.commandLine, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py flash -p/u);
const jobStatus = await runHwpodCli(["job", "status", "keil_job_123", "--spec", specPath, "--dry-run"], { now: () => NOW });
assert.equal(jobStatus.exitCode, 0);
assert.equal(jobStatus.payload.plan.ops[0].op, "cmd.run");
assert.equal(jobStatus.payload.plan.ops[0].args.step, "keil-job-status");
assert.deepEqual(jobStatus.payload.plan.ops[0].args.argv.slice(0, 3), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "job-status"]);
assert.equal(jobStatus.payload.plan.ops[0].args.argv[3], "keil_job_123");
assert.equal(jobStatus.payload.plan.ops[0].args.workspacePath, "C:\\Users\\liang\\.agents\\skills\\keil");
await runHwpodCtl(["spec", "set", "spec.debugProbe.downloadCommand", "custom download command", "--spec", specPath], { now: () => NOW });
const override = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
@@ -165,6 +170,23 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl
assert.equal(replace.payload.plan.ops[0].op, "workspace.replace");
assert.equal(replace.payload.plan.ops[0].args.find, " /* MARKER_OK */");
assert.equal(replace.payload.plan.ops[0].args.replace, "");
const read = await runHwpodCli(["workspace", "read", "--spec", specPath, "projects/01_baseline/User/main.c", "--dry-run"], { now: () => NOW });
assert.equal(read.exitCode, 0);
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");
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 });
assert.equal(uart.exitCode, 0);
assert.deepEqual(uart.payload.plan.ops.map((op: any) => op.op), ["cmd.run"]);
assert.equal(uart.payload.plan.ops[0].args.step, "serial-monitor-read");
assert.equal(uart.payload.plan.ops[0].args.workspacePath, "C:\\Users\\liang\\.agents\\skills\\serial-monitor");
assert.equal(uart.payload.plan.ops[0].args.command, "node");
const uartSequence = JSON.parse(uart.payload.plan.ops[0].args.argv[2]);
assert.deepEqual(uartSequence[0], ["bun", "scripts/serial-monitor-cli.ts", "monitor", "start", "-p", "COM9", "-b", "115200"]);
assert.deepEqual(uartSequence[1].slice(0, 5), ["bun", "scripts/serial-monitor-cli.ts", "fetch", "-l", "7"]);
} finally {
await rm(root, { recursive: true, force: true });
}
+152 -16
View File
@@ -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"]);
const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "json", "noAuth", "wait"]);
const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000;
type EnvLike = Record<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
@@ -112,7 +112,9 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc
}
const response = await submitHwpodNodeOpsPlan({ parsed, env, fetchImpl: options.fetchImpl, plan });
const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0;
return result(exitCode, ok("hwpod-cli.invoke", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed")), now);
const payload = ok("hwpod-cli.invoke", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed"));
if (exitCode !== 0) payload.ok = false;
return result(exitCode, payload, now);
} catch (error) {
return result(1, failure(CLI_NAME, error), now);
}
@@ -184,20 +186,31 @@ function cliHelp() {
"bun tools/hwpod-cli.ts inspect --dry-run",
"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",
"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 */\"",
"bun tools/hwpod-cli.ts build --dry-run",
"bun tools/hwpod-cli.ts download --dry-run",
"bun tools/hwpod-cli.ts job status <jobId> --dry-run",
"bun tools/hwpod-cli.ts uart read --port uart1 --dry-run",
"bun tools/hwpod-cli.ts --api-base-url http://74.48.78.17:19667 inspect"
],
workspace: {
cat: "Read a subject workspace file through hwpod-node; path is relative to spec.workspace.path.",
cat: "Read a subject workspace file through hwpod-node; path is relative to spec.workspace.path. `read` is a CLI alias for `cat`.",
applyPatch: "Apply a Codex apply_patch envelope through hwpod-node. Patch content may come from stdin, --patch/--patchText, or --patchBase64.",
write: "Write a whole file with optional --expected-sha, --line-ending preserve|lf|crlf and dry-run diff output.",
replace: "Replace exact text in a subject file with SHA/diff diagnostics; pass --all for global replacement.",
insertAfter: "Insert one or more lines after an exact anchor without shell quoting."
},
debug: {
build: "Keil build starts an async job by default; pass --wait only for bounded manual debugging.",
download: "Keil download compiles to a flash job through cmd.run, so build+program stays inside the Keil job.",
jobStatus: "Query a Keil async job with `hwpod job status <jobId>`; this compiles to cmd.run rather than a new node op."
},
uart: {
read: "Use the configured serial-monitor CLI through cmd.run. The hwpod-node contract stays at cmd.run plus file ops."
},
applyPatchExample: [
"*** Begin Patch",
"*** Update File: projects/01_baseline/User/main.c",
@@ -326,7 +339,7 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
if (command === "workspace") {
const subcommand = parsed._[1] || "ls";
if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } };
if (subcommand === "cat") return { intent: "workspace.cat", args: { path: requiredText(parsed.path ?? parsed._[2], "path") } };
if (subcommand === "cat" || subcommand === "read") return { intent: "workspace.cat", args: { path: requiredText(parsed.path ?? parsed._[2], "path") } };
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 === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredText(parsed.path ?? parsed._[2], "path"), 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) }) };
@@ -334,12 +347,17 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
if (subcommand === "insert-after") return { intent: "workspace.insert-after", args: cleanTextEditArgs({ path: requiredText(parsed.path ?? parsed._[2], "path"), anchor: requiredRawString(parsed.anchor ?? parsed._[3], "anchor"), line: requiredRawString(parsed.line ?? parsed.insert ?? parsed.text ?? parsed._[4], "line"), expectedSha: text(parsed.expectedSha), allowMultiple: parsed.allowMultiple === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) };
throw cliError("unsupported_workspace_command", `unsupported workspace command: ${subcommand}`);
}
if (command === "build") return { intent: "debug.build", args: clean({ target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "download") return { intent: "debug.download", args: clean({ artifact: text(parsed.artifact ?? parsed._[1]), target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "build") return { intent: "debug.build", args: clean({ target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), wait: parsed.wait === true, timeoutMs: numberValue(parsed.timeoutMs), reason: text(parsed.reason) }) };
if (command === "download") return { intent: "debug.download", args: clean({ artifact: text(parsed.artifact ?? parsed._[1]), target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), wait: parsed.wait === true, timeoutMs: numberValue(parsed.timeoutMs), reason: text(parsed.reason) }) };
if (command === "job") {
const subcommand = parsed._[1] || "status";
if (subcommand === "status") return { intent: "debug.job-status", args: clean({ jobId: requiredText(parsed.jobId ?? parsed._[2], "jobId"), timeoutMs: numberValue(parsed.timeoutMs), reason: text(parsed.reason) }) };
throw cliError("unsupported_job_command", `unsupported job command: ${subcommand}`);
}
if (command === "reset") return { intent: "debug.reset", args: clean({ mode: text(parsed.mode ?? parsed._[1]), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "uart") {
const subcommand = parsed._[1] || "read";
if (subcommand === "read") return { intent: "io.uart.read", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), maxBytes: numberValue(parsed.maxBytes), since: text(parsed.since) }) };
if (subcommand === "read") return { intent: "io.uart.read", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), maxBytes: numberValue(parsed.maxBytes), limit: numberValue(parsed.limit), since: text(parsed.since), timeoutMs: numberValue(parsed.timeoutMs), serialMonitorDir: text(parsed.serialMonitorDir), serialMonitorCommand: text(parsed.serialMonitorCommand), reason: text(parsed.reason) }) };
if (subcommand === "write") return { intent: "io.uart.write", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), data: requiredText(parsed.data ?? parsed._[3] ?? stdinText, "data") }) };
throw cliError("unsupported_uart_command", `unsupported uart command: ${subcommand}`);
}
@@ -367,8 +385,9 @@ function opsForIntent(intent: string, args: any, document: any) {
if (intent === "workspace.insert-after") return [{ op: "workspace.insert-after", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), anchor: requiredRawString(args.anchor, "anchor"), line: requiredRawString(args.line, "line"), expectedSha: text(args.expectedSha), allowMultiple: args.allowMultiple === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }];
if (intent === "debug.build") return debugBuildOps(common, args, document);
if (intent === "debug.download") return debugDownloadOps(common, args, document);
if (intent === "debug.job-status") return debugJobStatusOps(common, args, document);
if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode), command: text(args.command) || text(document.spec.debugProbe.resetCommand), reason: text(args.reason) }) }];
if (intent === "io.uart.read") return [{ op: "io.uart.read", args: clean({ ...common, port: text(args.port) || "uart1", maxBytes: numberValue(args.maxBytes), since: text(args.since) }) }];
if (intent === "io.uart.read") return uartReadOps(common, args, document);
if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }];
if (intent === "io.uart.jsonrpc") return [{ op: "io.uart.jsonrpc", args: clean({ ...common, port: text(args.port) || "uart1", method: requiredText(args.method, "method"), params: objectValue(args.params) }) }];
if (intent === "cmd.run") return [{ op: "cmd.run", args: clean({ ...common, command: requiredText(args.command, "command"), argv: Array.isArray(args.argv) ? args.argv : [] }) }];
@@ -442,6 +461,42 @@ function debugDownloadOps(common: any, args: any, document: any) {
return [{ op: "debug.download", args: debugDownloadArgs(common, args, document) }];
}
function debugJobStatusOps(common: any, args: any, document: any) {
const generated = keilJobStatusCommand(args, document);
return [{
op: "cmd.run",
args: clean({
...common,
workspacePath: dirnameForCommandPath(generated.keilCliPath),
command: generated.commandRun.command,
argv: generated.commandRun.argv,
commandLine: generated.command,
step: "keil-job-status",
commandBinding: generated.binding,
timeoutMs: numberValue(args.timeoutMs) ?? generated.timeoutMs,
reason: text(args.reason)
})
}];
}
function uartReadOps(common: any, args: any, document: any) {
const generated = serialMonitorReadCommand(args, document);
return [{
op: "cmd.run",
args: clean({
...common,
workspacePath: generated.serialMonitorDir,
command: generated.commandRun.command,
argv: generated.commandRun.argv,
commandLine: generated.command,
step: "serial-monitor-read",
commandBinding: generated.binding,
timeoutMs: numberValue(args.timeoutMs) ?? generated.timeoutMs,
reason: text(args.reason)
})
}];
}
function keilCommandForIntent(intent: "debug.build" | "debug.download", args: any, document: any) {
const workspace = objectValue(document.spec.workspace);
const debugProbe = objectValue(document.spec.debugProbe);
@@ -455,13 +510,13 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an
const timeoutMs = numberValue(args.timeoutMs) ?? numberValue(workspace.keilCommandTimeoutMs) ?? numberValue(debugProbe.keilCommandTimeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS;
const commonTokens = [...splitCommandWords(pythonCommand), keilCliPath];
if (intent === "debug.build") {
const commandTokens = [...commonTokens, "build", "-p", project, ...targetOption(target)];
const commandTokens = [...commonTokens, "build", "-p", project, ...targetOption(target), ...(args.wait === true ? ["--wait"] : [])];
return {
target,
command: shellCommand(commandTokens),
commandRun: commandRun(commandTokens),
timeoutMs,
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "build", project, target, keilCliPath, pythonCommand })
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "build", project, target, keilCliPath, pythonCommand, wait: args.wait === true })
};
}
@@ -469,13 +524,13 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an
const programmer = text(args.programmer) || text(debugProbe.programmer) || (text(debugProbe.type).toLowerCase() === "daplink" ? "daplink" : "daplink");
const programBackend = text(args.programBackend ?? args.backend) || text(debugProbe.programBackend) || (text(debugProbe.adapter).toLowerCase() === "keil" ? "keil" : "keil");
const probeName = text(args.probeName) || text(debugProbe.probeName) || text(debugProbe.name);
const autoBindUvoptx = debugProbe.autoBindUvoptx !== false && debugProbe.autoBindProbe !== false;
const autoBindUvoptx = debugProbe.autoBindUvoptx === true || debugProbe.autoBindProbe === true;
const bindTokens = autoBindUvoptx && probeUid
? [...commonTokens, "project", "probe-binding", "set", "-p", project, ...projectTargetOption(target), "--probe-uid", probeUid, ...probeNameOption(probeName)]
: [];
const programTokens = [
const flashTokens = [
...commonTokens,
"program",
"flash",
"-p",
project,
"-m",
@@ -483,18 +538,88 @@ function keilCommandForIntent(intent: "debug.build" | "debug.download", args: an
"--program-backend",
programBackend,
...probeOption(probeUid),
...targetOption(target)
...targetOption(target),
...(args.wait === true ? ["--wait"] : [])
];
const commandRuns = [
...(bindTokens.length > 0 ? [{ step: "keil-probe-binding", command: shellCommand(bindTokens), commandRun: commandRun(bindTokens) }] : []),
{ step: "keil-program", command: shellCommand(programTokens), commandRun: commandRun(programTokens) }
{ step: "keil-flash", command: shellCommand(flashTokens), commandRun: commandRun(flashTokens) }
];
return {
target,
command: commandRuns.map((entry) => entry.command).join(" && "),
commandRuns,
timeoutMs,
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: bindTokens.length > 0 })
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "download", keilCommand: "flash", project, target, keilCliPath, pythonCommand, programmer, programBackend, probeUid, probeName, autoBindUvoptx: bindTokens.length > 0, wait: args.wait === true })
};
}
function keilJobStatusCommand(args: any, document: any) {
const workspace = objectValue(document.spec.workspace);
const debugProbe = objectValue(document.spec.debugProbe);
const tooling = objectValue(document.spec.tooling);
const keilCliPath = text(args.keilCliPath) || text(workspace.keilCliPath) || text(debugProbe.keilCliPath) || text(tooling.keilCliPath) || "keil-cli.py";
const pythonCommand = text(args.pythonCommand) || text(workspace.pythonCommand) || text(debugProbe.pythonCommand) || text(tooling.pythonCommand) || "py -3";
const timeoutMs = numberValue(args.timeoutMs) ?? numberValue(workspace.keilCommandTimeoutMs) ?? numberValue(debugProbe.keilCommandTimeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS;
const jobId = requiredText(args.jobId, "jobId");
const commandTokens = [...splitCommandWords(pythonCommand), keilCliPath, "job-status", jobId];
return {
keilCliPath,
command: shellCommand(commandTokens),
commandRun: commandRun(commandTokens),
timeoutMs,
binding: clean({ kind: "keil-mdk", source: "hwpod-compiler.keil-mdk", action: "job-status", jobId, keilCliPath, pythonCommand })
};
}
function serialMonitorReadCommand(args: any, document: any) {
const ioProbe = objectValue(document.spec.ioProbe);
const uart = objectValue(ioProbe.uart);
const tooling = objectValue(document.spec.tooling);
const requestedPort = text(args.port) || text(uart.id) || "uart1";
const physicalPort = text(args.physicalPort) || text(uart.port) || text(uart.path) || text(ioProbe.port) || requestedPort;
const baudRate = numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate) ?? 115200;
const serialMonitorDir = text(args.serialMonitorDir) || text(tooling.serialMonitorDir) || text(ioProbe.serialMonitorDir) || "C:\\Users\\liang\\.agents\\skills\\serial-monitor";
const commandBase = serialMonitorCommandBaseForCompiler(args, tooling, ioProbe);
const timeoutMs = numberValue(args.timeoutMs) ?? 30000;
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
const fetchArgs = ["fetch", "-l", String(limit), "--session-only", ...(text(args.since) ? ["-s", text(args.since)] : [])];
const sequenceRun = jsonCliSequenceCommandRun([
[...commandBase, "monitor", "start", "-p", physicalPort, "-b", String(baudRate)],
[...commandBase, ...fetchArgs]
]);
return {
serialMonitorDir,
command: sequenceRun.commandLine,
commandRun: sequenceRun.commandRun,
timeoutMs,
binding: clean({ kind: "serial-monitor-cli", source: "hwpod-compiler.serial-monitor", action: "uart-read", requestedPort, physicalPort, baudRate, serialMonitorDir })
};
}
function serialMonitorCommandBaseForCompiler(args: any, tooling: any, ioProbe: any) {
const configured = text(args.serialMonitorCommand) || text(tooling.serialMonitorCommand) || text(ioProbe.serialMonitorCommand);
if (configured) return splitCommandWords(configured);
return ["bun", "scripts/serial-monitor-cli.ts"];
}
function jsonCliSequenceCommandRun(sequence: string[][]) {
const script = [
"const {spawnSync}=require('child_process');",
"const seq=JSON.parse(process.argv[1]);",
"for (const argv of seq) {",
"const r=spawnSync(argv[0],argv.slice(1),{encoding:'utf8'});",
"if(r.stdout) process.stdout.write(r.stdout);",
"if(r.stderr) process.stderr.write(r.stderr);",
"let ok=r.status===0;",
"try{const body=JSON.parse((r.stdout||'').trim()); if(body && body.success===false) ok=false;}catch{}",
"if(!ok) process.exit(r.status||1);",
"}",
"process.exit(0);"
].join(" ");
return {
commandLine: `node -e ${shellArg(script)} ${shellArg(JSON.stringify(sequence))}`,
commandRun: { command: "node", argv: ["-e", script, JSON.stringify(sequence)] }
};
}
@@ -524,6 +649,17 @@ function isAbsoluteLikePath(value: string) {
return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("\\\\");
}
function dirnameForCommandPath(candidate: string) {
const value = text(candidate);
if (!value) return ".";
if (/^[A-Za-z]:[\\/]/u.test(value) || value.includes("\\")) {
const normalized = value.replace(/[\\/]+$/u, "");
const parent = normalized.replace(/[\\/][^\\/]*$/u, "");
return parent && parent !== normalized ? parent : ".";
}
return path.dirname(value);
}
function targetOption(target: string) {
return target ? ["-t", target] : [];
}