Skip to content
40 changes: 40 additions & 0 deletions src/completion-shared.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
);
});
});
26 changes: 22 additions & 4 deletions src/completion-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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 ────────────────────────────────────────────────

Expand Down
27 changes: 27 additions & 0 deletions src/hosted/main-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
3 changes: 1 addition & 2 deletions src/hosted/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ describe('hosted manifest helpers', () => {
expect(stdout.text()).toMatch(/profile\s+Manage hosted browser profiles/);
expect(stdout.text()).toContain('--profile <name>');
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 () => {
Expand Down Expand Up @@ -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'], {
Expand Down
1 change: 0 additions & 1 deletion src/hosted/programmatic-differential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
69 changes: 69 additions & 0 deletions src/hosted/programmatic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions src/hosted/programmatic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; },
});

Expand Down
27 changes: 19 additions & 8 deletions src/hosted/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -251,12 +255,14 @@ async function dispatchHosted(
homeDir: string,
io: HostedDispatchIo = { fileIo: realHostedFileIo },
enableServerWebFetch = false,
hasLocalClientCommandHandlers = true,
signal?: AbortSignal,
onResolvedCommand?: (resolution: TrustedCommandResolution) => void,
): Promise<void> {
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);
}
Expand All @@ -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') {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[] = [];
Expand All @@ -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'),
);
}

Expand Down
20 changes: 14 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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');
Expand All @@ -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<void> {
// Fast path: --get-completions — read from manifest, skip discovery
const getCompIdx = process.argv.indexOf('--get-completions');
Expand Down
Loading