From d02a0421497aeb789fa62849d89be62f36f608e0 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 20:41:44 +0530 Subject: [PATCH 1/3] fix: surface workspace, origin, and browser-init in hosted help Expose --workspace/WEBCMD_WORKSPACE, list site/session at the root, point missing site commands at browser init, and show list origin. --- skill-src/cli/webcmd-usage/SKILL.src.md | 4 +++- skills/webcmd-usage/SKILL.md | 4 +++- src/command-suggest.ts | 14 ++++++++++++++ src/command-surface.ts | 9 ++++++++- src/completion-shared.test.ts | 10 ++++++++++ src/completion-shared.ts | 3 +++ src/hosted/client.ts | 4 ++-- src/hosted/manifest.test.ts | 19 +++++++++++++++++++ src/hosted/manifest.ts | 13 +++++++++++++ src/hosted/root-command-surface.test.ts | 4 ++-- src/hosted/runner.test.ts | 1 + src/hosted/runner.ts | 3 ++- src/hosted/types.ts | 3 +++ src/skills.test.ts | 2 ++ 14 files changed, 85 insertions(+), 8 deletions(-) diff --git a/skill-src/cli/webcmd-usage/SKILL.src.md b/skill-src/cli/webcmd-usage/SKILL.src.md index d3f6b3a3..06c914fe 100644 --- a/skill-src/cli/webcmd-usage/SKILL.src.md +++ b/skill-src/cli/webcmd-usage/SKILL.src.md @@ -139,6 +139,7 @@ Use this fallback order: | --- | --- | | `-f, --format ` | `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 ` | Workspace id/slug. Same as `WEBCMD_WORKSPACE`. First use creates it. | Command-specific flags such as `--limit` and `--filter` are not universal. Read ` --help`. @@ -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 @@ -198,7 +200,7 @@ argument, transient, or unreproduced failures. Storage paths: -- Private: `~/.webcmd/clis//.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//.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 (`core`, `market`, `user`, `override`). - Public (main repo, official or community): `plugins//` 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//`. 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. diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index 60f93577..1ae1134e 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -139,6 +139,7 @@ Use this fallback order: | --- | --- | | `-f, --format ` | `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 ` | Workspace id/slug. Same as `WEBCMD_WORKSPACE`. First use creates it. | Command-specific flags such as `--limit` and `--filter` are not universal. Read ` --help`. @@ -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 @@ -198,7 +200,7 @@ argument, transient, or unreproduced failures. Storage paths: -- Private: `~/.webcmd/clis//.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//.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 (`core`, `market`, `user`, `override`). - Public (main repo, official or community): `plugins//` 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//`. 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. diff --git a/src/command-suggest.ts b/src/command-suggest.ts index 11c0ff42..2c66ed22 100644 --- a/src/command-suggest.ts +++ b/src/command-suggest.ts @@ -153,6 +153,20 @@ export function unknownRootCommandMessage( return missingPluginGuidance(name); } +const RESERVED_ROOTS = new Set([ + 'adapter', 'artifact', 'auth', 'browser', 'completion', 'convention-audit', 'daemon', + 'doctor', 'external', 'list', 'plugin', 'profile', 'session', 'setup', 'site', 'skills', + 'tab', 'update', 'validate', 'verify', 'web', +]); + +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 RESERVED_ROOTS.has(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/command-surface.ts b/src/command-surface.ts index fb9d949d..e71cbd5b 100644 --- a/src/command-surface.ts +++ b/src/command-surface.ts @@ -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'; @@ -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( diff --git a/src/completion-shared.test.ts b/src/completion-shared.test.ts index ca4535b9..c30f27f6 100644 --- a/src/completion-shared.test.ts +++ b/src/completion-shared.test.ts @@ -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 '); + 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); + }); }); diff --git a/src/completion-shared.ts b/src/completion-shared.ts index 189a07d3..3990dd96 100644 --- a/src/completion-shared.ts +++ b/src/completion-shared.ts @@ -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.' }, @@ -65,6 +67,7 @@ const HOSTED_ROOT_HELP_BASE: Omit = { ], options: [ { flags: '--profile ', description: 'Browser profile/context alias for browser runtime commands' }, + { flags: '--workspace ', description: 'Hosted workspace id/slug; also WEBCMD_WORKSPACE' }, { flags: '-V, --version', description: 'Output the version number' }, { flags: '-h, --help', description: 'Display help for command' }, ], diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 5b8317dd..c519d79f 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -1125,7 +1125,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; @@ -1138,7 +1138,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; diff --git a/src/hosted/manifest.test.ts b/src/hosted/manifest.test.ts index 1fda0416..62b3a48f 100644 --- a/src/hosted/manifest.test.ts +++ b/src/hosted/manifest.test.ts @@ -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: 'market' }, + { ...manifest.commands[0]!, site: 'quotes', name: 'list', command: 'quotes/list', origin: 'user' }, + { ...manifest.commands[0]!, site: 'openfda', name: 'search', command: 'openfda/search', origin: 'override' }, + ], + }, true)).toEqual(expect.arrayContaining([ + expect.objectContaining({ command: 'github/whoami', origin: 'core' }), + expect.objectContaining({ command: 'pypi/package', origin: 'market' }), + expect.objectContaining({ command: 'quotes/list', origin: 'user' }), + expect.objectContaining({ command: 'openfda/search', origin: 'override' }), + ])); + }); + it('includes availability in hosted table presentation', () => { const presentation = hostedListPresentation(manifest, 'table'); @@ -259,7 +276,9 @@ describe('hosted manifest helpers', () => { 'list', 'plugin', 'profile', + 'session', 'setup', + 'site', 'skills', 'update', 'web', diff --git a/src/hosted/manifest.ts b/src/hosted/manifest.ts index d4af3eb3..b9885468 100644 --- a/src/hosted/manifest.ts +++ b/src/hosted/manifest.ts @@ -67,10 +67,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 'core'; + if (command.adapterPackageId === 'pkg_default_webcmd' || command.adapterPackageName === '@agentrhq/webcmd') { + return 'core'; + } + return undefined; +} + function presentHostedListCommand(command: HostedCommand): PresentableCommand { + const origin = hostedListOrigin(command); return { ...presentHostedCommand(command), availability: isLocalOnlyHostedCommand(command) ? 'LOCAL' : 'HOSTED', + ...(origin ? { origin } : {}), }; } @@ -79,9 +90,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 } : {}), }; }); diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 84e250b1..65e6fca4 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -796,11 +796,11 @@ describe('hosted root preflight call order', () => { expect(local).toMatchObject({ exitCode: 2, stdout: '', - stderr: "error: unknown command 'bogus'\nhelp: valid subcommands for `webcmd github`: whoami\n", + stderr: "error: unknown command 'bogus'\nhelp: valid subcommands for `webcmd github`: whoami\nTo author this command: webcmd browser init github/bogus\n", }); expect(hosted).toEqual({ handled: true, exitCode: local.exitCode }); expect(stdout.text()).toBe(local.stdout); - expect(stderr.text()).toBe("error: unknown command 'bogus'\nhelp: valid subcommands for `webcmd github`: whoami\n"); + expect(stderr.text()).toBe("error: unknown command 'bogus'\nhelp: valid subcommands for `webcmd github`: whoami\nTo author this command: webcmd browser init github/bogus\n"); expect(fetchImpl).toHaveBeenCalledTimes(1); expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://api.example.com/v1/manifest'); }); diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 2d0b1281..ff1ea26f 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -1708,6 +1708,7 @@ describe('runHostedCli', () => { expect(stderr.text()).toBe([ "error: unknown command 'missing-command'", 'help: valid subcommands for `webcmd github`: whoami', + 'To author this command: webcmd browser init github/missing-command', '', ].join('\n')); expect(stdout.text()).toBe(''); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 692505f1..148e4fde 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -38,6 +38,7 @@ import { loadBrowserRunSource, readProcessStdin } from '../browser/run/input.js' import { BrowserRunError } from '../browser/run/types.js'; import { CLI_COMMAND } from '../brand.js'; import { formatPluginSearchEmptyCopy, presentPluginSearch } from '../plugin-search-presentation.js'; +import { unknownSiteCommandHint } from '../command-suggest.js'; import { missingPluginGuidance } from '../discovery.js'; import type { ExternalCliConfig } from '../external.js'; import { webFetchCommand } from '../fetch/command.js'; @@ -721,7 +722,7 @@ async function dispatchHosted( const known = [...new Set(hostedCommands(manifest).filter(entry => entry.site === site).map(entry => entry.name))].sort(); const help = known.length > 0 ? `help: valid subcommands for \`webcmd ${site}\`: ${known.join(', ')}\n` : ''; throw new CommanderCompatibleError( - `error: unknown command '${commandName}'\n${help}`, + `error: unknown command '${commandName}'\n${help}${unknownSiteCommandHint(site, commandName)}\n`, EXIT_CODES.USAGE_ERROR, ); } diff --git a/src/hosted/types.ts b/src/hosted/types.ts index 3cd9e7e7..bdf70644 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -41,6 +41,9 @@ export interface HostedCommand extends CommandSurfaceMetadata { defaultFormat?: string | null; freshPage?: boolean; adapterPackageId?: string; + adapterPackageName?: string; + adapterPackageVersion?: string; + origin?: string; sourceFile?: string; modulePath?: string; } diff --git a/src/skills.test.ts b/src/skills.test.ts index 0d5c745b..0ae79254 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -280,6 +280,8 @@ describe('webcmd skills content', () => { const browser = bundledSkill('webcmd-browser'); const autofix = bundledSkill('webcmd-autofix'); + expect(usage).toContain('--workspace '); + expect(usage).toContain('WEBCMD_WORKSPACE'); expect(usage).toContain('webcmd --profile work session create "Work Project" -f json'); expect(usage).toContain('first-choice Webcmd fetch path'); expect(usage).toContain('webcmd profile create work'); From 2371bf8e3f9e34aeb018bd32ac58c8e170ba5078 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 20:46:28 +0530 Subject: [PATCH 2/3] fix: share origin vocabulary and reserved roots with local CLI Reserved roots now follow WEBCMD_ROOT_COMMANDS plus hosted root help. List origin uses builtin/plugin/local/override strings, not a second vocabulary. --- skill-src/cli/webcmd-usage/SKILL.src.md | 2 +- skills/webcmd-usage/SKILL.md | 2 +- src/command-origin.test.ts | 1 + src/command-suggest.test.ts | 17 ++++++++++++++++- src/command-suggest.ts | 11 ++++------- src/hosted/manifest.test.ts | 14 +++++++------- src/hosted/manifest.ts | 5 +++-- 7 files changed, 33 insertions(+), 19 deletions(-) diff --git a/skill-src/cli/webcmd-usage/SKILL.src.md b/skill-src/cli/webcmd-usage/SKILL.src.md index 06c914fe..8a7ca781 100644 --- a/skill-src/cli/webcmd-usage/SKILL.src.md +++ b/skill-src/cli/webcmd-usage/SKILL.src.md @@ -200,7 +200,7 @@ argument, transient, or unreproduced failures. Storage paths: -- Private: `~/.webcmd/clis//.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 (`core`, `market`, `user`, `override`). +- Private: `~/.webcmd/clis//.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:`, `local`, `override:`). - Public (main repo, official or community): `plugins//` 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//`. 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. diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index 1ae1134e..36fff64e 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -200,7 +200,7 @@ argument, transient, or unreproduced failures. Storage paths: -- Private: `~/.webcmd/clis//.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 (`core`, `market`, `user`, `override`). +- Private: `~/.webcmd/clis//.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:`, `local`, `override:`). - Public (main repo, official or community): `plugins//` 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//`. 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. diff --git a/src/command-origin.test.ts b/src/command-origin.test.ts index 068428d2..f5303e0c 100644 --- a/src/command-origin.test.ts +++ b/src/command-origin.test.ts @@ -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'); }); }); diff --git a/src/command-suggest.test.ts b/src/command-suggest.test.ts index d11f3acf..c2a2eb85 100644 --- a/src/command-suggest.test.ts +++ b/src/command-suggest.test.ts @@ -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, name: string) { return program.commands.find(command => command.name() === name)!; @@ -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'); diff --git a/src/command-suggest.ts b/src/command-suggest.ts index 2c66ed22..bc7c46c0 100644 --- a/src/command-suggest.ts +++ b/src/command-suggest.ts @@ -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. @@ -153,18 +155,13 @@ export function unknownRootCommandMessage( return missingPluginGuidance(name); } -const RESERVED_ROOTS = new Set([ - 'adapter', 'artifact', 'auth', 'browser', 'completion', 'convention-audit', 'daemon', - 'doctor', 'external', 'list', 'plugin', 'profile', 'session', 'setup', 'site', 'skills', - 'tab', 'update', 'validate', 'verify', 'web', -]); - 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 RESERVED_ROOTS.has(name); + 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. */ diff --git a/src/hosted/manifest.test.ts b/src/hosted/manifest.test.ts index 62b3a48f..bf6296f3 100644 --- a/src/hosted/manifest.test.ts +++ b/src/hosted/manifest.test.ts @@ -127,15 +127,15 @@ describe('hosted manifest helpers', () => { ...manifest, commands: [ { ...manifest.commands[0]!, adapterPackageId: 'pkg_default_webcmd' }, - { ...manifest.commands[0]!, site: 'pypi', name: 'package', command: 'pypi/package', origin: 'market' }, - { ...manifest.commands[0]!, site: 'quotes', name: 'list', command: 'quotes/list', origin: 'user' }, - { ...manifest.commands[0]!, site: 'openfda', name: 'search', command: 'openfda/search', origin: 'override' }, + { ...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: 'core' }), - expect.objectContaining({ command: 'pypi/package', origin: 'market' }), - expect.objectContaining({ command: 'quotes/list', origin: 'user' }), - expect.objectContaining({ command: 'openfda/search', origin: 'override' }), + 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' }), ])); }); diff --git a/src/hosted/manifest.ts b/src/hosted/manifest.ts index b9885468..5715d78c 100644 --- a/src/hosted/manifest.ts +++ b/src/hosted/manifest.ts @@ -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'; @@ -69,9 +70,9 @@ export function presentHostedCommand(command: HostedCommand): PresentableCommand function hostedListOrigin(command: HostedCommand): string | undefined { if (command.origin) return command.origin; - if (command.clientOwned) return 'core'; + if (command.clientOwned) return formatCommandOrigin({ kind: 'builtin' }); if (command.adapterPackageId === 'pkg_default_webcmd' || command.adapterPackageName === '@agentrhq/webcmd') { - return 'core'; + return formatCommandOrigin({ kind: 'builtin' }); } return undefined; } From 6dcf8dbd3261adeef7ab11276ea7e59bde04a435 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 21:05:23 +0530 Subject: [PATCH 3/3] fix: advertise hosted-command-origin-v1 so Cloud can gate origin Released 0.7.9 rejects extra command.origin. Request the field only when this client can parse it. --- src/hosted/client.test.ts | 12 ++++++------ src/hosted/client.ts | 7 ++++++- src/hosted/core-commands.test.ts | 2 ++ src/hosted/core-commands.ts | 1 + 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 0d8c0bae..6b0c7537 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -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(); @@ -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' }, ]); }); @@ -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' }); }); @@ -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 () => { diff --git a/src/hosted/client.ts b/src/hosted/client.ts index c519d79f..9fda2428 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -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 { @@ -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 } : {}), diff --git a/src/hosted/core-commands.test.ts b/src/hosted/core-commands.test.ts index c1289b63..a1bb351a 100644 --- a/src/hosted/core-commands.test.ts +++ b/src/hosted/core-commands.test.ts @@ -2,6 +2,7 @@ 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'; @@ -9,6 +10,7 @@ import { 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', diff --git a/src/hosted/core-commands.ts b/src/hosted/core-commands.ts index 6448b76d..2516a817 100644 --- a/src/hosted/core-commands.ts +++ b/src/hosted/core-commands.ts @@ -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',