feat: unified arrow-key selection + setup checkboxes (#2459)

* feat: unified arrow-key selection + setup checkboxes

Replace p.autocomplete (type-ahead) with p.select (arrow-key navigation)
for agent and cloud selection. Add p.multiselect checkboxes for optional
post-provision setup steps (GitHub CLI, Chrome browser), all ON by default.

Three fast prompts: agent → cloud → setup options. Defaults: OpenClaw,
first cloud with credentials, all steps enabled.

Key changes:
- interactive.ts: p.autocomplete → p.select with initialValue defaults
- interactive.ts: promptSetupOptions() with p.multiselect, exported for reuse
- run.ts: wire setup options into cmdRun direct path
- agents.ts: OptionalStep type, getAgentOptionalSteps() static metadata
- orchestrate.ts: read SPAWN_ENABLED_STEPS env var, gate GitHub auth + configure
- agent-setup.ts: gate Chrome install with enabledSteps in setupOpenclawConfig
- Version bump 0.15.40 → 0.16.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: mirror tarball files to $HOME for non-root SSH users (GCP, AWS)

Tarballs are built with absolute /root/ paths, but GCP and AWS Lightsail
SSH as a regular user whose $HOME is /home/<user>/. After extraction,
binaries like `claude` end up at /root/.claude/local/bin/ but the
launchCmd looks in $HOME/.claude/local/bin/ — causing "command not found".

Add a post-extraction step that copies /root/ dotfiles to $HOME/ when
the SSH user isn't root. This fixes `spawn claude gcp` failing with
exit code 127 after tarball install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-03-10 14:19:08 -07:00 committed by GitHub
parent dc3e4650bb
commit d82dea811d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 179 additions and 23 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.15.40",
"version": "0.16.0",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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 () => {

View file

@ -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();

View file

@ -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<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();
@ -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();
@ -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} `));
@ -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;
@ -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;

View file

@ -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

View file

@ -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);
@ -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: () =>

View file

@ -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;
}

View file

@ -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<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. */
@ -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 ──────────────────────────────────────────────────────────
/**

View file

@ -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) {