From fca2dda37ddc5ba2c4317138e95f5d39da44d68f Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 15:47:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(cli):=20unify=20the=20arg=20grammar=20?= =?UTF-8?q?=E2=80=94=20one=20program,=20--config=20flag,=20real=20web=20su?= =?UTF-8?q?bcommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the bare `dsh ` positional in favor of a `--config ` flag. Without a root positional, `web` can be a real Commander subcommand in one program instead of the reserved-first-token dispatch to a second parser, so `dsh --help` lists every mode natively (no hand-pasted command text) and the second parser + reserved-token machinery are gone. Grammar: dsh TUI (shipped tree + ~/.dsh overlay) dsh --config TUI, alternate tree (demos/tests only) dsh --resume TUI, resume a session dsh -p "task" headless one-shot dsh web [--host --port --dev] `dsh` is the product front door with no positional; `--config` exists only so demo:cordis, demo:code-mode, and the keyless PTY smokes can point the shipped bin at an example tree. Those three sites and the /resume re-exec argv move to `--config `. The `-p` + `--config`/`--resume` mode-mixing guard and the cordis.yml-owns-host/port-default fix are preserved. Agent Note + Chinese pair, README, tui.ts docs updated. All 13 PTY smokes (including code-mode via --config and the exec-replace resume handoff) green. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 14 +- ...07-24-dsh-commander-argument-adapter.zh.md | 14 +- apps/cli/README.md | 6 +- apps/cli/src/args.ts | 129 ++++++++++-------- apps/cli/src/tui.ts | 9 +- apps/cli/tests/args.spec.ts | 8 +- docs/module-graph.md | 3 +- examples/tui-agent/tests/pty-harness.ts | 4 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 4 +- package.json | 2 +- scripts/demo-code-mode.mjs | 2 +- vitest.e2e.config.ts | 4 +- 13 files changed, 110 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 7e947bbed6..6ac3cfdf1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-dsh-commander-argument-adapter.md: f90c4fb8d428eabed353176d98dce0fb9e34bf99 -2026-07-24-dsh-commander-argument-adapter.zh.md: fc0d1aa588ca6ce3b8c9d0b59343c4af698103da +2026-07-24-dsh-commander-argument-adapter.md: e023d9ff296dd4a4024824865358964c8a66f49a +2026-07-24-dsh-commander-argument-adapter.zh.md: 762e3e4b1609e9bc6f9f5bd5cc509c4084573833 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index f90c4fb8d4..e023d9ff29 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -12,17 +12,19 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`). Each parser reads Commander's `opts()`/`processedArgs` after `parse()`, then bails via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a stray config/`--resume` rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `dsh --help` discloses the `web` mode through an `addHelpText` line (a real `web` subcommand would hijack the `[config]` positional). `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. + +`--config ` replaces an earlier positional config argument. `dsh` is the product front door with no positional; the flag exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an alternate example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`. `parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. ## Resume without an environment variable -Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume= [-- ]` — the `--` keeps a config named `web` or starting with `-` a positional — so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. +Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume= [--config ]`, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. ## One terminal front door: `dsh` -The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. +The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh --config ` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with `--config `, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. ## Package topology @@ -34,17 +36,17 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages **Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end. -**Make `web` a Commander subcommand of one root program** — rejected: a single program mixing a root `-p`/`--resume` grammar with a `web` subcommand leaks the root options onto `web` unless `enablePositionalOptions()` plus a parent-option guard are bolted on, which is exactly the kind of special-case machinery this change removes. Dispatching `web` as a reserved first token to a second parser is smaller and keeps the two grammars fully independent. +**Keep the bare `dsh ` positional (and the reserved-`web`-token dispatch it forced)** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional), which is why an earlier revision dispatched a reserved leading `web` token to a second parser and hand-pasted a `web` line into `--help`. The positional existed only so the demo/test sites could boot an alternate tree through the shipped bin. Replacing it with a `--config` flag frees the default surface of any positional, so `web` becomes a normal subcommand in one program with native `--help` — deleting the reserved-token dispatch, the second parser, and the pasted help text. `dsh` loses nothing a user wanted; the demos gain an explicit flag. **Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern. **Keep `RESUME_SESSION_ID` as the resume bridge** — rejected: with `--resume` parsed into a value the bin already holds, threading it through an environment variable the config re-reads is indirection with no benefit, and it left the demo bin a second, env-only resume path. Providing the id on the boot context is the same channel `boot`'s `prepare` hook already uses for `tuiResumeHost`. -**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh [config]` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point. +**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh --config ` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point. ## Testing -`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index fc0d1aa588..762e3e4b16 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -12,17 +12,19 @@ Status: implemented argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 -`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法;`dsh web -p x` 会显式报错(`web` 没有 `-p`)。每个解析器都读取 Commander 的 `opts()`/`processedArgs`(在 `parse()` 之后),随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式,并在任务为空或存在多余的配置位置参数或 `--resume` 时拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);提供 `--host`/`--port` 时,`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数(这两项校验都从 `runWeb` 的内联检查移入解析器)。适配器**不会**为 host/port 设置默认值:未提供某个标志时,对应字段保持 undefined;`runWeb` 仅在相应字段存在时才将 host/port 转发给 `AppCLIEntry`;随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项是 host/port 默认值的唯一真源,只有显式提供标志时才会覆盖该默认值。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`dsh --help` 会展示 `web` 模式,具体通过一行 `addHelpText` 文本实现(真正的 `web` 子命令会劫持 `[config]` 位置参数)。`--version` 读取本应用的 `package.json`。 +`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);提供 `--host`/`--port` 时,`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数(这两项校验都从 `runWeb` 的内联检查移入解析器)。适配器**不会**为 host/port 设置默认值:未提供某个标志时,对应字段保持 undefined;`runWeb` 仅在相应字段存在时才将 host/port 转发给 `AppCLIEntry`;随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项是 host/port 默认值的唯一真源,只有显式提供标志时才会覆盖该默认值。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 + +`--config ` 取代了先前的配置位置参数。`dsh` 是不接受位置参数的产品入口;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动另一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 ## 无需环境变量即可恢复 -将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh --resume= [-- ]`;其中 `--` 可确保名称为 `web` 或以 `-` 开头的配置仍被视为位置参数。因此,合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 +将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh --resume= [--config ]`,因此合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 ## 唯一的终端入口:`dsh` -`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh [config]` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并将配置作为位置参数;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、演示包的 `built-bin.e2e.ts`,以及 tsdown 的 `bin` 入口。`dsh` 自身的 TTY 守卫会在标准输入输出接入管道时,于启动应用前拒绝运行,并提示自动化场景改用 `dsh -p`;为此新增的 `apps/cli/tests/built-bin.e2e.ts` 将标准输入输出接入管道,直接使用 Node 运行构建后的 `lib/bin.js`(`apps/*/tests` 已加入 e2e Vitest 的测试文件匹配范围)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 +`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh --config ` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config `;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、演示包的 `built-bin.e2e.ts`,以及 tsdown 的 `bin` 入口。`dsh` 自身的 TTY 守卫会在标准输入输出接入管道时,于启动应用前拒绝运行,并提示自动化场景改用 `dsh -p`;为此新增的 `apps/cli/tests/built-bin.e2e.ts` 将标准输入输出接入管道,直接使用 Node 运行构建后的 `lib/bin.js`(`apps/*/tests` 已加入 e2e Vitest 的测试文件匹配范围)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 ## 包拓扑 @@ -34,17 +36,17 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配 **保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 -**把 `web` 做成单个根程序的 Commander 子命令。** 已否决:一个程序若把根级 `-p`/`--resume` 语法与 `web` 子命令混在一起,除非再加上 `enablePositionalOptions()` 和一个父级选项守卫,否则根级选项会泄漏到 `web` 上——而这正是这次变更要移除的那类特殊处理机制。把 `web` 作为保留的首个 token 分发给第二个解析器更小巧,且让两套语法完全独立。 +**保留裸 `dsh ` 位置参数(以及它迫使系统采用的保留 `web` token 分发机制)。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。因此,先前版本才会把开头保留的 `web` token 分发给第二个解析器,并在 `--help` 中手工拼接一行 `web` 文本。该位置参数仅用于让演示和测试调用点通过随产品提供的 bin 启动另一份示例树。将其替换为 `--config` 标志后,默认接口不再包含任何位置参数,`web` 因而成为单个程序中的普通子命令,并由原生 `--help` 展示;保留 token 分发、第二个解析器和手工拼接的帮助文本均被删除。`dsh` 没有损失任何用户所需的功能,演示调用则改用显式标志。 **把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 **保留 `RESUME_SESSION_ID` 作为恢复通道**:不予采纳。`--resume` 已被解析成 bin 当前持有的值;若再通过环境变量传递并由配置重新读取,只会引入无益的间接层,还会使演示 bin 保留第二条仅依赖环境变量的恢复路径。在启动上下文中提供 id,与 `boot` 的 `prepare` 钩子为 `tuiResumeHost` 提供值所采用的是同一通道。 -**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh [config]` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。 +**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh --config ` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。 ## 测试 -`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev`),并验证以下情况各自的退出码行为:显式报错检查(恢复 id 或提示词为空、host 或 port 无效、`--prompt` 与配置混用、未知选项)以及 `--help` 和 `--version`;这些退出码通过 `process.exit` spy 捕获。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组将配置作为位置参数启动,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev`),并验证以下情况各自的退出码行为:显式报错检查(恢复 id 或提示词为空、host 或 port 无效、`--prompt` 与配置混用、未知选项)以及 `--help` 和 `--version`;这些退出码通过 `process.exit` spy 捕获。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 ## 影响 diff --git a/apps/cli/README.md b/apps/cli/README.md index 9e8c9b1e45..1241154d31 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,12 +1,12 @@ # `@deepseek-ai/dsh` -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)) that resolves the invocation into a single mode; `src/bin.ts` switches on that mode and dynamic-imports only the chosen mode's module. `dsh --help` and `dsh web --help` render usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting. The TUI surface: -- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index ff0cc65c84..8c804eddb5 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,10 +1,11 @@ /** * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant - * and dynamic-imports that mode's module. Commander owns `--help`/`--version` - * and parse errors: it prints and exits at the point of failure (a domain - * failure routes through `command.error`), so this returns only a resolved mode. - * The `web` subcommand is a reserved first token dispatched to its own parser. + * and dynamic-imports that mode's module. One program: the default (no + * subcommand) is the TUI/headless surface with option-only flags; `web` is a + * real subcommand. Commander owns `--help`/`--version` and parse errors — it + * prints and exits at the point of failure (a domain failure routes through + * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ @@ -15,7 +16,7 @@ export const LOOPBACK_HOST = '127.0.0.1' /** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */ export const ALL_INTERFACES_HOST = '0.0.0.0' -/** Interactive TUI: the default mode. Optional positional config and `--resume `. */ +/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' config?: string @@ -44,83 +45,91 @@ interface WebInvocation { /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation -/** A `Command` under `exitOverride`, so {@link parseDshArgs} owns the exit, named for its usage line. */ -function program(name: string, version: string): Command { - return new Command().name(name).version(version, '-V, --version', 'output the version number').exitOverride() +/** Raw web-subcommand options before validation. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean } -/** Parse `dsh web` arguments (everything after the `web` token). */ -function parseWeb(argv: readonly string[], version: string): WebInvocation { - // No Commander `default`: an absent flag leaves the option undefined so the - // shipped cordis.yml value stands (the single source of the host/port default). - const web = program('dsh web', version) - .description('serve the browser UI (host/port default to the shipped config)') - .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`) - .option('--port ', 'listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - web.parse(argv, { from: 'user' }) - const { host, port, dev } = web.opts<{ host?: string; port?: string; dev?: boolean }>() - if (host !== undefined && host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) { - web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`) +/** Validate and narrow the raw `web` options; a bad value fails loud via `command.error`. */ +function resolveWeb(command: Command, options: WebOptions): WebInvocation { + if (options.host !== undefined && options.host !== LOOPBACK_HOST && options.host !== ALL_INTERFACES_HOST) { + command.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`) } - let portNumber: number | undefined - if (port !== undefined) { - portNumber = Number(port) - if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) { - web.error('error: --port must be an integer in 0-65535') + let port: number | undefined + if (options.port !== undefined) { + port = Number(options.port) + if (!/^\d+$/.test(options.port) || !Number.isInteger(port) || port > 65535) { + command.error('error: --port must be an integer in 0-65535') } } return { mode: 'web', - ...host !== undefined && { host }, - ...portNumber !== undefined && { port: portNumber }, - dev: dev === true, + ...options.host !== undefined && { host: options.host }, + ...port !== undefined && { port }, + dev: options.dev === true, } } -/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */ -function parseRoot(argv: readonly string[], version: string): DshInvocation { - const root = program('dsh', version) - .description('dsh: interactive TUI, headless task, and browser UI') - .argument('[config]', 'config to boot instead of the shipped default (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') - // Disclose the web mode in `dsh --help`; a real `web` subcommand would - // hijack the `[config]` positional. `parseDshArgs` intercepts `web` first. - .addHelpText('after', '\nCommands:\n web serve the browser UI (run `dsh web --help`)') - root.parse(argv, { from: 'user' }) - const { prompt, resume } = root.opts<{ prompt?: string; resume?: string }>() - const config = root.processedArgs[0] as string | undefined - - if (prompt !== undefined) { - // A headless prompt owns the invocation; an empty task has nothing to run, - // and a config or --resume alongside it is a TUI input that must not - // silently vanish from the run. - if (prompt === '') root.error('error: --prompt needs a task') - if (config !== undefined || resume !== undefined) root.error('error: --prompt takes no config or --resume') - return { mode: 'headless', prompt } - } - // An empty `--resume=` id would silently start a fresh session downstream - // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. - if (resume === '') root.error('error: --resume needs a session id') - return { mode: 'tui', ...config !== undefined && { config }, ...resume !== undefined && { resume } } -} - /** * Resolve the raw argv into a {@link DshInvocation}, or print and exit for - * `--help`/`--version`/a parse error. A leading `web` token dispatches to the - * web parser; everything else is the default TUI/headless grammar. + * `--help`/`--version`/a parse error. The default (no subcommand) is the + * TUI/headless surface; `web` is a subcommand. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). * @param version - the version string `--version` prints; read from this app's package.json. * @returns the resolved invocation (only reached on a valid, non-help invocation). */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { + let resolved: DshInvocation | undefined + const program = new Command() + .name('dsh') + .version(version, '-V, --version', 'output the version number') + .description('dsh: interactive TUI (default), headless task, and browser UI') + .exitOverride() + // Default surface: option-only (no positional), so `web` can be a real + // subcommand without a positional collision. + .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') + .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') + .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .action((options: { config?: string; prompt?: string; resume?: string }) => { + if (options.prompt !== undefined) { + // A headless prompt owns the invocation; an empty task has nothing to + // run, and --config/--resume are TUI inputs that must not silently + // vanish from a headless run. + if (options.prompt === '') program.error('error: --prompt needs a task') + if (options.config !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config or --resume') + } + resolved = { mode: 'headless', prompt: options.prompt } + return + } + // An empty --resume= id would silently start a fresh session downstream + // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. + if (options.resume === '') program.error('error: --resume needs a session id') + resolved = { + mode: 'tui', + ...options.config !== undefined && { config: options.config }, + ...options.resume !== undefined && { resume: options.resume }, + } + }) + + const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + web + .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`) + .option('--port ', 'listen port (0 requests an OS-assigned port)') + .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') + .action((options: WebOptions) => { resolved = resolveWeb(web, options) }) + try { - return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version) + program.parse(argv, { from: 'user' }) } catch (error) { // Commander printed help/version/the error under `exitOverride`; exit with // the code it chose (0 for help/version, 1 for a parse or domain error). /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */ return process.exit(error instanceof CommanderError ? error.exitCode : 1) } + /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */ + if (resolved === undefined) throw new Error('dsh: no invocation resolved') + return resolved } diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index e741306463..4283668189 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,6 +1,6 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * tui-agent config (or an explicit config argument) with the personal overlay + * tui-agent config (or the `--config` override) with the personal overlay * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking @@ -42,7 +42,7 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** * Run the interactive TUI from the invoking directory. * @param config - a config path to boot instead of the shipped default, or - * `undefined` for the default; already parsed from the optional positional. + * `undefined` for the default; already parsed from `--config`. * @param resumeSessionId - a persisted session id to resume, or `undefined`; * already parsed and non-empty-validated from `--resume`. It is provided on the * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config @@ -73,14 +73,13 @@ export async function runTui(config: string | undefined, resumeSessionId: string const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) // Rebuild argv from the parsed config plus the selected id: TUI mode's - // only arguments are the optional config positional and `--resume `. - // The `--` guard keeps a config named like a flag or `web` a positional. + // only arguments are `--config ` and `--resume `. const nextArgv = [ process.execPath, ...process.execArgv, entry, `--resume=${sessionId}`, - ...config !== undefined ? ['--', config] : [], + ...config !== undefined ? ['--config', config] : [], ] try { await current.fiber.dispose() diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index f9f6363660..a0943d5e66 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -26,8 +26,8 @@ afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { expect(parse([])).toEqual({ mode: 'tui' }) - expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) - expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) + expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) @@ -43,8 +43,10 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--host', '10.0.0.1'])).toBe(1) expect(exitCode(['web', '--port', 'abc'])).toBe(1) expect(exitCode(['web', '--port='])).toBe(1) - expect(exitCode(['config.yml', '-p', 'x'])).toBe(1) + expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) + expect(exitCode(['bogus-positional'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/docs/module-graph.md b/docs/module-graph.md index d5845bf041..5de50ff672 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -765,7 +765,6 @@ flowchart TD pkg_tui_demo --> pkg_agent pkg_tui_demo --> pkg_agent_loop pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_app_boot pkg_tui_demo --> pkg_command_goal pkg_tui_demo --> pkg_commands pkg_tui_demo --> pkg_invariants @@ -919,4 +918,4 @@ flowchart TD | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index e55e77f4de..700c67f660 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -192,10 +192,12 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise` tree override; `configArgs` + // is the raw-args escape (e.g. `['--resume', ]`) for other flags. configArgs: options.configArgs !== undefined ? [...options.configArgs] /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */ - : [options.configPath ?? './cordis.yml'], + : options.configPath !== undefined ? ['--config', options.configPath] : [], tsconfigPath: options.tsconfigPath, env: { DSH_HOME: join(cwd, '.dsh'), diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index c464fa2a96..348ac94751 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -253,7 +253,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { label: 'dsh in-place resume', tempDirPrefix: 'dsh-in-place-resume-', binScript: dshBinScript, - configArgs: [scriptedConfigPath], + configPath: scriptedConfigPath, prepare: seedResumeSession, actions: [ { waitFor: 'scripted TUI ready.', send: '/resume\r' }, @@ -350,7 +350,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', binScript: dshBinScript, - configArgs: [scriptedConfigPath], + configPath: scriptedConfigPath, actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, diff --git a/package.json b/package.json index fbd2a8aa88..543ebea5e6 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx apps/cli/src/bin.ts examples/cordis-agent/cordis.yml", + "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 7b06b859f2..1118f10b96 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -7,7 +7,7 @@ import { spawn } from 'node:child_process' // Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index e8ca907439..3f9ceada28 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -38,7 +38,9 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { setupFiles: ['./scripts/test-invariants.ts'], - include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], + // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built + // frontend dist and runs under vitest.web.config.ts (the test:web job). + include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate.