From 49ff7a2e2a6955e019b8d56c3fe998bca63a605a Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 00:49:58 -0700 Subject: [PATCH 1/9] feat: add Telegram and WhatsApp options to OpenClaw setup picker Adds separate "Telegram" and "WhatsApp" checkboxes to the OpenClaw setup screen: - Telegram: prompts for bot token from @BotFather, injects into OpenClaw config via `openclaw config set` - WhatsApp: reminds user to scan QR code via the web dashboard after launch (no CLI setup possible) Updates USER.md with channel-specific guidance when either is selected. Bump CLI version to 0.16.16. Co-Authored-By: Claude Opus 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/shared/agent-setup.ts | 52 +++++++++++++++++++++++++- packages/cli/src/shared/agents.ts | 10 +++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 3e7f37bfb..d411b8c75 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.15", + "version": "0.16.16", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 5e417efca..d496afe61 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -9,7 +9,7 @@ import { join } from "node:path"; import { getTmpDir } from "./paths"; import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js"; import { getErrorMessage } from "./type-guards"; -import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, withRetry } from "./ui"; +import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, prompt, withRetry } from "./ui"; /** * Wrap an SSH-based async operation into a Result for use with withRetry. @@ -367,8 +367,55 @@ async function setupOpenclawConfig( logWarn("Browser config setup failed (non-fatal)"); } + // Telegram channel setup — prompt for bot token and inject into config + if (enabledSteps?.has("telegram")) { + logStep("Setting up Telegram..."); + const botToken = await prompt("Telegram bot token (from @BotFather): "); + const trimmedToken = botToken.trim(); + + if (trimmedToken) { + const escapedBotToken = jsonEscape(trimmedToken); + const telegramResult = await asyncTryCatchIf(isOperationalError, () => + runner.runServer( + "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + + `openclaw config set channels.telegram.botToken ${escapedBotToken}`, + ), + ); + if (telegramResult.ok) { + logInfo("Telegram bot token configured"); + } else { + logWarn("Telegram config failed — set it up via the web dashboard after launch"); + } + } else { + logInfo("No token entered — set up Telegram via the web dashboard after launch"); + } + } + + // WhatsApp — requires QR code scanning, remind user to use dashboard + if (enabledSteps?.has("whatsapp")) { + logInfo("WhatsApp requires QR code scanning — use the web dashboard after launch"); + } + // Write USER.md bootstrap file — guides users to the web dashboard for // visual tasks like WhatsApp QR code scanning that don't work in the TUI. + const messagingLines: string[] = []; + if (enabledSteps?.has("telegram") || enabledSteps?.has("whatsapp")) { + messagingLines.push("", "## Messaging Channels", "", "The user selected messaging channels during setup."); + if (enabledSteps.has("telegram")) { + messagingLines.push( + "- **Telegram**: If a bot token was provided, it is already configured.", + " To verify: `openclaw config get channels.telegram.botToken`", + ); + } + if (enabledSteps.has("whatsapp")) { + messagingLines.push( + "- **WhatsApp**: Requires QR code scanning. Guide the user to the web", + " dashboard to complete setup: http://localhost:18791", + ); + } + messagingLines.push(""); + } + const userMd = [ "# User", "", @@ -381,6 +428,7 @@ async function setupOpenclawConfig( "", "The dashboard URL is: http://localhost:18791", "(It may also be SSH-tunneled to the user's local machine automatically.)", + ...messagingLines, "", ].join("\n"); await runner.runServer("mkdir -p ~/.openclaw/workspace"); @@ -639,7 +687,7 @@ function createAgents(runner: CloudRunner): Record { configure: (apiKey: string, modelId?: string, enabledSteps?: Set) => setupOpenclawConfig(runner, apiKey, modelId || "openrouter/openrouter/auto", dashboardToken, enabledSteps), preLaunch: () => startGateway(runner), - preLaunchMsg: "Your web dashboard will open automatically. If it doesn't, check the terminal for the URL.", + preLaunchMsg: "Your web dashboard will open automatically — use it for WhatsApp QR scanning and channel setup.", launchCmd: () => "source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; openclaw tui", tunnel: { diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index 11c5713d3..fd9f08e45 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -56,6 +56,16 @@ const AGENT_EXTRA_STEPS: Record = { label: "Chrome browser", hint: "~400 MB — enables web tools", }, + { + value: "telegram", + label: "Telegram", + hint: "connect via bot token from @BotFather", + }, + { + value: "whatsapp", + label: "WhatsApp", + hint: "scan QR code via web dashboard after launch", + }, ], }; From 024dab41396bb2c174016ebb6b85d26c68bdfa68 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 00:55:01 -0700 Subject: [PATCH 2/9] feat: run WhatsApp QR scan interactively before TUI launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of punting WhatsApp setup to "after launch", runs `openclaw channels login --channel whatsapp` as an interactive SSH session between gateway start and TUI launch. The user scans the QR code with their phone during provisioning setup. Flow: gateway starts → tunnel set up → WhatsApp QR scan → TUI launch Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/shared/agent-setup.ts | 6 ++---- packages/cli/src/shared/orchestrate.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index d496afe61..63039c2a2 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -391,10 +391,8 @@ async function setupOpenclawConfig( } } - // WhatsApp — requires QR code scanning, remind user to use dashboard - if (enabledSteps?.has("whatsapp")) { - logInfo("WhatsApp requires QR code scanning — use the web dashboard after launch"); - } + // WhatsApp — QR code scanning happens interactively in orchestrate.ts + // after the gateway starts and tunnel is set up. No config needed here. // Write USER.md bootstrap file — guides users to the web dashboard for // visual tasks like WhatsApp QR code scanning that don't work in the TUI. diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 35ffdb4a1..474440c6b 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -250,7 +250,20 @@ export async function runOrchestration( } } - // 11c. Agent-specific pre-launch tip (e.g. channel setup ordering hint) + // 11c. Interactive channel login (WhatsApp QR scan, Telegram bot link) + // Runs before the TUI so users can link messaging channels during setup. + if (enabledSteps?.has("whatsapp")) { + logStep("Linking WhatsApp — scan the QR code with your phone..."); + logInfo("Open WhatsApp > Settings > Linked Devices > Link a Device"); + process.stderr.write("\n"); + const whatsappCmd = + "source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + + "openclaw channels login --channel whatsapp"; + prepareStdinForHandoff(); + await cloud.interactiveSession(whatsappCmd); + } + + // 11d. Agent-specific pre-launch tip (e.g. channel setup ordering hint) if (agent.preLaunchMsg) { process.stderr.write("\n"); logInfo(`Tip: ${agent.preLaunchMsg}`); From 49c9930393eb7c3854c6044f144e16782fe26c99 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 00:57:00 -0700 Subject: [PATCH 3/9] fix: update WhatsApp hint to reflect pre-TUI QR scanning Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/shared/agents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index fd9f08e45..fdc5bbe99 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -64,7 +64,7 @@ const AGENT_EXTRA_STEPS: Record = { { value: "whatsapp", label: "WhatsApp", - hint: "scan QR code via web dashboard after launch", + hint: "scan QR code during setup", }, ], }; From e1f80f4086eb0a59626e744af777c3586505a8ca Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 15:04:25 -0700 Subject: [PATCH 4/9] fix: write openclaw config atomically to preserve gateway auth token The `openclaw config set` calls for browser and Telegram settings were re-serializing openclaw.json and dropping the gateway.auth.token field, causing the dashboard to show "Unauthorized" when auto-opened via tunnel. Now all config (gateway auth, browser, channels) is built as a single JSON object and written once via uploadConfigFile. Co-Authored-By: Claude Opus 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/shared/agent-setup.ts | 105 ++++++++++++------------- 2 files changed, 51 insertions(+), 56 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9920a8764..107ec029f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.19", + "version": "0.16.20", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 71188d180..f86c83fc6 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -328,69 +328,64 @@ async function setupOpenclawConfig( } const gatewayToken = token ?? crypto.randomUUID().replace(/-/g, ""); - const escapedKey = jsonEscape(apiKey); - const escapedToken = jsonEscape(gatewayToken); - const escapedModel = jsonEscape(modelId); - - const config = `{ - "env": { - "OPENROUTER_API_KEY": ${escapedKey} - }, - "gateway": { - "mode": "local", - "auth": { - "token": ${escapedToken} - } - }, - "agents": { - "defaults": { - "model": { - "primary": ${escapedModel} - } - } - } -}`; - await uploadConfigFile(runner, config, "$HOME/.openclaw/openclaw.json"); - // Configure browser via CLI (openclaw config set) — the supported way to set - // browser options. Writing JSON directly may not be picked up by all versions. - const browserResult = await asyncTryCatchIf(isOperationalError, () => - runner.runServer( - "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + - "openclaw config set browser.executablePath /usr/bin/google-chrome-stable; " + - "openclaw config set browser.noSandbox true; " + - "openclaw config set browser.headless true; " + - "openclaw config set browser.defaultProfile openclaw", - ), - ); - if (!browserResult.ok) { - logWarn("Browser config setup failed (non-fatal)"); - } - - // Telegram channel setup — prompt for bot token and inject into config + // Prompt for Telegram bot token before building the config JSON so we can + // include it in a single atomic write — avoids `openclaw config set` calls + // that can clobber the gateway auth token. + let telegramBotToken = ""; if (enabledSteps?.has("telegram")) { logStep("Setting up Telegram..."); const botToken = await prompt("Telegram bot token (from @BotFather): "); - const trimmedToken = botToken.trim(); - - if (trimmedToken) { - const escapedBotToken = jsonEscape(trimmedToken); - const telegramResult = await asyncTryCatchIf(isOperationalError, () => - runner.runServer( - "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + - `openclaw config set channels.telegram.botToken ${escapedBotToken}`, - ), - ); - if (telegramResult.ok) { - logInfo("Telegram bot token configured"); - } else { - logWarn("Telegram config failed — set it up via the web dashboard after launch"); - } - } else { + telegramBotToken = botToken.trim(); + if (!telegramBotToken) { logInfo("No token entered — set up Telegram via the web dashboard after launch"); } } + // Build the full config as a single JSON object. All settings — gateway auth, + // browser, channels — are written atomically to avoid `openclaw config set` + // calls that re-serialize the file and can drop the gateway auth token. + const hasBrowser = !enabledSteps || enabledSteps.has("browser"); + const configObj: Record = { + env: { + OPENROUTER_API_KEY: apiKey, + }, + gateway: { + mode: "local", + auth: { + token: gatewayToken, + }, + }, + agents: { + defaults: { + model: { + primary: modelId, + }, + }, + }, + }; + + if (hasBrowser) { + configObj.browser = { + executablePath: "/usr/bin/google-chrome-stable", + noSandbox: true, + headless: true, + defaultProfile: "openclaw", + }; + } + + if (telegramBotToken) { + configObj.channels = { + telegram: { + botToken: telegramBotToken, + }, + }; + logInfo("Telegram bot token configured"); + } + + const config = JSON.stringify(configObj, null, 2); + await uploadConfigFile(runner, config, "$HOME/.openclaw/openclaw.json"); + // WhatsApp — QR code scanning happens interactively in orchestrate.ts // after the gateway starts and tunnel is set up. No config needed here. From 3aa895e87648eabdb2cf0edae72a80505af468d8 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 15:42:43 -0700 Subject: [PATCH 5/9] test: add OpenClaw config, messaging, and tunnel test coverage Adds 40 new tests across 2 files: openclaw-config.test.ts (30 tests): - Gateway auth token written correctly and matches browserUrl - Atomic config write (no `openclaw config set` commands) - Browser config gated by enabledSteps - Telegram bot token included/omitted based on input - USER.md messaging channel content - Tunnel config targeting port 18791 orchestrate-messaging.test.ts (10 tests): - SPAWN_ENABLED_STEPS parsing and threading - WhatsApp QR scan session triggered before agent launch - GitHub auth gated by enabledSteps - preLaunchMsg output behavior Also adds SPAWN_TELEGRAM_BOT_TOKEN env var override for non-interactive/CI Telegram setup (avoids prompt in tests). Co-Authored-By: Claude Opus 4.6 --- .../cli/src/__tests__/openclaw-config.test.ts | 591 ++++++++++++++++++ .../__tests__/orchestrate-messaging.test.ts | 335 ++++++++++ packages/cli/src/shared/agent-setup.ts | 4 +- 3 files changed, 929 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/openclaw-config.test.ts create mode 100644 packages/cli/src/__tests__/orchestrate-messaging.test.ts diff --git a/packages/cli/src/__tests__/openclaw-config.test.ts b/packages/cli/src/__tests__/openclaw-config.test.ts new file mode 100644 index 000000000..54d625d85 --- /dev/null +++ b/packages/cli/src/__tests__/openclaw-config.test.ts @@ -0,0 +1,591 @@ +/** + * openclaw-config.test.ts — Tests for OpenClaw config generation, gateway auth + * token threading, Telegram/WhatsApp setup, and USER.md content. + * + * Verifies that: + * - The gateway auth token in openclaw.json matches the browserUrl token + * - Browser config is included atomically (no separate `openclaw config set`) + * - Telegram bot tokens are written into the config JSON + * - USER.md includes messaging channel guidance when selected + * - Chrome install is gated by enabledSteps + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { readFileSync } from "node:fs"; +import { tryCatch } from "@openrouter/spawn-shared"; +import { toRecord } from "../shared/type-guards"; +import { mockClackPrompts } from "./test-helpers"; + +// ── Mock @clack/prompts (must be before importing agent-setup) ────────── +const clack = mockClackPrompts(); + +// ── Import the module under test ──────────────────────────────────────── +const { createCloudAgents } = await import("../shared/agent-setup"); + +import type { CloudRunner } from "../shared/agent-setup"; + +// ── Helpers ───────────────────────────────────────────────────────────── + +/** Tracks all commands and uploads sent to the mock runner. */ +interface RunnerCapture { + runner: CloudRunner; + commands: string[]; + /** Contents of files uploaded via runner.uploadFile, read at upload time. */ + uploadedContents: string[]; +} + +function createCapturingRunner(): RunnerCapture { + const commands: string[] = []; + const uploadedContents: string[] = []; + + const runner: CloudRunner = { + runServer: mock(async (cmd: string) => { + commands.push(cmd); + }), + uploadFile: mock(async (localPath: string, _remotePath: string) => { + // Read the file content immediately — uploadConfigFile deletes it right after + const r = tryCatch(() => readFileSync(localPath, "utf-8")); + if (r.ok) { + uploadedContents.push(r.data); + } + }), + }; + + return { + runner, + commands, + uploadedContents, + }; +} + +/** Find the openclaw.json config from uploaded files. */ +function findConfigJson(capture: RunnerCapture): Record | null { + for (const content of capture.uploadedContents) { + const r = tryCatch(() => JSON.parse(content)); + if (r.ok && r.data && typeof r.data === "object" && "gateway" in r.data) { + return toRecord(r.data); + } + } + return null; +} + +/** Find the USER.md content from uploaded files. */ +function findUserMd(capture: RunnerCapture): string | null { + for (const content of capture.uploadedContents) { + if (content.includes("# User")) { + return content; + } + } + return null; +} + +/** Safely drill into a nested config object. */ +function drill(obj: Record, ...keys: string[]): unknown { + let current: unknown = obj; + for (const key of keys) { + const rec = toRecord(current); + if (rec && key in rec) { + current = rec[key]; + } else { + return undefined; + } + } + return current; +} + +// ── Test suite ────────────────────────────────────────────────────────── + +describe("OpenClaw config (setupOpenclawConfig)", () => { + let stderrSpy: ReturnType; + let savedTelegramToken: string | undefined; + + beforeEach(() => { + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + savedTelegramToken = process.env.SPAWN_TELEGRAM_BOT_TOKEN; + delete process.env.SPAWN_TELEGRAM_BOT_TOKEN; + }); + + afterEach(() => { + stderrSpy.mockRestore(); + if (savedTelegramToken !== undefined) { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = savedTelegramToken; + } else { + delete process.env.SPAWN_TELEGRAM_BOT_TOKEN; + } + }); + + // ── Gateway auth token ────────────────────────────────────────────── + + describe("gateway auth token", () => { + it("writes gateway.auth.token to openclaw.json", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-test-key", "test-model", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + + const token = drill(config!, "gateway", "auth", "token"); + expect(typeof token).toBe("string"); + expect(String(token).length).toBe(32); + }); + + it("browserUrl token matches the gateway.auth.token in config", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-test-key", "test-model", new Set([])); + + const config = findConfigJson(capture); + const configToken = String(drill(config!, "gateway", "auth", "token")); + + const browserUrl = agents.openclaw.tunnel!.browserUrl!(12345); + expect(browserUrl).toContain(`?token=${configToken}`); + }); + + it("token is stable across browserUrl calls (same agent instance)", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url1 = agents.openclaw.tunnel!.browserUrl!(8080); + const url2 = agents.openclaw.tunnel!.browserUrl!(9090); + + const token1 = new URL(url1!).searchParams.get("token"); + const token2 = new URL(url2!).searchParams.get("token"); + expect(token1).toBe(token2); + }); + + it("different createCloudAgents calls generate different tokens", () => { + const capture1 = createCapturingRunner(); + const capture2 = createCapturingRunner(); + const { agents: agents1 } = createCloudAgents(capture1.runner); + const { agents: agents2 } = createCloudAgents(capture2.runner); + + const url1 = agents1.openclaw.tunnel!.browserUrl!(8000); + const url2 = agents2.openclaw.tunnel!.browserUrl!(8000); + + const token1 = new URL(url1!).searchParams.get("token"); + const token2 = new URL(url2!).searchParams.get("token"); + + expect(token1).not.toBe(token2); + expect(token1!.length).toBe(32); + expect(token2!.length).toBe(32); + }); + }); + + // ── Atomic config write ───────────────────────────────────────────── + + describe("atomic config write", () => { + it("writes API key, gateway token, and model in a single JSON upload", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-my-api-key", "anthropic/claude-3", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + + expect(drill(config!, "env", "OPENROUTER_API_KEY")).toBe("sk-my-api-key"); + expect(drill(config!, "gateway", "mode")).toBe("local"); + expect(drill(config!, "gateway", "auth", "token")).toBeDefined(); + expect(drill(config!, "agents", "defaults", "model", "primary")).toBe("anthropic/claude-3"); + }); + + it("does not run openclaw config set commands", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const configSetCmds = capture.commands.filter((c) => c.includes("openclaw config set")); + expect(configSetCmds).toHaveLength(0); + }); + + it("includes browser config in the JSON when browser step is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.browser).toBeDefined(); + expect(drill(config!, "browser", "executablePath")).toBe("/usr/bin/google-chrome-stable"); + expect(drill(config!, "browser", "noSandbox")).toBe(true); + expect(drill(config!, "browser", "headless")).toBe(true); + expect(drill(config!, "browser", "defaultProfile")).toBe("openclaw"); + }); + + it("includes browser config when enabledSteps is undefined (default)", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", undefined); + + const config = findConfigJson(capture); + expect(config!.browser).toBeDefined(); + }); + + it("excludes browser config when browser step is not in enabledSteps", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "github", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.browser).toBeUndefined(); + }); + + it("writes valid JSON with special characters in API key", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!('sk-key-with-"quotes"&special', "model/with/slashes", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + expect(drill(config!, "env", "OPENROUTER_API_KEY")).toBe('sk-key-with-"quotes"&special'); + expect(drill(config!, "agents", "defaults", "model", "primary")).toBe("model/with/slashes"); + }); + }); + + // ── Chrome browser install gating ─────────────────────────────────── + + describe("Chrome browser install", () => { + it("installs Chrome when browser step is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeDefined(); + }); + + it("installs Chrome when enabledSteps is undefined (default behavior)", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", undefined); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeDefined(); + }); + + it("skips Chrome install when browser step is not selected", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "github", + ]), + ); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeUndefined(); + }); + }); + + // ── Telegram setup ────────────────────────────────────────────────── + + describe("Telegram bot token", () => { + it("includes Telegram bot token in config JSON when provided", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "12345:ABCdefGhIjKlMnOpQrStUvWxYz"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("12345:ABCdefGhIjKlMnOpQrStUvWxYz"); + }); + + it("trims whitespace from Telegram bot token", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = " bot-token-123 "; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("bot-token-123"); + }); + + it("omits channels from config when Telegram token is empty", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = " "; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("omits channels when no token is provided", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("omits channels from config when Telegram is not in enabledSteps", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "should-not-be-used"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("gateway auth token is preserved when Telegram token is set", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "my-bot-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "browser", + ]), + ); + + const config = findConfigJson(capture); + + const token = drill(config!, "gateway", "auth", "token"); + expect(typeof token).toBe("string"); + expect(String(token).length).toBe(32); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("my-bot-token"); + + const browserUrl = agents.openclaw.tunnel!.browserUrl!(8080); + expect(browserUrl).toContain(`?token=${token}`); + }); + + it("browser config coexists with Telegram config in same JSON", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "my-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.gateway).toBeDefined(); + expect(config!.browser).toBeDefined(); + expect(config!.channels).toBeDefined(); + }); + }); + + // ── USER.md content ───────────────────────────────────────────────── + + describe("USER.md generation", () => { + it("writes USER.md with web dashboard info", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", new Set([])); + + const userMd = findUserMd(capture); + expect(userMd).not.toBeNull(); + expect(userMd).toContain("web dashboard"); + expect(userMd).toContain("18791"); + }); + + it("includes Telegram section when Telegram is enabled", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "test-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Messaging Channels"); + expect(userMd).toContain("Telegram"); + expect(userMd).toContain("openclaw config get channels.telegram.botToken"); + }); + + it("includes WhatsApp section when WhatsApp is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "whatsapp", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Messaging Channels"); + expect(userMd).toContain("WhatsApp"); + expect(userMd).toContain("QR code scanning"); + expect(userMd).toContain("http://localhost:18791"); + }); + + it("includes both Telegram and WhatsApp when both are enabled", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "bot-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "whatsapp", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Telegram"); + expect(userMd).toContain("WhatsApp"); + }); + + it("omits messaging section when neither Telegram nor WhatsApp is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).not.toBeNull(); + expect(userMd).not.toContain("Messaging Channels"); + }); + + it("creates .openclaw/workspace directory before uploading USER.md", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", new Set([])); + + const mkdirCmd = capture.commands.find((c) => c.includes("mkdir -p ~/.openclaw/workspace")); + expect(mkdirCmd).toBeDefined(); + }); + }); + + // ── Tunnel config ─────────────────────────────────────────────────── + + describe("tunnel config", () => { + it("openclaw agent has tunnel config targeting port 18791", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + expect(agents.openclaw.tunnel).toBeDefined(); + expect(agents.openclaw.tunnel!.remotePort).toBe(18791); + }); + + it("browserUrl includes the token as a query parameter", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url = agents.openclaw.tunnel!.browserUrl!(54321); + expect(url).toMatch(/^http:\/\/localhost:54321\/\?token=[a-f0-9]{32}$/); + }); + + it("browserUrl uses the provided local port", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url1 = agents.openclaw.tunnel!.browserUrl!(8080); + const url2 = agents.openclaw.tunnel!.browserUrl!(9999); + + expect(url1).toContain("localhost:8080"); + expect(url2).toContain("localhost:9999"); + }); + + it("no other agents define tunnel config", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + for (const [name, agent] of Object.entries(agents)) { + if (name === "openclaw") { + continue; + } + expect(agent.tunnel).toBeUndefined(); + } + }); + }); +}); diff --git a/packages/cli/src/__tests__/orchestrate-messaging.test.ts b/packages/cli/src/__tests__/orchestrate-messaging.test.ts new file mode 100644 index 000000000..9ddc9bc48 --- /dev/null +++ b/packages/cli/src/__tests__/orchestrate-messaging.test.ts @@ -0,0 +1,335 @@ +/** + * orchestrate-messaging.test.ts — Tests for messaging channel flows + * (WhatsApp QR scan, enabledSteps-dependent behavior) and SSH tunnel + * + browser open sequencing in the orchestration pipeline. + * + * These tests complement orchestrate.test.ts by covering the enabledSteps + * branches that were previously untested. + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { asyncTryCatch, tryCatch } from "@openrouter/spawn-shared"; +import { isNumber } from "../shared/type-guards.js"; + +const mockGetOrPromptApiKey = mock(() => Promise.resolve("sk-or-v1-test-key")); +const mockTryTarballInstall = mock(() => Promise.resolve(false)); + +import type { AgentConfig } from "../shared/agents"; +import type { CloudOrchestrator, OrchestrationOptions } from "../shared/orchestrate"; + +import { runOrchestration } from "../shared/orchestrate"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function createMockCloud(overrides: Partial = {}): CloudOrchestrator { + const mockRunner = { + runServer: mock(() => Promise.resolve()), + uploadFile: mock(() => Promise.resolve()), + }; + return { + cloudName: "testcloud", + cloudLabel: "Test Cloud", + runner: mockRunner, + authenticate: mock(() => Promise.resolve()), + promptSize: mock(() => Promise.resolve()), + createServer: mock(() => + Promise.resolve({ + ip: "10.0.0.1", + user: "root", + server_name: "test-server-1", + cloud: "testcloud", + }), + ), + getServerName: mock(() => Promise.resolve("test-server-1")), + waitForReady: mock(() => Promise.resolve()), + interactiveSession: mock(() => Promise.resolve(0)), + ...overrides, + }; +} + +function createMockAgent(overrides: Partial = {}): AgentConfig { + return { + name: "TestAgent", + install: mock(() => Promise.resolve()), + envVars: mock((key: string) => [ + `OPENROUTER_API_KEY=${key}`, + ]), + launchCmd: mock(() => "test-agent --start"), + ...overrides, + }; +} + +const defaultOpts: OrchestrationOptions = { + tryTarball: mockTryTarballInstall, + getApiKey: mockGetOrPromptApiKey, +}; + +async function runOrchestrationSafe( + cloud: CloudOrchestrator, + agent: AgentConfig, + agentName: string, + opts: OrchestrationOptions = defaultOpts, +): Promise { + const r = await asyncTryCatch(async () => runOrchestration(cloud, agent, agentName, opts)); + if (!r.ok) { + if (r.error.message.startsWith("__EXIT_")) { + return; + } + throw r.error; + } +} + +// ── Test suite ──────────────────────────────────────────────────────────── + +describe("orchestration — messaging and tunnel", () => { + let exitSpy: ReturnType; + let capturedExitCode: number | undefined; + let stderrSpy: ReturnType; + let testDir: string; + let savedSpawnHome: string | undefined; + let savedEnabledSteps: string | undefined; + + beforeEach(() => { + capturedExitCode = undefined; + testDir = join(process.env.HOME ?? "", `.spawn-test-msg-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { + recursive: true, + }); + savedSpawnHome = process.env.SPAWN_HOME; + savedEnabledSteps = process.env.SPAWN_ENABLED_STEPS; + process.env.SPAWN_HOME = testDir; + process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; + delete process.env.SPAWN_ENABLED_STEPS; + delete process.env.SPAWN_BETA; + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + exitSpy = spyOn(process, "exit").mockImplementation((code) => { + capturedExitCode = isNumber(code) ? code : 0; + throw new Error(`__EXIT_${capturedExitCode}__`); + }); + mockGetOrPromptApiKey.mockClear(); + mockGetOrPromptApiKey.mockImplementation(() => Promise.resolve("sk-or-v1-test-key")); + mockTryTarballInstall.mockClear(); + mockTryTarballInstall.mockImplementation(() => Promise.resolve(false)); + }); + + afterEach(() => { + if (savedSpawnHome !== undefined) { + process.env.SPAWN_HOME = savedSpawnHome; + } else { + delete process.env.SPAWN_HOME; + } + if (savedEnabledSteps !== undefined) { + process.env.SPAWN_ENABLED_STEPS = savedEnabledSteps; + } else { + delete process.env.SPAWN_ENABLED_STEPS; + } + tryCatch(() => + rmSync(testDir, { + recursive: true, + force: true, + }), + ); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + // ── enabledSteps parsing ──────────────────────────────────────────── + + describe("SPAWN_ENABLED_STEPS parsing", () => { + it("passes enabledSteps from env to configure", async () => { + process.env.SPAWN_ENABLED_STEPS = "github,telegram,whatsapp"; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + const enabledSteps = callArgs[2]; + expect(enabledSteps).toBeInstanceOf(Set); + expect(enabledSteps.has("github")).toBe(true); + expect(enabledSteps.has("telegram")).toBe(true); + expect(enabledSteps.has("whatsapp")).toBe(true); + }); + + it("passes undefined enabledSteps when env var is not set", async () => { + delete process.env.SPAWN_ENABLED_STEPS; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + expect(callArgs[2]).toBeUndefined(); + }); + + it("handles empty SPAWN_ENABLED_STEPS as empty set", async () => { + process.env.SPAWN_ENABLED_STEPS = ""; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + const enabledSteps = callArgs[2]; + expect(enabledSteps).toBeInstanceOf(Set); + expect(enabledSteps.size).toBe(0); + }); + }); + + // ── WhatsApp QR scan flow ─────────────────────────────────────────── + + describe("WhatsApp interactive session", () => { + it("runs WhatsApp QR scan session when whatsapp is in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "whatsapp"; + let whatsappSessionRun = false; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + if (cmd.includes("openclaw channels login")) { + whatsappSessionRun = true; + } + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + expect(whatsappSessionRun).toBe(true); + // The WhatsApp command should include the channel flag + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toContain("--channel whatsapp"); + }); + + it("does not run WhatsApp session when whatsapp is not in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "github,browser"; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toBeUndefined(); + }); + + it("does not run WhatsApp session when SPAWN_ENABLED_STEPS is not set", async () => { + delete process.env.SPAWN_ENABLED_STEPS; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toBeUndefined(); + }); + + it("WhatsApp session runs before the main agent launch", async () => { + process.env.SPAWN_ENABLED_STEPS = "whatsapp"; + const callOrder: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + if (cmd.includes("openclaw channels login")) { + callOrder.push("whatsapp-qr"); + } else { + callOrder.push("agent-launch"); + } + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappIdx = callOrder.indexOf("whatsapp-qr"); + const launchIdx = callOrder.indexOf("agent-launch"); + expect(whatsappIdx).toBeGreaterThanOrEqual(0); + expect(launchIdx).toBeGreaterThanOrEqual(0); + expect(whatsappIdx).toBeLessThan(launchIdx); + }); + }); + + // ── GitHub auth gating ────────────────────────────────────────────── + + describe("GitHub auth gating", () => { + it("skips GitHub auth when github is not in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "browser"; + // Remove the skip env var to actually test the gating logic + delete process.env.SPAWN_SKIP_GITHUB_AUTH; + const cloud = createMockCloud(); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + // GitHub auth should have been skipped — no github-related commands + // The runner shouldn't have github-related calls beyond agent setup + // (This is a negative test — we're verifying the branch wasn't taken) + expect(cloud.interactiveSession).toHaveBeenCalled(); + // Restore for other tests + process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; + }); + }); + + // ── preLaunchMsg ──────────────────────────────────────────────────── + + describe("preLaunchMsg", () => { + it("outputs preLaunchMsg to stderr when defined", async () => { + stderrSpy.mockRestore(); + const stderrOutput: string[] = []; + stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + + const cloud = createMockCloud(); + const agent = createMockAgent({ + preLaunchMsg: "Your web dashboard will open automatically", + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const allOutput = stderrOutput.join(""); + expect(allOutput).toContain("Your web dashboard will open automatically"); + }); + + it("does not output preLaunchMsg when not defined", async () => { + stderrSpy.mockRestore(); + const stderrOutput: string[] = []; + stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + + const cloud = createMockCloud(); + const agent = createMockAgent(); // no preLaunchMsg + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const allOutput = stderrOutput.join(""); + expect(allOutput).not.toContain("Tip:"); + }); + }); +}); diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index f86c83fc6..6d8474b4b 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -335,7 +335,9 @@ async function setupOpenclawConfig( let telegramBotToken = ""; if (enabledSteps?.has("telegram")) { logStep("Setting up Telegram..."); - const botToken = await prompt("Telegram bot token (from @BotFather): "); + // Allow env var override for non-interactive / CI / testing scenarios + const envToken = process.env.SPAWN_TELEGRAM_BOT_TOKEN ?? ""; + const botToken = envToken || (await prompt("Telegram bot token (from @BotFather): ")); telegramBotToken = botToken.trim(); if (!telegramBotToken) { logInfo("No token entered — set up Telegram via the web dashboard after launch"); From cd9a4718b0b73db68b43b119083f99af502b5d63 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 17:25:50 -0700 Subject: [PATCH 6/9] fix: add hint to GitHub CLI setup option Co-Authored-By: Claude Opus 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/shared/agents.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8cb019127..bbab5149c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.17.1", + "version": "0.17.2", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index 7c05f900a..8c818100b 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -74,6 +74,7 @@ const COMMON_STEPS: OptionalStep[] = [ { value: "github", label: "GitHub CLI", + hint: "install gh + authenticate on the remote server", }, { value: "reuse-api-key", From e24948b4cf844b9607128c7e6dcd6bc58802ab65 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 17:27:17 -0700 Subject: [PATCH 7/9] fix: add keybinding hints to setup options prompt Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/interactive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/interactive.ts b/packages/cli/src/commands/interactive.ts index 45b8be8af..9b7ddbb08 100644 --- a/packages/cli/src/commands/interactive.ts +++ b/packages/cli/src/commands/interactive.ts @@ -170,7 +170,7 @@ async function promptSetupOptions(agentName: string): Promise | unde } const selected = await p.multiselect({ - message: "Setup options", + message: "Setup options (↑/↓ navigate, space select, enter confirm)", options: filteredSteps.map((s) => ({ value: s.value, label: s.label, From f9117e14d1a76579f2523c646d730db8887c4202 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 17:28:14 -0700 Subject: [PATCH 8/9] fix: use clearer wording for setup options keybinding hints Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/interactive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/interactive.ts b/packages/cli/src/commands/interactive.ts index 9b7ddbb08..0bf1a847a 100644 --- a/packages/cli/src/commands/interactive.ts +++ b/packages/cli/src/commands/interactive.ts @@ -170,7 +170,7 @@ async function promptSetupOptions(agentName: string): Promise | unde } const selected = await p.multiselect({ - message: "Setup options (↑/↓ navigate, space select, enter confirm)", + message: "Setup options (space to select, enter to confirm)", options: filteredSteps.map((s) => ({ value: s.value, label: s.label, From a6259ac28c67b724fd14db5408348c1fd84669f6 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 12 Mar 2026 17:29:22 -0700 Subject: [PATCH 9/9] fix: add arrow key navigation hint to setup options prompt Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/interactive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/interactive.ts b/packages/cli/src/commands/interactive.ts index 0bf1a847a..5dd192790 100644 --- a/packages/cli/src/commands/interactive.ts +++ b/packages/cli/src/commands/interactive.ts @@ -170,7 +170,7 @@ async function promptSetupOptions(agentName: string): Promise | unde } const selected = await p.multiselect({ - message: "Setup options (space to select, enter to confirm)", + message: "Setup options (↑/↓ navigate, space to select, enter to confirm)", options: filteredSteps.map((s) => ({ value: s.value, label: s.label,