@kaynooo/cli-kit 0.9.0 → 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/README.md +1 -0
- package/package.json +1 -1
- package/src/args.ts +34 -6
- package/src/cli.ts +78 -37
- package/src/text.ts +12 -0
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
6
|
"author": "Kaynooo <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|
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
|
|
|
@@ -51,6 +52,7 @@ export const completion = { active: false }
|
|
|
51
52
|
const OPTIONAL = /\[[^\]]+\]/g
|
|
52
53
|
const USAGE_PREFIX = /^usage:\s*/i
|
|
53
54
|
const FULL_STOP = /\.$/
|
|
55
|
+
const DASHES = /^-+/
|
|
54
56
|
|
|
55
57
|
// `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
|
|
56
58
|
export function usageLine(usage: string, colors = paint(process.stdout)): string {
|
|
@@ -108,11 +110,11 @@ export function optionRows(options: Options, colors = paint(process.stdout)): st
|
|
|
108
110
|
const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
|
|
109
111
|
const value = option.type === 'string' ? ` ${colors.dim('<value>')}` : ''
|
|
110
112
|
|
|
111
|
-
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 ?? '' }
|
|
112
114
|
})
|
|
113
115
|
const width = Math.max(0, ...rows.map(row => row.width))
|
|
114
116
|
|
|
115
|
-
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)}`)
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
// The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
|
|
@@ -184,7 +186,9 @@ export function parse<const T extends Options, const Names extends readonly stri
|
|
|
184
186
|
parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
|
|
185
187
|
}
|
|
186
188
|
catch (error) {
|
|
187
|
-
|
|
189
|
+
const message = parseError(error)
|
|
190
|
+
|
|
191
|
+
throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true))
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
const values = parsed.values as { help?: boolean, color?: unknown, plain?: unknown }
|
|
@@ -248,6 +252,30 @@ function parseError(error: unknown): string {
|
|
|
248
252
|
return `${message.charAt(0).toLowerCase()}${message.slice(1)}`
|
|
249
253
|
}
|
|
250
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
|
+
|
|
251
279
|
export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
|
|
252
280
|
if (value === undefined)
|
|
253
281
|
return fallback
|
package/src/cli.ts
CHANGED
|
@@ -3,16 +3,24 @@ import type { Colors } from './style'
|
|
|
3
3
|
import process from 'node:process'
|
|
4
4
|
import { applyOutput, Completing, completion, description, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, replacesRows, UsageError, usageLine, wantsHelpJson } from './args'
|
|
5
5
|
import { paint, takesControlCodes } from './style'
|
|
6
|
-
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.
|
|
@@ -40,15 +51,16 @@ const LEVEL_OPTIONS = {
|
|
|
40
51
|
const TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: 'boolean', short: 'v', description: 'print the version' } } as const satisfies Options
|
|
41
52
|
|
|
42
53
|
// The rows under `Commands:`, then a heading per group in the order first met, names aligned across them all.
|
|
43
|
-
function commandRows(commands:
|
|
54
|
+
async function commandRows(commands: Commands, colors: Colors): Promise<string[]> {
|
|
44
55
|
const width = Math.max(...Object.keys(commands).map(name => name.length))
|
|
45
56
|
const groups = new Map<string, string[]>([['Commands:', []]])
|
|
57
|
+
const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)))
|
|
46
58
|
|
|
47
|
-
for (const [name, {
|
|
59
|
+
for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
|
|
48
60
|
const heading = group === undefined ? 'Commands:' : `${group}:`
|
|
49
61
|
const rows = groups.get(heading) ?? []
|
|
50
62
|
|
|
51
|
-
rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(
|
|
63
|
+
rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index]!)}`)
|
|
52
64
|
groups.set(heading, rows)
|
|
53
65
|
}
|
|
54
66
|
|
|
@@ -75,34 +87,51 @@ function takeOutputFlags(argv: string[]): string[] {
|
|
|
75
87
|
return argv.slice(index)
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return started
|
|
84
|
-
|
|
85
|
-
const [best] = names.map(known => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far)
|
|
86
|
-
|
|
87
|
-
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
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
interface Level {
|
|
97
|
+
interface Level extends Scope {
|
|
91
98
|
// The words typed to reach this level, `ft plan`.
|
|
92
99
|
path: string[]
|
|
93
|
-
header: (colors: Colors) => string
|
|
94
|
-
summary:
|
|
95
|
-
commands: Record<string, Command>
|
|
96
|
-
aliases: Record<string, string> | undefined
|
|
100
|
+
header: (colors: Colors, summary: string) => string
|
|
101
|
+
summary: Summary
|
|
97
102
|
defaultCommand: string | undefined
|
|
98
103
|
examples: Example[] | undefined
|
|
99
104
|
replaces: string | undefined
|
|
100
105
|
}
|
|
101
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: [] }
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
// The level's help; a default command's options follow the commands, as they apply with no command named.
|
|
103
|
-
async function help({ path, header, summary, commands, defaultCommand, examples, replaces }: Level, stream: { isTTY?: boolean }): Promise<string> {
|
|
131
|
+
async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }: Level, stream: { isTTY?: boolean }): Promise<string> {
|
|
104
132
|
const colors = paint(stream)
|
|
105
133
|
const prefix = path.join(' ')
|
|
134
|
+
const summary = await resolveSummary(lazySummary)
|
|
106
135
|
|
|
107
136
|
if (wantsHelpJson())
|
|
108
137
|
return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }))
|
|
@@ -113,12 +142,12 @@ async function help({ path, header, summary, commands, defaultCommand, examples,
|
|
|
113
142
|
const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS))
|
|
114
143
|
|
|
115
144
|
return [
|
|
116
|
-
header(colors),
|
|
145
|
+
header(colors, summary),
|
|
117
146
|
'',
|
|
118
147
|
usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
|
|
119
148
|
...replacesRows(replaces, colors),
|
|
120
149
|
'',
|
|
121
|
-
...commandRows(commands, colors),
|
|
150
|
+
...await commandRows(commands, colors),
|
|
122
151
|
...own.length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(Object.fromEntries(own), colors)],
|
|
123
152
|
'',
|
|
124
153
|
colors.bold('Options:'),
|
|
@@ -194,13 +223,13 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
194
223
|
const [name, ...args] = argv
|
|
195
224
|
|
|
196
225
|
if (name === undefined && level.path.length > 1) {
|
|
197
|
-
console.error(await help(level, process.stderr))
|
|
226
|
+
console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stderr))
|
|
198
227
|
|
|
199
228
|
return 2
|
|
200
229
|
}
|
|
201
230
|
|
|
202
231
|
if (name === undefined || name === 'help' || name === '--help' || name === '-h') {
|
|
203
|
-
console.log(await help(level, process.stdout))
|
|
232
|
+
console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stdout))
|
|
204
233
|
|
|
205
234
|
return 0
|
|
206
235
|
}
|
|
@@ -214,6 +243,11 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
214
243
|
let command = level.commands[level.aliases?.[name] ?? name]
|
|
215
244
|
let rest = args
|
|
216
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
|
+
|
|
217
251
|
if (command === undefined && level.defaultCommand !== undefined) {
|
|
218
252
|
command = level.commands[level.defaultCommand]
|
|
219
253
|
rest = argv
|
|
@@ -230,7 +264,7 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
230
264
|
if ('commands' in command) {
|
|
231
265
|
const { summary } = command
|
|
232
266
|
|
|
233
|
-
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 })
|
|
234
268
|
}
|
|
235
269
|
|
|
236
270
|
try {
|
|
@@ -269,16 +303,23 @@ async function leafCandidates(run: (args: string[]) => Promise<number>, words: s
|
|
|
269
303
|
if (!(words.at(-1) ?? '').startsWith('-'))
|
|
270
304
|
return [FILES]
|
|
271
305
|
|
|
272
|
-
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}`)
|
|
273
307
|
}
|
|
274
308
|
|
|
275
309
|
// The candidates for the last word, `value\tdescription` per line, after walking the subcommands the earlier words name.
|
|
276
|
-
async function candidates(
|
|
310
|
+
async function candidates(scope: Scope, defaultCommand: string | undefined, words: string[], before: string[] = []): Promise<string[]> {
|
|
277
311
|
const [word = '', ...rest] = words
|
|
278
|
-
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
|
+
}
|
|
279
320
|
|
|
280
|
-
if (
|
|
281
|
-
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)}`))
|
|
282
323
|
|
|
283
324
|
if (command === undefined) {
|
|
284
325
|
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
|
|
@@ -286,7 +327,7 @@ async function candidates(commands: Record<string, Command>, aliases: Record<str
|
|
|
286
327
|
return fallback !== undefined && 'run' in fallback ? leafCandidates(fallback.run, words) : []
|
|
287
328
|
}
|
|
288
329
|
|
|
289
|
-
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)
|
|
290
331
|
}
|
|
291
332
|
|
|
292
333
|
async function printCandidates(found: Promise<string[]>): Promise<number> {
|
|
@@ -299,9 +340,9 @@ async function printCandidates(found: Promise<string[]>): Promise<number> {
|
|
|
299
340
|
// Runs the command argv names and returns the exit code: 0 for help and version, 2 for an unknown command.
|
|
300
341
|
export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<number> {
|
|
301
342
|
if (argv[0] === COMPLETE)
|
|
302
|
-
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)))
|
|
303
344
|
|
|
304
|
-
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 })
|
|
305
346
|
}
|
|
306
347
|
|
|
307
348
|
export interface Main {
|
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
|
+
}
|