Skip to content

Commit 267adc6

Browse files
committed
feat(cli): suggest closest command for unknown root subcommands with explicit alias map for upgrade/remove/ls/ping (#83999)
1 parent 1d77170 commit 267adc6

3 files changed

Lines changed: 165 additions & 2 deletions

File tree

src/cli/program/error-output.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { formatCliParseErrorOutput } from "./error-output.js";
2+
import { formatCliParseErrorOutput, suggestClosestCommand } from "./error-output.js";
33

44
describe("formatCliParseErrorOutput", () => {
55
it("explains unknown commands with root help and plugin hints", () => {
@@ -12,6 +12,43 @@ describe("formatCliParseErrorOutput", () => {
1212
);
1313
});
1414

15+
it("suggests the closest root command for a typo when knownCommands is provided (#83999)", () => {
16+
const output = formatCliParseErrorOutput("error: unknown command 'upate'\n", {
17+
argv: ["node", "openclaw", "upate"],
18+
knownCommands: ["update", "doctor", "channels", "plugins"],
19+
});
20+
21+
expect(output).toBe(
22+
'OpenClaw does not know the command "upate".\nDid you mean this?\n openclaw update\nTry: openclaw --help\nPlugin command? openclaw plugins list\nDocs: https://docs.openclaw.ai/cli\n',
23+
);
24+
});
25+
26+
it("surfaces the explicit alias for `upgrade` -> `update` (#83999)", () => {
27+
const output = formatCliParseErrorOutput("error: unknown command 'upgrade'\n", {
28+
argv: ["node", "openclaw", "upgrade"],
29+
knownCommands: ["update", "doctor"],
30+
});
31+
32+
expect(output).toContain("Did you mean this?\n openclaw update");
33+
});
34+
35+
it("does not suggest when the typo is far from every known command (#83999)", () => {
36+
const output = formatCliParseErrorOutput("error: unknown command 'zzqwerty'\n", {
37+
argv: ["node", "openclaw", "zzqwerty"],
38+
knownCommands: ["update", "doctor", "channels"],
39+
});
40+
41+
expect(output).not.toContain("Did you mean this?");
42+
});
43+
44+
it("is a no-op when knownCommands is not provided (legacy caller shape)", () => {
45+
const output = formatCliParseErrorOutput("error: unknown command 'upate'\n", {
46+
argv: ["node", "openclaw", "upate"],
47+
});
48+
49+
expect(output).not.toContain("Did you mean this?");
50+
});
51+
1552
it("points unknown options at the active command help", () => {
1653
const output = formatCliParseErrorOutput("error: unknown option '--wat'\n", {
1754
argv: ["node", "openclaw", "channels", "status", "--wat"],
@@ -32,3 +69,34 @@ describe("formatCliParseErrorOutput", () => {
3269
);
3370
});
3471
});
72+
73+
describe("suggestClosestCommand (#83999)", () => {
74+
const known = ["update", "doctor", "channels", "plugins", "agents", "status"];
75+
76+
it("picks the canonical update on `upate`", () => {
77+
expect(suggestClosestCommand("upate", known)).toBe("update");
78+
});
79+
80+
it("returns undefined when nothing is close enough", () => {
81+
expect(suggestClosestCommand("zzqwerty", known)).toBeUndefined();
82+
});
83+
84+
it("honors the explicit upgrade -> update alias even when edit distance picks another", () => {
85+
// `upgrade` distance to `update` is 2 (`gr` -> `t`) — without the alias map
86+
// the Levenshtein-only path could ambiguously land on another short root.
87+
expect(suggestClosestCommand("upgrade", known)).toBe("update");
88+
});
89+
90+
it("does not invent a command — alias target must be in knownCommands", () => {
91+
expect(suggestClosestCommand("upgrade", ["doctor", "channels"])).toBeUndefined();
92+
});
93+
94+
it("returns undefined for empty input", () => {
95+
expect(suggestClosestCommand("", known)).toBeUndefined();
96+
expect(suggestClosestCommand(" ", known)).toBeUndefined();
97+
});
98+
99+
it("matches case-insensitively against known commands", () => {
100+
expect(suggestClosestCommand("DocTr", known)).toBe("doctor");
101+
});
102+
});

src/cli/program/error-output.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,89 @@ import { formatCliCommand } from "../command-format.js";
55

66
type FormatCliParseErrorOptions = {
77
argv?: string[];
8+
/**
9+
* Known root command + sub-CLI root names to consider for the
10+
* "Did you mean this?" suggester on unknown commands (#83999). When omitted
11+
* the suggester is a no-op, matching legacy callers / tests.
12+
*/
13+
knownCommands?: readonly string[];
814
};
915

16+
/**
17+
* Explicit aliases for common terminology that doesn't fall out of edit
18+
* distance — e.g. `upgrade` vs `update`. Keep tight; only add entries where
19+
* the alias is a widely-shared synonym, not a typo cluster, so the suggestion
20+
* always points at a canonical command without surfacing accidental API
21+
* surface. See #83999.
22+
*/
23+
const COMMAND_ALIASES: Record<string, string> = {
24+
upgrade: "update",
25+
remove: "uninstall",
26+
rm: "uninstall",
27+
ls: "list",
28+
ping: "doctor",
29+
};
30+
31+
function levenshtein(a: string, b: string): number {
32+
if (a === b) {
33+
return 0;
34+
}
35+
if (a.length === 0) {
36+
return b.length;
37+
}
38+
if (b.length === 0) {
39+
return a.length;
40+
}
41+
// Two-row dynamic-programming Levenshtein — O(min(a, b)) memory, O(a*b) time.
42+
// Sufficient for CLI command names; openclaw doesn't ship 1000-character roots.
43+
const prev: number[] = Array.from({ length: b.length + 1 }, (_, j) => j);
44+
const curr: number[] = Array.from({ length: b.length + 1 }, () => 0);
45+
for (let i = 1; i <= a.length; i += 1) {
46+
curr[0] = i;
47+
for (let j = 1; j <= b.length; j += 1) {
48+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
49+
curr[j] = Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
50+
}
51+
for (let j = 0; j <= b.length; j += 1) {
52+
prev[j] = curr[j] ?? 0;
53+
}
54+
}
55+
return prev[b.length] ?? 0;
56+
}
57+
58+
export function suggestClosestCommand(
59+
unknown: string,
60+
known: readonly string[],
61+
): string | undefined {
62+
const input = unknown.trim().toLowerCase();
63+
if (!input) {
64+
return undefined;
65+
}
66+
// Explicit alias wins over edit-distance — `openclaw upgrade` should always
67+
// suggest `openclaw update`, regardless of how close other commands look.
68+
const aliased = COMMAND_ALIASES[input];
69+
if (aliased && known.includes(aliased)) {
70+
return aliased;
71+
}
72+
// npm-style threshold: distance must be small relative to input length so we
73+
// don't surface a misleading suggestion for genuinely off-vocabulary inputs.
74+
const threshold = Math.max(1, Math.floor(input.length * 0.4));
75+
let best: { name: string; distance: number } | undefined;
76+
for (const name of known) {
77+
if (!name) {
78+
continue;
79+
}
80+
const distance = levenshtein(input, name.toLowerCase());
81+
if (distance > threshold) {
82+
continue;
83+
}
84+
if (!best || distance < best.distance) {
85+
best = { name, distance };
86+
}
87+
}
88+
return best?.name;
89+
}
90+
1091
function stripCommanderErrorPrefix(raw: string): string {
1192
return raw
1293
.trim()
@@ -49,8 +130,13 @@ export function formatCliParseErrorOutput(
49130
const unknownCommand = message.match(/^unknown command ['"`](.+?)['"`]/i);
50131
if (unknownCommand) {
51132
const command = unknownCommand[1] ?? "";
133+
const suggestion = options.knownCommands
134+
? suggestClosestCommand(command, options.knownCommands)
135+
: undefined;
52136
return lines(
53137
theme.error(`OpenClaw does not know the command ${quote(command)}.`),
138+
suggestion ? theme.muted("Did you mean this?") : undefined,
139+
suggestion ? ` ${theme.command(formatCliCommand(`openclaw ${suggestion}`))}` : undefined,
54140
formatHelpHint(options.argv, { root: true }),
55141
`${theme.muted("Plugin command?")} ${theme.command(formatCliCommand("openclaw plugins list"))}`,
56142
formatDocsHint(),

src/cli/program/help.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,16 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
105105
writeErr: (str) => {
106106
process.stderr.write(formatHelpOutput(str));
107107
},
108-
outputError: (str, write) => write(formatCliParseErrorOutput(str, { argv: process.argv })),
108+
outputError: (str, write) =>
109+
write(
110+
formatCliParseErrorOutput(str, {
111+
argv: process.argv,
112+
// Surface every registered root + sub-CLI root name so the
113+
// "Did you mean this?" suggester (#83999) can score the unknown
114+
// command against the same catalog `openclaw --help` would list.
115+
knownCommands: program.commands.map((cmd) => cmd.name()),
116+
}),
117+
),
109118
});
110119

111120
if (

0 commit comments

Comments
 (0)