diff --git a/packages/cli/package.json b/packages/cli/package.json index 4cfc9223..e5fb50f3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.15.40", + "version": "0.16.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/agent-tarball.test.ts b/packages/cli/src/__tests__/agent-tarball.test.ts index 54393bb4..6e003752 100644 --- a/packages/cli/src/__tests__/agent-tarball.test.ts +++ b/packages/cli/src/__tests__/agent-tarball.test.ts @@ -84,11 +84,14 @@ describe("tryTarballInstall", () => { const result = await tryTarballInstall(runner, "openclaw", fetchFn); expect(result).toBe(true); - expect(runner.runServer).toHaveBeenCalledTimes(1); + // 2 calls: download+extract, then mirror files for non-root users + expect(runner.runServer).toHaveBeenCalledTimes(2); const cmd = String(runner.runServer.mock.calls[0][0]); expect(cmd).toContain("curl -fsSL"); expect(cmd).toContain("tar xz -C /"); expect(cmd).toContain(".spawn-tarball"); + const mirrorCmd = String(runner.runServer.mock.calls[1][0]); + expect(mirrorCmd).toContain("cp -a"); }); it("returns false when release does not exist (404)", async () => { diff --git a/packages/cli/src/__tests__/orchestrate.test.ts b/packages/cli/src/__tests__/orchestrate.test.ts index 2fe685d7..e536b163 100644 --- a/packages/cli/src/__tests__/orchestrate.test.ts +++ b/packages/cli/src/__tests__/orchestrate.test.ts @@ -117,6 +117,8 @@ describe("runOrchestration", () => { process.env.SPAWN_HOME = testDir; // Skip GitHub auth prompts during tests process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; + // Ensure no stale SPAWN_ENABLED_STEPS leaks between tests + delete process.env.SPAWN_ENABLED_STEPS; stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); exitSpy = spyOn(process, "exit").mockImplementation((code) => { capturedExitCode = isNumber(code) ? code : 0; @@ -326,7 +328,7 @@ describe("runOrchestration", () => { await runOrchestrationSafe(cloud, agent, "testagent"); - expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "anthropic/claude-3"); + expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "anthropic/claude-3", undefined); stderrSpy.mockRestore(); exitSpy.mockRestore(); }); @@ -342,7 +344,7 @@ describe("runOrchestration", () => { await runOrchestrationSafe(cloud, agent, "testagent"); - expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "google/gemini-pro"); + expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "google/gemini-pro", undefined); process.env.MODEL_ID = originalModelId; stderrSpy.mockRestore(); exitSpy.mockRestore(); @@ -359,7 +361,7 @@ describe("runOrchestration", () => { await runOrchestrationSafe(cloud, agent, "testagent"); - expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", undefined); + expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", undefined, undefined); process.env.MODEL_ID = originalModelId; stderrSpy.mockRestore(); exitSpy.mockRestore(); diff --git a/packages/cli/src/commands/interactive.ts b/packages/cli/src/commands/interactive.ts index b0dcf764..837c009d 100644 --- a/packages/cli/src/commands/interactive.ts +++ b/packages/cli/src/commands/interactive.ts @@ -4,6 +4,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { getActiveServers } from "../history.js"; import { agentKeys } from "../manifest.js"; +import { getAgentOptionalSteps } from "../shared/agents.js"; import { activeServerPicker } from "./list.js"; import { execScript, showDryRunPreview } from "./run.js"; import { @@ -20,14 +21,14 @@ import { VERSION, } from "./shared.js"; -// Prompt user to select an agent with hints and type-ahead filtering +// Prompt user to select an agent with arrow-key navigation async function selectAgent(manifest: Manifest): Promise { const agents = agentKeys(manifest); const agentHints = buildAgentPickerHints(manifest); - const agentChoice = await p.autocomplete({ - message: "Select an agent (type to filter)", + const agentChoice = await p.select({ + message: "Select an agent", options: mapToSelectOptions(agents, manifest.agents, agentHints), - placeholder: "Start typing to search...", + initialValue: agents.includes("openclaw") ? "openclaw" : agents[0], }); if (p.isCancel(agentChoice)) { handleCancel(); @@ -73,16 +74,16 @@ function getAndValidateCloudChoices( }; } -// Prompt user to select a cloud from the sorted list with type-ahead filtering +// Prompt user to select a cloud with arrow-key navigation async function selectCloud( manifest: Manifest, cloudList: string[], hintOverrides: Record, ): Promise { - const cloudChoice = await p.autocomplete({ - message: "Select a cloud (type to filter)", + const cloudChoice = await p.select({ + message: "Select a cloud", options: mapToSelectOptions(cloudList, manifest.clouds, hintOverrides), - placeholder: "Start typing to search...", + initialValue: cloudList[0], }); if (p.isCancel(cloudChoice)) { handleCancel(); @@ -121,7 +122,66 @@ async function promptSpawnName(): Promise { return spawnName || undefined; } -export { promptSpawnName, getAndValidateCloudChoices, selectCloud }; +/** Check whether the local host has a GitHub token (env or `gh auth`). */ +function hasLocalGithubToken(): boolean { + if (process.env.GITHUB_TOKEN) { + return true; + } + try { + const result = Bun.spawnSync( + [ + "gh", + "auth", + "token", + ], + { + stdio: [ + "ignore", + "pipe", + "ignore", + ], + }, + ); + return result.exitCode === 0; + } catch { + return false; + } +} + +/** + * Show a multiselect prompt for optional post-provision setup steps. + * Returns a Set of enabled step values, or undefined if there are no steps. + * On cancel, returns all steps enabled (safe default). + */ +async function promptSetupOptions(agentName: string): Promise | undefined> { + const steps = getAgentOptionalSteps(agentName); + + // Filter GitHub option if no local token detected + const filteredSteps = hasLocalGithubToken() ? steps : steps.filter((s) => s.value !== "github"); + + if (filteredSteps.length === 0) { + return undefined; + } + + const allValues = filteredSteps.map((s) => s.value); + const selected = await p.multiselect({ + message: "Setup options", + options: filteredSteps.map((s) => ({ + value: s.value, + label: s.label, + hint: s.hint, + })), + initialValues: allValues, + required: false, + }); + + if (p.isCancel(selected)) { + return new Set(allValues); + } + return new Set(selected); +} + +export { promptSpawnName, promptSetupOptions, getAndValidateCloudChoices, selectCloud }; export async function cmdInteractive(): Promise { p.intro(pc.inverse(` spawn v${VERSION} `)); @@ -166,6 +226,13 @@ export async function cmdInteractive(): Promise { await preflightCredentialCheck(manifest, cloudChoice); + const enabledSteps = await promptSetupOptions(agentChoice); + if (enabledSteps) { + process.env.SPAWN_ENABLED_STEPS = [ + ...enabledSteps, + ].join(","); + } + const spawnName = await promptSpawnName(); const agentName = manifest.agents[agentChoice].name; @@ -212,6 +279,13 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun await preflightCredentialCheck(manifest, cloudChoice); + const enabledSteps = await promptSetupOptions(resolvedAgent); + if (enabledSteps) { + process.env.SPAWN_ENABLED_STEPS = [ + ...enabledSteps, + ].join(","); + } + const spawnName = await promptSpawnName(); const agentName = manifest.agents[resolvedAgent].name; diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 0a30dacd..a21e3d67 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -10,7 +10,7 @@ import { generateSpawnId, getActiveServers, saveSpawnRecord } from "../history.j import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js"; import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js"; import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js"; -import { promptSpawnName } from "./interactive.js"; +import { promptSetupOptions, promptSpawnName } from "./interactive.js"; import { handleRecordAction } from "./list.js"; import { buildRetryCommand, @@ -935,6 +935,13 @@ export async function cmdRun( await preflightCredentialCheck(manifest, cloud); + const enabledSteps = await promptSetupOptions(agent); + if (enabledSteps) { + process.env.SPAWN_ENABLED_STEPS = [ + ...enabledSteps, + ].join(","); + } + const spawnName = await promptSpawnName(); // If a name was given, check whether an active instance with that name already diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index d139e99c..4d3c456c 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -339,13 +339,17 @@ async function setupOpenclawConfig( apiKey: string, modelId: string, token?: string, + enabledSteps?: Set, ): Promise { logStep("Configuring openclaw..."); await runner.runServer("mkdir -p ~/.openclaw"); // Chrome must be installed before config is written (config references its path). // This runs in configure() — not install() — so it works even with tarball installs. - await installChromeBrowser(runner); + // Gate with enabledSteps — user can skip ~400 MB download via setup checkboxes. + if (!enabledSteps || enabledSteps.has("browser")) { + await installChromeBrowser(runner); + } const gatewayToken = token ?? crypto.randomUUID().replace(/-/g, ""); const escapedKey = jsonEscape(apiKey); @@ -655,8 +659,8 @@ function createAgents(runner: CloudRunner): Record { `ANTHROPIC_API_KEY=${apiKey}`, "ANTHROPIC_BASE_URL=https://openrouter.ai/api", ], - configure: (apiKey: string, modelId?: string) => - setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5", dashboardToken), + configure: (apiKey: string, modelId?: string, enabledSteps?: Set) => + setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5", dashboardToken, enabledSteps), preLaunch: () => startGateway(runner), preLaunchMsg: "Your web dashboard will open automatically. If it doesn't, check the terminal for the URL.", launchCmd: () => diff --git a/packages/cli/src/shared/agent-tarball.ts b/packages/cli/src/shared/agent-tarball.ts index f2d141ba..5b93e9e0 100644 --- a/packages/cli/src/shared/agent-tarball.ts +++ b/packages/cli/src/shared/agent-tarball.ts @@ -113,6 +113,27 @@ export async function tryTarballInstall( return false; } + // Phase 4: Mirror /root/ files to $HOME/ for non-root SSH users (e.g. GCP, AWS Lightsail). + // Tarballs are built with absolute /root/ paths, but some clouds SSH as a regular user + // whose $HOME is /home//, not /root/. Without this, binaries are unreachable. + const mirrorCmd = [ + 'if [ "$(id -u)" != "0" ]; then', + " for _d in .claude .local .npm-global .cargo .opencode .hermes .bun; do", + ' if [ -d "/root/$_d" ]; then', + ' mkdir -p "$HOME/$_d"', + ' cp -a "/root/$_d/." "$HOME/$_d/" 2>/dev/null || true', + " fi", + " done", + " # Copy marker file", + ' cp /root/.spawn-tarball "$HOME/.spawn-tarball" 2>/dev/null || true', + "fi", + ].join("\n"); + try { + await runner.runServer(mirrorCmd, 30); + } catch { + logWarn("Tarball file mirroring failed (non-fatal)"); + } + logInfo("Agent installed from pre-built tarball"); return true; } diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index 60b6fde3..d3236e16 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -7,6 +7,13 @@ import { logError } from "./ui"; /** Cloud-init dependency tier: what packages to pre-install on the VM. */ export type CloudInitTier = "minimal" | "node" | "bun" | "full"; +/** An optional post-provision setup step the user can toggle on/off. */ +export interface OptionalStep { + value: string; + label: string; + hint?: string; +} + export interface AgentConfig { name: string; /** Default model ID passed to configure() (no interactive prompt — override via MODEL_ID env var). */ @@ -18,7 +25,7 @@ export interface AgentConfig { /** Return env var pairs for .spawnrc. */ envVars: (apiKey: string) => string[]; /** Agent-specific configuration (settings files, etc.). */ - configure?: (apiKey: string, modelId?: string) => Promise; + configure?: (apiKey: string, modelId?: string, enabledSteps?: Set) => Promise; /** Pre-launch hook (e.g., start gateway daemon). */ preLaunch?: () => Promise; /** Optional tip or warning shown to the user just before the agent launches. */ @@ -39,6 +46,35 @@ export interface TunnelConfig { browserUrl?: (localPort: number) => string | undefined; } +// ─── Agent Optional Steps (static metadata — no CloudRunner needed) ───────── + +/** Optional setup steps for each agent, keyed by agent name. */ +const AGENT_OPTIONAL_STEPS: Record = { + openclaw: [ + { + value: "github", + label: "GitHub CLI", + }, + { + value: "browser", + label: "Chrome browser", + hint: "~400 MB — enables web tools", + }, + ], +}; + +const DEFAULT_OPTIONAL_STEPS: OptionalStep[] = [ + { + value: "github", + label: "GitHub CLI", + }, +]; + +/** Get the optional setup steps for a given agent (no CloudRunner required). */ +export function getAgentOptionalSteps(agentName: string): OptionalStep[] { + return AGENT_OPTIONAL_STEPS[agentName] ?? DEFAULT_OPTIONAL_STEPS; +} + // ─── Shared Helpers ────────────────────────────────────────────────────────── /** diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 5eee5389..77f5859b 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -170,17 +170,26 @@ export async function runOrchestration( logWarn("Environment setup had errors"); } - // 10. Agent-specific configuration + // 10. Parse enabled setup steps from env (set by interactive/run prompts) + let enabledSteps: Set | undefined; + const stepsEnv = process.env.SPAWN_ENABLED_STEPS; + if (stepsEnv !== undefined) { + enabledSteps = new Set(stepsEnv.split(",").filter(Boolean)); + } + + // 10b. Agent-specific configuration if (agent.configure) { try { - await withRetry("agent config", () => wrapSshCall(agent.configure!(apiKey, modelId)), 2, 5); + await withRetry("agent config", () => wrapSshCall(agent.configure!(apiKey, modelId, enabledSteps)), 2, 5); } catch { logWarn("Agent configuration failed (continuing with defaults)"); } } - // GitHub CLI setup - await offerGithubAuth(cloud.runner); + // GitHub CLI setup (skip if user unchecked in setup options) + if (!enabledSteps || enabledSteps.has("github")) { + await offerGithubAuth(cloud.runner); + } // 11. Pre-launch hooks (e.g. OpenClaw gateway) if (agent.preLaunch) {