diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 7b6ca0b7..0eb796ce 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -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 ` 查询;该入口仍编译为 `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 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 --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 ``` diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 16ec8aaf..dc8421eb 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -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 }); } diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 676db319..f8cdf28f 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -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; type ParsedArgs = Record & { _: 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 ", "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 --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 `; 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] : []; }