Skip to content

Commit 9d2113b

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

4 files changed

Lines changed: 333 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: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,135 @@ 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 nested browser parent structured help for a subtree', () => {
430+
const argv = process.argv;
431+
try {
432+
const program = createProgram('', '');
433+
const browser = program.commands.find(cmd => cmd.name() === 'browser')!;
434+
const tab = browser.commands.find(cmd => cmd.name() === 'tab');
435+
expect(tab).toBeTruthy();
436+
437+
process.argv = ['node', 'opencli', 'browser', 'tab', '--help', '-f', 'yaml'];
438+
const data = yaml.load(tab!.helpInformation()) as any;
439+
440+
expect(data).toMatchObject({
441+
namespace: 'browser',
442+
group: 'tab',
443+
command: 'opencli browser tab',
444+
usage: 'opencli browser tab <command> [args] [options]',
445+
command_count: 4,
446+
});
447+
expect(data.commands.map((cmd: any) => cmd.name)).toEqual([
448+
'tab close',
449+
'tab list',
450+
'tab new',
451+
'tab select',
452+
]);
453+
expect(data.commands.find((cmd: any) => cmd.name === 'tab close')).toMatchObject({
454+
command: 'opencli browser tab close',
455+
usage: 'opencli browser tab close [targetId] [options]',
456+
positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
457+
});
458+
expect(data.namespace_options.map((option: any) => option.name)).toEqual(['workspace']);
459+
expect(data.structured_help).toMatchObject({
460+
usage: 'opencli browser tab --help -f yaml',
461+
});
462+
} finally {
463+
process.argv = argv;
464+
}
465+
});
466+
467+
it('renders browser command structured help without needing the full namespace dump', () => {
468+
const argv = process.argv;
469+
try {
470+
const program = createProgram('', '');
471+
const browser = program.commands.find(cmd => cmd.name() === 'browser')!;
472+
const click = browser.commands.find(cmd => cmd.name() === 'click');
473+
expect(click).toBeTruthy();
474+
475+
process.argv = ['node', 'opencli', 'browser', 'click', '--help', '-f', 'yaml'];
476+
const data = yaml.load(click!.helpInformation()) as any;
477+
478+
expect(data).toMatchObject({
479+
namespace: 'browser',
480+
name: 'click',
481+
command: 'opencli browser click',
482+
usage: 'opencli browser click <target> [options]',
483+
positionals: [{ name: 'target', required: true }],
484+
structured_help: {
485+
usage: 'opencli browser click --help -f yaml',
486+
},
487+
});
488+
expect(data.command_options.map((option: any) => option.name)).toEqual(['nth', 'tab']);
489+
expect(data.namespace_options.map((option: any) => option.name)).toEqual(['workspace']);
490+
expect(data.global_options.map((option: any) => option.name)).toContain('profile');
491+
} finally {
492+
process.argv = argv;
493+
}
494+
});
366495
});
367496

368497
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 originalBrowserDescription = 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: originalBrowserDescription });
27842786
program.configureHelp({
27852787
visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())),
27862788
});

src/help.ts

