@kaynooo/cli-kit 0.8.1 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Kaynooo <kaynooo@kevyn.fr>
3
+ Copyright (c) 2026 Kaynooo <contact@kaynooo.fr>
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -6,7 +6,7 @@ What every @kaynooo CLI shares, so each one parses, helps and fails the same way
6
6
  bun add @kaynooo/cli-kit
7
7
  ```
8
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.
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 npmjs as a dependency (`bun add --exact @kaynooo/cli-kit`).
10
10
 
11
11
  ```ts
12
12
  import { parse, runCli } from '@kaynooo/cli-kit'
@@ -33,10 +33,11 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
33
33
  ## API
34
34
 
35
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.
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. A string option takes a next argument starting with a dash as its value (`--from "- item"`, `-n -3`) unless that argument names one of the command's options, which parseArgs alone refuses as ambiguous. `{ positionals: ['<key>', '[file]'] }` names the positionals the command takes: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more; one missing (`missing <key>`) or one too many (`unexpected argument x`) is a usage error, and `positionals` comes back typed as `[string, string?]`. `fail(message, code = 2, hint?)` ends a command, the hint printed under the error; `badUsage(message)` ends it with a `UsageError`, which `parse`, `wholeNumber` and `duration` throw too and which gets the hint `run <cli> <command> --help for its options`; `wholeNumber` reads a numeric option.
37
37
  - `table`, `truncate`, `formatSeconds`, `formatAge` and `upTo` format output.
38
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
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
+ - 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.
40
41
  - `<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
42
  - `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
43
  - `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.
@@ -45,7 +46,10 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
45
46
  - `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
47
  - 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
48
  - `--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
+ - `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 `agent-tools list` reads every bin.
50
+ - `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).
51
+ - `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.
52
+ - `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.
49
53
  - `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
50
54
  - `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
55
  - `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.
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
3
  "type": "module",
4
- "version": "0.8.1",
4
+ "version": "0.10.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
- "author": "Kaynooo <kaynooo@kevyn.fr>",
6
+ "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
8
- "homepage": "https://git.kevyn.fr/kaynooo/agent-tools/src/branch/main/packages/kit#readme",
8
+ "homepage": "https://git.kaynooo.fr/kaynooo/agent-tools/src/branch/main/packages/kit#readme",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://git.kevyn.fr/kaynooo/agent-tools.git",
11
+ "url": "git+https://git.kaynooo.fr/kaynooo/agent-tools.git",
12
12
  "directory": "packages/kit"
13
13
  },
14
- "bugs": "https://git.kevyn.fr/kaynooo/agent-tools/issues",
14
+ "bugs": "https://git.kaynooo.fr/kaynooo/agent-tools/issues",
15
15
  "keywords": [
16
16
  "args",
17
17
  "bun",
@@ -30,7 +30,7 @@
30
30
  "bun": ">=1.4.3"
31
31
  },
32
32
  "publishConfig": {
33
- "registry": "https://git.kevyn.fr/api/packages/kaynooo/npm/"
33
+ "registry": "https://git.kaynooo.fr/api/packages/kaynooo/npm/"
34
34
  },
