Skip to content

Commit e243845

Browse files
committed
feat(help): make adapter help agent-friendly
1 parent 8d201ae commit e243845

3 files changed

Lines changed: 280 additions & 9 deletions

File tree

src/cli.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ describe('createProgram root help descriptions', () => {
247247
strategy: Strategy.PUBLIC,
248248
browser: false,
249249
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
250+
columns: ['title', 'url'],
250251
});
251252

252253
const program = createProgram('', '');
@@ -261,10 +262,101 @@ describe('createProgram root help descriptions', () => {
261262
name: 'hot',
262263
access: 'read',
263264
description: 'Bilibili hot videos',
265+
browser: false,
264266
example: 'opencli bilibili hot -f yaml',
265-
args: [{ name: 'limit', type: 'int', default: 20 }],
267+
command_options: [{ name: 'limit', type: 'int', default: 20 }],
268+
columns: ['title', 'url'],
266269
},
267270
]);
271+
expect(data.commands[0]).not.toHaveProperty('args');
272+
} finally {
273+
process.argv = argv;
274+
registry.clear();
275+
for (const [key, value] of snapshot) registry.set(key, value);
276+
}
277+
});
278+
279+
it('renders per-site text help without per-command common option noise', () => {
280+
const registry = getRegistry();
281+
const snapshot = new Map(registry);
282+
registry.clear();
283+
try {
284+
cli({
285+
site: 'bilibili',
286+
name: 'hot',
287+
access: 'read',
288+
description: 'Bilibili hot videos',
289+
strategy: Strategy.PUBLIC,
290+
browser: false,
291+
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
292+
});
293+
cli({
294+
site: 'bilibili',
295+
name: 'video',
296+
access: 'read',
297+
description: 'Read one video',
298+
domain: 'www.bilibili.com',
299+
strategy: Strategy.PUBLIC,
300+
browser: true,
301+
args: [{ name: 'bvid', positional: true, required: true, help: 'Video id' }],
302+
});
303+
304+
const program = createProgram('', '');
305+
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
306+
expect(site).toBeTruthy();
307+
const help = site!.helpInformation();
308+
309+
expect(help).toContain('hot [options] [read] Bilibili hot videos');
310+
expect(help).toContain('video <bvid> [read] Read one video');
311+
expect(help).toContain('hot [options]');
312+
expect(help).not.toContain('video <bvid> [options]');
313+
expect(help).not.toContain('\nOptions:');
314+
expect(help).toContain('Common options:');
315+
expect(help).toContain('-f, --format <fmt>');
316+
expect(help).toContain('--trace <mode>');
317+
expect(help).toContain('get all command args/options in one structured response');
318+
} finally {
319+
registry.clear();
320+
for (const [key, value] of snapshot) registry.set(key, value);
321+
}
322+
});
323+
324+
it('separates command args from common options in structured help', () => {
325+
const registry = getRegistry();
326+
const snapshot = new Map(registry);
327+
const argv = process.argv;
328+
registry.clear();
329+
try {
330+
cli({
331+
site: 'bilibili',
332+
name: 'video',
333+
access: 'read',
334+
description: 'Read one video',
335+
strategy: Strategy.PUBLIC,
336+
domain: 'www.bilibili.com',
337+
browser: true,
338+
args: [
339+
{ name: 'bvid', positional: true, required: true, help: 'Video id' },
340+
{ name: 'with-comments', type: 'boolean', default: false, help: 'Include comments' },
341+
],
342+
columns: ['title', 'url'],
343+
});
344+
345+
const program = createProgram('', '');
346+
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
347+
const command = site!.commands.find(cmd => cmd.name() === 'video');
348+
expect(command).toBeTruthy();
349+
process.argv = ['node', 'opencli', 'bilibili', 'video', '--help', '-f', 'yaml'];
350+
const data = yaml.load(command!.helpInformation()) as any;
351+
352+
expect(data.usage).toBe('opencli bilibili video <bvid> [options]');
353+
expect(data.browser).toBe(true);
354+
expect(data.domain).toBe('www.bilibili.com');
355+
expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]);
356+
expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]);
357+
expect(data.common_options.map((option: any) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']);
358+
expect(data.columns).toEqual(['title', 'url']);
359+
expect(data).not.toHaveProperty('args');
268360
} finally {
269361
process.argv = argv;
270362
registry.clear();

src/commanderAdapter.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import { Command } from 'commander';
1414
import { log } from './logger.js';
1515
import yaml from 'js-yaml';
1616
import { type CliCommand, fullName, getRegistry } from './registry.js';
17-
import { formatRegistryHelpText } from './serialization.js';
1817
import { render as renderOutput } from './output.js';
1918
import { executeCommand, prepareCommandArgs } from './execution.js';
2019
import {
2120
commandHelpData,
21+
formatCommandHelpText,
22+
formatCommandListTerm,
2223
formatSiteCommandDescription,
24+
formatSiteHelpText,
25+
getRequestedHelpFormat,
2326
installStructuredHelp,
27+
renderStructuredHelp,
2428
siteHelpData,
2529
} from './help.js';
2630
import {
@@ -58,7 +62,15 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
5862
.option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
5963
.option('-v, --verbose', 'Debug output', false);
6064

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

6375
subCmd.action(async (...actionArgs: unknown[]) => {
6476
const actionOpts = actionArgs[positionalArgs.length] ?? {};
@@ -192,7 +204,17 @@ export function registerAllCommands(
192204
for (const cmd of commands) {
193205
registerCommandToProgram(siteCmd, cmd);
194206
}
195-
installStructuredHelp(siteCmd, () => siteHelpData(site, commands));
207+
const commandTerms = new Map(commands.map(cmd => [cmd.name, formatCommandListTerm(cmd)]));
208+
siteCmd.configureHelp({
209+
subcommandTerm: command => commandTerms.get(command.name()) ?? command.name(),
210+
});
211+
const originalSiteHelpInformation = siteCmd.helpInformation.bind(siteCmd);
212+
siteCmd.helpInformation = ((contextOptions?: unknown) => {
213+
const format = getRequestedHelpFormat();
214+
if (format) return renderStructuredHelp(siteHelpData(site, commands), format);
215+
void originalSiteHelpInformation(contextOptions as never);
216+
return formatSiteHelpText(site, commands);
217+
}) as Command['helpInformation'];
196218
}
197219
return [...commandsBySite.keys()].sort((a, b) => a.localeCompare(b));
198220
}

src/help.ts

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ import { formatCommandExample } from './serialization.js';
66

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

9+
const COMMON_OPTIONS = [
10+
{
11+
flags: '-f, --format <fmt>',
12+
name: 'format',
13+
help: 'Output format: table, plain, json, yaml, md, csv',
14+
default: 'table',
15+
choices: ['table', 'plain', 'json', 'yaml', 'md', 'csv'],
16+
},
17+
{
18+
flags: '--trace <mode>',
19+
name: 'trace',
20+
help: 'Trace capture: off, on, retain-on-failure',
21+
default: 'off',
22+
choices: ['off', 'on', 'retain-on-failure'],
23+
},
24+
{
25+
flags: '-v, --verbose',
26+
name: 'verbose',
27+
help: 'Debug output',
28+
default: false,
29+
},
30+
{
31+
flags: '-h, --help',
32+
name: 'help',
33+
help: 'display help for command',
34+
},
35+
] as const;
36+
937
function normalizeStructuredHelpFormat(value: string | undefined): StructuredHelpFormat | undefined {
1038
const normalized = value?.toLowerCase();
1139
if (normalized === 'yaml' || normalized === 'yml') return 'yaml';
@@ -102,7 +130,7 @@ export function formatRootAdapterHelpText(groups: RootAdapterGroups): string {
102130
lines.push(...formatGroupSection('App adapters', groups.apps));
103131
lines.push(...formatGroupSection('Site adapters', groups.sites));
104132
lines.push("Run 'opencli list' for full command details, or 'opencli <site> --help' to inspect one site.");
105-
lines.push("Agent tip: use 'opencli <site> --help -f yaml' for structured commands, args, access, and examples.");
133+
lines.push("Agent tip: use 'opencli <site> --help -f yaml' for all command args/options in one structured response.");
106134
lines.push('');
107135
return lines.join('\n');
108136
}
@@ -120,18 +148,62 @@ function compactArg(arg: Arg): Record<string, unknown> {
120148
};
121149
}
122150

123-
function compactCommand(cmd: CliCommand, opts: { includeColumns?: boolean } = {}): Record<string, unknown> {
151+
function compactCommonOption(option: typeof COMMON_OPTIONS[number]): Record<string, unknown> {
152+
return {
153+
name: option.name,
154+
flags: option.flags,
155+
help: option.help,
156+
...('default' in option ? { default: option.default } : {}),
157+
...('choices' in option ? { choices: option.choices } : {}),
158+
};
159+
}
160+
161+
function positionals(cmd: CliCommand): Arg[] {
162+
return cmd.args.filter(arg => arg.positional);
163+
}
164+
165+
function commandOptions(cmd: CliCommand): Arg[] {
166+
return cmd.args.filter(arg => !arg.positional);
167+
}
168+
169+
function formatPositionals(args: readonly Arg[]): string {
170+
return args
171+
.map(arg => arg.required ? `<${arg.name}>` : `[${arg.name}]`)
172+
.join(' ');
173+
}
174+
175+
function formatCommandOptionTerm(arg: Arg): string {
176+
if (arg.required || arg.valueRequired) return `--${arg.name} <value>`;
177+
return `--${arg.name} [value]`;
178+
}
179+
180+
export function formatCommandListTerm(cmd: CliCommand): string {
181+
const positionalText = formatPositionals(positionals(cmd));
182+
const optionText = commandOptions(cmd).length > 0 ? ' [options]' : '';
183+
return `${cmd.name}${positionalText ? ` ${positionalText}` : ''}${optionText}`;
184+
}
185+
186+
function formatUsage(cmd: CliCommand): string {
187+
const positionalText = formatPositionals(positionals(cmd));
188+
return `opencli ${cmd.site} ${cmd.name}${positionalText ? ` ${positionalText}` : ''} [options]`;
189+
}
190+
191+
function compactCommand(cmd: CliCommand): Record<string, unknown> {
124192
return {
125193
name: cmd.name,
126194
command: `opencli ${cmd.site} ${cmd.name}`,
195+
usage: formatUsage(cmd),
127196
access: cmd.access,
128197
description: cmd.description,
198+
browser: !!cmd.browser,
199+
...(cmd.domain ? { domain: cmd.domain } : {}),
129200
...(cmd.aliases?.length ? { aliases: cmd.aliases } : {}),
130-
args: cmd.args.map(compactArg),
201+
positionals: positionals(cmd).map(compactArg),
202+
command_options: commandOptions(cmd).map(compactArg),
131203
example: formatCommandExample(cmd),
132204
...(cmd.browserSession ? { browserSession: cmd.browserSession } : {}),
133205
...(cmd.defaultFormat ? { defaultFormat: cmd.defaultFormat } : {}),
134-
...(opts.includeColumns && cmd.columns?.length ? { columns: cmd.columns } : {}),
206+
...(cmd.columns?.length ? { columns: cmd.columns } : {}),
135207
};
136208
}
137209

@@ -176,6 +248,7 @@ export function siteHelpData(site: string, commands: readonly CliCommand[]): Rec
176248
site,
177249
command_count: unique.length,
178250
commands: unique.map(cmd => compactCommand(cmd)),
251+
common_options: COMMON_OPTIONS.map(compactCommonOption),
179252
next: [
180253
`opencli ${site} <command> --help -f yaml`,
181254
`opencli ${site} <command> -f yaml`,
@@ -186,11 +259,95 @@ export function siteHelpData(site: string, commands: readonly CliCommand[]): Rec
186259
export function commandHelpData(cmd: CliCommand): Record<string, unknown> {
187260
return {
188261
site: cmd.site,
189-
...compactCommand(cmd, { includeColumns: true }),
262+
...compactCommand(cmd),
263+
common_options: COMMON_OPTIONS.map(compactCommonOption),
190264
output_formats: ['table', 'plain', 'yaml', 'json', 'md', 'csv'],
191265
};
192266
}
193267

268+
function formatRows(rows: readonly [string, string][]): string[] {
269+
if (rows.length === 0) return [];
270+
const width = Math.min(Math.max(...rows.map(([left]) => left.length)), 34);
271+
return rows.map(([left, right]) => ` ${left.padEnd(width + 2)}${right}`);
272+
}
273+
274+
function formatArgHelp(arg: Arg): string {
275+
const parts: string[] = [];
276+
if (arg.help) parts.push(arg.help);
277+
if (arg.default !== undefined) parts.push(`default: ${arg.default}`);
278+
if (arg.choices?.length) parts.push(`choices: ${arg.choices.join(', ')}`);
279+
return parts.join(' ');
280+
}
281+
282+
export function formatCommonOptionsHelpText(): string {
283+
const rows = COMMON_OPTIONS.map(option => {
284+
const details: string[] = [option.help];
285+
if ('default' in option) details.push(`default: ${option.default}`);
286+
if ('choices' in option) details.push(`choices: ${option.choices.join(', ')}`);
287+
return [option.flags, details.join(' ')] as [string, string];
288+
});
289+
return ['Common options:', ...formatRows(rows)].join('\n');
290+
}
291+
292+
export function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string {
293+
const unique = [...new Map(commands.map(cmd => [fullName(cmd), cmd])).values()]
294+
.sort((a, b) => a.name.localeCompare(b.name));
295+
const lines: string[] = [
296+
`Usage: opencli ${site} <command> [args] [options]`,
297+
'',
298+
wrapCommaList(unique.map(cmd => cmd.name), { indent: '' }),
299+
'',
300+
'Commands:',
301+
...formatRows(unique.map(cmd => [formatCommandListTerm(cmd), formatSiteCommandDescription(cmd)])),
302+
'',
303+
formatCommonOptionsHelpText(),
304+
'',
305+
`Agent tip: use 'opencli ${site} --help -f yaml' to get all command args/options in one structured response.`,
306+
'',
307+
];
308+
return lines.join('\n');
309+
}
310+
311+
export function formatCommandHelpText(cmd: CliCommand): string {
312+
const lines: string[] = [
313+
`Usage: ${formatUsage(cmd)}`,
314+
'',
315+
cmd.description,
316+
'',
317+
];
318+
319+
const positionalRows = positionals(cmd).map(arg => [
320+
arg.name,
321+
formatArgHelp(arg),
322+
] as [string, string]);
323+
if (positionalRows.length) {
324+
lines.push('Arguments:', ...formatRows(positionalRows), '');
325+
}
326+
327+
const optionRows = commandOptions(cmd).map(arg => [
328+
formatCommandOptionTerm(arg),
329+
formatArgHelp(arg),
330+
] as [string, string]);
331+
if (optionRows.length) {
332+
lines.push('Command options:', ...formatRows(optionRows), '');
333+
}
334+
335+
lines.push(formatCommonOptionsHelpText(), '');
336+
337+
const meta: string[] = [];
338+
meta.push(`Access: ${cmd.access}`);
339+
meta.push(`Browser: ${cmd.browser ? 'yes' : 'no'}`);
340+
if (cmd.domain) meta.push(`Domain: ${cmd.domain}`);
341+
if (cmd.defaultFormat) meta.push(`Default format: ${cmd.defaultFormat}`);
342+
if (cmd.aliases?.length) meta.push(`Aliases: ${cmd.aliases.join(', ')}`);
343+
lines.push(meta.join(' | '));
344+
lines.push(`Example: ${formatCommandExample(cmd)}`);
345+
if (cmd.columns?.length) lines.push(`Output columns: ${cmd.columns.join(', ')}`);
346+
lines.push("Agent tip: use '--help -f yaml' for structured args/options.");
347+
lines.push('');
348+
return lines.join('\n');
349+
}
350+
194351
export function installStructuredHelp(
195352
command: Command,
196353
data: () => unknown,

0 commit comments

Comments
 (0)