Skip to content

Commit f65f444

Browse files
committed
feat(help): add browser structured help
1 parent 407b559 commit f65f444

4 files changed

Lines changed: 263 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
* **help / browser**`opencli browser --help -f yaml|json` now emits a structured, agent-ready index of all browser leaf commands (including nested `tab`, `get`, and `dialog` commands), their positionals, command options, namespace options, and root global options. Individual browser commands also support structured help, backed by a shared Commander option/argument spec extractor.
8+
59
### Bug Fixes
610

711
* **help / build** — every positional arg must now declare a non-empty `help` string. The build-manifest step fails closed when a positional has empty / whitespace-only / missing `help`, so `opencli <site> <cmd> --help` always shows callers what each parameter is for. Pre-existing offenders (`twitter followers/following/list-add/list-remove/list-tweets/search/thread`, `reddit search/subreddit/user/user-comments/user-posts`, `douyin stats/update`, `bilibili subtitle`, `jike search`) now have explicit help text — most notably `twitter followers [user]` and `following [user]` now document that omitting the user fetches the currently logged-in account.

src/cli.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,97 @@ describe('createProgram root help descriptions', () => {
363363
for (const [key, value] of snapshot) registry.set(key, value);
364364
}
365365
});
366+
367+
it('renders browser namespace structured help from Commander metadata', () => {
368+
const argv = process.argv;
369+
try {
370+
const program = createProgram('', '');
371+
const browser = program.commands.find(cmd => cmd.name() === 'browser');
372+
expect(browser).toBeTruthy();
373+
374+
process.argv = ['node', 'opencli', 'browser', '--help', '-f', 'yaml'];
375+
const data = yaml.load(browser!.helpInformation()) as any;
376+
377+
expect(data.namespace).toBe('browser');
378+
expect(data.command).toBe('opencli browser');
379+
expect(data.description).toBe('Browser control — navigate, click, type, extract, wait (no LLM needed)');
380+
expect(data.command_count).toBeGreaterThan(20);
381+
expect(data.namespace_options).toMatchObject([
382+
{
383+
name: 'workspace',
384+
flags: '--workspace <name>',
385+
takes_value: 'required',
386+
},
387+
]);
388+
expect(data.global_options).toEqual(expect.arrayContaining([
389+
expect.objectContaining({
390+
name: 'version',
391+
flags: '-V, --version',
392+
}),
393+
expect.objectContaining({
394+
name: 'profile',
395+
flags: '--profile <name>',
396+
takes_value: 'required',
397+
}),
398+
]));
399+
400+
const click = data.commands.find((cmd: any) => cmd.name === 'click');
401+
expect(click).toMatchObject({
402+
command: 'opencli browser click',
403+
usage: 'opencli browser click <target> [options]',
404+
positionals: [{ name: 'target', required: true }],
405+
});
406+
expect(click.command_options.map((option: any) => option.name)).toEqual(['nth', 'tab']);
407+
408+
const tabList = data.commands.find((cmd: any) => cmd.name === 'tab list');
409+
expect(tabList).toMatchObject({
410+
command: 'opencli browser tab list',
411+
usage: 'opencli browser tab list [options]',
412+
command_options: [],
413+
});
414+
415+
const getText = data.commands.find((cmd: any) => cmd.name === 'get text');
416+
expect(getText).toMatchObject({
417+
command: 'opencli browser get text',
418+
positionals: [{ name: 'target', required: true }],
419+
});
420+
expect(data.structured_help).toMatchObject({
421+
formats: ['yaml', 'json'],
422+
usage: 'opencli browser --help -f yaml',
423+
});
424+
} finally {
425+
process.argv = argv;
426+
}
427+
});
428+
429+
it('renders browser command structured help without needing the full namespace dump', () => {
430+
const argv = process.argv;
431+
try {
432+
const program = createProgram('', '');
433+
const browser = program.commands.find(cmd => cmd.name() === 'browser')!;
434+
const click = browser.commands.find(cmd => cmd.name() === 'click');
435+
expect(click).toBeTruthy();
436+
437+
process.argv = ['node', 'opencli', 'browser', 'click', '--help', '-f', 'yaml'];
438+
const data = yaml.load(click!.helpInformation()) as any;
439+
440+
expect(data).toMatchObject({
441+
namespace: 'browser',
442+
name: 'click',
443+
command: 'opencli browser click',
444+
usage: 'opencli browser click <target> [options]',
445+
positionals: [{ name: 'target', required: true }],
446+
structured_help: {
447+
usage: 'opencli browser click --help -f yaml',
448+
},
449+
});
450+
expect(data.command_options.map((option: any) => option.name)).toEqual(['nth', 'tab']);
451+
expect(data.namespace_options.map((option: any) => option.name)).toEqual(['workspace']);
452+
expect(data.global_options.map((option: any) => option.name)).toContain('profile');
453+
} finally {
454+
process.argv = argv;
455+
}
456+
});
366457
});
367458

