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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Ordinary `curl` is neither required nor automatically authenticated.
| `setup` | Choose local or hosted mode interactively. |
| `doctor` | Diagnose browser bridge and daemon connectivity. |
| `daemon` | Manage the local Webcmd daemon: status, stop, and restart. |
| `artifact` | Download a hosted execution artifact to `--output`. |
| `artifact` | Hosted mode only. Download a hosted execution artifact to `--output`. |
| `browser` | Agent-facing browser runtime for exploration and verification. |
| `web` | Local URL fetch helpers. |
| `profile` | List, rename, and select browser runtime profiles. |
Expand Down
37 changes: 36 additions & 1 deletion src/command-suggest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { handleProgramParseError } from './cli-error-report.js';
import { createProgram } from './cli.js';
import { editDistance, isReservedRootCommand, unknownRootCommandMessage, unknownSubcommandMessage } from './command-suggest.js';
import { editDistance, isHostedOnlyRootCommand, isReservedRootCommand, unknownRootCommandMessage, unknownSubcommandMessage } from './command-suggest.js';
import { HOSTED_ROOT_HELP } from './completion-shared.js';
import { WEBCMD_ROOT_COMMANDS } from './hooks.js';

Expand Down Expand Up @@ -41,6 +41,23 @@ describe('unknown root command', () => {
expect(message).toContain('Did you mean: webcmd plugin search <query>');
});

it('names hosted-only mode instead of sending artifact to a plugin hunt', () => {
const message = unknownRootCommandMessage(createProgram('', ''), 'artifact');

expect(message).toContain('"artifact" is a hosted-mode command');
expect(message).toContain('choose hosted mode');
// the plugin does not exist, so the old advice could only waste a turn
expect(message).not.toContain('plugin search');
expect(message).not.toContain('is not installed');
});

it('answers the same way regardless of the case typed', () => {
const message = unknownRootCommandMessage(createProgram('', ''), 'Artifact');

expect(message).toContain('"Artifact" is a hosted-mode command');
expect(message).not.toContain('plugin search');
});

it('still guides a genuinely unknown token to plugin search', () => {
const message = unknownRootCommandMessage(createProgram('', ''), 'zzzqqqwww');

Expand Down Expand Up @@ -77,6 +94,24 @@ describe('reserved roots', () => {
});
});

describe('hosted-only roots', () => {
it('is exactly the hosted surface local mode does not register', () => {
// derived, not listed: a hosted command added later is covered for free
for (const name of WEBCMD_ROOT_COMMANDS) expect(isHostedOnlyRootCommand(name)).toBe(false);
expect(isHostedOnlyRootCommand('artifact')).toBe(true);
expect(isHostedOnlyRootCommand('github')).toBe(false);
});

it('never claims a locally served command is hosted-only', () => {
// `web` ships in both surfaces; calling it hosted-only would be a new lie
expect(isHostedOnlyRootCommand('web')).toBe(false);
expect(isHostedOnlyRootCommand('browser')).toBe(false);
expect(isHostedOnlyRootCommand('doctor')).toBe(false);
// `setup` is served locally by main.ts before Commander parses argv
expect(isHostedOnlyRootCommand('setup')).toBe(false);
});
});

describe('unknown namespace subcommand', () => {
it('suggests adapter status and lists the valid subcommands', () => {
const message = unknownSubcommandMessage(namespaceOf(createProgram('', ''), 'adapter'), 'list');
Expand Down
29 changes: 28 additions & 1 deletion src/command-suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Command } from 'commander';
import { CLI_COMMAND } from './brand.js';
import { HOSTED_ROOT_HELP } from './completion-shared.js';
import { HOSTED_ONLY_COMMAND_HELP, HOSTED_ROOT_HELP } from './completion-shared.js';
import { getAdapterLoadFailures, missingPluginGuidance, PLUGINS_DIR, USER_CLIS_DIR } from './discovery.js';
import { WEBCMD_ROOT_COMMANDS } from './hooks.js';

Expand Down Expand Up @@ -136,6 +136,15 @@ export function unknownRootCommandMessage(
const canonical = CANONICAL_ROOT[name.toLowerCase()];
if (canonical) return `Unknown command "${name}".\nDid you mean: ${canonical}`;

// An exact hosted-command name is stronger evidence than any edit-distance
// guess below it, so this is settled before suggestions run.
if (isHostedOnlyRootCommand(name.toLowerCase())) {
return [
`"${name}" is a hosted-mode command and this installation is in local mode.`,
HOSTED_ONLY_COMMAND_HELP,
].join('\n');
}

const suggestions = suggestCommands(name, commandCandidates(program));
if (suggestions.length > 0) return `Unknown command "${name}".\n${formatSuggestions(suggestions)}`;

Expand Down Expand Up @@ -164,6 +173,24 @@ export function isReservedRootCommand(name: string): boolean {
|| HOSTED_ROOT_HELP.commands.some(command => command.name.split(/\s/, 1)[0] === name);
}

/**
* A command hosted mode serves that local mode never registers.
*
* Derived rather than listed, so a hosted command added later is covered
* without touching this file. Today that is `artifact`, which the CLI reference
* lists among the top-level commands: locally it resolved to "Site is not
* installed. Search: webcmd plugin search artifact" — a hunt for a plugin that
* cannot exist.
*/
export function isHostedOnlyRootCommand(name: string): boolean {
if (WEBCMD_ROOT_COMMANDS.has(name)) return false;
// `setup` chooses the mode, so both modes serve it. It is absent from
// WEBCMD_ROOT_COMMANDS only because main.ts answers it before Commander sees
// the argv at all, which would otherwise read here as hosted-only.
if (name === 'setup') return false;
return HOSTED_ROOT_HELP.commands.some(command => command.name.split(/\s/, 1)[0] === 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();
Expand Down
2 changes: 2 additions & 0 deletions src/completion-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const BUILTIN_COMMANDS = [
];

export const LOCAL_ONLY_COMMAND_HELP = 'Run `webcmd setup` and choose local mode to use local-only commands.';
/** Mirror of {@link LOCAL_ONLY_COMMAND_HELP} for a hosted command reached from local mode. */
export const HOSTED_ONLY_COMMAND_HELP = 'Run `webcmd setup` and choose hosted mode to use hosted-only commands.';

const HOSTED_CLIENT_ROOT_COMMANDS: readonly RootHelpCommand[] = [
{ name: 'adapter', description: 'Manage hosted adapter sources and overrides' },
Expand Down
Loading