@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 +1 -1
- package/README.md +7 -3
- package/package.json +6 -6
- package/src/args.ts +104 -10
- package/src/cli.ts +117 -41
- package/src/index.ts +1 -0
- package/src/limit.ts +31 -0
- package/src/text.ts +12 -0
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,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
|
-
- `
|
|
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.
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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, {
|
|
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(
|
|
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
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`)} ${
|
|
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(
|
|
310
|
+
async function candidates(scope: Scope, defaultCommand: string | undefined, words: string[], before: string[] = []): Promise<string[]> {
|
|
242
311
|
const [word = '', ...rest] = words
|
|
243
|
-
const
|
|
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 (
|
|
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)} ${
|
|
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
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
|
+
}
|