From 9ad2b50b06c21c1df6a99afe4a14a0b30c14580c Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:43:06 +0530 Subject: [PATCH] fix(cli): group the browser command surface and drop help-list noise (#317) `webcmd browser --help` listed eight subcommands as one flat block plus Commander's auto `help [command]`, so the adapter authoring commands (`init`, `fork`, `verify`) read as unrelated noise next to the raw session surface the namespace is named for. - Group browser subcommands under "Browser session commands:" and "Adapter authoring commands:", driven by a shared helper in the browser command catalog so local and hosted help agree. The catalog itself and the hosted wire contract are unchanged. - Register the authoring commands after the session ones so help leads with `tabs`, `bind`, `run`, `snapshot`, and `close`. - Hide `browser fork` from local help. It copies a plugin command into ~/.webcmd/clis and never touches a Session, which is what `webcmd adapter override` already does; that command now carries a `fork` alias, so `webcmd adapter fork` is the local spelling. `webcmd browser fork` stays registered and dispatchable, and hosted help still lists it because hosted mode has no `adapter fork`. - Stop advertising Commander's auto `help [command]` in namespace help. The root presentation already omitted it; `webcmd help ` keeps working. --- docs/cli-reference.mdx | 8 ++++- src/browser/command-catalog.test.ts | 51 ++++++++++++++++++++++++++--- src/browser/command-catalog.ts | 22 +++++++++++++ src/cli.ts | 33 +++++++++++++++---- src/help.test.ts | 48 +++++++++++++++++++++++++++ src/help.ts | 28 +++++++++++++++- src/hosted/browser-args.ts | 9 ++++- 7 files changed, 186 insertions(+), 13 deletions(-) diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 956a414b..a973f9fd 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**. Locally, forking an installed plugin command is +`webcmd adapter fork /` (an alias of `webcmd adapter override`); +`webcmd browser fork` still runs and remains the hosted spelling. + ## 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 5d5f468d..65908854 100644 --- a/src/browser/command-catalog.test.ts +++ b/src/browser/command-catalog.test.ts @@ -1,7 +1,13 @@ import type { Command } from 'commander'; import { describe, expect, it } from 'vitest'; import { createProgram } from '../cli.js'; -import { browserCommandCatalog, browserOptionValueParser } from './command-catalog.js'; +import { + BROWSER_AUTHORING_HELP_GROUP, + BROWSER_SESSION_HELP_GROUP, + browserCommandCatalog, + browserHelpGroup, + browserOptionValueParser, +} from './command-catalog.js'; function browserCommand(): Command { const browser = createProgram('', '').commands.find(command => command.name() === 'browser'); @@ -32,15 +38,17 @@ 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', - 'fork', - 'verify', 'tabs', 'bind', 'run', 'snapshot', 'close', + 'init', + 'verify', + 'fork', ]); }); @@ -97,3 +105,38 @@ 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', 'fork', '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('hides fork, whose local home is "webcmd adapter fork", without unregistering it', () => { + 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())).toContain('fork'); + expect(browser.helpInformation()).not.toMatch(/^\s+fork /m); + // 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 a2222a7f..c3d2b9c7 100644 --- a/src/browser/command-catalog.ts +++ b/src/browser/command-catalog.ts @@ -118,6 +118,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', 'fork']); + +/** 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', [], [], 'require-existing'), command('init', 'Generate an adapter scaffold', 'init', [adapterNamePositional], [], 'create-or-reuse'), diff --git a/src/cli.ts b/src/cli.ts index c7b2a09b..b46aa583 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, 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 { isVerbose, log } from './logger.js'; @@ -583,7 +583,7 @@ function applyVerbose(opts: { verbose?: boolean }): void { } 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(', '); } @@ -591,6 +591,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); } @@ -900,7 +903,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) => { @@ -965,14 +968,19 @@ cli({ } }); - browser.command('fork') + // Runs the same action as `webcmd adapter fork`: it copies a plugin command + // into ~/.webcmd/clis and never touches a browser Session. It stays registered + // and dispatchable, but locally `adapter fork` is the spelling we advertise, so + // it is hidden from `browser --help` below (#317). Hosted mode has no `adapter + // fork`, so its catalogue-driven help still lists this one. + const browserForkCommand = new Command('fork') .argument('', 'Command to fork in site/command format') .description('Fork an installed plugin command into a private copy') .action(handleAdapterOverride); // ── Verify (test adapter) ── - browser.command('verify') + const browserVerifyCommand = 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') @@ -1239,6 +1247,18 @@ 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(browserVerifyCommand); + browser.addCommand(browserForkCommand, { hidden: true }); + + // 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 ────────────────────────────────────────── program @@ -1745,6 +1765,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 f4258c64..2472475c 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; import { classifyAdapter, commandHelpData, formatCommandHelpText, formatRootAdapterHelpText, formatSiteHelpText, + hideAutoHelpCommands, siteHelpData, + visibleChildCommands, } from './help.js'; import { commandHelpData as sharedCommandHelpData, @@ -119,3 +122,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 11084f28..5fdfed6a 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'; @@ -378,6 +378,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 = {