@kaynooo/cli-kit 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,10 +38,13 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
38
38
  - `waitForCi({ tool, short, giveUpMs, noneMs, stalledMs, covering, finished, key, announce, links, settle, stall, lines })` is the wait loop of `ft ci` and `gl ci`: it polls `covering` until what it returns is `finished`, retries a poll that failed for a network reason, fails at once on a refusal (`Exit`) or a permanent failure, and gives up on a timeout, on nothing covering the commit for `noneMs`, or on a `stall` that is certain or lasts `stalledMs`.
39
39
  - `parse(args, usage, options)` wraps `node:util` `parseArgs` with a generated `--help` (`usage` given with or without its `usage:`), which prints the help and ends the command with exit 0 (it throws `HelpShown`, which `runCli` and `runMain` take), so the command needs no check after it. A string option takes a next argument starting with a dash as its value (`--from "- item"`, `-n -3`) unless that argument names one of the command's options, which parseArgs alone refuses as ambiguous. `{ positionals: ['<key>', '[file]'] }` names the positionals the command takes: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more; one missing (`missing <key>`) or one too many (`unexpected argument x`) is a usage error, and `positionals` comes back typed as `[string, string?]`. `fail(message, code = 2, hint?)` ends a command, the hint printed under the error; `badUsage(message)` ends it with a `UsageError`, which `parse`, `wholeNumber` and `duration` throw too and which gets the hint `run <cli> <command> --help for its options`; `wholeNumber` reads a numeric option. A string option may take `choices: ['patch', 'minor', 'major']`: a value outside them is a usage error naming the allowed ones, `--help` shows them as the option's value (`--bump <patch|minor|major>`) and `__complete` offers them after the flag; `oneOf(value, '<bump>', choices)` checks a positional against a set the same way and returns it typed as their union, as `parse` types the option's value (`'patch' | 'minor' | 'major' | undefined`, an array of them with `multiple`).
40
40
  - `table`, `truncate`, `formatSeconds`, `formatAge` and `upTo` format output.
41
+ - `mainArgs({ name, version }, argv => parse(argv, USAGE, OPTIONS))` is `runMain` for a module running at its top level: the parsed values come back, and the process ends itself on `--help`, `--version`, `__complete` or a usage error with the exit code `runMain` gives.
41
42
  - `runMain({ name, version, run })` runs a CLI that is a single command: `--version` or `-v` alone prints the version, and an `Exit` or any error prints `error: <name>: <message>` and becomes the exit code. `await` it into `process.exitCode`.
