Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227import { findSubcommand, optionsOf, type CommandSpec, type OptionSpec } from './command.ts';import { UsageError } from './errors.ts';import { configured, type SettingKey } from './config.ts';
// Turning argv into a command and its options.//// `node:util`'s parseArgs knows one flat set of options; a command tree needs// the set to change as the words name deeper commands, global options accepted// anywhere, and `--help` honoured wherever it appears, even on a line that// would otherwise not parse. So the walk is done here://// * Leading words name subcommands for as long as they match one.// * Options may come before, between or after positionals.// * `--name value`, `--name=value`, `-n value`, `-nvalue`, and bundled short// booleans (`-hv`) all work. `--no-<name>` turns a boolean off.// * `--` ends options: everything after it is positional, whatever it looks like.// * `--help`/`-h` anywhere before `--` asks for help on the deepest command// named, and nothing else on the line is checked.
export type OptionValues = Record<string, string | number | boolean | string[] | undefined>;
export interface Parsed { /** The commands named, outermost first. `path[0]` is the root. */ path: CommandSpec[]; command: CommandSpec; options: OptionValues; positionals: string[]; help: boolean;}
export function parse(root: CommandSpec, argv: readonly string[], settings: (key: SettingKey) => string | undefined = configured): Parsed { const path: CommandSpec[] = [root]; let command = root; const raw: Array<{ spec: OptionSpec; value: string | boolean; word: string }> = []; const positionals: string[] = []; const unknown: string[] = []; let help = false; let ended = false; /** Where in argv the deepest command was named, so a default subcommand can be put after it. */ let named = -1;
const lookup = (name: string) => optionsOf(command).find((spec) => spec.name === name); const lookupShort = (letter: string) => optionsOf(command).find((spec) => spec.short === letter);
for (let i = 0; i < argv.length; i++) { const word = argv[i]!; if (ended || word === '-' || !word.startsWith('-')) { // A word names a subcommand only while no positional has been taken. const sub = !ended && positionals.length === 0 ? findSubcommand(command, word) : undefined; if (sub) { command = sub; path.push(sub); named = i; } else if (!ended && positionals.length === 0 && !command.run && command.default && findSubcommand(command, command.default)) { // A group's default subcommand takes the arguments given to // the group: `profile bsky.app` is `profile show bsky.app`. command = findSubcommand(command, command.default)!; path.push(command); positionals.push(word); } else { positionals.push(word); } continue; } if (word === '--') { ended = true; continue; }
if (word.startsWith('--')) { const eq = word.indexOf('='); const name = eq === -1 ? word.slice(2) : word.slice(2, eq); const inline = eq === -1 ? undefined : word.slice(eq + 1); let spec = lookup(name); let negated = false; if (!spec && name.startsWith('no-')) { const positive = lookup(name.slice(3)); if (positive?.type === 'boolean') { spec = positive; negated = true; } } if (!spec) { unknown.push(word); continue; } if (spec.type === 'boolean') { if (inline !== undefined) { const value = parseBoolean(inline); if (value === undefined) throw new UsageError(`--${name} takes no value (or true/false), not '${inline}'`, path); raw.push({ spec, value: negated ? !value : value, word }); } else { raw.push({ spec, value: !negated, word }); } if (spec.name === 'help' && !negated) help = true; continue; } if (negated) throw new UsageError(`--${name} is not a switch that can be turned off`, path); let value = inline; if (value === undefined) { if (i + 1 >= argv.length) { if (help) continue; // Help is checked for later on the line too, so look before failing. if (argv.slice(i + 1).some(isHelp)) { help = true; continue; } throw new UsageError(`--${name} needs a value${spec.value ? ` (${spec.value})` : ''}`, path); } // `--reply --help` is a request for help, not a reply to "--help". if (isHelp(argv[i + 1]!)) { help = true; i++; continue; } value = argv[++i]!; } raw.push({ spec, value, word }); continue; }
// Short options: `-n 5`, `-n5`, `-hv`. for (let j = 1; j < word.length; j++) { const letter = word[j]!; const spec = lookupShort(letter); if (!spec) { unknown.push(`-${letter}`); break; } if (spec.type === 'boolean') { raw.push({ spec, value: true, word }); if (spec.name === 'help') help = true; continue; } let value = word.slice(j + 1); if (!value) { if (i + 1 >= argv.length) { if (help) break; throw new UsageError(`-${letter} (--${spec.name}) needs a value${spec.value ? ` (${spec.value})` : ''}`, path); } if (isHelp(argv[i + 1]!)) { help = true; i++; break; } value = argv[++i]!; } raw.push({ spec, value, word }); break; } }
if (help) return { path, command, options: {}, positionals, help };
// A group given no subcommand runs its default, if it has one — with the // options meant for it. if (!command.run && command.default && positionals.length === 0 && findSubcommand(command, command.default)) { return parse(root, [...argv.slice(0, named + 1), command.default, ...argv.slice(named + 1)], settings); }
if (unknown.length) { throw new UsageError(`${command === root ? '' : `'${path.slice(1).map((c) => c.name).join(' ')}' `}does not take ${unknown.join(', ')}`, path); }
const options: OptionValues = {}; for (const { spec, value } of raw) { options[spec.name] = coerce(spec, value, options[spec.name], path); } for (const spec of optionsOf(command)) { if (options[spec.name] !== undefined) continue; const fromSetting = spec.setting ? settings(spec.setting) : undefined; if (fromSetting !== undefined && fromSetting !== '') { options[spec.name] = coerce(spec, spec.type === 'boolean' ? parseBoolean(fromSetting) ?? true : fromSetting, undefined, path); } else if (spec.default !== undefined) { options[spec.name] = spec.type === 'list' ? [String(spec.default)] : spec.default; } }
checkArgs(command, positionals, path); return { path, command, options, positionals, help: false };}
function isHelp(word: string): boolean { return word === '--help' || word === '-h';}
function parseBoolean(text: string): boolean | undefined { if (/^(true|1|yes|on)$/i.test(text)) return true; if (/^(false|0|no|off)$/i.test(text)) return false; return undefined;}
function coerce(spec: OptionSpec, value: string | boolean, previous: OptionValues[string], path: CommandSpec[]): OptionValues[string] { const flag = `--${spec.name}`; if (spec.type === 'boolean') return Boolean(value); const text = String(value); if (spec.choices && !spec.choices.includes(text)) { throw new UsageError(`${flag} must be one of ${spec.choices.join(', ')}, not '${text}'`, path); } if (spec.type === 'number') { const number = Number(text); if (!Number.isFinite(number) || text.trim() === '') throw new UsageError(`${flag} takes a number, not '${text}'`, path); return number; } if (spec.type === 'list') return [...(Array.isArray(previous) ? previous : []), text]; return text;}
function checkArgs(command: CommandSpec, positionals: string[], path: CommandSpec[]): void { const name = path.slice(1).map((c) => c.name).join(' '); if (!command.run) { if (positionals.length) { throw new UsageError(`${name ? `'${name}' has no subcommand` : 'there is no command'} named '${positionals[0]}'`, path); } return; } const specs = command.args ?? []; const required = specs.filter((arg) => arg.required).length; const variadic = specs.some((arg) => arg.variadic); if (positionals.length < required) { const missing = specs.filter((arg) => arg.required).slice(positionals.length).map((arg) => `<${arg.name}>`); throw new UsageError(`'${name}' needs ${missing.join(' ')}`, path); } if (!variadic && positionals.length > specs.length) { throw new UsageError(`'${name}' takes ${specs.length ? `at most ${specs.length} argument${specs.length === 1 ? '' : 's'}` : 'no arguments'}, but was given '${positionals.slice(specs.length).join(' ')}'`, path); }}
/** The positionals, keyed by argument name; a variadic argument gets the rest as an array. */export function bindArgs(command: CommandSpec, positionals: string[]): Record<string, string | string[] | undefined> { const bound: Record<string, string | string[] | undefined> = {}; const specs = command.args ?? []; specs.forEach((spec, index) => { bound[spec.name] = spec.variadic ? positionals.slice(index) : positionals[index]; }); return bound;}