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 committed 2026-10-05 17:45:17 +08:00
1 parent 7d89563970
commit 75935af1d0
21 files changed
+811 -6

No files matched your search

+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',