42
- - `runCli` also takes `defaultCommand`, the command that gets the arguments when the first one names no command (`db "select 1"` runs `db query "select 1"`), its options listed in `<cli> --help` under the commands, and a command may be a group, `{ summary, commands, aliases? }`, dispatched with its own help and typo hints (`ft plan add`). A command taking `hidden: true` runs as usual but is left out of `--help`, `KIT_HELP=json`, `__complete` and the typo hints, for one only a script or a shell binding calls.
43
+ - `runCli` also takes `defaultCommand`, the command that gets the arguments when the first one names no command (`db "select 1"` runs `db query "select 1"`, and an error there reads `error: db query: ...`), its options listed in `<cli> --help` under the commands, and `runBare: true` to have a bare `<cli>` run it instead of printing the help, and a command may be a group, `{ summary, commands, aliases? }`, dispatched with its own help and typo hints (`ft plan add`). A command taking `hidden: true` runs as usual but is left out of `--help`, `KIT_HELP=json`, `__complete` and the typo hints, for one only a script or a shell binding calls.
43
44
  - Commands known only once something is read (a project's config adding some) are lazy: `extraCommands: async (words) => ({ ... })` on the CLI or on a group adds them to its `commands` (which win a shared name), and `commands` itself may be such a function when every command is lazy. The function gets the words typed so far from the CLI's name on (`['team', 'hello', 'b']`) and is called only when the first word names no command the level already has, or for `--help`, `help` and `__complete`, never for a plain run of one it has, so existing `Cli` literals are unchanged. A `summary` may be an async function too, awaited only where a help, the `KIT_HELP=json` description or `__complete` lists it, never for a run. An error the function throws is not caught: handle it inside.
44
- - `<bin> __complete <words>... <word>` is answered by `runCli` and `runMain` for shell completion: the subcommands with their summaries, a leaf command's options (read by running it until its `parse` call throws them, so every command parses its arguments first), or `__files__` where a path fits.
45
+ - `<bin> __complete <words>... <word>` is answered by `runCli` and `runMain` for shell completion: the subcommands with their summaries, a leaf command's options (read by running it until its `parse` call throws them, so every command parses its arguments first), or `__files__` where a path fits. `parse`'s `candidates: { '<project>': async () => ['alpha', ['beta', 'its description']] }` (by the positional's name as `positionals` writes it) gives the values of a positional, and `candidates: () => [...]` on a string option those of its value, besides `choices`; the function runs only while completing, and a command that gives none still answers `__files__`.
46
+ - `parse` options also take `optional: { bare: '1', accepts: /^\d+$/ }` on a string option (`--out [n]`: bare it reads `bare`, a next word is its value unless it starts with a dash or fails `accepts`, and then stays a positional), `conflicts: ['pdf']` (both given is a usage error naming them) and, with `allowNegative`, `negative: 'keep the cached copy'` on a boolean option, which the help and `__complete` then list as `--no-<name>` with that description, the positive flag only when it has a `description` of its own.
47
+ - `parse(args, usage, options, { positionals, trailing: true })` hands everything after the last declared positional back as `rest`, dashes included and without a `--` (`athome compose <project> exec -T php bash`); options typed before it parse as usual.
45
48
  - `parse(args, usage, options, { rest: true })` stops at the first `--` and returns what follows untouched as `rest` (`out -n 200 -- <cmd>`), `undefined` without a `--`; `{ allowNegative: true }` takes `--no-<flag>` as `false` for a boolean flag.
46
49
  - `readInput(path, { noStdin })` reads a file, or stdin when the path is `-`; a missing file or a stdin left on the terminal ends the command with a message.
47
50
  - `isStdinPiped()` is true when stdin is a pipe or a file, false on a terminal or `/dev/null`, so a command can read what is piped in without waiting on a terminal.
@@ -62,8 +65,8 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
62
65
  - `capture(fn)` from `@kaynooo/cli-kit/test` runs a command in-process and returns `{ result, out, err }`, what it printed through `console` and `process.stdout`/`stderr`, so a test needs no spy. `tempDirs()`, called at the top of a test file, answers a `temp(prefix?)` making fresh folders under the system temp folder, all removed once the file's tests are done.
63
66
  - `findUp(names, from?)` is the nearest of `names` in the current folder (or `from`) or one above it; `loadConfig(names, { from?, path? })` imports the nearest config (`.ts`, `.js` or `.json`), or the file `path` names, and returns `{ path, dir, config }` with its default export for the caller to check, `undefined` when there is none. A named file missing, one failing to import or one without a default export ends the command with exit 1.
64
67
  - `@kaynooo/cli-kit/prompt` asks on the terminal, on stderr:
65
- - `select(question, choices, { initial })` picks one `{ value, label, hint }`: typing filters the choices (every word typed in the label or hint, case ignored, Backspace editing), the arrows move and Enter picks.
66
- - `checklist(question, choices, { initial, toggle })` checks several the same way, Space toggling the choice under the cursor; `toggle(checked, value)` returns what is checked after it, for choices that pull others along, and flips the one choice by default.
68
+ - `select(question, choices, { initial, default })` picks one `{ value, label, hint }`, the cursor starting on the index `initial` or on the choice holding the value `default`: typing filters the choices (every word typed in the label or hint, case ignored, Backspace editing), the arrows move and Enter picks.
69
+ - `checklist(question, choices, { initial, min, toggle })` checks several the same way (Enter on fewer than `min` shows `pick at least N` under the list instead of answering), Space toggling the choice under the cursor; `toggle(checked, value)` returns what is checked after it, for choices that pull others along, and flips the one choice by default.
67
70
  - `confirm(question, { default })` answers y or n, Enter taking the default; `input(question, { default, mask, validate })` takes a line, `mask` drawing it as `*`, `validate(value)` returning a message shown under it until the answer passes.
68
71
  - `editor(question, { default, extension })` hands a temp file to `VISUAL` or `EDITOR` (`vi` without either), run through the shell so `code --wait` works, and answers the text it was left with; an editor exiting nonzero (`:cq`) cancels.
69
72
  - Each answers `undefined` once Escape or Ctrl+C cancels and leaves `✓ <question> <answer>` or `✗ <question> cancelled`; a list longer than the terminal (10 rows at most) scrolls with the cursor, a dimmed `… n more` above and below.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
3
  "type": "module",
4
- "version": "0.16.0",
4
+ "version": "0.18.0",
5
5
  "description": "What every @kaynooo CLI shares: argument parsing with --help, a command dispatcher with typo hints and shell completion, and output formatting.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
package/src/args.ts CHANGED
@@ -6,8 +6,21 @@ import { upTo } from './format'
6
6
  import { paint } from './style'
7
7
  import { closest } from './text'
8
8
 
9
- // A string option taking `choices` accepts only those values: another is a usage error naming them, the help shows them as the value and `__complete` offers them.
10
- export type Options = Record<string, ParseArgsOptionDescriptor & { description?: string, choices?: readonly string[] }>
9
+ interface OptionExtras {
10
+ description?: string
11
+ // A string option taking `choices` accepts only those values: another is a usage error naming them, the help shows them as the value and `__complete` offers them.
12
+ choices?: readonly string[]
13
+ // The values a string option can take, from a function, offered by `__complete` when `choices` does not give them.
14
+ candidates?: Candidates
15
+ // A string option whose value may be left out (`--out [n]`): bare, it reads `bare`. A next word is its value unless it starts with a dash or, given `accepts`, does not match it, and then stays a positional.
16
+ optional?: { bare: string, accepts?: RegExp }
17
+ // Options it cannot be given with, a usage error naming both when they are.
18
+ conflicts?: readonly string[]
19
+ // With `allowNegative`, a boolean option's `--no-<name>` as the help and `__complete` list it, with this description; the positive flag is then left out of both unless it has a `description` of its own, for a flag that defaults on.
20
+ negative?: string
21
+ }
22
+
23
+ export type Options = Record<string, ParseArgsOptionDescriptor & OptionExtras>
11
24
 
12
25
  // The `--json` flag every CLI words alike: `print JSON`, then what it holds when given.
13
26
  export function jsonOption(holds?: string) {
@@ -45,9 +58,15 @@ export class HelpShown extends Exit {
45
58
 
46
59
  // Thrown by parse while a bin answers `__complete`, so the command reached hands over its options before doing anything else.
47
60
  export class Completing {
48
- constructor(readonly options: Options) {}
61
+ constructor(readonly options: Options, readonly positionals: readonly string[] = [], readonly candidates: Record<string, Candidates | undefined> = {}) {}
49
62
  }
50
63
 
64
+ // A value `__complete` offers, with an optional description shown beside it.
65
+ export type Candidate = string | [value: string, description: string]
66
+
67
+ // The values a positional or an option can take, read only while a shell completes it.
68
+ export type Candidates = () => Candidate[] | Promise<Candidate[]>
69
+
51
70
  export const completion = { active: false }
52
71
 
53
72
  // Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
@@ -110,7 +129,8 @@ export function exampleRows(examples: Example[], colors = paint(process.stdout))
110
129
  export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
111
130
  const rows = Object.entries(options).map(([name, option]) => {
112
131
  const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
113
- const placeholder = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
132
+ const named = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
133
+ const placeholder = option.optional === undefined ? named : `[${named}]`
114
134
  const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
115
135
 
116
136
  return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
@@ -137,10 +157,14 @@ type Chosen<T extends Options, Values> = {
137
157
  export interface ParseSettings<Names extends readonly string[] = string[]> {
138
158
  // Stops at the first `--` and hands what follows back untouched as `rest`, for a command that runs another (`out -n 200 -- <cmd>`).
139
159
  rest?: boolean
160
+ // With `positionals`, passes everything after the last declared one back untouched as `rest`, dashes included and without a `--`: `athome compose <project> exec -T php bash`. Options typed before it parse as usual, and it replaces `rest`.
161
+ trailing?: boolean
140
162
  // Takes `--no-<flag>` as `false` for a boolean flag, as parseArgs' own `allowNegative` does.
141
163
  allowNegative?: boolean
142
164
  // The positionals the command takes, by name as its usage writes them: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more. One missing or one too many is a usage error, and the positionals come back typed by these names.
143
165
  positionals?: Names
166
+ // The values `__complete` offers for a positional, by its name as `positionals` writes it: `{ '<project>': async () => names }`. A positional without one completes as a path.
167
+ candidates?: Partial<Record<Names[number], Candidates>>
144
168
  // Command lines shown under `Examples:` in the help, each with an optional dimmed note: `['ft ci HEAD~1', 'wait for the commit before the last']`.
145
169
  examples?: Example[]
146
170
  // What the command is reached for instead of, shown as `Instead of:` in the help and listed by `cli list`: 'sed -n N,Mp, grep -n chains and whole-file reads'.
@@ -185,17 +209,17 @@ export function parse<const T extends Options, const Names extends readonly stri
185
209
  const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options))) as Omit<typeof OUTPUT_OPTIONS, keyof T>
186
210
  const all = { ...options, ...output, help: { type: 'boolean', short: 'h', description: 'show this help' } } as const
187
211
 
212
+ const listing = listed(all, settings.allowNegative === true)
213
+
188
214
  if (completion.active)
189
- throw new Completing(all)
215
+ throw new Completing(listing, settings.positionals, settings.candidates)
190
216
 
191
- const separator = settings.rest === true ? args.indexOf('--') : -1
192
- const own = separator === -1 ? args : args.slice(0, separator)
193
- const rest = separator === -1 ? undefined : args.slice(separator + 1)
217
+ const { own, rest } = settings.trailing === true ? splitTrailing(args, all, settings.positionals?.length ?? 0) : splitRest(args, settings.rest === true)
194
218
 
195
219
  let parsed
196
220
 
197
221
  try {
198
- parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
222
+ parsed = parseArgs({ args: joinDashValues(withBare(own, all), all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
199
223
  }
200
224
  catch (error) {
201
225
  const message = parseError(error)
@@ -208,12 +232,13 @@ export function parse<const T extends Options, const Names extends readonly stri
208
232
  applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
209
233
 
210
234
  if (values.help === true) {
211
- console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings))
235
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, listing, undefined, settings))
212
236
 
213
237
  throw new HelpShown()
214
238
  }
215
239
 
216
240
  checkChoices(parsed.values, all)
241
+ checkConflicts(parsed.values, all)
217
242
 
218
243
  if (settings.positionals !== undefined)
219
244
  checkPositionals(parsed.positionals, settings.positionals)
@@ -221,6 +246,87 @@ export function parse<const T extends Options, const Names extends readonly stri
221
246
  return { ...parsed, values: parsed.values as Chosen<T, typeof parsed.values>, positionals: parsed.positionals as Slots<Names>, rest }
222
247
  }
223
248
 
249
+ // The arguments before a `--` and what follows it, when the command passes that through.
250
+ function splitRest(args: string[], passes: boolean): { own: string[], rest: string[] | undefined } {
251
+ const separator = passes ? args.indexOf('--') : -1
252
+
253
+ return { own: separator === -1 ? args : args.slice(0, separator), rest: separator === -1 ? undefined : args.slice(separator + 1) }
254
+ }
255
+
256
+ // The arguments up to the last of `count` positionals, options and their values apart, and everything after it.
257
+ function splitTrailing(args: string[], options: Options, count: number): { own: string[], rest: string[] | undefined } {
258
+ const takesValue = (word: string) => !word.includes('=') && Object.entries(options).some(([name, { short, type }]) => type === 'string' && (word === `--${name}` || word === `-${short}`))
259
+ let seen = 0
260
+
261
+ if (count === 0)
262
+ return { own: args, rest: undefined }
263
+
264
+ for (let at = 0; at < args.length; at++) {
265
+ const word = args[at]!
266
+
267
+ if (word === '--')
268
+ break
269
+
270
+ if (word.startsWith('-')) {
271
+ if (takesValue(word))
272
+ at++
273
+
274
+ continue
275
+ }
276
+
277
+ seen++
278
+
279
+ if (seen === count)
280
+ return { own: args.slice(0, at + 1), rest: args.slice(at + 1) }
281
+ }
282
+
283
+ return { own: args, rest: undefined }
284
+ }
285
+
286
+ // The options as the help and `__complete` list them: with `allowNegative`, `--no-<name>` for an option with a `negative` description, in place of the positive flag when that has none of its own.
287
+ function listed(options: Options, allowNegative: boolean): Options {
288
+ if (!allowNegative)
289
+ return options
290
+
291
+ return Object.fromEntries(Object.entries(options).flatMap(([name, option]): [string, Options[string]][] => {
292
+ if (option.type !== 'boolean' || option.negative === undefined)
293
+ return [[name, option]]
294
+
295
+ return [...option.description === undefined ? [] : [[name, option] as [string, Options[string]]], [`no-${name}`, { type: 'boolean', description: option.negative }]]
296
+ }))
297
+ }
298
+
299
+ // The arguments with an optional-value option typed bare given its fallback, ahead of the next argument: `--out` becomes `--out 1` unless a value follows it.
300
+ function withBare(args: string[], options: Options): string[] {
301
+ const bare = new Map<string, NonNullable<Options[string]['optional']>>(Object.entries(options).flatMap(([name, { short, optional }]) => optional === undefined ? [] : [[`--${name}`, optional] as const, ...short === undefined ? [] : [[`-${short}`, optional] as const]]))
302
+
303
+ if (bare.size === 0)
304
+ return args
305
+
306
+ const separator = args.indexOf('--')
307
+ const own = separator === -1 ? args : args.slice(0, separator)
308
+
309
+ return [...own.flatMap((arg, index) => {
310
+ const optional = bare.get(arg)
311
+ const next = own[index + 1]
312
+ const value = next !== undefined && (optional?.accepts === undefined ? !next.startsWith('-') : optional.accepts.test(next))
313
+
314
+ return optional === undefined || value ? [arg] : [arg, optional.bare]
315
+ }), ...separator === -1 ? [] : args.slice(separator)]
316
+ }
317
+
318
+ // A usage error for two options given together that one of them declares in conflict with the other.
319
+ function checkConflicts(values: Record<string, unknown>, options: Options): void {
320
+ const given = (name: string) => values[name] !== undefined && values[name] !== false
321
+
322
+ for (const [name, { conflicts = [] }] of Object.entries(options)) {
323
+ const other = given(name) ? conflicts.find(given) : undefined
324
+
325
+ if (other !== undefined)
326
+ badUsage(`--${name} cannot be used with --${other}`)
327
+ }
328
+ }
329
+
224
330
  // A usage error for an option given a value outside its `choices`, in parseArgs' own order.
225
331
  function checkChoices(values: Record<string, unknown>, options: Options): void {
226
332
  for (const [name, option] of Object.entries(options)) {
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Example, Options } from './args'
1
+ import type { Candidate, Example, Options } from './args'
2
2
  import type { Colors } from './style'
3
3
  import process from 'node:process'
4
4
  import { applyOutput, Completing, completion, description, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, replacesRows, UsageError, usageLine, wantsHelpJson } from './args'
@@ -38,6 +38,8 @@ export interface Cli {
38
38
  aliases?: Record<string, string>
39
39
  // The command that takes the arguments when the first one names no command, so `db "select 1"` runs `db query "select 1"`; `--help` lists its options under the commands.
40
40
  defaultCommand?: string
41
+ // With `defaultCommand`, a bare `<cli>` runs it instead of printing the help (`account` lists, `mux` attaches).
42
+ runBare?: boolean
41
43
  // Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
42
44
  redact?: (text: string) => string
43
45
  // Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
@@ -108,6 +110,8 @@ interface Level extends Scope {
108
110
  header: (colors: Colors, summary: string) => string
109
111
  summary: Summary
110
112
  defaultCommand: string | undefined
113
+ // Whether a bare run of the level runs its default command instead of printing the help.
114
+ runBare: boolean
111
115
  examples: Example[] | undefined
112
116
  replaces: string | undefined
113
117
  }
@@ -229,6 +233,16 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
229
233
  }
230
234
 
231
235
  const [name, ...args] = argv
236
+ const fallback = level.defaultCommand === undefined ? undefined : level.commands[level.defaultCommand]
237
+
238
+ if (name === undefined && level.runBare && fallback !== undefined && 'run' in fallback) {
239
+ try {
240
+ return await fallback.run([])
241
+ }
242
+ catch (error) {
243
+ return reportThrown(`${prefix} ${level.defaultCommand}`, error, redact, false)
244
+ }
245
+ }
232
246
 
233
247
  if (name === undefined && level.path.length > 1) {
234
248
  console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stderr))
@@ -250,6 +264,7 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
250
264
 
251
265
  let command = level.commands[level.aliases?.[name] ?? name]
252
266
  let rest = args
267
+ let ran = name
253
268
 
254
269
  if (command === undefined && level.lazy.length > 0) {
255
270
  level = await expand(level, [...level.path.slice(1), ...argv])
@@ -259,6 +274,7 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
259
274
  if (command === undefined && level.defaultCommand !== undefined) {
260
275
  command = level.commands[level.defaultCommand]
261
276
  rest = argv
277
+ ran = level.defaultCommand
262
278
  }
263
279
 
264
280
  if (command === undefined) {
@@ -272,14 +288,14 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
272
288
  if ('commands' in command) {
273
289
  const { summary } = command
274
290
 
275
- return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact })
291
+ return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, runBare: false, examples: command.examples, replaces: undefined }, rest, { version, redact })
276
292
  }
277
293
 
278
294
  try {
279
295
  return await command.run(rest)
280
296
  }
281
297
  catch (error) {
282
- return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest))
298
+ return reportThrown(`${prefix} ${ran}`, error, redact, wantsJson(rest))
283
299
  }
