@kaynooo/cli-kit 0.18.0 → 0.20.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
@@ -42,6 +42,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
42
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`.
43
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.
44
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.
45
+ - `runCli` and `runMain` record each run in `$XDG_STATE_HOME/kaynooo-cli/usage.jsonl` (`~/.local/state/...`, 0600): the bin, its command, the flag names it saw (never a value or an argument), the exit code and the unknown option of a usage error. `KAYNOOO_CLI_NO_USAGE=1` stops it, `NODE_ENV=test` too, and `usage: false` on the `Cli` or `Main` opts a bin out (`guard`, run on every tool call). `readUsage` and `summarizeUsage` read it, `cli stats` shows it.
45
46
  - `<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
47
  - `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
48
  - `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.
@@ -56,7 +57,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
56
57
  - `--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.
57
58
  - `replaces` on the CLI or in `parse`'s settings (`'sed -n N,Mp, grep -n chains'`) adds an `Instead of:` line under the usage. With `KIT_HELP=json` in the environment, `--help` prints `{synopsis, summary, replaces?, examples}` instead, the usage's first line split at its first `: `, which is how `cli list` reads every bin.
58
59
  - `runHint(command, effect?)` words the hint of a refusal that a command resolves, ``run `ft take 12 --take-stale` to take it over``, so every CLI writes it alike: `fail(message, 1, runHint('ft take 12 --take-stale', 'take it over'))`. `printHint(hint)` prints the `hint:` line under a refusal a command prints itself (one issue of several refused).
59
- - `limitOptions(fallback)` is the `--limit <n>` and `--full` flags of a listing, `limitOf(values, fallback)` the row count they ask for (Infinity with `--full`; with `json: true` in the values, Infinity unless `--limit` is given, so JSON is never cut on its own), and `capped(rows, limit)` the first rows plus the footer `N more, rerun with --full` when some are left out.
60
+ - `limitOptions(fallback)` is the `--limit <n>` (`-n`) and `--full` flags of a listing, `limitOf(values, fallback)` the row count they ask for (Infinity with `--full`; with `json: true` in the values, Infinity unless `--limit` is given, so JSON is never cut on its own), and `capped(rows, limit)` the first rows plus the footer `N more, rerun with --full` when some are left out.
60
61
  - `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, bare, with no envelope. Under `--json` runMain and runCli print a thrown error as `{"error", "hint"?, "cause"?}` on stderr instead of the `error:` line, its exit code unchanged; `printJsonError(message, hint?, cause?)` prints one for a command reporting a failure itself.
61
62
  - `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
62
63
  - `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`).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
3
  "type": "module",
4
- "version": "0.18.0",
4
+ "version": "0.20.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
@@ -1,11 +1,12 @@
1
1
  import type { ParseArgsOptionDescriptor } from 'node:util'
2
- import { fstatSync } from 'node:fs'
3
2
  import process from 'node:process'
4
3
  import { parseArgs } from 'node:util'
5
4
  import { upTo } from './format'
6
- import { paint } from './style'
5
+ import { description, helpText, wantsHelpJson } from './help'
7
6
  import { closest } from './text'
8
7
 
8
+ const DASHES = /^-+/
9
+
9
10
  interface OptionExtras {
10
11
  description?: string
11
12
  // 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.
@@ -69,77 +70,6 @@ export type Candidates = () => Candidate[] | Promise<Candidate[]>
69
70
 
70
71
  export const completion = { active: false }
71
72
 
72
- // Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
73
- const OPTIONAL = /\[[^\]]+\]/g
74
- const USAGE_PREFIX = /^usage:\s*/i
75
- const FULL_STOP = /\.$/
76
- const DASHES = /^-+/
77
-
78
- // `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
79
- export function usageLine(usage: string, colors = paint(process.stdout)): string {
80
- return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
81
- }
82
-
83
- export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces }: Described = {}): string {
84
- return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
85
- }
86
-
87
- // An `Instead of:` line, after a blank line; nothing without one.
88
- export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
89
- return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
90
- }
91
-
92
- // What a bin's --help prints under KIT_HELP=json, for `cli list` to read without parsing the text.
93
- export interface Description {
94
- synopsis: string
95
- summary: string
96
- replaces?: string
97
- examples: { line: string, note?: string }[]
98
- }
99
-
100
- export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
101
- return env.KIT_HELP === 'json'
102
- }
103
-
104
- // The synopsis and the summary a usage's first line splits into at its first `: `, else the first line after the synopses as the summary, with the examples spelled out.
105
- export function description(usage: string, { examples = [], replaces }: Described = {}): Description {
106
- const lines = usage.replace(USAGE_PREFIX, '').split('\n')
107
- const first = lines[0]!
108
- const colon = first.indexOf(': ')
109
- const prose = lines[lines.indexOf('') + 1] ?? ''
110
- const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
111
-
112
- return {
113
- synopsis: colon === -1 ? first : first.slice(0, colon),
114
- summary: colon === -1 ? fallback : first.slice(colon + 2),
115
- ...replaces === undefined ? {} : { replaces },
116
- examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
117
- }
118
- }
119
-
120
- // An `Examples:` section, after a blank line, with a line per example; nothing without one.
121
- export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
122
- if (examples.length === 0)
123
- return []
124
-
125
- return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
126
- }
127
-
128
- // A line per option, `-n, --times <value> how many`, aligned on the visible text.
129
- export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
130
- const rows = Object.entries(options).map(([name, option]) => {
131
- const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
132
- const named = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
133
- const placeholder = option.optional === undefined ? named : `[${named}]`
134
- const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
135
-
136
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
137
- })
138
- const width = Math.max(0, ...rows.map(row => row.width))
139
-
140
- return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
141
- }
142
-
143
73
  // The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
