fix: handle common verb aliases (run, launch, start, deploy, exec) in CLI (#516)

Users coming from Docker, kubectl, or other CLIs naturally try
"spawn run claude sprite" or "spawn launch aider hetzner". Previously
these would show a confusing "Unknown command: run" error. Now the CLI
transparently strips these verb prefixes and forwards to the correct
agent/cloud handler. Bare verbs like "spawn run" show a helpful message
explaining the correct syntax.

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-11 13:26:25 -08:00 committed by GitHub
parent 9a5502906d
commit 2ee7c0cef5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 246 additions and 1 deletions

View file

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

View file

@ -0,0 +1,226 @@
import { describe, it, expect } from "bun:test";
/**
* Tests for verb alias handling in CLI dispatch.
*
* Users coming from Docker, kubectl, or other CLIs naturally try
* "spawn run claude sprite", "spawn launch aider hetzner", etc.
* The CLI should transparently strip these verb prefixes and forward
* to the default agent/cloud handler.
*/
// ── Replica of dispatch logic from index.ts ─────────────────────────────────
const VERB_ALIASES = new Set(["run", "launch", "start", "deploy", "exec"]);
const IMMEDIATE_COMMAND_KEYS = new Set([
"help", "--help", "-h",
"version", "--version", "-v", "-V",
]);
const SUBCOMMAND_KEYS = new Set([
"list", "ls", "matrix", "m", "agents", "clouds", "update",
]);
type DispatchResult =
| { type: "immediate"; cmd: string }
| { type: "subcommand"; cmd: string }
| { type: "verb_alias"; agent: string; cloud: string | undefined; prompt: string | undefined }
| { type: "verb_alias_bare"; verb: string }
| { type: "default"; agent: string; cloud: string | undefined; prompt: string | undefined };
function dispatchCommand(
cmd: string,
filteredArgs: string[],
prompt: string | undefined,
): DispatchResult {
if (IMMEDIATE_COMMAND_KEYS.has(cmd)) {
return { type: "immediate", cmd };
}
if (SUBCOMMAND_KEYS.has(cmd)) {
return { type: "subcommand", cmd };
}
// Handle verb aliases: "spawn run claude sprite" -> "spawn claude sprite"
if (VERB_ALIASES.has(cmd)) {
if (filteredArgs.length > 1) {
const remaining = filteredArgs.slice(1);
return {
type: "verb_alias",
agent: remaining[0],
cloud: remaining[1],
prompt,
};
}
return { type: "verb_alias_bare", verb: cmd };
}
return {
type: "default",
agent: filteredArgs[0],
cloud: filteredArgs[1],
prompt,
};
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("verb alias handling", () => {
describe("recognized aliases", () => {
for (const verb of ["run", "launch", "start", "deploy", "exec"]) {
it(`should recognize "${verb}" as a verb alias`, () => {
expect(VERB_ALIASES.has(verb)).toBe(true);
});
}
});
describe("verb alias with agent and cloud", () => {
it("should strip 'run' and forward to default handler", () => {
const result = dispatchCommand("run", ["run", "claude", "sprite"], undefined);
expect(result.type).toBe("verb_alias");
if (result.type === "verb_alias") {
expect(result.agent).toBe("claude");
expect(result.cloud).toBe("sprite");
}
});
it("should strip 'launch' and forward to default handler", () => {
const result = dispatchCommand("launch", ["launch", "aider", "hetzner"], undefined);
expect(result.type).toBe("verb_alias");
if (result.type === "verb_alias") {
expect(result.agent).toBe("aider");
expect(result.cloud).toBe("hetzner");
}
});
it("should strip 'start' and forward to default handler", () => {
const result = dispatchCommand("start", ["start", "codex", "vultr"], undefined);
expect(result.type).toBe("verb_alias");
if (result.type === "verb_alias") {
expect(result.agent).toBe("codex");
expect(result.cloud).toBe("vultr");
}
});
it("should strip 'deploy' and forward to default handler", () => {
const result = dispatchCommand("deploy", ["deploy", "claude", "linode"], undefined);
expect(result.type).toBe("verb_alias");
if (result.type === "verb_alias") {
expect(result.agent).toBe("claude");
expect(result.cloud).toBe("linode");
}
});
it("should strip 'exec' and forward to default handler", () => {
const result = dispatchCommand("exec", ["exec", "aider", "sprite"], undefined);
expect(result.type).toBe("verb_alias");
if (result.type === "verb_alias") {
expect(result.agent).toBe("aider");
expect(result.cloud).toBe("sprite");
}
});
it("should pass prompt through when using verb alias", () => {
const result = dispatchCommand("run", ["run", "claude", "sprite"], "Fix all bugs");
expect(result.type).toBe("verb_alias");
if (result.type === "verb_alias") {
expect(result.prompt).toBe("Fix all bugs");
}
});
});
describe("verb alias with agent only (no cloud)", () => {
it("should forward 'run claude' as agent-only", () => {
const result = dispatchCommand("run", ["run", "claude"], undefined);
expect(result.type).toBe("verb_alias");
if (result.type === "verb_alias") {
expect(result.agent).toBe("claude");
expect(result.cloud).toBeUndefined();
}
});
});
describe("bare verb (no additional args)", () => {
it("should show error for bare 'run'", () => {
const result = dispatchCommand("run", ["run"], undefined);
expect(result.type).toBe("verb_alias_bare");
if (result.type === "verb_alias_bare") {
expect(result.verb).toBe("run");
}
});
it("should show error for bare 'launch'", () => {
const result = dispatchCommand("launch", ["launch"], undefined);
expect(result.type).toBe("verb_alias_bare");
if (result.type === "verb_alias_bare") {
expect(result.verb).toBe("launch");
}
});
it("should show error for bare 'start'", () => {
const result = dispatchCommand("start", ["start"], undefined);
expect(result.type).toBe("verb_alias_bare");
if (result.type === "verb_alias_bare") {
expect(result.verb).toBe("start");
}
});
it("should show error for bare 'deploy'", () => {
const result = dispatchCommand("deploy", ["deploy"], undefined);
expect(result.type).toBe("verb_alias_bare");
});
it("should show error for bare 'exec'", () => {
const result = dispatchCommand("exec", ["exec"], undefined);
expect(result.type).toBe("verb_alias_bare");
});
});
describe("verb aliases do not shadow real commands", () => {
it("should not treat 'list' as a verb alias", () => {
expect(VERB_ALIASES.has("list")).toBe(false);
});
it("should not treat 'help' as a verb alias", () => {
expect(VERB_ALIASES.has("help")).toBe(false);
});
it("should not treat 'update' as a verb alias", () => {
expect(VERB_ALIASES.has("update")).toBe(false);
});
it("should not treat 'agents' as a verb alias", () => {
expect(VERB_ALIASES.has("agents")).toBe(false);
});
it("should not treat 'clouds' as a verb alias", () => {
expect(VERB_ALIASES.has("clouds")).toBe(false);
});
it("should not treat 'matrix' as a verb alias", () => {
expect(VERB_ALIASES.has("matrix")).toBe(false);
});
it("should not treat 'version' as a verb alias", () => {
expect(VERB_ALIASES.has("version")).toBe(false);
});
});
describe("routing priority: real commands take precedence", () => {
it("'help' routes as immediate, not verb alias", () => {
const result = dispatchCommand("help", ["help"], undefined);
expect(result.type).toBe("immediate");
});
it("'list' routes as subcommand, not verb alias", () => {
const result = dispatchCommand("list", ["list"], undefined);
expect(result.type).toBe("subcommand");
});
it("unknown names fall to default, not verb alias", () => {
const result = dispatchCommand("claude", ["claude", "sprite"], undefined);
expect(result.type).toBe("default");
});
});
});

View file

@ -279,6 +279,10 @@ const SUBCOMMANDS: Record<string, () => Promise<void>> = {
// list/ls handled separately for -a/-c flag parsing
const LIST_COMMANDS = new Set(["list", "ls"]);
// Common verb prefixes that users naturally try (e.g. "spawn run claude sprite")
// These are not real subcommands -- we strip them and forward to the default handler
const VERB_ALIASES = new Set(["run", "launch", "start", "deploy", "exec"]);
/** Warn when extra positional arguments are silently ignored */
function warnExtraArgs(filteredArgs: string[], maxExpected: number): void {
const extra = filteredArgs.slice(maxExpected);
@ -345,6 +349,21 @@ async function dispatchCommand(cmd: string, filteredArgs: string[], prompt: stri
return;
}
// Handle verb aliases: "spawn run claude sprite" -> "spawn claude sprite"
if (VERB_ALIASES.has(cmd)) {
if (filteredArgs.length > 1) {
const remaining = filteredArgs.slice(1);
warnExtraArgs(remaining, 2);
await handleDefaultCommand(remaining[0], remaining[1], prompt, dryRun);
return;
}
// Bare verb with no args: "spawn run" -> show usage hint
console.error(pc.red(`Error: ${pc.bold(cmd)} requires an agent and cloud`));
console.error(`\nUsage: ${pc.cyan("spawn <agent> <cloud>")}`);
console.error(pc.dim(` The "${cmd}" keyword is optional -- just use ${pc.cyan("spawn <agent> <cloud>")} directly.`));
process.exit(1);
}
warnExtraArgs(filteredArgs, 2);
await handleDefaultCommand(filteredArgs[0], filteredArgs[1], prompt, dryRun);
}