From 2bc42d658a11ac130579b6cf02488c775861c5be Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Sun, 23 Aug 2026 00:44:28 +0530 Subject: [PATCH] fix(cli): give every site command a description and structured help Co-Authored-By: Claude Opus 5 --- src/cli.ts | 4 + src/hosted/runner.test.ts | 2 +- src/site-memory/commands.test.ts | 46 ++++++++- src/site-memory/commands.ts | 159 ++++++++++++++++++++++++++----- 4 files changed, 182 insertions(+), 29 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3e1a0d61..10959262 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -673,6 +673,9 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi .description('Make any website your CLI. Zero setup. AI-powered.'); configureRootCommandSurface(program); registerSiteCommands(program, createLocalSiteMemoryBackend()); + const siteCmd = program.commands.find(command => command.name() === 'site')!; + // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing. + const originalSiteDescription = siteCmd.description(); // ── Built-in: list ──────────────────────────────────────────────────────── @@ -2218,6 +2221,7 @@ cli({ installCommanderNamespaceStructuredHelp(pluginCmd, { globalCommand: program, description: originalPluginDescription }); installCommanderNamespaceStructuredHelp(adapterCmd, { globalCommand: program, description: originalAdapterDescription }); installCommanderNamespaceStructuredHelp(profileCmd, { globalCommand: program, description: originalProfileDescription }); + installCommanderNamespaceStructuredHelp(siteCmd, { globalCommand: program, description: originalSiteDescription }); program.configureHelp({ visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())), }); diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 4a60b9cb..374dcd0e 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -415,7 +415,7 @@ describe('runHostedCli', () => { const config = makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }); await expect(runHostedCli(['site', '--help'], { config, stdout: stdout.stream })).resolves.toMatchObject({ exitCode: 0 }); await expect(runHostedCli(['adapter', 'source', '--help'], { config, stdout: stdout.stream })).resolves.toMatchObject({ exitCode: 0 }); - expect(stdout.text()).toContain('Read and write site memory'); + expect(stdout.text()).toContain('webcmd site '); expect(stdout.text()).toContain('adapter source'); }); diff --git a/src/site-memory/commands.test.ts b/src/site-memory/commands.test.ts index 92c09ce8..817e2f39 100644 --- a/src/site-memory/commands.test.ts +++ b/src/site-memory/commands.test.ts @@ -27,12 +27,12 @@ function program(store: SiteMemoryBackend, io?: { readStdin?: () => Promise { - it('documents concise authoring commands in site-memory help', () => { + it('documents the site argument grammar in site-memory help', () => { const help = program(backend()).commands.find(command => command.name() === 'site')!.helpInformation(); - expect(help).toContain('webcmd site note add --text'); - expect(help).toContain('webcmd site endpoint set --url'); - expect(help).toContain('webcmd site fixture put '); + expect(help).toContain('webcmd site '); + expect(help).toContain('Right: webcmd site field-map add example.com price'); + expect(help).toContain("Agent tip: use '--help -f yaml'"); }); it('accepts -f json on site fixture get', async () => { @@ -82,6 +82,44 @@ describe('site fixture put --stdin', () => { }); }); +describe('site command help coverage', () => { + function walk(command: Command): Command[] { + return command.commands + .filter(child => child.name() !== 'help') + .flatMap(child => [child, ...walk(child)]); + } + + const site = program(backend()).commands.find(command => command.name() === 'site')!; + + it('gives every command in the site tree a description', () => { + const missing = walk(site).filter(command => command.description().trim() === ''); + expect(missing.map(command => command.name())).toEqual([]); + }); + + it('gives every site option and positional a help string', () => { + const missing: string[] = []; + for (const command of walk(site)) { + for (const option of command.options) { + if (!option.description) missing.push(`${command.name()} ${option.flags}`); + } + for (const arg of command.registeredArguments) { + if (!arg.description) missing.push(`${command.name()} <${arg.name()}>`); + } + } + expect(missing).toEqual([]); + }); + + it('documents --text and the site-before-flags order on site note add', () => { + const help = site.commands.find(command => command.name() === 'note')! + .commands.find(command => command.name() === 'add')! + .helpInformation(); + + expect(help).toContain('--text '); + expect(help).toMatch(/Usage: .*note add \[options\] /); + expect(help).toContain('Example: webcmd site note add example.com --text'); + }); +}); + describe('readSitePutSource', () => { it('enumerates the valid input shape when neither path nor --stdin is given', async () => { await expect(readSitePutSource({})).rejects.toBeInstanceOf(ArgumentError); diff --git a/src/site-memory/commands.ts b/src/site-memory/commands.ts index 7662c76c..b6793ef5 100644 --- a/src/site-memory/commands.ts +++ b/src/site-memory/commands.ts @@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import type { Command } from 'commander'; import { addOutputFormatOption, outputFormatIsExplicit, resolveCommandOutputFormat } from '../command-surface.js'; import { ArgumentError, CliError, EXIT_CODES } from '../errors.js'; +import { getRequestedHelpFormat } from '../help.js'; import { render as renderOutput } from '../output.js'; import { writeToStream } from '../stream-write.js'; import { @@ -43,39 +44,105 @@ export interface SitePutSourceInput { stdin?: boolean; } +const SITE_ARG_HELP = 'Site key the memory belongs to, e.g. news.ycombinator.com'; +const SITE_COMMAND_ARG_HELP = 'Fixture key in / form, e.g. news.ycombinator.com/top'; +const OUTPUT_ARG_HELP = 'Write the result to this file instead of stdout'; + +const AGENT_TIP = "Agent tip: use '--help -f yaml' for structured args/options."; + +/** + * Appends footer lines to text help only. Commander's own `addHelpText` would + * also wrap `--help -f yaml`, so the suffix is applied inside `helpInformation` + * and skipped whenever a structured format was requested. + */ +function withHelpFooter(command: Command, ...lines: string[]): Command { + const original = command.helpInformation.bind(command); + command.helpInformation = ((contextOptions?: unknown) => { + const text = original(contextOptions as never); + return getRequestedHelpFormat() ? text : `${text}\n${lines.join('\n')}\n`; + }) as Command['helpInformation']; + return command; +} + +/** Adds the `Example:` / `Agent tip:` footer that adapter command help already shows. */ +function withExample(command: Command, example: string): Command { + return withHelpFooter(command, `Example: ${example}`, AGENT_TIP); +} + export function registerSiteCommands( root: Command, backend: SiteMemoryBackend, stdout?: NodeJS.WritableStream, io: SiteCommandIo = {}, ): void { - const site = root.command('site') - .description(`Read and write site memory - -Authoring: - webcmd site note add --text - webcmd site endpoint set --url --method GET - webcmd site fixture put - webcmd site sample add `); - const memory = site.command('memory').description('Inspect site memory'); - const show = addOutputFormatOption(memory.command('show').argument('').option('--kind ').option('-o, --output '), 'json'); + const site = withHelpFooter(root.command('site') + .description('Read and write per-site memory: notes, verified endpoints, field maps, fixtures and samples') + .usage('memory|note|endpoint|field-map|fixture|sample [args] [options]'), + 'Grammar: webcmd site [args] [options]', + ' The site name is a positional of the LEAF verb, never of the group.', + ' Right: webcmd site field-map add example.com price', + ' Wrong: webcmd site field-map example.com add price', + '', + 'Example: webcmd site note add news.ycombinator.com --text "front page is server-rendered"', + AGENT_TIP); + + const memory = withExample(site.command('memory') + .description('Inspect everything stored for a site: webcmd site memory ') + .usage('show|list [options]'), + 'webcmd site memory show example.com --kind endpoints'); + const show = addOutputFormatOption(withExample(memory.command('show') + .description('Print every memory record stored for a site, optionally narrowed with --kind') + .argument('', SITE_ARG_HELP) + .option('--kind ', 'Only show one kind: notes, endpoints, field-map, verify, fixture') + .option('-o, --output ', OUTPUT_ARG_HELP), + 'webcmd site memory show example.com --kind notes'), 'json'); show.action(async (name, opts: { kind?: string; output?: string; format?: string }) => { await emitListing(show, await backend.show(name, parseKind(opts.kind)), opts, stdout); }); - const list = addOutputFormatOption(memory.command('list').argument('').option('-o, --output ')); + const list = addOutputFormatOption(withExample(memory.command('list') + .description('List the memory files stored for a site with size, checksum and last update') + .argument('', SITE_ARG_HELP) + .option('-o, --output ', OUTPUT_ARG_HELP), + 'webcmd site memory list example.com')); list.action(async (name, opts: { output?: string; format?: string }) => { await emitListing(list, await backend.list(name), opts, stdout, ['path', 'updatedAt', 'byteSize', 'sha256']); }); - const note = site.command('note').description('Read and write site notes'); - note.command('add').argument('').requiredOption('--text ').option('--author ') + + const note = withExample(site.command('note') + .description('Read and write freeform site notes: webcmd site note ') + .usage('add|list [options]'), + 'webcmd site note add example.com --text "search needs a session cookie"'); + withExample(note.command('add') + .description('Append a markdown note to a site; the site name comes before --text') + .argument('', SITE_ARG_HELP) + .requiredOption('--text ', 'Note body, in markdown (required; there is no -m alias)') + .option('--author ', 'Who wrote the note'), + 'webcmd site note add example.com --text "search needs a session cookie" --author agent') .action((name, opts: { text: string; author?: string }) => backend.note(name, opts.text, opts.author)); - const noteList = addOutputFormatOption(note.command('list').argument('').option('-o, --output '), 'json'); + const noteList = addOutputFormatOption(withExample(note.command('list') + .description('Print the notes recorded for a site') + .argument('', SITE_ARG_HELP) + .option('-o, --output ', OUTPUT_ARG_HELP), + 'webcmd site note list example.com'), 'json'); noteList.action(async (name, opts: { output?: string; format?: string }) => { await emitListing(noteList, await backend.show(name, 'notes'), opts, stdout); }); - const endpoint = site.command('endpoint').description('Maintain verified endpoints'); - endpoint.command('set').argument('').argument('').requiredOption('--url ').requiredOption('--method ') - .option('--params ').option('--rows-path ').option('--fields ').option('--notes ') + + const endpoint = withExample(site.command('endpoint') + .description('Maintain the verified API endpoints found for a site: webcmd site endpoint ') + .usage('set|stale|list [args] [options]'), + 'webcmd site endpoint set example.com search --url https://example.com/api/search --method GET'); + withExample(endpoint.command('set') + .description('Record or update one verified endpoint for a site') + .argument('', SITE_ARG_HELP) + .argument('', 'Endpoint name to store it under, e.g. search') + .requiredOption('--url ', 'Request URL of the endpoint') + .requiredOption('--method ', 'HTTP method, e.g. GET or POST') + .option('--params ', 'Query or body parameters as a JSON object') + .option('--rows-path ', 'Dot path to the result rows inside the response, e.g. data.items') + .option('--fields ', 'Comma-separated list of the response fields worth keeping') + .option('--notes ', 'Freeform notes about auth, paging or quirks'), + 'webcmd site endpoint set example.com search --url https://example.com/api/search --method GET --rows-path data.items') .action((siteName, name, opts: { url: string; method: string; params?: string; rowsPath?: string; fields?: string; notes?: string }) => backend.endpoint(siteName, name, { url: opts.url, method: opts.method, ...(opts.params ? { params: parseJsonObject(opts.params) } : {}), @@ -83,15 +150,44 @@ Authoring: ...(opts.fields ? { sampleFields: opts.fields.split(',').map(value => value.trim()).filter(Boolean) } : {}), ...(opts.notes ? { notes: opts.notes } : {}), })); - endpoint.command('stale').argument('').argument('').action((siteName, name) => backend.stale(siteName, name)); - const endpointList = addOutputFormatOption(endpoint.command('list').argument('').option('-o, --output '), 'json'); + withExample(endpoint.command('stale') + .description('Mark a recorded endpoint stale once it stops returning what it used to') + .argument('', SITE_ARG_HELP) + .argument('', 'Name of the recorded endpoint to mark stale'), + 'webcmd site endpoint stale example.com search') + .action((siteName, name) => backend.stale(siteName, name)); + const endpointList = addOutputFormatOption(withExample(endpoint.command('list') + .description('Print the endpoints recorded for a site') + .argument('', SITE_ARG_HELP) + .option('-o, --output ', OUTPUT_ARG_HELP), + 'webcmd site endpoint list example.com'), 'json'); endpointList.action(async (name, opts: { output?: string; format?: string }) => { await emitListing(endpointList, await backend.show(name, 'endpoints'), opts, stdout); }); - site.command('field-map').command('add').argument('').argument('').requiredOption('--meaning ').requiredOption('--source ').option('--force') + + const fieldMap = withExample(site.command('field-map') + .description('Explain what opaque response field names mean: webcmd site field-map add ') + .usage('add [options]'), + 'webcmd site field-map add example.com p --meaning "price in cents" --source /api/search'); + withExample(fieldMap.command('add') + .description('Record what one response field means and where it was observed') + .argument('', SITE_ARG_HELP) + .argument('', 'Raw field name as it appears in the response, e.g. p') + .requiredOption('--meaning ', 'What the field actually holds') + .requiredOption('--source ', 'Where it was seen, e.g. the endpoint path') + .option('--force', 'Overwrite an existing mapping for this key'), + 'webcmd site field-map add example.com p --meaning "price in cents" --source /api/search') .action((siteName, key, opts: { meaning: string; source: string; force?: boolean }) => backend.fieldMap(siteName, key, opts.meaning, opts.source, opts.force === true)); - const fixture = site.command('fixture').description('Read and write verify fixtures'); - const get = addOutputFormatOption(fixture.command('get').argument('').option('--output '), 'json'); + + const fixture = withExample(site.command('fixture') + .description('Read and write the verify fixtures used by webcmd browser verify: webcmd site fixture /') + .usage('get|put / [args] [options]'), + 'webcmd site fixture get example.com/search'); + const get = addOutputFormatOption(withExample(fixture.command('get') + .description('Print the stored verify fixture for one site command') + .argument('', SITE_COMMAND_ARG_HELP) + .option('--output ', OUTPUT_ARG_HELP), + 'webcmd site fixture get example.com/search'), 'json'); get.action(async (key, opts: { output?: string; format?: string }) => { const { site: siteName, command } = parseSiteCommand(key); const body = await backend.fixture(siteName, command); @@ -109,7 +205,12 @@ Authoring: if (fmt === null) return; await renderOutput(parseFixtureBody(body), { fmt, fmtExplicit: true, stdout }); }); - fixture.command('put').argument('').argument('[path]').option('--stdin', 'Read the fixture from stdin') + withExample(fixture.command('put') + .description('Store a verify fixture for one site command, read from a file or stdin') + .argument('', SITE_COMMAND_ARG_HELP) + .argument('[path]', 'File holding the fixture body; omit it and pass --stdin to read stdin') + .option('--stdin', 'Read the fixture from stdin'), + 'webcmd site fixture put example.com/search ./search.json') .action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { const { site: siteName, command } = parseSiteCommand(key); await backend.putFixture(siteName, command, await readSitePutSource( @@ -117,7 +218,17 @@ Authoring: { readStdin: io.readStdin, usage: 'webcmd site fixture put ' }, )); }); - site.command('sample').command('add').argument('').argument('[path]').option('--stdin', 'Read the sample from stdin') + + const sample = withExample(site.command('sample') + .description('Keep raw response samples for a site command: webcmd site sample add /') + .usage('add / [path] [options]'), + 'webcmd site sample add example.com/search ./sample.json'); + withExample(sample.command('add') + .description('Save a raw response sample for one site command, read from a file or stdin') + .argument('', SITE_COMMAND_ARG_HELP) + .argument('[path]', 'File holding the sample body; omit it and pass --stdin to read stdin') + .option('--stdin', 'Read the sample from stdin'), + 'webcmd site sample add example.com/search ./sample.json') .action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { const { site: siteName, command } = parseSiteCommand(key); await backend.sample(siteName, command, await readSitePutSource(