Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/command-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { Strategy, type CliCommand } from './registry.js';
import {
commandHelpData,
commandListPresentation,
commandListRows,
filterCommandsByTag,
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 20 additions & 10 deletions src/command-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,29 +586,35 @@ export function commandHelpData(command: PresentableCommand): Record<string, unk
return {
site: command.site,
...compactCommand(command),
common_options: COMMON_OPTIONS.map(compactCommonOption),
...(command.browser ? { browser_common_options: BROWSER_COMMON_OPTIONS.map(compactCommonOption) } : {}),
common_options: unshadowedOptions(COMMON_OPTIONS, command).map(compactCommonOption),
...(command.browser ? { browser_common_options: unshadowedOptions(BROWSER_COMMON_OPTIONS, command).map(compactCommonOption) } : {}),
output_formats: ['table', 'plain', 'yaml', 'json', 'md', 'csv'],
};
}

export function formatCommonOptionsHelp(): string {
const rows = COMMON_OPTIONS.map((option) => {
function unshadowedOptions<T extends { name: string }>(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 {
Expand Down Expand Up @@ -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}`,
Expand Down
23 changes: 23 additions & 0 deletions src/command-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
66 changes: 55 additions & 11 deletions src/command-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,15 +232,21 @@ export function configureCommandSurface(command: Command, metadata: CommandSurfa
else command.option(flag, arg.help ?? '');
}

addOutputFormatOption(command)
.option('--trace <mode>', `Trace capture: ${TRACE_MODES.join(', ')}`, 'off')
.option('-v, --verbose', 'Debug output', false);
addSharedExecutionOptions(command);

if (metadata.browser) {
command
.option('--window <mode>', `Browser window mode: ${BROWSER_WINDOW_MODES.join(' or ')} (default: background)`)
.option('--site-session <mode>', `Adapter site session lifecycle: ${SITE_SESSION_MODES.join(' or ')}`)
.option('--keep-tab <bool>', 'Keep the browser tab lease after the command finishes');
addSharedOption(command, '--window', () => command.option(
'--window <mode>',
`Browser window mode: ${BROWSER_WINDOW_MODES.join(' or ')} (default: background)`,
));
addSharedOption(command, '--site-session', () => command.option(
'--site-session <mode>',
`Adapter site session lifecycle: ${SITE_SESSION_MODES.join(' or ')}`,
));
addSharedOption(command, '--keep-tab', () => command.option(
'--keep-tab <bool>',
'Keep the browser tab lease after the command finishes',
));
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -455,6 +461,41 @@ export function addOutputFormatOption(command: Command, defaultFormat = 'table')
.option('--json', JSON_FORMAT_ALIAS_HELP, false);
}

type CommandWithSharedOptions = Command & { _webcmdSharedOptions?: Set<string> };

function addSharedExecutionOptions(command: Command): void {
const flags = command.options.flatMap(option => [option.short, option.long]).filter(Boolean) as string[];
const shared = new Set<string>();
(command as CommandWithSharedOptions)._webcmdSharedOptions = shared;

if (!flags.includes('--format')) {
command.option(flags.includes('-f') ? '--format <fmt>' : '-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
shared.add('--format');
}
addSharedOption(command, '--json', () => command.option('--json', JSON_FORMAT_ALIAS_HELP, false));
addSharedOption(command, '--trace', () => command.option('--trace <mode>', `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.
*
Expand Down Expand Up @@ -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;
}
Expand Down
17 changes: 10 additions & 7 deletions src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> : {};
Expand All @@ -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;
Expand Down
Loading