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
16 changes: 8 additions & 8 deletions src/builtin-command-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export function configureListCommandSurface(command: Command): Command {

/** Configure completion grammar shared by the local and hosted runtimes. */
export function configureCompletionCommandSurface(command: Command): Command {
return command
return addOutputFormatOption(command
.description(COMPLETION_COMMAND_DESCRIPTION)
.argument('<shell>', COMPLETION_SHELL_DESCRIPTION);
.argument('<shell>', COMPLETION_SHELL_DESCRIPTION));
}

/** Configure plugin marketplace search grammar shared by local and hosted runtimes. */
Expand All @@ -29,10 +29,10 @@ export function configurePluginSearchSurface(command: Command): Command {

/** Configure plugin installation grammar shared by local and hosted runtimes. */
export function configurePluginInstallSurface(command: Command): Command {
return command
return addOutputFormatOption(command
.description('Install a plugin from a git repository')
.argument('<source>', 'Plugin source (e.g. github:user/repo/<plugin>)')
.option('--all', 'Install every plugin from a monorepo root');
.option('--all', 'Install every plugin from a monorepo root'));
}

/** Configure installed-plugin listing grammar shared by local and hosted runtimes. */
Expand All @@ -42,15 +42,15 @@ export function configurePluginListSurface(command: Command): Command {

/** Configure plugin uninstall grammar shared by local and hosted runtimes. */
export function configurePluginUninstallSurface(command: Command): Command {
return command
return addOutputFormatOption(command
.description('Uninstall a plugin')
.argument('<name>', 'Installed plugin name');
.argument('<name>', 'Installed plugin name'));
}

/** Configure plugin update grammar shared by local and hosted runtimes. */
export function configurePluginUpdateSurface(command: Command): Command {
return command
return addOutputFormatOption(command
.description('Update a plugin (or all plugins) to the latest version')
.argument('[name]', 'Installed plugin name')
.option('--all', 'Update all installed plugins');
.option('--all', 'Update all installed plugins'));
}
75 changes: 75 additions & 0 deletions src/cli-format-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { Command } from 'commander';
import { createProgram } from './cli.js';
import { getRegistry } from './registry.js';

/**
* One output-format grammar for the whole CLI.
*
* Agents learn `--json` on one command and reuse it everywhere; when half the
* tree rejected it they burned turns guessing. Walking the finished Commander
* tree keeps a command added later from quietly reintroducing the split.
*/
function walk(command: Command, path: string[] = []): { path: string; command: Command }[] {
return command.commands.flatMap((child) => {
const childPath = [...path, child.name()];
return [{ path: childPath.join(' '), command: child }, ...walk(child, childPath)];
});
}

function flagsOf(command: Command): Set<string> {
const flags = new Set<string>();
for (const option of command.options) {
if (option.short) flags.add(option.short);
if (option.long) flags.add(option.long);
}
return flags;
}

describe('output format contract', () => {
const commands = walk(createProgram('', ''));

it('registers commands to check', () => {
expect(commands.length).toBeGreaterThan(30);
});

it('accepts -f/--format and --json on every command', () => {
const missing = commands
.filter(({ command }) => {
// Namespace commands only print help; external CLI passthrough forwards
// argv untouched to the wrapped binary.
if (command.commands.length > 0) return false;
if ((command as Command & { _allowUnknownOption?: boolean })._allowUnknownOption === true) return false;
const flags = flagsOf(command);
return !flags.has('--format') || !flags.has('--json') || !flags.has('-f');
})
.map(({ path }) => path);
expect(missing).toEqual([]);
});

it('renders a structured result instead of prose for an action command', async () => {
const key = 'format-contract/search';
const source = path.join(os.tmpdir(), 'webcmd-format-contract.js');
fs.writeFileSync(source, 'export default {};');
getRegistry().set(key, {
site: 'format-contract', name: 'search', access: 'read', description: 'fixture', args: [], source,
} as never);
const logged: string[] = [];
const spy = vi.spyOn(console, 'log').mockImplementation((value: unknown) => { logged.push(String(value)); });
try {
await createProgram('', '').parseAsync(['node', 'webcmd', 'adapter', 'path', key]);
expect(logged).toEqual([source]);

logged.length = 0;
await createProgram('', '').parseAsync(['node', 'webcmd', 'adapter', 'path', key, '--json']);
expect(JSON.parse(logged[0]!)).toEqual({ ok: true, command: key, path: source });
} finally {
spy.mockRestore();
getRegistry().delete(key);
fs.rmSync(source, { force: true });
}
});
});
8 changes: 4 additions & 4 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('site-memory and local adapter authoring', () => {

expect(browserInit.helpInformation()).toContain('Create a new private adapter: webcmd browser init <site>/<command>');
const adapterHelp = adapter.helpInformation();
expect(adapterHelp).toMatch(/Override installed command: webcmd adapter override\s+<site>\/<command>/);
expect(adapterHelp).toMatch(/Override installed command: webcmd adapter\s+override\s+<site>\/<command>/);
expect(adapterHelp).toMatch(/Locate local source: webcmd adapter path\s+<site>\/<command>/);
});

Expand Down Expand Up @@ -1253,7 +1253,7 @@ name: 'search',
usage: 'webcmd browser bind [options]',
positionals: [],
});
expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', 'verbose']);
expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', 'verbose', 'format', 'json']);
expect(data.structured_help).toMatchObject({
formats: ['yaml', 'json'],
usage: 'webcmd browser --help -f yaml',
Expand Down Expand Up @@ -1326,7 +1326,7 @@ name: 'search',
usage: 'webcmd plugin update [name] [options]',
positionals: [{ name: 'name' }],
});
expect(update.command_options.map((option: any) => option.name)).toEqual(['all', 'force']);
expect(update.command_options.map((option: any) => option.name)).toEqual(['all', 'force', 'format', 'json']);
} finally {
process.argv = argv;
}
Expand Down Expand Up @@ -1414,7 +1414,7 @@ name: 'search',
usage: 'webcmd adapter reset [site] [options]',
positionals: [{ name: 'site' }],
});
expect(reset.command_options.map((option: any) => option.name)).toEqual(['all']);
expect(reset.command_options.map((option: any) => option.name)).toEqual(['all', 'format', 'json']);
} finally {
process.argv = argv;
}
Expand Down
Loading
Loading