fix(cli): bound CLI help alias discovery and document plugin option placement

`collectCliHelp` stops propagating an alias when it reaches a page
already on the current path, so a `help [command]` row that prints its
parent's help no longer recurses until the stack overflows. `serve|s`
command rows are split into separately collected names.

The CLI reference and upgrade guide describe the forwarded-argument
boundary that Commander applies, including a leading pnpm option, and
args.spec.ts pins that `--profile` placed after a leading pnpm option is
forwarded. Generated pages use the repository's two-line generated
header, and docs/AGENTS.md lists the CLI help among the generated
references.

Refs #4114
This commit is contained in:
Tianyi Cui committed 2026-10-05 17:45:17 +08:00
1 parent 75935af1d0
commit 231d5a311e
14 files changed
+71 -38

No files matched your search

+17
View File
@@ -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')
})
+21 -13
View File
@@ -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<HelpPage>): 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<string>, profiles: readonly string[],
): Promise<HelpPage[]> {
const pages = new Map<string, HelpPage>()
const visit = async (args: string[]): Promise<HelpPage> => {
const visit = async (args: string[], path: ReadonlySet<HelpPage>): 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)
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<HelpPage>()
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 = [
'<!-- Generated by scripts/gen-cli-help.ts; run pnpm run gen-cli-help to regenerate. -->',
'<!-- Generated by scripts/gen-cli-help.ts — do not edit by hand.',
' Run `pnpm run gen-cli-help` to regenerate. -->',
'',
chinese ? '# dsh 命令行帮助' : '# dsh CLI help',
'',