35
35
  "dependencies": {
36
36
  "@kaynooo/utils": "^2.31.0"
package/src/args.ts CHANGED
@@ -3,8 +3,9 @@ import process from 'node:process'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { upTo } from './format'
5
5
  import { paint } from './style'
6
+ import { closest } from './text'
6
7
 
7
- export type Options = Record<string, ParseArgsOptionDescriptor & { description: string }>
8
+ export type Options = Record<string, ParseArgsOptionDescriptor & { description?: string }>
8
9
 
9
10
  // The `--json` flag every CLI words alike: `print JSON`, then what it holds when given.
10
11
  export function jsonOption(holds?: string) {
@@ -24,8 +25,8 @@ export function fail(message: string, code = 2, hint?: string): never {
24
25
 
25
26
  // An Exit for arguments the command cannot take, which runCli points at the command's --help.
26
27
  export class UsageError extends Exit {
27
- constructor(message: string) {
28
- super(message, 2)
28
+ constructor(message: string, hint?: string) {
29
+ super(message, 2, hint)
29
30
  }
30
31
  }
31
32
 
@@ -50,14 +51,49 @@ export const completion = { active: false }
50
51
  // Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
51
52
  const OPTIONAL = /\[[^\]]+\]/g
52
53
  const USAGE_PREFIX = /^usage:\s*/i
54
+ const FULL_STOP = /\.$/
55
+ const DASHES = /^-+/
53
56
 
54
57
  // `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
55
58
  export function usageLine(usage: string, colors = paint(process.stdout)): string {
56
59
  return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
57
60
  }
58
61
 
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')
62
+ export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces }: Described = {}): string {
63
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
64
+ }
65
+
66
+ // An `Instead of:` line, after a blank line; nothing without one.
67
+ export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
68
+ return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
69
+ }
70
+
71
+ // What a bin's --help prints under KIT_HELP=json, for `agent-tools list` to read without parsing the text.
72
+ export interface Description {
73
+ synopsis: string
74
+ summary: string
75
+ replaces?: string
76
+ examples: { line: string, note?: string }[]
77
+ }
78
+
79
+ export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
80
+ return env.KIT_HELP === 'json'
81
+ }
82
+
83
+ // 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.
84
+ export function description(usage: string, { examples = [], replaces }: Described = {}): Description {
85
+ const lines = usage.replace(USAGE_PREFIX, '').split('\n')
86
+ const first = lines[0]!
87
+ const colon = first.indexOf(': ')
88
+ const prose = lines[lines.indexOf('') + 1] ?? ''
89
+ const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
90
+
91
+ return {
92
+ synopsis: colon === -1 ? first : first.slice(0, colon),
93
+ summary: colon === -1 ? fallback : first.slice(colon + 2),
94
+ ...replaces === undefined ? {} : { replaces },
95
+ examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
96
+ }
61
97
  }
62
98
 
63
99
  // An `Examples:` section, after a blank line, with a line per example; nothing without one.
@@ -74,11 +110,11 @@ export function optionRows(options: Options, colors = paint(process.stdout)): st
74
110
  const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
75
111
  const value = option.type === 'string' ? ` ${colors.dim('<value>')}` : ''
76
112
 
77
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? ' <value>'.length : 0), description: option.description }
113
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? ' <value>'.length : 0), description: option.description ?? '' }
78
114
  })
79
115
  const width = Math.max(0, ...rows.map(row => row.width))
80
116
 
81
- return rows.map(row => ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
117
+ return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
82
118
  }
83
119
 
84
120
  // The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
@@ -95,10 +131,15 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
95
131
  positionals?: Names
96
132
  // 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
133
  examples?: Example[]
134
+ // What the command is reached for instead of, shown as `Instead of:` in the help and listed by `agent-tools list`: 'sed -n N,Mp, grep -n chains and whole-file reads'.
135
+ replaces?: string
98
136
  }
99
137
 
100
138
  export type Example = string | [line: string, note: string]
101
139
 
140
+ // The parts of the settings the help and the description show.
141
+ interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
142
+
102
143
  // The output flags every command takes, before its own help.
103
144
  export const OUTPUT_OPTIONS = {
104
145
  color: { type: 'string', description: 'auto (on a terminal alone), always or never' },
@@ -142,10 +183,12 @@ export function parse<const T extends Options, const Names extends readonly stri
142
183
  let parsed
143
184
 
144
185
  try {
145
- parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
186
+ parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
146
187
  }
147
188
  catch (error) {
148
- badUsage(parseError(error))
189
+ const message = parseError(error)
190
+
191
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true))
149
192
  }
150
193
 
151
194
  const values = parsed.values as { help?: boolean, color?: unknown, plain?: unknown }
@@ -153,7 +196,7 @@ export function parse<const T extends Options, const Names extends readonly stri
153
196
  applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
