@kaynooo/cli-kit 0.0.0-stage → 0.8.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/LICENSE +21 -0
- package/README.md +65 -2
- package/package.json +36 -4
- package/src/args.ts +234 -0
- package/src/chart.ts +88 -0
- package/src/cli.ts +296 -0
- package/src/config.ts +59 -0
- package/src/format.ts +163 -0
- package/src/index.ts +8 -0
- package/src/process.ts +120 -0
- package/src/progress.ts +314 -0
- package/src/prompt.ts +331 -0
- package/src/shell.ts +373 -0
- package/src/style.ts +103 -0
- package/src/test.ts +42 -0
- package/src/text.ts +21 -0
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,66 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kaynooo/cli-kit
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
- Every command's `parse`, and `runCli` before the command name (`ft --plain runs`), takes `--color auto|always|never` and `--plain` (`--color never`), unless the command has its own option of that name. `always` sets `FORCE_COLOR=1` and `never` sets `NO_COLOR=1`, each dropping the other, so the kit's colours, marks and redrawn lines follow, and so do the processes the command starts; `always` colours a pipe but never redraws in one. `applyOutput(color, plain, env?)` is what they run.
|
|
47
|
+
- `--help` lists the commands under `Commands:`, then a heading per `group` a command names (`{ summary, group: 'Admin', run }`), then the level's `Options:` and the `Examples:` the CLI or a group's `examples` give (`['ft ci', ['ft ci HEAD~1', 'the commit before']]`, a note shown dimmed after `#`); a command's own examples go to `parse(args, usage, options, { examples })`, shown under its options.
|
|
48
|
+
- `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.
|
|
49
|
+
- `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
|
|
50
|
+
- `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`).
|
|
51
|
+
- `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.
|
|
52
|
+
- `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.
|
|
53
|
+
- `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.
|
|
54
|
+
- `@kaynooo/cli-kit/prompt` asks on the terminal, on stderr:
|
|
55
|
+
- `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.
|
|
56
|
+
- `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.
|
|
57
|
+
- `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.
|
|
58
|
+
- 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.
|
|
59
|
+
- 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.
|
|
60
|
+
- `@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.
|
|
61
|
+
- `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.
|
|
62
|
+
- `taskList(stream?, env?)` from `/progress` stacks a progress line per task running side by side: `add(label)` returns `{ update, done(text?), failed(text?) }`, and a task ending leaves its `✓` or `✗` line above those still running.
|
|
63
|
+
- `liveRegion(render, stream?, env?)` from `/progress` is the block both draw with: the lines `render(spinner)` returns, redrawn every frame (`redraw()` draws at once), each cut to the terminal's width and the block to its height, `print(text)` printing above it and `stop()` clearing it. Like the progress line it draws only where `takesControlCodes`, and `ft ci` and `waitfor` show what they wait on with it.
|
|
64
|
+
- `badge(word, stream)` marks a status word on a terminal, the same five ways everywhere: `✓ success` green, `✗ failure` red, `● running` cyan, `○ waiting` yellow, `- skipped` dimmed; the word alone elsewhere. `statusOf(word)` reads the words Forgejo and GitLab report (`success`, `failed`, `pending`, `canceled`...) as `passed`, `failed`, `running`, `queued` or `skipped`.
|
|
65
|
+
- `keyValue(pairs)` aligns `label value` lines, `sparkline(values)` draws `▁▃▇`, `bar(ratio)` and `barChart(rows)` draw cyan bars on a dimmed track, and `tree(roots)` hangs `{ label, children? }` nodes on dimmed `├─`, `└─` branches.
|
|
66
|
+
- `@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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.8.0",
|
|
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,234 @@
|
|
|
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), examples: Example[] = []): string {
|
|
60
|
+
return [usageLine(usage, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An `Examples:` section, after a blank line, with a line per example; nothing without one.
|
|
64
|
+
export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
|
|
65
|
+
if (examples.length === 0)
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A line per option, `-n, --times <value> how many`, aligned on the visible text.
|
|
72
|
+
export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
|
|
73
|
+
const rows = Object.entries(options).map(([name, option]) => {
|
|
74
|
+
const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
|
|
75
|
+
const value = option.type === 'string' ? ` ${colors.dim('<value>')}` : ''
|
|
76
|
+
|
|
77
|
+
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? ' <value>'.length : 0), description: option.description }
|
|
78
|
+
})
|
|
79
|
+
const width = Math.max(0, ...rows.map(row => row.width))
|
|
80
|
+
|
|
81
|
+
return rows.map(row => ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
|
|
85
|
+
type Slots<Names extends readonly string[]> = Names extends readonly [infer First extends string, ...infer Others extends readonly string[]]
|
|
86
|
+
? First extends `${string}...` | `${string}...]` ? string[] : First extends `[${string}` ? [string?, ...Slots<Others>] : [string, ...Slots<Others>]
|
|
87
|
+
: Names extends readonly [] ? [] : string[]
|
|
88
|
+
|
|
89
|
+
export interface ParseSettings<Names extends readonly string[] = string[]> {
|
|
90
|
+
// Stops at the first `--` and hands what follows back untouched as `rest`, for a command that runs another (`out -n 200 -- <cmd>`).
|
|
91
|
+
rest?: boolean
|
|
92
|
+
// Takes `--no-<flag>` as `false` for a boolean flag, as parseArgs' own `allowNegative` does.
|
|
93
|
+
allowNegative?: boolean
|
|
94
|
+
// 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.
|
|
95
|
+
positionals?: Names
|
|
96
|
+
// 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']`.
|
|
97
|
+
examples?: Example[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type Example = string | [line: string, note: string]
|
|
101
|
+
|
|
102
|
+
// The output flags every command takes, before its own help.
|
|
103
|
+
export const OUTPUT_OPTIONS = {
|
|
104
|
+
color: { type: 'string', description: 'auto (on a terminal alone), always or never' },
|
|
105
|
+
plain: { type: 'boolean', description: 'plain text, no colour or spinner: --color never' },
|
|
106
|
+
} as const satisfies Options
|
|
107
|
+
|
|
108
|
+
// Applies --color or --plain to the environment, where the kit's colours and redrawn lines read it and so do the processes a command starts; `auto` leaves it as it is.
|
|
109
|
+
export function applyOutput(color: string | undefined, plain: boolean | undefined, env: Record<string, string | undefined> = process.env): void {
|
|
110
|
+
const when = plain === true ? 'never' : color ?? 'auto'
|
|
111
|
+
|
|
112
|
+
if (when === 'auto')
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if (when !== 'always' && when !== 'never')
|
|
116
|
+
badUsage(`--color takes auto, always or never, not ${when}`)
|
|
117
|
+
|
|
118
|
+
// FORCE_COLOR wins over NO_COLOR for the colours, so each choice drops the other.
|
|
119
|
+
if (when === 'always') {
|
|
120
|
+
env.FORCE_COLOR = '1'
|
|
121
|
+
delete env.NO_COLOR
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
env.NO_COLOR = '1'
|
|
125
|
+
delete env.FORCE_COLOR
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Parses a command's arguments; on --help, prints its help and throws HelpShown, which ends the command with exit 0.
|
|
130
|
+
export function parse<const T extends Options, const Names extends readonly string[] = string[]>(args: string[], usage: string, options: T, settings: ParseSettings<Names> = {}) {
|
|
131
|
+
// A command's own --color or --plain is its own to read.
|
|
132
|
+
const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options))) as Omit<typeof OUTPUT_OPTIONS, keyof T>
|
|
133
|
+
const all = { ...options, ...output, help: { type: 'boolean', short: 'h', description: 'show this help' } } as const
|
|
134
|
+
|
|
135
|
+
if (completion.active)
|
|
136
|
+
throw new Completing(all)
|
|
137
|
+
|
|
138
|
+
const separator = settings.rest === true ? args.indexOf('--') : -1
|
|
139
|
+
const own = separator === -1 ? args : args.slice(0, separator)
|
|
140
|
+
const rest = separator === -1 ? undefined : args.slice(separator + 1)
|
|
141
|
+
|
|
142
|
+
let parsed
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
badUsage(parseError(error))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const values = parsed.values as { help?: boolean, color?: unknown, plain?: unknown }
|
|
152
|
+
|
|
153
|
+
applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
|
|
154
|
+
|
|
155
|
+
if (values.help === true) {
|
|
156
|
+
console.log(helpText(usage, all, undefined, settings.examples))
|
|
157
|
+
|
|
158
|
+
throw new HelpShown()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (settings.positionals !== undefined)
|
|
162
|
+
checkPositionals(parsed.positionals, settings.positionals)
|
|
163
|
+
|
|
164
|
+
return { ...parsed, positionals: parsed.positionals as Slots<Names>, rest }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function checkPositionals(given: string[], names: readonly string[]): void {
|
|
168
|
+
const variadic = names.at(-1)?.replace(']', '').endsWith('...') === true
|
|
169
|
+
const required = names.filter(name => !name.startsWith('[')).length
|
|
170
|
+
|
|
171
|
+
if (given.length < required)
|
|
172
|
+
badUsage(`missing ${names[given.length]}`)
|
|
173
|
+
|
|
174
|
+
if (!variadic && given.length > names.length)
|
|
175
|
+
badUsage(`unexpected argument ${given[names.length]}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// parseArgs' message, its first sentence alone and lowercased as the kit's own: `unknown option '--nope'`.
|
|
179
|
+
function parseError(error: unknown): string {
|
|
180
|
+
const message = upTo(error instanceof Error ? error.message : String(error), '. ')
|
|
181
|
+
|
|
182
|
+
return `${message.charAt(0).toLowerCase()}${message.slice(1)}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
|
|
186
|
+
if (value === undefined)
|
|
187
|
+
return fallback
|
|
188
|
+
|
|
189
|
+
const number = Number(value)
|
|
190
|
+
|
|
191
|
+
if (!Number.isInteger(number) || number < min)
|
|
192
|
+
badUsage(`--${name} takes a whole number from ${min}, not ${value}`)
|
|
193
|
+
|
|
194
|
+
return number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const DURATION = /^(\d+(?:\.\d+)?)(ms|[smh])?$/
|
|
198
|
+
const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 } as const
|
|
199
|
+
|
|
200
|
+
// A duration such as `500ms`, `90s`, `5m` or `1h` in milliseconds, a bare number counting in `unit`.
|
|
201
|
+
export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' = 's'): number {
|
|
202
|
+
if (value === undefined)
|
|
203
|
+
return fallback
|
|
204
|
+
|
|
205
|
+
const match = DURATION.exec(value.trim())
|
|
206
|
+
const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
|
|
207
|
+
|
|
208
|
+
if (ms < 1)
|
|
209
|
+
badUsage(`--${name} takes a duration such as 30s, 5m or 1h, not ${value}`)
|
|
210
|
+
|
|
211
|
+
return ms
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface InputSettings {
|
|
215
|
+
// What to say when stdin is a terminal, as nothing is piped.
|
|
216
|
+
noStdin?: string
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
|
|
220
|
+
export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
|
|
221
|
+
if (path === '-') {
|
|
222
|
+
if (process.stdin.isTTY)
|
|
223
|
+
fail(noStdin)
|
|
224
|
+
|
|
225
|
+
return Bun.stdin.text()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const file = Bun.file(path)
|
|
229
|
+
|
|
230
|
+
if (!await file.exists())
|
|
231
|
+
fail(`no file ${path}`)
|
|
232
|
+
|
|
233
|
+
return file.text()
|
|
234
|
+
}
|
package/src/chart.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TreeNode {
|
|
64
|
+
label: string
|
|
65
|
+
children?: TreeNode[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Each root on a line of its own and its children under it, hung on dimmed `├─`, `└─` and `│` branches.
|
|
69
|
+
export function tree(roots: TreeNode[], { stream = process.stdout, env = process.env }: KeyValueSettings = {}): string {
|
|
70
|
+
const colors = paint(stream, env)
|
|
71
|
+
const lines: string[] = []
|
|
72
|
+
|
|
73
|
+
function branch(nodes: TreeNode[], indent: string) {
|
|
74
|
+
for (const [index, { label, children = [] }] of nodes.entries()) {
|
|
75
|
+
const last = index === nodes.length - 1
|
|
76
|
+
|
|
77
|
+
lines.push(`${colors.dim(`${indent}${last ? '└─' : '├─'}`)} ${label}`)
|
|
78
|
+
branch(children, `${indent}${last ? ' ' : '│ '}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const { label, children = [] } of roots) {
|
|
83
|
+
lines.push(label)
|
|
84
|
+
branch(children, '')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return lines.join('\n')
|
|
88
|
+
}
|