Skip to content
Merged
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
94 changes: 93 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ describe('createProgram root help descriptions', () => {
strategy: Strategy.PUBLIC,
browser: false,
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
columns: ['title', 'url'],
});

const program = createProgram('', '');
Expand All @@ -261,10 +262,101 @@ describe('createProgram root help descriptions', () => {
name: 'hot',
access: 'read',
description: 'Bilibili hot videos',
browser: false,
example: 'opencli bilibili hot -f yaml',
args: [{ name: 'limit', type: 'int', default: 20 }],
command_options: [{ name: 'limit', type: 'int', default: 20 }],
columns: ['title', 'url'],
},
]);
expect(data.commands[0]).not.toHaveProperty('args');
} finally {
process.argv = argv;
registry.clear();
for (const [key, value] of snapshot) registry.set(key, value);
}
});

it('renders per-site text help without per-command common option noise', () => {
const registry = getRegistry();
const snapshot = new Map(registry);
registry.clear();
try {
cli({
site: 'bilibili',
name: 'hot',
access: 'read',
description: 'Bilibili hot videos',
strategy: Strategy.PUBLIC,
browser: false,
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
});
cli({
site: 'bilibili',
name: 'video',
access: 'read',
description: 'Read one video',
domain: 'www.bilibili.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [{ name: 'bvid', positional: true, required: true, help: 'Video id' }],
});

const program = createProgram('', '');
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
expect(site).toBeTruthy();
const help = site!.helpInformation();

expect(help).toContain('hot [options] [read] Bilibili hot videos');
expect(help).toContain('video <bvid> [read] Read one video');
expect(help).toContain('hot [options]');
expect(help).not.toContain('video <bvid> [options]');
expect(help).not.toContain('\nOptions:');
expect(help).toContain('Common options:');
expect(help).toContain('-f, --format <fmt>');
expect(help).toContain('--trace <mode>');
expect(help).toContain('get all command args/options in one structured response');
} finally {
registry.clear();
for (const [key, value] of snapshot) registry.set(key, value);
}
});

