mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 08:01:17 +00:00
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:
parent
9a5502906d
commit
2ee7c0cef5
3 changed files with 246 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.2.52",
|
||||
"version": "0.2.53",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
226
cli/src/__tests__/verb-aliases.test.ts
Normal file
226
cli/src/__tests__/verb-aliases.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue