import type { Context } from './context.ts'; import type { SettingKey } from './config.ts'; // The shape every command is written in. // // One declarative tree is the single source of truth for everything a command // is: what it parses, the help it prints (as text or as JSON), and what Tab // completion offers — on the command line and in the interactive shell alike, // which run the very same specs. Nothing about a command's interface is // written down twice, so help and completion cannot drift from the parser. /** * What Tab can offer for a value: a remote path (`/bucket/key`), a local file * or directory, a profile, a remembered bucket, a setting, or static choices. */ export type Completer = 'remote' | 'remote-dir' | 'local' | 'local-dir' | 'profile' | 'bucket' | 'setting' | 'command' | readonly string[]; export interface OptionSpec { /** Long name, without the dashes: `content-type`. */ name: string; /** One letter: `-r`. */ short?: string; /** * `boolean` takes no value (and `--no-` turns it off), `string` and * `number` take one, `list` takes one and may be given again. */ type: 'boolean' | 'string' | 'number' | 'list'; description: string; /** What the value is called in help: `--limit `. */ value?: string; /** The only values accepted, which are also what Tab offers. */ choices?: readonly string[]; default?: string | number | boolean; complete?: Completer; /** Left out of help and completion: for options kept for compatibility. */ hidden?: boolean; /** Taken from this setting (`s3cli config`) when not given. */ setting?: SettingKey; } export interface ArgSpec { name: string; description: string; required?: boolean; /** Takes every remaining positional. Only the last argument may. */ variadic?: boolean; complete?: Completer; } export interface CommandSpec { name: string; aliases?: readonly string[]; /** One line, shown in lists of commands. */ summary: string; /** Paragraphs, shown in this command's own help. */ description?: string; /** Heading this command is listed under in its parent's help. */ group?: string; options?: readonly OptionSpec[]; args?: readonly ArgSpec[]; examples?: readonly string[]; subcommands?: readonly CommandSpec[]; /** Run this subcommand when a group is given no subcommand. */ default?: string; /** Not listed in help, nor offered by Tab. */ hidden?: boolean; /** * Where the command can be run: only on the command line (`cli`: setting * up, which asks its own questions), only in the interactive shell * (`shell`: cd, lcd and the like, which change the shell's state), or, by * default, in both. */ scope?: 'cli' | 'shell'; /** * What the command writes, set on every runnable command: `remote` when * it changes what is stored at the service, `local` when it only writes * this machine's files or state, `false` when it only reads. */ writes?: false | 'remote' | 'local'; run?: (ctx: Context) => Promise; } /** Options every command takes. */ export const GLOBAL_OPTIONS: readonly OptionSpec[] = [ { name: 'help', short: 'h', type: 'boolean', description: 'show help for the command and stop' }, { name: 'json', type: 'boolean', description: 'print the result as JSON (same as --format json)' }, { name: 'format', type: 'string', value: 'format', choices: ['text', 'json'], description: 'how to print the result (default: the format setting, else text)' }, { name: 'profile', short: 'P', type: 'string', value: 'name', description: "use this profile's keys for buckets that are not remembered (default: the default profile)", complete: 'profile' }, { name: 'color', type: 'boolean', description: 'colour human-readable output (default: when stdout is a terminal)' }, { name: 'verbose', short: 'v', type: 'boolean', description: 'log every request to stderr' }, ]; /** The names a command answers to. */ export function namesOf(command: CommandSpec): string[] { return [command.name, ...(command.aliases ?? [])]; } export function findSubcommand(command: CommandSpec, name: string): CommandSpec | undefined { return command.subcommands?.find((sub) => namesOf(sub).includes(name)); } /** The options a command accepts: its own, then the global ones. */ export function optionsOf(command: CommandSpec): OptionSpec[] { return [...(command.options ?? []), ...GLOBAL_OPTIONS]; } /** The command tree as one mode sees it: without the commands scoped to the other. */ export function forScope(command: CommandSpec, scope: 'cli' | 'shell'): CommandSpec { if (!command.subcommands) return command; return { ...command, subcommands: command.subcommands.filter((sub) => !sub.scope || sub.scope === scope).map((sub) => forScope(sub, scope)) }; } export const YES: OptionSpec = { name: 'yes', short: 'y', type: 'boolean', description: 'do it without asking first' }; export const RECURSIVE: OptionSpec = { name: 'recursive', short: 'r', type: 'boolean', description: 'everything beneath a folder, not just the folder itself' };