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;