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
4 changes: 3 additions & 1 deletion skill-src/cli/webcmd-usage/SKILL.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Use this fallback order:
| --- | --- |
| `-f, --format <fmt>` | `table` in TTY by default; `yaml` outside TTY by default; also supports `json`, `plain`, `md`, `csv`. Agents usually want `-f json`. |
| `-v, --verbose` | Debug logs and stack traces on failure; also sets `WEBCMD_VERBOSE=1`. |
| `--workspace <id>` | Workspace id/slug. Same as `WEBCMD_WORKSPACE`. First use creates it. |

Command-specific flags such as `--limit` and `--filter` are not universal. Read `<site> <command> --help`.

Expand All @@ -164,6 +165,7 @@ Some commands override the default through `cmd.defaultFormat`; read `--help`.
| `WEBCMD_CACHE_DIR` | `~/.webcmd/cache` | Network capture and browser-state cache. |
| `WEBCMD_WINDOW` | `background` | Explicitly override browser window mode with `foreground` or `background`. |
| `WEBCMD_VERBOSE` | `false` | Verbose logging, also triggered by `-v`. |
| `WEBCMD_WORKSPACE` | unset | Workspace id/slug when `--workspace` is omitted. |

## Self-Repair

Expand Down Expand Up @@ -198,7 +200,7 @@ argument, transient, or unreproduced failures.

Storage paths:

- Private: `~/.webcmd/clis/<site>/<command>.js`. This path takes precedence over the same command from an installed plugin; `webcmd list`'s `origin` column shows which space each command resolves from.
- Private: `~/.webcmd/clis/<site>/<command>.js`. This path takes precedence over the same command from an installed plugin; `webcmd list`'s `origin` column shows which space each command resolves from (`builtin`, `plugin:<name>`, `local`, `override:<plugin>`).
- Public (main repo, official or community): `plugins/<plugin-name>/` with its own `webcmd-plugin.json`

The main Webcmd repo is itself a plugin monorepo: there is no separate "official bundle" location. Every public adapter belongs under `plugins/<plugin-name>/`. Do not hand-edit the root `webcmd-plugin.json` or generated README catalog; after merge, the community-plugin sync discovers each plugin manifest and updates both automatically.
Expand Down
4 changes: 3 additions & 1 deletion skills/webcmd-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Use this fallback order:
| --- | --- |
| `-f, --format <fmt>` | `table` in TTY by default; `yaml` outside TTY by default; also supports `json`, `plain`, `md`, `csv`. Agents usually want `-f json`. |
| `-v, --verbose` | Debug logs and stack traces on failure; also sets `WEBCMD_VERBOSE=1`. |
| `--workspace <id>` | Workspace id/slug. Same as `WEBCMD_WORKSPACE`. First use creates it. |

Command-specific flags such as `--limit` and `--filter` are not universal. Read `<site> <command> --help`.

Expand All @@ -164,6 +165,7 @@ Some commands override the default through `cmd.defaultFormat`; read `--help`.
| `WEBCMD_CACHE_DIR` | `~/.webcmd/cache` | Network capture and browser-state cache. |
| `WEBCMD_WINDOW` | `background` | Explicitly override browser window mode with `foreground` or `background`. |
| `WEBCMD_VERBOSE` | `false` | Verbose logging, also triggered by `-v`. |
| `WEBCMD_WORKSPACE` | unset | Workspace id/slug when `--workspace` is omitted. |

## Self-Repair

Expand Down Expand Up @@ -198,7 +200,7 @@ argument, transient, or unreproduced failures.

Storage paths:

- Private: `~/.webcmd/clis/<site>/<command>.js`. This path takes precedence over the same command from an installed plugin; `webcmd list`'s `origin` column shows which space each command resolves from.
- Private: `~/.webcmd/clis/<site>/<command>.js`. This path takes precedence over the same command from an installed plugin; `webcmd list`'s `origin` column shows which space each command resolves from (`builtin`, `plugin:<name>`, `local`, `override:<plugin>`).
- Public (main repo, official or community): `plugins/<plugin-name>/` with its own `webcmd-plugin.json`

