fix: Detect swapped agent/cloud arguments and fix count pluralization (#178)

When users type "spawn sprite claude" instead of "spawn claude sprite",
the CLI now detects the swap and suggests the correct order instead of
showing a confusing "Unknown agent" error. Also fixes grammar in
"spawn agents" and "spawn clouds" output (1 cloud vs 1 clouds).

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-10 07:42:25 -08:00 committed by GitHub
parent 836fd0db97
commit 86dfbacab0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 69 additions and 9 deletions

View file

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

View file

@ -27,6 +27,7 @@ const mockManifest = createMockManifest();
const mockLogError = mock(() => {});
const mockLogInfo = mock(() => {});
const mockLogStep = mock(() => {});
const mockLogWarn = mock(() => {});
const mockSpinnerStart = mock(() => {});
const mockSpinnerStop = mock(() => {});
@ -40,6 +41,7 @@ mock.module("@clack/prompts", () => ({
step: mockLogStep,
info: mockLogInfo,
error: mockLogError,
warn: mockLogWarn,
},
intro: mock(() => {}),
outro: mock(() => {}),
@ -61,6 +63,7 @@ describe("Commands Error Paths", () => {
mockLogError.mockClear();
mockLogInfo.mockClear();
mockLogStep.mockClear();
mockLogWarn.mockClear();
mockSpinnerStart.mockClear();
mockSpinnerStop.mockClear();
@ -347,4 +350,49 @@ describe("Commands Error Paths", () => {
expect(stepCalls.some((msg: string) => msg.includes("with prompt"))).toBe(true);
});
});
// ── cmdRun: swapped arguments detection ──────────────────────────────
describe("cmdRun - swapped arguments detection", () => {
it("should detect when cloud and agent arguments are swapped", async () => {
// "spawn sprite claude" should detect that sprite is a cloud and claude is an agent
await expect(cmdRun("sprite", "claude")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
const warnCalls = mockLogWarn.mock.calls.map((c: any[]) => c.join(" "));
expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(true);
});
it("should suggest the correct argument order when swapped", async () => {
await expect(cmdRun("sprite", "claude")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: any[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes("spawn claude sprite"))).toBe(true);
});
it("should suggest correct order for hetzner/aider swap", async () => {
await expect(cmdRun("hetzner", "aider")).rejects.toThrow("process.exit");
const warnCalls = mockLogWarn.mock.calls.map((c: any[]) => c.join(" "));
expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(true);
const infoCalls = mockLogInfo.mock.calls.map((c: any[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes("spawn aider hetzner"))).toBe(true);
});
it("should NOT trigger swap detection when both args are unknown", async () => {
await expect(cmdRun("unknown1", "unknown2")).rejects.toThrow("process.exit");
const warnCalls = mockLogWarn.mock.calls.map((c: any[]) => c.join(" "));
expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(false);
});
it("should NOT trigger swap detection when agent is valid", async () => {
// "spawn claude nonexistent" - agent is valid, cloud is not
await expect(cmdRun("claude", "nonexistent")).rejects.toThrow("process.exit");
const warnCalls = mockLogWarn.mock.calls.map((c: any[]) => c.join(" "));
expect(warnCalls.some((msg: string) => msg.includes("swapped"))).toBe(false);
});
});
});

View file

@ -27,6 +27,7 @@ mock.module("@clack/prompts", () => ({
log: {
step: mock(() => {}),
info: mock(() => {}),
warn: mock(() => {}),
error: mock(() => {}),
},
intro: mock(() => {}),
@ -137,7 +138,8 @@ describe("Command Output Functions", () => {
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
// claude has 2 clouds (sprite, hetzner), aider has 1 (sprite)
expect(output).toContain("2 clouds");
expect(output).toContain("1 clouds");
expect(output).toContain("1 cloud");
expect(output).not.toContain("1 clouds");
});
it("should show agent descriptions", async () => {
@ -187,7 +189,8 @@ describe("Command Output Functions", () => {
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
// sprite has 2 agents (claude, aider), hetzner has 1 (claude)
expect(output).toContain("2 agents");
expect(output).toContain("1 agents");
expect(output).toContain("1 agent");
expect(output).not.toContain("1 agents");
});
it("should show cloud descriptions", async () => {

View file

@ -39,6 +39,7 @@ mock.module("@clack/prompts", () => ({
log: {
step: mockLogStep,
info: mockLogInfo,
warn: mock(() => {}),
error: mockLogError,
},
intro: mock(() => {}),

View file

@ -244,14 +244,22 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro
process.exit(1);
}
validateNonEmptyString(agent, "Agent name", "spawn agents");
validateNonEmptyString(cloud, "Cloud name", "spawn clouds");
const [manifest, agentKey] = await validateAndGetAgent(agent);
// 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(`Try: ${pc.cyan(`spawn ${cloud} ${agent}`)}`);
process.exit(1);
}
validateAgent(manifest, agent);
validateCloud(manifest, cloud);
validateImplementation(manifest, cloud, agentKey);
validateImplementation(manifest, cloud, agent);
const agentName = manifest.agents[agentKey].name;
const agentName = manifest.agents[agent].name;
const cloudName = manifest.clouds[cloud].name;
if (prompt) {
@ -260,7 +268,7 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}...`);
}
await execScript(cloud, agentKey, prompt);
await execScript(cloud, agent, prompt);
}
function getStatusDescription(status: number): string {
@ -446,7 +454,7 @@ export async function cmdAgents(): Promise<void> {
for (const key of agentKeys(manifest)) {
const a = manifest.agents[key];
const implCount = getImplementedClouds(manifest, key).length;
console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${a.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${implCount} clouds ${a.description}`)}`);
console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${a.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${implCount} cloud${implCount !== 1 ? "s" : ""} ${a.description}`)}`);
}
console.log();
console.log(pc.dim(` Run ${pc.cyan("spawn <agent>")} for details, or ${pc.cyan("spawn <agent> <cloud>")} to launch.`));
@ -464,7 +472,7 @@ export async function cmdClouds(): Promise<void> {
for (const key of cloudKeys(manifest)) {
const c = manifest.clouds[key];
const implCount = getImplementedAgents(manifest, key).length;
console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${implCount} agents ${c.description}`)}`);
console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${implCount} agent${implCount !== 1 ? "s" : ""} ${c.description}`)}`);
}
console.log();
console.log(pc.dim(` Run ${pc.cyan("spawn <agent> <cloud>")} to launch.`));