Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.15.40",
"version": "0.16.0",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/__tests__/agent-tarball.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/__tests__/orchestrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
Expand All @@ -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();
Expand Down
92 changes: 83 additions & 9 deletions packages/cli/src/commands/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string> {
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();
Expand Down Expand Up @@ -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<string, string>,
): Promise<string> {
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();
Expand Down Expand Up @@ -121,7 +122,66 @@ async function promptSpawnName(): Promise<string | undefined> {
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<Set<string> | 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<void> {
p.intro(pc.inverse(` spawn v${VERSION} `));
Expand Down Expand Up @@ -166,6 +226,13 @@ export async function cmdInteractive(): Promise<void> {

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;
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions packages/cli/src/shared/agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,17 @@ async function setupOpenclawConfig(
apiKey: string,
modelId: string,
token?: string,
enabledSteps?: Set<string>,
): Promise<void> {
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);
Expand Down Expand Up @@ -655,8 +659,8 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
`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<string>) =>
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: () =>
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/shared/agent-tarball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user>/, 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;
}
38 changes: 37 additions & 1 deletion packages/cli/src/shared/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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<void>;
configure?: (apiKey: string, modelId?: string, enabledSteps?: Set<string>) => Promise<void>;
/** Pre-launch hook (e.g., start gateway daemon). */
preLaunch?: () => Promise<void>;
/** Optional tip or warning shown to the user just before the agent launches. */
Expand All @@ -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<string, OptionalStep[]> = {
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 ──────────────────────────────────────────────────────────

/**
Expand Down
17 changes: 13 additions & 4 deletions packages/cli/src/shared/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | 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) {
Expand Down
Loading