The main Webcmd repo is itself a plugin monorepo: there is no separate "official bundle" location. Every public adapter belongs under `plugins/<plugin-name>/`. Do not hand-edit the root `webcmd-plugin.json` or generated README catalog; after merge, the community-plugin sync discovers each plugin manifest and updates both automatically.
Expand Down
1 change: 1 addition & 0 deletions src/command-origin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ describe('classifyCommandOrigin', () => {
expect(formatCommandOrigin({ kind: 'plugin', plugin: 'linkedin' })).toBe('plugin:linkedin');
expect(formatCommandOrigin({ kind: 'override', plugin: 'linkedin' })).toBe('override:linkedin');
expect(formatCommandOrigin({ kind: 'local' })).toBe('local');
expect(formatCommandOrigin({ kind: 'builtin' })).toBe('builtin');
});
});
17 changes: 16 additions & 1 deletion src/command-suggest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ 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, unknownRootCommandMessage, unknownSubcommandMessage } from './command-suggest.js';
import { editDistance, isReservedRootCommand, unknownRootCommandMessage, unknownSubcommandMessage } from './command-suggest.js';
import { HOSTED_ROOT_HELP } from './completion-shared.js';
import { WEBCMD_ROOT_COMMANDS } from './hooks.js';

function namespaceOf(program: ReturnType<typeof createProgram>, name: string) {
return program.commands.find(command => command.name() === name)!;
Expand Down Expand Up @@ -62,6 +64,19 @@ describe('unknown root command', () => {
});
});

describe('reserved roots', () => {
it('matches WEBCMD_ROOT_COMMANDS plus hosted root help, not stale extras', () => {
for (const name of WEBCMD_ROOT_COMMANDS) expect(isReservedRootCommand(name)).toBe(true);
for (const command of HOSTED_ROOT_HELP.commands) {
expect(isReservedRootCommand(command.name.split(/\s/, 1)[0]!)).toBe(true);
}
expect(isReservedRootCommand('artifact')).toBe(true);
expect(isReservedRootCommand('setup')).toBe(true);
expect(isReservedRootCommand('tab')).toBe(false);
expect(isReservedRootCommand('github')).toBe(false);
});
});

describe('unknown namespace subcommand', () => {
it('suggests adapter status and lists the valid subcommands', () => {
const message = unknownSubcommandMessage(namespaceOf(createProgram('', ''), 'adapter'), 'list');
Expand Down
11 changes: 11 additions & 0 deletions src/command-suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ 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 { getAdapterLoadFailures, missingPluginGuidance, PLUGINS_DIR, USER_CLIS_DIR } from './discovery.js';
import { WEBCMD_ROOT_COMMANDS } from './hooks.js';

/**
* High-priority overrides: intent that edit distance cannot infer.
Expand Down Expand Up @@ -153,6 +155,15 @@ export function unknownRootCommandMessage(
return missingPluginGuidance(name);
}

export function unknownSiteCommandHint(site: string, commandName: string): string {
return `To author this command: ${CLI_COMMAND} browser init ${site}/${commandName}`;
}

export function isReservedRootCommand(name: string): boolean {
return WEBCMD_ROOT_COMMANDS.has(name)
|| 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
9 changes: 8 additions & 1 deletion src/command-surface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command, CommanderError } from 'commander';
import { isReservedRootCommand, unknownSiteCommandHint } from './command-suggest.js';
import { ArgumentError, CliError, EXIT_CODES, type ErrorEnvelope } from './errors.js';
import type { Arg, CliCommand, CommandArgs } from './registry.js';

Expand Down Expand Up @@ -135,7 +136,13 @@ export function structuralHelpText(code: string, command: Command): string | und
export function formatStructuralError(err: CommanderError, command: Command): string {
const help = structuralHelpText(err.code, command);
const message = err.message.replace(/^error:\s*/i, '');
return `error: ${message}\n${help ? `help: ${help}\n` : ''}`;
const unknown = err.code === 'commander.unknownCommand'
? /unknown command '([^']+)'/i.exec(err.message)?.[1]
: undefined;
const author = unknown && command.parent && !isReservedRootCommand(command.name())
? `${unknownSiteCommandHint(command.name(), unknown)}\n`
: '';
return `error: ${message}\n${help ? `help: ${help}\n` : ''}${author}`;
}

