diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 3da6cf51e6..bd587e5dbf 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -18,8 +18,8 @@ en: d6368bf97a02bd57 zh: 2ab7888a108961ce /dsh-cli-behavior-reference/plugin-management: - en: 142404f01a747121 - zh: a6a5edbb0614bc62 + en: 8d2cfff9bab7327f + zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: en: 42f0b9349cf1574b zh: a3cc6e352e71badb diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 865f955037..9f60072e6e 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -82,7 +82,7 @@ New directories and files request modes `0700` and `0600` on POSIX. The CLI prin `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` 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 ` 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. +`dsh plugin` reads `--profile` only before the forwarded arguments, which start at the first token it does not recognize, so `--profile ` comes first. When the forwarded arguments begin with the pnpm command, everything after that command reaches pnpm verbatim, including `-h` and `--help`. When they begin with a pnpm option such as `--filter ` or `-r`, a later `-h` or `--help` still prints the forwarder's help instead of pnpm's, so put the pnpm command before them to reach pnpm's help. `-h` or `--help` before any forwarded argument 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: diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 9cfc5327a3..b71ae8a64a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -84,7 +84,7 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `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 ` 必须写在最前面。被转发的参数以 pnpm 命令开头时,该命令之后的所有内容都会原样交给 pnpm,包括 `-h`、`--help` 和 `--profile`。其他情况下,`-h` 或 `--help` 会打印转发器自身的帮助并以 0 退出。 +`dsh plugin` 只在被转发的参数之前读取 `--profile`;被转发的参数从它无法识别的第一个 token 开始,因此 `--profile ` 必须写在最前面。被转发的参数以 pnpm 命令开头时,该命令之后的所有内容都会原样交给 pnpm,包括 `-h` 和 `--help`。被转发的参数以 `--filter ` 或 `-r` 等 pnpm 选项开头时,后面的 `-h` 或 `--help` 仍会打印转发器的帮助而不是 pnpm 的帮助,因此要查看 pnpm 的帮助,需把 pnpm 命令写在它们之前。在任何被转发的参数之前出现的 `-h` 或 `--help` 会打印转发器自身的帮助并以 0 退出。 Codex 与 Claude Code subagent 提供方是两个彼此独立的可选组合包。可以只添加一个包、在同一命令中添加两个包,或独立移除任一包: diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 465208b778..24d6789656 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -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 + // Commander copies the launcher's disabled help option into subcommands. The + // forwarder restores its own help and stops reading options at forwarded pnpm input. .helpOption('-h, --help', 'show this help') .passThroughOptions() .requiredOption('--profile ', 'the profile whose plugins to manage (initialized on first use)', selectProfile) diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index e331fa4725..e255789466 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -125,7 +125,11 @@ describe('parseDshArgs', () => { .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'] }) + // A leading pnpm option starts the forwarded arguments before the pnpm command. + expect(parse(['plugin', '--profile', 'tui', '--filter', 'x', '--profile', 'y', 'add', 'z'])) + .toEqual({ mode: 'plugin', profile: 'tui', args: ['--filter', 'x', '--profile', 'y', 'add', 'z'] }) expect(exitCode(['plugin', 'add', 'x', '--profile', 'tui'])).toBe(1) + expect(exitCode(['plugin', '--filter', 'x', 'add', '--profile', 'tui'])).toBe(1) }) it.each([ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index b524a84b3e..d4bbea471f 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -29,7 +29,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there. | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | Contributor setup, daily workflow, and a summary of CI; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), check-by-check lists that drift from `package.json` scripts | -| Generated reference: the per-page `cordis-surface` regions in [subsystems/](subsystems/README.md), the [Cordis core API + inherited tier](cordis-api/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive English sources regenerated from source and freshness-gated; reviewed Chinese counterparts follow the [pairing workflow](i18n/README.md#scope-and-exclusions) | Hand edits to generated English sources or regions; Chinese counterparts update through pairing only | +| Generated reference: the per-page `cordis-surface` regions in [subsystems/](subsystems/README.md), the [Cordis core API + inherited tier](cordis-api/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md), [cli-help](cli-help.md) | Exhaustive English sources regenerated from source and freshness-gated; reviewed Chinese counterparts follow the [pairing workflow](i18n/README.md#scope-and-exclusions) | Hand edits to generated English sources or regions; Chinese counterparts update through pairing only | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. diff --git a/docs/cli-help.i18n.yaml b/docs/cli-help.i18n.yaml index 91bcf85e29..6445218133 100644 --- a/docs/cli-help.i18n.yaml +++ b/docs/cli-help.i18n.yaml @@ -3,8 +3,8 @@ # 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 + en: d11ca88bdc409249 + zh: d11ca88bdc409249 /dsh-cli-help: en: b7174eab3476579d zh: 5ddab967320e3cff diff --git a/docs/cli-help.md b/docs/cli-help.md index 2f2c495216..4539636c4a 100644 --- a/docs/cli-help.md +++ b/docs/cli-help.md @@ -1,4 +1,5 @@ - + # dsh CLI help diff --git a/docs/cli-help.zh.md b/docs/cli-help.zh.md index d4f5238c3b..f875c988e1 100644 --- a/docs/cli-help.zh.md +++ b/docs/cli-help.zh.md @@ -1,4 +1,5 @@ - + # dsh 命令行帮助 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.i18n.yaml index 724c2237e7..e632de9feb 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.i18n.yaml @@ -3,14 +3,14 @@ # 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 + en: 3f9b48b2230ec6e4 + zh: c1aad0e9121d46a4 /dsh-plugin-option-placement: en: 3d717a2b7e9087d3 zh: e6004d015e6a4829 /dsh-plugin-option-placement/change: - en: 27539de1a79a7eec - zh: 3860da746b0f24a5 + en: 15fe8cf74db0ba53 + zh: c6ea1056aa7a6f77 /dsh-plugin-option-placement/migration: - en: 543880bb55c786bc - zh: 0be3793b66b0c1e6 + en: b7239597ebc4d970 + zh: fa45f851cf1f3af1 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.md index 058d910413..8bb89d56a9 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.md @@ -1,6 +1,6 @@ --- kind: upgrade-guide -description: "dsh plugin reads --profile and its own help only before the forwarded pnpm arguments." +description: "dsh plugin reads --profile only before the forwarded pnpm arguments and prints its own help." --- # `dsh plugin` option placement @@ -9,12 +9,12 @@ English | [中文](guide.zh.md) ## Change -`dsh plugin` reads its own options only before the forwarded pnpm arguments. Previously it accepted `--profile ` anywhere on the command line, had no help of its own, and forwarded `-h` and `--help` to pnpm from any position. +`dsh plugin` reads `--profile` only before the forwarded pnpm arguments, which start at the first token it does not recognize, and it has its own help. Previously it accepted `--profile ` 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 ' not specified`, and `dsh plugin --profile --help` no longer prints pnpm's help. +When the forwarded arguments begin with the pnpm command, everything after that command now reaches pnpm verbatim, including `-h`, `--help`, and `--profile`. In any other position, including after a leading pnpm option such as `--filter `, `-h` or `--help` prints the `dsh plugin` help and exits 0. Scripts that place `--profile` after the pnpm command or after a leading pnpm option fail with `required option '--profile ' not specified`, and `dsh plugin --profile --help` no longer prints pnpm's help. ## Migration -1. Move `--profile ` before the pnpm command: replace `dsh plugin add --profile ` with `dsh plugin --profile add `. -2. Request pnpm's help for one command after that command, as in `dsh plugin --profile add --help`. For pnpm's general help, forward `help`: `dsh plugin --profile help`. +1. Move `--profile ` before every pnpm argument: replace `dsh plugin add --profile ` with `dsh plugin --profile add `. +2. Request pnpm's help for one command after that command, with any pnpm options after it too, as in `dsh plugin --profile add --help`. For pnpm's general help, forward `help`: `dsh plugin --profile help`. 3. Confirm that `dsh plugin --help` prints `Usage: dsh plugin [options] [args...]` and that the commands from steps 1 and 2 reach pnpm. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.zh.md index d3924b2507..50c71978cf 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/plugin-option-placement/guide.zh.md @@ -1,6 +1,6 @@ --- kind: upgrade-guide -description: "dsh plugin 只在被转发的 pnpm 参数之前读取 --profile 和自身的帮助选项。" +description: "dsh plugin 只在被转发的 pnpm 参数之前读取 --profile,并提供自己的帮助。" --- # `dsh plugin` 选项位置 @@ -9,12 +9,12 @@ description: "dsh plugin 只在被转发的 pnpm 参数之前读取 --profile ## 变更 -`dsh plugin` 只在被转发的 pnpm 参数之前读取自己的选项。此前它接受出现在命令行任意位置的 `--profile `,没有自己的帮助,并且无论 `-h` 和 `--help` 位于何处都会转发给 pnpm。 +`dsh plugin` 只在被转发的 pnpm 参数之前读取 `--profile`,被转发的参数从它无法识别的第一个 token 开始;它还提供自己的帮助。此前它接受出现在命令行任意位置的 `--profile `,没有自己的帮助,并且无论 `-h` 和 `--help` 位于何处都会转发给 pnpm。 -被转发的参数以 pnpm 命令开头时,该命令之后的所有内容现在都会原样交给 pnpm,包括 `-h`、`--help` 和 `--profile`。其他情况下,`-h` 或 `--help` 会打印 `dsh plugin` 的帮助并以 0 退出。把 `--profile` 写在 pnpm 命令之后的脚本会因 `required option '--profile ' not specified` 而失败,`dsh plugin --profile --help` 也不再打印 pnpm 的帮助。 +被转发的参数以 pnpm 命令开头时,该命令之后的所有内容现在都会原样交给 pnpm,包括 `-h`、`--help` 和 `--profile`。在其他任何位置,包括位于 `--filter ` 等开头的 pnpm 选项之后,`-h` 或 `--help` 都会打印 `dsh plugin` 的帮助并以 0 退出。把 `--profile` 写在 pnpm 命令或开头的 pnpm 选项之后的脚本会因 `required option '--profile ' not specified` 而失败,`dsh plugin --profile --help` 也不再打印 pnpm 的帮助。 ## 迁移 -1. 把 `--profile ` 移到 pnpm 命令之前:将 `dsh plugin add --profile ` 改为 `dsh plugin --profile add `。 -2. 查看某个 pnpm 命令的帮助时,把帮助选项写在该命令之后,例如 `dsh plugin --profile add --help`。查看 pnpm 的总体帮助时转发 `help`:`dsh plugin --profile help`。 +1. 把 `--profile ` 移到所有 pnpm 参数之前:将 `dsh plugin add --profile ` 改为 `dsh plugin --profile add `。 +2. 查看某个 pnpm 命令的帮助时,把帮助选项和其他 pnpm 选项都写在该命令之后,例如 `dsh plugin --profile add --help`。查看 pnpm 的总体帮助时转发 `help`:`dsh plugin --profile help`。 3. 确认 `dsh plugin --help` 打印 `Usage: dsh plugin [options] [args...]`,并且第 1、2 步中的命令能够到达 pnpm。 diff --git a/scripts/gen-cli-help.spec.ts b/scripts/gen-cli-help.spec.ts index 27b76764c9..772bbc726b 100644 --- a/scripts/gen-cli-help.spec.ts +++ b/scripts/gen-cli-help.spec.ts @@ -28,6 +28,23 @@ describe('CLI help discovery', () => { ]) }) + it('visits each command alias and lists a help command that repeats its parent once', async () => { + const visited: string[] = [] + const parent = 'Usage: web [command]\n\nCommands:\n serve|s [options] Serve the UI\n help [command] display help for command\n' + const pages = await collectCliHelp(async (args) => { + visited.push(args.join(' ')) + if (args.length === 0) return 'Usage: dsh\n' + if (args[0] === 'plugin') return 'Usage: dsh plugin\n' + const selected = args[0] === '--profile' ? args.slice(1) : args + return selected[1] === 'serve' || selected[1] === 's' ? 'Usage: web serve\n' : parent + }, ['web']) + expect(visited).toEqual(['', 'plugin', 'web', 'web serve', 'web s', 'web help', '--profile web']) + expect(pages.map(page => page.invocations)).toEqual([ + ['dsh'], ['dsh plugin'], ['dsh web', 'dsh web help', 'dsh --profile web', 'dsh --profile web help'], + ['dsh web serve', 'dsh web s', 'dsh --profile web serve', 'dsh --profile web s'], + ]) + }) + it.each(['', ' \r\n\n'])('rejects blank help %j', (help) => { expect(() => parseCliHelp(help)).toThrow('CLI help returned empty output') }) diff --git a/scripts/gen-cli-help.ts b/scripts/gen-cli-help.ts index 7ddc5fa683..0076f9b468 100644 --- a/scripts/gen-cli-help.ts +++ b/scripts/gen-cli-help.ts @@ -21,7 +21,7 @@ const execFileAsync = promisify(execFile) export interface CliHelp { /** Complete help text with LF line endings. */ output: string - /** Immediate command names listed in the `Commands:` section, in their displayed order. */ + /** Immediate command names and aliases listed in the `Commands:` section, in their displayed order. */ commands: string[] } @@ -34,21 +34,26 @@ interface 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. + * @returns Help text with LF line endings and the command names and aliases (`serve|s`) 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)) + const commands = [...section.matchAll(/^ {2}\S+/gmu)].flatMap(match => match[0].slice(2).split('|')) return { output, commands } } -function addAlias(page: HelpPage, invocation: string): void { +// A `help [command]` row prints its parent's help, so a page can list itself as a child. +// An invocation that reaches a page already on the current path joins that page without +// extending its children, which keeps alias propagation finite. +function addAlias(page: HelpPage, invocation: string, path: ReadonlySet): void { if (page.invocations.includes(invocation)) return page.invocations.push(invocation) - for (const [name, child] of page.children) addAlias(child, `${invocation} ${name}`) + if (path.has(page)) return + const childPath = new Set([...path, page]) + for (const [name, child] of page.children) addAlias(child, `${invocation} ${name}`, childPath) } /** @@ -61,24 +66,26 @@ export async function collectCliHelp( readHelp: (args: readonly string[]) => Promise, profiles: readonly string[], ): Promise { const pages = new Map() - const visit = async (args: string[]): Promise => { + const visit = async (args: string[], path: ReadonlySet): Promise => { const invocation = ['dsh', ...args].join(' ') const help = parseCliHelp(await readHelp(args)) const existing = pages.get(help.output) if (existing !== undefined) { - addAlias(existing, invocation) + addAlias(existing, invocation, path) 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])) + const childPath = new Set([...path, page]) + for (const name of help.commands) page.children.set(name, await visit([...args, name], childPath)) return page } - await visit([]) - await visit(['plugin']) + const top = new Set() + await visit([], top) + await visit(['plugin'], top) for (const profile of [...profiles].sort()) { - await visit([profile]) - await visit(['--profile', profile]) + await visit([profile], top) + await visit(['--profile', profile], top) } return [...pages.values()] } @@ -115,7 +122,8 @@ function anchor(page: HelpPage): string { function renderHelp(pages: readonly HelpPage[], chinese: boolean): string { const lines = [ - '', + '', '', chinese ? '# dsh 命令行帮助' : '# dsh CLI help', '',