@kaynooo/cli-kit 0.15.2 → 0.16.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 +3 -2
- package/package.json +1 -1
- package/src/args.ts +40 -4
- package/src/cli.ts +19 -7
- package/src/prompt.ts +41 -0
package/README.md
CHANGED
|
@@ -36,10 +36,10 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
36
36
|
- `runTool(label, argv, { timeoutMs, cwd, onStart })` runs a command through `runCommand` with the current environment and returns `{ code, stdout, stderr }`; it throws, naming `label`, when the command cannot start (noting a removed working directory) or outlives `timeoutMs`. `isPermanentFailure(error)` is true for what a retry cannot mend: a missing binary, or an answer of 401, 403 or 404. `cwdGone()` tells that the working directory was deleted.
|
|
37
37
|
- `git(args, timeoutMs?)`, `gitOk`, `resolveCommit`, `fetchBranch`, `defaultBranch` and `leaveWorktree` are the git helpers the forge CLIs share, over `runTool`.
|
|
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
|
-
- `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.
|
|
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
41
|
- `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`).
|
|
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
43
|
- 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
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
45
|
- `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.
|
|
@@ -65,6 +65,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
65
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
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.
|
|
67
67
|
- `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
|
+
- `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.
|
|
68
69
|
- 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.
|
|
69
70
|
- Off a terminal, a prompt fails at once with exit 2, `cannot ask "<question>" off a terminal`, with `hint` under it when given (`{ hint: 'pass --yes' }`); none reads a piped line. Each takes a `terminal: { stdin, stderr }`, the process's by default, for a test to drive.
|
|
70
71
|
- `@kaynooo/cli-kit/progress` draws `progressLine(stream?, env?)` on stderr, one line redrawn in place while a task runs (`⠹ prerendering ████████░░░░░░░░░░░░ 8/20 routes 4.2s`, the bar cyan on a dimmed track), fed `update({ step, done?, total?, unit? })` and ended with `stop()`, `done(text)` leaving `✓ <text> <time>` or `failed(text)` leaving `✗ <text>`. It draws only where `takesControlCodes`; elsewhere `update` and `stop` do nothing and `done` and `failed` print their plain line. Anything printed meanwhile clears it first, and an interrupt clears it before the process ends.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-kit",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.16.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,7 +6,8 @@ import { upTo } from './format'
|
|
|
6
6
|
import { paint } from './style'
|
|
7
7
|
import { closest } from './text'
|
|
8
8
|
|
|
9
|
-
|
|
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[] }>
|
|
10
11
|
|
|
11
12
|
// The `--json` flag every CLI words alike: `print JSON`, then what it holds when given.
|
|
12
13
|
export function jsonOption(holds?: string) {
|
|
@@ -109,9 +110,10 @@ export function exampleRows(examples: Example[], colors = paint(process.stdout))
|
|
|
109
110
|
export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
|
|
110
111
|
const rows = Object.entries(options).map(([name, option]) => {
|
|
111
112
|
const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
|
|
112
|
-
const
|
|
113
|
+
const placeholder = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
|
|
114
|
+
const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
|
|
113
115
|
|
|
114
|
-
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ?
|
|
116
|
+
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
|
|
115
117
|
})
|
|
116
118
|
const width = Math.max(0, ...rows.map(row => row.width))
|
|
117
119
|
|
|
@@ -123,6 +125,15 @@ type Slots<Names extends readonly string[]> = Names extends readonly [infer Firs
|
|
|
123
125
|
? First extends `${string}...` | `${string}...]` ? string[] : First extends `[${string}` ? [string?, ...Slots<Others>] : [string, ...Slots<Others>]
|
|
124
126
|
: Names extends readonly [] ? [] : string[]
|
|
125
127
|
|
|
128
|
+
// The parsed values with an option that has `choices` typed as their union, `undefined` kept when the option may be absent.
|
|
129
|
+
type Chosen<T extends Options, Values> = {
|
|
130
|
+
[Name in keyof Values]: Name extends keyof T
|
|
131
|
+
? T[Name] extends { choices: infer Choices extends readonly string[] }
|
|
132
|
+
? Extract<Values[Name], undefined> | (NonNullable<Values[Name]> extends unknown[] ? Choices[number][] : Choices[number])
|
|
133
|
+
: Values[Name]
|
|
134
|
+
: Values[Name]
|
|
135
|
+
}
|
|
136
|
+
|
|
126
137
|
export interface ParseSettings<Names extends readonly string[] = string[]> {
|
|
127
138
|
// Stops at the first `--` and hands what follows back untouched as `rest`, for a command that runs another (`out -n 200 -- <cmd>`).
|
|
128
139
|
rest?: boolean
|
|
@@ -202,10 +213,35 @@ export function parse<const T extends Options, const Names extends readonly stri
|
|
|
202
213
|
throw new HelpShown()
|
|
203
214
|
}
|
|
204
215
|
|
|
216
|
+
checkChoices(parsed.values, all)
|
|
217
|
+
|
|
205
218
|
if (settings.positionals !== undefined)
|
|
206
219
|
checkPositionals(parsed.positionals, settings.positionals)
|
|
207
220
|
|
|
208
|
-
return { ...parsed, positionals: parsed.positionals as Slots<Names>, rest }
|
|
221
|
+
return { ...parsed, values: parsed.values as Chosen<T, typeof parsed.values>, positionals: parsed.positionals as Slots<Names>, rest }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A usage error for an option given a value outside its `choices`, in parseArgs' own order.
|
|
225
|
+
function checkChoices(values: Record<string, unknown>, options: Options): void {
|
|
226
|
+
for (const [name, option] of Object.entries(options)) {
|
|
227
|
+
if (option.choices === undefined)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
const given = values[name]
|
|
231
|
+
|
|
232
|
+
for (const value of Array.isArray(given) ? given : [given]) {
|
|
233
|
+
if (typeof value === 'string')
|
|
234
|
+
oneOf(value, name, option.choices)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The value when it is one of `choices`, else a usage error naming them: `--bump takes one of patch, minor, major, not big`. A name written `<bump>` is a positional and is printed as is, any other is an option printed `--name`.
|
|
240
|
+
export function oneOf<const Choices extends readonly string[]>(value: string, name: string, choices: Choices): Choices[number] {
|
|
241
|
+
if (!choices.includes(value))
|
|
242
|
+
badUsage(`${name.startsWith('<') ? name : `--${name}`} takes one of ${choices.join(', ')}, not ${value}`)
|
|
243
|
+
|
|
244
|
+
return value
|
|
209
245
|
}
|
|
210
246
|
|
|
211
247
|
function checkPositionals(given: string[], names: readonly string[]): void {
|
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type Command = {
|
|
|
17
17
|
summary: Summary
|
|
18
18
|
// The heading the command is listed under in the help, after those with none.
|
|
19
19
|
group?: string
|
|
20
|
+
// Runs as usual but is left out of the help, the typo hints and `__complete`, for a command only a script or a shell binding calls.
|
|
21
|
+
hidden?: boolean
|
|
20
22
|
} & (
|
|
21
23
|
| { run: (args: string[]) => Promise<number> }
|
|
22
24
|
// A group of subcommands dispatched the same way, with its own help; `commands` may be a function for a group made of lazy commands alone.
|
|
@@ -51,8 +53,9 @@ const LEVEL_OPTIONS = {
|
|
|
51
53
|
const TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: 'boolean', short: 'v', description: 'print the version' } } as const satisfies Options
|
|
52
54
|
|
|
53
55
|
// The rows under `Commands:`, then a heading per group in the order first met, names aligned across them all.
|
|
54
|
-
async function commandRows(
|
|
55
|
-
const
|
|
56
|
+
async function commandRows(all: Commands, colors: Colors): Promise<string[]> {
|
|
57
|
+
const commands = shown(all)
|
|
58
|
+
const width = Math.max(0, ...Object.keys(commands).map(name => name.length))
|
|
56
59
|
const groups = new Map<string, string[]>([['Commands:', []]])
|
|
57
60
|
const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)))
|
|
58
61
|
|
|
@@ -67,6 +70,11 @@ async function commandRows(commands: Commands, colors: Colors): Promise<string[]
|
|
|
67
70
|
return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [''], colors.bold(heading), ...rows])
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
// The commands that are not hidden.
|
|
74
|
+
function shown(commands: Commands): Commands {
|
|
75
|
+
return Object.fromEntries(Object.entries(commands).filter(([, { hidden }]) => hidden !== true))
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
// The output flags typed before the command name, applied and dropped: `ft --plain runs`.
|
|
71
79
|
function takeOutputFlags(argv: string[]): string[] {
|
|
72
80
|
let index = 0
|
|
@@ -254,7 +262,7 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
254
262
|
}
|
|
255
263
|
|
|
256
264
|
if (command === undefined) {
|
|
257
|
-
const meant = closest(name, Object.keys(level.commands))
|
|
265
|
+
const meant = closest(name, Object.keys(shown(level.commands)))
|
|
258
266
|
|
|
259
267
|
printError(prefix, `no command ${name}`, meant === undefined ? `run ${prefix} --help for its commands` : `did you mean ${meant}?`)
|
|
260
268
|
|
|
@@ -298,10 +306,14 @@ async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Opti
|
|
|
298
306
|
return {}
|
|
299
307
|
}
|
|
300
308
|
|
|
301
|
-
// A leaf command's candidates: its long options for a word starting with a dash, else paths.
|
|
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.
|
|
302
310
|
async function leafCandidates(run: (args: string[]) => Promise<number>, words: string[]): Promise<string[]> {
|
|
303
|
-
if (!(words.at(-1) ?? '').startsWith('-'))
|
|
304
|
-
|
|
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
|
|
314
|
+
|
|
315
|
+
return option?.type === 'string' && option.choices !== undefined ? [...option.choices] : [FILES]
|
|
316
|
+
}
|
|
305
317
|
|
|
306
318
|
return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name}\t${option.description}`)
|
|
307
319
|
}
|
|
@@ -319,7 +331,7 @@ async function candidates(scope: Scope, defaultCommand: string | undefined, word
|
|
|
319
331
|
}
|
|
320
332
|
|
|
321
333
|
if (listing)
|
|
322
|
-
return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
|
|
334
|
+
return Promise.all(Object.entries(shown(commands)).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
|
|
323
335
|
|
|
324
336
|
if (command === undefined) {
|
|
325
337
|
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
|
package/src/prompt.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { Colors } from './style'
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
2
5
|
import process from 'node:process'
|
|
3
6
|
import { fail } from './args'
|
|
4
7
|
import { truncate } from './format'
|
|
@@ -329,3 +332,41 @@ async function ask(question: string, { hint, terminal = process }: PromptSetting
|
|
|
329
332
|
stdin.pause()
|
|
330
333
|
}
|
|
331
334
|
}
|
|
335
|
+
|
|
336
|
+
export interface EditorSettings extends PromptSettings {
|
|
337
|
+
// The text the file starts with.
|
|
338
|
+
default?: string
|
|
339
|
+
// The file's extension, such as `.md`, so the editor picks its highlighting.
|
|
340
|
+
extension?: string
|
|
341
|
+
// Where VISUAL and EDITOR are read, the process's environment by default.
|
|
342
|
+
env?: Record<string, string | undefined>
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// A text edited in VISUAL or EDITOR (vi without either) on a temp file, the editor taking the terminal until it exits; undefined when it exits nonzero, as `:cq` does.
|
|
346
|
+
export async function editor(question: string, { default: text = '', extension = '.txt', env = process.env, hint, terminal = process }: EditorSettings = {}): Promise<string | undefined> {
|
|
347
|
+
const { stdin, stderr } = terminal
|
|
348
|
+
|
|
349
|
+
if (stdin.isTTY !== true || stderr.isTTY !== true)
|
|
350
|
+
fail(`cannot ask "${question}" off a terminal`, 2, hint)
|
|
351
|
+
|
|
352
|
+
const colors = paint(stderr)
|
|
353
|
+
const command = [env.VISUAL, env.EDITOR].find(value => value !== undefined && value !== '') ?? 'vi'
|
|
354
|
+
const dir = await mkdtemp(join(tmpdir(), 'kit-editor-'))
|
|
355
|
+
const file = join(dir, `edit${extension}`)
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await writeFile(file, text)
|
|
359
|
+
stderr.write(`${colors.cyan('?')} ${question} ${colors.dim(`(${command})`)}\n`)
|
|
360
|
+
|
|
361
|
+
// Through the shell, so an EDITOR with arguments (`code --wait`) runs as typed.
|
|
362
|
+
const edited = Bun.spawn(['sh', '-c', `${command} "$1"`, 'sh', file], { stdio: ['inherit', 'inherit', 'inherit'], env })
|
|
363
|
+
const done = await edited.exited === 0
|
|
364
|
+
|
|
365
|
+
stderr.write(`${answer(colors, question, done ? 'enter' : 'cancel', 'edited')}\n`)
|
|
366
|
+
|
|
367
|
+
return done ? await readFile(file, 'utf8') : undefined
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
await rm(dir, { recursive: true, force: true })
|
|
371
|
+
}
|
|
372
|
+
}
|