diff --git a/src/cli.test.ts b/src/cli.test.ts index 95788f343..5f921bb22 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -247,6 +247,7 @@ describe('createProgram root help descriptions', () => { strategy: Strategy.PUBLIC, browser: false, args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }], + columns: ['title', 'url'], }); const program = createProgram('', ''); @@ -261,10 +262,101 @@ describe('createProgram root help descriptions', () => { name: 'hot', access: 'read', description: 'Bilibili hot videos', + browser: false, example: 'opencli bilibili hot -f yaml', - args: [{ name: 'limit', type: 'int', default: 20 }], + command_options: [{ name: 'limit', type: 'int', default: 20 }], + columns: ['title', 'url'], }, ]); + expect(data.commands[0]).not.toHaveProperty('args'); + } finally { + process.argv = argv; + registry.clear(); + for (const [key, value] of snapshot) registry.set(key, value); + } + }); + + it('renders per-site text help without per-command common option noise', () => { + const registry = getRegistry(); + const snapshot = new Map(registry); + registry.clear(); + try { + cli({ + site: 'bilibili', + name: 'hot', + access: 'read', + description: 'Bilibili hot videos', + strategy: Strategy.PUBLIC, + browser: false, + args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }], + }); + cli({ + site: 'bilibili', + name: 'video', + access: 'read', + description: 'Read one video', + domain: 'www.bilibili.com', + strategy: Strategy.PUBLIC, + browser: true, + args: [{ name: 'bvid', positional: true, required: true, help: 'Video id' }], + }); + + const program = createProgram('', ''); + const site = program.commands.find(cmd => cmd.name() === 'bilibili'); + expect(site).toBeTruthy(); + const help = site!.helpInformation(); + + expect(help).toContain('hot [options] [read] Bilibili hot videos'); + expect(help).toContain('video [read] Read one video'); + expect(help).toContain('hot [options]'); + expect(help).not.toContain('video [options]'); + expect(help).not.toContain('\nOptions:'); + expect(help).toContain('Common options:'); + expect(help).toContain('-f, --format '); + expect(help).toContain('--trace '); + expect(help).toContain('get all command args/options in one structured response'); + } finally { + registry.clear(); + for (const [key, value] of snapshot) registry.set(key, value); + } + }); + + it('separates command args from common options in structured help', () => { + const registry = getRegistry(); + const snapshot = new Map(registry); + const argv = process.argv; + registry.clear(); + try { + cli({ + site: 'bilibili', + name: 'video', + access: 'read', + description: 'Read one video', + strategy: Strategy.PUBLIC, + domain: 'www.bilibili.com', + browser: true, + args: [ + { name: 'bvid', positional: true, required: true, help: 'Video id' }, + { name: 'with-comments', type: 'boolean', default: false, help: 'Include comments' }, + ], + columns: ['title', 'url'], + }); + + const program = createProgram('', ''); + const site = program.commands.find(cmd => cmd.name() === 'bilibili'); + const command = site!.commands.find(cmd => cmd.name() === 'video'); + expect(command).toBeTruthy(); + process.argv = ['node', 'opencli', 'bilibili', 'video', '--help', '-f', 'yaml']; + const data = yaml.load(command!.helpInformation()) as any; + + expect(data.usage).toBe('opencli bilibili video [options]'); + expect(data.browser).toBe(true); + expect(data.domain).toBe('www.bilibili.com'); + expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]); + expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]); + expect(data.common_options.map((option: any) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']); + expect(data.columns).toEqual(['title', 'url']); + expect(data).not.toHaveProperty('args'); } finally { process.argv = argv; registry.clear(); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index df489d0e1..f841d381b 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -14,13 +14,17 @@ import { Command } from 'commander'; import { log } from './logger.js'; import yaml from 'js-yaml'; import { type CliCommand, fullName, getRegistry } from './registry.js'; -import { formatRegistryHelpText } from './serialization.js'; import { render as renderOutput } from './output.js'; import { executeCommand, prepareCommandArgs } from './execution.js'; import { commandHelpData, + formatCommandHelpText, + formatCommandListTerm, formatSiteCommandDescription, + formatSiteHelpText, + getRequestedHelpFormat, installStructuredHelp, + renderStructuredHelp, siteHelpData, } from './help.js'; import { @@ -58,7 +62,15 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi .option('--trace ', 'Trace capture: off, on, retain-on-failure', 'off') .option('-v, --verbose', 'Debug output', false); - installStructuredHelp(subCmd, () => commandHelpData(cmd), () => formatRegistryHelpText(cmd)); + const originalHelpInformation = subCmd.helpInformation.bind(subCmd); + subCmd.helpInformation = ((contextOptions?: unknown) => { + const format = getRequestedHelpFormat(); + if (format) return renderStructuredHelp(commandHelpData(cmd), format); + // Keep a fallback reference so future Commander upgrades still initialize + // internal help state before we render the cleaner grouped command help. + void originalHelpInformation(contextOptions as never); + return formatCommandHelpText(cmd); + }) as Command['helpInformation']; subCmd.action(async (...actionArgs: unknown[]) => { const actionOpts = actionArgs[positionalArgs.length] ?? {}; @@ -192,7 +204,17 @@ export function registerAllCommands( for (const cmd of commands) { registerCommandToProgram(siteCmd, cmd); } - installStructuredHelp(siteCmd, () => siteHelpData(site, commands)); + const commandTerms = new Map(commands.map(cmd => [cmd.name, formatCommandListTerm(cmd)])); + siteCmd.configureHelp({ + subcommandTerm: command => commandTerms.get(command.name()) ?? command.name(), + }); + const originalSiteHelpInformation = siteCmd.helpInformation.bind(siteCmd); + siteCmd.helpInformation = ((contextOptions?: unknown) => { + const format = getRequestedHelpFormat(); + if (format) return renderStructuredHelp(siteHelpData(site, commands), format); + void originalSiteHelpInformation(contextOptions as never); + return formatSiteHelpText(site, commands); + }) as Command['helpInformation']; } return [...commandsBySite.keys()].sort((a, b) => a.localeCompare(b)); } diff --git a/src/help.ts b/src/help.ts index 6f2fafdb0..e4492eb71 100644 --- a/src/help.ts +++ b/src/help.ts @@ -6,6 +6,34 @@ import { formatCommandExample } from './serialization.js'; export type StructuredHelpFormat = 'yaml' | 'json'; +const COMMON_OPTIONS = [ + { + flags: '-f, --format ', + name: 'format', + help: 'Output format: table, plain, json, yaml, md, csv', + default: 'table', + choices: ['table', 'plain', 'json', 'yaml', 'md', 'csv'], + }, + { + flags: '--trace ', + name: 'trace', + help: 'Trace capture: off, on, retain-on-failure', + default: 'off', + choices: ['off', 'on', 'retain-on-failure'], + }, + { + flags: '-v, --verbose', + name: 'verbose', + help: 'Debug output', + default: false, + }, + { + flags: '-h, --help', + name: 'help', + help: 'display help for command', + }, +] as const; + function normalizeStructuredHelpFormat(value: string | undefined): StructuredHelpFormat | undefined { const normalized = value?.toLowerCase(); if (normalized === 'yaml' || normalized === 'yml') return 'yaml'; @@ -102,7 +130,7 @@ export function formatRootAdapterHelpText(groups: RootAdapterGroups): string { lines.push(...formatGroupSection('App adapters', groups.apps)); lines.push(...formatGroupSection('Site adapters', groups.sites)); lines.push("Run 'opencli list' for full command details, or 'opencli --help' to inspect one site."); - lines.push("Agent tip: use 'opencli --help -f yaml' for structured commands, args, access, and examples."); + lines.push("Agent tip: use 'opencli --help -f yaml' for all command args/options in one structured response."); lines.push(''); return lines.join('\n'); } @@ -120,18 +148,62 @@ function compactArg(arg: Arg): Record { }; } -function compactCommand(cmd: CliCommand, opts: { includeColumns?: boolean } = {}): Record { +function compactCommonOption(option: typeof COMMON_OPTIONS[number]): Record { + return { + name: option.name, + flags: option.flags, + help: option.help, + ...('default' in option ? { default: option.default } : {}), + ...('choices' in option ? { choices: option.choices } : {}), + }; +} + +function positionals(cmd: CliCommand): Arg[] { + return cmd.args.filter(arg => arg.positional); +} + +function commandOptions(cmd: CliCommand): Arg[] { + return cmd.args.filter(arg => !arg.positional); +} + +function formatPositionals(args: readonly Arg[]): string { + return args + .map(arg => arg.required ? `<${arg.name}>` : `[${arg.name}]`) + .join(' '); +} + +function formatCommandOptionTerm(arg: Arg): string { + if (arg.required || arg.valueRequired) return `--${arg.name} `; + return `--${arg.name} [value]`; +} + +export function formatCommandListTerm(cmd: CliCommand): string { + const positionalText = formatPositionals(positionals(cmd)); + const optionText = commandOptions(cmd).length > 0 ? ' [options]' : ''; + return `${cmd.name}${positionalText ? ` ${positionalText}` : ''}${optionText}`; +} + +function formatUsage(cmd: CliCommand): string { + const positionalText = formatPositionals(positionals(cmd)); + return `opencli ${cmd.site} ${cmd.name}${positionalText ? ` ${positionalText}` : ''} [options]`; +} + +function compactCommand(cmd: CliCommand): Record { return { name: cmd.name, command: `opencli ${cmd.site} ${cmd.name}`, + usage: formatUsage(cmd), access: cmd.access, description: cmd.description, + browser: !!cmd.browser, + ...(cmd.domain ? { domain: cmd.domain } : {}), ...(cmd.aliases?.length ? { aliases: cmd.aliases } : {}), - args: cmd.args.map(compactArg), + positionals: positionals(cmd).map(compactArg), + command_options: commandOptions(cmd).map(compactArg), example: formatCommandExample(cmd), ...(cmd.browserSession ? { browserSession: cmd.browserSession } : {}), ...(cmd.defaultFormat ? { defaultFormat: cmd.defaultFormat } : {}), - ...(opts.includeColumns && cmd.columns?.length ? { columns: cmd.columns } : {}), + ...(cmd.columns?.length ? { columns: cmd.columns } : {}), }; } @@ -176,6 +248,7 @@ export function siteHelpData(site: string, commands: readonly CliCommand[]): Rec site, command_count: unique.length, commands: unique.map(cmd => compactCommand(cmd)), + common_options: COMMON_OPTIONS.map(compactCommonOption), next: [ `opencli ${site} --help -f yaml`, `opencli ${site} -f yaml`, @@ -186,11 +259,95 @@ export function siteHelpData(site: string, commands: readonly CliCommand[]): Rec export function commandHelpData(cmd: CliCommand): Record { return { site: cmd.site, - ...compactCommand(cmd, { includeColumns: true }), + ...compactCommand(cmd), + common_options: COMMON_OPTIONS.map(compactCommonOption), output_formats: ['table', 'plain', 'yaml', 'json', 'md', 'csv'], }; } +function formatRows(rows: readonly [string, string][]): string[] { + if (rows.length === 0) return []; + const width = Math.min(Math.max(...rows.map(([left]) => left.length)), 34); + return rows.map(([left, right]) => ` ${left.padEnd(width + 2)}${right}`); +} + +function formatArgHelp(arg: Arg): string { + const parts: string[] = []; + if (arg.help) parts.push(arg.help); + if (arg.default !== undefined) parts.push(`default: ${arg.default}`); + if (arg.choices?.length) parts.push(`choices: ${arg.choices.join(', ')}`); + return parts.join(' '); +} + +export function formatCommonOptionsHelpText(): string { + const rows = COMMON_OPTIONS.map(option => { + const details: string[] = [option.help]; + if ('default' in option) details.push(`default: ${option.default}`); + if ('choices' in option) details.push(`choices: ${option.choices.join(', ')}`); + return [option.flags, details.join(' ')] as [string, string]; + }); + return ['Common options:', ...formatRows(rows)].join('\n'); +} + +export function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string { + const unique = [...new Map(commands.map(cmd => [fullName(cmd), cmd])).values()] + .sort((a, b) => a.name.localeCompare(b.name)); + const lines: string[] = [ + `Usage: opencli ${site} [args] [options]`, + '', + wrapCommaList(unique.map(cmd => cmd.name), { indent: '' }), + '', + 'Commands:', + ...formatRows(unique.map(cmd => [formatCommandListTerm(cmd), formatSiteCommandDescription(cmd)])), + '', + formatCommonOptionsHelpText(), + '', + `Agent tip: use 'opencli ${site} --help -f yaml' to get all command args/options in one structured response.`, + '', + ]; + return lines.join('\n'); +} + +export function formatCommandHelpText(cmd: CliCommand): string { + const lines: string[] = [ + `Usage: ${formatUsage(cmd)}`, + '', + cmd.description, + '', + ]; + + const positionalRows = positionals(cmd).map(arg => [ + arg.name, + formatArgHelp(arg), + ] as [string, string]); + if (positionalRows.length) { + lines.push('Arguments:', ...formatRows(positionalRows), ''); + } + + const optionRows = commandOptions(cmd).map(arg => [ + formatCommandOptionTerm(arg), + formatArgHelp(arg), + ] as [string, string]); + if (optionRows.length) { + lines.push('Command options:', ...formatRows(optionRows), ''); + } + + lines.push(formatCommonOptionsHelpText(), ''); + + const meta: string[] = []; + meta.push(`Access: ${cmd.access}`); + meta.push(`Browser: ${cmd.browser ? 'yes' : 'no'}`); + if (cmd.domain) meta.push(`Domain: ${cmd.domain}`); + if (cmd.defaultFormat) meta.push(`Default format: ${cmd.defaultFormat}`); + if (cmd.aliases?.length) meta.push(`Aliases: ${cmd.aliases.join(', ')}`); + lines.push(meta.join(' | ')); + lines.push(`Example: ${formatCommandExample(cmd)}`); + if (cmd.columns?.length) lines.push(`Output columns: ${cmd.columns.join(', ')}`); + lines.push("Agent tip: use '--help -f yaml' for structured args/options."); + lines.push(''); + return lines.join('\n'); +} + export function installStructuredHelp( command: Command, data: () => unknown,