144
74
  type Slots<Names extends readonly string[]> = Names extends readonly [infer First extends string, ...infer Others extends readonly string[]]
145
75
  ? First extends `${string}...` | `${string}...]` ? string[] : First extends `[${string}` ? [string?, ...Slots<Others>] : [string, ...Slots<Others>]
@@ -174,7 +104,7 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
174
104
  export type Example = string | [line: string, note: string]
175
105
 
176
106
  // The parts of the settings the help and the description show.
177
- interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
107
+ export interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
178
108
 
179
109
  // The output flags every command takes, before its own help.
180
110
  export const OUTPUT_OPTIONS = {
@@ -419,63 +349,5 @@ function nearestOption(message: string, options: Options, allowNegative: boolean
419
349
  return meant === undefined ? undefined : `did you mean ${known.find(flag => flag.replace(DASHES, '') === meant)}?`
420
350
  }
421
351
 
422
- const WHOLE = /^\d+$/
423
-
424
- // A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
425
- export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
426
- if (value === undefined)
427
- return fallback
428
-
429
- const number = Number(value)
430
-
431
- if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
432
- badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
433
-
434
- return number
435
- }
436
-
437
- const DURATION = /^(\d+(?:\.\d+)?)(ms|[smhd])?$/
438
- const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
439
-
440
- // A duration such as `500ms`, `90s`, `5m`, `1h` or `7d` in milliseconds, a bare number counting in `unit`.
441
- export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' | 'h' = 's'): number {
442
- if (value === undefined)
443
- return fallback
444
-
445
- const match = DURATION.exec(value.trim())
446
- const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
447
-
448
- if (ms < 1)
449
- badUsage(`--${name} takes a duration such as 30s, 5m, 1h or 7d, not ${value}`)
450
-
451
- return ms
452
- }
453
-
454
- // Whether stdin is a pipe or a file to read, rather than a terminal or /dev/null that would leave a command waiting or reading nothing.
455
- export function isStdinPiped(): boolean {
456
- const stat = fstatSync(0)
457
-
458
- return stat.isFIFO() || stat.isFile()
459
- }
460
-
461
- export interface InputSettings {
462
- // What to say when stdin is a terminal, as nothing is piped.
463
- noStdin?: string
464
- }
465
-
466
- // Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
467
- export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
468
- if (path === '-') {
469
- if (process.stdin.isTTY)
470
- fail(noStdin)
471
-
472
- return Bun.stdin.text()
473
- }
474
-
475
- const file = Bun.file(path)
476
-
477
- if (!await file.exists())
478
- fail(`no file ${path}`)
479
-
480
- return file.text()
481
- }
352
+ export * from './help'
353
+ export * from './input'
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import process from 'node:process'
4
4
  import { applyOutput, Completing, completion, description, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, replacesRows, UsageError, usageLine, wantsHelpJson } from './args'
