@kaynooo/cli-kit 0.0.0-stage → 0.7.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaynooo <kaynooo@kevyn.fr>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,60 @@
1
- # Temporary Holding Version
1
+ # @kaynooo/cli-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ What every @kaynooo CLI shares, so each one parses, helps and fails the same way.
4
+
5
+ ```sh
6
+ bun add @kaynooo/cli-kit
7
+ ```
8
+
9
+ It ships its TypeScript source and runs on Bun only (>= 1.4.3). The agent-tools CLIs take it as a `workspace:*` dev dependency and bundle it at build; a CLI elsewhere takes it from the registry as a dependency.
10
+
11
+ ```ts
12
+ import { parse, runCli } from '@kaynooo/cli-kit'
13
+
14
+ process.exitCode = await runCli({
15
+ name: 'tool',
16
+ version: '1.0.0',
17
+ about: 'does things',
18
+ commands: {
19
+ // A command imports its module when it runs, so `tool --help` loads none of them.
20
+ build: { summary: 'build the project', run: async args => (await import('./commands/build')).run(args) },
21
+ },
22
+ })
23
+ ```
24
+
25
+ ## Exit codes
26
+
27
+ 0 for success, help and version; 2 for a usage error (an unknown command, a bad option, a missing argument, what `parse` and `fail` throw by default); 1 for a command that ran and failed (`fail(message, 1)`, or any other error a command throws).
28
+
29
+ ## Look
30
+
31
+ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, command and flag names in cyan and descriptions dimmed; an error reads as a red `error`, the dimmed `<cli> <command>:` and the message, with a yellow `hint` under it. Piped, in CI or under `NO_COLOR`, `NODE_DISABLE_COLORS` or `TERM=dumb`, the same text prints with plain words (`error: demo greet: give a name`, ` hint: run demo greet --help for its options`), so logs and what agents read stay greppable. `FORCE_COLOR` forces colours as `@kaynooo/utils` reads it.
32
+
33
+ ## API
34
+
35
+ - `runCli({ name, version, about, commands, aliases })` dispatches `<cli> <command> [args]`: `--help` lists the commands, `--version` prints the version, a typo names the command it likely meant, and an `Exit` thrown by a command prints `error: <cli> <command>: <message>`, its hint under it, and exits with its code. Any other error exits 1, named by its class unless a plain `Error` (`error: tool sync: MigrationError: ...`), its cause's message on a `cause:` line under it when the cause is an `Error`; `redact(text)` rewrites both before they print, for errors that may quote what a log must not keep.
36
+ - `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. `{ 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.
37
+ - `table`, `truncate`, `formatSeconds`, `formatAge` and `upTo` format output.
38
+ - `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`.
39
+ - `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`).
40
+ - `<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.
41
+ - `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.
42
+ - `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.
43
+ - `runCommand(argv, { cwd, env, stdin })` runs a command and returns `{ code, text, output, chunks }`, the output of both streams (in arrival order, tagged by stream in `chunks`); it throws when the command cannot start. `exitCodeOf(code, signal)` is the exit code a shell reports.
44
+ - `holdSignals(cleanup?)` takes SIGINT, SIGTERM and SIGHUP over until `release()`: a signal goes to the child given to `forward(child)`, so the CLI exits with the child's code; without one, `cleanup(signal)` runs and the signal is raised again, ending the process as it would have; without either, it is only kept in `signal`, for the caller to wind down (`base` holds them while its worktree exists).
45
+ - `openUrl(target, env?)` opens a URL, or a file path made absolute, with the command `BROWSER` names or the platform's opener (`xdg-open`, `open`, `start`), without waiting; it returns `false` when that command cannot start.
46
+ - `jsonOption(holds?)` is the `--json` flag, worded `print JSON` or `print JSON: <holds>` alike in every CLI, and `printJson(value)` prints the value indented on stdout.
47
+ - `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
48
+ - `table(rows, { cellWidth, header })` also flattens each cell's line breaks and cuts it to `cellWidth`, and with `header` prints the first row bold on a terminal; and `formatDuration(ms)` formats milliseconds like `formatSeconds` but with one decimal under a minute (`0.3s`).
49
+ - `ok(text, detail?)`, `note`, `warn` and `problem` print a status line marked with a green ✓, a dimmed ·, a yellow ! or a red ✗ (`ok` and `note` on stdout, the others on stderr), the detail dimmed after the text; plain, the marks read `ok:`, `note:`, `warning:` and `error:`. `marked(mark, text, stream, detail?)` returns such a line, `paint(stream)` the colours for a stream and `takesControlCodes(stream)` whether it draws in place.
50
+ - `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.
51
+ - `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.
52
+ - `@kaynooo/cli-kit/prompt` asks on the terminal, on stderr:
53
+ - `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.
54
+ - `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.
55
+ - `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.
56
+ - 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.
57
+ - 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.
58
+ - `@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.
59
+ - `task(label, run)` from `/progress` runs one step under a spinner, `run` given an `update({ done?, total?, unit? })` for a bar, and ends on `✓ <label> <time>`, or `✗ <label>` and the error thrown again.
60
+ - `@kaynooo/cli-kit/shell` reads shell command lines the way a hook vetting them needs: `splitCommand` cuts one into pieces at the separators outside quotes and heredocs, `parseCommand` names a piece's program past `sudo`/`env` wrappers and assignments, `tokenize`, `words`, `substitutions` and `replacePieces` take it apart and put it back.
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "type": "module",
4
+ "version": "0.7.3",
5
+ "description": "What every @kaynooo CLI shares: argument parsing with --help, a command dispatcher with typo hints and shell completion, and output formatting.",
6
+ "author": "Kaynooo <kaynooo@kevyn.fr>",
7
+ "license": "MIT",
8
+ "homepage": "https://git.kevyn.fr/kaynooo/agent-tools/src/branch/main/packages/kit#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://git.kevyn.fr/kaynooo/agent-tools.git",
12
+ "directory": "packages/kit"
13
+ },
14
+ "bugs": "https://git.kevyn.fr/kaynooo/agent-tools/issues",
15
+ "keywords": [
16
+ "args",
17
+ "bun",
18
+ "cli",
19
+ "completion"
20
+ ],
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./progress": "./src/progress.ts",
24
+ "./prompt": "./src/prompt.ts",
25
+ "./shell": "./src/shell.ts",
26
+ "./test": "./src/test.ts"
27
+ },
28
+ "files": ["src"],
29
+ "engines": {
30
+ "bun": ">=1.4.3"
31
+ },
32
+ "publishConfig": {
33
+ "registry": "https://git.kevyn.fr/api/packages/kaynooo/npm/"
34
+ },
35
+ "dependencies": {
36
+ "@kaynooo/utils": "^2.31.0"
37
+ }
38
+ }
package/src/args.ts ADDED
@@ -0,0 +1,189 @@
1
+ import type { ParseArgsOptionDescriptor } from 'node:util'
2
+ import process from 'node:process'
3
+ import { parseArgs } from 'node:util'
4
+ import { upTo } from './format'
5
+ import { paint } from './style'
6
+
7
+ export type Options = Record<string, ParseArgsOptionDescriptor & { description: string }>
8
+
9
+ // The `--json` flag every CLI words alike: `print JSON`, then what it holds when given.
10
+ export function jsonOption(holds?: string) {
11
+ return { type: 'boolean', description: holds === undefined ? 'print JSON' : `print JSON: ${holds}` } as const
12
+ }
13
+
14
+ // Thrown to end a command with this exit code and message, which runCli prints as `error: <cli> <command>: <message>`, the hint on a line of its own.
15
+ export class Exit extends Error {
16
+ constructor(message: string, readonly code: number, readonly hint?: string) {
17
+ super(message)
18
+ }
19
+ }
20
+
21
+ export function fail(message: string, code = 2, hint?: string): never {
22
+ throw new Exit(message, code, hint)
23
+ }
24
+
25
+ // An Exit for arguments the command cannot take, which runCli points at the command's --help.
26
+ export class UsageError extends Exit {
27
+ constructor(message: string) {
28
+ super(message, 2)
29
+ }
30
+ }
31
+
32
+ export function badUsage(message: string): never {
33
+ throw new UsageError(message)
34
+ }
35
+
36
+ // Thrown by parse once it has printed --help, so the command stops there; runCli and runMain end on it with exit 0, printing nothing more.
37
+ export class HelpShown extends Exit {
38
+ constructor() {
39
+ super('help shown', 0)
40
+ }
41
+ }
42
+
43
+ // Thrown by parse while a bin answers `__complete`, so the command reached hands over its options before doing anything else.
44
+ export class Completing {
45
+ constructor(readonly options: Options) {}
46
+ }
47
+
48
+ export const completion = { active: false }
49
+
50
+ // Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
51
+ const OPTIONAL = /\[[^\]]+\]/g
52
+ const USAGE_PREFIX = /^usage:\s*/i
53
+
54
+ // `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
55
+ export function usageLine(usage: string, colors = paint(process.stdout)): string {
56
+ return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
57
+ }
58
+
59
+ export function helpText(usage: string, options: Options, colors = paint(process.stdout)): string {
60
+ return [usageLine(usage, colors), '', colors.bold('Options:'), ...optionRows(options, colors)].join('\n')
61
+ }
62
+
63
+ // A line per option, `-n, --times <value> how many`, aligned on the visible text.
64
+ export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
65
+ const rows = Object.entries(options).map(([name, option]) => {
66
+ const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
67
+ const value = option.type === 'string' ? ` ${colors.dim('<value>')}` : ''
68
+
69
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? ' <value>'.length : 0), description: option.description }
70
+ })
71
+ const width = Math.max(0, ...rows.map(row => row.width))
72
+
73
+ return rows.map(row => ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
74
+ }
75
+
76
+ // The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
77
+ type Slots<Names extends readonly string[]> = Names extends readonly [infer First extends string, ...infer Others extends readonly string[]]
78
+ ? First extends `${string}...` | `${string}...]` ? string[] : First extends `[${string}` ? [string?, ...Slots<Others>] : [string, ...Slots<Others>]
79
+ : Names extends readonly [] ? [] : string[]
80
+
81
+ export interface ParseSettings<Names extends readonly string[] = string[]> {
82
+ // Stops at the first `--` and hands what follows back untouched as `rest`, for a command that runs another (`out -n 200 -- <cmd>`).
83
+ rest?: boolean
84
+ // Takes `--no-<flag>` as `false` for a boolean flag, as parseArgs' own `allowNegative` does.
85
+ allowNegative?: boolean
86
+ // 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.
87
+ positionals?: Names
88
+ }
89
+
90
+ // Parses a command's arguments; on --help, prints its help and throws HelpShown, which ends the command with exit 0.
91
+ export function parse<const T extends Options, const Names extends readonly string[] = string[]>(args: string[], usage: string, options: T, settings: ParseSettings<Names> = {}) {
92
+ const all = { ...options, help: { type: 'boolean', short: 'h', description: 'show this help' } } as const
93
+
94
+ if (completion.active)
95
+ throw new Completing(all)
96
+
97
+ const separator = settings.rest === true ? args.indexOf('--') : -1
98
+ const own = separator === -1 ? args : args.slice(0, separator)
99
+ const rest = separator === -1 ? undefined : args.slice(separator + 1)
100
+
101
+ let parsed
102
+
103
+ try {
104
+ parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
105
+ }
106
+ catch (error) {
107
+ badUsage(parseError(error))
108
+ }
109
+
110
+ if ((parsed.values as { help?: boolean }).help === true) {
111
+ console.log(helpText(usage, all))
112
+
113
+ throw new HelpShown()
114
+ }
115
+
116
+ if (settings.positionals !== undefined)
117
+ checkPositionals(parsed.positionals, settings.positionals)
118
+
119
+ return { ...parsed, positionals: parsed.positionals as Slots<Names>, rest }
120
+ }
121
+
122
+ function checkPositionals(given: string[], names: readonly string[]): void {
123
+ const variadic = names.at(-1)?.replace(']', '').endsWith('...') === true
124
+ const required = names.filter(name => !name.startsWith('[')).length
125
+
126
+ if (given.length < required)
127
+ badUsage(`missing ${names[given.length]}`)
128
+
129
+ if (!variadic && given.length > names.length)
130
+ badUsage(`unexpected argument ${given[names.length]}`)
131
+ }
132
+
133
+ // parseArgs' message, its first sentence alone and lowercased as the kit's own: `unknown option '--nope'`.
134
+ function parseError(error: unknown): string {
135
+ const message = upTo(error instanceof Error ? error.message : String(error), '. ')
136
+
137
+ return `${message.charAt(0).toLowerCase()}${message.slice(1)}`
138
+ }
139
+
140
+ export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
141
+ if (value === undefined)
142
+ return fallback
143
+
144
+ const number = Number(value)
145
+
146
+ if (!Number.isInteger(number) || number < min)
147
+ badUsage(`--${name} takes a whole number from ${min}, not ${value}`)
148
+
149
+ return number
150
+ }
151
+
152
+ const DURATION = /^(\d+(?:\.\d+)?)(ms|[smh])?$/
153
+ const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 } as const
154
+
155
+ // A duration such as `500ms`, `90s`, `5m` or `1h` in milliseconds, a bare number counting in `unit`.
156
+ export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' = 's'): number {
157
+ if (value === undefined)
158
+ return fallback
159
+
160
+ const match = DURATION.exec(value.trim())
161
+ const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
162
+
163
+ if (ms < 1)
164
+ badUsage(`--${name} takes a duration such as 30s, 5m or 1h, not ${value}`)
165
+
166
+ return ms
167
+ }
168
+
169
+ export interface InputSettings {
170
+ // What to say when stdin is a terminal, as nothing is piped.
171
+ noStdin?: string
172
+ }
173
+
174
+ // Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
175
+ export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
176
+ if (path === '-') {
177
+ if (process.stdin.isTTY)
178
+ fail(noStdin)
179
+
180
+ return Bun.stdin.text()
181
+ }
182
+
183
+ const file = Bun.file(path)
184
+
185
+ if (!await file.exists())
186
+ fail(`no file ${path}`)
187
+
188
+ return file.text()
189
+ }
package/src/chart.ts ADDED
@@ -0,0 +1,61 @@
1
+ import process from 'node:process'
2
+ import { textWidth } from './format'
3
+ import { paint } from './style'
4
+
5
+ type Env = Record<string, string | undefined>
6
+
7
+ const LEVELS = '▁▂▃▄▅▆▇█'
8
+
9
+ // One block per value, tall in proportion to where it sits between the smallest and the largest; a flat series draws mid-height.
10
+ export function sparkline(values: number[]): string {
11
+ if (values.length === 0)
12
+ return ''
13
+
14
+ const low = Math.min(...values)
15
+ const span = Math.max(...values) - low
16
+
17
+ return values.map(value => LEVELS[span === 0 ? 3 : Math.round((value - low) / span * (LEVELS.length - 1))]).join('')
18
+ }
19
+
20
+ export interface BarSettings {
21
+ width?: number
22
+ stream?: { isTTY?: boolean }
23
+ env?: Env
24
+ }
25
+
26
+ // `████████░░░░░░░░░░░░` for a ratio between 0 and 1, filled in cyan and the rest dim on a terminal.
27
+ export function bar(ratio: number, { width = 20, stream = process.stdout, env = process.env }: BarSettings = {}): string {
28
+ const colors = paint(stream, env)
29
+ const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
30
+
31
+ return `${colors.cyan('█'.repeat(filled))}${colors.dim('░'.repeat(width - filled))}`
32
+ }
33
+
34
+ export interface KeyValueSettings {
35
+ stream?: { isTTY?: boolean }
36
+ env?: Env
37
+ }
38
+
39
+ // Labels dimmed and padded to the longest, one pair per line: `state open`.
40
+ export function keyValue(pairs: [label: string, value: string][], { stream = process.stdout, env = process.env }: KeyValueSettings = {}): string {
41
+ const colors = paint(stream, env)
42
+ const width = Math.max(0, ...pairs.map(([label]) => textWidth(label)))
43
+
44
+ return pairs.map(([label, value]) => `${colors.dim(label)}${' '.repeat(width - textWidth(label))} ${value}`).join('\n')
45
+ }
46
+
47
+ export interface BarChartSettings extends KeyValueSettings {
48
+ // Columns the bars take, 24 by default.
49
+ width?: number
50
+ // Formats the number at the end of each row.
51
+ format?: (value: number) => string
52
+ }
53
+
54
+ // A bar per labelled value, scaled to the largest: `label ██████░░░░░░ 42`.
55
+ export function barChart(rows: [label: string, value: number][], { width = 24, format = String, stream = process.stdout, env = process.env }: BarChartSettings = {}): string {
56
+ const largest = Math.max(0, ...rows.map(([, value]) => value))
57
+ const labels = Math.max(0, ...rows.map(([label]) => textWidth(label)))
58
+ const values = Math.max(0, ...rows.map(([, value]) => textWidth(format(value))))
59
+
60
+ return rows.map(([label, value]) => `${label}${' '.repeat(labels - textWidth(label))} ${bar(largest === 0 ? 0 : value / largest, { width, stream, env })} ${' '.repeat(values - textWidth(format(value)))}${format(value)}`).join('\n')
61
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type { Options } from './args'
2
+ import type { Colors } from './style'
3
+ import process from 'node:process'
4
+ import { Completing, completion, Exit, HelpShown, optionRows, UsageError, usageLine } from './args'
5
+ import { paint, takesControlCodes } from './style'
6
+ import { distance } from './text'
7
+
8
+ export type Command = { summary: string } & (
9
+ | { run: (args: string[]) => Promise<number> }
10
+ // A group of subcommands dispatched the same way, with its own help.
11
+ | { commands: Record<string, Command>, aliases?: Record<string, string> }
12
+ )
13
+
14
+ export interface Cli {
15
+ name: string
16
+ version: string
17
+ // One line after `<name> <version>: ` in the help.
18
+ about: string
19
+ commands: Record<string, Command>
20
+ // Names sessions reach for that mean an existing command.
21
+ aliases?: Record<string, string>
22
+ // 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.
23
+ defaultCommand?: string
24
+ // Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
25
+ redact?: (text: string) => string
26
+ }
27
+
28
+ // A command the name starts, else one within two edits (one for a name under four letters).
29
+ export function closest(name: string, names: string[]): string | undefined {
30
+ const started = names.find(known => known.startsWith(name))
31
+
32
+ if (started !== undefined)
33
+ return started
34
+
35
+ const [best] = names.map(known => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far)
36
+
37
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined
38
+ }
39
+
40
+ interface Level {
41
+ // The words typed to reach this level, `ft plan`.
42
+ path: string[]
43
+ header: (colors: Colors) => string
44
+ commands: Record<string, Command>
45
+ aliases: Record<string, string> | undefined
46
+ defaultCommand: string | undefined
47
+ }
48
+
49
+ // The level's help; a default command's options follow the commands, as they apply with no command named.
50
+ async function help({ path, header, commands, defaultCommand }: Level, stream: { isTTY?: boolean }): Promise<string> {
51
+ const colors = paint(stream)
52
+ const width = Math.max(...Object.keys(commands).map(name => name.length))
53
+ const prefix = path.join(' ')
54
+ const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
55
+ const options = fallback !== undefined && 'run' in fallback ? await optionsOf(fallback.run) : undefined
56
+
57
+ return [
58
+ header(colors),
59
+ '',
60
+ usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
61
+ '',
62
+ colors.bold('Commands:'),
63
+ ...Object.entries(commands).map(([name, { summary }]) => ` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`),
64
+ ...options === undefined || Object.keys(options).length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(options, colors)],
65
+ '',
66
+ `${colors.dim('Run')} ${prefix} ${colors.cyan('<command>')} --help ${colors.dim('for its options.')}`,
67
+ ].join('\n')
68
+ }
69
+
70
+ // `error <prefix>: <message>` on stderr, the cause and the hint each on a line of their own under it; plain words off a terminal.
71
+ export function printError(prefix: string, message: string, hint?: string, cause?: string): void {
72
+ const colors = paint(process.stderr)
73
+ const styled = takesControlCodes(process.stderr)
74
+ const head = styled ? `${colors.red.bold('error')} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`
75
+ const under = (label: string, text: string | undefined, tint: (text: string) => string) => text === undefined ? '' : `\n ${styled ? tint(label) : `${label}:`} ${text}`
76
+
77
+ console.error(`${head} ${message}${under('cause', cause, colors.dim)}${under('hint', hint, colors.yellow)}`)
78
+ }
79
+
80
+ // What a thrown error prints as and exits with: an Exit its own code and hint, a UsageError pointed at the help, HelpShown nothing at all and 0, anything else 1.
81
+ // An error other than an Exit is named unless a plain `Error` (`MigrationError: ...`), and its cause's message printed under it, never the cause itself.
82
+ function reportThrown(prefix: string, error: unknown, redact: (text: string) => string = text => text): number {
83
+ if (error instanceof HelpShown)
84
+ return 0
85
+
86
+ const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined
87
+ const named = error instanceof Error && !(error instanceof Exit) && error.name !== 'Error' ? `${error.name}: ${error.message}` : undefined
88
+ const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined
89
+
90
+ printError(prefix, redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
91
+
92
+ return error instanceof Exit ? error.code : 1
93
+ }
94
+
95
+ async function dispatch(level: Level, argv: string[], { version, redact }: { version: string, redact: Cli['redact'] | undefined }): Promise<number> {
96
+ const [name, ...args] = argv
97
+ const prefix = level.path.join(' ')
98
+
99
+ if (name === undefined && level.path.length > 1) {
100
+ console.error(await help(level, process.stderr))
101
+
102
+ return 2
103
+ }
104
+
105
+ if (name === undefined || name === 'help' || name === '--help' || name === '-h') {
106
+ console.log(await help(level, process.stdout))
107
+
108
+ return 0
109
+ }
110
+
111
+ if (level.path.length === 1 && (name === '--version' || name === '-v')) {
112
+ console.log(version)
113
+
114
+ return 0
115
+ }
116
+
117
+ let command = level.commands[level.aliases?.[name] ?? name]
118
+ let rest = args
119
+
120
+ if (command === undefined && level.defaultCommand !== undefined) {
121
+ command = level.commands[level.defaultCommand]
122
+ rest = argv
123
+ }
124
+
125
+ if (command === undefined) {
126
+ const meant = closest(name, Object.keys(level.commands))
127
+
128
+ printError(prefix, `no command ${name}`, meant === undefined ? `run ${prefix} --help for its commands` : `did you mean ${meant}?`)
129
+
130
+ return 2
131
+ }
132
+
133
+ if ('commands' in command) {
134
+ const { summary } = command
135
+
136
+ return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined }, rest, { version, redact })
137
+ }
138
+
139
+ try {
140
+ return await command.run(rest)
141
+ }
142
+ catch (error) {
143
+ return reportThrown(`${prefix} ${name}`, error, redact)
144
+ }
145
+ }
146
+
147
+ // The hidden first argument a shell completion calls a bin with: `<bin> __complete <words typed after it>... <word being typed>`.
148
+ export const COMPLETE = '__complete'
149
+ // Printed instead of candidates where a path fits, for the shell to complete files itself.
150
+ export const FILES = '__files__'
151
+
152
+ // A command's options, read by running it until its parse call throws them: every command parses its arguments before anything else.
153
+ async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Options> {
154
+ completion.active = true
155
+
156
+ try {
157
+ await run([])
158
+ }
159
+ catch (error) {
160
+ if (error instanceof Completing)
161
+ return error.options
162
+ }
163
+ finally {
164
+ completion.active = false
165
+ }
166
+
167
+ return {}
168
+ }
169
+
170
+ // A leaf command's candidates: its long options for a word starting with a dash, else paths.
171
+ async function leafCandidates(run: (args: string[]) => Promise<number>, words: string[]): Promise<string[]> {
172
+ if (!(words.at(-1) ?? '').startsWith('-'))
173
+ return [FILES]
174
+
175
+ return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name}\t${option.description}`)
176
+ }
177
+
178
+ // The candidates for the last word, `value\tdescription` per line, after walking the subcommands the earlier words name.
179
+ async function candidates(commands: Record<string, Command>, aliases: Record<string, string> | undefined, defaultCommand: string | undefined, words: string[]): Promise<string[]> {
180
+ const [word = '', ...rest] = words
181
+ const command = commands[aliases?.[word] ?? word]
182
+
183
+ if (rest.length === 0 && !word.startsWith('-'))
184
+ return Object.entries(commands).map(([name, { summary }]) => `${name}\t${summary}`)
185
+
186
+ if (command === undefined) {
187
+ const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
188
+
189
+ return fallback !== undefined && 'run' in fallback ? leafCandidates(fallback.run, words) : []
190
+ }
191
+
192
+ return 'commands' in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest)
193
+ }
194
+
195
+ async function printCandidates(found: Promise<string[]>): Promise<number> {
196
+ for (const line of await found)
197
+ console.log(line)
198
+
199
+ return 0
200
+ }
201
+
202
+ // Runs the command argv names and returns the exit code: 0 for help and version, 2 for an unknown command.
203
+ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<number> {
204
+ if (argv[0] === COMPLETE)
205
+ return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)))
206
+
207
+ return dispatch({ path: [cli.name], header: colors => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand }, argv, { version: cli.version, redact: cli.redact })
208
+ }
209
+
210
+ export interface Main {
211
+ name: string
212
+ version: string
213
+ run: (args: string[]) => Promise<number>
214
+ redact?: (text: string) => string
215
+ }
216
+
217
+ // 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.
218
+ export async function runMain({ name, version, run, redact }: Main, argv = process.argv.slice(2)): Promise<number> {
219
+ if (argv[0] === COMPLETE)
220
+ return printCandidates(leafCandidates(run, argv.slice(1)))
221
+
222
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
223
+ console.log(version)
224
+
225
+ return 0
226
+ }
227
+
228
+ try {
229
+ return await run(argv)
230
+ }
231
+ catch (error) {
232
+ return reportThrown(name, error, redact)
233
+ }
234
+ }