@kaynooo/cli-kit 0.8.0 → 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 +1 -1
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/args.ts +70 -4
- package/src/cli.ts +43 -8
- package/src/index.ts +1 -0
- package/src/limit.ts +31 -0
- package/src/shell.ts +10 -3
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026 Kaynooo <kaynooo
|
|
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
|
|
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
|
-
- `
|
|
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.
|
|
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
|
|
6
|
+
"author": "Kaynooo <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"homepage": "https://git.
|
|
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.
|
|
11
|
+
"url": "git+https://git.kaynooo.fr/kaynooo/agent-tools.git",
|
|
12
12
|
"directory": "packages/kit"
|
|
13
13
|
},
|
|
14
|
-
"bugs": "https://git.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
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/shell.ts
CHANGED
|
@@ -11,6 +11,7 @@ const LEADING_TILDE = /^~(?=\/|$)/
|
|
|
11
11
|
const LEADING_TABS = /^\t+/
|
|
12
12
|
const OPENERS = /^[({]+/
|
|
13
13
|
const CLOSERS = /[)}]+$/
|
|
14
|
+
const DOUBLE_QUOTED_ESCAPES = new Set(['$', '`', '"', '\\'])
|
|
14
15
|
const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/
|
|
15
16
|
// Programs that run the command following them, which is the one that counts.
|
|
16
17
|
const WRAPPERS = new Set(['command', 'env', 'sudo', 'exec', 'nohup', 'time'])
|
|
@@ -276,7 +277,10 @@ export function words(text: string): Word[] {
|
|
|
276
277
|
|
|
277
278
|
isStarted = true
|
|
278
279
|
|
|
279
|
-
|
|
280
|
+
// within double quotes a backslash escapes only `$`, a backtick, `"` and itself, and stays before anything else (`"v\d"`)
|
|
281
|
+
if (char === '\\' && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? ''))
|
|
282
|
+
current += char
|
|
283
|
+
else if (char === '\\' && quote !== '\'')
|
|
280
284
|
current += text[++i] ?? ''
|
|
281
285
|
else if (quote !== null && char === quote)
|
|
282
286
|
quote = null
|
|
@@ -329,8 +333,11 @@ export function parseCommand(text: string): Invocation {
|
|
|
329
333
|
break
|
|
330
334
|
}
|
|
331
335
|
}
|
|
332
|
-
// the closing of a subshell or a group, `(git stash)` or `{ cd x && npm i; }`, ends the last word
|
|
333
|
-
const
|
|
336
|
+
// the closing of a subshell or a group, `(git stash)` or `{ cd x && npm i; }`, ends the last word; a closer inside quotes (`-e "f(g(x))"`) is the word's own
|
|
337
|
+
const closers = CLOSERS.exec(text.trimEnd())?.[0].length ?? 0
|
|
338
|
+
const word = tokens.at(-1) ?? ''
|
|
339
|
+
const trimmed = word.length - (CLOSERS.exec(word)?.[0].length ?? 0)
|
|
340
|
+
const last = word.slice(0, Math.max(trimmed, word.length - closers))
|
|
334
341
|
|
|
335
342
|
if (tokens.length > 0)
|
|
336
343
|
tokens.splice(-1, 1, ...(last === '' ? [] : [last]))
|