@kaynooo/cli-kit 0.8.1 → 0.9.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,7 +33,7 @@ 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`).
@@ -45,7 +45,10 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
45
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
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
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.
48
+ - `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.
49
+ - `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).
50
+ - `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.
51
+ - `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
52
  - `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
50
53
  - `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
54
  - `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.9.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
@@ -50,14 +50,48 @@ export const completion = { active: false }
50
50
  // Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
51
51
  const OPTIONAL = /\[[^\]]+\]/g
52
52
  const USAGE_PREFIX = /^usage:\s*/i
53
+ const FULL_STOP = /\.$/
53
54
 
54
55
  // `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
55
56
  export function usageLine(usage: string, colors = paint(process.stdout)): string {
56
57
  return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
57
58
  }
58
59
 
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')
60
+ export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces }: Described = {}): string {
61
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
62
+ }
63
+
64
+ // An `Instead of:` line, after a blank line; nothing without one.
65
+ export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
66
+ return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
67
+ }
68
+
69
+ // What a bin's --help prints under KIT_HELP=json, for `agent-tools list` to read without parsing the text.
70
+ export interface Description {
71
+ synopsis: string
72
+ summary: string
73
+ replaces?: string
74
+ examples: { line: string, note?: string }[]
75
+ }
76
+
77
+ export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
78
+ return env.KIT_HELP === 'json'
79
+ }
80
+
81
+ // 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.
82
+ export function description(usage: string, { examples = [], replaces }: Described = {}): Description {
83
+ const lines = usage.replace(USAGE_PREFIX, '').split('\n')
84
+ const first = lines[0]!
85
+ const colon = first.indexOf(': ')
86
+ const prose = lines[lines.indexOf('') + 1] ?? ''
87
+ const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
88
+
89
+ return {
90
+ synopsis: colon === -1 ? first : first.slice(0, colon),
91
+ summary: colon === -1 ? fallback : first.slice(colon + 2),
92
+ ...replaces === undefined ? {} : { replaces },
93
+ examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
94
+ }
61
95
  }
62
96
 
63
97
  // An `Examples:` section, after a blank line, with a line per example; nothing without one.
@@ -95,10 +129,15 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
95
129
  positionals?: Names
96
130
  // 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
131
  examples?: Example[]
132
+ // 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'.
133
+ replaces?: string
98
134
  }
99
135
 
100
136
  export type Example = string | [line: string, note: string]
101
137
 
138
+ // The parts of the settings the help and the description show.
139
+ interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
140
+
102
141
  // The output flags every command takes, before its own help.
103
142
  export const OUTPUT_OPTIONS = {
104
143
  color: { type: 'string', description: 'auto (on a terminal alone), always or never' },
@@ -142,7 +181,7 @@ export function parse<const T extends Options, const Names extends readonly stri
142
181
  let parsed
143
182
 
144
183
  try {
145
- parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
184
+ parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
146
185
  }
147
186
  catch (error) {
148
187
  badUsage(parseError(error))
@@ -153,7 +192,7 @@ export function parse<const T extends Options, const Names extends readonly stri
153
192
  applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
154
193
 
155
194
  if (values.help === true) {
156
- console.log(helpText(usage, all, undefined, settings.examples))
195
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings))
157
196
 
158
197
  throw new HelpShown()
159
198
  }
@@ -175,6 +214,33 @@ function checkPositionals(given: string[], names: readonly string[]): void {
175
214
  badUsage(`unexpected argument ${given[names.length]}`)
176
215
  }
177
216
 
217
+ // 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"`.
218
+ function joinDashValues(args: readonly string[], options: Options): string[] {
219
+ const longOf = new Map<string, string>(Object.entries(options).flatMap(([name, option]): [string, string][] => option.short === undefined ? [[`--${name}`, name]] : [[`--${name}`, name], [`-${option.short}`, name]]))
220
+ const isOption = (arg: string) => arg === '--' || longOf.has(arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg)
221
+ const joined: string[] = []
222
+
223
+ for (let i = 0; i < args.length; i++) {
224
+ const arg = args[i] ?? ''
225
+
226
+ if (arg === '--')
227
+ return [...joined, ...args.slice(i)]
228
+
229
+ const name = longOf.get(arg)
230
+ const next = args[i + 1]
231
+
232
+ if (name !== undefined && options[name]?.type === 'string' && next !== undefined && next.startsWith('-') && next !== '-' && !isOption(next)) {
233
+ joined.push(`--${name}=${next}`)
234
+ i++
235
+ }
236
+ else {
237
+ joined.push(arg)
238
+ }
239
+ }
240
+
241
+ return joined
242
+ }
243
+
178
244
  // parseArgs' message, its first sentence alone and lowercased as the kit's own: `unknown option '--nope'`.
