fix: suggest cross-kind fuzzy matches when args may be swapped with typos (#510)

When a user types `spawn htzner claude` (cloud name typo as first arg),
checkEntity now detects that "htzner" is close to cloud "hetzner" and
suggests the user may have swapped agent and cloud arguments. Previously,
this only worked for exact cloud names; typos would produce a generic
"Unknown agent" error with no helpful suggestion.

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-11 12:47:53 -08:00 committed by GitHub
parent 9458836ca1
commit ff13a5fda9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 55 additions and 1 deletions

View file

@ -412,6 +412,45 @@ describe("checkEntity", () => {
});
});
// ── Cross-kind fuzzy match: detect swapped args with typos ──────────
describe("cross-kind fuzzy match for swapped args with typos", () => {
it("should return false for 'htzner' as agent (close to cloud 'hetzner')", () => {
expect(checkEntity(manifest, "htzner", "agent")).toBe(false);
});
it("should return false for 'sprit' as agent (close to cloud 'sprite')", () => {
expect(checkEntity(manifest, "sprit", "agent")).toBe(false);
});
it("should return false for 'vulr' as agent (close to cloud 'vultr')", () => {
expect(checkEntity(manifest, "vulr", "agent")).toBe(false);
});
it("should return false for 'claud' as cloud (close to agent 'claude')", () => {
expect(checkEntity(manifest, "claud", "cloud")).toBe(false);
});
it("should return false for 'aidr' as cloud (close to agent 'aider')", () => {
expect(checkEntity(manifest, "aidr", "cloud")).toBe(false);
});
it("should return false for 'goos' as cloud (close to agent 'goose')", () => {
expect(checkEntity(manifest, "goos", "cloud")).toBe(false);
});
it("should prefer same-kind match over cross-kind match", () => {
// "goose" checked as agent should match exactly (same-kind), not cross-kind
expect(checkEntity(manifest, "goose", "agent")).toBe(true);
});
it("should not suggest cross-kind match for values far from any candidate", () => {
// "zzzzzzz" is far from all agent and cloud names
expect(checkEntity(manifest, "zzzzzzz", "agent")).toBe(false);
expect(checkEntity(manifest, "zzzzzzz", "cloud")).toBe(false);
});
});
// ── Manifest with overlapping key names ────────────────────────────────
describe("manifest with overlapping patterns", () => {

View file

@ -182,6 +182,7 @@ export function checkEntity(manifest: Manifest, value: string, kind: "agent" | "
p.log.error(`Unknown ${def.label}: ${pc.bold(value)}`);
const oppositeKind = kind === "agent" ? "cloud" : "agent";
const oppositeDef = ENTITY_DEFS[oppositeKind];
const oppositeCollection = getEntityCollection(manifest, oppositeKind);
if (oppositeCollection[value]) {
p.log.info(`"${value}" is ${kind === "agent" ? "a cloud provider" : "an agent"}, not ${kind === "agent" ? "an agent" : "a cloud provider"}.`);
@ -190,12 +191,26 @@ export function checkEntity(manifest: Manifest, value: string, kind: "agent" | "
return false;
}
// Check for typo matches in the same kind
const keys = getEntityKeys(manifest, kind);
const match = findClosestKeyByNameOrKey(value, keys, (k) => collection[k].name);
if (match) {
p.log.info(`Did you mean ${pc.cyan(match)} (${collection[match].name})?`);
p.log.info(` ${pc.cyan(`spawn ${match}`)}`);
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
return false;
}
// Check for typo matches in the opposite kind (swapped arguments with typo)
const oppositeKeys = getEntityKeys(manifest, oppositeKind);
const oppositeMatch = findClosestKeyByNameOrKey(value, oppositeKeys, (k) => oppositeCollection[k].name);
if (oppositeMatch) {
p.log.info(`"${pc.bold(value)}" looks like ${oppositeDef.label} ${pc.cyan(oppositeMatch)} (${oppositeCollection[oppositeMatch].name}).`);
p.log.info(`Did you swap the agent and cloud arguments?`);
p.log.info(`Usage: ${pc.cyan("spawn <agent> <cloud>")}`);
return false;
}
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
return false;
}