154
197
 
155
198
  if (values.help === true) {
156
- console.log(helpText(usage, all, undefined, settings.examples))
199
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings))
157
200
 
158
201
  throw new HelpShown()
159
202
  }
@@ -175,6 +218,33 @@ function checkPositionals(given: string[], names: readonly string[]): void {
175
218
  badUsage(`unexpected argument ${given[names.length]}`)
176
219
  }
177
220
 
221
+ // The arguments with a string option's value joined to it as `--name=value` when that value starts with a dash and names no option, which parseArgs would refuse as ambiguous: `sub --from "- item"`.
222
+ function joinDashValues(args: readonly string[], options: Options): string[] {
223
+ const longOf = new Map<string, string>(Object.entries(options).flatMap(([name, option]): [string, string][] => option.short === undefined ? [[`--${name}`, name]] : [[`--${name}`, name], [`-${option.short}`, name]]))
224
+ const isOption = (arg: string) => arg === '--' || longOf.has(arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg)
225
+ const joined: string[] = []
226
+
227
+ for (let i = 0; i < args.length; i++) {
228
+ const arg = args[i] ?? ''
229
+
230
+ if (arg === '--')
231
+ return [...joined, ...args.slice(i)]
232
+
233
+ const name = longOf.get(arg)
234
+ const next = args[i + 1]
235
+
236
+ if (name !== undefined && options[name]?.type === 'string' && next !== undefined && next.startsWith('-') && next !== '-' && !isOption(next)) {
237
+ joined.push(`--${name}=${next}`)
238
+ i++
239
+ }
240
+ else {
241
+ joined.push(arg)
242
+ }
243
+ }
244
+
245
+ return joined
246
+ }
247
+
178
248
  // parseArgs' message, its first sentence alone and lowercased as the kit's own: `unknown option '--nope'`.