284
300
  }
285
301
 
@@ -288,8 +304,8 @@ export const COMPLETE = '__complete'
288
304
  // Printed instead of candidates where a path fits, for the shell to complete files itself.
289
305
  export const FILES = '__files__'
290
306
 
291
- // A command's options, read by running it until its parse call throws them: every command parses its arguments before anything else.
292
- async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Options> {
307
+ // What a command declares to its completion, read by running it until its parse call throws it: every command parses its arguments before anything else.
308
+ async function completingOf(run: (args: string[]) => Promise<number>): Promise<Completing> {
293
309
  completion.active = true
294
310
 
295
311
  try {
@@ -297,25 +313,74 @@ async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Opti
297
313
  }
298
314
  catch (error) {
299
315
  if (error instanceof Completing)
300
- return error.options
316
+ return error
301
317
  }
302
318
  finally {
303
319
  completion.active = false
304
320
  }
305
321
 
306
- return {}
322
+ return new Completing({})
323
+ }
324
+
325
+ async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Options> {
326
+ return (await completingOf(run)).options
327
+ }
328
+
329
+ function candidateLine(candidate: Candidate): string {
330
+ return typeof candidate === 'string' ? candidate : candidate.join('\t')
331
+ }
332
+
333
+ // The name of the positional the last word is, counting the words before it that are neither options, nor the values of string ones; the last name repeats when it ends in `...`.
334
+ function positionalBeingTyped(before: string[], { options, positionals }: Completing): string | undefined {
335
+ const takesValue = (word: string) => !word.includes('=') && Object.entries(options).some(([name, { short, type }]) => type === 'string' && (word === `--${name}` || word === `-${short}`))
336
+ let index = 0
337
+ let at = 0
338
+
339
+ while (at < before.length) {
340
+ const word = before[at]!
341
+
342
+ if (word === '--') {
343
+ index += before.length - at - 1
344
+
345
+ break
346
+ }
347
+
348
+ if (!word.startsWith('-'))
349
+ index++
350
+ else if (takesValue(word))
351
+ at++
352
+
353
+ at++
354
+ }
355
+
356
+ const last = positionals.at(-1)
357
+
358
+ if (index < positionals.length)
359
+ return positionals[index]
360
+
361
+ return last?.replace(']', '').endsWith('...') === true ? last : undefined
307
362
  }
308
363
 
309
- // A leaf command's candidates: the choices of the string option just typed, its long options for a word starting with a dash, else paths.
364
+ // A leaf command's candidates: the values of the string option just typed or of the positional being typed, else paths for a word not starting with a dash; its long options for one that does.
310
365
  async function leafCandidates(run: (args: string[]) => Promise<number>, words: string[]): Promise<string[]> {
311
- if (!(words.at(-1) ?? '').startsWith('-')) {
312
- const flag = words.at(-2)
313
- const option = flag?.startsWith('-') === true ? Object.entries(await optionsOf(run)).find(([name, { short }]) => flag === `--${name}` || flag === `-${short}`)?.[1] : undefined
366
+ const completing = await completingOf(run)
367
+
368
+ if ((words.at(-1) ?? '').startsWith('-'))
369
+ return Object.entries(completing.options).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name}\t${option.description}`)
370
+
371
+ const flag = words.at(-2)
372
+ const option = flag?.startsWith('-') === true ? Object.entries(completing.options).find(([name, { short }]) => flag === `--${name}` || flag === `-${short}`)?.[1] : undefined
314
373
 
315
- return option?.type === 'string' && option.choices !== undefined ? [...option.choices] : [FILES]
374
+ if (option?.type === 'string') {
375
+ const values = option.choices === undefined ? await option.candidates?.() : option.choices
376
+
377
+ return values === undefined ? [FILES] : values.map(candidateLine)
316
378
  }
317
379
 
318
- return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name}\t${option.description}`)
380
+ const name = positionalBeingTyped(words.slice(0, -1), completing)
381
+ const values = name === undefined ? undefined : await completing.candidates[name]?.()
382
+
383
+ return values === undefined ? [FILES] : values.map(candidateLine)
319
384
  }
