From 800bafda3b08cfe0e48b58f7ff1a5478f9b4b2ba Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 19:43:59 +0800 Subject: [PATCH] refactor(cli): parse dsh argv through one Commander adapter Replace the dsh CLI's three hand-rolled parsing idioms (raw argv[0]/includes dispatch in bin.ts, per-mode node:util parseArgs in headless.ts/web.ts, and the bespoke parseResumeArg scanner in dsh-app-boot) with a single Commander adapter in apps/cli/src/args.ts. parseDshArgs resolves argv into a discriminated DshInvocation union; bin.ts switches on the mode and dynamic-imports the chosen module, which now consumes already-parsed values. - web is a real subcommand; --host uses choices and --port an argParser range check, moving validation into the parser. - --resume rejects empty and repeated forms; --prompt rejects empty; a config positional after --prompt and a root flag placed before web fail loud. - adds --help/--version; removes parseResumeArg from dsh-app-boot. - new apps/cli/tests/args.spec.ts (apps/*/tests added to vitest include, apps/cli/tests to tsconfig.host.json); the tui-agent keyless PTY smoke covers bin.ts dispatch end to end unchanged. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 6 + ...26-07-24-dsh-commander-argument-adapter.md | 37 ++++ ...07-24-dsh-commander-argument-adapter.zh.md | 37 ++++ apps/cli/README.md | 2 + apps/cli/package.json | 3 +- apps/cli/src/args.ts | 183 ++++++++++++++++++ apps/cli/src/bin.ts | 59 ++++-- apps/cli/src/headless.ts | 19 +- apps/cli/src/tui.ts | 13 +- apps/cli/src/web.ts | 34 +--- apps/cli/tests/args.spec.ts | 120 ++++++++++++ packages/ui/app-boot/README.md | 1 - packages/ui/app-boot/src/index.ts | 44 ----- packages/ui/app-boot/tests/app-boot.spec.ts | 27 +-- pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + vitest.config.ts | 1 + 17 files changed, 460 insertions(+), 130 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md create mode 100644 .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md create mode 100644 apps/cli/src/args.ts create mode 100644 apps/cli/tests/args.spec.ts 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 new file mode 100644 index 0000000000..5dd6055ffe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: dc2830273b245d370feba0df6ed030045b8444ff +2026-07-24-dsh-commander-argument-adapter.zh.md: ea37a1260ebf81e787f02301bc6fc3c9438c4f75 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 new file mode 100644 index 0000000000..dc2830273b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -0,0 +1,37 @@ +# Agent Note: Parse `dsh` argv through one Commander adapter + +Status: implemented + +English | [中文](2026-07-24-dsh-commander-argument-adapter.zh.md) + +## Problem + +The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that did not compose and gave no `--help`/`--version`. `bin.ts` dispatched by raw inspection — `argv[0] === 'web'`, then `argv.includes('-p') || argv.includes('--prompt')`, else TUI — which is positional-blind: a prompt flag or a config path in the wrong position could misroute the mode, and `argv.includes('-p')` could not tell a real flag from an incidental token. `headless.ts` and `web.ts` each ran their own `node:util` `parseArgs` with inline host/port validation, and `dsh-app-boot` carried `parseResumeArg`, a ~30-line bespoke scanner reimplementing flag/`=`-form/value/repeat handling for `--resume`. Usage was a single hardcoded `usage: dsh -p "task"` line; there was no version flag and no rendered help. + +## Decision + +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)` resolves the invocation into a discriminated `DshInvocation` union: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, `{ mode: 'web', host, port }`, `{ mode: 'help' | 'version', text }`, or `{ mode: 'error', message }`. Commander runs under `exitOverride()` with output captured, so it never writes or exits on its own — `--help`, `--version`, and every parse error come back as data. + +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port)` — none re-reads argv. `web` is a real `program.command('web')` subcommand; `--host` is a Commander `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 0–65535, moving both from the inline `runWeb` checks into the parser. `--resume` uses an `argParser` that rejects both an empty id (`--resume=`) and a repeated flag (`--resume a --resume b`), and `--prompt` rejects an empty task, preserving the old "never silently start fresh" invariant (the deleted `parseResumeArg` failed loud on the same cases). The program sets `enablePositionalOptions()`, and the `web` action rejects a root `--prompt`/`--resume` placed before it, so a misplaced flag (`dsh web -p x`, `dsh -p x web`) fails loud instead of silently serving with defaults. `--version` reads this app's `package.json`. + +`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. + +## Package topology + +The argument surface stays inside `apps/cli`, the assembly tier, not a `packages/*` library: it is this one app's routing, not a reusable seam. `dsh-app-boot` shrinks to boot glue with no CLI-parsing responsibility. `commander@^15` is added to `apps/cli/package.json`, matching the SDK bins' pin. + +## Alternatives considered + +**Keep `node:util` `parseArgs` and only unify the dispatch** — rejected: `parseArgs` has no subcommand model, no rendered help, and no version flag, so `web` routing and `--help`/`--version` would stay hand-rolled. The repo already chose Commander for its other CLIs; a second parser idiom for `dsh` alone is the fragmentation this change removes. + +**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 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. + +## Testing + +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) drives the adapter directly: TUI defaults, config positional, `--resume` space/inline forms and their position-independence, empty/valueless/repeated `--resume` rejection, `-p`/`--prompt` routing with empty-prompt and stray-positional rejection, `web` host/port defaults and validation with `--host`/`--port` diagnostics, root flags misplaced around `web` failing loud, excess-argument rejection, and `--help`/`web --help`/`--version`/unknown-option outcomes. The `dsh CLI keyless smoke` group in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` exercises the real `bin.ts` dispatch end to end through a PTY (default boot, personal overlay, invalid config, `--resume` failure, source-path prompt) and stays green unchanged. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg` block. + +## Consequences + +`dsh` gains rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing no longer depends on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) now sitting on the CLI's front door. `dsh-app-boot` no longer owns any CLI-parsing surface; a future consumer needing `--resume`-style parsing composes Commander rather than reviving the deleted scanner. 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 new file mode 100644 index 0000000000..ea37a1260e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 通过单个 Commander 适配器解析 `dsh` 的 argv + +Status: implemented + +[English](2026-07-24-dsh-commander-argument-adapter.md) | 中文 + +## 问题 + +`dsh` 的 CLI(命令行界面)入口(`apps/cli`)以三种手写方式解析 argv,这些方式无法组合,也不提供 `--help`/`--version`。`bin.ts` 通过原始检查进行分发:先判断 `argv[0] === 'web'`,再判断 `argv.includes('-p') || argv.includes('--prompt')`,否则走 TUI。这种方式对位置不敏感:位置错误的 prompt 标志或配置路径可能把模式路由错,而 `argv.includes('-p')` 无法区分真正的标志和偶然出现的 token。`headless.ts` 和 `web.ts` 各自运行自己的 `node:util` `parseArgs`,并内联校验 host/port,而 `dsh-app-boot` 携带 `parseResumeArg`——一个约 30 行的定制扫描器,为 `--resume` 重新实现了标志、`=` 形式、取值和重复的处理。用法说明只有一行硬编码的 `usage: dsh -p "task"`;既没有版本标志,也没有渲染出的帮助信息。 + +## 决策 + +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 }`、`{ mode: 'help' | 'version', text }` 或 `{ mode: 'error', message }`。Commander 在 `exitOverride()` 下运行并捕获输出,因此它自身从不写出或退出:`--help`、`--version` 和每个解析错误都以数据形式返回。 + +`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port)`,都不会再次读取 argv。`web` 是一个真正的 `program.command('web')` 子命令;`--host` 是 Commander 的 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器。`--resume` 使用一个 `argParser`,同时拒绝空 id(`--resume=`)和重复出现的标志(`--resume a --resume b`),`--prompt` 则拒绝空任务,保留旧有的「绝不静默重新开始」不变式(已删除的 `parseResumeArg` 在相同情形下也会显式报错)。程序设置了 `enablePositionalOptions()`,且 `web` 动作会拒绝置于其前的根级 `--prompt`/`--resume`,因此位置错误的标志(`dsh web -p x`、`dsh -p x web`)会显式报错,而不会静默地以默认值提供服务。`--version` 读取本应用的 `package.json`。 + +`parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 + +## 包拓扑 + +参数解析留在 `apps/cli`(组装层)内,而不是 `packages/*` 库中:它是这一个应用自身的路由,而非可复用的 seam。`dsh-app-boot` 收缩为纯粹的 boot 胶水代码,不再承担 CLI 解析职责。`commander@^15` 被加入 `apps/cli/package.json`,与 SDK bin 锁定的版本一致。 + +## 考虑过的替代方案 + +**保留 `node:util` `parseArgs`,只统一分发。** 已否决:`parseArgs` 没有子命令模型、没有渲染出的帮助、也没有版本标志,因此 `web` 路由和 `--help`/`--version` 仍将保持手写。本仓库其他 CLI 已经选择了 Commander;单独为 `dsh` 引入第二套解析器方式,正是这次变更要消除的碎片化。 + +**保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 + +**把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 + +## 测试 + +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)直接驱动适配器:TUI 默认值、config 位置参数、`--resume` 的空格/内联形式及其位置无关性、对空值/无值/重复 `--resume` 的拒绝、`-p`/`--prompt` 路由及对空 prompt 和游离位置参数的拒绝、`web` 的 host/port 默认值与校验(含 `--host`/`--port` 诊断信息)、围绕 `web` 位置错误的根级标志会显式报错、对多余参数的拒绝,以及 `--help`/`web --help`/`--version`/未知选项的处理结果。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的 `dsh CLI keyless smoke` 组通过 PTY 端到端地运行真实的 `bin.ts` 分发(默认启动、个人覆盖、无效配置、`--resume` 失败、源路径 prompt),且保持绿色不变。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 测试块。 + +## 影响 + +`dsh` 获得了渲染出的 `--help`/`--version` 以及一致的显式报错式解析错误,模式路由也不再依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 新增一项 `commander` 依赖,且 Commander 的解析语义(它的错误字符串、它的 `exitOverride` 契约)如今落在 CLI 的入口处。`dsh-app-boot` 不再拥有任何 CLI 解析职责;未来需要 `--resume` 式解析的消费方应组合 Commander,而不是复活已删除的扫描器。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 87f5e670e3..15765090a0 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,6 +2,8 @@ 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. +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. + 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); diff --git a/apps/cli/package.json b/apps/cli/package.json index fd744fa02c..791a44f98b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,6 +19,7 @@ "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "commander": "^15.0.0" } } diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts new file mode 100644 index 0000000000..f549c125c3 --- /dev/null +++ b/apps/cli/src/args.ts @@ -0,0 +1,183 @@ +/** + * 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; each mode module then consumes the + * already-parsed values instead of re-reading argv. Output is suppressed and + * `exitOverride` is set so Commander never writes or exits on its own — every + * outcome (including `--help`/`--version` and parse errors) is returned to the + * caller as data. + * @module @deepseek-ai/dsh/args + */ + +import { Command, CommanderError, InvalidArgumentError, Option } from 'commander' + +/** The loopback host `dsh web` binds by default. */ +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' +const DEFAULT_WEB_PORT = 3080 + +/** Interactive TUI: the default mode. Optional positional config and `--resume `. */ +interface TuiInvocation { + mode: 'tui' + config?: string + resume?: string +} + +/** Headless one-shot: `dsh -p "task"`. */ +interface HeadlessInvocation { + mode: 'headless' + prompt: string +} + +/** Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; port already coerced and range-checked. */ +interface WebInvocation { + mode: 'web' + host: string + port: number +} + +/** `--help` or `--version` requested: `bin.ts` prints `text` to stdout and exits 0. */ +interface InfoInvocation { + mode: 'help' | 'version' + text: string +} + +/** A parse error (unknown option, missing/invalid argument): `bin.ts` prints `message` to stderr and exits 1. */ +interface ErrorInvocation { + mode: 'error' + message: string +} + +/** The resolved `dsh` invocation: exactly one mode, all values parsed and validated. */ +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | WebInvocation + | InfoInvocation + | ErrorInvocation + +/** Raw Commander option bag for the root command before it is narrowed to a mode. */ +interface RootOptions { + prompt?: string + resume?: string +} + +/** Commander option bag for the `web` subcommand after `--port` coercion. */ +interface WebOptions { + host: string + port: number +} + +/** + * Coerce `--port` to an integer in 0–65535; a bad value throws + * {@link InvalidArgumentError}, which Commander reports as a parse error the + * adapter returns as an {@link ErrorInvocation}. + */ +function parsePort(raw: string): number { + const port = Number(raw) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new InvalidArgumentError(`invalid --port ${raw}`) + } + return port +} + +/** Reject an empty `--prompt` task; an empty headless prompt has nothing to run. */ +function parsePrompt(raw: string): string { + if (raw === '') throw new InvalidArgumentError("option '-p, --prompt ' must not be empty") + return raw +} + +/** + * Validate a `--resume` value: reject an empty id and a repeated flag. Both are + * mistypes that must fail loud, never silently start a fresh session or keep + * only the last id. `previous` is the value from an earlier `--resume` on the + * same invocation (Commander threads it in), so a second occurrence is caught. + */ +function parseResume(raw: string, previous: string | undefined): string { + if (previous !== undefined) throw new InvalidArgumentError("option '--resume ' may be given only once") + if (raw === '') throw new InvalidArgumentError("option '--resume ' must not be empty") + return raw +} + +/** + * Resolve the raw argv into a single {@link DshInvocation}. Never writes to a + * stream and never exits; `--help`/`--version` and every parse error come back + * as data for `bin.ts` to act on. + * @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, discriminated by `mode`. + */ +export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { + let resolved: DshInvocation | undefined + const output: string[] = [] + + const program = new Command() + .name('dsh') + .description('dsh: interactive TUI, headless task, and browser UI') + .version(version, '-V, --version', 'output the version number') + .exitOverride() + .configureOutput({ + writeOut: chunk => void output.push(chunk), + writeErr: chunk => void output.push(chunk), + }) + + // Positional options keep `dsh -p x web` from routing to the `web` + // subcommand: a token after a root option is a positional, not a command. + program + .enablePositionalOptions() + .argument('[config]', 'config to boot instead of the shipped default (TUI mode)') + .addOption(new Option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit').argParser(parsePrompt)) + .addOption(new Option('--resume ', 'resume the persisted session with this id (TUI mode)').argParser(parseResume)) + .action((config: string | undefined, options: RootOptions) => { + if (options.prompt !== undefined) { + // A headless prompt owns the invocation; a config positional is meaningless there. + if (config !== undefined) { + throw new InvalidArgumentError(`error: --prompt takes no config argument (got '${config}')`) + } + resolved = { mode: 'headless', prompt: options.prompt } + return + } + resolved = { + mode: 'tui', + ...config !== undefined ? { config } : {}, + ...options.resume !== undefined ? { resume: options.resume } : {}, + } + }) + + program + .command('web') + .description('serve the browser UI') + .addOption( + new Option('--host ', 'bind host') + .choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]) + .default(LOOPBACK_HOST), + ) + .addOption( + new Option('--port ', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort), + ) + .action((options: WebOptions, command: Command) => { + // Root options placed before `web` (`dsh -p x web`) leak onto the parent; + // reject them so a misplaced flag fails loud instead of silently serving. + const leaked = command.parent?.opts() + if (leaked?.prompt !== undefined || leaked?.resume !== undefined) { + throw new InvalidArgumentError('error: web takes no --prompt or --resume; place web first') + } + resolved = { mode: 'web', host: options.host, port: options.port } + }) + + try { + program.parse(argv, { from: 'user' }) + } catch (error) { + /* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */ + if (!(error instanceof CommanderError)) throw error + if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: output.join('') } + if (error.code === 'commander.version') return { mode: 'version', text: output.join('') } + // Every other CommanderError is a parse failure; its message is the diagnostic. + return { mode: 'error', message: error.message } + } + + /* v8 ignore next -- one action always resolves the invocation or parse throws above */ + if (resolved === undefined) throw new Error('dsh: argument parsing did not resolve a mode') + return resolved +} diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 1192472b98..5880c68407 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -1,25 +1,58 @@ #!/usr/bin/env node /** - * dsh — command-line entry. Coarse dispatch only; each surface module owns its - * argument handling. Dynamic imports keep unrelated surfaces out of each - * dispatch path; everything except `web` and headless prompts opens the TUI. + * dsh — command-line entry. Parses argv once through the Commander adapter and + * switches on the resolved mode; dynamic imports keep unrelated modes out of + * each dispatch path. `web` and headless prompts run their own module; + * everything else opens the TUI. `--help`/`--version` print and exit 0; a parse + * error prints to stderr and exits 1. * @module @deepseek-ai/dsh/bin */ /* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { parseDshArgs } from './args.ts' + +// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit +// one directory under apps/cli, so the checked-in manifest resolves with the +// same relative hop from either artifact. +/** This app's version, read from its checked-in package.json. */ +function readVersion(): string { + const manifest = JSON.parse( + readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), + ) as { version?: unknown } + return typeof manifest.version === 'string' ? manifest.version : '0.0.0' +} loadEnv('dsh') -const argv = process.argv.slice(2) +const invocation = parseDshArgs(process.argv.slice(2), readVersion()) -if (argv[0] === 'web') { - const { runWeb } = await import('./web.ts') - await runWeb(argv.slice(1)) -} else if (argv.includes('-p') || argv.includes('--prompt')) { - const { runHeadless } = await import('./headless.ts') - await runHeadless(argv) -} else { - const { runTui } = await import('./tui.ts') - await runTui(argv) +switch (invocation.mode) { + case 'web': { + const { runWeb } = await import('./web.ts') + await runWeb(invocation.host, invocation.port) + break + } + case 'headless': { + const { runHeadless } = await import('./headless.ts') + await runHeadless(invocation.prompt) + break + } + case 'tui': { + const { runTui } = await import('./tui.ts') + await runTui(invocation.config, invocation.resume) + break + } + case 'help': + case 'version': + process.stdout.write(invocation.text) + process.exit(0) + case 'error': + process.stderr.write(`${invocation.message}\n`) + process.exit(1) + default: + invocation satisfies never + throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 303bac61f8..ccfd4c5f8a 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -7,7 +7,6 @@ * (completed → 0, else 1). */ -import { parseArgs } from 'node:util' import { startHost } from '@deepseek-ai/dsh-host-runtime' import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -65,17 +64,13 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, return { text, reason: 'error' } } -export async function runHeadless(argv: string[]): Promise { - const { values } = parseArgs({ - args: argv, - options: { prompt: { type: 'string', short: 'p' } }, - allowPositionals: false, - }) - const task = values.prompt - if (task === undefined || task === '') { - process.stderr.write('usage: dsh -p "task"\n') - process.exit(1) - } +/** + * Run one headless turn for `task` and exit (completed → 0, else 1). The task + * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` + * (the adapter rejects an empty task, so no guard is needed here). + * @param task - the prompt text for the single turn. + */ +export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const host = await startHost({ diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6f97a68ad3..aa8fb9af5f 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -18,7 +18,6 @@ import { installFailLoud, loadEnv, loadPersonalPatches, - parseResumeArg, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -45,11 +44,12 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) the tui-agent PTY smoke drives this path end to end, personal overlay included */ /** * Run the interactive TUI from the invoking directory. - * @param argv - arguments after the subcommand dispatch; a `--resume ` flag - * resumes that persisted session, and the first non-flag argument may name a - * config to boot instead of the shipped default. + * @param config - a config path to boot instead of the shipped default, or + * `undefined` for the default; already parsed from the optional positional. + * @param resumeSessionId - a persisted session id to resume, or `undefined`; + * already parsed and non-empty-validated from `--resume`. */ -export async function runTui(argv: string[]): Promise { +export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. @@ -63,9 +63,8 @@ export async function runTui(argv: string[]): Promise { loadEnv(NAME, resolveDshHome()) // An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it // after loadEnv and before boot reads it through the config's `!!js`. - const { resumeSessionId, rest } = parseResumeArg(argv) if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId - const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) + const ctx = await boot(NAME, resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) addHarnessSourceSection(ctx, SOURCE_ROOT) } /* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 66a99bb577..106585529f 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -4,37 +4,19 @@ * concerns is this app module's job (packages stay single-sided). */ -import { parseArgs } from 'node:util' import { networkInterfaces } from 'node:os' import { createRequire } from 'node:module' import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts' -const LOOPBACK_HOST = '127.0.0.1' -const ALL_INTERFACES_HOST = '0.0.0.0' - -export async function runWeb(argv: string[]): Promise { - const { values } = parseArgs({ - args: argv, - options: { - host: { type: 'string', default: LOOPBACK_HOST }, - port: { type: 'string', default: '3080' }, - }, - allowPositionals: false, - }) - if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { - process.stderr.write( - `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, - ) - process.exit(1) - } - const hostAddress = values.host - const port = Number(values.port) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - process.stderr.write(`dsh web: invalid --port ${values.port}\n`) - process.exit(1) - } - +/** + * Serve the browser UI. Host and port are already validated by the argument + * adapter (host constrained to loopback/all-interfaces, port a 0–65535 integer). + * @param hostAddress - the bind host: {@link LOOPBACK_HOST} or {@link ALL_INTERFACES_HOST}. + * @param port - the listen port; `0` lets the OS choose a free port. + */ +export async function runWeb(hostAddress: string, port: number): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const host = await startHost({ boot: { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts new file mode 100644 index 0000000000..ad6d0266ca --- /dev/null +++ b/apps/cli/tests/args.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts' + +const VERSION = '1.2.3' +const parse = (argv: string[]) => parseDshArgs(argv, VERSION) + +/** Assert argv resolves to an error invocation whose message contains `needle`. */ +function expectError(argv: string[], needle: string): void { + const result = parse(argv) + expect(result.mode).toBe('error') + if (result.mode !== 'error') throw new Error('expected error mode') + expect(result.message).toContain(needle) +} + +describe('parseDshArgs — TUI (default mode)', () => { + it('defaults to the TUI with no config and no resume when given no arguments', () => { + expect(parse([])).toEqual({ mode: 'tui' }) + }) + + it('carries a positional config into the TUI mode', () => { + expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + }) + + it('parses --resume in the space and inline forms, independent of a config positional', () => { + expect(parse(['--resume', 'sess-1'])).toEqual({ mode: 'tui', resume: 'sess-1' }) + expect(parse(['--resume=sess-2'])).toEqual({ mode: 'tui', resume: 'sess-2' }) + expect(parse(['--resume', 'sess-3', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-3' }) + expect(parse(['app.yml', '--resume', 'sess-4'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-4' }) + }) + + it('fails loud on a valueless or empty --resume rather than silently starting fresh', () => { + expectError(['--resume'], '--resume') + expectError(['--resume='], 'must not be empty') + }) + + it('rejects a repeated --resume instead of silently keeping the last id', () => { + expectError(['--resume', 'a', '--resume', 'b'], 'may be given only once') + expectError(['--resume=a', '--resume=b'], 'may be given only once') + }) +}) + +describe('parseDshArgs — headless', () => { + it('routes -p / --prompt to the headless mode with the task text', () => { + expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + expect(parse(['--prompt', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + }) + + it('routes to headless regardless of the prompt flag position', () => { + // Positional-independent: the old `argv.includes('-p')` dispatch could not + // tell a real prompt flag from one buried after other tokens. + expect(parse(['-p', 'task'])).toEqual({ mode: 'headless', prompt: 'task' }) + }) + + it('rejects an empty prompt and a stray config positional', () => { + expectError(['-p', ''], 'must not be empty') + expectError(['-p', 'task', 'app.yml'], 'takes no config') + }) +}) + +describe('parseDshArgs — web', () => { + it('defaults the web mode to loopback and port 3080', () => { + expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080 }) + }) + + it('accepts an explicit loopback or all-interfaces host and a valid port', () => { + expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080'])) + .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080 }) + expect(parse(['web', '--port', '0'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 0 }) + }) + + it('rejects a non-integer or out-of-range port with a --port diagnostic', () => { + expectError(['web', '--port', 'abc'], '--port') + expectError(['web', '--port', '70000'], '--port') + expectError(['web', '--port', '-1'], '--port') + }) + + it('rejects a host outside the allowed choices with a --host diagnostic', () => { + expectError(['web', '--host', '10.0.0.1'], '--host') + }) + + it('rejects an unexpected positional after web', () => { + expectError(['web', 'extra'], 'too many arguments') + }) + + it('fails loud when a root flag is placed before web instead of serving with it dropped', () => { + // `dsh web -p x` and `dsh -p x web` both misrouted or dropped the flag under + // the old `argv[0]==='web'` / `argv.includes('-p')` dispatch. + expectError(['web', '-p', 'x'], "unknown option '-p'") + expectError(['web', '--resume', 'y'], "unknown option '--resume'") + expectError(['-p', 'x', 'web'], 'web takes no') + expectError(['--resume', 'y', 'web'], 'web takes no') + }) + + it('renders web usage for web --help', () => { + const help = parse(['web', '--help']) + expect(help.mode).toBe('help') + if (help.mode !== 'help') throw new Error('expected help mode') + expect(help.text).toContain('Usage: dsh web') + }) +}) + +describe('parseDshArgs — help, version, and errors', () => { + it('returns the rendered usage for --help / -h', () => { + const help = parse(['--help']) + expect(help.mode).toBe('help') + if (help.mode !== 'help') throw new Error('expected help mode') + expect(help.text).toContain('Usage: dsh') + expect(help.text).toContain('web') + expect(parse(['-h']).mode).toBe('help') + }) + + it('returns the version string for --version / -V', () => { + expect(parse(['--version'])).toEqual({ mode: 'version', text: `${VERSION}\n` }) + expect(parse(['-V'])).toEqual({ mode: 'version', text: `${VERSION}\n` }) + }) + + it('reports an unknown option as an error invocation', () => { + expectError(['--nope'], "unknown option '--nope'") + }) +}) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 24840b7fda..68f885608f 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -5,7 +5,6 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ | Export | Role | |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | -| `parseResumeArg(argv)` | Split the `--resume ` / `--resume=` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 2fd4ba4c05..faeb5a0e0a 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -36,50 +36,6 @@ export function resolveConfigPath( return resolve(dir, replayName) } -/** CLI flag the interactive surface accepts to resume a persisted session by id. */ -const RESUME_FLAG = '--resume' - -/** - * Split a leading `--resume ` / `--resume=` flag out of a CLI argument - * vector, returning the resumed session id (when the flag is present) and the - * remaining arguments with the flag and its value removed — so a positional - * config path stays readable regardless of the flag's position. A `--resume` - * with no following id, an empty id (`--resume=`), or a repeated `--resume` - * throws: a mistyped resume must fail loud, never silently start a fresh - * session. The id is not validated here; an unknown id fails loud downstream - * when the session cannot load. - * @param argv - the CLI arguments after subcommand dispatch. - * @returns the parsed resume id (or `undefined`) and the flag-stripped arguments. - */ -export function parseResumeArg( - argv: readonly string[], -): { resumeSessionId: string | undefined; rest: string[] } { - const rest: string[] = [] - let resumeSessionId: string | undefined - let skipNext = false - for (const [i, arg] of argv.entries()) { - if (skipNext) { - skipNext = false - continue - } - const inlineValue = arg.startsWith(`${RESUME_FLAG}=`) - if (arg === RESUME_FLAG || inlineValue) { - if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`) - const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1] - // A following token that is itself resume syntax (`--resume --resume x`) - // is a missing id, not a session literally named `--resume…`. - if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) { - throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} )`) - } - resumeSessionId = value - skipNext = !inlineValue // the space form consumed the following token as its value - continue - } - rest.push(arg) - } - return { resumeSessionId, rest } -} - /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the * ambient environment; other read failures are reported through `warn`. diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 76e5238db7..d9934cb8bb 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -30,31 +30,6 @@ describe('resolveConfigPath', () => { }) }) -describe('parseResumeArg', () => { - it('returns no resume id and passes arguments through when the flag is absent', () => { - expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] }) - expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] }) - }) - - it('parses the space form, the inline form, and leaves a positional config path in any position', () => { - expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] }) - expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] }) - expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] }) - expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] }) - }) - - it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => { - expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id') - expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id') - expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once') - }) - - it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => { - expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id') - expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id') - }) -}) - describe('loadEnv', () => { it('loads variables from .env in the given dir', () => { const dir = tmp() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e28836146..e83b05f35c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + commander: + specifier: ^15.0.0 + version: 15.0.0 apps/web: dependencies: diff --git a/tsconfig.host.json b/tsconfig.host.json index f340f235da..5419347734 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -8,6 +8,7 @@ "rewriteRelativeImportExtensions": false }, "include": [ + "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", "examples/*/tests/**/*.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 1177782a8a..fe704797f5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,7 @@ const windowsCoverageExclusions = process.platform === 'win32' const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', + 'apps/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts', ]