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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 22 additions & 40 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,22 +84,6 @@ function parseSessionListLimit(value: string): number {
return parsed;
}

function rootCommandSuggestion(name: string): string | undefined {
const canonical: Record<string, string> = {
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 <query>`,
plugins: `${CLI_COMMAND} plugin list`,
pluginlist: `${CLI_COMMAND} plugin list`,
search: `${CLI_COMMAND} plugin search <query>`,
};
return canonical[name.toLowerCase()];
}

type BrowserNetworkItem = {
url: string;
method: string;
Expand Down Expand Up @@ -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<string, string> = {
fork: `${CLI_COMMAND} adapter override <site>/<command>`,
};
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) ──

Expand Down Expand Up @@ -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;
}

Expand Down
117 changes: 117 additions & 0 deletions src/command-suggest.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createProgram>, 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 <query>');
});

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 <site>/<command>');
});
});

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 });
});
});
170 changes: 170 additions & 0 deletions src/command-suggest.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 <query>`,
plugins: `${CLI_COMMAND} plugin list`,
pluginlist: `${CLI_COMMAND} plugin list`,
search: `${CLI_COMMAND} plugin search <query>`,
};

/** Subcommands that were removed and whose replacement lives elsewhere. */
const RETIRED_SUBCOMMANDS: Record<string, string> = {
'browser fork': `${CLI_COMMAND} adapter override <site>/<command>`,
};

/** Same idea as CANONICAL_ROOT, one level down: intent, not spelling. */
const CANONICAL_SUB: Record<string, string> = {
'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<string, number>();
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/<site>` or `~/.webcmd/plugins/<site>` 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');
}
Loading
Loading