5
5
  import { paint, takesControlCodes } from './style'
6
6
  import { closest } from './text'
7
+ import { noteUsageError, recordUsage, usageEntry } from './usage'
7
8
 
8
9
  // A command's one-line summary, or a function reading it (a lazily loaded module) that only a listing of the command awaits, never a run.
9
10
  export type Summary = string | (() => Promise<string>)
@@ -42,6 +43,8 @@ export interface Cli {
42
43
  runBare?: boolean
43
44
  // Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
44
45
  redact?: (text: string) => string
46
+ // Set to false for a bin run on every tool call, which would fill the usage log (`guard hook`).
47
+ usage?: false
45
48
  // Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
46
49
  examples?: Example[]
47
50
  // What the CLI is reached for instead of, shown as `Instead of:` in `<cli> --help`.
@@ -214,6 +217,9 @@ function reportThrown(prefix: string, error: unknown, redact: (text: string) =>
214
217
  const named = error instanceof Error && !(error instanceof Exit) && error.name !== 'Error' ? `${error.name}: ${error.message}` : undefined
215
218
  const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined
216
219
 
220
+ if (error instanceof UsageError)
221
+ noteUsageError(error.message)
222
+
217
223
  const report = json ? printJsonError : (message: string, hint?: string, causeText?: string) => printError(prefix, message, hint, causeText)
218
224
 
219
225
  report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
@@ -427,7 +433,39 @@ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<nu
427
433
  if (argv[0] === COMPLETE)
428
434
  return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
429
435
 
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 })
436
+ const code = await 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 })
437
+
438
+ if (cli.usage !== false) {
439
+ try {
440
+ recordUsage(usageEntry(cli.name, commandPath(scopeOf(cli.commands, cli.extraCommands, cli.aliases), argv), argv, code))
441
+ }
442
+ catch {}
443
+ }
444
+
445
+ return code
446
+ }
447
+
448
+ // The command words the arguments start with, a group's subcommand included; lazy commands are not loaded for it.
449
+ function commandPath(scope: Pick<Level, 'commands' | 'aliases'>, argv: readonly string[]): string | undefined {
450
+ const path: string[] = []
451
+ let level = scope
452
+
453
+ for (const word of argv.filter(each => !each.startsWith('-'))) {
454
+ const name = level.aliases !== undefined && Object.hasOwn(level.aliases, word) ? (level.aliases[word] ?? word) : word
455
+ const command: Level['commands'][string] | undefined = Object.hasOwn(level.commands, name) ? level.commands[name] : undefined
456
+
457
+ if (command === undefined)
458
+ break
459
+
460
+ path.push(word)
461
+
462
+ if (!('commands' in command))
463
+ break
464
+
465
+ level = scopeOf(command.commands, command.extraCommands, command.aliases)
466
+ }
467
+
468
+ return path.length === 0 ? undefined : path.join(' ')
431
469
  }
432
470
 
