diff --git a/src/command-presentation.test.ts b/src/command-presentation.test.ts index 29eee224b..bc09e3cbb 100644 --- a/src/command-presentation.test.ts +++ b/src/command-presentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Strategy, type CliCommand } from './registry.js'; import { + commandHelpData, commandListPresentation, commandListRows, filterCommandsByTag, @@ -184,6 +185,33 @@ describe('shared command presentation', () => { ); }); + it('shows adapter-owned flags only with their adapter meanings', () => { + const command = toPresentableCommand({ + ...localCommand, + args: [ + { name: 'json', type: 'bool', help: 'Adapter JSON' }, + { name: 'trace', type: 'string', help: 'Adapter trace' }, + { name: 'window', type: 'string', help: 'Adapter window' }, + ], + }); + const help = formatCommandHelp(command); + const data = commandHelpData(command) as { + common_options: Array<{ name: string }>; + browser_common_options: Array<{ name: string }>; + }; + + expect(help).toContain('Adapter JSON'); + expect(help).toContain('Adapter trace'); + expect(help).toContain('Adapter window'); + expect(help).not.toContain('Alias of --format json'); + expect(help).not.toContain('Trace capture:'); + expect(help).not.toContain('Browser window mode:'); + expect(data.common_options.map(option => option.name)).not.toContain('json'); + expect(data.common_options.map(option => option.name)).not.toContain('trace'); + expect(data.browser_common_options.map(option => option.name)).not.toContain('window'); + expect(data.common_options.map(option => option.name)).toContain('format'); + }); + it('builds byte-identical structured and display list rows', () => { const local = toPresentableCommand(localCommand); const hosted = toPresentableCommand(hostedCommand); diff --git a/src/command-presentation.ts b/src/command-presentation.ts index 5b5916e93..fb1d427a4 100644 --- a/src/command-presentation.ts +++ b/src/command-presentation.ts @@ -586,29 +586,35 @@ export function commandHelpData(command: PresentableCommand): Record { +function unshadowedOptions(options: readonly T[], command?: PresentableCommand): T[] { + if (!command) return [...options]; + const owned = new Set(commandOptions(command).map(arg => arg.name)); + return options.filter(option => !owned.has(option.name)); +} + +export function formatCommonOptionsHelp(command?: PresentableCommand): string { + const rows = unshadowedOptions(COMMON_OPTIONS, command).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'); + return rows.length ? ['Common options:', ...formatRows(rows)].join('\n') : ''; } -export function formatBrowserCommonOptionsHelp(): string { - const rows = BROWSER_COMMON_OPTIONS.map((option) => { +export function formatBrowserCommonOptionsHelp(command?: PresentableCommand): string { + const rows = unshadowedOptions(BROWSER_COMMON_OPTIONS, command).map((option) => { const details: string[] = [option.help]; if ('choices' in option) details.push(`choices: ${option.choices.join(', ')}`); return [option.flags, details.join(' ')] as [string, string]; }); - return ['Browser common options:', ...formatRows(rows)].join('\n'); + return rows.length ? ['Browser common options:', ...formatRows(rows)].join('\n') : ''; } export function formatSiteHelp(site: string, commands: readonly PresentableCommand[]): string { @@ -650,8 +656,12 @@ export function formatCommandHelp(command: PresentableCommand): string { ] as [string, string]); if (optionRows.length) lines.push('Command options:', ...formatRows(optionRows), ''); - lines.push(formatCommonOptionsHelp(), ''); - if (command.browser) lines.push(formatBrowserCommonOptionsHelp(), ''); + const commonOptions = formatCommonOptionsHelp(command); + if (commonOptions) lines.push(commonOptions, ''); + if (command.browser) { + const browserOptions = formatBrowserCommonOptionsHelp(command); + if (browserOptions) lines.push(browserOptions, ''); + } const meta = [ `Access: ${command.access}`, diff --git a/src/command-surface.test.ts b/src/command-surface.test.ts index 51f337354..65a47d938 100644 --- a/src/command-surface.test.ts +++ b/src/command-surface.test.ts @@ -219,6 +219,29 @@ describe('configureCommandSurface', () => { ])); expect(command.registeredArguments.map((argument) => argument.name())).toEqual(['query', 'scope']); }); + + it('preserves adapter arguments that collide with shared execution flags', () => { + const command = new Command('search'); + const collisions = ['format', 'json', 'trace', 'verbose', 'window', 'site-session', 'keep-tab']; + configureCommandSurface(command, { + ...metadata, + browser: true, + args: collisions.map(name => ({ name, type: 'string' })), + }); + + expect(command.options.map(option => option.long)).toEqual(collisions.map(name => `--${name}`)); + expect(parseCommandSurface({ + ...metadata, + browser: true, + args: [{ name: 'json', type: 'bool', default: false }], + }, ['--json'])).toMatchObject({ + args: { json: true }, + format: 'plain', + formatExplicit: false, + trace: 'off', + verbose: false, + }); + }); }); describe('unknown option contract', () => { diff --git a/src/command-surface.ts b/src/command-surface.ts index e71cbd5b6..d22794846 100644 --- a/src/command-surface.ts +++ b/src/command-surface.ts @@ -232,15 +232,21 @@ export function configureCommandSurface(command: Command, metadata: CommandSurfa else command.option(flag, arg.help ?? ''); } - addOutputFormatOption(command) - .option('--trace ', `Trace capture: ${TRACE_MODES.join(', ')}`, 'off') - .option('-v, --verbose', 'Debug output', false); + addSharedExecutionOptions(command); if (metadata.browser) { - command - .option('--window ', `Browser window mode: ${BROWSER_WINDOW_MODES.join(' or ')} (default: background)`) - .option('--site-session ', `Adapter site session lifecycle: ${SITE_SESSION_MODES.join(' or ')}`) - .option('--keep-tab ', 'Keep the browser tab lease after the command finishes'); + addSharedOption(command, '--window', () => command.option( + '--window ', + `Browser window mode: ${BROWSER_WINDOW_MODES.join(' or ')} (default: background)`, + )); + addSharedOption(command, '--site-session', () => command.option( + '--site-session ', + `Adapter site session lifecycle: ${SITE_SESSION_MODES.join(' or ')}`, + )); + addSharedOption(command, '--keep-tab', () => command.option( + '--keep-tab ', + 'Keep the browser tab lease after the command finishes', + )); } } @@ -324,8 +330,8 @@ export function parseCommandSurface( const args = coerceCommandArguments(metadata.args, input); const formatExplicit = outputFormatIsExplicit(command); const format = parseOutputFormat(formatExplicit ? requestedOutputFormat(command, parsedOptions.format) : defaultFormat); - const trace = parseTraceMode(parsedOptions.trace ?? 'off'); - const verbose = parsedOptions.verbose === true; + const trace = isSharedCommandOption(command, '--trace') ? parseTraceMode(parsedOptions.trace ?? 'off') : 'off'; + const verbose = isSharedCommandOption(command, '--verbose') && parsedOptions.verbose === true; return { args, @@ -455,6 +461,41 @@ export function addOutputFormatOption(command: Command, defaultFormat = 'table') .option('--json', JSON_FORMAT_ALIAS_HELP, false); } +type CommandWithSharedOptions = Command & { _webcmdSharedOptions?: Set }; + +function addSharedExecutionOptions(command: Command): void { + const flags = command.options.flatMap(option => [option.short, option.long]).filter(Boolean) as string[]; + const shared = new Set(); + (command as CommandWithSharedOptions)._webcmdSharedOptions = shared; + + if (!flags.includes('--format')) { + command.option(flags.includes('-f') ? '--format ' : '-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + shared.add('--format'); + } + addSharedOption(command, '--json', () => command.option('--json', JSON_FORMAT_ALIAS_HELP, false)); + addSharedOption(command, '--trace', () => command.option('--trace ', `Trace capture: ${TRACE_MODES.join(', ')}`, 'off')); + if (!command.options.some(option => option.long === '--verbose')) { + command.option( + command.options.some(option => option.short === '-v') ? '--verbose' : '-v, --verbose', + 'Debug output', + false, + ); + shared.add('--verbose'); + } +} + +function addSharedOption(command: Command, flag: string, register: () => void): void { + if (command.options.some(option => option.short === flag || option.long === flag)) return; + register(); + ((command as CommandWithSharedOptions)._webcmdSharedOptions ??= new Set()).add(flag); +} + +/** Whether an option belongs to webcmd rather than to the adapter grammar. */ +export function isSharedCommandOption(command: Command, flag: string): boolean { + const shared = (command as CommandWithSharedOptions)._webcmdSharedOptions; + return shared ? shared.has(flag) : command.options.some(option => option.short === flag || option.long === flag); +} + /** * Give every command in a tree the same output-format grammar. * @@ -487,12 +528,15 @@ export function ensureOutputFormatOptions(command: Command): void { } export function outputFormatIsExplicit(command: Command): boolean { - return command.getOptionValueSource('format') === 'cli' || command.getOptionValueSource('json') === 'cli'; + return (isSharedCommandOption(command, '--format') && command.getOptionValueSource('format') === 'cli') + || (isSharedCommandOption(command, '--json') && command.getOptionValueSource('json') === 'cli'); } /** Resolve `--json` onto `--format json` unless `--format` was also passed. */ export function requestedOutputFormat(command: Command, format: unknown): unknown { - return command.getOptionValueSource('json') === 'cli' && command.getOptionValueSource('format') !== 'cli' + return isSharedCommandOption(command, '--json') + && command.getOptionValueSource('json') === 'cli' + && command.getOptionValueSource('format') !== 'cli' ? 'json' : format; } diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index d2e252d4f..f98d8736e 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -14,7 +14,7 @@ import { Command } from 'commander'; import { log } from './logger.js'; import { type CliCommand, fullName, getRegistry } from './registry.js'; import { errorEnvelopeFormat, formatErrorEnvelope, render as renderOutput } from './output.js'; -import { configureCommandSurface, outputFormatIsExplicit, parseOutputFormat, prepareCommandArgs, requestedOutputFormat } from './command-surface.js'; +import { configureCommandSurface, isSharedCommandOption, outputFormatIsExplicit, parseOutputFormat, prepareCommandArgs, requestedOutputFormat } from './command-surface.js'; import { commandHelpData, formatCommandHelpText, @@ -95,8 +95,11 @@ export function registerCommandToProgram( } const kwargs = prepareCommandArgs(cmd, rawKwargs); - const verbose = optionsRecord.verbose === true; - let format = parseOutputFormat(requestedOutputFormat(subCmd, optionsRecord.format ?? 'table')); + const verbose = isSharedCommandOption(subCmd, '--verbose') && optionsRecord.verbose === true; + let format = parseOutputFormat(requestedOutputFormat( + subCmd, + isSharedCommandOption(subCmd, '--format') ? optionsRecord.format ?? 'table' : 'table', + )); const formatExplicit = outputFormatIsExplicit(subCmd); if (verbose) process.env.WEBCMD_VERBOSE = '1'; const globals = typeof subCmd.optsWithGlobals === 'function' ? subCmd.optsWithGlobals() as Record : {}; @@ -106,10 +109,10 @@ export function registerCommandToProgram( prepared: true, ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}), ...(typeof globals.session === 'string' && globals.session.trim() ? { session: globals.session.trim() } : {}), - ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}), - ...(cmd.browser && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}), - ...(cmd.browser && typeof optionsRecord.siteSession === 'string' ? { siteSession: optionsRecord.siteSession } : {}), - ...(cmd.browser && typeof optionsRecord.keepTab === 'string' ? { keepTab: optionsRecord.keepTab } : {}), + ...(isSharedCommandOption(subCmd, '--trace') && typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}), + ...(cmd.browser && isSharedCommandOption(subCmd, '--window') && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}), + ...(cmd.browser && isSharedCommandOption(subCmd, '--site-session') && typeof optionsRecord.siteSession === 'string' ? { siteSession: optionsRecord.siteSession } : {}), + ...(cmd.browser && isSharedCommandOption(subCmd, '--keep-tab') && typeof optionsRecord.keepTab === 'string' ? { keepTab: optionsRecord.keepTab } : {}), }); if (result === null || result === undefined) { return;