Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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