fix: Resolve display names and case-insensitive input in CLI (#306)

Users typing "spawn Claude" or "spawn Claude Code" now get resolved
to the correct key automatically instead of an "invalid characters"
error. Works for both agents and clouds in single-arg info and
two-arg run paths.

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-10 17:37:51 -08:00 committed by GitHub
parent fdca682072
commit 832f3f8ec1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 171 additions and 23 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.21",
"version": "0.2.22",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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();
});
});
});

View file

@ -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<void> {
// ── Run ────────────────────────────────────────────────────────────────────────
export async function cmdRun(agent: string, cloud: string, prompt?: string): Promise<void> {
// 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 <cloud> <agent>" instead of "spawn <agent> <cloud>"
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}`)}`);

View file

@ -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<void> {
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<void> {