it('separates command args from common options in structured help', () => {
const registry = getRegistry();
const snapshot = new Map(registry);
const argv = process.argv;
registry.clear();
try {
cli({
site: 'bilibili',
name: 'video',
access: 'read',
description: 'Read one video',
strategy: Strategy.PUBLIC,
domain: 'www.bilibili.com',
browser: true,
args: [
{ name: 'bvid', positional: true, required: true, help: 'Video id' },
{ name: 'with-comments', type: 'boolean', default: false, help: 'Include comments' },
],
columns: ['title', 'url'],
});

const program = createProgram('', '');
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
const command = site!.commands.find(cmd => cmd.name() === 'video');
expect(command).toBeTruthy();
process.argv = ['node', 'opencli', 'bilibili', 'video', '--help', '-f', 'yaml'];
const data = yaml.load(command!.helpInformation()) as any;

expect(data.usage).toBe('opencli bilibili video <bvid> [options]');
expect(data.browser).toBe(true);
expect(data.domain).toBe('www.bilibili.com');
expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]);
expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]);
expect(data.common_options.map((option: any) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']);
expect(data.columns).toEqual(['title', 'url']);
expect(data).not.toHaveProperty('args');
} finally {
process.argv = argv;
registry.clear();
Expand Down
28 changes: 25 additions & 3 deletions src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import { Command } from 'commander';
import { log } from './logger.js';
import yaml from 'js-yaml';
import { type CliCommand, fullName, getRegistry } from './registry.js';
import { formatRegistryHelpText } from './serialization.js';
import { render as renderOutput } from './output.js';
import { executeCommand, prepareCommandArgs } from './execution.js';
import {
commandHelpData,
formatCommandHelpText,
formatCommandListTerm,
formatSiteCommandDescription,
formatSiteHelpText,
getRequestedHelpFormat,
installStructuredHelp,
renderStructuredHelp,
siteHelpData,
} from './help.js';
import {
Expand Down Expand Up @@ -58,7 +62,15 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
.option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
.option('-v, --verbose', 'Debug output', false);

installStructuredHelp(subCmd, () => commandHelpData(cmd), () => formatRegistryHelpText(cmd));
const originalHelpInformation = subCmd.helpInformation.bind(subCmd);
subCmd.helpInformation = ((contextOptions?: unknown) => {
const format = getRequestedHelpFormat();
if (format) return renderStructuredHelp(commandHelpData(cmd), format);
// Keep a fallback reference so future Commander upgrades still initialize
// internal help state before we render the cleaner grouped command help.
void originalHelpInformation(contextOptions as never);
return formatCommandHelpText(cmd);
}) as Command['helpInformation'];

subCmd.action(async (...actionArgs: unknown[]) => {
const actionOpts = actionArgs[positionalArgs.length] ?? {};
Expand Down Expand Up @@ -192,7 +204,17 @@ export function registerAllCommands(
for (const cmd of commands) {
registerCommandToProgram(siteCmd, cmd);
}
installStructuredHelp(siteCmd, () => siteHelpData(site, commands));
const commandTerms = new Map(commands.map(cmd => [cmd.name, formatCommandListTerm(cmd)]));
siteCmd.configureHelp({
subcommandTerm: command => commandTerms.get(command.name()) ?? command.name(),
});
const originalSiteHelpInformation = siteCmd.helpInformation.bind(siteCmd);
siteCmd.helpInformation = ((contextOptions?: unknown) => {
const format = getRequestedHelpFormat();
if (format) return renderStructuredHelp(siteHelpData(site, commands), format);
void originalSiteHelpInformation(contextOptions as never);
return formatSiteHelpText(site, commands);
}) as Command['helpInformation'];
}
return [...commandsBySite.keys()].sort((a, b) => a.localeCompare(b));
}
167 changes: 162 additions & 5 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ import { formatCommandExample } from './serialization.js';

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

const COMMON_OPTIONS = [
{
flags: '-f, --format <fmt>',
name: 'format',
help: 'Output format: table, plain, json, yaml, md, csv',
default: 'table',
choices: ['table', 'plain', 'json', 'yaml', 'md', 'csv'],
},
{
flags: '--trace <mode>',
name: 'trace',
help: 'Trace capture: off, on, retain-on-failure',
default: 'off',
choices: ['off', 'on', 'retain-on-failure'],
},
{
flags: '-v, --verbose',
name: 'verbose',
help: 'Debug output',
default: false,
},
{
flags: '-h, --help',
name: 'help',
help: 'display help for command',
},
] as const;

function normalizeStructuredHelpFormat(value: string | undefined): StructuredHelpFormat | undefined {
const normalized = value?.toLowerCase();
if (normalized === 'yaml' || normalized === 'yml') return 'yaml';
Expand Down Expand Up @@ -102,7 +130,7 @@ export function formatRootAdapterHelpText(groups: RootAdapterGroups): string {
lines.push(...formatGroupSection('App adapters', groups.apps));
lines.push(...formatGroupSection('Site adapters', groups.sites));
lines.push("Run 'opencli list' for full command details, or 'opencli <site> --help' to inspect one site.");
lines.push("Agent tip: use 'opencli <site> --help -f yaml' for structured commands, args, access, and examples.");
lines.push("Agent tip: use 'opencli <site> --help -f yaml' for all command args/options in one structured response.");
lines.push('');
return lines.join('\n');
}
Expand All @@ -120,18 +148,62 @@ function compactArg(arg: Arg): Record<string, unknown> {
};
}

function compactCommand(cmd: CliCommand, opts: { includeColumns?: boolean } = {}): Record<string, unknown> {
function compactCommonOption(option: typeof COMMON_OPTIONS[number]): Record<string, unknown> {
return {
name: option.name,
flags: option.flags,
help: option.help,
...('default' in option ? { default: option.default } : {}),
...('choices' in option ? { choices: option.choices } : {}),
};
}

function positionals(cmd: CliCommand): Arg[] {
return cmd.args.filter(arg => arg.positional);
}

function commandOptions(cmd: CliCommand): Arg[] {
return cmd.args.filter(arg => !arg.positional);
}

function formatPositionals(args: readonly Arg[]): string {
return args
.map(arg => arg.required ? `<${arg.name}>` : `[${arg.name}]`)
.join(' ');
}

function formatCommandOptionTerm(arg: Arg): string {
if (arg.required || arg.valueRequired) return `--${arg.name} <value>`;
return `--${arg.name} [value]`;
}

export function formatCommandListTerm(cmd: CliCommand): string {
const positionalText = formatPositionals(positionals(cmd));
const optionText = commandOptions(cmd).length > 0 ? ' [options]' : '';
return `${cmd.name}${positionalText ? ` ${positionalText}` : ''}${optionText}`;
}

function formatUsage(cmd: CliCommand): string {
const positionalText = formatPositionals(positionals(cmd));
return `opencli ${cmd.site} ${cmd.name}${positionalText ? ` ${positionalText}` : ''} [options]`;
}

function compactCommand(cmd: CliCommand): Record<string, unknown> {
return {
name: cmd.name,
command: `opencli ${cmd.site} ${cmd.name}`,
usage: formatUsage(cmd),
access: cmd.access,
description: cmd.description,
browser: !!cmd.browser,
...(cmd.domain ? { domain: cmd.domain } : {}),
...(cmd.aliases?.length ? { aliases: cmd.aliases } : {}),
args: cmd.args.map(compactArg),
positionals: positionals(cmd).map(compactArg),
command_options: commandOptions(cmd).map(compactArg),
example: formatCommandExample(cmd),
...(cmd.browserSession ? { browserSession: cmd.browserSession } : {}),
...(cmd.defaultFormat ? { defaultFormat: cmd.defaultFormat } : {}),
...(opts.includeColumns && cmd.columns?.length ? { columns: cmd.columns } : {}),
...(cmd.columns?.length ? { columns: cmd.columns } : {}),
};
}

Expand Down Expand Up @@ -176,6 +248,7 @@ export function siteHelpData(site: string, commands: readonly CliCommand[]): Rec
site,
command_count: unique.length,
commands: unique.map(cmd => compactCommand(cmd)),
common_options: COMMON_OPTIONS.map(compactCommonOption),
next: [
`opencli ${site} <command> --help -f yaml`,
`opencli ${site} <command> -f yaml`,
Expand All @@ -186,11 +259,95 @@ export function siteHelpData(site: string, commands: readonly CliCommand[]): Rec
export function commandHelpData(cmd: CliCommand): Record<string, unknown> {
return {
site: cmd.site,
...compactCommand(cmd, { includeColumns: true }),
...compactCommand(cmd),
common_options: COMMON_OPTIONS.map(compactCommonOption),
output_formats: ['table', 'plain', 'yaml', 'json', 'md', 'csv'],
};
}

function formatRows(rows: readonly [string, string][]): string[] {
if (rows.length === 0) return [];
const width = Math.min(Math.max(...rows.map(([left]) => left.length)), 34);
return rows.map(([left, right]) => ` ${left.padEnd(width + 2)}${right}`);
}

function formatArgHelp(arg: Arg): string {
const parts: string[] = [];
if (arg.help) parts.push(arg.help);
if (arg.default !== undefined) parts.push(`default: ${arg.default}`);
if (arg.choices?.length) parts.push(`choices: ${arg.choices.join(', ')}`);
return parts.join(' ');
}

export function formatCommonOptionsHelpText(): string {
const rows = COMMON_OPTIONS.map(option => {
const details: string[] = [option.help];
if ('default' in option) details.push(`default: ${option.default}`);
if ('choices' in option) details.push(`choices: ${option.choices.join(', ')}`);
return [option.flags, details.join(' ')] as [string, string];
});
return ['Common options:', ...formatRows(rows)].join('\n');
}

export function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string {
const unique = [...new Map(commands.map(cmd => [fullName(cmd), cmd])).values()]
.sort((a, b) => a.name.localeCompare(b.name));
const lines: string[] = [
`Usage: opencli ${site} <command> [args] [options]`,
'',
wrapCommaList(unique.map(cmd => cmd.name), { indent: '' }),
'',
'Commands:',
...formatRows(unique.map(cmd => [formatCommandListTerm(cmd), formatSiteCommandDescription(cmd)])),
'',
formatCommonOptionsHelpText(),
'',
`Agent tip: use 'opencli ${site} --help -f yaml' to get all command args/options in one structured response.`,
'',
];
return lines.join('\n');
}

export function formatCommandHelpText(cmd: CliCommand): string {
const lines: string[] = [
`Usage: ${formatUsage(cmd)}`,
'',
cmd.description,
'',
];

const positionalRows = positionals(cmd).map(arg => [
arg.name,
formatArgHelp(arg),
] as [string, string]);
if (positionalRows.length) {
lines.push('Arguments:', ...formatRows(positionalRows), '');
}

const optionRows = commandOptions(cmd).map(arg => [
formatCommandOptionTerm(arg),
formatArgHelp(arg),
] as [string, string]);
if (optionRows.length) {
lines.push('Command options:', ...formatRows(optionRows), '');
}

lines.push(formatCommonOptionsHelpText(), '');

const meta: string[] = [];
meta.push(`Access: ${cmd.access}`);
meta.push(`Browser: ${cmd.browser ? 'yes' : 'no'}`);
if (cmd.domain) meta.push(`Domain: ${cmd.domain}`);
if (cmd.defaultFormat) meta.push(`Default format: ${cmd.defaultFormat}`);
if (cmd.aliases?.length) meta.push(`Aliases: ${cmd.aliases.join(', ')}`);
lines.push(meta.join(' | '));
lines.push(`Example: ${formatCommandExample(cmd)}`);
if (cmd.columns?.length) lines.push(`Output columns: ${cmd.columns.join(', ')}`);
lines.push("Agent tip: use '--help -f yaml' for structured args/options.");
lines.push('');
return lines.join('\n');
}

export function installStructuredHelp(
command: Command,
data: () => unknown,
Expand Down
Loading