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 3a70d276fe..7e947bbed6 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: 0f6b18848eacc5d5771e500bf226cc6f680f8d3f -2026-07-24-dsh-commander-argument-adapter.zh.md: ae523d0e37d09ce1f85b317bb0850194045474cb +2026-07-24-dsh-commander-argument-adapter.md: f90c4fb8d428eabed353176d98dce0fb9e34bf99 +2026-07-24-dsh-commander-argument-adapter.zh.md: fc0d1aa588ca6ce3b8c9d0b59343c4af698103da 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 0f6b18848e..f90c4fb8d4 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 @@ -10,9 +10,9 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di ## 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)` 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`. +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); `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535, moving both from the inline `runWeb` checks into the parser. `--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. `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`. `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. 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 ae523d0e37..fc0d1aa588 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 @@ -10,9 +10,9 @@ 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`。 +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` 必须是 0–65535 范围内的整数,这两项校验都从 `runWeb` 的内联检查移入解析器。`--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。`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`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 8fc040168f..ff0cc65c84 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -14,7 +14,6 @@ import { Command, CommanderError } from 'commander' 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 { @@ -29,11 +28,16 @@ interface HeadlessInvocation { prompt: string } -/** Browser UI: `dsh web`. Host is loopback/all-interfaces, port a 0–65535 integer, `dev` mounts the HMR driver. */ +/** + * Browser UI: `dsh web`. `host`/`port` are present only when the flag was + * passed (validated: host is loopback/all-interfaces, port a 0–65535 integer); + * absent means the shipped `cordis.yml` default stands, so the yml is the sole + * source of the default. `dev` mounts the client HMR driver. + */ interface WebInvocation { mode: 'web' - host: string - port: number + host?: string + port?: number dev: boolean } @@ -47,21 +51,31 @@ function program(name: string, version: string): Command { /** 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') - .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`, LOOPBACK_HOST) - .option('--port ', 'listen port', String(DEFAULT_WEB_PORT)) + .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 !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) { + 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}`) } - const portNumber = Number(port) - if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) { - web.error('error: --port must be an integer in 0-65535') + 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') + } + } + return { + mode: 'web', + ...host !== undefined && { host }, + ...portNumber !== undefined && { port: portNumber }, + dev: dev === true, } - return { mode: 'web', host, port: portNumber, dev: dev === true } } /** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index c8ecf581ba..1f32c74d0d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -13,13 +13,19 @@ import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) /** - * Serve the browser UI from the shipped config tree. - * @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. + * Serve the browser UI from the shipped config tree. `host`/`port` are passed + * through only when the flag was given; absent, the `cordis.yml` value stands. + * @param host - the bind host ({@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}), or `undefined` to keep the config default. + * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. */ -export async function runWeb(hostAddress: string, port: number, dev: boolean): Promise { - const entry = new AppCLIEntry({ configPath: CONFIG_PATH, dev, host: hostAddress, port }) +export async function runWeb(host: string | undefined, port: number | undefined, dev: boolean): Promise { + const entry = new AppCLIEntry({ + configPath: CONFIG_PATH, + dev, + ...host !== undefined && { host }, + ...port !== undefined && { port }, + }) const { ctx, port: boundPort } = await entry.run() let exiting = false @@ -29,7 +35,7 @@ export async function runWeb(hostAddress: string, port: number, dev: boolean): P void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = hostAddress === ALL_INTERFACES_HOST + const lanCandidate = host === ALL_INTERFACES_HOST ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 80e64534c5..f9f6363660 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts' +import { ALL_INTERFACES_HOST, parseDshArgs } from '../src/args.ts' const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') @@ -29,7 +29,8 @@ describe('parseDshArgs', () => { 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(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: false }) + // Bare `web` carries no host/port: the shipped cordis.yml owns the default. + expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080', '--dev'])) .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: true }) })