diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2d7bdded6..300368bec 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -752,136 +752,85 @@ async function dispatchSlashNotation( 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 { - if (IMMEDIATE_COMMANDS[cmd]) { - warnExtraArgs(filteredArgs, 1); - IMMEDIATE_COMMANDS[cmd](); - return; - } +interface ParsedFlags { + filteredArgs: string[]; + prompt: string | undefined; + dryRun: boolean; + debug: boolean; + headless: boolean; + custom: boolean; + outputFormat: string | undefined; + betaFeatures: string[]; +} - if (cmd === "tree") { - if (hasTrailingHelpFlag(filteredArgs)) { - cmdHelp(); - return; - } - const jsonFlag = filteredArgs.slice(1).includes("--json"); - await cmdTree(jsonFlag); - return; - } - if (cmd === "pull-history") { - await cmdPullHistory(); - return; - } - if (LIST_COMMANDS.has(cmd)) { - // Handle "history export" subcommand - if (cmd === "history" && filteredArgs[1] === "export") { - cmdHistoryExport(); - return; - } - await dispatchListCommand(filteredArgs); - return; - } - if (DELETE_COMMANDS.has(cmd)) { - await dispatchDeleteCommand(filteredArgs); - return; - } - if (FIX_COMMANDS.has(cmd)) { - if (hasTrailingHelpFlag(filteredArgs)) { - cmdHelp(); - return; - } - // Optional positional argument: spawn fix [spawn-id] - const spawnId = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined; - await cmdFix(spawnId); - return; - } - if (STATUS_COMMANDS.has(cmd)) { - await dispatchStatusCommand(filteredArgs); - return; - } - if (SUBCOMMANDS[cmd]) { - await dispatchSubcommand(cmd, filteredArgs); - return; - } - if (cmd === "link" || cmd === "reconnect") { - if (hasTrailingHelpFlag(filteredArgs)) { - cmdHelp(); - return; - } - await cmdLink(filteredArgs); - return; - } - if (cmd === "export") { - if (hasTrailingHelpFlag(filteredArgs)) { - cmdHelp(); - return; - } - const targetArg = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined; - await cmdExport(targetArg); - return; - } - if (VERB_ALIASES.has(cmd)) { - await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug, headless, outputFormat); - return; +/** Extract a boolean flag from args, mutating in place. Returns true if found. */ +function extractBooleanFlag(args: string[], flags: string[]): boolean { + const idx = args.findIndex((a) => flags.includes(a)); + if (idx === -1) { + return false; } + args.splice(idx, 1); + return true; +} - if (filteredArgs.length === 1 && cmd.includes("/")) { - if (await dispatchSlashNotation(cmd, prompt, dryRun, debug, headless, outputFormat)) { - return; - } +/** Extract and apply an env-var-setting boolean flag. Returns true if found. */ +function extractEnvFlag(args: string[], flag: string, envVar: string): boolean { + const idx = args.indexOf(flag); + if (idx === -1) { + return false; } + args.splice(idx, 1); + process.env[envVar] = "1"; + return true; +} - if (hasTrailingHelpFlag(filteredArgs)) { - cmdHelp(); - return; - } - if (hasTrailingVersionFlag(filteredArgs)) { - showVersion(); - return; +/** Extract a value flag and set it as an env var if present. Returns the value. */ +function extractValueToEnv(args: string[], flags: string[], usageHint: string, envVar: string): string | undefined { + const [value, remaining] = extractFlagValue(args, flags, usageHint); + args.splice(0, args.length, ...remaining); + if (value) { + process.env[envVar] = value; } + return value; +} - warnExtraArgs(filteredArgs, 2); - await handleDefaultCommand(filteredArgs[0], filteredArgs[1], prompt, dryRun, debug, headless, outputFormat); +/** Extract a value flag without setting an env var. Returns the value. */ +function extractValue(args: string[], flags: string[], usageHint: string): string | undefined { + const [value, remaining] = extractFlagValue(args, flags, usageHint); + args.splice(0, args.length, ...remaining); + return value; } -async function main(): Promise { - const rawArgs = process.argv.slice(2); +const VALID_BETA_FEATURES = new Set([ + "tarball", + "images", + "parallel", + "docker", + "recursive", + "sandbox", + "skills", +]); - // ── `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. - // Runs before initFeatureFlags() — this is a hot path called by shell - // scripts and must stay fast; it has no code paths that gate on a flag. - if (rawArgs[0] === "pick") { - const pickResult = await asyncTryCatch(() => cmdPick(expandEqualsFlags(rawArgs.slice(1)))); - if (!pickResult.ok) { - handleError(pickResult.error); +/** Validate beta feature flags and exit with usage info if any are unknown. */ +function validateBetaFeatures(features: string[]): void { + for (const flag of features) { + if (!VALID_BETA_FEATURES.has(flag)) { + console.error(pc.red(`Unknown beta feature: ${pc.bold(flag)}`)); + console.error("\nAvailable beta features:"); + console.error(` ${pc.cyan("tarball")} Use pre-built tarball for agent installation`); + console.error(` ${pc.cyan("images")} Use pre-built DO marketplace images (faster boot)`); + console.error(` ${pc.cyan("parallel")} Parallelize server boot with setup prompts`); + console.error(` ${pc.cyan("docker")} Use Docker CE app image on Hetzner/GCP (faster boot)`); + console.error(` ${pc.cyan("sandbox")} Run local agents in a Docker container (sandboxed)`); + console.error(` ${pc.cyan("skills")} Pre-install MCP servers and tools on the VM`); + console.error(` ${pc.cyan("recursive")} Install spawn CLI on VM for recursive spawning`); + process.exit(1); } - return; - } - - // ── `spawn feedback` — bypass flag parsing; rest of args are the message ─── - // Also runs before initFeatureFlags() for the same reason as `pick`. - if (rawArgs[0] === "feedback") { - await cmdFeedback(rawArgs.slice(1)); - return; } +} - // Fetch feature flags (1.5s timeout, fail-open). Must run before any code - // path that gates on a flag — currently the SPAWN_BETA composition for the - // `fast_provision` experiment. Placed AFTER the pick/feedback bypasses so - // those fast paths never pay the flag-fetch cost. - await initFeatureFlags(); - +/** Parse all global flags from args, returning structured flags and the remaining args. */ +async function parseFlags(rawArgs: string[]): Promise { const args = expandEqualsFlags(rawArgs); // Pre-scan for --output json before checkForUpdates() so install script @@ -893,77 +842,31 @@ async function main(): Promise { 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; + // Boolean flags + const dryRun = extractBooleanFlag(filteredArgs, [ + "--dry-run", + "-n", + ]); + const debug = extractBooleanFlag(filteredArgs, [ + "--debug", + ]); + const headless = extractBooleanFlag(filteredArgs, [ + "--headless", + ]); + const custom = extractBooleanFlag(filteredArgs, [ + "--custom", + ]); if (custom) { - filteredArgs.splice(customIdx, 1); process.env.SPAWN_CUSTOM = "1"; } + extractEnvFlag(filteredArgs, "--reauth", "SPAWN_REAUTH"); + const fast = extractEnvFlag(filteredArgs, "--fast", "SPAWN_FAST"); - // Extract --reauth boolean flag - const reauthIdx = filteredArgs.indexOf("--reauth"); - if (reauthIdx !== -1) { - filteredArgs.splice(reauthIdx, 1); - process.env.SPAWN_REAUTH = "1"; - } - - // Extract --fast boolean flag — enables images + tarballs + parallel setup - const fastIdx = filteredArgs.indexOf("--fast"); - if (fastIdx !== -1) { - filteredArgs.splice(fastIdx, 1); - process.env.SPAWN_FAST = "1"; - } - - // Extract all --beta flags (repeatable, opt-in to experimental features) - const VALID_BETA_FEATURES = new Set([ - "tarball", - "images", - "parallel", - "docker", - "recursive", - "sandbox", - "skills", - ]); + // Beta features (repeatable) const betaFeatures = extractAllFlagValues(filteredArgs, "--beta", "spawn --beta parallel"); const userOptedIntoBeta = betaFeatures.length > 0 || process.env.SPAWN_FAST === "1"; - for (const flag of betaFeatures) { - if (!VALID_BETA_FEATURES.has(flag)) { - console.error(pc.red(`Unknown beta feature: ${pc.bold(flag)}`)); - console.error("\nAvailable beta features:"); - console.error(` ${pc.cyan("tarball")} Use pre-built tarball for agent installation`); - console.error(` ${pc.cyan("images")} Use pre-built DO marketplace images (faster boot)`); - console.error(` ${pc.cyan("parallel")} Parallelize server boot with setup prompts`); - console.error(` ${pc.cyan("docker")} Use Docker CE app image on Hetzner/GCP (faster boot)`); - console.error(` ${pc.cyan("sandbox")} Run local agents in a Docker container (sandboxed)`); - console.error(` ${pc.cyan("skills")} Pre-install MCP servers and tools on the VM`); - console.error(` ${pc.cyan("recursive")} Install spawn CLI on VM for recursive spawning`); - process.exit(1); - } - } - // --fast implies all beta features - if (process.env.SPAWN_FAST === "1") { + validateBetaFeatures(betaFeatures); + if (fast) { betaFeatures.push("tarball", "images", "parallel", "docker"); } @@ -984,30 +887,25 @@ async function main(): Promise { ].join(","); } - // Extract --model / -m flag → MODEL_ID env var (must be before --config so it takes priority) - const [modelFlag, modelFilteredArgs] = extractFlagValue( + // Value flags + extractValueToEnv( filteredArgs, [ "--model", "-m", ], 'spawn --model "openai/gpt-5.3-codex"', + "MODEL_ID", ); - filteredArgs.splice(0, filteredArgs.length, ...modelFilteredArgs); - if (modelFlag) { - process.env.MODEL_ID = modelFlag; - } - // Extract --config flag — load config file and apply as defaults - const [configPath, configFilteredArgs] = extractFlagValue( + // --config loads a config file and applies values as defaults + const configPath = extractValue( filteredArgs, [ "--config", ], "spawn --config setup.json", ); - filteredArgs.splice(0, filteredArgs.length, ...configFilteredArgs); - if (configPath) { const { loadSpawnConfig } = await import("./shared/spawn-config.js"); const configResult = tryCatch(() => loadSpawnConfig(configPath)); @@ -1017,7 +915,6 @@ async function main(): Promise { } const config = configResult.data; if (config) { - // Apply config values as defaults (explicit flags take priority) if (config.model && !process.env.MODEL_ID) { process.env.MODEL_ID = config.model; } @@ -1036,66 +933,53 @@ async function main(): Promise { } } - // Extract --steps flag — comma-separated list of setup steps - const [stepsFlag, stepsFilteredArgs] = extractFlagValue( + // --steps + const stepsFlag = extractValue( filteredArgs, [ "--steps", ], "spawn --steps github,browser,telegram", ); - filteredArgs.splice(0, filteredArgs.length, ...stepsFilteredArgs); if (stepsFlag !== undefined) { - // --steps "" means disable all optional steps process.env.SPAWN_ENABLED_STEPS = stepsFlag; } - // Extract --output flag - const [outputFormat, outputFilteredArgs] = extractFlagValue( + // --output + const outputFormat = extractValue( filteredArgs, [ "--output", ], "spawn --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 --headless --output json")}`); process.exit(1); } - // Extract --name flag - const [nameFlag, nameFilteredArgs] = extractFlagValue( + // --repo — clone a template repo and apply spawn.md + extractValueToEnv( filteredArgs, [ - "--name", + "--repo", ], - 'spawn --name "my-dev-box"', + 'spawn --repo "user/my-template"', + "SPAWN_REPO", ); - filteredArgs.splice(0, filteredArgs.length, ...nameFilteredArgs); - if (nameFlag) { - process.env.SPAWN_NAME = nameFlag; - } - // Extract --repo flag — clone a template repo and apply spawn.md - const [repoFlag, repoFilteredArgs] = extractFlagValue( + // --name, --zone/--region, --size/--machine-type + extractValueToEnv( filteredArgs, [ - "--repo", + "--name", ], - 'spawn --repo "user/my-template"', + 'spawn --name "my-dev-box"', + "SPAWN_NAME", ); - filteredArgs.splice(0, filteredArgs.length, ...repoFilteredArgs); - if (repoFlag) { - process.env.SPAWN_REPO = repoFlag; - } - // Extract --zone / --region flag (maps to cloud-specific env vars) - const [zoneFlag, zoneFilteredArgs] = extractFlagValue( + const zoneFlag = extractValue( filteredArgs, [ "--zone", @@ -1103,7 +987,6 @@ async function main(): Promise { ], "spawn gcp --zone us-east1-b", ); - filteredArgs.splice(0, filteredArgs.length, ...zoneFilteredArgs); if (zoneFlag) { process.env.GCP_ZONE = zoneFlag; process.env.DO_REGION = zoneFlag; @@ -1111,8 +994,7 @@ async function main(): Promise { process.env.AWS_DEFAULT_REGION = zoneFlag; } - // Extract --machine-type / --size flag (maps to cloud-specific env vars) - const [sizeFlag, sizeFilteredArgs] = extractFlagValue( + const sizeFlag = extractValue( filteredArgs, [ "--machine-type", @@ -1120,7 +1002,6 @@ async function main(): Promise { ], "spawn 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; @@ -1128,6 +1009,150 @@ async function main(): Promise { process.env.LIGHTSAIL_BUNDLE = sizeFlag; } + return { + filteredArgs, + prompt, + dryRun, + debug, + headless, + custom, + outputFormat, + betaFeatures, + }; +} + +/** 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 { + if (IMMEDIATE_COMMANDS[cmd]) { + warnExtraArgs(filteredArgs, 1); + IMMEDIATE_COMMANDS[cmd](); + return; + } + + if (cmd === "tree") { + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + const jsonFlag = filteredArgs.slice(1).includes("--json"); + await cmdTree(jsonFlag); + return; + } + if (cmd === "pull-history") { + await cmdPullHistory(); + return; + } + if (LIST_COMMANDS.has(cmd)) { + // Handle "history export" subcommand + if (cmd === "history" && filteredArgs[1] === "export") { + cmdHistoryExport(); + return; + } + await dispatchListCommand(filteredArgs); + return; + } + if (DELETE_COMMANDS.has(cmd)) { + await dispatchDeleteCommand(filteredArgs); + return; + } + if (FIX_COMMANDS.has(cmd)) { + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + // Optional positional argument: spawn fix [spawn-id] + const spawnId = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined; + await cmdFix(spawnId); + return; + } + if (STATUS_COMMANDS.has(cmd)) { + await dispatchStatusCommand(filteredArgs); + return; + } + if (SUBCOMMANDS[cmd]) { + await dispatchSubcommand(cmd, filteredArgs); + return; + } + if (cmd === "link" || cmd === "reconnect") { + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + await cmdLink(filteredArgs); + return; + } + if (cmd === "export") { + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + const targetArg = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined; + await cmdExport(targetArg); + 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; + } + } + + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + if (hasTrailingVersionFlag(filteredArgs)) { + showVersion(); + return; + } + + warnExtraArgs(filteredArgs, 2); + await handleDefaultCommand(filteredArgs[0], filteredArgs[1], prompt, dryRun, debug, headless, outputFormat); +} + +async function main(): Promise { + 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. + // Runs before initFeatureFlags() — this is a hot path called by shell + // scripts and must stay fast; it has no code paths that gate on a flag. + if (rawArgs[0] === "pick") { + const pickResult = await asyncTryCatch(() => cmdPick(expandEqualsFlags(rawArgs.slice(1)))); + if (!pickResult.ok) { + handleError(pickResult.error); + } + return; + } + + // ── `spawn feedback` — bypass flag parsing; rest of args are the message ─── + // Also runs before initFeatureFlags() for the same reason as `pick`. + if (rawArgs[0] === "feedback") { + await cmdFeedback(rawArgs.slice(1)); + return; + } + + // Fetch feature flags (1.5s timeout, fail-open). Must run before any code + // path that gates on a flag — currently the SPAWN_BETA composition for the + // `fast_provision` experiment. Placed AFTER the pick/feedback bypasses so + // those fast paths never pay the flag-fetch cost. + await initFeatureFlags(); + + const { filteredArgs, prompt, dryRun, debug, headless, custom, outputFormat } = await parseFlags(rawArgs); + // --output implies --headless const effectiveHeadless = headless || !!outputFormat;