179
249
  function parseError(error: unknown): string {
180
250
  const message = upTo(error instanceof Error ? error.message : String(error), '. ')
@@ -182,6 +252,30 @@ function parseError(error: unknown): string {
182
252
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`
183
253
  }
184
254
 
255
+ const UNKNOWN_OPTION = /^unknown option '(-[^']+)'/
256
+
257
+ // `did you mean --no-check?` for an unknown option within reach of a known one (shorts and `--no-<flag>` included), nothing when none is, so runCli keeps its `--help` hint.
258
+ function nearestOption(message: string, options: Options, allowNegative: boolean): string | undefined {
259
+ const typed = UNKNOWN_OPTION.exec(message)?.[1]
260
+
261
+ if (typed === undefined)
262
+ return undefined
263
+
264
+ const word = typed.replace(DASHES, '')
265
+
266
+ if (word.length < 2 && !typed.startsWith('--'))
267
+ return undefined
268
+
269
+ const known = Object.entries(options).flatMap(([name, option]) => [
270
+ ...option.short === undefined ? [] : [`-${option.short}`],
271
+ `--${name}`,
272
+ ...allowNegative && option.type === 'boolean' ? [`--no-${name}`] : [],
273
+ ])
274
+ const meant = closest(word, known.map(flag => flag.replace(DASHES, '')))
275
+
276
+ return meant === undefined ? undefined : `did you mean ${known.find(flag => flag.replace(DASHES, '') === meant)}?`
277
+ }
278
+
185
279
  export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
186
280
  if (value === undefined)
187
281
  return fallback
package/src/cli.ts CHANGED
@@ -1,18 +1,26 @@
1
1
  import type { Example, Options } from './args'
2
2
  import type { Colors } from './style'
3
3
  import process from 'node:process'
4
- import { applyOutput, Completing, completion, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, UsageError, usageLine } from './args'
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
- import { distance } from './text'
6
+ import { closest } from './text'
7
+
8
+ // 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
+ export type Summary = string | (() => Promise<string>)
10
+
11
+ export type Commands = Record<string, Command>
12
+
13
+ // Commands known only once something is read, called with the words typed so far from the CLI's name on, and only when a word is not a command the level already has, or for a help or `__complete`.
14
+ export type LazyCommands = (words: string[]) => Promise<Commands>
7
15
 
8
16
  export type Command = {
9
- summary: string
17
+ summary: Summary
10
18
  // The heading the command is listed under in the help, after those with none.
11
19
  group?: string
12
20
  } & (
13
21
  | { run: (args: string[]) => Promise<number> }
14
- // A group of subcommands dispatched the same way, with its own help.
15
- | { commands: Record<string, Command>, aliases?: Record<string, string>, examples?: Example[] }
22
+ // A group of subcommands dispatched the same way, with its own help; `commands` may be a function for a group made of lazy commands alone.
23
+ | { commands: Commands | LazyCommands, extraCommands?: LazyCommands, aliases?: Record<string, string>, examples?: Example[] }
16
24
  )
17
25
 
18
26
  export interface Cli {
@@ -20,7 +28,10 @@ export interface Cli {
20
28
  version: string
21
29
  // One line after `<name> <version>: ` in the help.
22
30
  about: string
23
- commands: Record<string, Command>
31
+ // The commands, or a function giving them all lazily.
32
+ commands: Commands | LazyCommands
33
+ // Lazy commands added to `commands`, which win a name they share.
34
+ extraCommands?: LazyCommands
24
35
  // Names sessions reach for that mean an existing command.
25
36
  aliases?: Record<string, string>
26
37
  // 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.
@@ -29,6 +40,8 @@ export interface Cli {
29
40
  redact?: (text: string) => string
30
41
  // Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
31
42
  examples?: Example[]
43
+ // What the CLI is reached for instead of, shown as `Instead of:` in `<cli> --help`.
44
+ replaces?: string
32
45
  }
33
46
 
34
47
  const LEVEL_OPTIONS = {
@@ -38,15 +51,16 @@ const LEVEL_OPTIONS = {
38
51
  const TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: 'boolean', short: 'v', description: 'print the version' } } as const satisfies Options
39
52
 
40
53
  // The rows under `Commands:`, then a heading per group in the order first met, names aligned across them all.
41
- function commandRows(commands: Record<string, Command>, colors: Colors): string[] {
54
+ async function commandRows(commands: Commands, colors: Colors): Promise<string[]> {
42
55
  const width = Math.max(...Object.keys(commands).map(name => name.length))
43
56
  const groups = new Map<string, string[]>([['Commands:', []]])
57
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)))
44
58
 
45
- for (const [name, { summary, group }] of Object.entries(commands)) {
59
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
46
60
  const heading = group === undefined ? 'Commands:' : `${group}:`
47
61
  const rows = groups.get(heading) ?? []
48
62
 
49
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`)
63
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index]!)}`)
50
64
  groups.set(heading, rows)
51
65
  }
52
66
 
@@ -73,43 +87,67 @@ function takeOutputFlags(argv: string[]): string[] {
73
87
  return argv.slice(index)
74
88
  }
75
89
 
76
- // A command the name starts, else one within two edits (one for a name under four letters).
77
- export function closest(name: string, names: string[]): string | undefined {
78
- const started = names.find(known => known.startsWith(name))
79
-
80
- if (started !== undefined)
81
- return started
82
-
83
- const [best] = names.map(known => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far)
84
-
85
- return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined
90
+ // What a level lists and dispatches: the commands it has, and the functions giving more once called.
91
+ interface Scope {
92
+ commands: Commands
93
+ lazy: LazyCommands[]
94
+ aliases: Record<string, string> | undefined
86
95
  }
87
96
 
88
- interface Level {
97
+ interface Level extends Scope {
89
98
  // The words typed to reach this level, `ft plan`.
90
99
  path: string[]
91
- header: (colors: Colors) => string
92
- commands: Record<string, Command>
93
- aliases: Record<string, string> | undefined
100
+ header: (colors: Colors, summary: string) => string
101
+ summary: Summary
94
102
  defaultCommand: string | undefined
95
103
  examples: Example[] | undefined
104
+ replaces: string | undefined
105
+ }
106
+
107
+ async function resolveSummary(summary: Summary): Promise<string> {
108
+ return typeof summary === 'string' ? summary : summary()
109
+ }
110
+
111
+ function scopeOf(commands: Commands | LazyCommands, extra: LazyCommands | undefined, aliases: Record<string, string> | undefined): Scope {
112
+ return { commands: typeof commands === 'function' ? {} : commands, lazy: [...typeof commands === 'function' ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases }
113
+ }
114
+
115
+ // The scope with its lazy commands called with the words typed so far and merged in, the ones it already has winning a shared name.
116
+ async function expand<T extends Scope>(scope: T, words: string[]): Promise<T> {
117
+ if (scope.lazy.length === 0)
118
+ return scope
119
+
120
+ const commands = { ...scope.commands }
121
+
122
+ for (const added of await Promise.all(scope.lazy.map(async lazy => lazy(words)))) {
123
+ for (const [name, command] of Object.entries(added))
124
+ commands[name] ??= command
125
+ }
126
+
127
+ return { ...scope, commands, lazy: [] }
96
128
  }
97
129
 
98
130
  // The level's help; a default command's options follow the commands, as they apply with no command named.
99
- async function help({ path, header, commands, defaultCommand, examples }: Level, stream: { isTTY?: boolean }): Promise<string> {
131
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }: Level, stream: { isTTY?: boolean }): Promise<string> {
100
132
  const colors = paint(stream)
101
133
  const prefix = path.join(' ')
134
+ const summary = await resolveSummary(lazySummary)
135
+
136
+ if (wantsHelpJson())
137
+ return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }))
138
+
102
139
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
103
140
  const options = fallback !== undefined && 'run' in fallback ? await optionsOf(fallback.run) : undefined
104
141
  // The default command's own run lists the output flags again, so they show once, under the level's.
105
142
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS))
106
143
 
107
144
  return [
108
- header(colors),
145
+ header(colors, summary),
109
146
  '',
110
147
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
148
+ ...replacesRows(replaces, colors),
111
149
  '',
112
- ...commandRows(commands, colors),
150
+ ...await commandRows(commands, colors),
113
151
  ...own.length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(Object.fromEntries(own), colors)],
114
152
  '',
115
153
  colors.bold('Options:'),
@@ -130,9 +168,33 @@ export function printError(prefix: string, message: string, hint?: string, cause
130
168
  console.error(`${head} ${message}${under('cause', cause, colors.dim)}${under('hint', hint, colors.yellow)}`)
131
169
  }
132
170
 
171
+ // The hint of a refusal a command resolves with another command, worded alike everywhere: ``run `ft take 12 --take-stale` ``, then what it does when that is not obvious.
172
+ export function runHint(command: string, effect?: string): string {
173
+ return `run \`${command}\`${effect === undefined ? '' : ` to ${effect}`}`
174
+ }
175
+
176
+ // A hint under a refusal a command prints itself, laid out as printError lays it.
177
+ export function printHint(hint: string): void {
178
+ const colors = paint(process.stderr)
179
+
180
+ console.error(` ${takesControlCodes(process.stderr) ? colors.yellow('hint') : 'hint:'} ${hint}`)
181
+ }
182
+
183
+ // An error as `{"error", "hint"?, "cause"?}` on stderr, for a command run with `--json`, so a caller parses its failures as it parses its output.
184
+ export function printJsonError(message: string, hint?: string, cause?: string): void {
185
+ console.error(JSON.stringify({ error: message, ...(hint === undefined ? {} : { hint }), ...(cause === undefined ? {} : { cause }) }))
186
+ }
187
+
188
+ // Whether the arguments ask for JSON, read before parsing so even a parse error prints as JSON.
189
+ function wantsJson(argv: readonly string[]): boolean {
190
+ const end = argv.indexOf('--')
191
+
192
+ return (end === -1 ? argv : argv.slice(0, end)).includes('--json')
193
+ }
194
+
133
195
  // 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.
