mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
fdca682072
commit
832f3f8ec1
4 changed files with 171 additions and 23 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.2.21",
|
||||
"version": "0.2.22",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`)}`);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue