import { parseArgs, type ParseArgsOptionDescriptor } from 'node:util'; // golem's command line as one tree. Every command says what it is, what it // takes and what it does, in one place, and everything else is read from that: // parsing (node:util's parseArgs), `--help` at any level, `golem help `, // and Tab completion in every shell (complete.ts). A command added to the tree // has all of them without another line. // // A word is a subcommand while the command so far has subcommands and the // word names one; options can come anywhere, before or after, at any level. // The global options (`--config`, `--help`) are known everywhere. /** What a value is, for Tab completion: file names, a fixed list, or something looked up. */ export type Hint = 'files' | 'dirs' | readonly string[] | ((context: HintContext) => Candidate[] | Promise); export interface HintContext { /** the options given so far, `--config` among them */ values: Record; /** the arguments given so far */ positionals: string[]; } export interface Candidate { value: string; description?: string | undefined; } export interface Option { type: 'string' | 'boolean'; short?: string | undefined; multiple?: boolean | undefined; /** what the option is for, in help and completion */ description: string; /** the value's name in help: `--config ` */ value?: string | undefined; /** shown in help, not applied: defaults belong to the command */ default?: string | undefined; complete?: Hint | undefined; } export interface Positional { name: string; description: string; optional?: boolean | undefined; /** takes every remaining argument */ variadic?: boolean | undefined; complete?: Hint | undefined; } export interface Invocation { /** the command's words, from the root: `['models', 'list']` */ path: string[]; values: Record; positionals: string[]; io: IO; } export interface IO { out(text: string): void; err(text: string): void; } export interface Command { name: string; /** one line, for the list of commands and the top of the help */ summary: string; /** more, under the summary in this command's own help */ description?: string | undefined; options?: Record | undefined; positionals?: Positional[] | undefined; commands?: Command[] | undefined; /** left out of help and completion: `__complete` */ hidden?: boolean | undefined; /** a command with subcommands and no `run` shows its help */ run?: ((invocation: Invocation) => number | Promise) | undefined; } export const GLOBAL: Record = { config: { type: 'string', value: 'file', description: 'the config file to use', default: '~/.config/golem/golem.json', complete: 'files' }, help: { type: 'boolean', short: 'h', description: 'show help for this command' }, }; /** A usage mistake: what went wrong, and which command's help would set it right. */ export class UsageError extends Error { readonly path: string[]; constructor(message: string, path: string[]) { super(message); this.path = path; } } /** Every option a command knows: its own and the global ones. */ export function optionsOf(command: Command, root: Command): Record { return { ...(command === root ? root.options : {}), ...command.options, ...GLOBAL }; } /** Find the command a line of words names, and what is left for it. */ export function resolve(root: Command, argv: string[]): { command: Command; path: string[]; rest: string[] } { let command = root; const path: string[] = []; const rest: string[] = []; let descending = true; for (let i = 0; i < argv.length; i++) { const word = argv[i]!; if (word === '--') { rest.push(...argv.slice(i)); break; } if (word.startsWith('-') && word !== '-') { rest.push(word); // An option that takes a value, given as a separate word, takes the next one with it. const option = optionByWord(optionsOf(command, root), word); if (option?.type === 'string' && !word.includes('=') && !(word.startsWith('-') && !word.startsWith('--') && word.length > 2) && i + 1 < argv.length) rest.push(argv[++i]!); continue; } const sub = descending ? command.commands?.find((c) => c.name === word) : undefined; if (sub) { command = sub; path.push(word); } else { descending = false; rest.push(word); } } return { command, path, rest }; } export function optionByWord(options: Record, word: string): Option | undefined { if (word.startsWith('--')) return options[word.slice(2).split('=')[0]!]; return Object.values(options).find((o) => o.short === word[1]); } /** Run a line of words against the tree: parse, check, show help, or run. */ export async function execute(root: Command, argv: string[], io: IO): Promise { const { command, path, rest } = resolve(root, argv); try { const options = optionsOf(command, root); const descriptors: Record = {}; for (const [name, o] of Object.entries(options)) { const d: ParseArgsOptionDescriptor = { type: o.type }; if (o.short) d.short = o.short; if (o.multiple) d.multiple = true; descriptors[name] = d; } let parsed: { values: Record; positionals: string[] }; try { parsed = parseArgs({ args: rest, options: descriptors, allowPositionals: true, strict: true }); } catch (error) { throw new UsageError(plain((error as Error).message), path); } const { values, positionals } = parsed; if (values['help']) { io.out(help(root, path)); return 0; } if (!command.run) { if (positionals.length) throw new UsageError(`unknown command '${positionals[0]}'`, path); io.err(help(root, path)); return 2; } if (command.commands?.length && !command.positionals?.length && positionals.length) throw new UsageError(`unknown command '${positionals[0]}'`, path); checkPositionals(command, positionals, path); return await command.run({ path, values, positionals, io }); } catch (error) { if (!(error instanceof UsageError)) throw error; const name = ['golem', ...error.path].join(' '); io.err(`${name}: ${error.message}\nRun '${name} --help' for what it takes.\n`); return 2; } } function checkPositionals(command: Command, given: string[], path: string[]): void { const declared = command.positionals ?? []; const required = declared.filter((p) => !p.optional && !p.variadic).length; if (given.length < required) throw new UsageError(`missing <${declared[given.length]!.name}>`, path); if (!declared.some((p) => p.variadic) && given.length > declared.length) throw new UsageError(`unexpected argument '${given[declared.length]}'`, path); } /** parseArgs's messages name the fix for a script author; this is for a person at a shell. */ function plain(message: string): string { const unknown = /Unknown option '([^']+)'/.exec(message); if (unknown) return `unknown option '${unknown[1]}'`; const missing = /Option '([^']+)' argument missing/.exec(message); if (missing) return `${missing[1]!.replace(/^-., /, '').replace(/ $/, '')} needs a value`; return message.replace(/\. To specify .*$/s, ''); } // help /** The command a path of words names, or the root. */ export function find(root: Command, path: string[]): Command { let command = root; for (const word of path) command = command.commands?.find((c) => c.name === word) ?? command; return command; } export function help(root: Command, path: string[]): string { const command = find(root, path); const name = ['golem', ...path].join(' '); const subcommands = (command.commands ?? []).filter((c) => !c.hidden); const positionals = command.positionals ?? []; const own = command === root ? { ...root.options } : { ...command.options }; const out: string[] = []; out.push(`${name} — ${command.summary}`, ''); const shape = [name]; if (subcommands.length) shape.push(command.run ? '[]' : ''); if (Object.keys(own).length || subcommands.length || command.run) shape.push('[options]'); for (const p of positionals) shape.push(p.optional ? `[<${p.name}>${p.variadic ? '…' : ''}]` : `<${p.name}>${p.variadic ? '…' : ''}`); out.push(`usage: ${shape.join(' ')}`); if (command.description) out.push('', command.description.trim()); const tables: [string, [string, string][]][] = [ ['commands', subcommands.map((c) => [c.name, c.summary])], ['arguments', positionals.map((p) => [`<${p.name}>`, p.description])], ['options', Object.entries(own).map(([n, o]) => [flag(n, o), describe(o)])], ['global options', Object.entries(GLOBAL).map(([n, o]) => [flag(n, o), describe(o)])], ]; // One width for every table on the page, so they line up with each other. const width = Math.min(28, Math.max(...tables.flatMap(([, rows]) => rows.map(([left]) => left.length))) + 2); const table = (title: string, rows: [string, string][]) => { if (!rows.length) return; out.push('', `${title}:`); for (const [left, right] of rows) out.push(left.length >= width ? ` ${left}\n ${' '.repeat(width)}${right}` : ` ${left.padEnd(width)}${right}`); }; for (const [title, rows] of tables) table(title, rows); if (subcommands.length) out.push('', `Run '${name} --help' for more about a command.`); return `${out.join('\n')}\n`; } function flag(name: string, o: Option): string { return `${o.short ? `-${o.short}, ` : ''}--${name}${o.type === 'string' ? ` <${o.value ?? 'value'}>` : ''}`; } function describe(o: Option): string { return `${o.description}${o.default ? ` (default: ${o.default})` : ''}${o.multiple ? ' (can be repeated)' : ''}`; }