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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148import { GLOBAL_OPTIONS, namesOf, type ArgSpec, type CommandSpec, type OptionSpec } from './command.ts';import { bold, dim, cyan, visibleLength } from './output.ts';import { code, h, p as para, strong, text, type Block, type Doc } from './doc.ts';
// Help, from the command tree: `atcli --help` is the overview, `atcli post// --help` is everything about posting, and `--json` turns either into a// description a program can read — options, types, choices, arguments, and// the subcommands beneath — so an agent can discover the whole interface// without parsing prose.
function commandName(path: CommandSpec[]): string { return ['atcli', ...path.slice(1).map((command) => command.name)].join(' ');}
function optionFlag(spec: OptionSpec): string { const short = spec.short ? `-${spec.short}, ` : ' '; const value = spec.type === 'boolean' ? '' : ` <${spec.value ?? (spec.choices ? spec.choices.join('|') : spec.type === 'number' ? 'n' : 'value')}>`; return `${short}--${spec.name}${value}`;}
function optionText(spec: OptionSpec): string { const extra: string[] = []; if (spec.choices && spec.value) extra.push(`one of: ${spec.choices.join(', ')}`); if (spec.type === 'list') extra.push('repeatable'); if (spec.default !== undefined && spec.type !== 'boolean') extra.push(`default: ${spec.default}`); if (spec.setting) extra.push(`setting: ${spec.setting}`); return `${spec.description}${extra.length ? dim(` (${extra.join('; ')})`) : ''}`;}
function rows(entries: [string, string][], indent = 2): string[] { const width = Math.min(34, Math.max(...entries.map(([left]) => visibleLength(left))) + 2); return entries.map(([left, right]) => (visibleLength(left) + 2 > width ? `${' '.repeat(indent)}${left}\n${' '.repeat(indent + width)}${right}` : `${' '.repeat(indent)}${left}${' '.repeat(width - visibleLength(left))}${right}`));}
function argUsage(arg: ArgSpec): string { const name = `<${arg.name}>${arg.variadic ? '...' : ''}`; return arg.required ? name : `[${name}]`;}
export function usageLine(path: CommandSpec[]): string { const command = path[path.length - 1]!; const name = commandName(path); if (!command.run) return `${name} <command> [options]`; const args = (command.args ?? []).map(argUsage).join(' '); return `${name}${command.options?.length ? ' [options]' : ''}${args ? ` ${args}` : ''}`;}
export function helpText(path: CommandSpec[]): string { const command = path[path.length - 1]!; const root = path.length === 1; const lines: string[] = [];
lines.push(root ? `${bold('atcli')} — Bluesky and the atproto network, from the command line.` : `${bold(commandName(path))} — ${command.summary}`); if (command.description) lines.push('', ...command.description.trim().split('\n')); lines.push('', bold('Usage'), ` ${usageLine(path)}`); if (command.default) lines.push(` ${commandName(path)} ${dim(`(same as '${commandName(path)} ${command.default}')`)}`); const aliases = namesOf(command).slice(1); if (aliases.length) lines.push(` ${dim(`also: ${aliases.map((alias) => ['atcli', ...path.slice(1, -1).map((c) => c.name), alias].join(' ')).join(', ')}`)}`);
const subs = (command.subcommands ?? []).filter((sub) => !sub.hidden); if (subs.length) { const groups = new Map<string, CommandSpec[]>(); for (const sub of subs) { const group = sub.group ?? 'Commands'; groups.set(group, [...(groups.get(group) ?? []), sub]); } for (const [group, members] of groups) { lines.push('', bold(group)); lines.push(...rows(members.map((sub) => [cyan(sub.name), sub.summary]))); } }
if (command.args?.length && command.run) { lines.push('', bold('Arguments'), ...rows(command.args.map((arg) => [argUsage(arg), arg.description]))); }
const own = (command.options ?? []).filter((spec) => !spec.hidden); if (own.length && command.run) lines.push('', bold('Options'), ...rows(own.map((spec) => [optionFlag(spec), optionText(spec)]))); lines.push('', bold(root ? 'Options' : 'Global options'), ...rows(GLOBAL_OPTIONS.map((spec) => [optionFlag(spec), optionText(spec)])));
if (command.examples?.length) lines.push('', bold('Examples'), ...command.examples.map((example) => ` ${example.startsWith('#') ? dim(example) : `$ ${example}`}`));
if (subs.length) lines.push('', dim(`Add --help to any command for its details, e.g. '${commandName(path)} ${subs[0]!.name} --help'.`)); if (root) { lines.push(dim('Add --json to any command for machine-readable output (help included).')); lines.push(dim("Exit codes: 0 ok, 1 error, 2 usage, 3 not signed in, 4 not found, 5 rate limited, 6 needs input.")); } return `${lines.join('\n')}\n`;}
/** The command (and everything beneath it) as data. */export function helpJson(path: CommandSpec[], deep = true): unknown { const command = path[path.length - 1]!; const option = (spec: OptionSpec) => ({ name: spec.name, flag: `--${spec.name}`, ...(spec.short ? { short: `-${spec.short}` } : {}), type: spec.type, ...(spec.value ? { value: spec.value } : {}), ...(spec.choices ? { choices: spec.choices } : {}), ...(spec.default !== undefined ? { default: spec.default } : {}), ...(spec.setting ? { setting: spec.setting } : {}), description: spec.description, }); return { command: commandName(path), summary: command.summary, ...(command.description ? { description: command.description.trim() } : {}), ...(command.aliases?.length ? { aliases: command.aliases } : {}), usage: usageLine(path), ...(command.run ? { arguments: (command.args ?? []).map((arg) => ({ name: arg.name, required: Boolean(arg.required), variadic: Boolean(arg.variadic), description: arg.description })), options: (command.options ?? []).filter((spec) => !spec.hidden).map(option), } : {}), ...(path.length === 1 ? { globalOptions: GLOBAL_OPTIONS.map(option) } : {}), ...(command.default ? { default: command.default } : {}), ...(command.examples?.length ? { examples: command.examples } : {}), ...(command.subcommands?.length ? { subcommands: command.subcommands.filter((sub) => !sub.hidden).map((sub) => (deep ? helpJson([...path, sub], true) : { command: commandName([...path, sub]), summary: sub.summary })), } : {}), };}
/** Help as a document, for --markdown and --html. */export function helpDoc(path: CommandSpec[], title: string): Doc { const command = path[path.length - 1]!; const blocks: Block[] = [h(1, title), para(command.summary)]; if (command.description) blocks.push(...command.description.trim().split(/\n\s*\n/).map((part) => para(part.replace(/\s*\n\s*/g, ' ')))); blocks.push(h(2, 'Usage'), { t: 'code', text: usageLine(path) }); const subs = (command.subcommands ?? []).filter((sub) => !sub.hidden); if (subs.length) { blocks.push(h(2, 'Commands'), { t: 'table', head: ['Command', ''], rows: subs.map((sub) => [[code(`${title} ${sub.name}`)], [text(sub.summary)]]) }); } if (command.run && command.args?.length) { blocks.push(h(2, 'Arguments'), { t: 'table', head: ['Argument', ''], rows: command.args.map((arg) => [[code(argUsage(arg))], [text(arg.description)]]) }); } const options = command.run ? (command.options ?? []).filter((spec) => !spec.hidden) : []; const row = (spec: OptionSpec) => [[code(optionFlag(spec).trim())], [text(spec.description), ...(spec.choices ? [text(` (one of: ${spec.choices.join(', ')})`)] : []), ...(spec.type === 'list' ? [text(' (repeatable)')] : [])]]; if (options.length) blocks.push(h(2, 'Options'), { t: 'table', head: ['Option', ''], rows: options.map(row) }); blocks.push(h(2, path.length === 1 ? 'Options' : 'Global options'), { t: 'table', head: ['Option', ''], rows: GLOBAL_OPTIONS.map(row) }); if (command.examples?.length) blocks.push(h(2, 'Examples'), { t: 'code', lang: 'sh', text: command.examples.join('\n') }); if (path.length === 1) blocks.push(para(strong('Exit codes: '), '0 ok, 1 error, 2 usage, 3 not signed in, 4 not found, 5 rate limited, 6 needs input.')); return { title, blocks };}