diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index d492a2cb..96da742c 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -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. | diff --git a/src/command-suggest.test.ts b/src/command-suggest.test.ts index c2a2eb85..ca10105d 100644 --- a/src/command-suggest.test.ts +++ b/src/command-suggest.test.ts @@ -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'; @@ -41,6 +41,23 @@ describe('unknown root command', () => { expect(message).toContain('Did you mean: webcmd plugin search '); }); + 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'); @@ -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'); diff --git a/src/command-suggest.ts b/src/command-suggest.ts index bc7c46c0..9389d832 100644 --- a/src/command-suggest.ts +++ b/src/command-suggest.ts @@ -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'; @@ -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)}`; @@ -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(); diff --git a/src/completion-shared.ts b/src/completion-shared.ts index 3990dd96..cb412dde 100644 --- a/src/completion-shared.ts +++ b/src/completion-shared.ts @@ -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' },