134
196
  // 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.
135
- function reportThrown(prefix: string, error: unknown, redact: (text: string) => string = text => text): number {
197
+ function reportThrown(prefix: string, error: unknown, redact: (text: string) => string = text => text, json = false): number {
136
198
  if (error instanceof HelpShown)
137
199
  return 0
138
200
 
@@ -140,7 +202,9 @@ function reportThrown(prefix: string, error: unknown, redact: (text: string) =>
140
202
  const named = error instanceof Error && !(error instanceof Exit) && error.name !== 'Error' ? `${error.name}: ${error.message}` : undefined
141
203
  const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined
142
204
 
143
- printError(prefix, redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
205
+ const report = json ? printJsonError : (message: string, hint?: string, causeText?: string) => printError(prefix, message, hint, causeText)
206
+
207
+ report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
144
208
 
145
209
  return error instanceof Exit ? error.code : 1
146
210
  }
@@ -159,13 +223,13 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
159
223
  const [name, ...args] = argv
160
224
 
161
225
  if (name === undefined && level.path.length > 1) {
162
- console.error(await help(level, process.stderr))
226
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stderr))
163
227
 
164
228
  return 2
165
229
  }
166
230
 
167
231
  if (name === undefined || name === 'help' || name === '--help' || name === '-h') {
168
- console.log(await help(level, process.stdout))
232
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stdout))
169
233
 
