From 86dfbacab00765ffede257d1831916d7d7ca9fb3 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:42:25 -0800 Subject: [PATCH] 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 --- cli/package.json | 2 +- .../__tests__/commands-error-paths.test.ts | 48 +++++++++++++++++++ cli/src/__tests__/commands-output.test.ts | 7 ++- .../commands-update-download.test.ts | 1 + cli/src/commands.ts | 20 +++++--- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/cli/package.json b/cli/package.json index b94c9732..69d3c2d7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.13", + "version": "0.2.14", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/commands-error-paths.test.ts b/cli/src/__tests__/commands-error-paths.test.ts index d2b6d849..32ce1b2d 100644 --- a/cli/src/__tests__/commands-error-paths.test.ts +++ b/cli/src/__tests__/commands-error-paths.test.ts @@ -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); + }); + }); }); diff --git a/cli/src/__tests__/commands-output.test.ts b/cli/src/__tests__/commands-output.test.ts index e7bb935b..ecb77817 100644 --- a/cli/src/__tests__/commands-output.test.ts +++ b/cli/src/__tests__/commands-output.test.ts @@ -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 () => { diff --git a/cli/src/__tests__/commands-update-download.test.ts b/cli/src/__tests__/commands-update-download.test.ts index b0a4843b..ea0c84bb 100644 --- a/cli/src/__tests__/commands-update-download.test.ts +++ b/cli/src/__tests__/commands-update-download.test.ts @@ -39,6 +39,7 @@ mock.module("@clack/prompts", () => ({ log: { step: mockLogStep, info: mockLogInfo, + warn: mock(() => {}), error: mockLogError, }, intro: mock(() => {}), diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 491a3b75..5ee02d1f 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -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 " 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(`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 { 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 ")} for details, or ${pc.cyan("spawn ")} to launch.`)); @@ -464,7 +472,7 @@ export async function cmdClouds(): Promise { 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 ")} to launch.`));