433
471
  export interface Main {
@@ -435,6 +473,7 @@ export interface Main {
435
473
  version: string
436
474
  run: (args: string[]) => Promise<number>
437
475
  redact?: (text: string) => string
476
+ usage?: false
438
477
  }
439
478
 
440
479
  // 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.
@@ -454,7 +493,7 @@ export async function mainArgs<T>(main: Omit<Main, 'run'>, parseArgv: (args: str
454
493
  }
455
494
 
456
495
  // 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.
457
- export async function runMain({ name, version, run, redact }: Main, argv = process.argv.slice(2)): Promise<number> {
496
+ export async function runMain({ name, version, run, redact, usage }: Main, argv = process.argv.slice(2)): Promise<number> {
458
497
  if (argv[0] === COMPLETE)
459
498
  return printCandidates(leafCandidates(run, argv.slice(1)))
460
499
 
@@ -464,10 +503,17 @@ export async function runMain({ name, version, run, redact }: Main, argv = proce
464
503
  return 0
465
504
  }
466
505
 
506
+ let code: number
507
+
467
508
  try {
468
- return await run(argv)
509
+ code = await run(argv)
469
510
  }
470
511
  catch (error) {
471
- return reportThrown(name, error, redact, wantsJson(argv))
512
+ code = reportThrown(name, error, redact, wantsJson(argv))
472
513
  }
514
+
515
+ if (usage !== false)
516
+ recordUsage(usageEntry(name, undefined, argv, code))
517
+
518
+ return code
473
519
  }
package/src/help.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { Described, Example, Options } from './args'
2
+ import process from 'node:process'
3
+ import { paint } from './style'
4
+
5
+ // Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
6
+ const OPTIONAL = /\[[^\]]+\]/g
7
+ const USAGE_PREFIX = /^usage:\s*/i
8
+ const FULL_STOP = /\.$/
9
+
10
+ // `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
11
+ export function usageLine(usage: string, colors = paint(process.stdout)): string {
12
+ return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
13
+ }
14
+
15
+ export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces }: Described = {}): string {
16
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
17
+ }
18
+
19
+ // An `Instead of:` line, after a blank line; nothing without one.
20
+ export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
21
+ return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
22
+ }
23
+
24
+ // What a bin's --help prints under KIT_HELP=json, for `cli list` to read without parsing the text.
25
+ export interface Description {
26
+ synopsis: string
27
+ summary: string
28
+ replaces?: string
29
+ examples: { line: string, note?: string }[]
30
+ }
31
+
32
+ export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
33
+ return env.KIT_HELP === 'json'
34
+ }
35
+
36
+ // The synopsis and the summary a usage's first line splits into at its first `: `, else the first line after the synopses as the summary, with the examples spelled out.
37
+ export function description(usage: string, { examples = [], replaces }: Described = {}): Description {
38
+ const lines = usage.replace(USAGE_PREFIX, '').split('\n')
39
+ const first = lines[0]!
40
+ const colon = first.indexOf(': ')
41
+ const prose = lines[lines.indexOf('') + 1] ?? ''
42
+ const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
43
+
44
+ return {
45
+ synopsis: colon === -1 ? first : first.slice(0, colon),
46
+ summary: colon === -1 ? fallback : first.slice(colon + 2),
47
+ ...replaces === undefined ? {} : { replaces },
48
+ examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
49
+ }
50
+ }
51
+
52
+ // An `Examples:` section, after a blank line, with a line per example; nothing without one.
53
+ export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
54
+ if (examples.length === 0)
55
+ return []
56
+
57
+ return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
58
+ }
59
+
60
+ // A line per option, `-n, --times <value> how many`, aligned on the visible text.
61
+ export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
62
+ const rows = Object.entries(options).map(([name, option]) => {
63
+ const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
64
+ const named = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
65
+ const placeholder = option.optional === undefined ? named : `[${named}]`
66
+ const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
67
+
68
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
69
+ })
70
+ const width = Math.max(0, ...rows.map(row => row.width))
71
+
72
+ return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
73
+ }
package/src/index.ts CHANGED
@@ -13,3 +13,4 @@ export * from './sessions'
13
13
  export * from './style'
14
14
  export * from './text'
15
15
  export * from './tool'
