feat(cli): generate the CLI help reference and add dsh plugin help

`scripts/gen-cli-help.ts` runs the source `dsh` launcher with temporary
Harness and Agents homes and captures the launcher, `dsh plugin`, and every
shipped profile's text help (profile names come from `PROFILE_TEMPLATES`).
It recursively discovers subcommands from each page's `Commands:` section,
shows identical alias help once with every invocation, and writes
`docs/cli-help.md`, its Chinese page, and their pairing record. The record
resolves links between the two pages as written, so a first run already
matches `verify-translation-pairing`. `verify-cli-help` checks all three
files and runs as the `cli-help` doc-sync leaf, which the static CI lane
also runs on an unbuilt tree.

`dsh plugin` now owns `-h`/`--help` and reads its own options only before
the forwarded pnpm arguments: `dsh plugin --help` prints its help instead of
failing on the missing `--profile`, while everything after the pnpm command
(including `-h`, `--help`, and `--profile`) reaches pnpm verbatim. A
`--profile` placed after the pnpm command is no longer accepted; the
v0.2.1-alpha.1 upgrade guide covers the migration.

Refs #4114
This commit is contained in:
Tianyi Cui
2026-10-05 17:45:17 +08:00
parent 7d89563970
commit 75935af1d0
21 changed files with 811 additions and 6 deletions
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-14-generated-cli-help-reference.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-14-generated-cli-help-reference.md
/agent-note-generate-the-cli-reference-from-command-help:
en: 618bafe3fd2afdb9
zh: 433df2a8d0530c41
/agent-note-generate-the-cli-reference-from-command-help/problem:
en: 2ad571458d5d8191
zh: 4db6bddbad65bf5c
/agent-note-generate-the-cli-reference-from-command-help/decision:
en: d99a93133c460385
zh: 0f710ecf6e7137f2
/agent-note-generate-the-cli-reference-from-command-help/alternatives-considered:
en: df2f7f07b3e84418
zh: 2dd87333d9d460c3
/agent-note-generate-the-cli-reference-from-command-help/consequences:
en: aada6686406c892e
zh: 9a30b6b1c5ab6d55
@@ -0,0 +1,25 @@
# Agent Note: Generate the CLI reference from command help
Status: implemented
English | [中文](2026-09-14-generated-cli-help-reference.zh.md)
## Problem
Launcher flags, profile options, and nested commands have separate owners. A manually maintained command reference can omit subcommands or retain obsolete defaults.
## Decision
The CLI reference captures the supported `dsh` launcher's help, the plugin forwarder's help, and each shipped profile's help, then recursively discovers the subcommands listed in each page's `Commands:` section. Shipped profile names come from `PROFILE_TEMPLATES`. Every page retains its complete help text; identical alias help appears once with all invocation forms.
Generation uses temporary Harness and Agents homes. Help exits before application work starts and requires no server or model. The plugin forwarder exposes its own help before pnpm arguments; flags after the pnpm command remain pnpm input.
The generated bilingual pages retain CLI output in its original language. `verify-cli-help` compares the complete pages and pairing record and runs with the documentation checks.
## Alternatives considered
A second command inventory or help-specific runtime API would duplicate the parser's definitions. Reading actual help keeps the reference tied to the same descriptions, options, defaults, and examples users receive.
## Consequences
Command changes update the reference through the same generator. Reviewers can compare complete help without assembling separate command invocations.
@@ -0,0 +1,25 @@
# Agent Note: 从命令帮助生成 CLI 参考
Status: implemented
[English](2026-09-14-generated-cli-help-reference.md) | 中文
## Problem
启动器选项、profile 选项和嵌套命令由不同部分负责。手工维护的命令参考可能遗漏子命令或保留过时的默认值。
## Decision
CLI 参考采集受支持的 `dsh` 启动器帮助、插件转发器帮助和各随附 profile 的帮助,并递归发现每页 `Commands:` 段落列出的子命令。随附 profile 的名称来自 `PROFILE_TEMPLATES`。每页都保留完整的帮助文本;相同的别名帮助只展示一次,并列出所有调用形式。
生成过程使用临时 Harness home 和 Agents home。帮助在应用开始工作前退出,不需要服务器或模型。插件转发器在 pnpm 参数之前提供自己的帮助;pnpm 命令之后的选项仍作为 pnpm 输入。
生成的双语页面保留 CLI 输出的原始语言。`verify-cli-help` 比较完整页面和配对记录,并随文档检查一起运行。
## Alternatives considered
另一份命令清单或专供帮助使用的运行时 API 会重复解析器的定义。读取实际帮助,可以让参考文档始终对应用户收到的说明、选项、默认值和示例。
## Consequences
命令变化通过同一个生成器更新参考文档。审阅者可以直接比较完整帮助,无需自行拼接各个命令的调用结果。
+4 -4
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
/dsh-cli-behavior-reference:
en: d25712a65d21ab45
zh: b1c57b706bc64085
en: 5a4f43756dae900e
zh: d58c479481cfa5fe
/dsh-cli-behavior-reference/profile-boot:
en: e3a159750f5f9ed3
zh: e02d9004fcc239f9
@@ -18,8 +18,8 @@
en: d6368bf97a02bd57
zh: 2ab7888a108961ce
/dsh-cli-behavior-reference/plugin-management:
en: 9980ff1600fa9460
zh: cc66ba45e27b5540
en: 142404f01a747121
zh: a6a5edbb0614bc62
/dsh-cli-behavior-reference/web-profile:
en: 42f0b9349cf1574b
zh: a3cc6e352e71badb
+4
View File
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
This reference defines the profile, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
The [generated command help](../../../docs/cli-help.md) contains complete launcher, plugin-management, and shipped-profile help. Run `pnpm run gen-cli-help` to refresh it; `pnpm run verify-cli-help` checks it against the current CLI.
## Profile boot
`dsh <name>` abbreviates `dsh --profile <name>` and boots the profile at `$DSH_HOME/profiles/<name>`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
@@ -80,6 +82,8 @@ New directories and files request modes `0700` and `0600` on POSIX. The CLI prin
`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
`dsh plugin` reads its own options only before the forwarded arguments, so `--profile <name>` comes first. When the forwarded arguments begin with the pnpm command, everything after that command reaches pnpm verbatim, including `-h`, `--help`, and `--profile`. Otherwise `-h` or `--help` prints the forwarder's own help and exits 0.
The Codex and Claude Code subagent providers are separate optional Bundles. Add either package, both in one command, or remove either package independently:
```sh
+4
View File
@@ -4,6 +4,8 @@
本参考定义 profile 启动、插件管理和配置 dump 等命令模式。argv 由 [`src/args.ts`](../src/args.ts) 统一解析一次,[`src/bin.ts`](../src/bin.ts) 只会动态导入选中的运行器。
[生成的命令帮助](../../../docs/cli-help.zh.md)包含启动器、插件管理和随附 profile 的完整帮助。运行 `pnpm run gen-cli-help` 刷新,运行 `pnpm run verify-cli-help` 检查它是否与当前 CLI 一致。
<a id="profile-boot"></a>
## Profile 启动
@@ -82,6 +84,8 @@ dsh --profile web --patch ./extra.yml --dump-config
`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。
`dsh plugin` 只在被转发的参数之前读取自己的选项,因此 `--profile <name>` 必须写在最前面。被转发的参数以 pnpm 命令开头时,该命令之后的所有内容都会原样交给 pnpm,包括 `-h`、`--help` 和 `--profile`。其他情况下,`-h` 或 `--help` 会打印转发器自身的帮助并以 0 退出。
Codex 与 Claude Code subagent 提供方是两个彼此独立的可选组合包。可以只添加一个包、在同一命令中添加两个包,或独立移除任一包:
```sh
+2
View File
@@ -187,6 +187,8 @@ export function parseDshArgs(argv: readonly string[], version: string, manageDes
if (first === 'plugin') {
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
plugin
.helpOption('-h, --help', 'show this help')
.passThroughOptions()
.requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)', selectProfile)
.allowUnknownOption()
.argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
+15
View File
@@ -120,6 +120,21 @@ describe('parseDshArgs', () => {
// Unknown pnpm flags forward verbatim.
expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x']))
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
// Options after the pnpm command belong to pnpm, including the forwarder's own names.
expect(parse(['plugin', '--profile', 'tui', 'add', '--help']))
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--help'] })
expect(parse(['plugin', '--profile', 'tui', 'add', '-h', '--profile', 'x']))
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '-h', '--profile', 'x'] })
expect(exitCode(['plugin', 'add', 'x', '--profile', 'tui'])).toBe(1)
})
it.each([
['plugin', '--help'], ['plugin', '-h'], ['plugin', '--profile', 'tui', '--help'],
['plugin', '--profile', 'tui', '--filter', 'x', 'add', '--help'],
])('prints the plugin forwarder help for %j', (...argv: string[]) => {
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
expect(exitCode(argv)).toBe(0)
expect(stdout.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain('Usage: dsh plugin [options] [args...]')
})
it.each(['desktop', 'Desktop', 'DESKTOP'])('permits installed Desktop plugin commands for %s', (profile) => {
+34
View File
@@ -0,0 +1,34 @@
# Bilingual-pair consistency record for cli-help.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cli-help.md
/:
en: 24a3ad7efe7ed273
zh: 24a3ad7efe7ed273
/dsh-cli-help:
en: b7174eab3476579d
zh: 5ddab967320e3cff
/dsh-cli-help/contents:
en: 00308bcc6dc90afd
zh: 16f7442b76686a02
/dsh-cli-help/dsh:
en: e0695abcf7d8b521
zh: 712c23b3c8a115ca
/dsh-cli-help/dsh-plugin:
en: adad99c125d0ac51
zh: f11024971184f737
/dsh-cli-help/dsh-acp:
en: bcb0f84577ce5f03
zh: 4382e617006849db
/dsh-cli-help/dsh-headless:
en: 878a1dd78017964d
zh: bb43d91b4beb9bda
/dsh-cli-help/dsh-sdk:
en: 701f239c5157dedb
zh: ee11be2932b05ce4
/dsh-cli-help/dsh-sdk-minimal:
en: 8f0d7548d299ffd3
zh: fa2262e70d6819a0
/dsh-cli-help/dsh-web:
en: 5d073104e8954cc8
zh: 91cd28f7c6ac96ac
+193
View File
@@ -0,0 +1,193 @@
<!-- Generated by scripts/gen-cli-help.ts; run pnpm run gen-cli-help to regenerate. -->
# dsh CLI help
This reference contains complete help for the launcher, subcommands, and shipped profiles. Identical help appears once with every invocation listed. Help output retains its original language.
Run `pnpm run gen-cli-help` to regenerate; run `pnpm run verify-cli-help` to check freshness.
## Contents
- [`dsh`](#dsh)
- [`dsh plugin`](#dsh-plugin)
- [`dsh acp`](#dsh-acp)
- [`dsh headless`](#dsh-headless)
- [`dsh sdk`](#dsh-sdk)
- [`dsh sdk-minimal`](#dsh-sdk-minimal)
- [`dsh web`](#dsh-web)
<a id="dsh"></a>
## `dsh`
Invocations: `dsh --help`
```text
Usage: dsh [--profile] <name> [options] [app-args...]
dsh plugin --profile <name> <pnpm-args...>
dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch
layers under your own overrides.
Arguments:
args arguments for the booted profile's app (see:
dsh --profile <name> --help)
Options:
-V, --version output the version number
--profile <name> the profile under $DSH_HOME/profiles to boot
--from-default-profile <name> initialize a new custom profile from a shipped
profile template
--patch <path> extra patch-list overlay applied after the
profile layer (repeatable)
--dump-config print the composed profile tree and exit
--dump-config-schema print JSON Schema for profile entries and
patches without mounting
--dump-default-config print the profile tree without its user layer
or --patch overlays and exit
Examples:
dsh web boot the web profile (same as: dsh --profile web)
dsh rescue --from-default-profile web
create rescue from the shipped web template, then boot it
dsh headless "run the tests" answer one task, print the result, and exit
dsh tui --patch ./extra.yml boot a custom profile with one extra overlay
dsh tui --resume <session> arguments after the launcher flags reach the app
dsh web --help the web app's own flags and help
dsh plugin --profile tui add <package> install a plugin into the tui profile
```
<a id="dsh-plugin"></a>
## `dsh plugin`
Invocations: `dsh plugin --help`
```text
Usage: dsh plugin [options] [args...]
manage a profile's plugins by forwarding the remaining arguments to pnpm in the
profile directory
Arguments:
args pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>,
why <pkg>, ...)
Options:
--profile <name> the profile whose plugins to manage (initialized on first
use)
-h, --help show this help
```
<a id="dsh-acp"></a>
## `dsh acp`
Invocations: `dsh acp --help`, `dsh --profile acp --help`
```text
Usage: dsh --profile acp [options]
Serve automation clients over Agent Client Protocol stdio.
Options:
-h, --help show this help
Example:
dsh --profile acp serve ACP until the client disconnects
```
<a id="dsh-headless"></a>
## `dsh headless`
Invocations: `dsh headless --help`, `dsh --profile headless --help`
```text
Usage: dsh --profile headless [options] [task...]
Answer one task and exit; the answer goes to stdout and diagnostics to stderr.
Arguments:
task the task text; multiple words are joined by spaces, and `-`
reads stdin
Options:
--json write newline-delimited run events to stdout instead of the
final message
--session-id <id> adopt the persisted Session with this id; an unknown id is
an error
-h, --help show this help
Examples:
dsh --profile headless "run the tests" answer one task and exit
echo "run the tests" | dsh --profile headless read the task from stdin
dsh --profile headless --json "run the tests" emit machine-readable run events
dsh --profile headless --session-id session-… "continue" resume an existing Session
```
<a id="dsh-sdk"></a>
## `dsh sdk`
Invocations: `dsh sdk --help`, `dsh --profile sdk --help`
```text
Usage: dsh --profile sdk [options]
Serve DeepSeek Harness SDK clients over stdio JSON-RPC.
Options:
-h, --help show this help
Example:
dsh --profile sdk serve one SDK runtime until its client disconnects
```
<a id="dsh-sdk-minimal"></a>
## `dsh sdk-minimal`
Invocations: `dsh sdk-minimal --help`, `dsh --profile sdk-minimal --help`
```text
Usage: dsh --profile sdk-minimal [options]
Serve DeepSeek Harness SDK clients over stdio JSON-RPC.
Options:
-h, --help show this help
Example:
dsh --profile sdk-minimal serve one SDK runtime until its client disconnects
```
<a id="dsh-web"></a>
## `dsh web`
Invocations: `dsh web --help`, `dsh --profile web --help`
```text
Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
--host <host> bind host
--no-open do not open the Web UI in the default browser
--port <port> listen port; pass 0 to let the OS pick a free
one
--public-url <url> advertise this HTTP(S) root in the printed,
opened, web-surface, and DSH_WEB_URL forms;
grants no trust
--trusted-host <authority...> extra authority the /api browser-trust fence
accepts (host or host:port; repeatable)
-h, --help show this help
Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
```
+195
View File
@@ -0,0 +1,195 @@
<!-- Generated by scripts/gen-cli-help.ts; run pnpm run gen-cli-help to regenerate. -->
# dsh 命令行帮助
[English](cli-help.md) | 中文
此参考收录启动器、子命令和随附 profile 的完整帮助。同一份帮助只展示一次,并列出所有调用方式。帮助输出保留原文。
运行 `pnpm run gen-cli-help` 重新生成;运行 `pnpm run verify-cli-help` 检查是否与当前命令一致。
## 目录
- [`dsh`](#dsh)
- [`dsh plugin`](#dsh-plugin)
- [`dsh acp`](#dsh-acp)
- [`dsh headless`](#dsh-headless)
- [`dsh sdk`](#dsh-sdk)
- [`dsh sdk-minimal`](#dsh-sdk-minimal)
- [`dsh web`](#dsh-web)
<a id="dsh"></a>
## `dsh`
调用方式: `dsh --help`
```text
Usage: dsh [--profile] <name> [options] [app-args...]
dsh plugin --profile <name> <pnpm-args...>
dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch
layers under your own overrides.
Arguments:
args arguments for the booted profile's app (see:
dsh --profile <name> --help)
Options:
-V, --version output the version number
--profile <name> the profile under $DSH_HOME/profiles to boot
--from-default-profile <name> initialize a new custom profile from a shipped
profile template
--patch <path> extra patch-list overlay applied after the
profile layer (repeatable)
--dump-config print the composed profile tree and exit
--dump-config-schema print JSON Schema for profile entries and
patches without mounting
--dump-default-config print the profile tree without its user layer
or --patch overlays and exit
Examples:
dsh web boot the web profile (same as: dsh --profile web)
dsh rescue --from-default-profile web
create rescue from the shipped web template, then boot it
dsh headless "run the tests" answer one task, print the result, and exit
dsh tui --patch ./extra.yml boot a custom profile with one extra overlay
dsh tui --resume <session> arguments after the launcher flags reach the app
dsh web --help the web app's own flags and help
dsh plugin --profile tui add <package> install a plugin into the tui profile
```
<a id="dsh-plugin"></a>
## `dsh plugin`
调用方式: `dsh plugin --help`
```text
Usage: dsh plugin [options] [args...]
manage a profile's plugins by forwarding the remaining arguments to pnpm in the
profile directory
Arguments:
args pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>,
why <pkg>, ...)
Options:
--profile <name> the profile whose plugins to manage (initialized on first
use)
-h, --help show this help
```
<a id="dsh-acp"></a>
## `dsh acp`
调用方式: `dsh acp --help`, `dsh --profile acp --help`
```text
Usage: dsh --profile acp [options]
Serve automation clients over Agent Client Protocol stdio.
Options:
-h, --help show this help
Example:
dsh --profile acp serve ACP until the client disconnects
```
<a id="dsh-headless"></a>
## `dsh headless`
调用方式: `dsh headless --help`, `dsh --profile headless --help`
```text
Usage: dsh --profile headless [options] [task...]
Answer one task and exit; the answer goes to stdout and diagnostics to stderr.
Arguments:
task the task text; multiple words are joined by spaces, and `-`
reads stdin
Options:
--json write newline-delimited run events to stdout instead of the
final message
--session-id <id> adopt the persisted Session with this id; an unknown id is
an error
-h, --help show this help
Examples:
dsh --profile headless "run the tests" answer one task and exit
echo "run the tests" | dsh --profile headless read the task from stdin
dsh --profile headless --json "run the tests" emit machine-readable run events
dsh --profile headless --session-id session-… "continue" resume an existing Session
```
<a id="dsh-sdk"></a>
## `dsh sdk`
调用方式: `dsh sdk --help`, `dsh --profile sdk --help`
```text
Usage: dsh --profile sdk [options]
Serve DeepSeek Harness SDK clients over stdio JSON-RPC.
Options:
-h, --help show this help
Example:
dsh --profile sdk serve one SDK runtime until its client disconnects
```
<a id="dsh-sdk-minimal"></a>
## `dsh sdk-minimal`
调用方式: `dsh sdk-minimal --help`, `dsh --profile sdk-minimal --help`
```text
Usage: dsh --profile sdk-minimal [options]
Serve DeepSeek Harness SDK clients over stdio JSON-RPC.
Options:
-h, --help show this help
Example:
dsh --profile sdk-minimal serve one SDK runtime until its client disconnects
```
<a id="dsh-web"></a>
## `dsh web`
调用方式: `dsh web --help`, `dsh --profile web --help`
```text
Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
--host <host> bind host
--no-open do not open the Web UI in the default browser
--port <port> listen port; pass 0 to let the OS pick a free
one
--public-url <url> advertise this HTTP(S) root in the printed,
opened, web-surface, and DSH_WEB_URL forms;
grants no trust
--trusted-host <authority...> extra authority the /api browser-trust fence
accepts (host or host:port; repeatable)
-h, --help show this help
Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
```
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.md
/:
en: 26325999c8fa35d8
zh: 1cee6fc8d459053c
/dsh-plugin-option-placement:
en: 3d717a2b7e9087d3
zh: e6004d015e6a4829
/dsh-plugin-option-placement/change:
en: 27539de1a79a7eec
zh: 3860da746b0f24a5
/dsh-plugin-option-placement/migration:
en: 543880bb55c786bc
zh: 0be3793b66b0c1e6
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "dsh plugin reads --profile and its own help only before the forwarded pnpm arguments."
---
# `dsh plugin` option placement
English | [中文](guide.zh.md)
## Change
`dsh plugin` reads its own options only before the forwarded pnpm arguments. Previously it accepted `--profile <name>` anywhere on the command line, had no help of its own, and forwarded `-h` and `--help` to pnpm from any position.
When the forwarded arguments begin with the pnpm command, everything after that command now reaches pnpm verbatim, including `-h`, `--help`, and `--profile`. Otherwise `-h` or `--help` prints the `dsh plugin` help and exits 0. Scripts that place `--profile` after the pnpm command fail with `required option '--profile <name>' not specified`, and `dsh plugin --profile <name> --help` no longer prints pnpm's help.
## Migration
1. Move `--profile <name>` before the pnpm command: replace `dsh plugin add <package> --profile <name>` with `dsh plugin --profile <name> add <package>`.
2. Request pnpm's help for one command after that command, as in `dsh plugin --profile <name> add --help`. For pnpm's general help, forward `help`: `dsh plugin --profile <name> help`.
3. Confirm that `dsh plugin --help` prints `Usage: dsh plugin [options] [args...]` and that the commands from steps 1 and 2 reach pnpm.
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "dsh plugin 只在被转发的 pnpm 参数之前读取 --profile 和自身的帮助选项。"
---
# `dsh plugin` 选项位置
[English](guide.md) | 中文
## 变更
`dsh plugin` 只在被转发的 pnpm 参数之前读取自己的选项。此前它接受出现在命令行任意位置的 `--profile <name>`,没有自己的帮助,并且无论 `-h` 和 `--help` 位于何处都会转发给 pnpm。
被转发的参数以 pnpm 命令开头时,该命令之后的所有内容现在都会原样交给 pnpm,包括 `-h`、`--help` 和 `--profile`。其他情况下,`-h` 或 `--help` 会打印 `dsh plugin` 的帮助并以 0 退出。把 `--profile` 写在 pnpm 命令之后的脚本会因 `required option '--profile <name>' not specified` 而失败,`dsh plugin --profile <name> --help` 也不再打印 pnpm 的帮助。
## 迁移
1. 把 `--profile <name>` 移到 pnpm 命令之前:将 `dsh plugin add <package> --profile <name>` 改为 `dsh plugin --profile <name> add <package>`。
2. 查看某个 pnpm 命令的帮助时,把帮助选项写在该命令之后,例如 `dsh plugin --profile <name> add --help`。查看 pnpm 的总体帮助时转发 `help`:`dsh plugin --profile <name> help`。
3. 确认 `dsh plugin --help` 打印 `Usage: dsh plugin [options] [args...]`,并且第 1、2 步中的命令能够到达 pnpm。
+2
View File
@@ -150,6 +150,8 @@
"verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts",
"gen-tsconfig-paths": "tsx scripts/gen-tsconfig-paths.ts",
"verify-tsconfig-paths": "tsx scripts/gen-tsconfig-paths.ts --check",
"gen-cli-help": "tsx scripts/gen-cli-help.ts",
"verify-cli-help": "tsx scripts/gen-cli-help.ts --check",
"gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
"verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
"gen-cordis-api": "tsx scripts/gen-cordis-api.ts",
+34
View File
@@ -0,0 +1,34 @@
/** Command discovery and complete output preservation for the CLI reference. */
import { describe, expect, it } from 'vitest'
import { collectCliHelp, parseCliHelp } from './gen-cli-help.ts'
describe('CLI help discovery', () => {
it('reads command rows while retaining wrapped descriptions and examples', () => {
const help = 'Usage: dsh\r\n\r\nCommands:\r\n web [options] Serve the browser\r\n with a wrapped description\r\n plugin Manage plugins\r\n\r\nExamples:\r\n dsh web\r\n'
const result = parseCliHelp(help)
expect(result.commands).toEqual(['web', 'plugin'])
expect(result.output).toBe(help.replaceAll('\r\n', '\n'))
})
it('includes plugin, both profile forms, and their nested commands when launcher help has no command list', async () => {
const visited: string[] = []
const pages = await collectCliHelp(async (args) => {
visited.push(args.join(' '))
if (args.length === 0) return 'Usage: dsh [--profile] <name>\n'
if (args[0] === 'plugin') return 'Usage: dsh plugin --profile <name> <args...>\n'
const selected = args[0] === '--profile' ? args.slice(1) : args
if (selected[1] === 'nested') return 'Usage: nested\n'
return `Usage: ${selected[0]} [command]\n\nCommands:\n nested Run the nested command\n`
}, ['web'])
expect(visited).toEqual(['', 'plugin', 'web', 'web nested', '--profile web'])
expect(pages.map(page => page.invocations)).toEqual([
['dsh'], ['dsh plugin'], ['dsh web', 'dsh --profile web'],
['dsh web nested', 'dsh --profile web nested'],
])
})
it.each(['', ' \r\n\n'])('rejects blank help %j', (help) => {
expect(() => parseCliHelp(help)).toThrow('CLI help returned empty output')
})
})
+187
View File
@@ -0,0 +1,187 @@
/** Generate paired CLI references by invoking the shipped launcher and profile help. */
import { execFile } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot'
import {
computeTranslationPairingRecord,
renderTranslationPairingRecord,
translationPairPaths,
} from './translation-pairing-record.ts'
import { parseTranslationPairingManifest, translationPairSourcePredicate } from './translation-pairing.ts'
const root = resolve(import.meta.dirname, '..')
const paths = translationPairPaths('docs/cli-help.md')
const execFileAsync = promisify(execFile)
/** Complete help text and the immediate subcommands it advertises. */
export interface CliHelp {
/** Complete help text with LF line endings. */
output: string
/** Immediate command names listed in the `Commands:` section, in their displayed order. */
commands: string[]
}
interface HelpPage {
help: CliHelp
invocations: [string, ...string[]]
children: Map<string, HelpPage>
}
/**
* Discover commands from actual text help while preserving every displayed line.
* @param stdout - Successful CLI help output.
* @returns Help text with LF line endings and the command names listed in its `Commands:` section.
* @throws When the output is empty.
*/
export function parseCliHelp(stdout: string): CliHelp {
const output = stdout.replaceAll('\r\n', '\n')
if (output.trim() === '') throw new Error('CLI help returned empty output')
const section = /^Commands:\n((?:[ \t][^\n]*\n|\n)*)/mu.exec(output)?.[1] ?? ''
const commands = [...section.matchAll(/^ {2}\S+/gmu)].map(match => match[0].slice(2))
return { output, commands }
}
function addAlias(page: HelpPage, invocation: string): void {
if (page.invocations.includes(invocation)) return
page.invocations.push(invocation)
for (const [name, child] of page.children) addAlias(child, `${invocation} ${name}`)
}
/**
* Collect launcher, plugin, and profile help even when root help lists no commands.
* @param readHelp - run one help invocation and return its complete stdout.
* @param profiles - shipped profile names whose explicit and shorthand forms must be documented.
* @returns distinct help pages with every discovered invocation and nested command.
*/
export async function collectCliHelp(
readHelp: (args: readonly string[]) => Promise<string>, profiles: readonly string[],
): Promise<HelpPage[]> {
const pages = new Map<string, HelpPage>()
const visit = async (args: string[]): Promise<HelpPage> => {
const invocation = ['dsh', ...args].join(' ')
const help = parseCliHelp(await readHelp(args))
const existing = pages.get(help.output)
if (existing !== undefined) {
addAlias(existing, invocation)
return existing
}
const page: HelpPage = { help, invocations: [invocation], children: new Map() }
pages.set(help.output, page)
for (const name of help.commands) page.children.set(name, await visit([...args, name]))
return page
}
await visit([])
await visit(['plugin'])
for (const profile of [...profiles].sort()) {
await visit([profile])
await visit(['--profile', profile])
}
return [...pages.values()]
}
async function collectHelp(): Promise<HelpPage[]> {
const directory = mkdtempSync(join(tmpdir(), 'dsh-cli-help-'))
const environment = {
...process.env,
DSH_HOME: join(directory, 'harness'),
DSH_AGENTS_HOME: join(directory, 'agents'),
DSH_TELEMETRY_DISABLED: '1',
NO_COLOR: '1',
}
for (const name of ['NODE_OPTIONS', 'FORCE_COLOR']) Reflect.deleteProperty(environment, name)
try {
return await collectCliHelp(async (args) => {
const { stdout, stderr } = await execFileAsync(process.execPath, [
'--import', 'tsx/esm', 'apps/cli/src/bin.ts', ...args, '--help',
], {
cwd: root, env: environment, encoding: 'utf8', timeout: 30_000,
killSignal: 'SIGKILL', maxBuffer: 2 * 1024 * 1024,
})
if (stderr !== '') throw new Error(`${['dsh', ...args].join(' ')} --help wrote diagnostics: ${stderr}`)
return stdout
}, Object.keys(PROFILE_TEMPLATES))
} finally {
rmSync(directory, { recursive: true, force: true })
}
}
function anchor(page: HelpPage): string {
return page.invocations[0].replaceAll(/[^a-zA-Z0-9]+/gu, '-').replace(/-$/u, '').toLowerCase()
}
function renderHelp(pages: readonly HelpPage[], chinese: boolean): string {
const lines = [
'<!-- Generated by scripts/gen-cli-help.ts; run pnpm run gen-cli-help to regenerate. -->',
'',
chinese ? '# dsh 命令行帮助' : '# dsh CLI help',
'',
...(chinese ? ['[English](cli-help.md) | 中文', ''] : []),
chinese
? '此参考收录启动器、子命令和随附 profile 的完整帮助。同一份帮助只展示一次,并列出所有调用方式。帮助输出保留原文。'
: 'This reference contains complete help for the launcher, subcommands, and shipped profiles. Identical help appears once with every invocation listed. Help output retains its original language.',
'',
chinese
? '运行 `pnpm run gen-cli-help` 重新生成;运行 `pnpm run verify-cli-help` 检查是否与当前命令一致。'
: 'Run `pnpm run gen-cli-help` to regenerate; run `pnpm run verify-cli-help` to check freshness.',
'',
chinese ? '## 目录' : '## Contents',
'',
...pages.map(page => `- [\`${page.invocations[0]}\`](#${anchor(page)})`),
'',
]
for (const page of pages) {
lines.push(
`<a id="${anchor(page)}"></a>`,
`## \`${page.invocations[0]}\``,
'',
`${chinese ? '调用方式' : 'Invocations'}: ${page.invocations.map(invocation => `\`${invocation} --help\``).join(', ')}`,
'',
`\`\`\`text\n${page.help.output}${page.help.output.endsWith('\n') ? '' : '\n'}\`\`\``,
'',
)
}
return lines.join('\n')
}
/** Regenerate both CLI reference pages, or check their bytes with --check. @returns Nothing. */
export async function main(): Promise<void> {
const check = process.argv.includes('--check')
const pages = await collectHelp()
const source = renderHelp(pages, false)
const zh = renderHelp(pages, true)
const written = new Set([paths.source, paths.zh])
const context = {
repoRoot: root,
isTranslationPairSource: translationPairSourcePredicate(parseTranslationPairingManifest(
readFileSync(resolve(root, 'scripts/translation-pairing.manifest.json'), 'utf8'),
)),
// Resolve links between the two pages as this run writes them, so a first run
// records the same hashes that verify-translation-pairing computes afterward.
repositoryFileExists: (path: string) => written.has(path)
|| existsSync(resolve(root, path)) && statSync(resolve(root, path)).isFile(),
}
const outputs = new Map([
[paths.source, source],
[paths.zh, zh],
[paths.meta, renderTranslationPairingRecord(paths, computeTranslationPairingRecord(paths, source, zh, context))],
])
const changed: string[] = []
for (const [path, content] of outputs) {
const destination = resolve(root, path)
if (existsSync(destination) && readFileSync(destination, 'utf8') === content) continue
changed.push(path)
if (!check) writeFileSync(destination, content)
}
if (check && changed.length > 0) {
console.error(`gen-cli-help: stale — ${changed.join(', ')}. Run pnpm run gen-cli-help.`)
process.exitCode = 1
} else {
console.log(`gen-cli-help: ${pages.length} help pages; ${check ? 'all outputs are current' : `${changed.length} files written`}.`)
}
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
+9 -2
View File
@@ -284,6 +284,13 @@ describe('gate graph validation', () => {
expect(ids).toContain('public-repository-links')
})
it('checks the complete CLI reference locally and in the static CI lane', () => {
for (const mode of ['doc-sync', 'ci-static'] as const) {
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
expect(gates.find(gate => gate.id === 'cli-help')).toBeDefined()
}
})
it('keeps the concrete terminology policy in the documentation gate', () => {
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
@@ -360,8 +367,8 @@ describe('gate graph validation', () => {
it('schedules the longest documentation leaves before short checks', () => {
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
expect(ids.slice(0, 10)).toEqual([
'doc-typecheck', 'docs-site-build', 'doc-graphs', 'markdown-links', 'type-equivalence',
expect(ids.slice(0, 11)).toEqual([
'doc-typecheck', 'docs-site-build', 'doc-graphs', 'cli-help', 'markdown-links', 'type-equivalence',
'cordis-catalog', 'cordis-inspect-catalog', 'workflow-guest', 'mermaid', 'translation-pairing',
])
})
+1
View File
@@ -799,6 +799,7 @@ function docSyncLeafGates(options: {
: [pnpmScript('doc-typecheck', options.docTypecheckScript ?? 'doc-typecheck', docTypecheckOptions)],
pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }),
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
pnpmScript('cli-help', 'verify-cli-help', { label: 'CLI help reference' }),
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links', quick: true }),
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence', quick: true }),
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
+1
View File
@@ -133,6 +133,7 @@ describe('translation pairing manifest', () => {
describe('translation pairing switchers', () => {
it('exempts only paired generated English sources from reciprocal switchers', () => {
expect(requiresSourceLanguageSwitcher('docs/cli-help.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/config-catalog.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/cordis-api/context.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/cordis-api/inherited.md')).toBe(false)
+1
View File
@@ -311,6 +311,7 @@ export function requiresSourceLanguageSwitcher(source: string): boolean {
return ![
'docs/agent-lifecycle.md',
'docs/capability-seams.md',
'docs/cli-help.md',
'docs/config-catalog.md',
'docs/cordis-api/context.md',
'docs/cordis-api/events.md',