diff --git a/src/cli.test.ts b/src/cli.test.ts index 2b461089..939fbe95 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1191,7 +1191,8 @@ name: 'search', errors.length = 0; createProgram('', '').parse(['browser', 'nonsense'], { from: 'user' }); - expect(errors.join('\n')).toBe("error: unknown command 'nonsense'"); + expect(errors.join('\n')).toContain("error: unknown command 'nonsense'"); + expect(errors.join('\n')).toContain('Valid webcmd browser commands:'); } finally { spy.mockRestore(); process.exitCode = previousExitCode; diff --git a/src/cli.ts b/src/cli.ts index 3e1a0d61..6a67df35 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,7 +54,8 @@ import type { BrowserWindowMode } from './runtime.js'; import { configureRootCommandSurface } from './root-command-surface.js'; import { validateRawBrowserSession } from './hosted/browser-args.js'; import { LocalBrowserSessionStore, requireSessionIdShape, type BrowserSessionListRow } from './browser/sessions.js'; -import { missingPluginGuidance, PLUGINS_DIR } from './discovery.js'; +import { PLUGINS_DIR } from './discovery.js'; +import { unknownRootCommandMessage, unknownSubcommandMessage } from './command-suggest.js'; import { loadBrowserRunSource } from './browser/run/input.js'; import { BrowserRunError } from './browser/run/types.js'; import { classifyCommandOrigin, formatCommandOrigin } from './command-origin.js'; @@ -83,22 +84,6 @@ function parseSessionListLimit(value: string): number { return parsed; } -function rootCommandSuggestion(name: string): string | undefined { - const canonical: Record = { - catalog: `${CLI_COMMAND} plugin catalog list`, - catalogs: `${CLI_COMMAND} plugin catalog list`, - command: `${CLI_COMMAND} list`, - commands: `${CLI_COMMAND} list`, - cmds: `${CLI_COMMAND} list`, - ls: `${CLI_COMMAND} list`, - marketplace: `${CLI_COMMAND} plugin search `, - plugins: `${CLI_COMMAND} plugin list`, - pluginlist: `${CLI_COMMAND} plugin list`, - search: `${CLI_COMMAND} plugin search `, - }; - return canonical[name.toLowerCase()]; -} - type BrowserNetworkItem = { url: string; method: string; @@ -954,20 +939,8 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi .description('Run Playwright programs against an explicit browser Session'); const originalBrowserDescription = browser.description(); - // Retired browser subcommands. `fork` was a duplicate of `adapter override` - // that never appeared in the docs; commander's bare "unknown command" leaves - // the caller with no way to find the replacement, so name it. - const RETIRED_BROWSER_SUBCOMMANDS: Record = { - fork: `${CLI_COMMAND} adapter override /`, - }; - browser.on('command:*', (operands: string[]) => { - const name = operands[0]!; - const replacement = RETIRED_BROWSER_SUBCOMMANDS[name]; - console.error(replacement - ? `error: '${CLI_COMMAND} browser ${name}' was removed. Use: ${replacement}` - : `error: unknown command '${name}'`); - process.exitCode = EXIT_CODES.USAGE_ERROR; - }); + // Unknown `browser` subcommands (including the retired `fork`) are handled by + // the shared namespace handler installed at the end of createProgram. // ── Init (adapter scaffolding) ── @@ -2231,19 +2204,28 @@ cli({ // Security: do NOT auto-discover and register arbitrary system binaries. // Only explicitly registered external CLIs are allowed. + // Error output goes to stderr only. `outputHelp()` used to dump the whole root + // help to stdout here, which with `--json` in argv looked like a successful + // JSON response to anything parsing stdout. program.on('command:*', (operands: string[]) => { - const binary = operands[0]!; - const suggestion = rootCommandSuggestion(binary); - if (suggestion) { - console.error(`Unknown command "${binary}".\nDid you mean: ${suggestion}`); - process.exitCode = EXIT_CODES.USAGE_ERROR; - return; - } - console.error(missingPluginGuidance(binary)); - program.outputHelp(); + console.error(unknownRootCommandMessage(program, operands[0]!)); process.exitCode = EXIT_CODES.USAGE_ERROR; }); + // Same treatment one level down, for the built-in namespaces. Site adapter + // groups are left on Commander's own suggestion path so hosted mode, which + // has no Command tree to match against, stays byte-compatible with local. + const SUGGEST_NAMESPACES = new Set([ + 'adapter', 'plugin', 'session', 'profile', 'daemon', 'external', 'browser', 'site', 'auth', 'skills', + ]); + for (const namespace of program.commands) { + if (namespace.commands.length === 0 || !SUGGEST_NAMESPACES.has(namespace.name())) continue; + namespace.on('command:*', (operands: string[]) => { + console.error(unknownSubcommandMessage(namespace, operands[0]!)); + process.exitCode = EXIT_CODES.USAGE_ERROR; + }); + } + return program; } diff --git a/src/command-suggest.test.ts b/src/command-suggest.test.ts new file mode 100644 index 00000000..4959e8d6 --- /dev/null +++ b/src/command-suggest.test.ts @@ -0,0 +1,117 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { createProgram } from './cli.js'; +import { editDistance, unknownRootCommandMessage, unknownSubcommandMessage } from './command-suggest.js'; + +function namespaceOf(program: ReturnType, name: string) { + return program.commands.find(command => command.name() === name)!; +} + +describe('editDistance', () => { + it('counts single edits', () => { + expect(editDistance('adapters', 'adapter')).toBe(1); + expect(editDistance('fetch', 'fetch')).toBe(0); + expect(editDistance('kitten', 'sitting')).toBe(3); + }); +}); + +describe('unknown root command', () => { + it('suggests the real command instead of a plugin hunt for a near miss', () => { + const message = unknownRootCommandMessage(createProgram('', ''), 'adapters'); + + expect(message).toContain('Unknown command "adapters".'); + expect(message).toContain('webcmd adapter'); + expect(message).not.toContain('plugin search'); + }); + + it('reaches subcommand leaves so a bare verb finds its namespace', () => { + const message = unknownRootCommandMessage(createProgram('', ''), 'fetch'); + + expect(message).toContain('Did you mean: webcmd web fetch'); + }); + + it('keeps the hardcoded intent overrides ahead of edit distance', () => { + const message = unknownRootCommandMessage(createProgram('', ''), 'marketplace'); + + expect(message).toContain('Did you mean: webcmd plugin search '); + }); + + it('still guides a genuinely unknown token to plugin search', () => { + const message = unknownRootCommandMessage(createProgram('', ''), 'zzzqqqwww'); + + expect(message).toContain('Site "zzzqqqwww" is not installed.'); + expect(message).toContain('webcmd plugin search zzzqqqwww'); + }); + + it('says the adapter failed to load when its directory is on disk', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-suggest-')); + const installed = path.join(root, 'zzzqqqwww'); + fs.mkdirSync(installed); + try { + const message = unknownRootCommandMessage(createProgram('', ''), 'zzzqqqwww', [root]); + + expect(message).toContain(`Site "zzzqqqwww" is installed at ${installed}`); + expect(message).toContain('failed to load'); + expect(message).not.toContain('is not installed'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('unknown namespace subcommand', () => { + it('suggests adapter status and lists the valid subcommands', () => { + const message = unknownSubcommandMessage(namespaceOf(createProgram('', ''), 'adapter'), 'list'); + + expect(message).toContain("error: unknown command 'list'"); + expect(message).toContain('Did you mean: webcmd adapter status'); + expect(message).toContain('Valid webcmd adapter commands: override, path, reset, source, status'); + }); + + it('lists valid subcommands even when nothing is close enough to suggest', () => { + const message = unknownSubcommandMessage(namespaceOf(createProgram('', ''), 'plugin'), 'zzzqqqwww'); + + expect(message).toContain('Valid webcmd plugin commands: catalog, create, install, list, search, uninstall, update'); + }); + + it('names the replacement for a retired subcommand', () => { + const message = unknownSubcommandMessage(namespaceOf(createProgram('', ''), 'browser'), 'fork'); + + expect(message).toContain('webcmd adapter override /'); + }); +}); + +describe('error paths write nothing to stdout', () => { + async function captureStdout(argv: string[]): Promise<{ stdout: string; exitCode: unknown }> { + const program = createProgram('', ''); + const previousExitCode = process.exitCode; + let stdout = ''; + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + stdout += String(chunk); + return true; + }); + const log = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout += args.join(' '); + }); + const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await program.parseAsync(argv, { from: 'user' }); + return { stdout, exitCode: process.exitCode }; + } finally { + process.exitCode = previousExitCode; + write.mockRestore(); + log.mockRestore(); + stderr.mockRestore(); + } + } + + it('does not print root help to stdout for an unknown root command', async () => { + expect(await captureStdout(['adapters', '--json'])).toEqual({ stdout: '', exitCode: 2 }); + }); + + it('does not print help to stdout for an unknown subcommand', async () => { + expect(await captureStdout(['adapter', 'list', 'rest'])).toEqual({ stdout: '', exitCode: 2 }); + }); +}); diff --git a/src/command-suggest.ts b/src/command-suggest.ts new file mode 100644 index 00000000..67dcfaac --- /dev/null +++ b/src/command-suggest.ts @@ -0,0 +1,170 @@ +/** + * "Did you mean" engine for unknown commands. + * + * A mistyped token used to fall straight through to `missingPluginGuidance`, + * telling the caller to search a plugin marketplace for a plugin that cannot + * exist (`webcmd adapters` → "Search: webcmd plugin search adapters"). Agents + * burned turns on those hunts. Everything registered on the program — built-in + * namespaces, their leaves, installed site adapters, external CLIs — is already + * in memory when the miss happens, so match against it instead. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Command } from 'commander'; +import { CLI_COMMAND } from './brand.js'; +import { missingPluginGuidance, PLUGINS_DIR, USER_CLIS_DIR } from './discovery.js'; + +/** + * High-priority overrides: intent that edit distance cannot infer. + * `marketplace` is nowhere near `plugin search`, but it is what people type. + */ +const CANONICAL_ROOT: Record = { + catalog: `${CLI_COMMAND} plugin catalog list`, + catalogs: `${CLI_COMMAND} plugin catalog list`, + command: `${CLI_COMMAND} list`, + commands: `${CLI_COMMAND} list`, + cmds: `${CLI_COMMAND} list`, + ls: `${CLI_COMMAND} list`, + marketplace: `${CLI_COMMAND} plugin search `, + plugins: `${CLI_COMMAND} plugin list`, + pluginlist: `${CLI_COMMAND} plugin list`, + search: `${CLI_COMMAND} plugin search `, +}; + +/** Subcommands that were removed and whose replacement lives elsewhere. */ +const RETIRED_SUBCOMMANDS: Record = { + 'browser fork': `${CLI_COMMAND} adapter override /`, +}; + +/** Same idea as CANONICAL_ROOT, one level down: intent, not spelling. */ +const CANONICAL_SUB: Record = { + 'adapter list': `${CLI_COMMAND} adapter status`, + 'adapter ls': `${CLI_COMMAND} adapter status`, + 'plugin ls': `${CLI_COMMAND} plugin list`, + 'session ls': `${CLI_COMMAND} session list`, + 'profile ls': `${CLI_COMMAND} profile list`, + 'external ls': `${CLI_COMMAND} external list`, +}; + +/** Levenshtein distance, two-row variant. */ +export function editDistance(a: string, b: string): number { + if (a === b) return 0; + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + for (let j = 1; j <= b.length; j++) { + row[j] = Math.min( + prev[j]! + 1, + row[j - 1]! + 1, + prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + prev = row; + } + return prev[b.length]!; +} + +/** A command the user could have meant: the token they'd type, and its full path. */ +type Candidate = { token: string; commandPath: string }; + +function collect(parent: Command, prefix: string, depth: number, out: Candidate[]): void { + for (const child of parent.commands) { + const name = child.name(); + const commandPath = prefix ? `${prefix} ${name}` : name; + out.push({ token: name, commandPath }); + for (const alias of child.aliases()) out.push({ token: alias, commandPath }); + if (depth > 0) collect(child, commandPath, depth - 1, out); + } +} + +export function commandCandidates(root: Command, prefix = ''): Candidate[] { + const out: Candidate[] = []; + collect(root, prefix, 2, out); + return out; +} + +/** + * Best matches for `token`, closest first, at most 3. + * Only long tokens tolerate two edits: at distance 2 a short token matches half + * the command surface, and a confidently wrong suggestion costs more than none. + */ +export function suggestCommands(token: string, candidates: Candidate[]): string[] { + const needle = token.toLowerCase(); + const threshold = needle.length >= 8 ? 2 : 1; + const best = new Map(); + for (const candidate of candidates) { + const distance = editDistance(needle, candidate.token.toLowerCase()); + if (distance > threshold) continue; + const existing = best.get(candidate.commandPath); + if (existing === undefined || distance < existing) best.set(candidate.commandPath, distance); + } + return [...best.entries()] + .sort((a, b) => a[1] - b[1] || a[0].length - b[0].length || a[0].localeCompare(b[0])) + .slice(0, 3) + .map(([commandPath]) => commandPath); +} + +function formatSuggestions(paths: string[]): string { + if (paths.length === 1) return `Did you mean: ${CLI_COMMAND} ${paths[0]}`; + return [`Did you mean one of:`, ...paths.map(p => ` ${CLI_COMMAND} ${p}`)].join('\n'); +} + +/** + * `~/.webcmd/clis/` or `~/.webcmd/plugins/` exists, so "not + * installed" would be a lie — the adapter is on disk and failed to register. + */ +function installedDirFor(site: string, dirs: string[]): string | undefined { + if (!/^[a-zA-Z0-9_.-]+$/.test(site) || site.startsWith('.')) return undefined; + for (const dir of dirs) { + const candidate = path.join(dir, site); + try { + if (fs.statSync(candidate).isDirectory()) return candidate; + } catch { /* not there */ } + } + return undefined; +} + +/** Message for an unknown root token. Caller writes it to stderr. */ +export function unknownRootCommandMessage( + program: Command, + name: string, + installDirs: string[] = [PLUGINS_DIR, USER_CLIS_DIR], +): string { + const canonical = CANONICAL_ROOT[name.toLowerCase()]; + if (canonical) return `Unknown command "${name}".\nDid you mean: ${canonical}`; + + const suggestions = suggestCommands(name, commandCandidates(program)); + if (suggestions.length > 0) return `Unknown command "${name}".\n${formatSuggestions(suggestions)}`; + + const installedDir = installedDirFor(name, installDirs); + if (installedDir) { + return [ + `Site "${name}" is installed at ${installedDir} but registered no commands.`, + 'The adapter failed to load; this is not a missing plugin.', + `Re-run with WEBCMD_VERBOSE=1 to see the load error, then: ${CLI_COMMAND} plugin update ${name}`, + ].join('\n'); + } + return missingPluginGuidance(name); +} + +/** Message for an unknown subcommand inside a namespace. Caller writes it to stderr. */ +export function unknownSubcommandMessage(namespace: Command, name: string): string { + const nsPath = namespace.name(); + const key = `${nsPath} ${name.toLowerCase()}`; + const retired = RETIRED_SUBCOMMANDS[key]; + const lines = [retired + ? `error: '${CLI_COMMAND} ${nsPath} ${name}' was removed. Use: ${retired}` + : `error: unknown command '${name}'`]; + if (!retired) { + const canonical = CANONICAL_SUB[key]; + if (canonical) lines.push(`Did you mean: ${canonical}`); + else { + const suggestions = suggestCommands(name, commandCandidates(namespace, nsPath)); + if (suggestions.length > 0) lines.push(formatSuggestions(suggestions)); + } + } + const valid = [...new Set(namespace.commands.map(child => child.name()))].sort(); + if (valid.length > 0) lines.push(`Valid ${CLI_COMMAND} ${nsPath} commands: ${valid.join(', ')}`); + return lines.join('\n'); +} diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index 60a3758d..dbe46f11 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -121,7 +121,7 @@ describe('hosted CLI process lifecycle', () => { 'Install using the installSource returned by search.', '', ].join('\n')); - expect(result.stdout).toContain('Local-only commands:'); + expect(result.stdout).toBe(''); await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }, 20_000); diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index f126fba4..c088a73a 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -493,7 +493,7 @@ describe('hosted root preflight call order', () => { expect(result).toEqual({ handled: true, exitCode: 2 }); expect(stderr.text()).toBe(MISSING_SITE_GUIDANCE); - expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); + expect(stdout.text()).toBe(''); }); it('preserves the literal separator when list receives a help-shaped excess argument', async () => { @@ -766,9 +766,10 @@ describe('hosted root preflight call order', () => { expect(local.stdout).not.toBe(''); expect(local.stderr).toBe(''); } else { - expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); + // Unknown site is an error path: stderr only, nothing on stdout. + expect(stdout.text()).toBe(''); expect(stderr.text()).toBe(MISSING_SITE_GUIDANCE); - expect(local.stdout).not.toBe(''); + expect(local.stdout).toBe(''); expect(local.stderr).toBe(MISSING_SITE_GUIDANCE); } expect(fetchImpl).toHaveBeenCalledTimes(1); @@ -817,9 +818,9 @@ describe('hosted root preflight call order', () => { }); expect(local).toMatchObject({ exitCode: 2, stderr: MISSING_SITE_GUIDANCE }); - expect(local.stdout).not.toBe(''); + expect(local.stdout).toBe(''); expect(hosted).toEqual({ handled: true, exitCode: local.exitCode }); - expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); + expect(stdout.text()).toBe(local.stdout); expect(stderr.text()).toBe(local.stderr); expect(fetchImpl).toHaveBeenCalledTimes(1); expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://api.example.com/v1/manifest'); diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 4a60b9cb..0a7ba35d 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -1122,7 +1122,9 @@ describe('runHostedCli', () => { 'Search: webcmd plugin search missing-site', 'Install using the installSource returned by search.', ].join('\n')); - expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); + // Error path: nothing on stdout, so a caller parsing stdout cannot mistake + // the root help for a successful response. + expect(stdout.text()).toBe(''); expect(fetchImpl).toHaveBeenCalledTimes(1); expect(String(fetchImpl.mock.calls[0]![0])).toMatch(/\/v1\/manifest$/); expect(fetchImpl.mock.calls.some(([url]) => /plugin|execute/.test(String(url)))).toBe(false); @@ -1599,7 +1601,7 @@ describe('runHostedCli', () => { expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); }); - it('writes unknown-site stderr before root-help stdout', async () => { + it('writes unknown-site guidance to stderr only', async () => { const order: string[] = []; const orderedSink = (label: string) => new Writable({ write(_chunk, _encoding, callback) { @@ -1616,7 +1618,7 @@ describe('runHostedCli', () => { }); expect(result.exitCode).toBe(2); - expect(order).toEqual(['stderr', 'stdout']); + expect(order).toEqual(['stderr']); }); it('does not resolve until a slow typed-error stderr write completes', async () => { diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 0425c0cf..8cf54cb8 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -415,20 +415,14 @@ async function dispatchHosted( await writeToStream(stdout, formatRootHelp(HOSTED_ROOT_HELP)); return; } - throw new CommanderCompatibleError( - `${missingPluginGuidance(site)}\n`, - EXIT_CODES.USAGE_ERROR, - formatRootHelp(HOSTED_ROOT_HELP), - ); + // No help on stdout: an error path that emits a well-formed document to + // stdout reads as success to anything parsing it. + throw new CommanderCompatibleError(`${missingPluginGuidance(site)}\n`, EXIT_CODES.USAGE_ERROR); } if (!commandName || commandName === '--help' || commandName === '-h') { const data = hostedSiteHelpData(manifest, site); if (!data) { - throw new CommanderCompatibleError( - `error: unknown command '${site}'\n`, - EXIT_CODES.USAGE_ERROR, - formatRootHelp(HOSTED_ROOT_HELP), - ); + throw new CommanderCompatibleError(`error: unknown command '${site}'\n`, EXIT_CODES.USAGE_ERROR); } await writeHostedHelp(stdout, args, data, renderHostedSiteHelp(manifest, site)); return; @@ -439,11 +433,7 @@ async function dispatchHosted( if (!normalized.literal && hasTerminalBeforeSeparator(args.slice(1), token => token === '--help' || token === '-h')) { const data = hostedSiteHelpData(manifest, site); if (!data) { - throw new CommanderCompatibleError( - `error: unknown command '${site}'\n`, - EXIT_CODES.USAGE_ERROR, - formatRootHelp(HOSTED_ROOT_HELP), - ); + throw new CommanderCompatibleError(`error: unknown command '${site}'\n`, EXIT_CODES.USAGE_ERROR); } await writeHostedHelp(stdout, args, data, renderHostedSiteHelp(manifest, site)); return;