Lines changed: 197 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,182 @@ 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 collectDescendantCommands(command: Command): Command[] {
236+
return command.commands.flatMap(child => [child, ...collectDescendantCommands(child)]);
237+
}
238+
239+
function formatCommanderPositionals(args: readonly CommanderArgument[]): string {
240+
return args
241+
.map(arg => {
242+
const name = `${arg.name()}${arg.variadic ? '...' : ''}`;
243+
return arg.required ? `<${name}>` : `[${name}]`;
244+
})
245+
.join(' ');
246+
}
247+
248+
function formatCommanderUsage(
249+
command: Command,
250+
opts: { namespaceRoot?: Command; globalCommand?: Command } = {},
251+
): string {
252+
const path = commanderPath(command).join(' ');
253+
const positionalText = formatCommanderPositionals(command.registeredArguments);
254+
const hasOptions = compactCommanderOptions(command.options).length > 0
255+
|| (opts.namespaceRoot ? compactCommanderOptions(opts.namespaceRoot.options).length > 0 : false)
256+
|| (opts.globalCommand ? compactCommanderOptions(opts.globalCommand.options).length > 0 : false);
257+
const optionText = hasOptions ? ' [options]' : '';
258+
return `${path}${positionalText ? ` ${positionalText}` : ''}${optionText}`;
259+
}
260+
261+
function compactCommanderCommand(
262+
namespaceRoot: Command,
263+
command: Command,
264+
opts: { globalCommand?: Command } = {},
265+
): Record<string, unknown> {
266+
const relativePath = commandPathFromRoot(namespaceRoot, command);
267+
return {
268+
name: relativePath.join(' '),
269+
command: commanderPath(command).join(' '),
270+
usage: formatCommanderUsage(command, { namespaceRoot, globalCommand: opts.globalCommand }),
271+
description: command.description(),
272+
...(command.aliases().length ? { aliases: command.aliases() } : {}),
273+
positionals: command.registeredArguments.map(compactCommanderArgument),
274+
command_options: compactCommanderOptions(command.options),
275+
};
276+
}
277+
278+
export function commanderNamespaceHelpData(
279+
namespaceRoot: Command,
280+
opts: { globalCommand?: Command; description?: string } = {},
281+
): Record<string, unknown> {
282+
const leaves = collectLeafCommands(namespaceRoot)
283+
.filter(command => command !== namespaceRoot)
284+
.sort((a, b) => commandPathFromRoot(namespaceRoot, a).join(' ').localeCompare(commandPathFromRoot(namespaceRoot, b).join(' ')));
285+
return {
286+
namespace: namespaceRoot.name(),
287+
command: commanderPath(namespaceRoot).join(' '),
288+
usage: `${commanderPath(namespaceRoot).join(' ')} <command> [args] [options]`,
289+
description: opts.description ?? namespaceRoot.description(),
290+
command_count: leaves.length,
291+
commands: leaves.map(command => compactCommanderCommand(namespaceRoot, command, opts)),
292+
namespace_options: compactCommanderOptions(namespaceRoot.options),
293+
...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}),
294+
structured_help: {
295+
formats: ['yaml', 'json'],
296+
usage: `${commanderPath(namespaceRoot).join(' ')} --help -f yaml`,
297+
},
298+
};
299+
}
300+
301+
export function commanderCommandHelpData(
302+
namespaceRoot: Command,
303+
command: Command,
304+
opts: { globalCommand?: Command } = {},
305+
): Record<string, unknown> {
306+
return {
307+
namespace: namespaceRoot.name(),
308+
...compactCommanderCommand(namespaceRoot, command, opts),
309+
namespace_options: compactCommanderOptions(namespaceRoot.options),
310+
...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}),
311+
structured_help: {
312+
formats: ['yaml', 'json'],
313+
usage: `${commanderPath(command).join(' ')} --help -f yaml`,
314+
},
315+
};
316+
}
317+
318+
export function commanderGroupHelpData(
319+
namespaceRoot: Command,
320+
groupCommand: Command,
321+
opts: { globalCommand?: Command } = {},
322+
): Record<string, unknown> {
323+
const leaves = collectLeafCommands(groupCommand)
324+
.filter(command => command !== groupCommand)
325+
.sort((a, b) => commandPathFromRoot(namespaceRoot, a).join(' ').localeCompare(commandPathFromRoot(namespaceRoot, b).join(' ')));
326+
return {
327+
namespace: namespaceRoot.name(),
328+
group: commandPathFromRoot(namespaceRoot, groupCommand).join(' '),
329+
command: commanderPath(groupCommand).join(' '),
330+
usage: `${commanderPath(groupCommand).join(' ')} <command> [args] [options]`,
331+
description: groupCommand.description(),
332+
command_count: leaves.length,
333+
commands: leaves.map(command => compactCommanderCommand(namespaceRoot, command, opts)),
334+
namespace_options: compactCommanderOptions(namespaceRoot.options),
335+
...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}),
336+
structured_help: {
337+
formats: ['yaml', 'json'],
338+
usage: `${commanderPath(groupCommand).join(' ')} --help -f yaml`,
339+
},
340+
};
341+
}
342+
343+
export function installCommanderNamespaceStructuredHelp(
344+
namespaceRoot: Command,
345+
opts: { globalCommand?: Command; description?: string } = {},
346+
): void {
347+
installStructuredHelp(namespaceRoot, () => commanderNamespaceHelpData(namespaceRoot, opts));
348+
for (const command of collectDescendantCommands(namespaceRoot)) {
349+
if (command.commands.length > 0) {
350+
installStructuredHelp(command, () => commanderGroupHelpData(namespaceRoot, command, opts));
351+
} else {
352+
installStructuredHelp(command, () => commanderCommandHelpData(namespaceRoot, command, opts));
353+
}
354+
}
355+
}
356+
161357
function positionals(cmd: CliCommand): Arg[] {
162358
return cmd.args.filter(arg => arg.positional);
163359
}

0 commit comments

Comments
 (0)