170
234
  return 0
171
235
  }
@@ -179,6 +243,11 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
179
243
  let command = level.commands[level.aliases?.[name] ?? name]
180
244
  let rest = args
181
245
 
246
+ if (command === undefined && level.lazy.length > 0) {
247
+ level = await expand(level, [...level.path.slice(1), ...argv])
248
+ command = level.commands[level.aliases?.[name] ?? name]
249
+ }
250
+
182
251
  if (command === undefined && level.defaultCommand !== undefined) {
183
252
  command = level.commands[level.defaultCommand]
184
253
  rest = argv
@@ -195,14 +264,14 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
195
264
  if ('commands' in command) {
196
265
  const { summary } = command
197
266
 
198
- return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples }, rest, { version, redact })
267
+ return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact })
199
268
  }
200
269
 
201
270
  try {
202
271
  return await command.run(rest)
203
272
  }
204
273
  catch (error) {
205
- return reportThrown(`${prefix} ${name}`, error, redact)
274
+ return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest))
206
275
  }
207
276
  }
208
277
 
@@ -234,16 +303,23 @@ async function leafCandidates(run: (args: string[]) => Promise<number>, words: s
234
303
  if (!(words.at(-1) ?? '').startsWith('-'))
235
304
  return [FILES]
236
305
 
237
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name}\t${option.description}`)
306
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name}\t${option.description}`)
238
307
  }
239
308
 
240
309
  // The candidates for the last word, `value\tdescription` per line, after walking the subcommands the earlier words name.
241
- async function candidates(commands: Record<string, Command>, aliases: Record<string, string> | undefined, defaultCommand: string | undefined, words: string[]): Promise<string[]> {
310
+ async function candidates(scope: Scope, defaultCommand: string | undefined, words: string[], before: string[] = []): Promise<string[]> {
242
311
  const [word = '', ...rest] = words
243
- const command = commands[aliases?.[word] ?? word]
312
+ const listing = rest.length === 0 && !word.startsWith('-')
313
+ let { commands, aliases } = scope
314
+ let command = commands[aliases?.[word] ?? word]
315
+
316
+ if (listing || command === undefined) {
317
+ ({ commands, aliases } = await expand(scope, [...before, ...words]))
318
+ command = commands[aliases?.[word] ?? word]
319
+ }
244
320
 
245
- if (rest.length === 0 && !word.startsWith('-'))
246
- return Object.entries(commands).map(([name, { summary }]) => `${name}\t${summary}`)
321
+ if (listing)
322
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
247
323
 
248
324
  if (command === undefined) {
249
325
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
@@ -251,7 +327,7 @@ async function candidates(commands: Record<string, Command>, aliases: Record<str
251
327
  return fallback !== undefined && 'run' in fallback ? leafCandidates(fallback.run, words) : []
252
328
  }
253
329
 
254
- return 'commands' in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest)
330
+ return 'commands' in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest)
255
331
  }
