Skip to content

Commit 1f4f1bf

Browse files
authored
chore: command suggestions enhancements (#828)
1 parent d1dfc66 commit 1f4f1bf

File tree

6 files changed

+191
-13
lines changed

6 files changed

+191
-13
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"is-ci": "~4.1.0",
9696
"istextorbinary": "~9.5.0",
9797
"jju": "~1.4.0",
98+
"js-levenshtein": "^1.1.6",
9899
"lodash.clonedeep": "^4.5.0",
99100
"mime": "~4.0.4",
100101
"mixpanel": "~0.18.0",
@@ -127,6 +128,7 @@
127128
"@types/inquirer": "^9.0.7",
128129
"@types/is-ci": "^3.0.4",
129130
"@types/jju": "^1.4.5",
131+
"@types/js-levenshtein": "^1",
130132
"@types/lodash.clonedeep": "^4",
131133
"@types/mime": "^4.0.0",
132134
"@types/node": "^22.0.0",

src/commands/help.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { jaroWinkler } from '@skyra/jaro-winkler';
21
import chalk from 'chalk';
32

43
import { ApifyCommand, commandRegistry } from '../lib/command-framework/apify-command.js';
54
import { Args } from '../lib/command-framework/args.js';
65
import { renderHelpForCommand, renderMainHelpMenu } from '../lib/command-framework/help.js';
6+
import { useCommandSuggestions } from '../lib/hooks/useCommandSuggestions.js';
77
import { error } from '../lib/outputs.js';
88

99
export class HelpCommand extends ApifyCommand<typeof HelpCommand> {
@@ -37,13 +37,7 @@ export class HelpCommand extends ApifyCommand<typeof HelpCommand> {
3737
const command = commandRegistry.get(lowercasedCommandString);
3838

3939
if (!command) {
40-
const allCommands = [...commandRegistry.keys()];
41-
42-
const closestMatches = allCommands.filter((cmd) => {
43-
const lowercased = cmd.toLowerCase();
44-
45-
return jaroWinkler(lowercasedCommandString, lowercased) >= 0.95;
46-
});
40+
const closestMatches = useCommandSuggestions(lowercasedCommandString);
4741

4842
let message = chalk.gray(`Command ${chalk.whiteBright(commandString)} not found`);
4943

src/entrypoints/_shared.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { renderMainHelpMenu, selectiveRenderHelpForCommand } from '../lib/comman
1515
import { readStdin } from '../lib/commands/read-stdin.js';
1616
import { useCLIMetadata } from '../lib/hooks/useCLIMetadata.js';
1717
import { shouldSkipVersionCheck } from '../lib/hooks/useCLIVersionCheck.js';
18+
import { useCommandSuggestions } from '../lib/hooks/useCommandSuggestions.js';
1819
import { error } from '../lib/outputs.js';
1920
import { cliDebugPrint } from '../lib/utils/cliDebugPrint.js';
2021

@@ -122,9 +123,18 @@ export async function runCLI(entrypoint: string) {
122123
const command = commandRegistry.get(possibleCommands.find((cmd) => commandRegistry.has(cmd)) ?? '');
123124

124125
if (!command) {
125-
error({
126-
message: `Command ${parsed._[0]} not found`,
127-
});
126+
const closestMatches = useCommandSuggestions(String(parsed._[0]));
127+
128+
let message = chalk.gray(`Command ${chalk.whiteBright(parsed._[0])} not found`);
129+
130+
if (closestMatches.length) {
131+
message += '\n ';
132+
message += chalk.gray(
133+
`Did you mean: ${closestMatches.map((cmd) => chalk.whiteBright(cmd)).join(', ')}?`,
134+
);
135+
}
136+
137+
error({ message });
128138

129139
return;
130140
}
@@ -221,10 +231,26 @@ export async function runCLI(entrypoint: string) {
221231
return errorMessageSplit[1];
222232
})();
223233

234+
const closestMatches =
235+
nonexistentType === 'subcommand'
236+
? useCommandSuggestions(`${parsed._[0]} ${errorMessageSplit[1]}`)
237+
: [];
238+
239+
const messageParts = [
240+
chalk.gray(`Nonexistent ${nonexistentType}: ${chalk.whiteBright(nonexistentRepresentation)}`),
241+
];
242+
243+
if (closestMatches.length) {
244+
messageParts.push(
245+
chalk.gray(
246+
` Did you mean: ${closestMatches.map((cmd) => chalk.whiteBright(cmd)).join(', ')}?`,
247+
),
248+
);
249+
}
250+
224251
error({
225252
message: [
226-
`Nonexistent ${nonexistentType}: ${nonexistentRepresentation}`,
227-
` ${chalk.red('>')} See more help with --help`,
253+
...messageParts,
228254
'',
229255
selectiveRenderHelpForCommand(command, {
230256
showUsageString: true,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { jaroWinkler } from '@skyra/jaro-winkler';
2+
import levenshtein from 'js-levenshtein';
3+
4+
import { commandRegistry } from '../command-framework/apify-command.js';
5+
import { cliDebugPrint } from '../utils/cliDebugPrint.js';
6+
7+
export function useCommandSuggestions(inputString: string) {
8+
const allCommands = [...commandRegistry.entries()].sort(([a], [b]) => a.localeCompare(b));
9+
10+
const lowercasedCommandString = inputString.toLowerCase();
11+
12+
const closestMatches = allCommands
13+
.map(([cmdString, cmdClass]) => {
14+
const lowercased = cmdString.toLowerCase();
15+
const cmdStringParts = cmdString.split(' ');
16+
const lastPart = cmdStringParts[cmdStringParts.length - 1].toLowerCase();
17+
18+
const isAlias = cmdClass.aliases?.includes(lastPart) || cmdClass.hiddenAliases?.includes(lastPart) || false;
19+
20+
const levenshteinDistance = levenshtein(lowercasedCommandString, lowercased);
21+
const jaroWinklerDistance = jaroWinkler(lowercasedCommandString, lowercased);
22+
23+
const matches = levenshteinDistance <= 2 || jaroWinklerDistance >= 0.975;
24+
25+
if (matches) {
26+
cliDebugPrint('useCommandSuggestions', {
27+
inputString: lowercasedCommandString,
28+
lowercased,
29+
matches,
30+
levenshtein: levenshteinDistance,
31+
jaroWinkler: jaroWinklerDistance,
32+
});
33+
34+
if (!isAlias) {
35+
return { string: `${lowercased}`, distance: jaroWinklerDistance };
36+
}
37+
38+
return { string: `${lowercased} (alias for ${cmdClass.name})`, distance: jaroWinklerDistance };
39+
}
40+
41+
return null;
42+
})
43+
.filter((item) => item !== null)
44+
.sort((a, b) => b.distance - a.distance)
45+
.map((item) => item.string);
46+
47+
return closestMatches;
48+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/* eslint-disable max-classes-per-file */
2+
import {
3+
ApifyCommand,
4+
type BuiltApifyCommand as _BuiltApifyCommand,
5+
commandRegistry,
6+
} from '../../../src/lib/command-framework/apify-command.js';
7+
import { useCommandSuggestions } from '../../../src/lib/hooks/useCommandSuggestions.js';
8+
9+
const BuiltApifyCommand = ApifyCommand as typeof _BuiltApifyCommand;
10+
11+
const subcommands = [
12+
class KVSGetValue extends BuiltApifyCommand {
13+
static override name = 'get-value';
14+
},
15+
class KVSSetValue extends BuiltApifyCommand {
16+
static override name = 'set-value';
17+
},
18+
];
19+
20+
const fakeCommands = [
21+
class Help extends BuiltApifyCommand {
22+
static override name = 'help';
23+
},
24+
class Upgrade extends BuiltApifyCommand {
25+
static override name = 'upgrade';
26+
static override aliases = ['cv', 'check-version'];
27+
},
28+
class KeyValueStores extends BuiltApifyCommand {
29+
static override name = 'key-value-stores';
30+
static override aliases = ['kvs'];
31+
static override subcommands = subcommands;
32+
},
33+
];
34+
35+
describe('useCommandSuggestions', () => {
36+
let existingCommands: [string, typeof BuiltApifyCommand][];
37+
38+
beforeAll(() => {
39+
existingCommands = [...commandRegistry.entries()];
40+
41+
commandRegistry.clear();
42+
43+
for (const command of fakeCommands) {
44+
commandRegistry.set(command.name, command);
45+
46+
if (command.aliases?.length) {
47+
for (const alias of command.aliases) {
48+
commandRegistry.set(alias, command);
49+
50+
if (command.subcommands?.length) {
51+
for (const subcommand of command.subcommands) {
52+
commandRegistry.set(`${alias} ${subcommand.name}`, subcommand);
53+
}
54+
}
55+
}
56+
}
57+
58+
if (command.subcommands?.length) {
59+
for (const subcommand of command.subcommands) {
60+
commandRegistry.set(`${command.name} ${subcommand.name}`, subcommand);
61+
62+
if (command.aliases?.length) {
63+
for (const alias of command.aliases) {
64+
commandRegistry.set(`${command.name} ${alias}`, subcommand);
65+
}
66+
}
67+
}
68+
}
69+
}
70+
});
71+
72+
afterAll(() => {
73+
commandRegistry.clear();
74+
75+
for (const [name, command] of existingCommands) {
76+
commandRegistry.set(name, command);
77+
}
78+
});
79+
80+
test.each([
81+
['hlp', 'help'],
82+
['kv', 'kvs (alias for key-value-stores)', 'cv (alias for upgrade)'],
83+
['key-value-stor', 'key-value-stores'],
84+
// assert order based on distance
85+
['kvs get-values', 'kvs get-value', 'kvs set-value'],
86+
['kvs set-values', 'kvs set-value', 'kvs get-value'],
87+
])('command suggestions for %s', (input, ...expected) => {
88+
const suggestions = useCommandSuggestions(input);
89+
90+
expect(suggestions).toEqual(expected);
91+
});
92+
});

yarn.lock

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,13 @@ __metadata:
16871687
languageName: node
16881688
linkType: hard
16891689

1690+
"@types/js-levenshtein@npm:^1":
1691+
version: 1.1.3
1692+
resolution: "@types/js-levenshtein@npm:1.1.3"
1693+
checksum: 10c0/025f2bd8d865cfa7a996799a1a2f2a77fa2fc74a28971aa035a103de35d7c1e3d949721a88f57fdb532815bbcb2bf7019196a608ed0a8bbd1023d64c52bb251b
1694+
languageName: node
1695+
linkType: hard
1696+
16901697
"@types/json-schema@npm:^7.0.15":
16911698
version: 7.0.15
16921699
resolution: "@types/json-schema@npm:7.0.15"
@@ -2432,6 +2439,7 @@ __metadata:
24322439
"@types/inquirer": "npm:^9.0.7"
24332440
"@types/is-ci": "npm:^3.0.4"
24342441
"@types/jju": "npm:^1.4.5"
2442+
"@types/js-levenshtein": "npm:^1"
24352443
"@types/lodash.clonedeep": "npm:^4"
24362444
"@types/mime": "npm:^4.0.0"
24372445
"@types/node": "npm:^22.0.0"
@@ -2465,6 +2473,7 @@ __metadata:
24652473
is-ci: "npm:~4.1.0"
24662474
istextorbinary: "npm:~9.5.0"
24672475
jju: "npm:~1.4.0"
2476+
js-levenshtein: "npm:^1.1.6"
24682477
lint-staged: "npm:^16.0.0"
24692478
lodash.clonedeep: "npm:^4.5.0"
24702479
mdast-util-from-markdown: "npm:^2.0.2"
@@ -5957,6 +5966,13 @@ __metadata:
59575966
languageName: node
59585967
linkType: hard
59595968

5969+
"js-levenshtein@npm:^1.1.6":
5970+
version: 1.1.6
5971+
resolution: "js-levenshtein@npm:1.1.6"
5972+
checksum: 10c0/14045735325ea1fd87f434a74b11d8a14380f090f154747e613529c7cff68b5ee607f5230fa40665d5fb6125a3791f4c223f73b9feca754f989b059f5c05864f
5973+
languageName: node
5974+
linkType: hard
5975+
59605976
"js-tokens@npm:^4.0.0":
59615977
version: 4.0.0
59625978
resolution: "js-tokens@npm:4.0.0"

0 commit comments

Comments
 (0)