Skip to content
Open
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
8 changes: 7 additions & 1 deletion docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <site>/<command>` (an alias of `webcmd adapter override`);
`webcmd browser fork` still runs and remains the hosted spelling.

## Top-Level Commands

| Command | Purpose |
Expand All @@ -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. |
Expand Down
51 changes: 47 additions & 4 deletions src/browser/command-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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',
]);
});

Expand Down Expand Up @@ -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);
});
});
22 changes: 22 additions & 0 deletions src/browser/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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'),
Expand Down
33 changes: 27 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -583,14 +583,17 @@ 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(', ');
}

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);
}
Expand Down Expand Up @@ -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('<name>', 'Adapter name in site/command format (e.g. hn/top)')
.description('Generate adapter scaffold in ~/.webcmd/clis/')
.action(async (name: string) => {
Expand Down Expand Up @@ -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('<name>', '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('<name>', 'Adapter name in site/command format (e.g. hn/top)')
.option('--write-fixture', 'Write a starter fixture to ~/.webcmd/sites/<site>/verify/<command>.json if none exists')
.option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>', 'Command to override, as <site>/<command>')
.action(handleAdapterOverride);
Expand Down
48 changes: 48 additions & 0 deletions src/help.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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([]);
});
});
28 changes: 27 additions & 1 deletion src/help.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 <namespace> help <command>` 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 } = {},
Expand Down
9 changes: 8 additions & 1 deletion src/hosted/browser-args.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 ? '...' : '';
Expand Down Expand Up @@ -106,6 +111,8 @@ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHost
});
}

hideAutoHelpCommands(browser);

let stderr = '';
let stdout = '';
const output = {
Expand Down