diff --git a/src/completion-shared.test.ts b/src/completion-shared.test.ts new file mode 100644 index 00000000..2a4d336e --- /dev/null +++ b/src/completion-shared.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + HOSTED_BUILTIN_COMMANDS, + HOSTED_ROOT_HELP, + LOCAL_ONLY_COMMAND_HELP, +} from './completion-shared.js'; +import { formatRootHelp } from './command-presentation.js'; + +describe('hosted root help', () => { + const advertised = HOSTED_ROOT_HELP.commands.map(command => command.name.split(/\s/, 1)[0]!); + const localOnly = (HOSTED_ROOT_HELP.localOnlyCommands ?? []).map(command => command.name); + + it.each(['skills', 'update'])('advertises %s, which already runs locally in hosted mode', (name) => { + expect(advertised).toContain(name); + expect(localOnly).not.toContain(name); + }); + + it.each(['skills', 'update'])('offers %s in hosted completion', (name) => { + expect(HOSTED_BUILTIN_COMMANDS).toContain(name); + }); + + it('still lists daemon as local-only', () => { + expect(localOnly).toContain('daemon'); + }); + + it('never lists a command as both advertised and local-only', () => { + expect(advertised.filter(name => localOnly.includes(name))).toEqual([]); + }); + + it('drops the choose-local-mode footer from root help', () => { + expect(HOSTED_ROOT_HELP.localOnlyExplanation).toBeUndefined(); + expect(formatRootHelp(HOSTED_ROOT_HELP)).not.toContain(LOCAL_ONLY_COMMAND_HELP); + }); + + it('keeps the footer string exported for the daemon error hint', () => { + expect(LOCAL_ONLY_COMMAND_HELP).toBe( + 'Run `webcmd setup` and choose local mode to use local-only commands.', + ); + }); +}); diff --git a/src/completion-shared.ts b/src/completion-shared.ts index 64e01d16..700c7ae2 100644 --- a/src/completion-shared.ts +++ b/src/completion-shared.ts @@ -46,6 +46,8 @@ export const HOSTED_ROOT_HELP: RootHelpPresentation = { { name: 'list', description: 'List all available hosted CLI commands' }, { name: 'profile', description: 'Manage hosted browser profiles' }, { name: 'setup', description: 'Configure local or hosted mode' }, + { name: 'skills', description: 'Manage bundled Webcmd skills on this computer' }, + { name: 'update', description: 'Update the installed Webcmd CLI on this computer' }, { name: 'web', description: 'Fetch URLs locally without launching a browser. Use after a blocked, 403, or Cloudflare response.' }, ], localOnlyCommands: [ @@ -57,15 +59,31 @@ export const HOSTED_ROOT_HELP: RootHelpPresentation = { { name: 'doctor', description: 'Diagnose local browser bridge connectivity' }, { name: 'external', description: 'Manage local CLI passthrough commands' }, { name: 'plugin', description: 'Manage plugins installed on this computer' }, - { name: 'skills', description: 'Manage bundled skills on this computer' }, { name: 'validate', description: 'Validate local CLI definitions' }, { name: 'verify', description: 'Validate and smoke-test local adapters' }, ], - localOnlyExplanation: LOCAL_ONLY_COMMAND_HELP, }; -export const HOSTED_BUILTIN_COMMANDS = HOSTED_ROOT_HELP.commands - .map((command) => command.name.split(/\s/, 1)[0]!); +const LOCAL_CLIENT_ROOT_COMMANDS = new Set(['skills', 'update']); + +export function getHostedRootHelp(hasLocalClientCommandHandlers = true): RootHelpPresentation { + if (hasLocalClientCommandHandlers) return HOSTED_ROOT_HELP; + return { + ...HOSTED_ROOT_HELP, + commands: HOSTED_ROOT_HELP.commands.filter(command => !LOCAL_CLIENT_ROOT_COMMANDS.has(command.name.split(/\s/, 1)[0]!)), + }; +} + +export function getHostedBuiltinCommands(hasLocalClientCommandHandlers = true): string[] { + return getHostedRootHelp(hasLocalClientCommandHandlers).commands + .map((command) => command.name.split(/\s/, 1)[0]!); +} + +export function isLocalClientRootCommand(command: string | undefined): boolean { + return command !== undefined && LOCAL_CLIENT_ROOT_COMMANDS.has(command); +} + +export const HOSTED_BUILTIN_COMMANDS = getHostedBuiltinCommands(); // ── Shell script generators ──────────────────────────────────────────────── diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index dbe46f11..87139d1c 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -152,6 +152,33 @@ describe('hosted CLI process lifecycle', () => { expect(fixture.requests).toEqual([]); }, 20_000); + it.each([ + { + name: 'profile before skills', + argv: ['--profile', 'work', 'skills', '--help'], + help: 'Usage: webcmd skills [options] [command]', + }, + { + name: 'workspace before update', + argv: ['--workspace', 'ws', 'update', '--help'], + help: 'update [options]', + }, + { + name: 'session before skills', + argv: ['--session', 'session_work', 'skills', '--help'], + help: 'Usage: webcmd skills [options] [command]', + }, + ])('routes $name to the local client command handler', async ({ argv, help }) => { + const fixture = await createHostedFixture('success'); + + const result = await runCli(argv, fixture.env); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(help); + expect(result.stderr).toBe(''); + expect(fixture.requests).toEqual([]); + }, 20_000); + it('keeps hosted auth on Cloud without local discovery', async () => { const fixture = await createHostedFixture('success'); diff --git a/src/hosted/manifest.test.ts b/src/hosted/manifest.test.ts index ea0ef8d3..e5eda35e 100644 --- a/src/hosted/manifest.test.ts +++ b/src/hosted/manifest.test.ts @@ -151,7 +151,6 @@ describe('hosted manifest helpers', () => { expect(stdout.text()).toMatch(/profile\s+Manage hosted browser profiles/); expect(stdout.text()).toContain('--profile '); expect(stdout.text()).toContain('Local-only commands:'); - expect(stdout.text()).toContain('Run `webcmd setup` and choose local mode to use local-only commands.'); }); it('completes private hosted manifest commands without local discovery', async () => { @@ -226,7 +225,7 @@ describe('hosted manifest helpers', () => { fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }), }); - expect(stdout.text().trim().split('\n')).toEqual(['artifact', 'browser', 'completion', 'github', 'list', 'profile', 'setup', 'web']); + expect(stdout.text().trim().split('\n')).toEqual(['artifact', 'browser', 'completion', 'github', 'list', 'profile', 'setup', 'skills', 'update', 'web']); const siteHelp = sink(); await runHostedCli(['web', '--help'], { diff --git a/src/hosted/programmatic-differential.test.ts b/src/hosted/programmatic-differential.test.ts index 459507b9..d8ee4aec 100644 --- a/src/hosted/programmatic-differential.test.ts +++ b/src/hosted/programmatic-differential.test.ts @@ -58,7 +58,6 @@ function runProgrammatic(argv: string[], files?: readonly HostedVirtualFile[]) { } const FIXTURES: { name: string; argv: string[]; files?: readonly HostedVirtualFile[] }[] = [ - { name: 'root help', argv: ['--help'] }, { name: 'version', argv: ['--version'] }, { name: 'list', argv: ['list'] }, { name: 'list json', argv: ['list', '-f', 'json'] }, diff --git a/src/hosted/programmatic.test.ts b/src/hosted/programmatic.test.ts index 1e49a587..f519b288 100644 --- a/src/hosted/programmatic.test.ts +++ b/src/hosted/programmatic.test.ts @@ -31,6 +31,75 @@ function fakeCloud(handler?: (url: string, init?: RequestInit) => Response): typ describe('runHostedProgrammatic', () => { afterEach(() => vi.restoreAllMocks()); + it('does not advertise client-owned commands in root help', async () => { + const result = await runHostedProgrammatic({ + argv: ['--help'], + apiBaseUrl: 'http://127.0.0.1:8787', + accessToken: 'oauth-access-token', + fetchImpl: fakeCloud(), + }); + + expect(result).toMatchObject({ exitCode: 0, stderr: '' }); + expect(result.stdout).not.toContain('skills'); + expect(result.stdout).not.toContain('update'); + }); + + it('does not offer client-owned commands in completion', async () => { + const result = await runHostedProgrammatic({ + argv: ['--get-completions', '--cursor', '0'], + apiBaseUrl: 'http://127.0.0.1:8787', + accessToken: 'oauth-access-token', + fetchImpl: fakeCloud(), + }); + + expect(result).toMatchObject({ exitCode: 0, stderr: '' }); + const completions = result.stdout.split('\n'); + expect(completions).not.toContain('skills'); + expect(completions).not.toContain('update'); + }); + + it('does not offer manifest sites that collide with client-owned commands', async () => { + const result = await runHostedProgrammatic({ + argv: ['--get-completions', '--cursor', '0'], + apiBaseUrl: 'http://127.0.0.1:8787', + accessToken: 'oauth-access-token', + fetchImpl: fakeCloud(() => new Response(JSON.stringify({ + ...manifest, + commands: ['skills', 'update'].map(site => ({ + site, + name: 'status', + command: `${site}/status`, + description: `Show ${site} status`, + access: 'read', + strategy: 'PUBLIC', + browser: false, + args: [], + columns: [], + })), + }), { status: 200, headers: { 'content-type': 'application/json' } })), + }); + + expect(result).toMatchObject({ exitCode: 0, stderr: '' }); + const completions = result.stdout.split('\n'); + expect(completions).not.toContain('skills'); + expect(completions).not.toContain('update'); + }); + + it.each(['skills', 'update'])('rejects %s help instead of returning generic root help', async (command) => { + const fetchImpl = vi.fn(fakeCloud()); + const result = await runHostedProgrammatic({ + argv: [command, '--help'], + apiBaseUrl: 'http://127.0.0.1:8787', + accessToken: 'oauth-access-token', + fetchImpl, + }); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(`error: unknown command '${command}'\n`); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('returns captured stdout and a zero exit code', async () => { const result = await runHostedProgrammatic({ argv: ['list', '-f', 'json'], diff --git a/src/hosted/programmatic.ts b/src/hosted/programmatic.ts index 4c6e250c..7b46e15e 100644 --- a/src/hosted/programmatic.ts +++ b/src/hosted/programmatic.ts @@ -90,6 +90,7 @@ export async function runHostedProgrammatic( ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), ...(options.now ? { now: options.now } : {}), ...(options.enableServerWebFetch === true ? { enableServerWebFetch: true } : {}), + hasLocalClientCommandHandlers: false, onTrustedCommandResolution: resolution => { trustedResolution = resolution; }, }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 19c58c44..0206b3ad 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -16,8 +16,9 @@ import { BrowserSessionArgvError, rejectMisplacedSessionSelectorArgv, rejectPosi import { addOutputFormatOption, CommanderStructuralError, MissingRequiredPositionalError, outputFormatIsExplicit, parseOutputFormat, requestedOutputFormat, resolveCommandFromArgv, structuralErrorFromCommander } from '../command-surface.js'; import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { - HOSTED_BUILTIN_COMMANDS, - HOSTED_ROOT_HELP, + getHostedBuiltinCommands, + getHostedRootHelp, + isLocalClientRootCommand, LOCAL_ONLY_COMMAND_HELP, } from '../completion-shared.js'; import { splitAdapterCommandKey } from '../adapter-source.js'; @@ -87,6 +88,8 @@ export interface HostedRunnerOptions { now?: () => number; /** Explicitly grants the hosted runner public-network-only web fetch authority. */ enableServerWebFetch?: boolean; + /** True when the installed executable can handle client-owned root commands locally. */ + hasLocalClientCommandHandlers?: boolean; /** @internal Receives sanitized manifest resolution metadata for embedders. */ onTrustedCommandResolution?: (resolution: TrustedCommandResolution) => void; /** Supplies `--stdin` content without reading `process.stdin`. */ @@ -189,6 +192,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { opts.homeDir ?? opts.env?.HOME ?? homedir(), io, opts.enableServerWebFetch === true, + opts.hasLocalClientCommandHandlers !== false, opts.signal, opts.onTrustedCommandResolution, ); @@ -251,12 +255,14 @@ async function dispatchHosted( homeDir: string, io: HostedDispatchIo = { fileIo: realHostedFileIo }, enableServerWebFetch = false, + hasLocalClientCommandHandlers = true, signal?: AbortSignal, onResolvedCommand?: (resolution: TrustedCommandResolution) => void, ): Promise { + const rootHelp = getHostedRootHelp(hasLocalClientCommandHandlers); const normalized = parseHostedRootCommandSurface(argv); if (normalized.kind === 'help') { - const help = formatRootHelp(HOSTED_ROOT_HELP); + const help = formatRootHelp(rootHelp); if (normalized.exitCode !== EXIT_CODES.SUCCESS) { throw new CommanderCompatibleError(help, normalized.exitCode); } @@ -269,10 +275,13 @@ async function dispatchHosted( } if (normalized.kind === 'completion') { const manifest = await getPresentationManifest(client, enableServerWebFetch); - await writeToStream(stdout, hostedCompletions(manifest, normalized.argv).join('\n') + '\n'); + await writeToStream(stdout, hostedCompletions(manifest, normalized.argv, hasLocalClientCommandHandlers).join('\n') + '\n'); return; } const args = normalized.argv; + if (!hasLocalClientCommandHandlers && isLocalClientRootCommand(args[0])) { + throw new CommanderCompatibleError(`error: unknown command '${args[0]}'\n`, EXIT_CODES.USAGE_ERROR); + } if (args[0] === 'completion') { const parsed = parseHostedCompletionSurface(args.slice(1), normalized.literal); if (parsed.kind === 'help') { @@ -501,7 +510,7 @@ async function dispatchHosted( return; } if (unknownRoot.help) { - await writeToStream(stdout, formatRootHelp(HOSTED_ROOT_HELP)); + await writeToStream(stdout, formatRootHelp(rootHelp)); return; } // No help on stdout: an error path that emits a well-formed document to @@ -1622,7 +1631,7 @@ function hasTerminalBeforeSeparator( return false; } -function hostedCompletions(manifest: HostedManifest, argv: string[]): string[] { +function hostedCompletions(manifest: HostedManifest, argv: string[], hasLocalClientCommandHandlers = true): string[] { const index = argv.indexOf('--get-completions'); const rest = index === -1 ? argv : argv.slice(index + 1); const words: string[] = []; @@ -1634,11 +1643,13 @@ function hostedCompletions(manifest: HostedManifest, argv: string[]): string[] { words.push(rest[i]!); } } + const commands = hostedCommands(manifest) + .filter(command => hasLocalClientCommandHandlers || !isLocalClientRootCommand(command.site)); return getCommandCompletionCandidates( - hostedCommands(manifest), + commands, words, Number.isFinite(cursor) ? cursor! : words.length, - HOSTED_BUILTIN_COMMANDS.filter(command => command !== 'web'), + getHostedBuiltinCommands(hasLocalClientCommandHandlers).filter(command => command !== 'web'), ); } diff --git a/src/main.ts b/src/main.ts index 55789529..25b4f3b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,7 @@ const USER_PLUGINS = path.join(os.homedir(), CONFIG_DIR_NAME, 'plugins'); // ── Ultra-fast path: lightweight commands bypass full discovery ────────── // These are high-frequency or trivial paths that must not pay the startup tax. const argv = process.argv.slice(2); +const normalizedRootArgv = normalizedRootArgvFor(argv); if (typeof (globalThis as { Bun?: unknown }).Bun === 'undefined' && !isSupportedNodeVersion(process.version)) { process.stderr.write( @@ -75,10 +76,10 @@ if (!fastPathHandled) { if (argv[0] === 'setup') { const { runHostedSetup } = await import('./hosted/setup.js'); process.exitCode = await runHostedSetup({ argv: argv.slice(1) }); - } else if (argv[0] === 'skills' || argv[0] === 'update') { + } else if (normalizedRootArgv?.[0] === 'skills' || normalizedRootArgv?.[0] === 'update') { const { createProgram } = await import('./cli.js'); await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' }); - } else if (isWebFetch(argv)) { + } else if (isWebFetch(normalizedRootArgv)) { const { runWebFetchCommand } = await import('./fetch/command.js'); await runWebFetchCommand(argv); } else { @@ -87,7 +88,10 @@ if (!fastPathHandled) { const { runHostedCli } = await import('./hosted/runner.js'); // The installed CLI already owns local web/fetch transport authority. // Programmatic embedders remain opt-in and default to no network access. - const result = await runHostedCli(argv, { enableServerWebFetch: true }); + const result = await runHostedCli(argv, { + enableServerWebFetch: true, + hasLocalClientCommandHandlers: true, + }); process.exitCode = result.exitCode; } else { const { installDaemonRunSignalCancellation } = await import('./signal-cancel.js'); @@ -101,15 +105,19 @@ if (!fastPathHandled) { } } -function isWebFetch(args: readonly string[]): boolean { +function normalizedRootArgvFor(args: readonly string[]): string[] | undefined { try { const parsed = parseHostedRootCommandSurface(args); - return parsed.kind === 'dispatch' && parsed.argv[0] === 'web' && parsed.argv[1] === 'fetch'; + return parsed.kind === 'dispatch' ? parsed.argv : undefined; } catch { - return false; + return undefined; } } +function isWebFetch(args: readonly string[] | undefined): boolean { + return args?.[0] === 'web' && args[1] === 'fetch'; +} + async function runLocalMain(): Promise { // Fast path: --get-completions — read from manifest, skip discovery const getCompIdx = process.argv.indexOf('--get-completions');