179
245
  function parseError(error: unknown): string {
180
246
  const message = upTo(error instanceof Error ? error.message : String(error), '. ')
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
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
6
  import { distance } from './text'
7
7
 
@@ -29,6 +29,8 @@ export interface Cli {
29
29
  redact?: (text: string) => string
30
30
  // Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
31
31
  examples?: Example[]
32
+ // What the CLI is reached for instead of, shown as `Instead of:` in `<cli> --help`.
33
+ replaces?: string
32
34
  }
33
35
 
34
36
  const LEVEL_OPTIONS = {
@@ -89,16 +91,22 @@ interface Level {
89
91
  // The words typed to reach this level, `ft plan`.
90
92
  path: string[]
91
93
  header: (colors: Colors) => string
94
+ summary: string
92
95
  commands: Record<string, Command>
93
96
  aliases: Record<string, string> | undefined
94
97
  defaultCommand: string | undefined
95
98
  examples: Example[] | undefined
99
+ replaces: string | undefined
96
100
  }
97
101
 
98
102
  // 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> {
103
+ async function help({ path, header, summary, commands, defaultCommand, examples, replaces }: Level, stream: { isTTY?: boolean }): Promise<string> {
100
104
  const colors = paint(stream)
101
105
  const prefix = path.join(' ')
106
+
107
+ if (wantsHelpJson())
108
+ return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }))
109
+
102
110
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
103
111
  const options = fallback !== undefined && 'run' in fallback ? await optionsOf(fallback.run) : undefined
104
112
  // The default command's own run lists the output flags again, so they show once, under the level's.
@@ -108,6 +116,7 @@ async function help({ path, header, commands, defaultCommand, examples }: Level,
108
116
  header(colors),
109
117
  '',
110
118
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
119
+ ...replacesRows(replaces, colors),
111
120
  '',
112
121
  ...commandRows(commands, colors),
113
122
  ...own.length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(Object.fromEntries(own), colors)],
@@ -130,9 +139,33 @@ export function printError(prefix: string, message: string, hint?: string, cause
130
139
  console.error(`${head} ${message}${under('cause', cause, colors.dim)}${under('hint', hint, colors.yellow)}`)
131
140
  }
132
141
 
142
+ // 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.
143
+ export function runHint(command: string, effect?: string): string {
144
+ return `run \`${command}\`${effect === undefined ? '' : ` to ${effect}`}`
145
+ }
146
+
147
+ // A hint under a refusal a command prints itself, laid out as printError lays it.
148
+ export function printHint(hint: string): void {
149
+ const colors = paint(process.stderr)
150
+
151
+ console.error(` ${takesControlCodes(process.stderr) ? colors.yellow('hint') : 'hint:'} ${hint}`)
152
+ }
153
+
154
+ // 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.
155
+ export function printJsonError(message: string, hint?: string, cause?: string): void {
156
+ console.error(JSON.stringify({ error: message, ...(hint === undefined ? {} : { hint }), ...(cause === undefined ? {} : { cause }) }))
157
+ }
158
+
159
+ // Whether the arguments ask for JSON, read before parsing so even a parse error prints as JSON.
160
+ function wantsJson(argv: readonly string[]): boolean {
161
+ const end = argv.indexOf('--')
162
+
163
+ return (end === -1 ? argv : argv.slice(0, end)).includes('--json')
164
+ }
165
+
133
166
  // 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
167
  // 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 {
168
+ function reportThrown(prefix: string, error: unknown, redact: (text: string) => string = text => text, json = false): number {
136
169
  if (error instanceof HelpShown)
137
170
  return 0
138
171
 
@@ -140,7 +173,9 @@ function reportThrown(prefix: string, error: unknown, redact: (text: string) =>
140
173
  const named = error instanceof Error && !(error instanceof Exit) && error.name !== 'Error' ? `${error.name}: ${error.message}` : undefined
141
174
  const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined
142
175
 
143
- printError(prefix, redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
176
+ const report = json ? printJsonError : (message: string, hint?: string, causeText?: string) => printError(prefix, message, hint, causeText)
177
+
178
+ report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
144
179
 
145
180
  return error instanceof Exit ? error.code : 1
146
181
  }
@@ -195,14 +230,14 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
195
230
  if ('commands' in command) {
196
231
  const { summary } = command
197
232
 
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 })
233
+ return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, summary, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact })
199
234
  }
200
235
 
201
236
  try {
202
237
  return await command.run(rest)
203
238
  }
204
239
  catch (error) {
205
- return reportThrown(`${prefix} ${name}`, error, redact)
240
+ return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest))
206
241
  }
207
242
  }
208
243
 
@@ -266,7 +301,7 @@ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<nu
266
301
  if (argv[0] === COMPLETE)
267
302
  return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)))
268
303
 
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 })
304
+ return dispatch({ path: [cli.name], header: colors => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, summary: cli.about, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
270
305
  }
271
306
 
272
307
  export interface Main {
@@ -291,6 +326,6 @@ export async function runMain({ name, version, run, redact }: Main, argv = proce
291
326
  return await run(argv)
292
327
  }
293
328
  catch (error) {
294
- return reportThrown(name, error, redact)
329
+ return reportThrown(name, error, redact, wantsJson(argv))
295
330
  }
296
331
  }
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
+ }