import { parseArgs } from 'node:util'; import { bold, dim } from './output.ts'; // The command tree. A command either runs or holds subcommands; help for every // level is generated from the same description that parses its arguments, so // the two cannot drift apart. export interface OptionSpec { type: 'string' | 'boolean'; short?: string; multiple?: boolean; description: string; /** The value's name in help: `--title `. */ placeholder?: string; } export type Options = Record; export type Values = { [K in keyof O]?: O[K]['type'] extends 'boolean' ? (O[K]['multiple'] extends true ? boolean[] : boolean) : (O[K]['multiple'] extends true ? string[] : string); }; export interface Invocation { values: Values & GlobalValues; positionals: string[]; /** The command path as typed: `tangled issue list`. */ path: string[]; } export interface GlobalValues { as?: string; help?: boolean; } export interface Command { name: string; aliases?: string[]; summary: string; description?: string; /** The positional part of the usage line: ` []`. */ args?: string; options?: O; examples?: string[]; subcommands?: Command[]; /** Which help section a subcommand is listed under. */ group?: string; hidden?: boolean; /** * What the command writes, set on every runnable command: `network` when * it changes state on the network (a repository, a knot, what other people * can see), `local` when it only writes this machine's state (sessions, * config, git config, the shell's startup file), `false` when it only * reads. The reference generator refuses to run while any runnable command * has none, so the classification cannot silently fall behind the commands. */ writes?: false | 'network' | 'local'; /** Pass everything after the command through unparsed (for `api`, aliases). */ raw?: boolean; run?(invocation: Invocation): Promise | number | void; } /** Declare a command; only here to carry the option types into `run`. */ export function command(spec: Command): Command { return spec; } /** An error the user made: printed without a stack, with a hint at help. */ export class UsageError extends Error { constructor(message: string) { super(message); this.name = 'UsageError'; } } export const GLOBAL_OPTIONS: Options = { as: { type: 'string', description: 'Act as this signed-in account (handle or DID)', placeholder: 'account' }, help: { type: 'boolean', short: 'h', description: 'Show help for a command' }, }; /** Find the command `argv` names, and what is left of `argv` after it. */ export function resolve(root: Command, argv: string[]): { command: Command; path: string[]; rest: string[] } { let current = root; const path = [root.name]; let index = 0; for (; index < argv.length; index++) { const word = argv[index]!; if (word.startsWith('-') || !current.subcommands) break; const next = current.subcommands.find((sub) => sub.name === word || sub.aliases?.includes(word)); if (!next) { if (current.run) break; throw new UsageError(`unknown command "${word}" for "${path.join(' ')}"`); } current = next; path.push(next.name); } return { command: current, path, rest: argv.slice(index) }; } export async function execute(root: Command, argv: string[]): Promise { const { command, path, rest } = resolve(root, argv); if (command.raw && command.run) { return (await command.run({ values: {}, positionals: rest, path })) ?? 0; } const options = { ...GLOBAL_OPTIONS, ...command.options }; let parsed; try { parsed = parseArgs({ args: rest, options: Object.fromEntries(Object.entries(options).map(([name, spec]) => [name, { type: spec.type, ...(spec.short ? { short: spec.short } : {}), ...(spec.multiple ? { multiple: true } : {}), }])), allowPositionals: true, allowNegative: true, strict: true, }); } catch (error) { throw new UsageError(`${(error as Error).message.replace(/\. To specify.*$/s, '')}\n\nRun '${path.join(' ')} --help' for usage.`); } // With allowNegative, parseArgs reads an option named `no-x` (and its // short form) as `x: false`; give it back under the name it was declared with. for (const name of Object.keys(options)) { const positive = name.startsWith('no-') ? name.slice(3) : undefined; if (positive && !(positive in options) && parsed.values[positive] === false) { delete parsed.values[positive]; (parsed.values as Record)[name] = true; } } if (parsed.values['help'] || !command.run) { if (!command.run && parsed.positionals.length) { throw new UsageError(`unknown command "${parsed.positionals[0]}" for "${path.join(' ')}"`); } process.stdout.write(help(command, path)); return 0; } return (await command.run({ values: parsed.values as Invocation['values'], positionals: parsed.positionals, path })) ?? 0; } // --------------------------------------------------------------------- help --- export function help(command: Command, path: string[]): string { const lines: string[] = []; lines.push(command.description ?? command.summary, ''); lines.push(bold('USAGE')); if (command.run) { const flags = Object.keys(command.options ?? {}).length ? ' [flags]' : ''; lines.push(` ${path.join(' ')}${command.args ? ` ${command.args}` : ''}${flags}`); } if (command.subcommands?.length) lines.push(` ${path.join(' ')} [flags]`); lines.push(''); const visible = (command.subcommands ?? []).filter((sub) => !sub.hidden); if (visible.length) { const groups = new Map(); for (const sub of visible) { const group = sub.group ?? (path.length === 1 ? 'COMMANDS' : 'AVAILABLE COMMANDS'); groups.set(group, [...(groups.get(group) ?? []), sub]); } const width = Math.max(...visible.map((sub) => sub.name.length)) + 2; for (const [group, subs] of groups) { lines.push(bold(group)); for (const sub of subs) { const aliases = sub.aliases?.length ? dim(` (${sub.aliases.join(', ')})`) : ''; lines.push(` ${`${sub.name}:`.padEnd(width)} ${sub.summary}${aliases}`); } lines.push(''); } } const optionLines = (options: Options) => { const rows = Object.entries(options).map(([name, spec]) => { const short = spec.short ? `-${spec.short}, ` : ' '; const value = spec.type === 'string' ? ` <${spec.placeholder ?? 'string'}>` : ''; return [`${short}--${name}${value}`, `${spec.description}${spec.multiple ? ' (repeatable)' : ''}`] as const; }); const width = Math.max(...rows.map(([flag]) => flag.length)) + 2; return rows.map(([flag, text]) => ` ${flag.padEnd(width)}${text}`); }; if (command.run && command.options && Object.keys(command.options).length) { lines.push(bold('FLAGS'), ...optionLines(command.options), ''); } lines.push(bold('INHERITED FLAGS'), ...optionLines(GLOBAL_OPTIONS), ''); if (command.examples?.length) { lines.push(bold('EXAMPLES'), ...command.examples.map((example) => ` $ ${example}`), ''); } if (command.subcommands?.length) { lines.push(bold('LEARN MORE'), ` Use '${path.join(' ')} --help' for more information about a command.`, ''); } return `${lines.join('\n')}`; }