368459
describe('resolveBrowserVerifyInvocation', () => {

src/cli.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { PKG_VERSION } from './version.js';
1919
import { printCompletionScript } from './completion.js';
2020
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
2121
import { registerAllCommands } from './commanderAdapter.js';
22-
import { classifyAdapter, formatRootAdapterHelpText, installStructuredHelp, rootHelpData, type RootAdapterGroups } from './help.js';
22+
import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, rootHelpData, type RootAdapterGroups } from './help.js';
2323
import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
2424
import { TargetError, type TargetErrorCode } from './browser/target-errors.js';
2525
import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs, type ResolveOptions, type TargetMatchLevel } from './browser/target-resolver.js';
@@ -628,6 +628,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
628628
.command('browser')
629629
.option('--workspace <name>', 'Browser workspace to use (default: browser:default; bound tabs use bound:<name>)')
630630
.description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
631+
const browserStructuredDescription = browser.description();
631632

632633
/**
633634
* Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
@@ -2781,6 +2782,7 @@ cli({
27812782
}
27822783
const adapterGroups: RootAdapterGroups = { external: externalNames, apps, sites };
27832784
const adapterNameSet = new Set<string>([...externalNames, ...siteNames]);
2785+
installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: browserStructuredDescription });
27842786
program.configureHelp({
27852787
visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())),
27862788
});

src/help.ts

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
1-
import { Command } from 'commander';
1+
import { Command, type Argument as CommanderArgument, type Option as CommanderOption } from 'commander';
22
import yaml from 'js-yaml';
33
import type { Arg, CliCommand } from './registry.js';
44
import { fullName } from './registry.js';
55
import { formatCommandExample } from './serialization.js';
66

77
export type StructuredHelpFormat = 'yaml' | 'json';
88

9+
export interface ArgSpec {
10+
name: string;
11+
required?: true;
12+
variadic?: true;
13+
help?: string;
14+
default?: unknown;
15+
choices?: string[];
16+
}
17+
18+
export interface OptionSpec {
19+
name: string;
20+
flags: string;
21+
help?: string;
22+
takes_value?: 'required' | 'optional';
23+
required?: true;
24+
default?: unknown;
25+
choices?: string[];
26+
negate?: true;
27+
}
28+
929
const COMMON_OPTIONS = [
1030
{
1131
flags: '-f, --format <fmt>',
@@ -158,6 +178,150 @@ function compactCommonOption(option: typeof COMMON_OPTIONS[number]): Record<stri
158178
};
159179
}
160180

181+
function compactCommanderArgument(arg: CommanderArgument): ArgSpec {
182+
return {
183+
name: arg.name(),
184+
...(arg.required ? { required: true } : {}),
185+
...(arg.variadic ? { variadic: true } : {}),
186+
...(arg.description ? { help: arg.description } : {}),
187+
...(arg.defaultValue !== undefined ? { default: arg.defaultValue } : {}),
188+
...(arg.argChoices?.length ? { choices: [...arg.argChoices] } : {}),
189+
};
190+
}
191+
192+
function compactCommanderOption(option: CommanderOption): OptionSpec | null {
193+
if (option.hidden) return null;
194+
return {
195+
name: option.attributeName(),
196+
flags: option.flags,
197+
...(option.description ? { help: option.description } : {}),
198+
...(option.required ? { takes_value: 'required' as const } : {}),
199+
...(option.optional ? { takes_value: 'optional' as const } : {}),
200+
...(option.mandatory ? { required: true } : {}),
201+
...(option.defaultValue !== undefined ? { default: option.defaultValue } : {}),
202+
...(option.argChoices?.length ? { choices: [...option.argChoices] } : {}),
203+
...(option.negate ? { negate: true } : {}),
204+
};
205+
}
206+
207+
function compactCommanderOptions(options: readonly CommanderOption[]): OptionSpec[] {
208+
return options
209+
.map(compactCommanderOption)
210+
.filter((option): option is OptionSpec => option !== null);
211+
}
212+
213+
function commanderPath(command: Command): string[] {
214+
const parts: string[] = [];
215+
let current: Command | null = command;
216+
while (current) {
217+
const name = current.name();
218+
if (name) parts.push(name);
219+
current = current.parent;
220+
}
221+
return parts.reverse();
222+
}
223+
224+
function commandPathFromRoot(namespaceRoot: Command, command: Command): string[] {
225+
const rootPath = commanderPath(namespaceRoot);
226+
const commandPath = commanderPath(command);
227+
return commandPath.slice(rootPath.length);
228+
}
229+
230+
function collectLeafCommands(command: Command): Command[] {
231+
if (command.commands.length === 0) return [command];
232+
return command.commands.flatMap(child => collectLeafCommands(child));
233+
}
234+
235+
function formatCommanderPositionals(args: readonly CommanderArgument[]): string {
236+
return args
237+
.map(arg => {
238+
const name = `${arg.name()}${arg.variadic ? '...' : ''}`;
239+
return arg.required ? `<${name}>` : `[${name}]`;
240+
})
241+
.join(' ');
242+
}
243+
244+
function formatCommanderUsage(
245+
command: Command,
246+
opts: { namespaceRoot?: Command; globalCommand?: Command } = {},
247+
): string {
248+
const path = commanderPath(command).join(' ');
249+
const positionalText = formatCommanderPositionals(command.registeredArguments);
250+
const hasOptions = compactCommanderOptions(command.options).length > 0
251+
|| (opts.namespaceRoot ? compactCommanderOptions(opts.namespaceRoot.options).length > 0 : false)
252+
|| (opts.globalCommand ? compactCommanderOptions(opts.globalCommand.options).length > 0 : false);
253+
const optionText = hasOptions ? ' [options]' : '';
254+
return `${path}${positionalText ? ` ${positionalText}` : ''}${optionText}`;
255+
}
256+
257+
function compactCommanderCommand(
258+
namespaceRoot: Command,
259+
command: Command,
260+
opts: { globalCommand?: Command } = {},
261+
): Record<string, unknown> {
262+
const relativePath = commandPathFromRoot(namespaceRoot, command);
263+
return {
264+
name: relativePath.join(' '),
265+
command: commanderPath(command).join(' '),
266+
usage: formatCommanderUsage(command, { namespaceRoot, globalCommand: opts.globalCommand }),
267+
description: command.description(),
268+
...(command.aliases().length ? { aliases: command.aliases() } : {}),
269+
positionals: command.registeredArguments.map(compactCommanderArgument),
270+
command_options: compactCommanderOptions(command.options),
271+
};
272+
}
273+
274+
export function commanderNamespaceHelpData(
275+
namespaceRoot: Command,
276+
opts: { globalCommand?: Command; description?: string } = {},
277+
): Record<string, unknown> {
278+
const leaves = collectLeafCommands(namespaceRoot)
279+
.filter(command => command !== namespaceRoot)
280+
.sort((a, b) => commandPathFromRoot(namespaceRoot, a).join(' ').localeCompare(commandPathFromRoot(namespaceRoot, b).join(' ')));
281+
return {
282+
namespace: namespaceRoot.name(),
283+
command: commanderPath(namespaceRoot).join(' '),
284+
usage: `${commanderPath(namespaceRoot).join(' ')} <command> [args] [options]`,
285+
description: opts.description ?? namespaceRoot.description(),
286+
command_count: leaves.length,
287+
commands: leaves.map(command => compactCommanderCommand(namespaceRoot, command, opts)),
288+
namespace_options: compactCommanderOptions(namespaceRoot.options),
289+
...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}),
290+
structured_help: {
291+
formats: ['yaml', 'json'],
292+
usage: `${commanderPath(namespaceRoot).join(' ')} --help -f yaml`,
293+
},
294+
};
295+
}
296+
297+
export function commanderCommandHelpData(
298+
namespaceRoot: Command,
299+
command: Command,
300+
opts: { globalCommand?: Command } = {},
301+
): Record<string, unknown> {
302+
return {
303+
namespace: namespaceRoot.name(),
304+
...compactCommanderCommand(namespaceRoot, command, opts),
305+
namespace_options: compactCommanderOptions(namespaceRoot.options),
306+
...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}),
307+
structured_help: {
308+
formats: ['yaml', 'json'],
309+
usage: `${commanderPath(command).join(' ')} --help -f yaml`,
310+
},
311+
};
312+
}
313+
314+
export function installCommanderNamespaceStructuredHelp(
315+
namespaceRoot: Command,
316+
opts: { globalCommand?: Command; description?: string } = {},
317+
): void {
318+
installStructuredHelp(namespaceRoot, () => commanderNamespaceHelpData(namespaceRoot, opts));
319+
for (const command of collectLeafCommands(namespaceRoot)) {
320+
if (command === namespaceRoot) continue;
321+
installStructuredHelp(command, () => commanderCommandHelpData(namespaceRoot, command, opts));
322+
}
323+
}
324+
161325
function positionals(cmd: CliCommand): Arg[] {
162326
return cmd.args.filter(arg => arg.positional);
163327
}

0 commit comments

Comments
 (0)