incur 0.3.5 → 0.3.7
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 +61 -0
- package/dist/Cli.d.ts +15 -0
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +300 -25
- package/dist/Cli.js.map +1 -1
- package/dist/Filter.js +0 -18
- package/dist/Filter.js.map +1 -1
- package/dist/Help.d.ts +4 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +17 -14
- package/dist/Help.js.map +1 -1
- package/dist/Parser.d.ts +2 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +69 -37
- package/dist/Parser.js.map +1 -1
- package/dist/bin.d.ts +1 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +17 -2
- package/dist/bin.js.map +1 -1
- package/dist/internal/command.d.ts +2 -0
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +1 -0
- package/dist/internal/command.js.map +1 -1
- package/dist/internal/configSchema.d.ts +8 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +57 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/helpers.d.ts +9 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +39 -0
- package/dist/internal/helpers.js.map +1 -0
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +137 -0
- package/package.json +1 -1
- package/src/Cli.test-d.ts +39 -0
- package/src/Cli.test.ts +714 -25
- package/src/Cli.ts +353 -27
- package/src/Filter.ts +0 -17
- package/src/Help.test.ts +66 -0
- package/src/Help.ts +20 -13
- package/src/Openapi.test.ts +6 -1
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +86 -35
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +22 -19
- package/src/internal/command.ts +3 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- package/src/internal/helpers.test.ts +54 -0
- package/src/internal/helpers.ts +41 -0
package/src/e2e.test.ts
CHANGED
|
@@ -70,9 +70,9 @@ describe('routing', () => {
|
|
|
70
70
|
"code: COMMAND_NOT_FOUND
|
|
71
71
|
message: 'nonexistent' is not a command for 'app'.
|
|
72
72
|
cta:
|
|
73
|
-
description: "
|
|
74
|
-
commands[1]{command}:
|
|
75
|
-
app --help
|
|
73
|
+
description: "Next steps:"
|
|
74
|
+
commands[1]{command,description}:
|
|
75
|
+
app --help,see all available commands
|
|
76
76
|
"
|
|
77
77
|
`)
|
|
78
78
|
})
|
|
@@ -85,8 +85,8 @@ describe('routing', () => {
|
|
|
85
85
|
expect(output).toMatchInlineSnapshot(`
|
|
86
86
|
"Error: 'nonexistent' is not a command for 'app'.
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
app --help
|
|
88
|
+
Next steps:
|
|
89
|
+
app --help # see all available commands
|
|
90
90
|
"
|
|
91
91
|
`)
|
|
92
92
|
})
|
|
@@ -98,9 +98,9 @@ describe('routing', () => {
|
|
|
98
98
|
"code: COMMAND_NOT_FOUND
|
|
99
99
|
message: 'whoami' is not a command for 'app auth'.
|
|
100
100
|
cta:
|
|
101
|
-
description: "
|
|
102
|
-
commands[1]{command}:
|
|
103
|
-
app auth --help
|
|
101
|
+
description: "Next steps:"
|
|
102
|
+
commands[1]{command,description}:
|
|
103
|
+
app auth --help,see all available commands
|
|
104
104
|
"
|
|
105
105
|
`)
|
|
106
106
|
})
|
|
@@ -112,9 +112,9 @@ describe('routing', () => {
|
|
|
112
112
|
"code: COMMAND_NOT_FOUND
|
|
113
113
|
message: 'nope' is not a command for 'app project deploy'.
|
|
114
114
|
cta:
|
|
115
|
-
description: "
|
|
116
|
-
commands[1]{command}:
|
|
117
|
-
app project deploy --help
|
|
115
|
+
description: "Next steps:"
|
|
116
|
+
commands[1]{command,description}:
|
|
117
|
+
app project deploy --help,see all available commands
|
|
118
118
|
"
|
|
119
119
|
`)
|
|
120
120
|
})
|
|
@@ -650,9 +650,10 @@ describe('error handling', () => {
|
|
|
650
650
|
"commands": [
|
|
651
651
|
{
|
|
652
652
|
"command": "app --help",
|
|
653
|
+
"description": "see all available commands",
|
|
653
654
|
},
|
|
654
655
|
],
|
|
655
|
-
"description": "
|
|
656
|
+
"description": "Next steps:",
|
|
656
657
|
},
|
|
657
658
|
"duration": "<stripped>",
|
|
658
659
|
},
|
|
@@ -1974,13 +1975,15 @@ describe('skills staleness', () => {
|
|
|
1974
1975
|
|
|
1975
1976
|
afterEach(() => {
|
|
1976
1977
|
stderrSpy.mockRestore()
|
|
1978
|
+
__mockSkillsHash = undefined
|
|
1977
1979
|
})
|
|
1978
1980
|
|
|
1979
|
-
test('
|
|
1981
|
+
test('includes skills CTA when stale', async () => {
|
|
1980
1982
|
__mockSkillsHash = '0000000000000000'
|
|
1981
1983
|
const { output } = await serve(createApp(), ['ping'])
|
|
1982
1984
|
expect(output).toContain('pong: true')
|
|
1983
|
-
expect(
|
|
1985
|
+
expect(output).toContain('Skills are out of date:')
|
|
1986
|
+
expect(output).toContain('skills add')
|
|
1984
1987
|
})
|
|
1985
1988
|
|
|
1986
1989
|
test('no warning when skills hash matches', async () => {
|
|
@@ -1991,20 +1994,20 @@ describe('skills staleness', () => {
|
|
|
1991
1994
|
|
|
1992
1995
|
const { output } = await serve(cli, ['ping'])
|
|
1993
1996
|
expect(output).toContain('pong: true')
|
|
1994
|
-
expect(
|
|
1997
|
+
expect(output).not.toContain('Skills are out of date')
|
|
1995
1998
|
})
|
|
1996
1999
|
|
|
1997
2000
|
test('no warning on first use (no hash stored)', async () => {
|
|
1998
2001
|
__mockSkillsHash = undefined
|
|
1999
2002
|
const { output } = await serve(createApp(), ['ping'])
|
|
2000
2003
|
expect(output).toContain('pong: true')
|
|
2001
|
-
expect(
|
|
2004
|
+
expect(output).not.toContain('Skills are out of date')
|
|
2002
2005
|
})
|
|
2003
2006
|
|
|
2004
2007
|
test('no warning for --llms', async () => {
|
|
2005
2008
|
__mockSkillsHash = '0000000000000000'
|
|
2006
|
-
await serve(createApp(), ['--llms'])
|
|
2007
|
-
expect(
|
|
2009
|
+
const { output } = await serve(createApp(), ['--llms'])
|
|
2010
|
+
expect(output).not.toContain('Skills are out of date')
|
|
2008
2011
|
})
|
|
2009
2012
|
|
|
2010
2013
|
test('no warning for --mcp', async () => {
|
|
@@ -2204,7 +2207,7 @@ describe('fetch gateway', () => {
|
|
|
2204
2207
|
})
|
|
2205
2208
|
|
|
2206
2209
|
test('query params from --key value', async () => {
|
|
2207
|
-
|
|
2210
|
+
await serve(createApp(), ['api', 'users', '--limit', '5'])
|
|
2208
2211
|
const { output: jsonOut } = await serve(createApp(), [
|
|
2209
2212
|
'api',
|
|
2210
2213
|
'users',
|
package/src/internal/command.ts
CHANGED
|
@@ -71,6 +71,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
|
|
|
71
71
|
const parsed = Parser.parse(argv, {
|
|
72
72
|
alias: command.alias as Record<string, string> | undefined,
|
|
73
73
|
args: command.args,
|
|
74
|
+
defaults: options.defaults,
|
|
74
75
|
options: command.options,
|
|
75
76
|
})
|
|
76
77
|
args = parsed.args
|
|
@@ -269,6 +270,8 @@ export declare namespace execute {
|
|
|
269
270
|
agent: boolean
|
|
270
271
|
/** Raw positional tokens (already separated from flags). For HTTP/MCP, pass `[]`. */
|
|
271
272
|
argv: string[]
|
|
273
|
+
/** Default option values from config file. */
|
|
274
|
+
defaults?: Record<string, unknown> | undefined
|
|
272
275
|
/** CLI-level env schema. */
|
|
273
276
|
env?: z.ZodObject<any> | undefined
|
|
274
277
|
/** Source for environment variables. Defaults to `process.env`. */
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Cli, z } from 'incur'
|
|
2
|
+
|
|
3
|
+
import * as ConfigSchema from './configSchema.js'
|
|
4
|
+
|
|
5
|
+
describe('fromCli', () => {
|
|
6
|
+
test('generates schema for root options and leaf commands', () => {
|
|
7
|
+
const cli = Cli.create('test', {
|
|
8
|
+
options: z.object({
|
|
9
|
+
verbose: z.boolean().default(false),
|
|
10
|
+
}),
|
|
11
|
+
})
|
|
12
|
+
cli.command('echo', {
|
|
13
|
+
options: z.object({
|
|
14
|
+
prefix: z.string().default(''),
|
|
15
|
+
upper: z.boolean().default(false),
|
|
16
|
+
}),
|
|
17
|
+
run: (c) => c.options,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const schema = ConfigSchema.fromCli(cli)
|
|
21
|
+
expect(schema).toMatchInlineSnapshot(`
|
|
22
|
+
{
|
|
23
|
+
"additionalProperties": false,
|
|
24
|
+
"properties": {
|
|
25
|
+
"$schema": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
},
|
|
28
|
+
"commands": {
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"properties": {
|
|
31
|
+
"echo": {
|
|
32
|
+
"additionalProperties": false,
|
|
33
|
+
"properties": {
|
|
34
|
+
"options": {
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"properties": {
|
|
37
|
+
"prefix": {
|
|
38
|
+
"default": "",
|
|
39
|
+
"type": "string",
|
|
40
|
+
},
|
|
41
|
+
"upper": {
|
|
42
|
+
"default": false,
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
"type": "object",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"type": "object",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"type": "object",
|
|
53
|
+
},
|
|
54
|
+
"options": {
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"properties": {
|
|
57
|
+
"verbose": {
|
|
58
|
+
"default": false,
|
|
59
|
+
"type": "boolean",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
"type": "object",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"type": "object",
|
|
66
|
+
}
|
|
67
|
+
`)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('generates schema for nested command groups', () => {
|
|
71
|
+
const project = Cli.create('project')
|
|
72
|
+
project.command('list', {
|
|
73
|
+
options: z.object({
|
|
74
|
+
limit: z.number().default(10),
|
|
75
|
+
label: z.array(z.string()).default([]),
|
|
76
|
+
}),
|
|
77
|
+
run: (c) => c.options,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const cli = Cli.create('test')
|
|
81
|
+
cli.command(project)
|
|
82
|
+
|
|
83
|
+
const schema = ConfigSchema.fromCli(cli)
|
|
84
|
+
expect(schema).toMatchInlineSnapshot(`
|
|
85
|
+
{
|
|
86
|
+
"additionalProperties": false,
|
|
87
|
+
"properties": {
|
|
88
|
+
"$schema": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
},
|
|
91
|
+
"commands": {
|
|
92
|
+
"additionalProperties": false,
|
|
93
|
+
"properties": {
|
|
94
|
+
"project": {
|
|
95
|
+
"additionalProperties": false,
|
|
96
|
+
"properties": {
|
|
97
|
+
"commands": {
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"properties": {
|
|
100
|
+
"list": {
|
|
101
|
+
"additionalProperties": false,
|
|
102
|
+
"properties": {
|
|
103
|
+
"options": {
|
|
104
|
+
"additionalProperties": false,
|
|
105
|
+
"properties": {
|
|
106
|
+
"label": {
|
|
107
|
+
"default": [],
|
|
108
|
+
"items": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
},
|
|
111
|
+
"type": "array",
|
|
112
|
+
},
|
|
113
|
+
"limit": {
|
|
114
|
+
"default": 10,
|
|
115
|
+
"type": "number",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
"type": "object",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
"type": "object",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
"type": "object",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
"type": "object",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"type": "object",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
"type": "object",
|
|
134
|
+
}
|
|
135
|
+
`)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('returns schema with only $schema for cli with no commands', () => {
|
|
139
|
+
const cli = Cli.create('test')
|
|
140
|
+
const schema = ConfigSchema.fromCli(cli)
|
|
141
|
+
expect(schema).toEqual({
|
|
142
|
+
type: 'object',
|
|
143
|
+
additionalProperties: false,
|
|
144
|
+
properties: { $schema: { type: 'string' } },
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('skips fetch gateway commands', () => {
|
|
149
|
+
const cli = Cli.create('test')
|
|
150
|
+
cli.command('echo', {
|
|
151
|
+
options: z.object({ prefix: z.string().default('') }),
|
|
152
|
+
run: (c) => c.options,
|
|
153
|
+
})
|
|
154
|
+
cli.command('api', {
|
|
155
|
+
description: 'API gateway',
|
|
156
|
+
fetch: () => new Response('ok'),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const schema = ConfigSchema.fromCli(cli)
|
|
160
|
+
const commandKeys = Object.keys((schema as any).properties.commands.properties)
|
|
161
|
+
expect(commandKeys).toEqual(['echo'])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('includes commands without options as empty objects', () => {
|
|
165
|
+
const cli = Cli.create('test')
|
|
166
|
+
cli.command('ping', {
|
|
167
|
+
run: () => 'pong',
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const schema = ConfigSchema.fromCli(cli)
|
|
171
|
+
expect(schema).toMatchInlineSnapshot(`
|
|
172
|
+
{
|
|
173
|
+
"additionalProperties": false,
|
|
174
|
+
"properties": {
|
|
175
|
+
"$schema": {
|
|
176
|
+
"type": "string",
|
|
177
|
+
},
|
|
178
|
+
"commands": {
|
|
179
|
+
"additionalProperties": false,
|
|
180
|
+
"properties": {
|
|
181
|
+
"ping": {
|
|
182
|
+
"additionalProperties": false,
|
|
183
|
+
"type": "object",
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
"type": "object",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
"type": "object",
|
|
190
|
+
}
|
|
191
|
+
`)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import * as Cli from '../Cli.js'
|
|
5
|
+
import * as Schema from '../Schema.js'
|
|
6
|
+
import { importCli } from './utils.js'
|
|
7
|
+
|
|
8
|
+
/** Returns `true` if the CLI has `config` enabled on `Cli.create()`. */
|
|
9
|
+
export function hasConfig(cli: Cli.Cli): boolean {
|
|
10
|
+
return Cli.toConfigEnabled.get(cli) === true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Imports a CLI from `input` (must `export default` a `Cli`), generates the JSON Schema, and writes it to `output`. */
|
|
14
|
+
export async function generate(input: string, output: string): Promise<void> {
|
|
15
|
+
const cli = await importCli(input)
|
|
16
|
+
await fs.writeFile(output, JSON.stringify(fromCli(cli), null, 2) + '\n')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Generates a JSON Schema describing the config file structure for a CLI. */
|
|
20
|
+
export function fromCli(cli: Cli.Cli): Record<string, unknown> {
|
|
21
|
+
const commands = Cli.toCommands.get(cli)
|
|
22
|
+
if (!commands) return { type: 'object' }
|
|
23
|
+
|
|
24
|
+
const rootOptions = Cli.toRootOptions.get(cli)
|
|
25
|
+
const node = buildNode(commands, rootOptions)
|
|
26
|
+
const properties = (node.properties ?? {}) as Record<string, unknown>
|
|
27
|
+
properties.$schema = { type: 'string' }
|
|
28
|
+
node.properties = properties
|
|
29
|
+
return node
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Builds a JSON Schema node for a command level. */
|
|
33
|
+
function buildNode(
|
|
34
|
+
commands: Map<string, any>,
|
|
35
|
+
options?: z.ZodObject<any>,
|
|
36
|
+
): Record<string, unknown> {
|
|
37
|
+
const properties: Record<string, unknown> = {}
|
|
38
|
+
|
|
39
|
+
// Add `options` property from the options schema
|
|
40
|
+
if (options) {
|
|
41
|
+
const optSchema = Schema.toJsonSchema(options)
|
|
42
|
+
const props = optSchema.properties as Record<string, unknown> | undefined
|
|
43
|
+
if (props && Object.keys(props).length > 0)
|
|
44
|
+
properties.options = { type: 'object', additionalProperties: false, properties: props }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Add `commands` property with subcommand namespaces
|
|
48
|
+
const commandProps: Record<string, unknown> = {}
|
|
49
|
+
for (const [name, entry] of commands) {
|
|
50
|
+
if ('_group' in entry && entry._group) {
|
|
51
|
+
commandProps[name] = buildNode(entry.commands, undefined)
|
|
52
|
+
} else if (!('_fetch' in entry)) {
|
|
53
|
+
const cmd = entry as { options?: z.ZodObject<any> }
|
|
54
|
+
commandProps[name] = buildNode(new Map(), cmd.options)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(commandProps).length > 0)
|
|
58
|
+
properties.commands = { type: 'object', additionalProperties: false, properties: commandProps }
|
|
59
|
+
|
|
60
|
+
const node: Record<string, unknown> = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(properties).length > 0) node.properties = properties
|
|
65
|
+
return node
|
|
66
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { levenshtein, suggest } from './helpers.js'
|
|
3
|
+
|
|
4
|
+
describe('levenshtein', () => {
|
|
5
|
+
test('identical strings', () => {
|
|
6
|
+
expect(levenshtein('abc', 'abc')).toBe(0)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('single insertion', () => {
|
|
10
|
+
expect(levenshtein('abc', 'abcd')).toBe(1)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('single deletion', () => {
|
|
14
|
+
expect(levenshtein('abcd', 'abc')).toBe(1)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('single substitution', () => {
|
|
18
|
+
expect(levenshtein('abc', 'axc')).toBe(1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('transposition counts as 2', () => {
|
|
22
|
+
expect(levenshtein('mpc', 'mcp')).toBe(2)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('empty strings', () => {
|
|
26
|
+
expect(levenshtein('', '')).toBe(0)
|
|
27
|
+
expect(levenshtein('abc', '')).toBe(3)
|
|
28
|
+
expect(levenshtein('', 'abc')).toBe(3)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('suggest', () => {
|
|
33
|
+
const commands = ['deploy', 'status', 'list', 'create']
|
|
34
|
+
|
|
35
|
+
test('returns closest match within threshold', () => {
|
|
36
|
+
expect(suggest('deplyo', commands)).toBe('deploy')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('returns match for transposition', () => {
|
|
40
|
+
expect(suggest('mpc', ['mcp', 'skills', 'completions'])).toBe('mcp')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('returns undefined when no match is close enough', () => {
|
|
44
|
+
expect(suggest('xyz', commands)).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('returns undefined for empty candidates', () => {
|
|
48
|
+
expect(suggest('deploy', [])).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('picks the closest among multiple candidates', () => {
|
|
52
|
+
expect(suggest('craete', ['list', 'create', 'delete'])).toBe('create')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Checks whether a value is a plain object record. */
|
|
2
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Converts a camelCase string to kebab-case. */
|
|
7
|
+
export function toKebab(value: string): string {
|
|
8
|
+
return value.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Computes the Levenshtein edit distance between two strings. */
|
|
12
|
+
export function levenshtein(a: string, b: string): number {
|
|
13
|
+
const m = a.length
|
|
14
|
+
const n = b.length
|
|
15
|
+
const dp: number[] = Array.from({ length: n + 1 }, (_, i) => i)
|
|
16
|
+
for (let i = 1; i <= m; i++) {
|
|
17
|
+
let prev = dp[0]!
|
|
18
|
+
dp[0] = i
|
|
19
|
+
for (let j = 1; j <= n; j++) {
|
|
20
|
+
const tmp = dp[j]!
|
|
21
|
+
dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j]!, dp[j - 1]!)
|
|
22
|
+
prev = tmp
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return dp[n]!
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Suggests the closest command name from a set, returning it if within a reasonable edit distance. */
|
|
29
|
+
export function suggest(input: string, candidates: Iterable<string>): string | undefined {
|
|
30
|
+
const threshold = input.length <= 4 ? 2 : Math.floor(input.length / 2)
|
|
31
|
+
let best: string | undefined
|
|
32
|
+
let bestDist = threshold + 1
|
|
33
|
+
for (const c of candidates) {
|
|
34
|
+
const d = levenshtein(input, c)
|
|
35
|
+
if (d < bestDist) {
|
|
36
|
+
bestDist = d
|
|
37
|
+
best = c
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return best
|
|
41
|
+
}
|