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; }