mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-30 04:49:33 +00:00
* fix: add unique spawn IDs to prevent history record corruption
History records were matched by heuristic ("most recent record for this
cloud without a connection"), which caused saveVmConnection and
saveLaunchCmd to overwrite the wrong record during concurrent or failed
spawns.
Fix: every SpawnRecord now has a unique `id` (UUID). All history
operations (saveVmConnection, saveLaunchCmd, removeRecord,
markRecordDeleted, mergeLastConnection) match by id when available,
falling back to the old heuristic for pre-migration records.
The orchestrator (TS path) now creates the history record AFTER server
creation succeeds, not before — so failed provisions don't leave orphan
entries.
Also adds "Remove from history" option to the spawn ls action picker,
restoring the ability to soft-delete entries without destroying the VM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add 18 unit tests for spawn ID history behavior
Tests cover:
- generateSpawnId returns unique UUIDs
- saveSpawnRecord auto-generates id when not provided
- saveVmConnection matches by spawnId (not heuristic)
- saveVmConnection does not cross-contaminate concurrent spawns
- saveVmConnection falls back to heuristic without spawnId
- saveLaunchCmd matches by spawnId (not heuristic)
- saveLaunchCmd falls back without spawnId
- removeRecord matches by id, not by timestamp+agent+cloud
- removeRecord handles duplicate timestamps correctly
- removeRecord falls back for legacy records without id
- markRecordDeleted targets correct record by id
- mergeLastConnection uses spawn_id from last-connection.json
- mergeLastConnection falls back to heuristic without spawn_id
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: enable biome import sorting with grouped imports
Adds organizeImports to biome assist config with groups:
1. Type imports
2. Node built-ins
3. Third-party packages
4. @openrouter/* packages
5. Aliases
Auto-fixed import order and lint issues across all TypeScript files,
including .claude/skills/ and packages/cli/src/.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
894 lines
28 KiB
TypeScript
894 lines
28 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import pc from "picocolors";
|
|
import pkg from "../package.json" with { type: "json" };
|
|
import {
|
|
cmdAgentInfo,
|
|
cmdAgentInteractive,
|
|
cmdAgents,
|
|
cmdCloudInfo,
|
|
cmdClouds,
|
|
cmdDelete,
|
|
cmdHelp,
|
|
cmdInteractive,
|
|
cmdLast,
|
|
cmdList,
|
|
cmdListClear,
|
|
cmdMatrix,
|
|
cmdPick,
|
|
cmdRun,
|
|
cmdRunHeadless,
|
|
cmdUpdate,
|
|
findClosestKeyByNameOrKey,
|
|
isInteractiveTTY,
|
|
loadManifestWithSpinner,
|
|
resolveAgentKey,
|
|
resolveCloudKey,
|
|
} from "./commands.js";
|
|
import { expandEqualsFlags, findUnknownFlag } from "./flags.js";
|
|
import { agentKeys, cloudKeys, getCacheAge, loadManifest } from "./manifest.js";
|
|
import { checkForUpdates } from "./update-check.js";
|
|
|
|
const VERSION = pkg.version;
|
|
|
|
function handleError(err: unknown): never {
|
|
// Use duck typing instead of instanceof to avoid prototype chain issues
|
|
const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
console.error(pc.red(`Error: ${msg}`));
|
|
console.error(`\nRun ${pc.cyan("spawn help")} for usage information.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/** Extract a flag and its value from args, returning [value, remainingArgs] */
|
|
function extractFlagValue(
|
|
args: string[],
|
|
flags: string[],
|
|
_flagLabel: string,
|
|
usageHint: string,
|
|
): [
|
|
string | undefined,
|
|
string[],
|
|
] {
|
|
const idx = args.findIndex((arg) => flags.includes(arg));
|
|
if (idx === -1) {
|
|
return [
|
|
undefined,
|
|
args,
|
|
];
|
|
}
|
|
|
|
if (!args[idx + 1] || args[idx + 1].startsWith("-")) {
|
|
console.error(pc.red(`Error: ${pc.bold(args[idx])} requires a value`));
|
|
console.error(`\nUsage: ${pc.cyan(usageHint)}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const value = args[idx + 1];
|
|
const remaining = [
|
|
...args,
|
|
];
|
|
remaining.splice(idx, 2);
|
|
return [
|
|
value,
|
|
remaining,
|
|
];
|
|
}
|
|
|
|
const HELP_FLAGS = [
|
|
"--help",
|
|
"-h",
|
|
"help",
|
|
];
|
|
|
|
/** Check for unknown flags and show an actionable error */
|
|
function checkUnknownFlags(args: string[]): void {
|
|
const unknown = findUnknownFlag(args);
|
|
if (unknown) {
|
|
console.error(pc.red(`Unknown flag: ${pc.bold(unknown)}`));
|
|
console.error();
|
|
console.error(" Supported flags:");
|
|
console.error(` ${pc.cyan("--prompt, -p")} Provide a prompt for non-interactive execution`);
|
|
console.error(` ${pc.cyan("--prompt-file, -f")} Read prompt from a file`);
|
|
console.error(` ${pc.cyan("--dry-run, -n")} Preview what would be provisioned`);
|
|
console.error(` ${pc.cyan("--debug")} Show all commands being executed`);
|
|
console.error(` ${pc.cyan("--headless")} Non-interactive mode (no prompts, no SSH session)`);
|
|
console.error(` ${pc.cyan("--output json")} Output structured JSON to stdout`);
|
|
console.error(` ${pc.cyan("--custom")} Show interactive size/region pickers`);
|
|
console.error(` ${pc.cyan("--zone, --region")} Set zone/region (e.g. us-east1-b, nyc3)`);
|
|
console.error(` ${pc.cyan("--size, --machine-type")} Set instance size (e.g. e2-standard-4, s-2vcpu-4gb)`);
|
|
console.error(` ${pc.cyan("--name")} Set the spawn/resource name`);
|
|
console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`);
|
|
console.error(` ${pc.cyan("--help, -h")} Show help information`);
|
|
console.error(` ${pc.cyan("--version, -v")} Show version`);
|
|
console.error();
|
|
console.error(` For ${pc.cyan("spawn pick")}:`);
|
|
console.error(` ${pc.cyan("--default")} Pre-selected value in the picker`);
|
|
console.error();
|
|
console.error(` For ${pc.cyan("spawn list")}:`);
|
|
console.error(` ${pc.cyan("-a, --agent")} Filter history by agent`);
|
|
console.error(` ${pc.cyan("-c, --cloud")} Filter history by cloud`);
|
|
console.error(` ${pc.cyan("--clear")} Clear all spawn history`);
|
|
console.error();
|
|
console.error(` Run ${pc.cyan("spawn help")} for full usage information.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/** Show info for a name that could be an agent or cloud, or show an error with suggestions */
|
|
function showUnknownCommandError(
|
|
name: string,
|
|
manifest: {
|
|
agents: Record<
|
|
string,
|
|
{
|
|
name: string;
|
|
}
|
|
>;
|
|
clouds: Record<
|
|
string,
|
|
{
|
|
name: string;
|
|
}
|
|
>;
|
|
},
|
|
): never {
|
|
const agentMatch = findClosestKeyByNameOrKey(name, agentKeys(manifest), (k) => manifest.agents[k].name);
|
|
const cloudMatch = findClosestKeyByNameOrKey(name, cloudKeys(manifest), (k) => manifest.clouds[k].name);
|
|
|
|
console.error(pc.red(`Unknown agent or cloud: ${pc.bold(name)}`));
|
|
console.error();
|
|
if (agentMatch || cloudMatch) {
|
|
const suggestions: string[] = [];
|
|
if (agentMatch) {
|
|
suggestions.push(`${pc.cyan(agentMatch)} (agent: ${manifest.agents[agentMatch].name})`);
|
|
}
|
|
if (cloudMatch) {
|
|
suggestions.push(`${pc.cyan(cloudMatch)} (cloud: ${manifest.clouds[cloudMatch].name})`);
|
|
}
|
|
console.error(` Did you mean ${suggestions.join(" or ")}?`);
|
|
}
|
|
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 showInfoOrError(name: string): Promise<void> {
|
|
const manifest = await loadManifestWithSpinner();
|
|
|
|
// Direct key match — pass pre-loaded manifest to avoid a redundant spinner
|
|
if (manifest.agents[name]) {
|
|
await cmdAgentInfo(name, manifest);
|
|
return;
|
|
}
|
|
if (manifest.clouds[name]) {
|
|
await cmdCloudInfo(name, manifest);
|
|
return;
|
|
}
|
|
|
|
// Try resolving display names and case-insensitive matches
|
|
const resolvedAgent = resolveAgentKey(manifest, name);
|
|
if (resolvedAgent) {
|
|
await cmdAgentInfo(resolvedAgent, manifest);
|
|
return;
|
|
}
|
|
const resolvedCloud = resolveCloudKey(manifest, name);
|
|
if (resolvedCloud) {
|
|
await cmdCloudInfo(resolvedCloud, manifest);
|
|
return;
|
|
}
|
|
|
|
showUnknownCommandError(name, manifest);
|
|
}
|
|
|
|
async function handleDefaultCommand(
|
|
agent: string,
|
|
cloud: string | undefined,
|
|
prompt?: string,
|
|
dryRun?: boolean,
|
|
debug?: boolean,
|
|
headless?: boolean,
|
|
outputFormat?: string,
|
|
): Promise<void> {
|
|
if (cloud && HELP_FLAGS.includes(cloud)) {
|
|
await showInfoOrError(agent);
|
|
return;
|
|
}
|
|
if (headless) {
|
|
if (!cloud) {
|
|
if (outputFormat === "json") {
|
|
console.log(
|
|
JSON.stringify({
|
|
status: "error",
|
|
error_code: "VALIDATION_ERROR",
|
|
error_message: "--headless requires both <agent> and <cloud>",
|
|
}),
|
|
);
|
|
} else {
|
|
console.error(pc.red("Error: --headless requires both <agent> and <cloud>"));
|
|
console.error(`\nUsage: ${pc.cyan("spawn <agent> <cloud> --headless --output json")}`);
|
|
}
|
|
process.exit(3);
|
|
}
|
|
await cmdRunHeadless(agent, cloud, {
|
|
prompt,
|
|
debug,
|
|
outputFormat,
|
|
spawnName: process.env.SPAWN_NAME,
|
|
});
|
|
return;
|
|
}
|
|
if (cloud) {
|
|
await cmdRun(agent, cloud, prompt, dryRun, debug);
|
|
return;
|
|
}
|
|
if (dryRun) {
|
|
console.error(pc.red("Error: --dry-run requires both <agent> and <cloud>"));
|
|
console.error(`\nUsage: ${pc.cyan("spawn <agent> <cloud> --dry-run")}`);
|
|
process.exit(1);
|
|
}
|
|
if (prompt) {
|
|
await suggestCloudsForPrompt(agent);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check if the single argument is a cloud name before routing to agent-interactive.
|
|
// This fixes: `spawn digitalocean` telling users to run `spawn digitalocean` for
|
|
// setup instructions, but `spawn digitalocean` routing to "Unknown agent: digitalocean".
|
|
try {
|
|
const manifest = await loadManifest();
|
|
const resolvedCloud = resolveCloudKey(manifest, agent);
|
|
if (resolvedCloud) {
|
|
await cmdCloudInfo(resolvedCloud, manifest);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Manifest unavailable — fall through to cmdAgentInteractive which handles errors gracefully
|
|
}
|
|
|
|
// Interactive cloud selection when agent is provided without cloud
|
|
if (isInteractiveTTY()) {
|
|
await cmdAgentInteractive(agent, prompt, dryRun);
|
|
return;
|
|
}
|
|
|
|
await showInfoOrError(agent);
|
|
}
|
|
|
|
/** Show "prompt requires cloud" error and suggest available clouds for the agent */
|
|
async function suggestCloudsForPrompt(agent: string): Promise<void> {
|
|
console.error(pc.red("Error: --prompt requires both <agent> and <cloud>"));
|
|
console.error(`\nUsage: ${pc.cyan(`spawn ${agent} <cloud> --prompt "your prompt here"`)}`);
|
|
|
|
try {
|
|
const manifest = await loadManifest();
|
|
const resolvedAgent = resolveAgentKey(manifest, agent);
|
|
if (!resolvedAgent) {
|
|
return;
|
|
}
|
|
|
|
const clouds = cloudKeys(manifest).filter(
|
|
(c: string) => manifest.matrix[`${c}/${resolvedAgent}`] === "implemented",
|
|
);
|
|
if (clouds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const agentName = manifest.agents[resolvedAgent].name;
|
|
console.error(`\nAvailable clouds for ${pc.bold(agentName)}:`);
|
|
for (const c of clouds.slice(0, 5)) {
|
|
console.error(` ${pc.cyan(`spawn ${resolvedAgent} ${c} --prompt "..."`)}`);
|
|
}
|
|
if (clouds.length > 5) {
|
|
console.error(` Run ${pc.cyan(`spawn ${resolvedAgent}`)} to see all ${clouds.length} clouds.`);
|
|
}
|
|
} catch (_err) {
|
|
// Manifest unavailable — skip cloud suggestions
|
|
}
|
|
}
|
|
|
|
/** Print a descriptive error for a failed prompt file read and exit */
|
|
function handlePromptFileError(promptFile: string, err: unknown): never {
|
|
const code = err && typeof err === "object" && "code" in err ? err.code : "";
|
|
if (code === "ENOENT") {
|
|
console.error(pc.red(`Prompt file not found: ${pc.bold(promptFile)}`));
|
|
console.error("\nCheck the path and try again.");
|
|
} else if (code === "EACCES") {
|
|
console.error(pc.red(`Permission denied reading prompt file: ${pc.bold(promptFile)}`));
|
|
console.error(`\nCheck file permissions: ${pc.cyan(`ls -la ${promptFile}`)}`);
|
|
} else if (code === "EISDIR") {
|
|
console.error(pc.red(`'${promptFile}' is a directory, not a file.`));
|
|
console.error("\nProvide a path to a text file containing your prompt.");
|
|
} else {
|
|
const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
console.error(pc.red(`Error reading prompt file '${promptFile}': ${msg}`));
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
/** Read and validate a prompt file, exiting on any error */
|
|
async function readPromptFile(promptFile: string): Promise<string> {
|
|
const { validatePromptFilePath, validatePromptFileStats } = await import("./security.js");
|
|
const { readFileSync, statSync } = await import("node:fs");
|
|
|
|
try {
|
|
validatePromptFilePath(promptFile);
|
|
} catch (err) {
|
|
const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
console.error(pc.red(msg));
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
const stats = statSync(promptFile);
|
|
validatePromptFileStats(promptFile, stats);
|
|
} catch (err) {
|
|
const code = err && typeof err === "object" && "code" in err ? err.code : "";
|
|
if (code === "ENOENT" || code === "EACCES" || code === "EISDIR") {
|
|
handlePromptFileError(promptFile, err);
|
|
}
|
|
const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
console.error(pc.red(msg));
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
return readFileSync(promptFile, "utf-8");
|
|
} catch (err) {
|
|
handlePromptFileError(promptFile, err);
|
|
}
|
|
}
|
|
|
|
/** Parse --prompt / -p and --prompt-file flags, returning the resolved prompt text and remaining args */
|
|
async function resolvePrompt(args: string[]): Promise<
|
|
[
|
|
string | undefined,
|
|
string[],
|
|
]
|
|
> {
|
|
let [prompt, filteredArgs] = extractFlagValue(
|
|
args,
|
|
[
|
|
"--prompt",
|
|
"-p",
|
|
],
|
|
"prompt",
|
|
'spawn <agent> <cloud> --prompt "your prompt here"',
|
|
);
|
|
|
|
const [promptFile, finalArgs] = extractFlagValue(
|
|
filteredArgs,
|
|
[
|
|
"--prompt-file",
|
|
"-f",
|
|
],
|
|
"prompt file",
|
|
"spawn <agent> <cloud> --prompt-file instructions.txt",
|
|
);
|
|
filteredArgs = finalArgs;
|
|
|
|
if (prompt && promptFile) {
|
|
console.error(pc.red("Error: --prompt and --prompt-file cannot be used together"));
|
|
console.error("\nUse one or the other:");
|
|
console.error(` ${pc.cyan('spawn <agent> <cloud> --prompt "your prompt here"')}`);
|
|
console.error(` ${pc.cyan("spawn <agent> <cloud> --prompt-file instructions.txt")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (promptFile) {
|
|
prompt = await readPromptFile(promptFile);
|
|
}
|
|
|
|
return [
|
|
prompt,
|
|
filteredArgs,
|
|
];
|
|
}
|
|
|
|
/** Handle the case when no command is given (interactive mode or help) */
|
|
async function handleNoCommand(prompt: string | undefined, dryRun?: boolean): Promise<void> {
|
|
if (dryRun) {
|
|
console.error(pc.red("Error: --dry-run requires both <agent> and <cloud>"));
|
|
console.error(`\nUsage: ${pc.cyan("spawn <agent> <cloud> --dry-run")}`);
|
|
process.exit(1);
|
|
}
|
|
if (prompt) {
|
|
console.error(pc.red("Error: --prompt requires both <agent> and <cloud>"));
|
|
console.error(`\nUsage: ${pc.cyan('spawn <agent> <cloud> --prompt "your prompt here"')}`);
|
|
process.exit(1);
|
|
}
|
|
if (isInteractiveTTY()) {
|
|
await cmdInteractive();
|
|
} else {
|
|
console.error(pc.yellow("Cannot run interactive picker: not a terminal"));
|
|
console.error(pc.dim(" (stdin/stdout is piped or redirected)"));
|
|
console.error();
|
|
console.error(` Launch directly: ${pc.cyan("spawn <agent> <cloud>")}`);
|
|
console.error(` Rerun previous: ${pc.cyan("spawn list")}`);
|
|
console.error(` Browse agents: ${pc.cyan("spawn agents")}`);
|
|
console.error(` Browse clouds: ${pc.cyan("spawn clouds")}`);
|
|
console.error(` Full help: ${pc.cyan("spawn help")}`);
|
|
console.error();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function formatCacheAge(seconds: number): string {
|
|
if (!Number.isFinite(seconds)) {
|
|
return "no cache";
|
|
}
|
|
if (seconds < 60) {
|
|
return "just now";
|
|
}
|
|
if (seconds < 3600) {
|
|
return `${Math.floor(seconds / 60)}m ago`;
|
|
}
|
|
if (seconds < 86400) {
|
|
return `${Math.floor(seconds / 3600)}h ago`;
|
|
}
|
|
return `${Math.floor(seconds / 86400)}d ago`;
|
|
}
|
|
|
|
function showVersion(): void {
|
|
console.log(`spawn v${VERSION}`);
|
|
const binPath = process.argv[1];
|
|
if (binPath) {
|
|
console.log(pc.dim(` ${binPath}`));
|
|
}
|
|
console.log(
|
|
pc.dim(
|
|
` ${process.versions.bun ? "bun" : "node"} ${process.versions.bun ?? process.versions.node} ${process.platform} ${process.arch}`,
|
|
),
|
|
);
|
|
const age = getCacheAge();
|
|
console.log(pc.dim(` manifest cache: ${formatCacheAge(age)}`));
|
|
console.log(pc.dim(" https://github.com/OpenRouterTeam/spawn"));
|
|
console.log(pc.dim(` Run ${pc.cyan("spawn update")} to check for updates.`));
|
|
}
|
|
|
|
const IMMEDIATE_COMMANDS: Record<string, () => void> = {
|
|
help: cmdHelp,
|
|
"--help": cmdHelp,
|
|
"-h": cmdHelp,
|
|
version: showVersion,
|
|
"--version": showVersion,
|
|
"-v": showVersion,
|
|
"-V": showVersion,
|
|
};
|
|
|
|
const SUBCOMMANDS: Record<string, () => Promise<void>> = {
|
|
matrix: cmdMatrix,
|
|
m: cmdMatrix,
|
|
agents: cmdAgents,
|
|
clouds: cmdClouds,
|
|
update: cmdUpdate,
|
|
last: cmdLast,
|
|
rerun: cmdLast,
|
|
};
|
|
|
|
// list/ls/history handled separately for -a/-c flag parsing
|
|
const LIST_COMMANDS = new Set([
|
|
"list",
|
|
"ls",
|
|
"history",
|
|
]);
|
|
|
|
// delete/rm/destroy handled separately for -a/-c flag parsing
|
|
const DELETE_COMMANDS = new Set([
|
|
"delete",
|
|
"rm",
|
|
"destroy",
|
|
"kill",
|
|
]);
|
|
|
|
// 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);
|
|
if (extra.length > 0) {
|
|
console.error(pc.yellow(`Extra argument${extra.length > 1 ? "s" : ""} ignored: ${extra.join(", ")}`));
|
|
console.error(pc.dim(` Usage: spawn <agent> <cloud> [--prompt "..."]`));
|
|
console.error();
|
|
}
|
|
}
|
|
|
|
/** Parse -a/--agent <agent> and -c/--cloud <cloud> filter flags from args.
|
|
* Also accepts a bare positional arg as a filter (e.g. "spawn list claude"). */
|
|
function parseListFilters(args: string[]): {
|
|
agentFilter?: string;
|
|
cloudFilter?: string;
|
|
} {
|
|
let agentFilter: string | undefined;
|
|
let cloudFilter: string | undefined;
|
|
const positional: string[] = [];
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === "-a" || args[i] === "--agent") {
|
|
if (!args[i + 1] || args[i + 1].startsWith("-")) {
|
|
console.error(pc.red(`Error: ${pc.bold(args[i])} requires an agent name`));
|
|
console.error(`\nUsage: ${pc.cyan("spawn list -a <agent>")}`);
|
|
process.exit(1);
|
|
}
|
|
agentFilter = args[i + 1];
|
|
i++;
|
|
} else if (args[i] === "-c" || args[i] === "--cloud") {
|
|
if (!args[i + 1] || args[i + 1].startsWith("-")) {
|
|
console.error(pc.red(`Error: ${pc.bold(args[i])} requires a cloud name`));
|
|
console.error(`\nUsage: ${pc.cyan("spawn list -c <cloud>")}`);
|
|
process.exit(1);
|
|
}
|
|
cloudFilter = args[i + 1];
|
|
i++;
|
|
} else if (!args[i].startsWith("-")) {
|
|
positional.push(args[i]);
|
|
}
|
|
}
|
|
|
|
// Support bare positional filter: "spawn list claude" or "spawn list hetzner"
|
|
if (!agentFilter && !cloudFilter && positional.length > 0) {
|
|
agentFilter = positional[0];
|
|
}
|
|
|
|
return {
|
|
agentFilter,
|
|
cloudFilter,
|
|
};
|
|
}
|
|
|
|
/** Check if trailing args contain a help flag */
|
|
function hasTrailingHelpFlag(args: string[]): boolean {
|
|
return args.slice(1).some((a) => HELP_FLAGS.includes(a));
|
|
}
|
|
|
|
/** Handle list/ls/history commands with filters and --clear */
|
|
async function dispatchListCommand(filteredArgs: string[]): Promise<void> {
|
|
if (hasTrailingHelpFlag(filteredArgs)) {
|
|
cmdHelp();
|
|
return;
|
|
}
|
|
if (filteredArgs.slice(1).includes("--clear")) {
|
|
await cmdListClear();
|
|
return;
|
|
}
|
|
const { agentFilter, cloudFilter } = parseListFilters(filteredArgs.slice(1));
|
|
await cmdList(agentFilter, cloudFilter);
|
|
}
|
|
|
|
/** Handle delete/rm/destroy commands with filters */
|
|
async function dispatchDeleteCommand(filteredArgs: string[]): Promise<void> {
|
|
if (hasTrailingHelpFlag(filteredArgs)) {
|
|
cmdHelp();
|
|
return;
|
|
}
|
|
const { agentFilter, cloudFilter } = parseListFilters(filteredArgs.slice(1));
|
|
await cmdDelete(agentFilter, cloudFilter);
|
|
}
|
|
|
|
/** Handle named subcommands (agents, clouds, matrix, etc.) */
|
|
async function dispatchSubcommand(cmd: string, filteredArgs: string[]): Promise<void> {
|
|
if (hasTrailingHelpFlag(filteredArgs)) {
|
|
cmdHelp();
|
|
return;
|
|
}
|
|
|
|
// "spawn agents <name>" or "spawn clouds <name>" -> show info for that name
|
|
if ((cmd === "agents" || cmd === "clouds") && filteredArgs.length > 1 && !filteredArgs[1].startsWith("-")) {
|
|
const name = filteredArgs[1];
|
|
warnExtraArgs(filteredArgs, 2);
|
|
console.error(pc.dim(`Tip: next time you can just run ${pc.cyan(`spawn ${name}`)}`));
|
|
console.error();
|
|
await showInfoOrError(name);
|
|
return;
|
|
}
|
|
|
|
warnExtraArgs(filteredArgs, 1);
|
|
await SUBCOMMANDS[cmd]();
|
|
}
|
|
|
|
/** Handle verb aliases like "spawn run claude sprite" -> "spawn claude sprite" */
|
|
async function dispatchVerbAlias(
|
|
cmd: string,
|
|
filteredArgs: string[],
|
|
prompt: string | undefined,
|
|
dryRun: boolean,
|
|
debug: boolean,
|
|
headless: boolean,
|
|
outputFormat?: string,
|
|
): Promise<void> {
|
|
if (filteredArgs.length > 1) {
|
|
const remaining = filteredArgs.slice(1);
|
|
warnExtraArgs(remaining, 2);
|
|
await handleDefaultCommand(remaining[0], remaining[1], prompt, dryRun, debug, headless, outputFormat);
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
|
|
/** Handle slash notation: "spawn claude/hetzner" -> "spawn claude hetzner" */
|
|
async function dispatchSlashNotation(
|
|
cmd: string,
|
|
prompt: string | undefined,
|
|
dryRun: boolean,
|
|
debug: boolean,
|
|
headless: boolean,
|
|
outputFormat?: string,
|
|
): Promise<boolean> {
|
|
const parts = cmd.split("/");
|
|
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
if (!headless) {
|
|
console.error(pc.dim(`Tip: use a space instead of slash: ${pc.cyan(`spawn ${parts[0]} ${parts[1]}`)}`));
|
|
console.error();
|
|
}
|
|
await handleDefaultCommand(parts[0], parts[1], prompt, dryRun, debug, headless, outputFormat);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Dispatch a named command or fall through to agent/cloud handling */
|
|
async function dispatchCommand(
|
|
cmd: string,
|
|
filteredArgs: string[],
|
|
prompt: string | undefined,
|
|
dryRun: boolean,
|
|
debug: boolean,
|
|
headless: boolean,
|
|
outputFormat?: string,
|
|
): Promise<void> {
|
|
if (IMMEDIATE_COMMANDS[cmd]) {
|
|
warnExtraArgs(filteredArgs, 1);
|
|
IMMEDIATE_COMMANDS[cmd]();
|
|
return;
|
|
}
|
|
|
|
if (LIST_COMMANDS.has(cmd)) {
|
|
await dispatchListCommand(filteredArgs);
|
|
return;
|
|
}
|
|
if (DELETE_COMMANDS.has(cmd)) {
|
|
await dispatchDeleteCommand(filteredArgs);
|
|
return;
|
|
}
|
|
if (SUBCOMMANDS[cmd]) {
|
|
await dispatchSubcommand(cmd, filteredArgs);
|
|
return;
|
|
}
|
|
if (VERB_ALIASES.has(cmd)) {
|
|
await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug, headless, outputFormat);
|
|
return;
|
|
}
|
|
|
|
if (filteredArgs.length === 1 && cmd.includes("/")) {
|
|
if (await dispatchSlashNotation(cmd, prompt, dryRun, debug, headless, outputFormat)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
warnExtraArgs(filteredArgs, 2);
|
|
await handleDefaultCommand(filteredArgs[0], filteredArgs[1], prompt, dryRun, debug, headless, outputFormat);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const rawArgs = process.argv.slice(2);
|
|
|
|
// ── `spawn pick` — bypass all flag parsing; used by bash scripts ──────────
|
|
// Must be handled before expandEqualsFlags / resolvePrompt so that pick's
|
|
// own --prompt flag is not mistakenly consumed by the top-level prompt logic.
|
|
if (rawArgs[0] === "pick") {
|
|
try {
|
|
await cmdPick(expandEqualsFlags(rawArgs.slice(1)));
|
|
} catch (err) {
|
|
handleError(err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const args = expandEqualsFlags(rawArgs);
|
|
|
|
await checkForUpdates();
|
|
|
|
const [prompt, filteredArgs] = await resolvePrompt(args);
|
|
|
|
// Extract --dry-run / -n boolean flag
|
|
const dryRunIdx = filteredArgs.findIndex((a) => a === "--dry-run" || a === "-n");
|
|
const dryRun = dryRunIdx !== -1;
|
|
if (dryRun) {
|
|
filteredArgs.splice(dryRunIdx, 1);
|
|
}
|
|
|
|
// Extract --debug boolean flag
|
|
const debugIdx = filteredArgs.indexOf("--debug");
|
|
const debug = debugIdx !== -1;
|
|
if (debug) {
|
|
filteredArgs.splice(debugIdx, 1);
|
|
}
|
|
|
|
// Extract --headless boolean flag
|
|
const headlessIdx = filteredArgs.indexOf("--headless");
|
|
const headless = headlessIdx !== -1;
|
|
if (headless) {
|
|
filteredArgs.splice(headlessIdx, 1);
|
|
}
|
|
|
|
// Extract --custom boolean flag
|
|
const customIdx = filteredArgs.indexOf("--custom");
|
|
const custom = customIdx !== -1;
|
|
if (custom) {
|
|
filteredArgs.splice(customIdx, 1);
|
|
process.env.SPAWN_CUSTOM = "1";
|
|
}
|
|
|
|
// Extract --reauth boolean flag
|
|
const reauthIdx = filteredArgs.indexOf("--reauth");
|
|
if (reauthIdx !== -1) {
|
|
filteredArgs.splice(reauthIdx, 1);
|
|
process.env.SPAWN_REAUTH = "1";
|
|
}
|
|
|
|
// Extract --output <format> flag
|
|
const [outputFormat, outputFilteredArgs] = extractFlagValue(
|
|
filteredArgs,
|
|
[
|
|
"--output",
|
|
],
|
|
"output format",
|
|
"spawn <agent> <cloud> --headless --output json",
|
|
);
|
|
// Replace filteredArgs contents in-place (splice + push to maintain reference)
|
|
filteredArgs.splice(0, filteredArgs.length, ...outputFilteredArgs);
|
|
|
|
// Validate --output value
|
|
if (outputFormat && outputFormat !== "json") {
|
|
console.error(pc.red(`Error: --output only supports "json" (got "${outputFormat}")`));
|
|
console.error(`\nUsage: ${pc.cyan("spawn <agent> <cloud> --headless --output json")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Extract --name <value> flag
|
|
const [nameFlag, nameFilteredArgs] = extractFlagValue(
|
|
filteredArgs,
|
|
[
|
|
"--name",
|
|
],
|
|
"spawn name",
|
|
'spawn <agent> <cloud> --name "my-dev-box"',
|
|
);
|
|
filteredArgs.splice(0, filteredArgs.length, ...nameFilteredArgs);
|
|
if (nameFlag) {
|
|
process.env.SPAWN_NAME = nameFlag;
|
|
}
|
|
|
|
// Extract --zone / --region <value> flag (maps to cloud-specific env vars)
|
|
const [zoneFlag, zoneFilteredArgs] = extractFlagValue(
|
|
filteredArgs,
|
|
[
|
|
"--zone",
|
|
"--region",
|
|
],
|
|
"zone/region",
|
|
"spawn <agent> gcp --zone us-east1-b",
|
|
);
|
|
filteredArgs.splice(0, filteredArgs.length, ...zoneFilteredArgs);
|
|
if (zoneFlag) {
|
|
process.env.GCP_ZONE = zoneFlag;
|
|
process.env.DO_REGION = zoneFlag;
|
|
process.env.HETZNER_LOCATION = zoneFlag;
|
|
process.env.AWS_DEFAULT_REGION = zoneFlag;
|
|
}
|
|
|
|
// Extract --machine-type / --size <value> flag (maps to cloud-specific env vars)
|
|
const [sizeFlag, sizeFilteredArgs] = extractFlagValue(
|
|
filteredArgs,
|
|
[
|
|
"--machine-type",
|
|
"--size",
|
|
],
|
|
"machine type/size",
|
|
"spawn <agent> gcp --machine-type e2-standard-4",
|
|
);
|
|
filteredArgs.splice(0, filteredArgs.length, ...sizeFilteredArgs);
|
|
if (sizeFlag) {
|
|
process.env.GCP_MACHINE_TYPE = sizeFlag;
|
|
process.env.DO_DROPLET_SIZE = sizeFlag;
|
|
process.env.HETZNER_SERVER_TYPE = sizeFlag;
|
|
process.env.LIGHTSAIL_BUNDLE = sizeFlag;
|
|
}
|
|
|
|
// --output implies --headless
|
|
const effectiveHeadless = headless || !!outputFormat;
|
|
|
|
// Validate --custom + --headless incompatibility
|
|
if (custom && effectiveHeadless) {
|
|
if (outputFormat === "json") {
|
|
console.log(
|
|
JSON.stringify({
|
|
status: "error",
|
|
error_code: "VALIDATION_ERROR",
|
|
error_message: "--custom and --headless cannot be used together",
|
|
}),
|
|
);
|
|
} else {
|
|
console.error(pc.red("Error: --custom and --headless cannot be used together"));
|
|
console.error(
|
|
`\n${pc.cyan("--custom")} enables interactive pickers, but ${pc.cyan("--headless")} disables all prompts.`,
|
|
);
|
|
}
|
|
process.exit(3);
|
|
}
|
|
|
|
// Validate headless-incompatible flags
|
|
if (effectiveHeadless && dryRun) {
|
|
if (outputFormat === "json") {
|
|
console.log(
|
|
JSON.stringify({
|
|
status: "error",
|
|
error_code: "VALIDATION_ERROR",
|
|
error_message: "--headless and --dry-run cannot be used together",
|
|
}),
|
|
);
|
|
} else {
|
|
console.error(pc.red("Error: --headless and --dry-run cannot be used together"));
|
|
console.error(`\nUse ${pc.cyan("--dry-run")} for previewing, or ${pc.cyan("--headless")} for execution.`);
|
|
}
|
|
process.exit(3);
|
|
}
|
|
|
|
checkUnknownFlags(filteredArgs);
|
|
|
|
const cmd = filteredArgs[0];
|
|
|
|
try {
|
|
if (!cmd) {
|
|
if (effectiveHeadless) {
|
|
if (outputFormat === "json") {
|
|
console.log(
|
|
JSON.stringify({
|
|
status: "error",
|
|
error_code: "VALIDATION_ERROR",
|
|
error_message: "--headless requires both <agent> and <cloud>",
|
|
}),
|
|
);
|
|
} else {
|
|
console.error(pc.red("Error: --headless requires both <agent> and <cloud>"));
|
|
console.error(`\nUsage: ${pc.cyan("spawn <agent> <cloud> --headless --output json")}`);
|
|
}
|
|
process.exit(3);
|
|
}
|
|
await handleNoCommand(prompt, dryRun);
|
|
} else {
|
|
await dispatchCommand(cmd, filteredArgs, prompt, dryRun, debug, effectiveHeadless, outputFormat);
|
|
}
|
|
} catch (err) {
|
|
if (effectiveHeadless && outputFormat === "json") {
|
|
const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
console.log(
|
|
JSON.stringify({
|
|
status: "error",
|
|
error_code: "UNEXPECTED_ERROR",
|
|
error_message: msg,
|
|
}),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
handleError(err);
|
|
}
|
|
}
|
|
|
|
main().then(
|
|
() => process.exit(0),
|
|
(err) => {
|
|
handleError(err);
|
|
},
|
|
);
|