diff --git a/cli/package.json b/cli/package.json index 14a7a3d9..ea9a3b87 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.21", + "version": "0.2.22", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/commands-helpers.test.ts b/cli/src/__tests__/commands-helpers.test.ts index 0b70b0f5..3058f4e5 100644 --- a/cli/src/__tests__/commands-helpers.test.ts +++ b/cli/src/__tests__/commands-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import { levenshtein, findClosestMatch } from "../commands"; +import { levenshtein, findClosestMatch, resolveAgentKey, resolveCloudKey } from "../commands"; +import type { Manifest } from "../manifest"; /** * Tests for helper functions in commands.ts @@ -312,4 +313,87 @@ describe("Command Helpers", () => { expect(desc).toBe("HTTP 403"); }); }); + + describe("resolveAgentKey", () => { + const manifest = { + agents: { + claude: { name: "Claude Code", description: "AI assistant", url: "", install: "", launch: "", env: {} }, + aider: { name: "Aider", description: "AI pair programmer", url: "", install: "", launch: "", env: {} }, + "open-interpreter": { name: "Open Interpreter", description: "Code interpreter", url: "", install: "", launch: "", env: {} }, + }, + clouds: {}, + matrix: {}, + } as unknown as Manifest; + + it("should return exact key match", () => { + expect(resolveAgentKey(manifest, "claude")).toBe("claude"); + }); + + it("should resolve case-insensitive key", () => { + expect(resolveAgentKey(manifest, "Claude")).toBe("claude"); + expect(resolveAgentKey(manifest, "AIDER")).toBe("aider"); + }); + + it("should resolve display name to key", () => { + expect(resolveAgentKey(manifest, "Claude Code")).toBe("claude"); + expect(resolveAgentKey(manifest, "Aider")).toBe("aider"); + }); + + it("should resolve display name case-insensitively", () => { + expect(resolveAgentKey(manifest, "claude code")).toBe("claude"); + expect(resolveAgentKey(manifest, "CLAUDE CODE")).toBe("claude"); + }); + + it("should resolve hyphenated key case-insensitively", () => { + expect(resolveAgentKey(manifest, "Open-Interpreter")).toBe("open-interpreter"); + }); + + it("should return null for unknown input", () => { + expect(resolveAgentKey(manifest, "nonexistent")).toBeNull(); + expect(resolveAgentKey(manifest, "kubernetes")).toBeNull(); + }); + + it("should return null for empty input", () => { + expect(resolveAgentKey(manifest, "")).toBeNull(); + }); + }); + + describe("resolveCloudKey", () => { + const manifest = { + agents: {}, + clouds: { + sprite: { name: "Sprite", description: "Fast VM", url: "", type: "vm", auth: "", provision_method: "", exec_method: "", interactive_method: "" }, + hetzner: { name: "Hetzner Cloud", description: "EU cloud", url: "", type: "vm", auth: "", provision_method: "", exec_method: "", interactive_method: "" }, + "digital-ocean": { name: "DigitalOcean", description: "Cloud VPS", url: "", type: "vm", auth: "", provision_method: "", exec_method: "", interactive_method: "" }, + }, + matrix: {}, + } as unknown as Manifest; + + it("should return exact key match", () => { + expect(resolveCloudKey(manifest, "sprite")).toBe("sprite"); + }); + + it("should resolve case-insensitive key", () => { + expect(resolveCloudKey(manifest, "Sprite")).toBe("sprite"); + expect(resolveCloudKey(manifest, "HETZNER")).toBe("hetzner"); + }); + + it("should resolve display name to key", () => { + expect(resolveCloudKey(manifest, "Hetzner Cloud")).toBe("hetzner"); + expect(resolveCloudKey(manifest, "DigitalOcean")).toBe("digital-ocean"); + }); + + it("should resolve display name case-insensitively", () => { + expect(resolveCloudKey(manifest, "hetzner cloud")).toBe("hetzner"); + expect(resolveCloudKey(manifest, "digitalocean")).toBe("digital-ocean"); + }); + + it("should return null for unknown input", () => { + expect(resolveCloudKey(manifest, "aws")).toBeNull(); + }); + + it("should return null for empty input", () => { + expect(resolveCloudKey(manifest, "")).toBeNull(); + }); + }); }); diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 86a4d4dd..fe35aba7 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -108,6 +108,40 @@ export function findClosestMatch(input: string, candidates: string[]): string | return bestDist <= 3 ? best : null; } +/** + * Resolve user input to a valid agent key. + * Tries: exact key -> case-insensitive key -> display name match (case-insensitive). + * Returns the key if found, or null. + */ +export function resolveAgentKey(manifest: Manifest, input: string): string | null { + if (manifest.agents[input]) return input; + const lower = input.toLowerCase(); + for (const key of agentKeys(manifest)) { + if (key.toLowerCase() === lower) return key; + } + for (const key of agentKeys(manifest)) { + if (manifest.agents[key].name.toLowerCase() === lower) return key; + } + return null; +} + +/** + * Resolve user input to a valid cloud key. + * Tries: exact key -> case-insensitive key -> display name match (case-insensitive). + * Returns the key if found, or null. + */ +export function resolveCloudKey(manifest: Manifest, input: string): string | null { + if (manifest.clouds[input]) return input; + const lower = input.toLowerCase(); + for (const key of cloudKeys(manifest)) { + if (key.toLowerCase() === lower) return key; + } + for (const key of cloudKeys(manifest)) { + if (manifest.clouds[key].name.toLowerCase() === lower) return key; + } + return null; +} + function validateAgent(manifest: Manifest, agent: string): asserts agent is keyof typeof manifest.agents { if (!manifest.agents[agent]) { p.log.error(`Unknown agent: ${pc.bold(agent)}`); @@ -216,6 +250,19 @@ export async function cmdInteractive(): Promise { // ── Run ──────────────────────────────────────────────────────────────────────── export async function cmdRun(agent: string, cloud: string, prompt?: string): Promise { + // Try to resolve display names / casing before strict validation + const manifest = await loadManifestWithSpinner(); + const resolvedAgent = resolveAgentKey(manifest, agent); + const resolvedCloud = resolveCloudKey(manifest, cloud); + if (resolvedAgent && resolvedAgent !== agent) { + p.log.info(`Resolved "${agent}" to ${pc.cyan(resolvedAgent)}`); + agent = resolvedAgent; + } + if (resolvedCloud && resolvedCloud !== cloud) { + p.log.info(`Resolved "${cloud}" to ${pc.cyan(resolvedCloud)}`); + cloud = resolvedCloud; + } + // SECURITY: Validate input arguments for injection attacks try { validateIdentifier(agent, "Agent name"); @@ -232,7 +279,6 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro validateNonEmptyString(cloud, "Cloud name", "spawn clouds"); // Detect swapped arguments: user typed "spawn " instead of "spawn " - const manifest = await loadManifestWithSpinner(); if (!manifest.agents[agent] && manifest.clouds[agent] && manifest.agents[cloud]) { p.log.warn(`It looks like you swapped the agent and cloud arguments.`); p.log.info(`Running: ${pc.cyan(`spawn ${cloud} ${agent}`)}`); diff --git a/cli/src/index.ts b/cli/src/index.ts index 2c7ceb76..c89e7e2e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -10,6 +10,8 @@ import { cmdUpdate, cmdHelp, findClosestMatch, + resolveAgentKey, + resolveCloudKey, } from "./commands.js"; import pc from "picocolors"; import pkg from "../package.json" with { type: "json" }; @@ -86,27 +88,43 @@ async function showInfoOrError(name: string): Promise { const manifest = await loadManifest(); if (manifest.agents[name]) { await cmdAgentInfo(name); - } else if (manifest.clouds[name]) { - await cmdCloudInfo(name); - } else { - const agentMatch = findClosestMatch(name, agentKeys(manifest)); - const cloudMatch = findClosestMatch(name, cloudKeys(manifest)); - - console.error(pc.red(`Unknown command: ${pc.bold(name)}`)); - console.error(); - if (agentMatch && cloudMatch) { - console.error(` Did you mean ${pc.cyan(agentMatch)} (agent) or ${pc.cyan(cloudMatch)} (cloud)?`); - } else if (agentMatch) { - console.error(` Did you mean ${pc.cyan(agentMatch)} (agent)?`); - } else if (cloudMatch) { - console.error(` Did you mean ${pc.cyan(cloudMatch)} (cloud)?`); - } - console.error(); - console.error(` Run ${pc.cyan("spawn agents")} to see available agents.`); - console.error(` Run ${pc.cyan("spawn clouds")} to see available clouds.`); - console.error(` Run ${pc.cyan("spawn help")} for usage information.`); - process.exit(1); + return; } + if (manifest.clouds[name]) { + await cmdCloudInfo(name); + return; + } + + // Try resolving display names and case-insensitive matches + const resolvedAgent = resolveAgentKey(manifest, name); + if (resolvedAgent) { + await cmdAgentInfo(resolvedAgent); + return; + } + const resolvedCloud = resolveCloudKey(manifest, name); + if (resolvedCloud) { + await cmdCloudInfo(resolvedCloud); + return; + } + + // Fall back to fuzzy matching suggestions + const agentMatch = findClosestMatch(name, agentKeys(manifest)); + const cloudMatch = findClosestMatch(name, cloudKeys(manifest)); + + console.error(pc.red(`Unknown command: ${pc.bold(name)}`)); + console.error(); + if (agentMatch && cloudMatch) { + console.error(` Did you mean ${pc.cyan(agentMatch)} (agent) or ${pc.cyan(cloudMatch)} (cloud)?`); + } else if (agentMatch) { + console.error(` Did you mean ${pc.cyan(agentMatch)} (agent)?`); + } else if (cloudMatch) { + console.error(` Did you mean ${pc.cyan(cloudMatch)} (cloud)?`); + } + console.error(); + console.error(` Run ${pc.cyan("spawn agents")} to see available agents.`); + console.error(` Run ${pc.cyan("spawn clouds")} to see available clouds.`); + console.error(` Run ${pc.cyan("spawn help")} for usage information.`); + process.exit(1); } async function handleDefaultCommand(agent: string, cloud: string | undefined, prompt?: string): Promise {