256
332
 
257
333
  async function printCandidates(found: Promise<string[]>): Promise<number> {
@@ -264,9 +340,9 @@ async function printCandidates(found: Promise<string[]>): Promise<number> {
264
340
  // Runs the command argv names and returns the exit code: 0 for help and version, 2 for an unknown command.
265
341
  export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<number> {
266
342
  if (argv[0] === COMPLETE)
267
- return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)))
343
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
268
344
 
269
- 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, examples: cli.examples }, argv, { version: cli.version, redact: cli.redact })
345
+ return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
270
346
  }
271
347
 
272
348
  export interface Main {
@@ -291,6 +367,6 @@ export async function runMain({ name, version, run, redact }: Main, argv = proce
291
367
  return await run(argv)
292
368
  }
293
369
  catch (error) {
294
- return reportThrown(name, error, redact)
370
+ return reportThrown(name, error, redact, wantsJson(argv))
295
371
  }
296
372
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './chart'
3
3
  export * from './cli'
4
4
  export * from './config'
5
5
  export * from './format'
6
+ export * from './limit'
6
7
  export * from './process'
7
8
  export * from './style'
8
9
  export * from './text'
package/src/limit.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { badUsage, wholeNumber } from './args'
2
+
3
+ // The `--limit` and `--full` flags every listing words alike, `fallback` being the rows shown without either.
4
+ export function limitOptions(fallback: number) {
5
+ return {
6
+ limit: { type: 'string', description: `show at most this many rows, the rest counted (${fallback})` },
7
+ full: { type: 'boolean', description: 'show every row' },
8
+ } as const
9
+ }
10
+
11
+ // How many rows to print: --limit, the fallback without one, and every row (Infinity) with --full or, when `json` is set, whenever --limit is not given, so JSON is never cut on its own.
12
+ export function limitOf(values: { limit?: string | undefined, full?: boolean | undefined, json?: boolean | undefined }, fallback: number): number {
13
+ if (values.full === true && values.limit !== undefined)
14
+ badUsage('--limit and --full exclude each other')
15
+
16
+ if (values.full === true || (values.json === true && values.limit === undefined))
17
+ return Number.POSITIVE_INFINITY
18
+
19
+ return wholeNumber(values.limit, 'limit', fallback)
20
+ }
21
+
22
+ export function moreFooter(more: number): string {
23
+ return `${more} more, rerun with --full`
24
+ }
25
+
26
+ // The first `limit` rows and, when some are left out, the footer counting them: `12 more, rerun with --full`.
27
+ export function capped<T>(rows: readonly T[], limit: number): { shown: T[], footer: string | undefined } {
28
+ const more = rows.length - limit
29
+
30
+ return more > 0 ? { shown: rows.slice(0, limit), footer: moreFooter(more) } : { shown: [...rows], footer: undefined }
31
+ }
package/src/text.ts CHANGED
@@ -19,3 +19,15 @@ export function distance(a: string, b: string): number {
19
19
 
20
20
  return previous[b.length] ?? 0
21
21
  }
22
+
23
+ // A name the typed one starts, else one within two edits (one for a name under four letters).
24
+ export function closest(name: string, names: string[]): string | undefined {
25
+ const started = names.find(known => known.startsWith(name))
26
+
27
+ if (started !== undefined)
28
+ return started
29
+
30
+ const [best] = names.map(known => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far)
31
+
32
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined
33
+ }