diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 4073a61d..8a7ed93c 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -77,6 +77,7 @@ The old `web read` command has been renamed to `web fetch-browser`. ```bash webcmd hackernews top -f table +webcmd hackernews top -f plain webcmd hackernews top -f json webcmd hackernews top -f yaml webcmd hackernews top -f md @@ -131,10 +132,16 @@ webcmd plugin install github:agentrhq/webcmd/plugins/ycombinator Hosted mode supports the same `plugin search` and `plugin install` grammar for Webcmd-verified marketplace adapters. Other plugin management commands remain local-only in hosted mode. +`plugin list`, `plugin search`, and the `plugin catalog` commands honor the universal `-f/--format` flag from the [Output Formats](#output-formats) section. For example, `webcmd plugin list -f json` returns a JSON array of installed plugins (or `[]` when none are installed) and `-f yaml` emits YAML. Unknown format names are rejected with a usage error. + +Note: `plugin list` always renders the human-friendly grouped listing when the effective format is `table` — whether that is the default or an explicit `-f table`, and regardless of TTY — so script-like automation can rely on `-f json` or `-f yaml` for machine-readable output. + ## External CLIs Agents can expose local tools through Webcmd, such as `gh`, `docker`, `vercel`, or internal CLIs. +`external list` honors the universal `-f/--format` flag (for example `webcmd external list -f json` or `-f yaml`). Without an explicit `-f`, it emits a table on a TTY and YAML when output is not a TTY. + Prompt example: ```text diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index da259239..c5ee5e69 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -117,6 +117,7 @@ Command-specific flags such as `--limit`, `--tab`, and `--filter` are not univer - `yaml`: default when output is not a TTY and `-f` is not explicit. - `table`: color-coded and grouped for humans. - `md`, `csv`: tabular dumps. +- Unsupported format names are rejected with a usage error; `yml` and `markdown` are accepted aliases for `yaml` and `md`. Some commands override the default through `cmd.defaultFormat`; read `--help`. @@ -191,7 +192,7 @@ webcmd plugin catalog add webcmd plugin catalog remove ``` -Plugins are installable extensions pulled from git or local paths. Use `plugin search` for marketplace discovery and `plugin list` for already-installed plugins. Main-repo community CLIs are exposed through the root plugin catalog manifest, not bundled into npm's `clis/` set. +Plugins are installable extensions pulled from git or local paths. Use `plugin search` for marketplace discovery and `plugin list` for already-installed plugins. `webcmd plugin list -f json` returns an empty array `[]` when no plugins are installed. Note: `plugin list` renders its human-friendly grouped listing whenever the effective format is `table` (default or explicit, TTY or non-TTY); use `-f json` or `-f yaml` for machine-readable output. Main-repo community CLIs are exposed through the root plugin catalog manifest, not bundled into npm's `clis/` set. > **Note:** The repository's `plugins/` directory is not shipped in the npm package. Find the required plugin with `webcmd plugin search`, then install its `installSource` with `webcmd plugin install `. diff --git a/src/builtin-command-surface.ts b/src/builtin-command-surface.ts index dde75938..7772fa9f 100644 --- a/src/builtin-command-surface.ts +++ b/src/builtin-command-surface.ts @@ -1,7 +1,8 @@ +import { OUTPUT_FORMAT_HELP } from './command-surface.js'; import type { Command } from 'commander'; export const LIST_COMMAND_DESCRIPTION = 'List all available CLI commands'; -export const LIST_FORMAT_DESCRIPTION = 'Output format: table, json, yaml, md, csv'; +export const LIST_FORMAT_DESCRIPTION = OUTPUT_FORMAT_HELP; export const COMPLETION_COMMAND_DESCRIPTION = 'Output shell completion script'; export const COMPLETION_SHELL_DESCRIPTION = 'Shell type: bash, zsh, or fish'; @@ -25,7 +26,7 @@ export function configurePluginSearchSurface(command: Command): Command { return command .description('Search installable marketplace plugins') .argument('[query]', 'Search query matched against plugin name and description') - .option('-f, --format ', 'Output format: table, json', 'table'); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); } /** Configure plugin installation grammar shared by local and hosted runtimes. */ diff --git a/src/cli.test.ts b/src/cli.test.ts index 6b98a601..832497bd 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -14,6 +14,7 @@ import { formatRootHelp, toPresentableCommand, } from './command-presentation.js'; +import { parseOutputFormat } from './command-surface.js'; import { render as renderOutput } from './output.js'; const { @@ -366,7 +367,7 @@ name: 'search', } }); - it.each(['json', 'yaml', 'yml'])( + it.each(['json', 'yaml', 'yml', 'md', 'csv', 'plain'])( 'renders local list %s through the shared list presentation', async (format) => { const registry = getRegistry(); @@ -384,7 +385,8 @@ name: 'search', args: [{ name: 'limit', type: 'int', default: 20, help: 'Maximum issues' }], columns: ['number', 'title'], }); - const presentation = commandListPresentation([toPresentableCommand(command)], format); + const normalized = parseOutputFormat(format); + const presentation = commandListPresentation([toPresentableCommand(command)], normalized); const outputSpy = vi.mocked(console.log); outputSpy.mockClear(); @@ -394,7 +396,7 @@ name: 'search', outputSpy.mockClear(); renderOutput(presentation.rows, { - fmt: format, + fmt: normalized, columns: presentation.columns, title: 'webcmd/list', source: 'webcmd list', @@ -3702,3 +3704,165 @@ describe('renderVerifyPreview', () => { expect(out).not.toContain('xxxxxxxxxxx'); // never 11 consecutive }); }); + +describe('output format normalization across builtin command families', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + process.exitCode = undefined; + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.exitCode = undefined; + stdoutSpy.mockClear(); + stderrSpy.mockClear(); + }); + + async function run(...args: string[]) { + stdoutSpy.mockClear(); + stderrSpy.mockClear(); + await createProgram('', '').parseAsync(['node', 'webcmd', ...args]); + return { + stdout: stdoutSpy.mock.calls.flat().join('\n'), + stderr: stderrSpy.mock.calls.flat().join('\n'), + exitCode: process.exitCode, + }; + } + + it('skills list renders YAML implicitly on non-TTY and stays a table with explicit -f table', async () => { + const implicit = await run('skills', 'list'); + expect(implicit.exitCode).toBeUndefined(); + const rows = yaml.load(implicit.stdout) as Array>; + expect(Array.isArray(rows)).toBe(true); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]).toHaveProperty('name'); + + const explicit = await run('skills', 'list', '-f', 'table'); + expect(explicit.exitCode).toBeUndefined(); + expect(explicit.stdout).toContain('webcmd/skills/list'); + expect(explicit.stdout).toContain('items | webcmd skills list'); + }); + + it('skills list -f json emits a JSON array', async () => { + const { stdout, exitCode } = await run('skills', 'list', '-f', 'json'); + expect(exitCode).toBeUndefined(); + const rows = JSON.parse(stdout); + expect(Array.isArray(rows)).toBe(true); + expect(rows[0]).toHaveProperty('name'); + }); + + it.each(['xml', 'jsonl'])('skills list rejects the unsupported %s format with a usage error', async (format) => { + const { stderr, exitCode } = await run('skills', 'list', '-f', format); + expect(exitCode).toBe(2); + expect(stderr).toContain(`Unknown output format "${format}"`); + expect(stderr).toContain('Supported formats: table, plain, json, yaml, md, csv'); + }); + + it('plugin list -f json emits valid JSON and -f yaml emits valid YAML', async () => { + const json = await run('plugin', 'list', '-f', 'json'); + expect(json.exitCode).toBeUndefined(); + expect(Array.isArray(JSON.parse(json.stdout))).toBe(true); + + const yamlOut = await run('plugin', 'list', '-f', 'yaml'); + expect(yamlOut.exitCode).toBeUndefined(); + expect(Array.isArray(yaml.load(yamlOut.stdout))).toBe(true); + }); + + it('plugin list rejects an unsupported format with a usage error', async () => { + const { stderr, exitCode } = await run('plugin', 'list', '-f', 'xml'); + expect(exitCode).toBe(2); + expect(stderr).toContain('Unknown output format "xml"'); + }); + + it('plugin search rejects an unsupported format before any network call', async () => { + const { stderr, exitCode } = await run('plugin', 'search', '-f', 'xml'); + expect(exitCode).toBe(2); + expect(stderr).toContain('Unknown output format "xml"'); + }); + + it('plugin catalog list -f json emits the catalog object and -f yaml the sources', async () => { + const json = await run('plugin', 'catalog', 'list', '-f', 'json'); + expect(json.exitCode).toBeUndefined(); + const catalog = JSON.parse(json.stdout); + expect(Array.isArray(catalog.sources)).toBe(true); + + const yamlOut = await run('plugin', 'catalog', 'list', '-f', 'yaml'); + expect(yamlOut.exitCode).toBeUndefined(); + const sources = yaml.load(yamlOut.stdout) as Array>; + expect(Array.isArray(sources)).toBe(true); + expect(sources[0]).toHaveProperty('id'); + }); + + it('external list renders YAML implicitly on non-TTY and stays a table with explicit -f table', async () => { + const implicit = await run('external', 'list'); + expect(implicit.exitCode).toBeUndefined(); + const rows = yaml.load(implicit.stdout) as Array>; + expect(Array.isArray(rows)).toBe(true); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]).toHaveProperty('name'); + + const explicit = await run('external', 'list', '-f', 'table'); + expect(explicit.exitCode).toBeUndefined(); + expect(explicit.stdout).toContain('items | webcmd external list'); + }); + + it('convention-audit renders structured JSON through the shared renderer', async () => { + const { stdout, exitCode } = await run('convention-audit', '-f', 'json'); + expect(exitCode).toBeUndefined(); + const report = JSON.parse(stdout); + expect(report).toHaveProperty('ok'); + expect(report).toHaveProperty('summary'); + }); + + it('convention-audit rejects an unsupported format with a usage error', async () => { + const { stderr, exitCode } = await run('convention-audit', '-f', 'xml'); + expect(exitCode).toBe(2); + expect(stderr).toContain('Unknown output format "xml"'); + }); + + it.each(['status', 'refresh'] as const)('auth %s validates the output format and renders JSON on -f json', async (subcommand) => { + const unsupported = await run('auth', subcommand, '-f', 'xml'); + expect(unsupported.exitCode).toBe(2); + expect(unsupported.stderr).toContain('Unknown output format "xml"'); + + process.exitCode = undefined; + const json = await run('auth', subcommand, '-f', 'json'); + expect(json.exitCode).toBeUndefined(); + expect(Array.isArray(JSON.parse(json.stdout))).toBe(true); + }); + + it('webcmd list rejects an unsupported format with a usage error', async () => { + const { stderr, exitCode } = await run('list', '-f', 'xml'); + expect(exitCode).toBe(2); + expect(stderr).toContain('Unknown output format "xml"'); + }); + + it('webcmd list and convention-audit accept case-insensitive and aliased formats', async () => { + const yamlList = await run('list', '-f', 'yaml'); + const upperList = await run('list', '-f', 'YAML'); + expect(upperList.exitCode).toBeUndefined(); + expect(upperList.stdout).toBe(yamlList.stdout); + + const audit = await run('convention-audit', '-f', 'YAML'); + expect(audit.exitCode).toBeUndefined(); + expect(yaml.load(audit.stdout)).toMatchObject({ summary: expect.anything() }); + }); + + it('advertises the canonical format list across builtin and shared surfaces', () => { + const program = createProgram('', ''); + const list = program.commands.find(cmd => cmd.name() === 'list')!; + const plugin = program.commands.find(cmd => cmd.name() === 'plugin')!; + const search = plugin.commands.find(cmd => cmd.name() === 'search')!; + const skillsList = program.commands.find(cmd => cmd.name() === 'skills')!.commands.find(cmd => cmd.name() === 'list')!; + + for (const cmd of [list, search, skillsList]) { + const help = cmd.helpInformation(); + expect(help).toContain('Output format: table, plain, json, yaml, md, csv'); + expect(help).not.toContain('markdown'); + expect(help).not.toContain('yml'); + } + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 55237cf5..d726e4ae 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js'; import { type CliCommand, getRegistry } from './registry.js'; import { commandListPresentation, filterCommandsByTag, toPresentableCommand } from './command-presentation.js'; import { configureCompletionCommandSurface, configureListCommandSurface, configurePluginInstallSurface, configurePluginSearchSurface } from './builtin-command-surface.js'; +import { OUTPUT_FORMAT_HELP, resolveOutputFormat } from './command-surface.js'; import { render as renderOutput } from './output.js'; import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; @@ -791,12 +792,14 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command // ── Built-in: list ──────────────────────────────────────────────────────── - configureListCommandSurface(program.command('list')) + const listCmd = configureListCommandSurface(program.command('list')) .action((opts) => { - const externalClis = opts.format === 'table' ? loadExternalClis() : []; + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const externalClis = fmt === 'table' ? loadExternalClis() : []; const presentation = commandListPresentation( filterCommandsByTag([...new Set(getRegistry().values())].map(toPresentableCommand), opts.tag), - opts.format, + fmt, { externalClis: externalClis.map((external) => ({ label: formatExternalCliLabel(external), @@ -810,7 +813,8 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command return; } renderOutput(presentation.rows, { - fmt: opts.format, + fmt, + fmtExplicit: listCmd.getOptionValueSource('format') === 'cli', columns: presentation.columns, title: 'webcmd/list', source: 'webcmd list', @@ -854,20 +858,22 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command }); }); - skillsCmd + const skillsListCmd = skillsCmd .command('list') .description('List bundled Webcmd skills') - .option('-f, --format ', 'Output format: table, json, yaml, md, csv', 'table') - .action((opts) => { - const rows = listWebcmdSkills(); - renderOutput(rows, { - fmt: opts.format, - fmtExplicit: !!opts.format, - columns: ['name', 'description', 'version', 'path'], - title: 'webcmd/skills/list', - source: 'webcmd skills list', - }); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + skillsListCmd.action((opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const rows = listWebcmdSkills(); + renderOutput(rows, { + fmt, + fmtExplicit: skillsListCmd.getOptionValueSource('format') === 'cli', + columns: ['name', 'description', 'version', 'path'], + title: 'webcmd/skills/list', + source: 'webcmd skills list', }); + }); skillsCmd .command('add') @@ -914,28 +920,29 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command const authCmd = registerAuthCommands(program); - program + const conventionAuditCmd = program .command('convention-audit') .description('Scan adapters for agent-native convention violations') .argument('[target]', 'site or site/name') .option('--site ', 'Limit audit to one site') - .option('-f, --format ', 'Output format: table, json, yaml', 'table') - .option('--strict', 'Exit non-zero when violations are found', false) - .action(async (target, opts) => { - const { runConventionAudit, renderConventionAuditText } = await import('./convention-audit.js'); - const report = runConventionAudit({ - projectRoot: findPackageRoot(CLI_FILE), - target, - site: opts.site, - }); - const fmt = String(opts.format ?? 'table').toLowerCase(); - if (fmt === 'json' || fmt === 'yaml' || fmt === 'yml') { - renderOutput(report, { fmt }); - } else { - console.log(renderConventionAuditText(report)); - } - if (opts.strict && !report.ok) process.exitCode = EXIT_CODES.GENERIC_ERROR; + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') + .option('--strict', 'Exit non-zero when violations are found', false); + conventionAuditCmd.action(async (target, opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const { runConventionAudit, renderConventionAuditText } = await import('./convention-audit.js'); + const report = runConventionAudit({ + projectRoot: findPackageRoot(CLI_FILE), + target, + site: opts.site, }); + if (fmt === 'table') { + console.log(renderConventionAuditText(report)); + } else { + renderOutput(report, { fmt }); + } + if (opts.strict && !report.ok) process.exitCode = EXIT_CODES.GENERIC_ERROR; + }); // ── Built-in: browser (browser control for Claude Code skill) ─────────────── // @@ -3163,114 +3170,123 @@ cli({ }); - pluginCmd + const pluginListCmd = pluginCmd .command('list') .description('List installed plugins') - .option('-f, --format ', 'Output format: table, json', 'table') - .action(async (opts) => { - const { listPlugins } = await import('./plugin.js'); - const plugins = listPlugins(); - if (plugins.length === 0) { - console.log(' No plugins installed.'); - console.log(` Install one with: ${CLI_COMMAND} plugin install github:user/repo`); - return; - } - if (opts.format === 'json') { - renderOutput(plugins, { - fmt: 'json', - columns: ['name', 'commands', 'source'], - title: `${CLI_COMMAND}/plugins`, - source: `${CLI_COMMAND} plugin list`, - }); - return; - } - console.log(); - console.log(' Installed plugins'); - console.log(); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + pluginListCmd.action(async (opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const { listPlugins } = await import('./plugin.js'); + const plugins = listPlugins(); + if (fmt !== 'table') { + renderOutput(plugins, { + fmt, + fmtExplicit: pluginListCmd.getOptionValueSource('format') === 'cli', + columns: ['name', 'commands', 'source'], + title: `${CLI_COMMAND}/plugins`, + source: `${CLI_COMMAND} plugin list`, + }); + return; + } + if (plugins.length === 0) { + console.log(' No plugins installed.'); + console.log(` Install one with: ${CLI_COMMAND} plugin install github:user/repo`); + return; + } + console.log(); + console.log(' Installed plugins'); + console.log(); + + // Group by monorepo + const standalone = plugins.filter((p) => !p.monorepoName); + const monoGroups = new Map(); + for (const p of plugins) { + if (!p.monorepoName) continue; + const g = monoGroups.get(p.monorepoName) ?? []; + g.push(p); + monoGroups.set(p.monorepoName, g); + } - // Group by monorepo - const standalone = plugins.filter((p) => !p.monorepoName); - const monoGroups = new Map(); - for (const p of plugins) { - if (!p.monorepoName) continue; - const g = monoGroups.get(p.monorepoName) ?? []; - g.push(p); - monoGroups.set(p.monorepoName, g); - } + for (const p of standalone) { + const version = p.version ? ` @${p.version}` : ''; + const desc = p.description ? ` — ${p.description}` : ''; + const cmds = p.commands.length > 0 ? ` (${p.commands.join(', ')})` : ''; + const src = p.source ? ` ← ${p.source}` : ''; + console.log(` ${p.name}${version}${desc}${cmds}${src}`); + } - for (const p of standalone) { + for (const [mono, group] of monoGroups) { + console.log(); + console.log(` 📦 ${mono}` + ' (monorepo)'); + for (const p of group) { const version = p.version ? ` @${p.version}` : ''; const desc = p.description ? ` — ${p.description}` : ''; const cmds = p.commands.length > 0 ? ` (${p.commands.join(', ')})` : ''; - const src = p.source ? ` ← ${p.source}` : ''; - console.log(` ${p.name}${version}${desc}${cmds}${src}`); - } - - for (const [mono, group] of monoGroups) { - console.log(); - console.log(` 📦 ${mono}` + ' (monorepo)'); - for (const p of group) { - const version = p.version ? ` @${p.version}` : ''; - const desc = p.description ? ` — ${p.description}` : ''; - const cmds = p.commands.length > 0 ? ` (${p.commands.join(', ')})` : ''; - console.log(` ${p.name}${version}${desc}${cmds}`); - } + console.log(` ${p.name}${version}${desc}${cmds}`); } + } - console.log(); - console.log(` ${plugins.length} plugin(s) installed`); - console.log(); - }); + console.log(); + console.log(` ${plugins.length} plugin(s) installed`); + console.log(); + }); const catalogCmd = pluginCmd .command('catalog') .description('Manage plugin marketplace sources'); - catalogCmd + const catalogListCmd = catalogCmd .command('list') .description('List configured plugin marketplace sources') - .option('-f, --format ', 'Output format: table, json', 'table') - .action(async (opts: { format?: string }) => { - const { readCatalog } = await import('./plugin-catalog.js'); - try { - const catalog = readCatalog(); - if (opts.format === 'json') { - renderOutput(catalog, { fmt: 'json' }); - return; - } - renderOutput(catalog.sources, { - fmt: opts.format, - columns: ['id', 'source', 'manifestUrl'], - title: `${CLI_COMMAND}/plugin-catalog`, - source: `${CLI_COMMAND} plugin catalog list`, - }); - } catch (err) { - console.error(`Error: ${getErrorMessage(err)}`); - process.exitCode = EXIT_CODES.GENERIC_ERROR; + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + catalogListCmd.action(async (opts: { format?: string }) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const { readCatalog } = await import('./plugin-catalog.js'); + try { + const catalog = readCatalog(); + if (fmt === 'json') { + renderOutput(catalog, { fmt: 'json' }); + return; } - }); + renderOutput(catalog.sources, { + fmt, + fmtExplicit: catalogListCmd.getOptionValueSource('format') === 'cli', + columns: ['id', 'source', 'manifestUrl'], + title: `${CLI_COMMAND}/plugin-catalog`, + source: `${CLI_COMMAND} plugin catalog list`, + }); + } catch (err) { + console.error(`Error: ${getErrorMessage(err)}`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + }); - catalogCmd + const catalogAddCmd = catalogCmd .command('add') .description('Add a plugin marketplace source') .argument('', 'Marketplace source, e.g. github:owner/repo') - .option('-f, --format ', 'Output format: table, json', 'table') - .action(async (source: string, opts: { format?: string }) => { - const { addCatalogSource } = await import('./plugin-catalog.js'); - try { - const added = await addCatalogSource(source); - renderOutput(opts.format === 'json' ? added : [added], { - fmt: opts.format, - columns: ['id', 'source', 'manifestUrl'], - title: `${CLI_COMMAND}/plugin-catalog`, - source: `${CLI_COMMAND} plugin catalog add`, - }); - } catch (err) { - console.error(`Error: ${getErrorMessage(err)}`); - process.exitCode = EXIT_CODES.GENERIC_ERROR; - } - }); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + catalogAddCmd.action(async (source: string, opts: { format?: string }) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const { addCatalogSource } = await import('./plugin-catalog.js'); + try { + const added = await addCatalogSource(source); + renderOutput(fmt === 'json' ? added : [added], { + fmt, + fmtExplicit: catalogAddCmd.getOptionValueSource('format') === 'cli', + columns: ['id', 'source', 'manifestUrl'], + title: `${CLI_COMMAND}/plugin-catalog`, + source: `${CLI_COMMAND} plugin catalog add`, + }); + } catch (err) { + console.error(`Error: ${getErrorMessage(err)}`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + }); catalogCmd .command('remove') @@ -3287,33 +3303,37 @@ cli({ } }); - configurePluginSearchSurface(pluginCmd.command('search')) - .action(async (query: string | undefined, opts: { format?: string }) => { - const { readCatalog, searchCatalogPlugins } = await import('./plugin-catalog.js'); - try { - const catalog = readCatalog(); - const result = await searchCatalogPlugins(catalog, { query }); - if (opts.format === 'json') { - renderOutput(result, { fmt: 'json' }); - } else { - for (const err of result.errors) { - console.error(`Warning: ${err.sourceId}: ${err.message}`); - } - renderOutput(result.plugins, { - fmt: opts.format, - columns: ['name', 'description', 'version', 'sourceId', 'installSource', 'webcmd'], - title: `${CLI_COMMAND}/plugin-search`, - source: `${CLI_COMMAND} plugin search`, - }); - } - if (catalog.sources.length > 0 && result.errors.length === catalog.sources.length) { - process.exitCode = EXIT_CODES.GENERIC_ERROR; + const pluginSearchCmd = configurePluginSearchSurface(pluginCmd.command('search')); + pluginSearchCmd.action(async (query: string | undefined, opts: { format?: string }) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const { readCatalog, searchCatalogPlugins } = await import('./plugin-catalog.js'); + try { + const catalog = readCatalog(); + const result = await searchCatalogPlugins(catalog, { query }); + const fmtExplicit = pluginSearchCmd.getOptionValueSource('format') === 'cli'; + if (fmt === 'json') { + renderOutput(result, { fmt }); + } else { + for (const err of result.errors) { + console.error(`Warning: ${err.sourceId}: ${err.message}`); } - } catch (err) { - console.error(`Error: ${getErrorMessage(err)}`); + renderOutput(result.plugins, { + fmt, + fmtExplicit, + columns: ['name', 'description', 'version', 'sourceId', 'installSource', 'webcmd'], + title: `${CLI_COMMAND}/plugin-search`, + source: `${CLI_COMMAND} plugin search`, + }); + } + if (catalog.sources.length > 0 && result.errors.length === catalog.sources.length) { process.exitCode = EXIT_CODES.GENERIC_ERROR; } - }); + } catch (err) { + console.error(`Error: ${getErrorMessage(err)}`); + process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + }); pluginCmd .command('create') @@ -3618,27 +3638,30 @@ cli({ registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc }); }); - externalCmd + const externalListCmd = externalCmd .command('list') .description('List registered external CLIs') - .option('-f, --format ', 'Output format: table, json, yaml, md, csv', 'table') - .action((opts) => { - const rows = loadExternalClis().map((ext) => ({ - name: ext.name, - package: ext.package ?? '', - binary: ext.binary, - installed: isBinaryInstalled(ext.binary), - description: ext.description ?? '', - homepage: ext.homepage ?? '', - tags: ext.tags?.join(', ') ?? '', - })); - renderOutput(rows, { - fmt: opts.format, - columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'], - title: 'webcmd/external/list', - source: 'webcmd external list', - }); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + externalListCmd.action((opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const rows = loadExternalClis().map((ext) => ({ + name: ext.name, + package: ext.package ?? '', + binary: ext.binary, + installed: isBinaryInstalled(ext.binary), + description: ext.description ?? '', + homepage: ext.homepage ?? '', + tags: ext.tags?.join(', ') ?? '', + })); + renderOutput(rows, { + fmt, + fmtExplicit: externalListCmd.getOptionValueSource('format') === 'cli', + columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'], + title: 'webcmd/external/list', + source: 'webcmd external list', }); + }); function passthroughExternal(name: string, parsedArgs?: string[]) { const args = parsedArgs ?? (() => { diff --git a/src/command-presentation.ts b/src/command-presentation.ts index 67942a10..6b1e9d48 100644 --- a/src/command-presentation.ts +++ b/src/command-presentation.ts @@ -1,4 +1,5 @@ import { CLI_COMMAND } from './brand.js'; +import { OUTPUT_FORMAT_HELP, OUTPUT_FORMATS } from './command-surface.js'; import type { Arg } from './registry.js'; export interface PresentableCommand { @@ -88,9 +89,9 @@ const COMMON_OPTIONS = [ { flags: '-f, --format ', name: 'format', - help: 'Output format: table, plain, json, yaml, md, csv', + help: OUTPUT_FORMAT_HELP, default: 'table', - choices: ['table', 'plain', 'json', 'yaml', 'md', 'csv'], + choices: [...OUTPUT_FORMATS], }, { flags: '--trace ', diff --git a/src/command-surface.test.ts b/src/command-surface.test.ts index e28f615d..6e3131a8 100644 --- a/src/command-surface.test.ts +++ b/src/command-surface.test.ts @@ -88,15 +88,33 @@ describe('parseCommandSurface', () => { }); }); - it.each(['table', 'plain', 'json', 'yaml', 'yml', 'md', 'markdown', 'csv'])( - 'accepts the %s output format', - (format) => { - expect(parseCommandSurface(metadata, ['needle', `--format=${format}`])).toMatchObject({ - format, - formatExplicit: true, - }); - }, - ); + it.each<{ input: string; normalized: OutputFormat }>([ + { input: 'table', normalized: 'table' }, + { input: 'plain', normalized: 'plain' }, + { input: 'json', normalized: 'json' }, + { input: 'yaml', normalized: 'yaml' }, + { input: 'yml', normalized: 'yaml' }, + { input: 'md', normalized: 'md' }, + { input: 'markdown', normalized: 'md' }, + { input: 'csv', normalized: 'csv' }, + ])('accepts and normalizes the %s output format', ({ input, normalized }) => { + expect(parseCommandSurface(metadata, ['needle', `--format=${input}`])).toMatchObject({ + format: normalized, + formatExplicit: true, + }); + }); + + it.each(['xml', 'html', 'jsonl', 'yamlml'])('rejects the unsupported %s output format', (format) => { + expect(() => parseCommandSurface(metadata, ['needle', '--format', format])) + .toThrow(/Unknown output format ".*". Supported formats: table, plain, json, yaml, md, csv/); + }); + + it.each(['JSON', 'YAML', 'Yml', 'Markdown', 'Md'])('accepts the %s output format case-insensitively', (format) => { + expect(() => parseCommandSurface(metadata, ['needle', '--format', format])).not.toThrow(); + expect(parseOutputFormat(format).toLowerCase()).toBe( + parseOutputFormat(format.toLowerCase()), + ); + }); it.each(['off', 'on', 'retain-on-failure'])('accepts the %s trace mode', (trace) => { expect(parseCommandSurface(metadata, ['needle', '--trace', trace])).toMatchObject({ trace }); @@ -143,15 +161,11 @@ describe('coerceCommandArguments', () => { ], {})).toEqual({ limit: '10', mode: 'open' }); }); - it('preserves legacy numeric and unknown-format coercion', () => { + it('preserves legacy numeric coercion', () => { expect(coerceCommandArguments([ { name: 'count', type: 'int' }, { name: 'ratio', type: 'number' }, ], { count: '1.5', ratio: 'Infinity' })).toEqual({ count: 1.5, ratio: Infinity }); - expect(parseCommandSurface(metadata, ['needle', '--format', 'xml'])).toMatchObject({ - format: 'xml', - formatExplicit: true, - }); }); }); diff --git a/src/command-surface.ts b/src/command-surface.ts index e3456fe3..e0e6290f 100644 --- a/src/command-surface.ts +++ b/src/command-surface.ts @@ -1,8 +1,13 @@ import { Command } from 'commander'; -import { ArgumentError } from './errors.js'; +import { ArgumentError, CliError, EXIT_CODES } from './errors.js'; import type { Arg } from './registry.js'; -export const OUTPUT_FORMATS = ['table', 'plain', 'json', 'yaml', 'yml', 'md', 'markdown', 'csv'] as const; +/** Canonical output format names accepted by the shared renderer. */ +export const OUTPUT_FORMATS = ['table', 'plain', 'json', 'yaml', 'md', 'csv'] as const; +/** Accepted aliases that normalize onto the canonical names above. */ +export const OUTPUT_FORMAT_ALIASES: Readonly> = { yml: 'yaml', markdown: 'md' }; +/** Shared option description so every `-f/--format` flag advertises the same formats. */ +export const OUTPUT_FORMAT_HELP = `Output format: ${OUTPUT_FORMATS.join(', ')}`; export const TRACE_MODES = ['off', 'on', 'retain-on-failure'] as const; const BROWSER_WINDOW_MODES = ['foreground', 'background'] as const; @@ -69,7 +74,7 @@ export function configureCommandSurface(command: Command, metadata: CommandSurfa } command - .option('-f, --format ', `Output format: ${OUTPUT_FORMATS.join(', ')}`, 'table') + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') .option('--trace ', `Trace capture: ${TRACE_MODES.join(', ')}`, 'off') .option('-v, --verbose', 'Debug output', false); @@ -247,9 +252,33 @@ export function coerceCommandArguments( } export function parseOutputFormat(value: unknown): OutputFormat { - // Preserve the long-standing local behavior: unknown format names flow to - // output.ts, whose default switch branch renders a table. - return String(value); + const raw = String(value); + const lower = raw.toLowerCase(); + const normalized = Object.prototype.hasOwnProperty.call(OUTPUT_FORMAT_ALIASES, lower) + ? OUTPUT_FORMAT_ALIASES[lower]! + : lower; + if (!OUTPUT_FORMATS.includes(normalized as (typeof OUTPUT_FORMATS)[number])) { + throw new ArgumentError(`Unknown output format "${raw}". Supported formats: ${OUTPUT_FORMATS.join(', ')}.`); + } + return normalized; +} + +/** + * Validate and normalize an `-f/--format` value for a CLI action. Returns the + * canonical format, or `null` after emitting a usage error when the value is + * unsupported. + */ +export function resolveOutputFormat(raw: string | undefined): OutputFormat | null { + try { + return parseOutputFormat(raw); + } catch (err) { + if (err instanceof CliError) { + console.error(`error: ${err.message}`); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return null; + } + throw err; + } } function parseTraceMode(value: unknown): TraceMode { diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index c947fd4f..b8baf798 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -326,17 +326,15 @@ describe('commanderAdapter default formats', () => { ); }); - it('preserves the legacy fallback for an unknown explicit format', async () => { + it('rejects an unknown explicit format with a usage error before execution', async () => { const program = new Command(); const siteCmd = program.command('gemini'); registerCommandToProgram(siteCmd, cmd); await program.parseAsync(['node', 'webcmd', 'gemini', 'ask', '--format', 'xml']); - expect(mockExecuteCommand).toHaveBeenCalled(); - expect(mockRenderOutput).toHaveBeenCalledWith( - [{ response: 'hello' }], - expect.objectContaining({ fmt: 'xml', fmtExplicit: true }), - ); + expect(mockExecuteCommand).not.toHaveBeenCalled(); + expect(mockRenderOutput).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); }); }); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index cb7c0860..74f4c3db 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { Command, InvalidArgumentError, Option } from 'commander'; +import { OUTPUT_FORMAT_HELP, resolveOutputFormat } from '../command-surface.js'; import { AuthRequiredError, CliError, getErrorMessage } from '../errors.js'; import { executeCommand } from '../execution.js'; import { @@ -465,7 +466,7 @@ export function registerAuthCommands(program: Command): Command { .option('--concurrency ', 'Maximum sites to check at once') .option('--timeout ', 'Per-site timeout in seconds') .addOption(new Option('--only ', 'Filter rows by status').choices(['all', 'logged-in', 'not-logged-in', 'unknown', 'error']).default('all')) - .option('-f, --format ', 'Output format: table, plain, json, yaml, md, csv', 'table') + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') .action(async (opts) => { const globals = typeof status.optsWithGlobals === 'function' ? status.optsWithGlobals() as Record : {}; const rows = await collectAuthStatus({ @@ -476,7 +477,8 @@ export function registerAuthCommands(program: Command): Command { only: opts.only, profile: typeof globals.profile === 'string' && globals.profile.trim() ? globals.profile.trim() : undefined, }); - const fmt = typeof opts.format === 'string' ? opts.format : 'table'; + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; renderOutput(rows, { fmt, fmtExplicit: status.getOptionValueSource('format') === 'cli', @@ -493,7 +495,7 @@ export function registerAuthCommands(program: Command): Command { .option('--all', 'Ignore the 24h refresh throttle and force every selected site', false) .option('--concurrency ', 'Maximum sites to refresh at once') .option('--timeout ', 'Per-site timeout in seconds') - .option('-f, --format ', 'Output format: table, plain, json, yaml, md, csv', 'table') + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') .action(async (opts) => { const globals = typeof refresh.optsWithGlobals === 'function' ? refresh.optsWithGlobals() as Record : {}; const rows = await collectAuthRefresh({ @@ -503,7 +505,8 @@ export function registerAuthCommands(program: Command): Command { timeout: opts.timeout, profile: typeof globals.profile === 'string' && globals.profile.trim() ? globals.profile.trim() : undefined, }); - const fmt = typeof opts.format === 'string' ? opts.format : 'table'; + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; renderOutput(rows, { fmt, fmtExplicit: refresh.getOptionValueSource('format') === 'cli', diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index d9d4c57f..09ccef96 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -357,6 +357,45 @@ describe('runHostedCli', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it.each([ + ['plugin search'], + ['profile list'], + ['list'], + ])('rejects an unknown hosted %s format without an API call', async (argvCommand) => { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(); + const argv = argvCommand === 'list' + ? ['list', '-f', 'xml'] + : [...argvCommand.split(' '), '-f', 'xml']; + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(stderr.text()).toContain('error: Unknown output format "xml"'); + expect(stdout.text()).toBe(''); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('normalizes hosted list output format aliases and case', async () => { + const stdout = sink(); + const stderr = sink(); + const result = await runHostedCli(['list', '-f', 'JSON'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl: async () => manifestResponse(), + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(stderr.text()).toBe(''); + expect(JSON.parse(stdout.text())).toEqual([expect.objectContaining({ command: 'github/whoami' })]); + }); + it('lists and deletes hosted profiles without fetching the manifest', async () => { const requests: Array<{ url: string; method: string; body?: unknown }> = []; const fetchImpl = vi.fn(async (url, init) => { diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 815bf426..ba0cedc3 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -4,7 +4,12 @@ import { fileURLToPath } from 'node:url'; import { Command, CommanderError } from 'commander'; import { configureCompletionCommandSurface, configureListCommandSurface, configurePluginInstallSurface, configurePluginSearchSurface } from '../builtin-command-surface.js'; import { BrowserSessionArgvError, rewriteBrowserArgv } from '../cli-argv-preprocess.js'; -import { CommanderStructuralError, MissingRequiredPositionalError } from '../command-surface.js'; +import { + CommanderStructuralError, + MissingRequiredPositionalError, + OUTPUT_FORMAT_HELP, + parseOutputFormat, +} from '../command-surface.js'; import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { HOSTED_BUILTIN_COMMANDS, @@ -72,6 +77,23 @@ class CommanderCompatibleError extends Error { } } +/** + * Validate and normalize an `-f/--format` value for the hosted surfaces that + * build their own Commander grammar (`webcmd list`, `profile`, `plugin search`). + * Mirrors the local CLI behavior: an unsupported format is a usage error with + * Commander-style lowercase output and exit code 2. + */ +function validateHostedFormat(raw: string): string { + try { + return parseOutputFormat(raw); + } catch (err) { + if (err instanceof CliError) { + throw new CommanderStructuralError(`error: ${err.message}\n`, EXIT_CODES.USAGE_ERROR); + } + throw err; + } +} + const hostedBrowserCommandsByPath = new Map(browserCommandCatalog.map(command => [command.command, command])); export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = {}): Promise { @@ -734,7 +756,7 @@ function parseHostedListSurface(argv: readonly string[], literal: boolean): Pars root.exitOverride().configureOutput(output); list.exitOverride().configureOutput(output).action((options: { format: string; tag?: string }) => { actionRan = true; - parsedFormat = options.format; + parsedFormat = validateHostedFormat(options.format); parsedTag = options.tag; formatExplicit = list.getOptionValueSource('format') === 'cli'; }); @@ -779,7 +801,7 @@ function parseHostedProfileSurface( profile.exitOverride().configureOutput(output); const configureFormat = (command: Command): Command => - command.option('-f, --format ', 'Output format: table, json, yaml, md, csv', 'table'); + command.option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); const setParsed = ( command: HostedProfileCommand, surface: Command, @@ -789,7 +811,7 @@ function parseHostedProfileSurface( parsed = { kind: 'run', command, - format: options.format, + format: validateHostedFormat(options.format), formatExplicit: surface.getOptionValueSource('format') === 'cli', ...(value !== undefined ? { value } : {}), }; @@ -851,7 +873,7 @@ function parseHostedPluginSurface( const search = configurePluginSearchSurface(plugin.command('search')); search.exitOverride().configureOutput(output).action((query: string | undefined, options: { format: string }) => { - parsed = { kind: 'run', command: 'search', ...(query !== undefined ? { query } : {}), format: options.format }; + parsed = { kind: 'run', command: 'search', ...(query !== undefined ? { query } : {}), format: validateHostedFormat(options.format) }; }); const install = configurePluginInstallSurface(plugin.command('install')); install.exitOverride().configureOutput(output).action((source: string) => { diff --git a/tests/e2e/plugin-management.test.ts b/tests/e2e/plugin-management.test.ts index 7d2df44a..9c12ca63 100644 --- a/tests/e2e/plugin-management.test.ts +++ b/tests/e2e/plugin-management.test.ts @@ -44,6 +44,18 @@ describe('plugin management E2E', () => { expect(stdout).toContain('No plugins installed'); }); + it('plugin list -f json emits valid JSON when none exist', async () => { + const { stdout, code } = await runPluginCli(['plugin', 'list', '-f', 'json']); + expect(code).toBe(0); + expect(stdout.trim()).toBe('[]'); + }); + + it('plugin list -f yaml emits valid YAML when none exist', async () => { + const { stdout, code } = await runPluginCli(['plugin', 'list', '-f', 'yaml']); + expect(code).toBe(0); + expect(stdout.trim()).toBe('[]'); + }); + // ── plugin install ── it('plugin install clones and sets up a real plugin', async () => { const { stdout, code } = await runPluginCli(['plugin', 'install', PLUGIN_SOURCE], { @@ -137,4 +149,11 @@ describe('plugin management E2E', () => { expect(code).toBe(2); expect(stderr).toContain('specify a plugin name'); }); + + it('plugin list rejects an unsupported format with a usage error', async () => { + const { stderr, code } = await runPluginCli(['plugin', 'list', '-f', 'xml']); + expect(code).toBe(2); + expect(stderr).toContain('Unknown output format "xml"'); + expect(stderr).toContain('Supported formats: table, plain, json, yaml, md, csv'); + }); });