Something went wrong. Try again.
The `gh` equivalent for tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195import { 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 <string>`. */ placeholder?: string;}
export type Options = Record<string, OptionSpec>;
export type Values<O extends Options> = { [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<O extends Options = Options> { values: Values<O> & GlobalValues; positionals: string[]; /** The command path as typed: `tangled issue list`. */ path: string[];}
export interface GlobalValues { as?: string; help?: boolean;}
export interface Command<O extends Options = Options> { name: string; aliases?: string[]; summary: string; description?: string; /** The positional part of the usage line: `<repo> [<directory>]`. */ args?: string; options?: O; examples?: string[]; subcommands?: Command<any>[]; /** 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<O>): Promise<number | void> | number | void;}
/** Declare a command; only here to carry the option types into `run`. */export function command<O extends Options>(spec: Command<O>): Command<O> { 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<number> { 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<string, unknown>)[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(' ')} <command> [flags]`); lines.push('');
const visible = (command.subcommands ?? []).filter((sub) => !sub.hidden); if (visible.length) { const groups = new Map<string, Command[]>(); 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(' ')} <command> --help' for more information about a command.`, ''); } return `${lines.join('\n')}`;}