Skip to content

Commit d82dea8

Browse files
AhmedTMMclaudela14-1
authored
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>
1 parent dc3e465 commit d82dea8

9 files changed

Lines changed: 179 additions & 23 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/spawn",
3-
"version": "0.15.40",
3+
"version": "0.16.0",
44
"type": "module",
55
"bin": {
66
"spawn": "cli.js"

packages/cli/src/__tests__/agent-tarball.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,14 @@ describe("tryTarballInstall", () => {
8484
const result = await tryTarballInstall(runner, "openclaw", fetchFn);
8585

8686
expect(result).toBe(true);
87-
expect(runner.runServer).toHaveBeenCalledTimes(1);
87+
// 2 calls: download+extract, then mirror files for non-root users
88+
expect(runner.runServer).toHaveBeenCalledTimes(2);
8889
const cmd = String(runner.runServer.mock.calls[0][0]);
8990
expect(cmd).toContain("curl -fsSL");
9091
expect(cmd).toContain("tar xz -C /");
9192
expect(cmd).toContain(".spawn-tarball");
93+
const mirrorCmd = String(runner.runServer.mock.calls[1][0]);
94+
expect(mirrorCmd).toContain("cp -a");
9295
});
9396

9497
it("returns false when release does not exist (404)", async () => {

packages/cli/src/__tests__/orchestrate.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ describe("runOrchestration", () => {
117117
process.env.SPAWN_HOME = testDir;
118118
// Skip GitHub auth prompts during tests
119119
process.env.SPAWN_SKIP_GITHUB_AUTH = "1";
120+
// Ensure no stale SPAWN_ENABLED_STEPS leaks between tests
121+
delete process.env.SPAWN_ENABLED_STEPS;
120122
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
121123
exitSpy = spyOn(process, "exit").mockImplementation((code) => {
122124
capturedExitCode = isNumber(code) ? code : 0;
@@ -326,7 +328,7 @@ describe("runOrchestration", () => {
326328

327329
await runOrchestrationSafe(cloud, agent, "testagent");
328330

329-
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "anthropic/claude-3");
331+
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "anthropic/claude-3", undefined);
330332
stderrSpy.mockRestore();
331333
exitSpy.mockRestore();
332334
});
@@ -342,7 +344,7 @@ describe("runOrchestration", () => {
342344

343345
await runOrchestrationSafe(cloud, agent, "testagent");
344346

345-
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "google/gemini-pro");
347+
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", "google/gemini-pro", undefined);
346348
process.env.MODEL_ID = originalModelId;
347349
stderrSpy.mockRestore();
348350
exitSpy.mockRestore();
@@ -359,7 +361,7 @@ describe("runOrchestration", () => {
359361

360362
await runOrchestrationSafe(cloud, agent, "testagent");
361363

362-
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", undefined);
364+
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", undefined, undefined);
363365
process.env.MODEL_ID = originalModelId;
364366
stderrSpy.mockRestore();
365367
exitSpy.mockRestore();

packages/cli/src/commands/interactive.ts

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as p from "@clack/prompts";
44
import pc from "picocolors";
55
import { getActiveServers } from "../history.js";
66
import { agentKeys } from "../manifest.js";
7+
import { getAgentOptionalSteps } from "../shared/agents.js";
78
import { activeServerPicker } from "./list.js";
89
import { execScript, showDryRunPreview } from "./run.js";
910
import {
@@ -20,14 +21,14 @@ import {
2021
VERSION,
2122
} from "./shared.js";
2223

23-
// Prompt user to select an agent with hints and type-ahead filtering
24+
// Prompt user to select an agent with arrow-key navigation
2425
async function selectAgent(manifest: Manifest): Promise<string> {
2526
const agents = agentKeys(manifest);
2627
const agentHints = buildAgentPickerHints(manifest);
27-
const agentChoice = await p.autocomplete({
28-
message: "Select an agent (type to filter)",
28+
const agentChoice = await p.select({
29+
message: "Select an agent",
2930
options: mapToSelectOptions(agents, manifest.agents, agentHints),
30-
placeholder: "Start typing to search...",
31+
initialValue: agents.includes("openclaw") ? "openclaw" : agents[0],
3132
});
3233
if (p.isCancel(agentChoice)) {
3334
handleCancel();
@@ -73,16 +74,16 @@ function getAndValidateCloudChoices(
7374
};
7475
}
7576

76-
// Prompt user to select a cloud from the sorted list with type-ahead filtering
77+
// Prompt user to select a cloud with arrow-key navigation
7778
async function selectCloud(
7879
manifest: Manifest,
7980
cloudList: string[],
8081
hintOverrides: Record<string, string>,
8182
): Promise<string> {
82-
const cloudChoice = await p.autocomplete({
83-
message: "Select a cloud (type to filter)",
83+
const cloudChoice = await p.select({
84+
message: "Select a cloud",
8485
options: mapToSelectOptions(cloudList, manifest.clouds, hintOverrides),
85-
placeholder: "Start typing to search...",
86+
initialValue: cloudList[0],
8687
});
8788
if (p.isCancel(cloudChoice)) {
8889
handleCancel();
@@ -121,7 +122,66 @@ async function promptSpawnName(): Promise<string | undefined> {
121122
return spawnName || undefined;
122123
}
123124

124-
export { promptSpawnName, getAndValidateCloudChoices, selectCloud };
125+
/** Check whether the local host has a GitHub token (env or `gh auth`). */
126+
function hasLocalGithubToken(): boolean {
127+
if (process.env.GITHUB_TOKEN) {
128+
return true;
129+
}
130+
try {
131+
const result = Bun.spawnSync(
132+
[
133+
"gh",
134+
"auth",
135+
"token",
136+
],
137+
{
138+
stdio: [
139+
"ignore",
140+
"pipe",
141+
"ignore",
142+
],
143+
},
144+
);
145+
return result.exitCode === 0;
146+
} catch {
147+
return false;
148+
}
149+
}
150+
151+
/**
152+
* Show a multiselect prompt for optional post-provision setup steps.
153+
* Returns a Set of enabled step values, or undefined if there are no steps.
154+
* On cancel, returns all steps enabled (safe default).
155+
*/
156+
async function promptSetupOptions(agentName: string): Promise<Set<string> | undefined> {
157+
const steps = getAgentOptionalSteps(agentName);
158+
159+
// Filter GitHub option if no local token detected
160+
const filteredSteps = hasLocalGithubToken() ? steps : steps.filter((s) => s.value !== "github");
161+
162+
if (filteredSteps.length === 0) {
163+
return undefined;
164+
}
165+
166+
const allValues = filteredSteps.map((s) => s.value);
167+
const selected = await p.multiselect({
168+
message: "Setup options",
169+
options: filteredSteps.map((s) => ({
170+
value: s.value,
171+
label: s.label,
172+
hint: s.hint,
173+
})),
174+
initialValues: allValues,
175+
required: false,
176+
});
177+
178+
if (p.isCancel(selected)) {
179+
return new Set(allValues);
180+
}
181+
return new Set(selected);
182+
}
183+
184+
export { promptSpawnName, promptSetupOptions, getAndValidateCloudChoices, selectCloud };
125185

126186
export async function cmdInteractive(): Promise<void> {
127187
p.intro(pc.inverse(` spawn v${VERSION} `));
@@ -166,6 +226,13 @@ export async function cmdInteractive(): Promise<void> {
166226

167227
await preflightCredentialCheck(manifest, cloudChoice);
168228

229+
const enabledSteps = await promptSetupOptions(agentChoice);
230+
if (enabledSteps) {
231+
process.env.SPAWN_ENABLED_STEPS = [
232+
...enabledSteps,
233+
].join(",");
234+
}
235+
169236
const spawnName = await promptSpawnName();
170237

171238
const agentName = manifest.agents[agentChoice].name;
@@ -212,6 +279,13 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun
212279

213280
await preflightCredentialCheck(manifest, cloudChoice);
214281

282+
const enabledSteps = await promptSetupOptions(resolvedAgent);
283+
if (enabledSteps) {
284+
process.env.SPAWN_ENABLED_STEPS = [
285+
...enabledSteps,
286+
].join(",");
287+
}
288+
215289
const spawnName = await promptSpawnName();
216290

217291
const agentName = manifest.agents[resolvedAgent].name;

packages/cli/src/commands/run.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { generateSpawnId, getActiveServers, saveSpawnRecord } from "../history.j
1010
import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js";
1111
import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js";
1212
import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
13-
import { promptSpawnName } from "./interactive.js";
13+
import { promptSetupOptions, promptSpawnName } from "./interactive.js";
1414
import { handleRecordAction } from "./list.js";
1515
import {
1616
buildRetryCommand,
@@ -935,6 +935,13 @@ export async function cmdRun(
935935

936936
await preflightCredentialCheck(manifest, cloud);
937937

938+
const enabledSteps = await promptSetupOptions(agent);
939+
if (enabledSteps) {
940+
process.env.SPAWN_ENABLED_STEPS = [
941+
...enabledSteps,
942+
].join(",");
943+
}
944+
938945
const spawnName = await promptSpawnName();
939946

940947
// If a name was given, check whether an active instance with that name already

packages/cli/src/shared/agent-setup.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,17 @@ async function setupOpenclawConfig(
339339
apiKey: string,
340340
modelId: string,
341341
token?: string,
342+
enabledSteps?: Set<string>,
342343
): Promise<void> {
343344
logStep("Configuring openclaw...");
344345
await runner.runServer("mkdir -p ~/.openclaw");
345346

346347
// Chrome must be installed before config is written (config references its path).
347348
// This runs in configure() — not install() — so it works even with tarball installs.
348-
await installChromeBrowser(runner);
349+
// Gate with enabledSteps — user can skip ~400 MB download via setup checkboxes.
350+
if (!enabledSteps || enabledSteps.has("browser")) {
351+
await installChromeBrowser(runner);
352+
}
349353

350354
const gatewayToken = token ?? crypto.randomUUID().replace(/-/g, "");
351355
const escapedKey = jsonEscape(apiKey);
@@ -655,8 +659,8 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
655659
`ANTHROPIC_API_KEY=${apiKey}`,
656660
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
657661
],
658-
configure: (apiKey: string, modelId?: string) =>
659-
setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5", dashboardToken),
662+
configure: (apiKey: string, modelId?: string, enabledSteps?: Set<string>) =>
663+
setupOpenclawConfig(runner, apiKey, modelId || "moonshotai/kimi-k2.5", dashboardToken, enabledSteps),
660664
preLaunch: () => startGateway(runner),
661665
preLaunchMsg: "Your web dashboard will open automatically. If it doesn't, check the terminal for the URL.",
662666
launchCmd: () =>

packages/cli/src/shared/agent-tarball.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,27 @@ export async function tryTarballInstall(
113113
return false;
114114
}
115115

116+
// Phase 4: Mirror /root/ files to $HOME/ for non-root SSH users (e.g. GCP, AWS Lightsail).
117+
// Tarballs are built with absolute /root/ paths, but some clouds SSH as a regular user
118+
// whose $HOME is /home/<user>/, not /root/. Without this, binaries are unreachable.
119+
const mirrorCmd = [
120+
'if [ "$(id -u)" != "0" ]; then',
121+
" for _d in .claude .local .npm-global .cargo .opencode .hermes .bun; do",
122+
' if [ -d "/root/$_d" ]; then',
123+
' mkdir -p "$HOME/$_d"',
124+
' cp -a "/root/$_d/." "$HOME/$_d/" 2>/dev/null || true',
125+
" fi",
126+
" done",
127+
" # Copy marker file",
128+
' cp /root/.spawn-tarball "$HOME/.spawn-tarball" 2>/dev/null || true',
129+
"fi",
130+
].join("\n");
131+
try {
132+
await runner.runServer(mirrorCmd, 30);
133+
} catch {
134+
logWarn("Tarball file mirroring failed (non-fatal)");
135+
}
136+
116137
logInfo("Agent installed from pre-built tarball");
117138
return true;
118139
}

packages/cli/src/shared/agents.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { logError } from "./ui";
77
/** Cloud-init dependency tier: what packages to pre-install on the VM. */
88
export type CloudInitTier = "minimal" | "node" | "bun" | "full";
99

10+
/** An optional post-provision setup step the user can toggle on/off. */
11+
export interface OptionalStep {
12+
value: string;
13+
label: string;
14+
hint?: string;
15+
}
16+
1017
export interface AgentConfig {
1118
name: string;
1219
/** Default model ID passed to configure() (no interactive prompt — override via MODEL_ID env var). */
@@ -18,7 +25,7 @@ export interface AgentConfig {
1825
/** Return env var pairs for .spawnrc. */
1926
envVars: (apiKey: string) => string[];
2027
/** Agent-specific configuration (settings files, etc.). */
21-
configure?: (apiKey: string, modelId?: string) => Promise<void>;
28+
configure?: (apiKey: string, modelId?: string, enabledSteps?: Set<string>) => Promise<void>;
2229
/** Pre-launch hook (e.g., start gateway daemon). */
2330
preLaunch?: () => Promise<void>;
2431
/** Optional tip or warning shown to the user just before the agent launches. */
@@ -39,6 +46,35 @@ export interface TunnelConfig {
3946
browserUrl?: (localPort: number) => string | undefined;
4047
}
4148

49+
// ─── Agent Optional Steps (static metadata — no CloudRunner needed) ─────────
50+
51+
/** Optional setup steps for each agent, keyed by agent name. */
52+
const AGENT_OPTIONAL_STEPS: Record<string, OptionalStep[]> = {
53+
openclaw: [
54+
{
55+
value: "github",
56+
label: "GitHub CLI",
57+
},
58+
{
59+
value: "browser",
60+
label: "Chrome browser",
61+
hint: "~400 MB — enables web tools",
62+
},
63+
],
64+
};
65+
66+
const DEFAULT_OPTIONAL_STEPS: OptionalStep[] = [
67+
{
68+
value: "github",
69+
label: "GitHub CLI",
70+
},
71+
];
72+
73+
/** Get the optional setup steps for a given agent (no CloudRunner required). */
74+
export function getAgentOptionalSteps(agentName: string): OptionalStep[] {
75+
return AGENT_OPTIONAL_STEPS[agentName] ?? DEFAULT_OPTIONAL_STEPS;
76+
}
77+
4278
// ─── Shared Helpers ──────────────────────────────────────────────────────────
4379

4480
/**

packages/cli/src/shared/orchestrate.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,26 @@ export async function runOrchestration(
170170
logWarn("Environment setup had errors");
171171
}
172172

173-
// 10. Agent-specific configuration
173+
// 10. Parse enabled setup steps from env (set by interactive/run prompts)
174+
let enabledSteps: Set<string> | undefined;
175+
const stepsEnv = process.env.SPAWN_ENABLED_STEPS;
176+
if (stepsEnv !== undefined) {
177+
enabledSteps = new Set(stepsEnv.split(",").filter(Boolean));
178+
}
179+
180+
// 10b. Agent-specific configuration
174181
if (agent.configure) {
175182
try {
176-
await withRetry("agent config", () => wrapSshCall(agent.configure!(apiKey, modelId)), 2, 5);
183+
await withRetry("agent config", () => wrapSshCall(agent.configure!(apiKey, modelId, enabledSteps)), 2, 5);
177184
} catch {
178185
logWarn("Agent configuration failed (continuing with defaults)");
179186
}
180187
}
181188

182-
// GitHub CLI setup
183-
await offerGithubAuth(cloud.runner);
189+
// GitHub CLI setup (skip if user unchecked in setup options)
190+
if (!enabledSteps || enabledSteps.has("github")) {
191+
await offerGithubAuth(cloud.runner);
192+
}
184193

185194
// 11. Pre-launch hooks (e.g. OpenClaw gateway)
186195
if (agent.preLaunch) {

0 commit comments

Comments
 (0)