diff --git a/src/builtin-command-surface.ts b/src/builtin-command-surface.ts index 002fbcb6..ded6e72e 100644 --- a/src/builtin-command-surface.ts +++ b/src/builtin-command-surface.ts @@ -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('', COMPLETION_SHELL_DESCRIPTION); + .argument('', COMPLETION_SHELL_DESCRIPTION)); } /** Configure plugin marketplace search grammar shared by local and hosted runtimes. */ @@ -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('', 'Plugin source (e.g. github:user/repo/)') - .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. */ @@ -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('', 'Installed plugin name'); + .argument('', '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')); } diff --git a/src/cli-format-contract.test.ts b/src/cli-format-contract.test.ts new file mode 100644 index 00000000..fe681cba --- /dev/null +++ b/src/cli-format-contract.test.ts @@ -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 { + const flags = new Set(); + 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 }); + } + }); +}); diff --git a/src/cli.test.ts b/src/cli.test.ts index 69a8af37..c9e37dcc 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -160,7 +160,7 @@ describe('site-memory and local adapter authoring', () => { expect(browserInit.helpInformation()).toContain('Create a new private adapter: webcmd browser init /'); const adapterHelp = adapter.helpInformation(); - expect(adapterHelp).toMatch(/Override installed command: webcmd adapter override\s+\//); + expect(adapterHelp).toMatch(/Override installed command: webcmd adapter\s+override\s+\//); expect(adapterHelp).toMatch(/Locate local source: webcmd adapter path\s+\//); }); @@ -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', @@ -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; } @@ -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; } diff --git a/src/cli.ts b/src/cli.ts index 1aad249a..bd58c72a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import './fetch/command.js'; import { commandListPresentation, filterCommandsByTag, toPresentableCommand } from './command-presentation.js'; import { configureCompletionCommandSurface, configureListCommandSurface, configurePluginInstallSurface, configurePluginListSurface, configurePluginSearchSurface } from './builtin-command-surface.js'; import { formatPluginSearchEmptyCopy, presentPluginSearch } from './plugin-search-presentation.js'; -import { addOutputFormatOption, applyUnknownOptionContract, CommanderStructuralError, JSON_FORMAT_ALIAS_HELP, outputFormatIsExplicit, resolveCommandOutputFormat } from './command-surface.js'; +import { addOutputFormatOption, applyUnknownOptionContract, CommanderStructuralError, ensureOutputFormatOptions, JSON_FORMAT_ALIAS_HELP, outputFormatIsExplicit, resolveCommandOutputFormat } from './command-surface.js'; import { render as renderOutput } from './output.js'; import { handleProgramParseError } from './cli-error-report.js'; import { PKG_VERSION } from './version.js'; @@ -190,6 +190,11 @@ async function handleSkillLinkCommand(action: () => WebcmdSkillAddResult | Promi } } +/** The skills commands predate `-f/--format`; honour both spellings. */ +function wantsJsonEnvelope(opts: { json?: boolean; format?: string }): boolean { + return opts.json === true || opts.format === 'json'; +} + function handleSkillRemoveCommand(customPath: string | undefined, json: boolean): void { try { const result = removeWebcmdSkills({ customPath }); @@ -616,7 +621,8 @@ function applyVerbose(opts: { verbose?: boolean }): void { * there would advertise diagnostics that do not exist. */ function withBrowserVerbose(command: Command): Command { - return command.option('-v, --verbose', 'Debug output', false); + // These leaves have always emitted JSON, so `json` stays their default format. + return addOutputFormatOption(command.option('-v, --verbose', 'Debug output', false), 'json'); } function formatChildCommandSummary(command: Command): string { @@ -633,17 +639,47 @@ function applyRootSubcommandSummaries(program: Command): void { } } -async function handleAdapterOverride(commandKey: string): Promise { +/** + * Emit an action command's result in the requested format. + * + * With no `-f/--format`/`--json` the human lines are printed unchanged; with one, + * the command renders a structured payload instead so agents get a parseable + * result from `install`, `create`, `use`, … not just prose. + */ +async function emitActionResult( + command: Command, + payload: Record, + human: () => void, +): Promise { + if (!outputFormatIsExplicit(command)) { + human(); + return; + } + const fmt = resolveCommandOutputFormat(command, (command.opts() as { format?: string }).format); + if (fmt === null) return; + await renderOutput(payload, { fmt, fmtExplicit: true }); +} + +async function handleAdapterOverride(commandKey: string, _opts: unknown, command: Command): Promise { const { createAdapterOverride } = await import('./adapter-override.js'); try { const result = createAdapterOverride(commandKey); - console.log(`✅ Override created for ${result.commandKey}`); - console.log(` yours: ${result.overridePath}`); - console.log(` base: ${result.basePath}`); - console.log(); - console.log(` Your copy now takes precedence over plugin "${result.plugin}".`); - console.log(` "${CLI_COMMAND} plugin update" keeps updating the plugin copy, not your override,`); - console.log(' and will tell you when the upstream file changes so you can merge.'); + await emitActionResult(command, { + ok: true, + action: 'override', + command: result.commandKey, + plugin: result.plugin, + overridePath: result.overridePath, + basePath: result.basePath, + }, () => { + console.log(`✅ Override created for ${result.commandKey}`); + console.log(` yours: ${result.overridePath}`); + console.log(` base: ${result.basePath}`); + console.log(); + console.log(` Your copy now takes precedence over plugin "${result.plugin}".`); + console.log(` "${CLI_COMMAND} plugin update" keeps updating the plugin copy, not your override,`); + console.log(' and will tell you when the upstream file changes so you can merge.'); + }); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; @@ -785,7 +821,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi scope: resolved.scope, customPath: resolved.path, }); - }, opts.json, 'added'); + }, wantsJsonEnvelope(opts), 'added'); }); skillsCmd @@ -800,7 +836,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi provider: opts.provider, scope: opts.scope, customPath: opts.path, - }), opts.json, 'updated'); + }), wantsJsonEnvelope(opts), 'updated'); }); skillsCmd @@ -808,7 +844,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi .description('Remove bundled Webcmd skill symlinks from supported locations') .option('--path ', 'Also remove links from a custom agent skills directory') .option('--json', 'Output a JSON envelope', false) - .action((opts) => handleSkillRemoveCommand(opts.path, opts.json)); + .action((opts) => handleSkillRemoveCommand(opts.path, wantsJsonEnvelope(opts))); program .command('update') @@ -955,7 +991,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi Create a new private adapter: ${CLI_COMMAND} browser init / Override an installed command: ${CLI_COMMAND} adapter override / Test either local adapter: ${CLI_COMMAND} browser verify /`) - .action(async (name: string) => { + .action(async (name: string, _opts: unknown, initCmd: Command) => { try { const parts = name.split('/'); if (parts.length !== 2 || !parts[0] || !parts[1]) { @@ -977,7 +1013,9 @@ Test either local adapter: ${CLI_COMMAND} browser verify /`) const filePath = path.join(dir, `${command}.js`); if (fs.existsSync(filePath)) { - console.log(`Adapter already exists: ${filePath}`); + await emitActionResult(initCmd, { + ok: true, action: 'init', adapter: name, path: filePath, created: false, + }, () => console.log(`Adapter already exists: ${filePath}`)); return; } @@ -1008,9 +1046,13 @@ cli({ `; fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, template, 'utf-8'); - console.log(`Created: ${filePath}`); - console.log('First time on this site? Run: webcmd session create, then webcmd --session browser run --stdin'); - console.log(`Edit the file to implement your adapter, then run: webcmd browser verify ${name}`); + await emitActionResult(initCmd, { + ok: true, action: 'init', adapter: name, path: filePath, created: true, + }, () => { + console.log(`Created: ${filePath}`); + console.log('First time on this site? Run: webcmd session create, then webcmd --session browser run --stdin'); + console.log(`Edit the file to implement your adapter, then run: webcmd browser verify ${name}`); + }); } catch (err) { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; @@ -1259,25 +1301,28 @@ cli({ const runId = generateRunId(); const commandName = `browser/${command.name()}`; let releaseRun = true; + const fmt = resolveCommandOutputFormat(command, (opts as { format?: string }).format); + if (fmt === null) return; + const emit = (payload: unknown) => renderOutput(payload, { fmt, fmtExplicit: true }); try { const session = getBrowserSession(command); const routing = profileRouteParams(getBrowserProfileSelection(command)); const result = await runWithDaemonRunContext({ runId, command: commandName }, () => fn(session, routing, opts)); - console.log(JSON.stringify(result, null, 2)); + await emit(result); } catch (error) { if (isUnknownOutcomeError(error)) releaseRun = false; if (error instanceof BrowserCommandError && error.code) { - console.log(JSON.stringify({ + await emit({ error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}), ...(error.details !== undefined ? { details: error.details } : {}), }, - }, null, 2)); + }); } else if (error instanceof CliError) { const payload = { error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) } }; - console.log(JSON.stringify(payload, null, 2)); + await emit(payload); process.stderr.write(`${error.code}: ${error.message}\n`); if (error.hint) process.stderr.write(`${error.hint}\n`); process.exitCode = error.exitCode; @@ -1316,7 +1361,6 @@ cli({ .addOption(new Option('--max-output ', 'Maximum returned characters').argParser(browserOptionValueParser('run', 'maxOutput')!)) .addOption(new Option('--snapshot-mode ', 'Snapshot mode for automatic diff: act or tree').default('act').argParser(browserOptionValueParser('run', 'snapshotMode')!)) .option('--no-snapshot-diff', 'Skip the automatic before/after snapshot diff')); - runCommand.option('--json', JSON_FORMAT_ALIAS_HELP, false); runCommand.action(rawBrowserAction(async (session, routing, opts) => { let source: string; try { @@ -1410,21 +1454,24 @@ cli({ const originalPluginDescription = pluginCmd.description(); configurePluginInstallSurface(pluginCmd.command('install')) - .action(async (source: string, opts: { all?: boolean }) => { + .action(async (source: string, opts: { all?: boolean }, command: Command) => { const { installPlugin } = await import('./plugin.js'); const { discoverPlugins } = await import('./discovery.js'); try { const result = installPlugin(source, { all: opts.all === true }); await discoverPlugins(); - if (Array.isArray(result)) { - if (result.length === 0) { - console.log('No plugins were installed (all skipped or incompatible).'); + const plugins = Array.isArray(result) ? result : [result]; + await emitActionResult(command, { ok: true, action: 'install', source, plugins }, () => { + if (Array.isArray(result)) { + if (result.length === 0) { + console.log('No plugins were installed (all skipped or incompatible).'); + } else { + console.log(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`); + } } else { - console.log(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`); + console.log(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`); } - } else { - console.log(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`); - } + }); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; @@ -1435,11 +1482,13 @@ cli({ .command('uninstall') .description('Uninstall a plugin') .argument('', 'Plugin name') - .action(async (name: string) => { + .action(async (name: string, _opts: unknown, command: Command) => { const { uninstallPlugin } = await import('./plugin.js'); try { uninstallPlugin(name); - console.log(`✅ Plugin "${name}" uninstalled.`); + await emitActionResult(command, { ok: true, action: 'uninstall', plugin: name }, () => { + console.log(`✅ Plugin "${name}" uninstalled.`); + }); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; @@ -1452,7 +1501,7 @@ cli({ .argument('[name]', 'Plugin name (required unless --all is passed)') .option('--all', 'Update all installed plugins') .option('--force', 'Discard uncommitted changes in this plugin\'s files') - .action(async (name: string | undefined, opts: { all?: boolean; force?: boolean }) => { + .action(async (name: string | undefined, opts: { all?: boolean; force?: boolean }, command: Command) => { if (!name && !opts.all) { console.error('Error: Please specify a plugin name or use the --all flag.'); process.exitCode = EXIT_CODES.USAGE_ERROR; @@ -1472,6 +1521,21 @@ cli({ await discoverPlugins(); } + if (outputFormatIsExplicit(command)) { + const failed = results.filter((result) => !result.success); + if (failed.length > 0) process.exitCode = EXIT_CODES.GENERIC_ERROR; + await emitActionResult(command, { + ok: failed.length === 0, + action: 'update', + plugins: results.map((result) => ({ + name: result.name, + ok: result.success, + ...(result.success ? {} : { error: String(result.error) }), + })), + }, () => undefined); + return; + } + let hasErrors = false; console.log(' Update Results:'); for (const result of results) { @@ -1505,8 +1569,14 @@ cli({ try { const updatedPlugins = updatePlugin(name!, { force: opts.force === true }); await discoverPlugins(); - console.log(`✅ Plugin "${name}" updated successfully.`); - printReconcileReport(findOverridesNeedingReconcile(updatedPlugins)); + await emitActionResult(command, { + ok: true, + action: 'update', + plugins: updatedPlugins.map((plugin) => ({ name: plugin, ok: true })), + }, () => { + console.log(`✅ Plugin "${name}" updated successfully.`); + printReconcileReport(findOverridesNeedingReconcile(updatedPlugins)); + }); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; @@ -1696,7 +1766,7 @@ cli({ description?: string; authorName?: string; authorHandle?: string; - }) => { + }, command: Command) => { const { createPluginScaffold } = await import('./plugin-scaffold.js'); try { let authorName = opts.authorName?.trim(); @@ -1719,17 +1789,25 @@ cli({ handle: authorHandle ?? '', }, }); - console.log(`✅ Plugin scaffold created at ${result.dir}`); - console.log(); - console.log(' Files created:'); - for (const f of result.files) { - console.log(` ${f}`); - } - console.log(); - console.log(' Next steps:'); - console.log(` cd ${result.dir}`); - console.log(` ${CLI_COMMAND} plugin install file://${result.dir}`); - console.log(` ${CLI_COMMAND} ${name} hello`); + await emitActionResult(command, { + ok: true, + action: 'create', + plugin: name, + dir: result.dir, + files: result.files, + }, () => { + console.log(`✅ Plugin scaffold created at ${result.dir}`); + console.log(); + console.log(' Files created:'); + for (const f of result.files) { + console.log(` ${f}`); + } + console.log(); + console.log(' Next steps:'); + console.log(` cd ${result.dir}`); + console.log(` ${CLI_COMMAND} plugin install file://${result.dir}`); + console.log(` ${CLI_COMMAND} ${name} hello`); + }); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; @@ -1831,7 +1909,7 @@ cli({ .description('Remove a local adapter override') .argument('[site]', 'Site name (e.g. twitter, youtube)') .option('--all', 'Reset all local overrides') - .action(async (site: string | undefined, opts: { all?: boolean }) => { + .action(async (site: string | undefined, opts: { all?: boolean }, command: Command) => { if (opts.all) { let userClisListed = false; try { @@ -1840,7 +1918,9 @@ cli({ const dirs = userEntries.filter(e => e.isDirectory() && e.name !== '.base'); readOverrideRecords(); if (dirs.length === 0) { - console.log('No local sites to reset.'); + await emitActionResult(command, { ok: true, action: 'reset', all: true, sites: [], records: 0 }, () => { + console.log('No local sites to reset.'); + }); return; } let removedRecords = 0; @@ -1848,10 +1928,21 @@ cli({ fs.rmSync(path.join(USER_CLIS, dir.name), { recursive: true, force: true }); removedRecords += removeOverrideRecords(dir.name).length; } - console.log(`✅ Removed ${dirs.length} local adapter override(s) and ${removedRecords} provenance record(s).`); + await emitActionResult(command, { + ok: true, + action: 'reset', + all: true, + sites: dirs.map((dir) => dir.name), + records: removedRecords, + }, () => { + console.log(`✅ Removed ${dirs.length} local adapter override(s) and ${removedRecords} provenance record(s).`); + }); } catch (err) { - if (!userClisListed && (err as NodeJS.ErrnoException).code === 'ENOENT') console.log('No local sites to reset.'); - else { + if (!userClisListed && (err as NodeJS.ErrnoException).code === 'ENOENT') { + await emitActionResult(command, { ok: true, action: 'reset', all: true, sites: [], records: 0 }, () => { + console.log('No local sites to reset.'); + }); + } else { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; } @@ -1875,7 +1966,15 @@ cli({ fs.rmSync(userSiteDir, { recursive: true, force: true }); const removedRecords = removeOverrideRecords(site).length; - console.log(`✅ Removed local adapter override "${site}" and ${removedRecords} provenance record(s).`); + await emitActionResult(command, { + ok: true, + action: 'reset', + all: false, + sites: [site], + records: removedRecords, + }, () => { + console.log(`✅ Removed local adapter override "${site}" and ${removedRecords} provenance record(s).`); + }); }); adapterCmd @@ -1898,13 +1997,21 @@ cli({ if (!source) throw new ArgumentError(`Adapter source is unavailable for ${key}.`); return source; }; - const reportLocalAdapterPath = (commandKey: string, commandName?: string): void => console.log(localAdapterPath(commandKey, commandName)); + const reportLocalAdapterPath = async (command: Command, commandKey: string, commandName?: string): Promise => { + const parsed = splitAdapterCommandKey(commandKey, commandName); + const source = localAdapterPath(commandKey, commandName); + await emitActionResult(command, { + ok: true, + command: parsed ? `${parsed.site}/${parsed.command}` : commandKey, + path: source, + }, () => console.log(source)); + }; const adapterSourceCmd = adapterCmd.command('source').description('Inspect local adapter source paths; hosted mode reads or writes source'); - adapterSourceCmd.command('get').description('Print local source path; --output is hosted-only').argument('').argument('[name]').option('-o, --output ').action((commandKey: string, commandName: string | undefined, options: { output?: string }) => { + adapterSourceCmd.command('get').description('Print local source path; --output is hosted-only').argument('').argument('[name]').option('-o, --output ').action(async (commandKey: string, commandName: string | undefined, options: { output?: string }, command: Command) => { const parsed = splitAdapterCommandKey(commandKey, commandName); const key = parsed ? `${parsed.site}/${parsed.command}` : commandKey; if (options.output) throw new ArgumentError(`Local adapter source get does not support --output. Use webcmd adapter path ${key} and edit that file.`); - reportLocalAdapterPath(commandKey, commandName); + await reportLocalAdapterPath(command, commandKey, commandName); }); adapterSourceCmd.command('put').description('Hosted-only source write; local users edit the adapter path').argument('').argument('').action((commandKey: string) => { throw new ArgumentError(`Local adapter source put is unavailable. Use webcmd adapter path ${commandKey} and edit that file.`); @@ -1913,7 +2020,7 @@ cli({ .description(`Locate local source: ${CLI_COMMAND} adapter path /`) .argument('', 'site/command, or site when followed by the command name') .argument('[name]', 'command name when passed as a second token') - .action((commandKey: string, commandName?: string) => reportLocalAdapterPath(commandKey, commandName)); + .action((commandKey: string, commandName: string | undefined, _opts: unknown, command: Command) => reportLocalAdapterPath(command, commandKey, commandName)); // ── Built-in: browser profile selection ────────────────────────────────── const PROFILE_LIST_COLUMNS = ['contextId', 'alias', 'default', 'connected', 'runtimeVersion']; @@ -2005,11 +2112,19 @@ cli({ .command('create') .description('Create a Cloak profile alias') .argument('', 'Local alias, e.g. work or personal') - .action((alias: string) => { + .action(async (alias: string, _opts: unknown, command: Command) => { const result = createProfile(alias); - console.log(result.created - ? `Profile ${result.alias} created (contextId: ${result.contextId}).` - : `Profile ${result.alias} already exists (contextId: ${result.contextId}).`); + await emitActionResult(command, { + ok: true, + action: 'create', + alias: result.alias, + contextId: result.contextId, + created: result.created, + }, () => { + console.log(result.created + ? `Profile ${result.alias} created (contextId: ${result.contextId}).` + : `Profile ${result.alias} already exists (contextId: ${result.contextId}).`); + }); }); profileCmd @@ -2017,10 +2132,12 @@ cli({ .description('Assign a local alias to an available Cloak profile') .argument('', 'Profile contextId from webcmd profile list') .argument('', 'Local alias, e.g. work or personal') - .action((contextId: string, alias: string) => { + .action(async (contextId: string, alias: string, _opts: unknown, command: Command) => { try { renameProfile(contextId, alias); - console.log(`Profile ${contextId} is now aliased as ${alias}.`); + await emitActionResult(command, { ok: true, action: 'rename', contextId, alias }, () => { + console.log(`Profile ${contextId} is now aliased as ${alias}.`); + }); } catch (err) { console.error(`Error: ${getErrorMessage(err)}`); process.exitCode = EXIT_CODES.USAGE_ERROR; @@ -2031,14 +2148,21 @@ cli({ .command('use') .description('Set the default Cloak profile for future commands') .argument('', 'Profile alias or contextId from webcmd profile list') - .action(async (profile: string) => { + .action(async (profile: string, _opts: unknown, command: Command) => { const status = await fetchDaemonStatus(); const config = loadProfileConfig(); const connected = status && !isDaemonStale(status, PKG_VERSION) && Array.isArray(status.profiles) ? status.profiles : []; const next = setDefaultProfile(profile, profileListRows(config, connected)); - console.log(`Default Cloak profile: ${next.defaultContextId ?? profile}`); + await emitActionResult(command, { + ok: true, + action: 'use', + profile, + defaultContextId: next.defaultContextId ?? profile, + }, () => { + console.log(`Default Cloak profile: ${next.defaultContextId ?? profile}`); + }); }); // ── Built-in: daemon ────────────────────────────────────────────────────── @@ -2056,11 +2180,19 @@ cli({ daemonCmd .command('stop') .description('Stop the daemon') - .action(async () => { await daemonStop(); }); + .action(async (_opts: unknown, command: Command) => { + await daemonStop(); + // daemonStop/daemonRestart report progress on stderr, so the structured + // envelope is additive: stdout stays empty without a format flag. + await emitActionResult(command, { ok: !process.exitCode, action: 'stop' }, () => undefined); + }); daemonCmd .command('restart') .description('Restart the daemon') - .action(async () => { await daemonRestart(); }); + .action(async (_opts: unknown, command: Command) => { + await daemonRestart(); + await emitActionResult(command, { ok: !process.exitCode, action: 'restart' }, () => undefined); + }); // ── External CLIs ───────────────────────────────────────────────────────── @@ -2074,14 +2206,20 @@ cli({ .command('install') .description('Install an external CLI') .argument('', 'Name of the external CLI') - .action((name: string) => { + .action(async (name: string, _opts: unknown, command: Command) => { const ext = externalClis.find(e => e.name === name); if (!ext) { console.error(`External CLI '${name}' not found in registry.`); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } - installExternalCli(ext); + const installed = installExternalCli(ext); + await emitActionResult(command, { + ok: installed, + action: 'install', + cli: ext.name, + binary: ext.binary, + }, () => undefined); }); externalCmd @@ -2091,8 +2229,14 @@ cli({ .option('--binary ', 'Binary name if different from name') .option('--install ', 'Auto-install command') .option('--desc ', 'Description') - .action((name, opts) => { + .action(async (name: string, opts: { binary?: string; install?: string; desc?: string }, command: Command) => { registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc }); + await emitActionResult(command, { + ok: true, + action: 'register', + cli: name, + binary: opts.binary ?? name, + }, () => undefined); }); const externalListCmd = addOutputFormatOption(externalCmd @@ -2169,6 +2313,11 @@ cli({ const siteNames = registerAllCommands(program, siteGroups); applyRootSubcommandSummaries(program); + // Every leaf in the finished tree speaks the same output-format grammar, + // whether or not its own registration remembered to ask for it. Must run + // before help presentation is captured below. + ensureOutputFormatOptions(program); + // ── Help-text grouping: External CLIs / App adapters / Site adapters ── // Classification derives from each adapter's `domain` field — see classifyAdapter. // External CLIs are taken from the externalClis registry (passthrough binaries). diff --git a/src/command-surface.ts b/src/command-surface.ts index ab15e282..07baf627 100644 --- a/src/command-surface.ts +++ b/src/command-surface.ts @@ -437,6 +437,37 @@ export function addOutputFormatOption(command: Command, defaultFormat = 'table') .option('--json', JSON_FORMAT_ALIAS_HELP, false); } +/** + * Give every command in a tree the same output-format grammar. + * + * Registering `-f/--format`/`--json` command by command drifted: agents learned + * the flag on `plugin list`, then hit `unknown option '--json'` on + * `plugin install`. Walking the finished tree makes acceptance the default, so + * commands added later cannot regress the contract. + * + * Only leaves are touched. Namespace commands (`adapter`, `plugin`, …) produce no + * output of their own — they print help — so a format flag there would be dead + * grammar in every help listing. Passthrough commands (external CLIs, + * `allowUnknownOption()`) are skipped too: their argv belongs to the wrapped + * binary, not to us. + */ +export function ensureOutputFormatOptions(command: Command): void { + for (const child of command.commands) { + if (child.commands.length === 0 && (child as Command & { _allowUnknownOption?: boolean })._allowUnknownOption !== true) { + const flags = new Set(); + for (const option of child.options) { + if (option.short) flags.add(option.short); + if (option.long) flags.add(option.long); + } + if (!flags.has('--format')) { + child.option(flags.has('-f') ? '--format ' : '-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + } + if (!flags.has('--json')) child.option('--json', JSON_FORMAT_ALIAS_HELP, false); + } + ensureOutputFormatOptions(child); + } +} + export function outputFormatIsExplicit(command: Command): boolean { return command.getOptionValueSource('format') === 'cli' || command.getOptionValueSource('json') === 'cli'; } diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index 5f4f7ac6..abdcbd70 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -5,7 +5,7 @@ import { browserOptionFlags, browserOptionValueParser, } from '../browser/command-catalog.js'; -import { CommanderStructuralError, JSON_FORMAT_ALIAS_HELP } from '../command-surface.js'; +import { addOutputFormatOption, CommanderStructuralError } from '../command-surface.js'; import { CliError, EXIT_CODES } from '../errors.js'; import { configureRootCommandSurface } from '../root-command-surface.js'; @@ -92,8 +92,11 @@ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHost if (valueParser) commanderOption.argParser(valueParser); leaf.addOption(commanderOption); } + // Mirror the local grammar: the raw session leaves (the ones carrying `-v`) + // have always emitted JSON, so `json` is their default format; `init` and + // `verify` render like every other built-in. + addOutputFormatOption(leaf, contract.options.some(option => option.name === 'verbose') ? 'json' : 'table'); if (contract.command === 'run') { - leaf.option('--json', JSON_FORMAT_ALIAS_HELP, false); const originalHelpInformation = leaf.helpInformation.bind(leaf); leaf.helpInformation = ((contextOptions?: unknown) => ( originalHelpInformation(contextOptions as never) + BROWSER_RUN_HELP_TEXT diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 993b2a60..1a133a9f 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -654,11 +654,12 @@ describe('hosted root preflight call order', () => { expect(stdout.text()).toBe(local.stdout); if (name === 'help') { expect(stderr.text()).toBe(local.stderr); - expect(local.stdout).toHaveLength(181); + expect(local.stdout).toHaveLength(371); } else if (name === 'leaf version is unknown') { expect(stderr.text()).toBe([ "error: unknown option '-V'", "error: unknown option '-V'", + 'help: valid flags for `webcmd completion`: -f, --format, --json', '', ].join('\n')); } else { diff --git a/src/site-memory/commands.test.ts b/src/site-memory/commands.test.ts index 817e2f39..0bd4f8ab 100644 --- a/src/site-memory/commands.test.ts +++ b/src/site-memory/commands.test.ts @@ -52,6 +52,23 @@ describe('site memory format flags', () => { expect(vi.mocked(store.show).mock.calls.length + vi.mocked(store.list).mock.calls.length).toBeGreaterThan(0); }); + it.each([ + { argv: ['site', 'note', 'add', 'quotes-toscrape', '--text', 'hi'], expected: { ok: true, action: 'note add', site: 'quotes-toscrape' } }, + { argv: ['site', 'endpoint', 'stale', 'quotes-toscrape', 'listing'], expected: { ok: true, action: 'endpoint stale', site: 'quotes-toscrape', endpoint: 'listing' } }, + { argv: ['site', 'field-map', 'add', 'quotes-toscrape', 'q', '--meaning', 'quote', '--source', 'dom'], expected: { ok: true, action: 'field-map add', site: 'quotes-toscrape', key: 'q' } }, + ])('renders a structured result for write command $argv.1 $argv.2', async ({ argv, expected }) => { + const logged: unknown[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((value: unknown) => { logged.push(value); }); + try { + await program(backend()).parseAsync(argv, { from: 'user' }); + expect(logged).toEqual([]); + await program(backend()).parseAsync([...argv, '--json'], { from: 'user' }); + expect(JSON.parse(String(logged[0]))).toEqual(expected); + } finally { + spy.mockRestore(); + } + }); + it('rejects unknown flags with the valid set including --format and --json', async () => { try { await program(backend()).parseAsync(['site', 'memory', 'show', 'quotes-toscrape', '--nope'], { from: 'user' }); diff --git a/src/site-memory/commands.ts b/src/site-memory/commands.ts index b6793ef5..b69110d0 100644 --- a/src/site-memory/commands.ts +++ b/src/site-memory/commands.ts @@ -108,17 +108,28 @@ export function registerSiteCommands( await emitListing(list, await backend.list(name), opts, stdout, ['path', 'updatedAt', 'byteSize', 'sha256']); }); + /** Write commands print nothing by default; a format flag turns that into a result object. */ + const emitWriteResult = async (command: Command, payload: Record): Promise => { + if (!outputFormatIsExplicit(command)) return; + const fmt = resolveCommandOutputFormat(command, (command.opts() as { format?: string }).format); + if (fmt === null) return; + await renderOutput(payload, { fmt, fmtExplicit: true, stdout }); + }; + const note = withExample(site.command('note') .description('Read and write freeform site notes: webcmd site note ') .usage('add|list [options]'), 'webcmd site note add example.com --text "search needs a session cookie"'); - withExample(note.command('add') + const noteAdd = addOutputFormatOption(withExample(note.command('add') .description('Append a markdown note to a site; the site name comes before --text') .argument('', SITE_ARG_HELP) .requiredOption('--text ', 'Note body, in markdown (required; there is no -m alias)') .option('--author ', 'Who wrote the note'), - 'webcmd site note add example.com --text "search needs a session cookie" --author agent') - .action((name, opts: { text: string; author?: string }) => backend.note(name, opts.text, opts.author)); + 'webcmd site note add example.com --text "search needs a session cookie" --author agent'), 'json'); + noteAdd.action(async (name, opts: { text: string; author?: string }) => { + await backend.note(name, opts.text, opts.author); + await emitWriteResult(noteAdd, { ok: true, action: 'note add', site: name }); + }); const noteList = addOutputFormatOption(withExample(note.command('list') .description('Print the notes recorded for a site') .argument('', SITE_ARG_HELP) @@ -132,7 +143,7 @@ export function registerSiteCommands( .description('Maintain the verified API endpoints found for a site: webcmd site endpoint ') .usage('set|stale|list [args] [options]'), 'webcmd site endpoint set example.com search --url https://example.com/api/search --method GET'); - withExample(endpoint.command('set') + const endpointSet = addOutputFormatOption(withExample(endpoint.command('set') .description('Record or update one verified endpoint for a site') .argument('', SITE_ARG_HELP) .argument('', 'Endpoint name to store it under, e.g. search') @@ -142,20 +153,26 @@ export function registerSiteCommands( .option('--rows-path ', 'Dot path to the result rows inside the response, e.g. data.items') .option('--fields ', 'Comma-separated list of the response fields worth keeping') .option('--notes ', 'Freeform notes about auth, paging or quirks'), - 'webcmd site endpoint set example.com search --url https://example.com/api/search --method GET --rows-path data.items') - .action((siteName, name, opts: { url: string; method: string; params?: string; rowsPath?: string; fields?: string; notes?: string }) => backend.endpoint(siteName, name, { + 'webcmd site endpoint set example.com search --url https://example.com/api/search --method GET --rows-path data.items'), 'json'); + endpointSet.action(async (siteName, name, opts: { url: string; method: string; params?: string; rowsPath?: string; fields?: string; notes?: string }) => { + await backend.endpoint(siteName, name, { url: opts.url, method: opts.method, ...(opts.params ? { params: parseJsonObject(opts.params) } : {}), ...(opts.rowsPath ? { rowsPath: opts.rowsPath } : {}), ...(opts.fields ? { sampleFields: opts.fields.split(',').map(value => value.trim()).filter(Boolean) } : {}), ...(opts.notes ? { notes: opts.notes } : {}), - })); - withExample(endpoint.command('stale') + }); + await emitWriteResult(endpointSet, { ok: true, action: 'endpoint set', site: siteName, endpoint: name, url: opts.url, method: opts.method }); + }); + const endpointStale = addOutputFormatOption(withExample(endpoint.command('stale') .description('Mark a recorded endpoint stale once it stops returning what it used to') .argument('', SITE_ARG_HELP) .argument('', 'Name of the recorded endpoint to mark stale'), - 'webcmd site endpoint stale example.com search') - .action((siteName, name) => backend.stale(siteName, name)); + 'webcmd site endpoint stale example.com search'), 'json'); + endpointStale.action(async (siteName, name) => { + await backend.stale(siteName, name); + await emitWriteResult(endpointStale, { ok: true, action: 'endpoint stale', site: siteName, endpoint: name }); + }); const endpointList = addOutputFormatOption(withExample(endpoint.command('list') .description('Print the endpoints recorded for a site') .argument('', SITE_ARG_HELP) @@ -169,15 +186,18 @@ export function registerSiteCommands( .description('Explain what opaque response field names mean: webcmd site field-map add ') .usage('add [options]'), 'webcmd site field-map add example.com p --meaning "price in cents" --source /api/search'); - withExample(fieldMap.command('add') + const fieldMapAdd = addOutputFormatOption(withExample(fieldMap.command('add') .description('Record what one response field means and where it was observed') .argument('', SITE_ARG_HELP) .argument('', 'Raw field name as it appears in the response, e.g. p') .requiredOption('--meaning ', 'What the field actually holds') .requiredOption('--source ', 'Where it was seen, e.g. the endpoint path') .option('--force', 'Overwrite an existing mapping for this key'), - 'webcmd site field-map add example.com p --meaning "price in cents" --source /api/search') - .action((siteName, key, opts: { meaning: string; source: string; force?: boolean }) => backend.fieldMap(siteName, key, opts.meaning, opts.source, opts.force === true)); + 'webcmd site field-map add example.com p --meaning "price in cents" --source /api/search'), 'json'); + fieldMapAdd.action(async (siteName, key, opts: { meaning: string; source: string; force?: boolean }) => { + await backend.fieldMap(siteName, key, opts.meaning, opts.source, opts.force === true); + await emitWriteResult(fieldMapAdd, { ok: true, action: 'field-map add', site: siteName, key }); + }); const fixture = withExample(site.command('fixture') .description('Read and write the verify fixtures used by webcmd browser verify: webcmd site fixture /') @@ -205,37 +225,39 @@ export function registerSiteCommands( if (fmt === null) return; await renderOutput(parseFixtureBody(body), { fmt, fmtExplicit: true, stdout }); }); - withExample(fixture.command('put') + const fixturePut = addOutputFormatOption(withExample(fixture.command('put') .description('Store a verify fixture for one site command, read from a file or stdin') .argument('', SITE_COMMAND_ARG_HELP) .argument('[path]', 'File holding the fixture body; omit it and pass --stdin to read stdin') .option('--stdin', 'Read the fixture from stdin'), - 'webcmd site fixture put example.com/search ./search.json') - .action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { - const { site: siteName, command } = parseSiteCommand(key); - await backend.putFixture(siteName, command, await readSitePutSource( - { path: file, stdin: opts.stdin === true }, - { readStdin: io.readStdin, usage: 'webcmd site fixture put ' }, - )); - }); + 'webcmd site fixture put example.com/search ./search.json'), 'json'); + fixturePut.action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { + const { site: siteName, command } = parseSiteCommand(key); + await backend.putFixture(siteName, command, await readSitePutSource( + { path: file, stdin: opts.stdin === true }, + { readStdin: io.readStdin, usage: 'webcmd site fixture put ' }, + )); + await emitWriteResult(fixturePut, { ok: true, action: 'fixture put', site: siteName, command }); + }); const sample = withExample(site.command('sample') .description('Keep raw response samples for a site command: webcmd site sample add /') .usage('add / [path] [options]'), 'webcmd site sample add example.com/search ./sample.json'); - withExample(sample.command('add') + const sampleAdd = addOutputFormatOption(withExample(sample.command('add') .description('Save a raw response sample for one site command, read from a file or stdin') .argument('', SITE_COMMAND_ARG_HELP) .argument('[path]', 'File holding the sample body; omit it and pass --stdin to read stdin') .option('--stdin', 'Read the sample from stdin'), - 'webcmd site sample add example.com/search ./sample.json') - .action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { - const { site: siteName, command } = parseSiteCommand(key); - await backend.sample(siteName, command, await readSitePutSource( - { path: file, stdin: opts.stdin === true }, - { readStdin: io.readStdin, usage: 'webcmd site sample add ' }, - )); - }); + 'webcmd site sample add example.com/search ./sample.json'), 'json'); + sampleAdd.action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { + const { site: siteName, command } = parseSiteCommand(key); + await backend.sample(siteName, command, await readSitePutSource( + { path: file, stdin: opts.stdin === true }, + { readStdin: io.readStdin, usage: 'webcmd site sample add ' }, + )); + await emitWriteResult(sampleAdd, { ok: true, action: 'sample add', site: siteName, command }); + }); } export function createLocalSiteMemoryBackend(options: LocalStoreOptions = {}): SiteMemoryBackend {