16
+ export * from './usage'
package/src/input.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { fstatSync } from 'node:fs'
2
+ import process from 'node:process'
3
+ import { badUsage, fail } from './args'
4
+
5
+ const WHOLE = /^\d+$/
6
+
7
+ // A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
8
+ export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
9
+ if (value === undefined)
10
+ return fallback
11
+
12
+ const number = Number(value)
13
+
14
+ if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
15
+ badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
16
+
17
+ return number
18
+ }
19
+
20
+ const DURATION = /^(\d+(?:\.\d+)?)(ms|[smhd])?$/
21
+ const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
22
+
23
+ // A duration such as `500ms`, `90s`, `5m`, `1h` or `7d` in milliseconds, a bare number counting in `unit`.
24
+ export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' | 'h' = 's'): number {
25
+ if (value === undefined)
26
+ return fallback
27
+
28
+ const match = DURATION.exec(value.trim())
29
+ const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
30
+
31
+ if (ms < 1)
32
+ badUsage(`--${name} takes a duration such as 30s, 5m, 1h or 7d, not ${value}`)
33
+
34
+ return ms
35
+ }
36
+
37
+ // Whether stdin is a pipe or a file to read, rather than a terminal or /dev/null that would leave a command waiting or reading nothing.
38
+ export function isStdinPiped(): boolean {
39
+ const stat = fstatSync(0)
40
+
41
+ return stat.isFIFO() || stat.isFile()
42
+ }
43
+
44
+ export interface InputSettings {
45
+ // What to say when stdin is a terminal, as nothing is piped.
46
+ noStdin?: string
47
+ }
48
+
49
+ // Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
50
+ export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
51
+ if (path === '-') {
52
+ if (process.stdin.isTTY)
53
+ fail(noStdin)
54
+
55
+ return Bun.stdin.text()
56
+ }
57
+
58
+ const file = Bun.file(path)
59
+
60
+ if (!await file.exists())
61
+ fail(`no file ${path}`)
62
+
63
+ return file.text()
64
+ }
package/src/limit.ts CHANGED
@@ -3,7 +3,7 @@ import { badUsage, wholeNumber } from './args'
3
3
  // The `--limit` and `--full` flags every listing words alike, `fallback` being the rows shown without either.
4
4
  export function limitOptions(fallback: number) {
5
5
  return {
6
- limit: { type: 'string', description: `show at most this many rows, the rest counted (${fallback})` },
6
+ limit: { type: 'string', short: 'n', description: `show at most this many rows, the rest counted (${fallback})` },
7
7
  full: { type: 'boolean', description: 'show every row' },
8
8
  } as const
9
9
  }
package/src/shell.ts CHANGED
@@ -162,6 +162,13 @@ export function splitCommand(command: string): Piece[] {
162
162
  continue
163
163
  }
164
164
 