320
385
 
321
386
  // The candidates for the last word, `value\tdescription` per line, after walking the subcommands the earlier words name.
@@ -330,8 +395,16 @@ async function candidates(scope: Scope, defaultCommand: string | undefined, word
330
395
  command = commands[aliases?.[word] ?? word]
331
396
  }
332
397
 
333
- if (listing)
334
- return Promise.all(Object.entries(shown(commands)).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
398
+ if (listing) {
399
+ const own = await Promise.all(Object.entries(shown(commands)).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
400
+ const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
401
+ // What the default command takes as its first positional is typed here too, `athome <project>`; a name a command already has is left out.
402
+ const values = fallback !== undefined && 'run' in fallback ? await leafCandidates(fallback.run, [word]) : []
403
+
404
+ const valueOf = (line: string) => line.slice(0, line.includes('\t') ? line.indexOf('\t') : undefined)
405
+
406
+ return [...own, ...values.filter(line => line !== FILES && !(valueOf(line) in commands))]
407
+ }
335
408
 
336
409
  if (command === undefined) {
337
410
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
@@ -354,7 +427,7 @@ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<nu
354
427
  if (argv[0] === COMPLETE)
355
428
  return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
356
429
 
357
- return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
430
+ return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, runBare: cli.runBare === true, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
358
431
  }
359
432
 
360
433
  export interface Main {
@@ -364,6 +437,22 @@ export interface Main {
364
437
  redact?: (text: string) => string
365
438
  }
366
439
 
440
+ // Parses a module's own arguments through runMain's handling, for a script running at its top level: the parsed values come back, and the process ends itself on --help, --version, `__complete` or an error, with the exit code runMain gives.
441
+ export async function mainArgs<T>(main: Omit<Main, 'run'>, parseArgv: (args: string[]) => T, argv = process.argv.slice(2)): Promise<T> {
442
+ let parsed: { value: T } | undefined
443
+ // eslint-disable-next-line ts/require-await -- commands are async by contract
444
+ const code = await runMain({ ...main, run: async (args) => {
445
+ parsed = { value: parseArgv(args) }
446
+
447
+ return 0
448
+ } }, argv)
449
+
450
+ if (parsed === undefined)
451
+ process.exit(code)
452
+
453
+ return parsed.value
454
+ }
455
+
367
456
  // Runs a CLI that is a single command: `--version` or `-v` alone prints the version, an `Exit` or any error prints as `error: <name>: <message>`. Returns the exit code.
368
457
  export async function runMain({ name, version, run, redact }: Main, argv = process.argv.slice(2)): Promise<number> {
369
458
  if (argv[0] === COMPLETE)
package/src/prompt.ts CHANGED
@@ -45,14 +45,17 @@ function readKeys(input: string): Key[] {
45
45
  return (input.match(KEY_SEQUENCES) ?? []).flatMap(sequence => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]))
46
46
  }
47
47
 
48
- export interface SelectSettings extends PromptSettings {
48
+ export interface SelectSettings<T> extends PromptSettings {
49
49
  // The index of the choice the cursor starts on.
50
50
  initial?: number
51
+ // The value of the choice the cursor starts on, winning over `initial` when a choice holds it.
52
+ default?: T
51
53
  }
52
54
 
53
55
  // One choice among several, typing filtering them, the arrows moving and Enter picking; undefined once Escape or Ctrl+C cancels.
54
- export async function select<T>(question: string, choices: Choice<T>[], { initial = 0, ...settings }: SelectSettings = {}): Promise<T | undefined> {
55
- const list = new List(choices, initial)
56
+ export async function select<T>(question: string, choices: Choice<T>[], { initial = 0, default: fallback, ...settings }: SelectSettings<T> = {}): Promise<T | undefined> {
57
+ const named = choices.findIndex(choice => choice.value === fallback)
58
+ const list = new List(choices, fallback === undefined || named === -1 ? initial : named)
56
59
  const picked = await ask(question, settings, (colors, state, rows) => state === 'asking'
57
60
  ? [asking(colors, question, list.query, 'type to filter, enter picks'), ...list.rows(colors, rows, () => '')]
58
61
  : [answer(colors, question, state, list.current()?.label ?? '')], (key) => {
@@ -72,6 +75,8 @@ export async function select<T>(question: string, choices: Choice<T>[], { initia
72
75
 
73
76
  export interface ChecklistSettings<T> extends PromptSettings {
74
77
  initial?: ReadonlySet<T>
78
+ // How many choices Enter needs checked: on fewer, `pick at least N` shows under the list until the next key instead of answering.
79
+ min?: number
75
80
  // What checking or unchecking `value` leaves checked, which may change other choices along; flips `value` alone by default.
76
81
  toggle?: (checked: ReadonlySet<T>, value: T) => ReadonlySet<T>
77
82
  }
@@ -86,15 +91,24 @@ function flip<T>(checked: ReadonlySet<T>, value: T): ReadonlySet<T> {
86
91
  }
87
92
 
88
93
  // Several choices, typing filtering them, Space checking or unchecking the one under the cursor and Enter accepting what is checked; undefined once cancelled.
89
- export async function checklist<T>(question: string, choices: Choice<T>[], { initial = new Set(), toggle = flip, ...settings }: ChecklistSettings<T> = {}): Promise<ReadonlySet<T> | undefined> {
94
+ export async function checklist<T>(question: string, choices: Choice<T>[], { initial = new Set(), min = 0, toggle = flip, ...settings }: ChecklistSettings<T> = {}): Promise<ReadonlySet<T> | undefined> {
90
95
  const list = new List(choices, 0)
91
96
  let checked = initial
97
+ let invalid: string | undefined
92
98
  const accepted = await ask(question, settings, (colors, state, rows) => state === 'asking'
93
- ? [asking(colors, question, list.query, 'type to filter, space toggles, enter accepts'), ...list.rows(colors, rows, choice => checked.has(choice.value) ? `${colors.cyan('◉')} ` : `${colors.dim('◯')} `)]
99
+ ? [asking(colors, question, list.query, 'type to filter, space toggles, enter accepts'), ...list.rows(colors, rows, choice => checked.has(choice.value) ? `${colors.cyan('◉')} ` : `${colors.dim('◯')} `), ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]]
94
100
  : [answer(colors, question, state, choices.filter(choice => checked.has(choice.value)).map(choice => choice.label).join(', ') || 'none')], (key) => {
95
- if (key === 'enter' || key === 'cancel')
101
+ if (key === 'cancel')
96
102
  return key
97
103
 
104
+ invalid = undefined
105
+
106
+ if (key === 'enter') {
107
+ invalid = checked.size < min ? `pick at least ${min}` : undefined
108
+
109
+ return invalid === undefined ? 'enter' : undefined
110
+ }
111
+
98
112
  const current = list.current()
99
113
 
100
114
  if (typeof key === 'object' && key.text === ' ') {