export function structuralErrorFromCommander(
Expand Down
10 changes: 10 additions & 0 deletions src/completion-shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,14 @@ describe('hosted root help', () => {
{ name: 'daemon', description: 'Manage the local Webcmd daemon' },
]);
});

it('advertises --workspace and WEBCMD_WORKSPACE in hosted root help', () => {
const help = formatRootHelp(HOSTED_ROOT_HELP);
expect(help).toContain('--workspace <id>');
expect(help).toContain('WEBCMD_WORKSPACE');
});

it.each(['session', 'site'])('lists the working %s group in hosted root help', (name) => {
expect(HOSTED_ROOT_HELP.commands.map(command => command.name.split(/\s/, 1)[0]!)).toContain(name);
});
});
3 changes: 3 additions & 0 deletions src/completion-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ const HOSTED_CLIENT_ROOT_COMMANDS: readonly RootHelpCommand[] = [
{ name: 'list', description: 'List all available hosted CLI commands' },
{ name: 'plugin', description: 'Manage Webcmd plugins' },
{ name: 'profile', description: 'Manage hosted browser profiles' },
{ name: 'session', description: 'Create, list, and close browser Sessions' },
{ name: 'setup', description: 'Configure local or hosted mode' },
{ name: 'site', description: 'Read and write per-site memory: notes, endpoints, field maps, fixtures' },
{ 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.' },
Expand Down Expand Up @@ -65,6 +67,7 @@ const HOSTED_ROOT_HELP_BASE: Omit<RootHelpPresentation, 'commands'> = {
],
options: [
{ flags: '--profile <name>', description: 'Browser profile/context alias for browser runtime commands' },
{ flags: '--workspace <id>', description: 'Hosted workspace id/slug; also WEBCMD_WORKSPACE' },
{ flags: '-V, --version', description: 'Output the version number' },
{ flags: '-h, --help', description: 'Display help for command' },
],
Expand Down
12 changes: 6 additions & 6 deletions src/hosted/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ describe('HostedClient', () => {
it('advertises live-view and hosted-core capability tokens', async () => {
const fetchImpl = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
expect(new Headers(init?.headers).get('x-webcmd-client-capabilities'))
.toBe('hosted-live-view-v1, hosted-core-commands-v1');
.toBe('hosted-live-view-v1, hosted-core-commands-v1, hosted-command-origin-v1');
return jsonResponse({ ok: true, manifest });
});
await new HostedClient(clientOptions(fetchImpl)).getManifest();
Expand Down Expand Up @@ -452,11 +452,11 @@ describe('HostedClient', () => {
url: 'https://api.example.com/v1/sessions',
method: 'POST',
body: '{"name":"Work Project","profile":"work"}',
liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1',
liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1, hosted-command-origin-v1',
});
expect(requests.slice(1).map(({ url, method, liveViewCapability }) => ({ url, method, liveViewCapability }))).toEqual([
{ url: 'https://api.example.com/v1/sessions?profile=default&limit=20', method: 'GET', liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1' },
{ url: 'https://api.example.com/v1/sessions/session_wire/close', method: 'POST', liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1' },
{ url: 'https://api.example.com/v1/sessions?profile=default&limit=20', method: 'GET', liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1, hosted-command-origin-v1' },
{ url: 'https://api.example.com/v1/sessions/session_wire/close', method: 'POST', liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1, hosted-command-origin-v1' },
]);
});

Expand Down Expand Up @@ -504,7 +504,7 @@ describe('HostedClient', () => {
expect(prepareBody).toEqual({
command: 'github/whoami', profile: 'work', session: 'session_work', executionScope: 'profile',
});
expect(prepareCapability).toBe('hosted-live-view-v1, hosted-core-commands-v1');
expect(prepareCapability).toBe('hosted-live-view-v1, hosted-core-commands-v1, hosted-command-origin-v1');
await expect(client.prepareExecution({ command: 'github/unknown' })).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
});

Expand Down Expand Up @@ -1139,7 +1139,7 @@ describe('HostedClient', () => {
body: new Uint8Array(Buffer.from('png')),
});
expect(JSON.parse(String(requests[2]?.body))).toMatchObject({ session: 'session_a' });
expect(requests[3]?.capabilities).toBe('hosted-live-view-v1, hosted-core-commands-v1');
expect(requests[3]?.capabilities).toBe('hosted-live-view-v1, hosted-core-commands-v1, hosted-command-origin-v1');
});

it('preserves execution and trace metadata from hosted failure envelopes', async () => {
Expand Down
11 changes: 8 additions & 3 deletions src/hosted/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type { ConventionAuditReport, ConventionRuleId, ConventionViolation } fro
import { ArgumentError, attachTraceReceipt, CliError, EXIT_CODES, type ExitCode } from '../errors.js';
import type { ValidationReport } from '../validate.js';
import { parseExecutionArtifactDownloadUrl } from './artifact-url.js';
import { HOSTED_CORE_COMMANDS_CAPABILITY, isHostedCoreCommandId } from './core-commands.js';
import {
HOSTED_COMMAND_ORIGIN_CAPABILITY,
HOSTED_CORE_COMMANDS_CAPABILITY,
isHostedCoreCommandId,
} from './core-commands.js';
import { log } from '../logger.js';
import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js';
import type {
Expand Down Expand Up @@ -609,6 +613,7 @@ export class HostedClient {
'x-webcmd-client-capabilities': [
'hosted-live-view-v1',
HOSTED_CORE_COMMANDS_CAPABILITY,
HOSTED_COMMAND_ORIGIN_CAPABILITY,
].join(', '),
...(init.body ? { 'content-type': 'application/json' } : {}),
...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}),
Expand Down Expand Up @@ -1125,7 +1130,7 @@ function isHostedManifestCommand(value: unknown): boolean {
if (!hasOnlyKeys(value, [
'site', 'name', 'aliases', 'command', 'description', 'access', 'example', 'domain', 'strategy', 'browser',
'args', 'columns', 'tags', 'keywords', 'pipeline', 'defaultFormat', 'type', 'modulePath', 'sourceFile', 'navigateBefore',
'siteSession', 'freshPage', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion',
'siteSession', 'freshPage', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion', 'origin',
])) return false;
if (typeof value.site !== 'string' || typeof value.name !== 'string' || typeof value.command !== 'string') return false;
if (typeof value.description !== 'string' || typeof value.access !== 'string' || typeof value.strategy !== 'string') return false;
Expand All @@ -1138,7 +1143,7 @@ function isHostedManifestCommand(value: unknown): boolean {
if (value.defaultFormat !== undefined && value.defaultFormat !== null && typeof value.defaultFormat !== 'string') return false;
if (value.example !== undefined && typeof value.example !== 'string') return false;
if (value.pipeline !== undefined && (!Array.isArray(value.pipeline) || !value.pipeline.every(isRecord))) return false;
for (const key of ['type', 'modulePath', 'sourceFile', 'siteSession', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion']) {
for (const key of ['type', 'modulePath', 'sourceFile', 'siteSession', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion', 'origin']) {
if (value[key] !== undefined && typeof value[key] !== 'string') return false;
}
if (value.freshPage !== undefined && typeof value.freshPage !== 'boolean') return false;
Expand Down
2 changes: 2 additions & 0 deletions src/hosted/core-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { describe, expect, it } from 'vitest';
import {
hasHostedCoreCommand,
HOSTED_CORE_COMMAND_IDS,
HOSTED_COMMAND_ORIGIN_CAPABILITY,
HOSTED_CORE_COMMANDS_CAPABILITY,
isHostedCoreCommandId,
} from './core-commands.js';

describe('hosted core command capability', () => {
it('publishes the v1 capability and canonical command IDs', () => {
expect(HOSTED_CORE_COMMANDS_CAPABILITY).toBe('hosted-core-commands-v1');
expect(HOSTED_COMMAND_ORIGIN_CAPABILITY).toBe('hosted-command-origin-v1');
expect(HOSTED_CORE_COMMAND_IDS).toEqual([
'validate',
'verify',
Expand Down
1 change: 1 addition & 0 deletions src/hosted/core-commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const HOSTED_CORE_COMMANDS_CAPABILITY = 'hosted-core-commands-v1' as const;
export const HOSTED_COMMAND_ORIGIN_CAPABILITY = 'hosted-command-origin-v1' as const;

export const HOSTED_CORE_COMMAND_IDS = [
'validate',
Expand Down
19 changes: 19 additions & 0 deletions src/hosted/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,23 @@ describe('hosted manifest helpers', () => {
expect(commandNamesForSite(manifest, 'docker')).toEqual([]);
});

it('reports origin on hosted list rows', () => {
expect(hostedListRows({
...manifest,
commands: [
{ ...manifest.commands[0]!, adapterPackageId: 'pkg_default_webcmd' },
{ ...manifest.commands[0]!, site: 'pypi', name: 'package', command: 'pypi/package', origin: 'plugin:pypi' },
{ ...manifest.commands[0]!, site: 'quotes', name: 'list', command: 'quotes/list', origin: 'local' },
{ ...manifest.commands[0]!, site: 'openfda', name: 'search', command: 'openfda/search', origin: 'override:openfda' },
],
}, true)).toEqual(expect.arrayContaining([
expect.objectContaining({ command: 'github/whoami', origin: 'builtin' }),
expect.objectContaining({ command: 'pypi/package', origin: 'plugin:pypi' }),
expect.objectContaining({ command: 'quotes/list', origin: 'local' }),
expect.objectContaining({ command: 'openfda/search', origin: 'override:openfda' }),
]));
});

it('includes availability in hosted table presentation', () => {
const presentation = hostedListPresentation(manifest, 'table');

Expand Down Expand Up @@ -259,7 +276,9 @@ describe('hosted manifest helpers', () => {
'list',
'plugin',
'profile',
'session',
'setup',
'site',
'skills',
'update',
'web',
Expand Down
14 changes: 14 additions & 0 deletions src/hosted/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type PresentableCommand,
type CommandListPresentation,
} from '../command-presentation.js';
import { formatCommandOrigin } from '../command-origin.js';
import type { HostedCommand, HostedManifest } from './types.js';
import { webFetchCommand } from '../fetch/command.js';

Expand Down Expand Up @@ -67,10 +68,21 @@ export function presentHostedCommand(command: HostedCommand): PresentableCommand
return toPresentableCommand(command);
}

function hostedListOrigin(command: HostedCommand): string | undefined {
if (command.origin) return command.origin;
if (command.clientOwned) return formatCommandOrigin({ kind: 'builtin' });
if (command.adapterPackageId === 'pkg_default_webcmd' || command.adapterPackageName === '@agentrhq/webcmd') {
return formatCommandOrigin({ kind: 'builtin' });
}
return undefined;
}

function presentHostedListCommand(command: HostedCommand): PresentableCommand {
const origin = hostedListOrigin(command);
return {
...presentHostedCommand(command),
availability: isLocalOnlyHostedCommand(command) ? 'LOCAL' : 'HOSTED',
...(origin ? { origin } : {}),
};
}

Expand All @@ -79,9 +91,11 @@ export function hostedListRows(manifest: HostedManifest, structured: boolean): R
const commandsByName = new Map(commands.map((command) => [`${command.site}/${command.name}`, command]));
return commandListRows(commands.map(presentHostedCommand), structured).map((row) => {
const command = commandsByName.get(String(row.command))!;
const origin = hostedListOrigin(command);
return {
...row,
availability: isLocalOnlyHostedCommand(command) ? 'LOCAL' : 'HOSTED',
...(origin ? { origin } : {}),
...(structured && command.clientOwned ? { clientOwned: true } : {}),
};
});
Expand Down
Loading
Loading