165
+ // A backquoted body is read whole, as its own separators split nothing of the command around it.
166
+ if (char === '`') {
167
+ i = closingBackquote(command, i)
168
+
169
+ continue
170
+ }
171
+
165
172
  if (char === '#' && startsComment(command, i)) {
166
173
  const newline = command.indexOf('\n', i)
167
174
  const end = newline === -1 ? command.length : newline
package/src/usage.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, isAbsolute, join } from 'node:path'
4
+ import process from 'node:process'
5
+ import { CLI_FOLDER } from './logs'
6
+
7
+ // One run of a bin: which command it ran, the names of the flags it saw (never a value or an argument), how it exited and, on a usage error, the option it did not know.
8
+ export interface UsageEntry {
9
+ at: string
10
+ bin: string
11
+ command?: string
12
+ flags: string[]
13
+ code: number
14
+ unknown?: string
15
+ }
16
+
17
+ const UNKNOWN_OPTION = /unknown option '(--[a-z][a-z0-9-]*)[=']/
18
+ // A long name, or a single letter: anything else is an argument that happens to start with a dash, such as `-secret`.
19
+ const FLAG_NAME = /^(?:--[a-z][a-z0-9-]*|-[a-zA-Z])$/
20
+ const LONGEST_NAME = 40
21
+
22
+ // Set when a run reported a usage error, so the entry of that run can name the option.
23
+ let usageError: string | undefined
24
+
25
+ export function noteUsageError(message: string): void {
26
+ usageError = message
27
+ }
28
+
29
+ // XDG_STATE_HOME when it is an absolute path, else ~/.local/state, as the spec says an empty or relative value is ignored.
30
+ export function stateHome(env: Record<string, string | undefined> = process.env): string {
31
+ const dir = env.XDG_STATE_HOME
32
+
33
+ return dir !== undefined && isAbsolute(dir) ? dir : join(homedir(), '.local', 'state')
34
+ }
35
+
36
+ export function usagePath(env: Record<string, string | undefined> = process.env): string {
37
+ return join(stateHome(env), CLI_FOLDER, 'usage.jsonl')
38
+ }
39
+
40
+ // Whether runs are recorded: never under `bun test`, and not once KAYNOOO_CLI_NO_USAGE is set to anything but 0 or an empty value.
41
+ export function usageRecorded(env: Record<string, string | undefined> = process.env): boolean {
42
+ const off = env.KAYNOOO_CLI_NO_USAGE
43
+
44
+ return env.NODE_ENV !== 'test' && (off === undefined || off === '' || off === '0')
45
+ }
46
+
47
+ // The flags among the arguments by name, `--limit=5` as `--limit`, stopping at `--`.
48
+ export function flagNames(argv: readonly string[]): string[] {
49
+ const end = argv.indexOf('--')
50
+ const names = (end === -1 ? argv : argv.slice(0, end)).filter(word => word.startsWith('-') && word !== '-').map(word => word.includes('=') ? word.slice(0, word.indexOf('=')) : word)
51
+
52
+ return [...new Set(names)].filter(name => name.length <= LONGEST_NAME && FLAG_NAME.test(name))
53
+ }
54
+
55
+ // The entry for a run that exited with `code`, with the unknown option a usage error named.
56
+ export function usageEntry(bin: string, command: string | undefined, argv: readonly string[], code: number, now = new Date()): UsageEntry {
57
+ const unknown = code === 2 && usageError !== undefined ? UNKNOWN_OPTION.exec(usageError)?.[1] : undefined
58
+
59
+ usageError = undefined
60
+
61
+ return { at: now.toISOString(), bin, ...(command === undefined ? {} : { command }), flags: flagNames(argv), code, ...(unknown === undefined ? {} : { unknown }) }
62
+ }
63
+
64
+ // Appends the run to the usage log, best effort: a log that cannot be written never changes what a bin prints or exits with.
65
+ export function recordUsage(entry: UsageEntry, env: Record<string, string | undefined> = process.env): void {
66
+ if (!usageRecorded(env))
67
+ return
68
+
69
+ try {
70
+ const path = usagePath(env)
71
+
72
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
73
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 })
74
+ }
75
+ catch {}
76
+ }
77
+
78
+ export async function readUsage(path = usagePath(), since = 0): Promise<UsageEntry[]> {
79
+ const file = Bun.file(path)
80
+
81
+ if (!await file.exists())
82
+ return []
83
+
84
+ return (await file.text()).split('\n').flatMap((line) => {
85
+ try {
86
+ const entry = JSON.parse(line) as UsageEntry
87
+
88
+ return Date.parse(entry.at) >= since ? [entry] : []
89
+ }
90
+ catch {
91
+ return []
92
+ }
93
+ })
94
+ }
95
+
96
+ export interface UsageSummary {
97
+ runs: number
98
+ bins: { bin: string, runs: number, failed: number, usageErrors: number }[]
99
+ usageErrors: { bin: string, command?: string, unknown: string, count: number }[]
100
+ }
101
+
102
+ // Runs per bin, and the usage errors by the option that was not known, most common first.
103
+ export function summarizeUsage(entries: readonly UsageEntry[]): UsageSummary {
104
+ const bins = new Map<string, { runs: number, failed: number, usageErrors: number }>()
105
+ const errors = new Map<string, { bin: string, command?: string, unknown: string, count: number }>()
106
+
107
+ for (const entry of entries) {
108
+ const bin = bins.get(entry.bin) ?? { runs: 0, failed: 0, usageErrors: 0 }
109
+
110
+ bin.runs++
111
+ bin.failed += entry.code === 0 ? 0 : 1
112
+ bin.usageErrors += entry.code === 2 ? 1 : 0
113
+ bins.set(entry.bin, bin)
114
+
115
+ if (entry.code === 2 && entry.unknown !== undefined) {
116
+ const key = `${entry.bin} ${entry.command ?? ''} ${entry.unknown}`
117
+ const error = errors.get(key) ?? { bin: entry.bin, ...(entry.command === undefined ? {} : { command: entry.command }), unknown: entry.unknown, count: 0 }
118
+
119
+ error.count++
120
+ errors.set(key, error)
121
+ }
122
+ }
123
+
124
+ return {
125
+ runs: entries.length,
126
+ bins: [...bins].map(([bin, counts]) => ({ bin, ...counts })).toSorted((a, b) => b.runs - a.runs),
127
+ usageErrors: [...errors.values()].toSorted((a, b) => b.count - a.count),
128
+ }
129
+ }