From ff13a5fda96fc57ceaaeeaa2f595e6e245ecae6e Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:47:53 -0800 Subject: [PATCH] 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 --- cli/package.json | 2 +- cli/src/__tests__/check-entity.test.ts | 39 ++++++++++++++++++++++++++ cli/src/commands.ts | 15 ++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index c8830176..23a16b41 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.50", + "version": "0.2.51", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/check-entity.test.ts b/cli/src/__tests__/check-entity.test.ts index 7be14910..d73a113c 100644 --- a/cli/src/__tests__/check-entity.test.ts +++ b/cli/src/__tests__/check-entity.test.ts @@ -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", () => { diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 54c94c57..0ba1a040 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -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 ")}`); + return false; + } + p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`); return false; }