diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 70358747..70e3ef57 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -124,6 +124,12 @@ Reusable adapters continue to use the existing `IPage` API. Playwright-style programs are for reconnaissance and ad-hoc multi-step work; they are not pasted into adapter modules. +`browser --help` lists that surface plus `close` under **Browser session +commands**, and the adapter authoring commands `init` and `verify` under +**Adapter authoring commands**. Forking an installed plugin command is +`webcmd adapter override /` (alias: `webcmd adapter fork`); +`webcmd browser fork` was removed. + ## Top-Level Commands | Command | Purpose | @@ -137,7 +143,7 @@ into adapter modules. | `profile` | List, rename, and select browser runtime profiles. | | `auth` | Inspect website login status, and refresh logged-in site sessions. | | `plugin` | Install, update, list, create, and uninstall plugins. | -| `adapter` | Inspect or remove legacy adapters in `~/.webcmd/clis/`. | +| `adapter` | Inspect, fork, or remove legacy adapters in `~/.webcmd/clis/`. | | `external` | Register or install external local CLIs. | | `validate` | Validate adapter definitions. | | `verify` | Validate and smoke test an adapter. | diff --git a/src/browser/command-catalog.test.ts b/src/browser/command-catalog.test.ts index f76f635e..34d36b66 100644 --- a/src/browser/command-catalog.test.ts +++ b/src/browser/command-catalog.test.ts @@ -1,7 +1,14 @@ import type { Command } from 'commander'; import { describe, expect, it } from 'vitest'; import { createProgram } from '../cli.js'; -import { browserCommandCatalog, browserOptionFlags, browserOptionValueParser } from './command-catalog.js'; +import { + BROWSER_AUTHORING_HELP_GROUP, + BROWSER_SESSION_HELP_GROUP, + browserCommandCatalog, + browserHelpGroup, + browserOptionFlags, + browserOptionValueParser, +} from './command-catalog.js'; function browserCommand(): Command { const browser = createProgram('', '').commands.find(command => command.name() === 'browser'); @@ -27,14 +34,16 @@ describe('browserCommandCatalog', () => { }); it('keeps adapter authoring separate from the raw session catalog', () => { + // Registration order drives help-group order, so the raw session surface the + // namespace is named for comes first. expect(browserCommand().commands.map(command => command.name())).toEqual([ - 'init', - 'verify', 'tabs', 'bind', 'run', 'snapshot', 'close', + 'init', + 'verify', ]); }); @@ -89,7 +98,7 @@ describe('browserCommandCatalog', () => { for (const leaf of ['tabs', 'bind', 'run', 'snapshot', 'close']) { expect(optionNames(leaf)).toContain('verbose'); } - for (const leaf of ['init', 'fork', 'verify']) { + for (const leaf of ['init', 'verify']) { expect(optionNames(leaf)).not.toContain('verbose'); } }); @@ -117,3 +126,37 @@ describe('browserCommandCatalog', () => { expect(() => parse?.('full')).toThrow('--snapshot-mode for snapshot must be act, tree, or read'); }); }); + +describe('browser namespace help presentation', () => { + it('groups every catalogued command as session control or adapter authoring', () => { + const groups = new Map(browserCommandCatalog.map(command => [command.command, browserHelpGroup(command.command)])); + + expect([...groups].filter(([, group]) => group === BROWSER_SESSION_HELP_GROUP).map(([name]) => name)) + .toEqual(['tabs', 'bind', 'run', 'snapshot', 'close']); + expect([...groups].filter(([, group]) => group === BROWSER_AUTHORING_HELP_GROUP).map(([name]) => name)) + .toEqual(['init', 'verify']); + }); + + it('leads with the raw session surface and drops the auto help entry', () => { + const help = browserCommand().helpInformation(); + + expect(help).toContain(BROWSER_SESSION_HELP_GROUP); + expect(help).toContain(BROWSER_AUTHORING_HELP_GROUP); + expect(help.indexOf(BROWSER_SESSION_HELP_GROUP)).toBeLessThan(help.indexOf(BROWSER_AUTHORING_HELP_GROUP)); + expect(help).not.toMatch(/^\s+help \[command\]/m); + }); + + it('does not register fork under browser; its local home is "webcmd adapter fork"', () => { + const program = createProgram('', ''); + const browser = program.commands.find(command => command.name() === 'browser')!; + const adapter = program.commands.find(command => command.name() === 'adapter')!; + const override = adapter.commands.find(command => command.name() === 'override')!; + + expect(browser.commands.map(command => command.name())).not.toContain('fork'); + // The namespace summary the root help renders must not advertise it either. + expect(browser.description()).toBe('bind, close, init, run, snapshot, tabs, verify'); + + expect(override.aliases()).toContain('fork'); + expect(adapter.helpInformation()).toMatch(/^\s+override\|fork /m); + }); +}); diff --git a/src/browser/command-catalog.ts b/src/browser/command-catalog.ts index e63655c7..d247151b 100644 --- a/src/browser/command-catalog.ts +++ b/src/browser/command-catalog.ts @@ -140,6 +140,28 @@ export function browserOptionValueParser( return undefined; } +/** + * Help-only presentation metadata for the `browser` namespace. This is not part + * of the hosted wire contract: the catalog below still declares every command, + * so local and hosted dispatch are unchanged. It only decides how + * `browser --help` groups them. + * + * `browser` carries two unrelated surfaces: the raw-browser session commands and + * the adapter authoring commands that drive the cloud/local authoring flow. Listed + * as one flat block they read as unrelated noise (#317), so they are grouped. + */ +export const BROWSER_SESSION_HELP_GROUP = 'Browser session commands:'; +export const BROWSER_AUTHORING_HELP_GROUP = 'Adapter authoring commands:'; + +const AUTHORING_COMMAND_PATHS: ReadonlySet = new Set(['init', 'verify']); + +/** Help heading a catalogued browser command belongs under. */ +export function browserHelpGroup(commandPath: string): string { + return AUTHORING_COMMAND_PATHS.has(commandPath) + ? BROWSER_AUTHORING_HELP_GROUP + : BROWSER_SESSION_HELP_GROUP; +} + export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [ command('tabs', 'List pages in the existing browser session', 'tabs', [], [ verboseFlag(), diff --git a/src/cli.ts b/src/cli.ts index ecd68d43..78d67068 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,7 +25,7 @@ import { printCompletionScript } from './completion.js'; import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js'; import { addWebcmdSkills, listWebcmdSkills, removeWebcmdSkills, updateWebcmdSkill, type WebcmdSkillAddResult } from './skills.js'; import { registerAllCommands } from './commanderAdapter.js'; -import { buildRootHelpPresentation, classifyAdapter, installCommanderNamespaceStructuredHelp, installRootPresentationHelp, leadingPositionalFromUsage, rootHelpData, type RootAdapterGroups } from './help.js'; +import { buildRootHelpPresentation, classifyAdapter, hideAutoHelpCommands, installCommanderNamespaceStructuredHelp, installRootPresentationHelp, leadingPositionalFromUsage, rootHelpData, visibleChildCommands, type RootAdapterGroups } from './help.js'; import { EXIT_CODES, getErrorMessage, toEnvelope, BrowserConnectError, CliError, ArgumentError } from './errors.js'; import { TargetError, type TargetErrorCode } from './browser/target-errors.js'; import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs, type ResolveOptions, type TargetMatchLevel } from './browser/target-resolver.js'; @@ -38,7 +38,7 @@ import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js'; import { buildHtmlTreeJs, type HtmlTreeResult } from './browser/html-tree.js'; import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js'; import { analyzeSite, type PageSignals } from './browser/analyze.js'; -import { browserOptionValueParser } from './browser/command-catalog.js'; +import { browserHelpGroup, browserOptionValueParser } from './browser/command-catalog.js'; import { registerAuthCommands } from './commands/auth.js'; import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { enableVerbose, isVerbose, log } from './logger.js'; @@ -617,7 +617,7 @@ function withBrowserVerbose(command: Command): Command { } function formatChildCommandSummary(command: Command): string { - return [...new Set(command.commands.map(child => child.name()))] + return [...new Set(visibleChildCommands(command).map(child => child.name()))] .sort((a, b) => a.localeCompare(b)) .join(', '); } @@ -625,6 +625,9 @@ function formatChildCommandSummary(command: Command): string { function applyRootSubcommandSummaries(program: Command): void { for (const command of program.commands) { if (command.commands.length === 0) continue; + // The root presentation already omits Commander's auto `help [command]`; + // namespaces listed it a line below their own `-h, --help` option (#317). + hideAutoHelpCommands(command); const summary = formatChildCommandSummary(command); if (summary) command.description(summary); } @@ -954,7 +957,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi // ── Init (adapter scaffolding) ── - browser.command('init') + const browserInitCommand = new Command('init') .argument('', 'Adapter name in site/command format (e.g. hn/top)') .description('Generate adapter scaffold in ~/.webcmd/clis/') .action(async (name: string) => { @@ -1021,7 +1024,7 @@ cli({ // ── Verify (test adapter) ── - const browserVerifyCmd = browser.command('verify') + const browserVerifyCmd = new Command('verify') .argument('', 'Adapter name in site/command format (e.g. hn/top)') .option('--write-fixture', 'Write a starter fixture to ~/.webcmd/sites//verify/.json if none exists') .option('--update-fixture', 'Overwrite an existing fixture with one derived from current output') @@ -1362,6 +1365,17 @@ cli({ surface: 'browser', ...routing, }))))); + + // Adapter authoring is attached after the session surface so `browser --help` + // leads with the commands the namespace is actually named for. + browser.addCommand(browserInitCommand); + browser.addCommand(browserVerifyCmd); + + // Session control and adapter authoring are unrelated surfaces that both live + // under `browser`. Group them from the shared catalog so local and hosted help + // read the same way. + for (const child of browser.commands) child.helpGroup(browserHelpGroup(child.name())); + // ── Built-in: doctor / completion ────────────────────────────────────────── const doctorCmd = program @@ -1870,6 +1884,7 @@ cli({ adapterCmd .command('override') + .alias('fork') .description('Fork an installed plugin command into ~/.webcmd/clis so you can modify it') .argument('', 'Command to override, as /') .action(handleAdapterOverride); diff --git a/src/help.test.ts b/src/help.test.ts index 8de46521..c2f827dd 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; import { classifyAdapter, commandHelpData, @@ -6,7 +7,9 @@ import { formatRootAdapterHelpText, formatSiteHelpText, getRequestedHelpFormat, + hideAutoHelpCommands, siteHelpData, + visibleChildCommands, } from './help.js'; import { commandHelpData as sharedCommandHelpData, @@ -124,3 +127,48 @@ describe('shared presentation delegation', () => { expect(commandHelpData(presentableFixture)).toEqual(sharedCommandHelpData(presentable)); }); }); + +describe('namespace help command listing', () => { + function namespace(): Command { + const root = new Command('root'); + root.command('child').description('Child command').action(() => {}); + const group = root.command('group').description('Group command'); + group.command('leaf').description('Leaf command').action(() => {}); + return root; + } + + it('lists registered children only, without Commander\'s auto help entry', () => { + const root = namespace(); + expect(root.helpInformation()).toMatch(/^\s+help \[command\]/m); + + hideAutoHelpCommands(root); + + const help = root.helpInformation(); + expect(help).toMatch(/^\s+child\s+Child command$/m); + expect(help).not.toMatch(/^\s+help \[command\]/m); + expect(visibleChildCommands(root).map(command => command.name())).toEqual(['child', 'group']); + }); + + it('applies to nested groups and leaves the help command dispatchable', () => { + const root = namespace(); + hideAutoHelpCommands(root); + const group = root.commands.find(command => command.name() === 'group')!; + expect(group.helpInformation()).not.toMatch(/^\s+help \[command\]/m); + + let out = ''; + const configure = (command: Command): void => { + command.exitOverride().configureOutput({ writeOut: value => { out += value; } }); + for (const child of command.commands) configure(child); + }; + configure(root); + expect(() => root.parse(['help', 'child'], { from: 'user' })) + .toThrow(expect.objectContaining({ code: 'commander.help' })); + expect(out).toContain('Child command'); + }); + + it('ignores commands that have no children', () => { + const leaf = new Command('leaf').description('Leaf command'); + expect(() => hideAutoHelpCommands(leaf)).not.toThrow(); + expect(visibleChildCommands(leaf)).toEqual([]); + }); +}); diff --git a/src/help.ts b/src/help.ts index 38012b85..94c596cd 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,4 +1,4 @@ -import { Command, type Argument as CommanderArgument, type Option as CommanderOption } from 'commander'; +import { Command, Help, type Argument as CommanderArgument, type Option as CommanderOption } from 'commander'; import yaml from 'js-yaml'; import type { CliCommand } from './registry.js'; import { CLI_COMMAND } from './brand.js'; @@ -389,6 +389,32 @@ export function commanderGroupHelpData( }; } +/** + * Child commands Commander would list in `--help`, minus its auto-generated + * `help [command]` entry. That entry is not a registered child, so restricting + * the default result to `command.commands` drops it while keeping Commander's + * own hidden-command filtering. + */ +export function visibleChildCommands(command: Command): Command[] { + return new Help().visibleCommands(command).filter(child => command.commands.includes(child)); +} + +/** + * Namespace help lists every registered child plus Commander's auto-generated + * `help [command]`, which duplicates the `-h, --help` option one line above it. + * The root presentation already omits that entry; mirror it on namespaces and + * their groups. `webcmd help ` keeps working — it is only + * dropped from the advertised command list. + */ +export function hideAutoHelpCommands(namespaceRoot: Command): void { + const configure = (command: Command): void => { + if (command.commands.length === 0) return; + command.configureHelp({ visibleCommands: visibleChildCommands }); + for (const child of command.commands) configure(child); + }; + configure(namespaceRoot); +} + export function installCommanderNamespaceStructuredHelp( namespaceRoot: Command, opts: { globalCommand?: Command; description?: string } = {}, diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index 85e1d0f5..3b92fa7e 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -1,11 +1,13 @@ import { Command, Option } from 'commander'; import { browserCommandCatalog, + browserHelpGroup, browserOptionFlags, browserOptionValueParser, } from '../browser/command-catalog.js'; import { CommanderStructuralError } from '../command-surface.js'; import { CliError, EXIT_CODES } from '../errors.js'; +import { hideAutoHelpCommands } from '../help.js'; import { configureRootCommandSurface } from '../root-command-surface.js'; export class HostedBrowserHelp extends Error { @@ -71,7 +73,10 @@ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHost } const leafName = parts.at(-1)!; - const leaf = parent.command(leafName).description(contract.description); + const leaf = parent + .command(leafName) + .description(contract.description) + .helpGroup(browserHelpGroup(contract.command)); for (const alias of contract.aliases) leaf.alias(alias); for (const positional of contract.positionals) { const suffix = positional.variadic ? '...' : ''; @@ -106,6 +111,8 @@ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHost }); } + hideAutoHelpCommands(browser); + let stderr = ''; let stdout = ''; const output = {