From ee5132c1e190759c10daa6c0dd8fa47e08b27c28 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 20:01:38 +0800 Subject: [PATCH] refactor(cli): dispatch web as a reserved token, drop parse machinery Simplify the Commander adapter now that behavior can change: dispatch a leading `web` token to its own parser instead of a subcommand of the root program, and read opts()/processedArgs after parse() instead of action closures with a mutable holder. This removes enablePositionalOptions(), the parent-option leak guard, both action closures, and the --resume/--prompt argParser threading. Behavior changes: `dsh -p x web` is a headless prompt (extra positional dropped), `dsh web -p x` fails loud (web has no -p), and a repeated --resume is natural last-wins. The two real fail-loud invariants stay as post-parse checks: an empty --resume= id (agent-loop treats '' as no-resume) and an empty -p task. Trims args.spec.ts to the routing/fail-loud/help behavior that matters; the tui-agent keyless PTY smoke still covers bin.ts dispatch end to end. Net ~114 fewer lines across adapter and tests. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 6 +- ...07-24-dsh-commander-argument-adapter.zh.md | 6 +- apps/cli/src/args.ts | 177 ++++++++---------- apps/cli/tests/args.spec.ts | 119 ++---------- 5 files changed, 101 insertions(+), 211 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 5dd6055ffe..03b3c8e6f7 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: dc2830273b245d370feba0df6ed030045b8444ff -2026-07-24-dsh-commander-argument-adapter.zh.md: ea37a1260ebf81e787f02301bc6fc3c9438c4f75 +2026-07-24-dsh-commander-argument-adapter.md: 4decd926c7fffc8f7d24200f8b91044eaa1d00f1 +2026-07-24-dsh-commander-argument-adapter.zh.md: eaccc221d362804b0aa3081d0593e9dee8af1d4c 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 dc2830273b..4decd926c7 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,7 +12,7 @@ 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)` 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`. +`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 **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`) and `dsh -p x web` is just a headless prompt whose second positional is dropped, with no cross-command leakage to guard against. Each parser reads Commander's `opts()`/`processedArgs` after `parse()` rather than through action closures. `--host` is a `.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. Two post-parse checks preserve the "never silently start fresh" invariant: an empty `--resume=` id and an empty `-p` task each become a `mode: 'error'`, because agent-loop treats an empty resume id as no-resume and an empty prompt has nothing to run. A repeated `--resume` is Commander's natural last-wins (the old bespoke scanner rejected it; last-wins is the standard CLI behavior and needs no special case). `--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. @@ -26,11 +26,13 @@ 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. + **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. +`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, the fail-loud checks (empty resume/prompt, bad host/port, unknown option), and `--help`/`--version` surfacing as data. 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 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 ea37a1260e..eaccc221d3 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,7 +12,7 @@ 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 }`、`{ 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`。 +`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法——`dsh web -p x` 会显式报错(`web` 没有 `-p`),而 `dsh -p x web` 只是一个 headless prompt,其第二个位置参数被丢弃,无需防范任何跨命令泄漏。每个解析器都在 `parse()` 之后读取 Commander 的 `opts()`/`processedArgs`,而不是通过 action 闭包。`--host` 是一个 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器。两处解析后的检查保留了「绝不静默重新开始」不变式:空的 `--resume=` id 和空的 `-p` 任务各自变为 `mode: 'error'`,因为 agent-loop 把空的 resume id 视为不恢复,而空的 prompt 没有任何内容可运行。重复出现的 `--resume` 采用 Commander 天然的后者胜出(旧的定制扫描器会拒绝它;后者胜出是标准的 CLI 行为,无需特殊处理)。`--version` 读取本应用的 `package.json`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 @@ -26,11 +26,13 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适 **保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 +**把 `web` 做成单个根程序的 Commander 子命令。** 已否决:一个程序若把根级 `-p`/`--resume` 语法与 `web` 子命令混在一起,除非再加上 `enablePositionalOptions()` 和一个父级选项守卫,否则根级选项会泄漏到 `web` 上——而这正是这次变更要移除的那类特殊处理机制。把 `web` 作为保留的首个 token 分发给第二个解析器更小巧,且让两套语法完全独立。 + **把参数解析做成 `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` 测试块。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)在关键层面覆盖适配器:按形态进行的模式路由、显式报错检查(空 resume/prompt、错误的 host/port、未知选项),以及 `--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` 测试块。 ## 影响 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index f549c125c3..e45393e8c6 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -5,7 +5,8 @@ * 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. + * caller as data. The `web` subcommand is a reserved first token dispatched to + * its own parser, so root flags and `web` flags never share a grammar. * @module @deepseek-ai/dsh/args */ @@ -57,23 +58,7 @@ export type DshInvocation = | 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}. - */ +/** Coerce `--port` to an integer in 0–65535; a bad value fails loud as a parse error. */ function parsePort(raw: string): number { const port = Number(raw) if (!Number.isInteger(port) || port < 0 || port > 65535) { @@ -82,102 +67,90 @@ function parsePort(raw: string): number { 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 +/** + * A configured `Command` under `exitOverride` with output captured into `sink`, + * so `--help`, `--version`, and parse errors surface as thrown `CommanderError`s + * (see {@link settle}) rather than writing to a stream or exiting. + */ +function program(name: string, version: string, sink: string[]): Command { + return new Command() + .name(name) + .version(version, '-V, --version', 'output the version number') + .exitOverride() + .configureOutput({ + writeOut: chunk => void sink.push(chunk), + writeErr: chunk => void sink.push(chunk), + }) } /** - * 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. + * Run `command.parse` and map its thrown `CommanderError` to an info/error + * invocation, or `undefined` when the parse succeeded (the caller then reads the + * parsed options). */ -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 +function settle(command: Command, argv: readonly string[], sink: string[]): InfoInvocation | ErrorInvocation | undefined { + try { + command.parse(argv, { from: 'user' }) + return undefined + } 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: sink.join('') } + if (error.code === 'commander.version') return { mode: 'version', text: sink.join('') } + return { mode: 'error', message: error.message } + } +} + +/** Parse `dsh web` arguments (everything after the `web` token). */ +function parseWeb(argv: readonly string[], version: string): DshInvocation { + const sink: string[] = [] + const web = program('dsh web', version, sink) + .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)) + const settled = settle(web, argv, sink) + if (settled !== undefined) return settled + const { host, port } = web.opts<{ host: string; port: number }>() + return { mode: 'web', host, port } +} + +/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */ +function parseRoot(argv: readonly string[], version: string): DshInvocation { + const sink: string[] = [] + const root = program('dsh', version, sink) + .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)') + const settled = settle(root, argv, sink) + if (settled !== undefined) return settled + 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. + if (prompt === '') return { mode: 'error', message: "error: option '-p, --prompt ' must not be empty" } + 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 === '') return { mode: 'error', message: "error: option '--resume ' must not be empty" } + return { + mode: 'tui', + ...config !== undefined ? { config } : {}, + ...resume !== undefined ? { resume } : {}, + } } /** * 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. + * as data for `bin.ts` to act on. A leading `web` token dispatches to the web + * parser; everything else is the default TUI/headless grammar. * @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 + return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version) } diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index ad6d0266ca..c9d3c236ad 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,120 +1,33 @@ 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) +const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') -/** 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', () => { +describe('parseDshArgs', () => { + it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { 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(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) 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('fails loud instead of silently starting fresh or serving on bad input', () => { + // An empty resume/prompt would otherwise be swallowed (agent-loop treats an + // empty resume id as no-resume); a bad host/port must not reach the listener. + expect(parse(['--resume=']).mode).toBe('error') + expect(parse(['-p', '']).mode).toBe('error') + expect(parse(['web', '--host', '10.0.0.1']).mode).toBe('error') + expect(parse(['web', '--port', 'abc']).mode).toBe('error') + expect(parse(['--bogus']).mode).toBe('error') }) - 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', () => { + it('surfaces --help and --version as printable data, not a process exit', () => { 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'") + expect(help).toMatchObject({ mode: 'help' }) + if (help.mode === 'help') expect(help.text).toContain('Usage: dsh') + expect(parse(['--version'])).toEqual({ mode: 'version', text: '1.2.3\n' }) }) })