feat: add --config and --steps CLI flags for programmatic setup (#2545)

* 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 <noreply@anthropic.com>

* feat: run WhatsApp QR scan interactively before TUI launch

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 <noreply@anthropic.com>

* fix: update WhatsApp hint to reflect pre-TUI QR scanning

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

* feat: add --config and --steps CLI flags for programmatic setup

Add --config <path> flag to load spawn options from a JSON config file
(model, steps, name, setup data like telegram_bot_token). Add --steps
<list> flag for comma-separated setup step control. Both enable the
web UI and headless automation to control which setup steps run.

Priority order: CLI flags > --config file > env vars > defaults.

- New spawn-config.ts module with valibot validation
- OptionalStep extended with dataEnvVar and interactive metadata
- validateStepNames() for step name validation with warnings
- Telegram setup reads TELEGRAM_BOT_TOKEN env var before prompting
- WhatsApp auto-skipped in headless mode with warning
- promptSetupOptions() skipped when SPAWN_ENABLED_STEPS already set
- E2E verify helpers for github, browser, telegram setup artifacts
- QA reference file documenting all agent setup options
- Version bump to 0.17.0

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

* feat: add --model flag and priority order tests

- Add --model <id> CLI flag that sets MODEL_ID env var
- --model is extracted before --config so it takes priority
- Add config-priority.test.ts with 8 tests verifying:
  - --model overrides config model
  - --steps overrides config steps
  - --steps "" disables all steps
  - --name overrides config name
  - Config tokens apply as defaults
  - Explicit env vars override config tokens
- Remove preferences.json from priority order docs (not needed)
- Add --model to help text and unknown-flag guidance

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

* docs: add --model, --config, --steps to README

Document config file format, setup steps table, and new CLI flags
in the commands table.

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

* fix: address security review feedback

- Move null byte check before path resolution (defense-in-depth)
- Move agent-setup-options.md from .claude/rules/ to .docs/ (git-ignored)
  per documentation policy

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

* fix: resolve rebase conflicts and deduplicate --model flag extraction

Rebase on main introduced a duplicate --model flag extraction block
(one from the PR at line 804, one from main at line 941). Consolidated
into the single early extraction point with -m shorthand support.
Also removed duplicate --model entry from KNOWN_FLAGS set.

Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-03-12 17:32:58 -07:00 committed by GitHub
parent ff8bff4c02
commit f683dd857b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 795 additions and 35 deletions

View file

@ -50,6 +50,9 @@ spawn delete -c hetzner # Delete a server on Hetzner
| `spawn <agent> <cloud> --prompt-file f.txt` | Prompt from file |
| `spawn <agent> <cloud> --headless` | Provision and exit (no interactive session) |
| `spawn <agent> <cloud> --output json` | Headless mode with structured JSON on stdout |
| `spawn <agent> <cloud> --model <id>` | Set the model ID (overrides agent default) |
| `spawn <agent> <cloud> --config <file>` | Load options from a JSON config file |
| `spawn <agent> <cloud> --steps <list>` | Comma-separated setup steps to enable |
| `spawn <agent> <cloud> --custom` | Show interactive size/region pickers |
| `spawn <agent>` | Show available clouds for an agent |
| `spawn <cloud>` | Show available agents for a cloud |
@ -74,6 +77,45 @@ spawn delete -c hetzner # Delete a server on Hetzner
| `spawn version` | Show version |
| `spawn <agent> <cloud> --beta <feature>` | Opt-in to an experimental feature (repeatable) |
#### Config File
The `--config` flag loads options from a JSON file. CLI flags override config values.
```json
{
"model": "openai/gpt-5.3-codex",
"steps": ["github", "browser", "telegram"],
"name": "my-dev-box",
"setup": {
"telegram_bot_token": "123456:ABC-DEF...",
"github_token": "ghp_xxxx"
}
}
```
```bash
spawn codex gcp --config setup.json --headless --output json
```
#### Setup Steps
Control which optional setup steps run with `--steps`:
```bash
spawn openclaw gcp --steps github,browser # Only GitHub + Chrome
spawn claude gcp --steps "" # Skip all optional steps
```
Available steps vary by agent:
| Step | Agents | Description |
|------|--------|-------------|
| `github` | All | GitHub CLI + git identity |
| `reuse-api-key` | All | Reuse saved OpenRouter key |
| `browser` | openclaw | Chrome browser (~400 MB) |
| `telegram` | openclaw | Telegram bot (set `TELEGRAM_BOT_TOKEN` for non-interactive) |
| `whatsapp` | openclaw | WhatsApp linking (interactive QR scan, skipped in headless) |
#### Beta Features
Experimental features can be enabled with `--beta <feature>`. The flag is repeatable:

View file

@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tryCatch } from "../shared/result";
import { loadSpawnConfig } from "../shared/spawn-config";
/**
* Tests the priority order: CLI flags > --config > env vars > defaults.
*
* These tests simulate the logic in index.ts where:
* 1. --model sets MODEL_ID env var
* 2. --config loads a file and applies values only if env var is NOT already set
* 3. --steps unconditionally overwrites SPAWN_ENABLED_STEPS
*/
describe("Config priority order", () => {
const testDir = join(process.env.HOME ?? "/tmp", ".spawn-priority-test");
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
mkdirSync(testDir, {
recursive: true,
});
savedEnv = {
MODEL_ID: process.env.MODEL_ID,
SPAWN_ENABLED_STEPS: process.env.SPAWN_ENABLED_STEPS,
SPAWN_NAME: process.env.SPAWN_NAME,
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
};
// Clear all relevant env vars
delete process.env.MODEL_ID;
delete process.env.SPAWN_ENABLED_STEPS;
delete process.env.SPAWN_NAME;
delete process.env.TELEGRAM_BOT_TOKEN;
delete process.env.GITHUB_TOKEN;
});
afterEach(() => {
// Restore original env
for (const [key, val] of Object.entries(savedEnv)) {
if (val === undefined) {
delete process.env[key];
} else {
process.env[key] = val;
}
}
tryCatch(() =>
rmSync(testDir, {
recursive: true,
force: true,
}),
);
});
function writeConfig(filename: string, data: Record<string, unknown>): string {
const p = join(testDir, filename);
writeFileSync(p, JSON.stringify(data));
return p;
}
/** Simulate the config-application logic from index.ts */
function applyConfigAsDefaults(config: NonNullable<ReturnType<typeof loadSpawnConfig>>): void {
if (config.model && !process.env.MODEL_ID) {
process.env.MODEL_ID = config.model;
}
if (config.steps && !process.env.SPAWN_ENABLED_STEPS) {
process.env.SPAWN_ENABLED_STEPS = config.steps.join(",");
}
if (config.name && !process.env.SPAWN_NAME) {
process.env.SPAWN_NAME = config.name;
}
if (config.setup?.telegram_bot_token && !process.env.TELEGRAM_BOT_TOKEN) {
process.env.TELEGRAM_BOT_TOKEN = config.setup.telegram_bot_token;
}
if (config.setup?.github_token && !process.env.GITHUB_TOKEN) {
process.env.GITHUB_TOKEN = config.setup.github_token;
}
}
it("--model flag should override config file model", () => {
// Simulate: --model sets MODEL_ID before config is loaded
process.env.MODEL_ID = "openai/gpt-5.3-codex";
const configPath = writeConfig("model-override.json", {
model: "anthropic/claude-4-sonnet",
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
// CLI flag wins
expect(process.env.MODEL_ID).toBe("openai/gpt-5.3-codex");
});
it("config file model should apply when no --model flag", () => {
const configPath = writeConfig("model-default.json", {
model: "anthropic/claude-4-sonnet",
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
expect(process.env.MODEL_ID).toBe("anthropic/claude-4-sonnet");
});
it("--steps flag should override config file steps", () => {
const configPath = writeConfig("steps-override.json", {
steps: [
"browser",
"telegram",
],
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
// Config sets SPAWN_ENABLED_STEPS
expect(process.env.SPAWN_ENABLED_STEPS).toBe("browser,telegram");
// Then --steps flag overwrites it (simulates index.ts line 850-852)
const stepsFlag = "github";
process.env.SPAWN_ENABLED_STEPS = stepsFlag;
expect(process.env.SPAWN_ENABLED_STEPS).toBe("github");
});
it("--steps '' should disable all steps even when config has steps", () => {
const configPath = writeConfig("steps-empty.json", {
steps: [
"browser",
"telegram",
],
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
// --steps "" overwrites
process.env.SPAWN_ENABLED_STEPS = "";
expect(process.env.SPAWN_ENABLED_STEPS).toBe("");
});
it("--name flag should override config file name", () => {
process.env.SPAWN_NAME = "cli-name";
const configPath = writeConfig("name-override.json", {
name: "config-name",
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
expect(process.env.SPAWN_NAME).toBe("cli-name");
});
it("config setup tokens should apply as defaults", () => {
const configPath = writeConfig("setup-tokens.json", {
setup: {
telegram_bot_token: "config-token",
github_token: "ghp_config",
},
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
expect(process.env.TELEGRAM_BOT_TOKEN).toBe("config-token");
expect(process.env.GITHUB_TOKEN).toBe("ghp_config");
});
it("explicit env vars should override config setup tokens", () => {
process.env.TELEGRAM_BOT_TOKEN = "env-token";
process.env.GITHUB_TOKEN = "ghp_env";
const configPath = writeConfig("setup-override.json", {
setup: {
telegram_bot_token: "config-token",
github_token: "ghp_config",
},
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
expect(process.env.TELEGRAM_BOT_TOKEN).toBe("env-token");
expect(process.env.GITHUB_TOKEN).toBe("ghp_env");
});
it("all config fields should apply when nothing is pre-set", () => {
const configPath = writeConfig("full.json", {
model: "openai/o3",
steps: [
"github",
"browser",
],
name: "full-box",
setup: {
telegram_bot_token: "tok123",
github_token: "ghp_full",
},
});
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
applyConfigAsDefaults(config!);
expect(process.env.MODEL_ID).toBe("openai/o3");
expect(process.env.SPAWN_ENABLED_STEPS).toBe("github,browser");
expect(process.env.SPAWN_NAME).toBe("full-box");
expect(process.env.TELEGRAM_BOT_TOKEN).toBe("tok123");
expect(process.env.GITHUB_TOKEN).toBe("ghp_full");
});
});

View file

@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tryCatch } from "../shared/result";
import { loadSpawnConfig } from "../shared/spawn-config";
describe("loadSpawnConfig", () => {
const testDir = join(process.env.HOME ?? "/tmp", ".spawn-config-test");
beforeEach(() => {
mkdirSync(testDir, {
recursive: true,
});
});
afterEach(() => {
tryCatch(() =>
rmSync(testDir, {
recursive: true,
force: true,
}),
);
});
it("should load a valid config file", () => {
const configPath = join(testDir, "valid.json");
writeFileSync(
configPath,
JSON.stringify({
model: "openai/gpt-5.3-codex",
steps: [
"github",
"browser",
],
name: "my-box",
setup: {
telegram_bot_token: "123:ABC",
github_token: "ghp_test",
},
}),
);
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
expect(config?.model).toBe("openai/gpt-5.3-codex");
expect(config?.steps).toEqual([
"github",
"browser",
]);
expect(config?.name).toBe("my-box");
expect(config?.setup?.telegram_bot_token).toBe("123:ABC");
expect(config?.setup?.github_token).toBe("ghp_test");
});
it("should load a minimal config file", () => {
const configPath = join(testDir, "minimal.json");
writeFileSync(
configPath,
JSON.stringify({
model: "openai/gpt-4o",
}),
);
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
expect(config?.model).toBe("openai/gpt-4o");
expect(config?.steps).toBeUndefined();
expect(config?.name).toBeUndefined();
expect(config?.setup).toBeUndefined();
});
it("should load an empty config file", () => {
const configPath = join(testDir, "empty.json");
writeFileSync(configPath, "{}");
const config = loadSpawnConfig(configPath);
expect(config).not.toBeNull();
});
it("should return null for malformed JSON", () => {
const configPath = join(testDir, "bad.json");
writeFileSync(configPath, "not json {{{");
const config = loadSpawnConfig(configPath);
expect(config).toBeNull();
});
it("should throw for missing file", () => {
expect(() => loadSpawnConfig(join(testDir, "nonexistent.json"))).toThrow();
});
it("should throw for file that is too large", () => {
const configPath = join(testDir, "huge.json");
// Write a file larger than 1 MB
writeFileSync(configPath, "x".repeat(1024 * 1024 + 1));
expect(() => loadSpawnConfig(configPath)).toThrow(/too large/);
});
it("should throw for null bytes in path", () => {
expect(() => loadSpawnConfig("config\0.json")).toThrow(/null bytes/);
});
});

View file

@ -0,0 +1,111 @@
import { describe, expect, it } from "bun:test";
import { findUnknownFlag, KNOWN_FLAGS } from "../flags";
import { getAgentOptionalSteps, validateStepNames } from "../shared/agents";
describe("--steps and --config flags", () => {
it("should recognize --steps as a known flag", () => {
expect(KNOWN_FLAGS.has("--steps")).toBe(true);
expect(
findUnknownFlag([
"--steps",
]),
).toBeNull();
});
it("should recognize --config as a known flag", () => {
expect(KNOWN_FLAGS.has("--config")).toBe(true);
expect(
findUnknownFlag([
"--config",
]),
).toBeNull();
});
});
describe("validateStepNames", () => {
it("should validate known steps for claude", () => {
const { valid, invalid } = validateStepNames("claude", [
"github",
"reuse-api-key",
]);
expect(valid).toEqual([
"github",
"reuse-api-key",
]);
expect(invalid).toEqual([]);
});
it("should validate known steps for openclaw", () => {
const { valid, invalid } = validateStepNames("openclaw", [
"github",
"browser",
"telegram",
"whatsapp",
]);
expect(valid).toEqual([
"github",
"browser",
"telegram",
"whatsapp",
]);
expect(invalid).toEqual([]);
});
it("should separate invalid step names", () => {
const { valid, invalid } = validateStepNames("claude", [
"github",
"nonexistent",
"bogus",
]);
expect(valid).toEqual([
"github",
]);
expect(invalid).toEqual([
"nonexistent",
"bogus",
]);
});
it("should return all invalid for unknown agent with no extra steps", () => {
// unknown agent still has COMMON_STEPS (github, reuse-api-key)
const { valid, invalid } = validateStepNames("unknown-agent", [
"browser",
"telegram",
]);
expect(valid).toEqual([]);
expect(invalid).toEqual([
"browser",
"telegram",
]);
});
it("should handle empty steps array", () => {
const { valid, invalid } = validateStepNames("claude", []);
expect(valid).toEqual([]);
expect(invalid).toEqual([]);
});
});
describe("OptionalStep metadata", () => {
it("openclaw telegram step should have dataEnvVar", () => {
const steps = getAgentOptionalSteps("openclaw");
const telegram = steps.find((s) => s.value === "telegram");
expect(telegram).toBeDefined();
expect(telegram?.dataEnvVar).toBe("TELEGRAM_BOT_TOKEN");
});
it("openclaw whatsapp step should be marked interactive", () => {
const steps = getAgentOptionalSteps("openclaw");
const whatsapp = steps.find((s) => s.value === "whatsapp");
expect(whatsapp).toBeDefined();
expect(whatsapp?.interactive).toBe(true);
});
it("common steps should not have dataEnvVar or interactive", () => {
const steps = getAgentOptionalSteps("claude");
for (const step of steps) {
expect(step.dataEnvVar).toBeUndefined();
expect(step.interactive).toBeUndefined();
}
});
});

View file

@ -17,6 +17,11 @@ function getHelpUsageSection(): string {
Execute agent with prompt (non-interactive)
spawn <agent> <cloud> --prompt-file <file> (or -f)
Execute agent with prompt from file
spawn <agent> <cloud> --model <id> Set the model ID (overrides config/default)
spawn <agent> <cloud> --config <file>
Load all options from a JSON config file
spawn <agent> <cloud> --steps <list>
Comma-separated setup steps to enable
spawn <agent> Interactive cloud picker for agent
spawn <cloud> Show available agents for cloud
spawn list Browse and rerun previous spawns (aliases: ls, history)
@ -59,6 +64,10 @@ function getHelpExamplesSection(): string {
spawn opencode gcp --dry-run ${pc.dim("# Preview without provisioning")}
spawn claude hetzner --headless ${pc.dim("# Provision, print connection info, exit")}
spawn claude hetzner --output json ${pc.dim("# Structured JSON output on stdout")}
spawn codex gcp --config setup.json --headless --output json
${pc.dim("# Config file with headless JSON output")}
spawn openclaw gcp --steps github,browser --headless
${pc.dim("# Only run specific setup steps")}
spawn claude ${pc.dim("# Show which clouds support Claude")}
spawn hetzner ${pc.dim("# Show which agents run on Hetzner")}
spawn list ${pc.dim("# Browse history and pick one to rerun")}
@ -103,6 +112,8 @@ function getHelpEnvVarsSection(): string {
${pc.cyan("SPAWN_UNICODE=1")} Force Unicode output (override auto-detection)
${pc.cyan("SPAWN_HOME")} Override spawn data directory (default: ~/.spawn)
${pc.cyan("SPAWN_DEBUG=1")} Show debug output (unicode detection, etc.)
${pc.cyan("SPAWN_ENABLED_STEPS")} Comma-separated setup steps (set by --steps/--config)
${pc.cyan("TELEGRAM_BOT_TOKEN")} Telegram bot token for non-interactive setup
${pc.cyan("SPAWN_HEADLESS=1")} Set automatically in --headless mode (for scripts)
${pc.cyan("SPAWN_CUSTOM=1")} Set automatically in --custom mode (show size/region pickers)`;
}

View file

@ -227,11 +227,14 @@ export async function cmdInteractive(): Promise<void> {
await preflightCredentialCheck(manifest, cloudChoice);
const enabledSteps = await promptSetupOptions(agentChoice);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
// Skip setup prompt if steps already set via --steps or --config
if (!process.env.SPAWN_ENABLED_STEPS) {
const enabledSteps = await promptSetupOptions(agentChoice);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
}
}
const spawnName = await promptSpawnName();
@ -280,11 +283,14 @@ 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(",");
// Skip setup prompt if steps already set via --steps or --config
if (!process.env.SPAWN_ENABLED_STEPS) {
const enabledSteps = await promptSetupOptions(resolvedAgent);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
}
}
const spawnName = await promptSpawnName();

View file

@ -935,11 +935,14 @@ export async function cmdRun(
await preflightCredentialCheck(manifest, cloud);
const enabledSteps = await promptSetupOptions(agent);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
// Skip setup prompt if steps already set via --steps or --config
if (!process.env.SPAWN_ENABLED_STEPS) {
const enabledSteps = await promptSetupOptions(agent);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
}
}
const spawnName = await promptSpawnName();

View file

@ -33,6 +33,8 @@ export const KNOWN_FLAGS = new Set([
"--beta",
"--model",
"-m",
"--config",
"--steps",
]);
/** Return the first unknown flag in args, or null if all are known/positional */

View file

@ -118,6 +118,9 @@ function checkUnknownFlags(args: string[]): void {
console.error(` ${pc.cyan("--model, -m")} Set the LLM model (e.g. openai/gpt-5.3-codex)`);
console.error(` ${pc.cyan("--name")} Set the spawn/resource name`);
console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`);
console.error(` ${pc.cyan("--model <id>")} Set the model ID (e.g. openai/gpt-5.3-codex)`);
console.error(` ${pc.cyan("--config <path>")} Load config from JSON file`);
console.error(` ${pc.cyan("--steps <list>")} Comma-separated setup steps to enable`);
console.error(` ${pc.cyan("--beta tarball")} Use pre-built tarball for agent install (repeatable)`);
console.error(` ${pc.cyan("--help, -h")} Show help information`);
console.error(` ${pc.cyan("--version, -v")} Show version`);
@ -797,6 +800,75 @@ async function main(): Promise<void> {
process.env.SPAWN_BETA = betaFeatures.join(",");
}
// Extract --model / -m <value> flag → MODEL_ID env var (must be before --config so it takes priority)
const [modelFlag, modelFilteredArgs] = extractFlagValue(
filteredArgs,
[
"--model",
"-m",
],
"model ID",
'spawn <agent> <cloud> --model "openai/gpt-5.3-codex"',
);
filteredArgs.splice(0, filteredArgs.length, ...modelFilteredArgs);
if (modelFlag) {
process.env.MODEL_ID = modelFlag;
}
// Extract --config <path> flag — load config file and apply as defaults
const [configPath, configFilteredArgs] = extractFlagValue(
filteredArgs,
[
"--config",
],
"config file",
"spawn <agent> <cloud> --config setup.json",
);
filteredArgs.splice(0, filteredArgs.length, ...configFilteredArgs);
if (configPath) {
const { loadSpawnConfig } = await import("./shared/spawn-config.js");
const configResult = tryCatch(() => loadSpawnConfig(configPath));
if (!configResult.ok) {
console.error(pc.red(`Error loading config file: ${getErrorMessage(configResult.error)}`));
process.exit(1);
}
const config = configResult.data;
if (config) {
// Apply config values as defaults (explicit flags take priority)
if (config.model && !process.env.MODEL_ID) {
process.env.MODEL_ID = config.model;
}
if (config.steps && !process.env.SPAWN_ENABLED_STEPS) {
process.env.SPAWN_ENABLED_STEPS = config.steps.join(",");
}
if (config.name && !process.env.SPAWN_NAME) {
process.env.SPAWN_NAME = config.name;
}
if (config.setup?.telegram_bot_token && !process.env.TELEGRAM_BOT_TOKEN) {
process.env.TELEGRAM_BOT_TOKEN = config.setup.telegram_bot_token;
}
if (config.setup?.github_token && !process.env.GITHUB_TOKEN) {
process.env.GITHUB_TOKEN = config.setup.github_token;
}
}
}
// Extract --steps <value> flag — comma-separated list of setup steps
const [stepsFlag, stepsFilteredArgs] = extractFlagValue(
filteredArgs,
[
"--steps",
],
"setup steps",
"spawn <agent> <cloud> --steps github,browser,telegram",
);
filteredArgs.splice(0, filteredArgs.length, ...stepsFilteredArgs);
if (stepsFlag !== undefined) {
// --steps "" means disable all optional steps
process.env.SPAWN_ENABLED_STEPS = stepsFlag;
}
// Extract --output <format> flag
const [outputFormat, outputFilteredArgs] = extractFlagValue(
filteredArgs,
@ -866,21 +938,6 @@ async function main(): Promise<void> {
process.env.LIGHTSAIL_BUNDLE = sizeFlag;
}
// Extract --model / -m <model_id> flag (overrides the agent's default model)
const [modelFlag, modelFilteredArgs] = extractFlagValue(
filteredArgs,
[
"--model",
"-m",
],
"model ID",
"spawn codex gcp --model openai/gpt-5.3-codex",
);
filteredArgs.splice(0, filteredArgs.length, ...modelFilteredArgs);
if (modelFlag) {
process.env.MODEL_ID = modelFlag;
}
// --output implies --headless
const effectiveHeadless = headless || !!outputFormat;

View file

@ -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, shellQuote, withRetry } from "./ui";
import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, prompt, shellQuote, withRetry } from "./ui";
/**
* Wrap an SSH-based async operation into a Result for use with withRetry.
@ -364,8 +364,53 @@ async function setupOpenclawConfig(
logWarn("Browser config setup failed (non-fatal)");
}
// Telegram channel setup — check env var first, then prompt interactively
if (enabledSteps?.has("telegram")) {
logStep("Setting up Telegram...");
const envToken = process.env.TELEGRAM_BOT_TOKEN;
const trimmedToken = envToken?.trim() || (await prompt("Telegram bot token (from @BotFather): ")).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 — 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.
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",
"",
@ -378,6 +423,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");
@ -636,7 +682,7 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
configure: (apiKey: string, modelId?: string, enabledSteps?: Set<string>) =>
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: {

View file

@ -12,6 +12,10 @@ export interface OptionalStep {
value: string;
label: string;
hint?: string;
/** Env var that supplies data for this step (e.g. TELEGRAM_BOT_TOKEN). */
dataEnvVar?: string;
/** When true, step requires interactive input (e.g. QR scan) — skipped in headless. */
interactive?: boolean;
}
export interface AgentConfig {
@ -56,6 +60,18 @@ const AGENT_EXTRA_STEPS: Record<string, OptionalStep[]> = {
label: "Chrome browser",
hint: "~400 MB — enables web tools",
},
{
value: "telegram",
label: "Telegram",
hint: "connect via bot token from @BotFather",
dataEnvVar: "TELEGRAM_BOT_TOKEN",
},
{
value: "whatsapp",
label: "WhatsApp",
hint: "scan QR code during setup",
interactive: true,
},
],
};
@ -83,6 +99,31 @@ export function getAgentOptionalSteps(agentName: string): OptionalStep[] {
: COMMON_STEPS;
}
/** Validate step names against the known steps for an agent.
* Returns valid and invalid step names separately. */
export function validateStepNames(
agentName: string,
steps: string[],
): {
valid: string[];
invalid: string[];
} {
const known = new Set(getAgentOptionalSteps(agentName).map((s) => s.value));
const valid: string[] = [];
const invalid: string[] = [];
for (const step of steps) {
if (known.has(step)) {
valid.push(step);
} else {
invalid.push(step);
}
}
return {
valid,
invalid,
};
}
// ─── Shared Helpers ──────────────────────────────────────────────────────────
/**

View file

@ -212,11 +212,28 @@ export async function runOrchestration(
logWarn("Environment setup had errors");
}
// 10. Parse enabled setup steps from env (set by interactive/run prompts)
// 10. Parse enabled setup steps from env (set by --steps, --config, or interactive prompts)
let enabledSteps: Set<string> | undefined;
const stepsEnv = process.env.SPAWN_ENABLED_STEPS;
if (stepsEnv !== undefined) {
enabledSteps = new Set(stepsEnv.split(",").filter(Boolean));
const stepNames = stepsEnv.split(",").filter(Boolean);
// Validate step names and warn about unknowns
if (stepNames.length > 0) {
const { validateStepNames } = await import("./agents.js");
const { valid, invalid } = validateStepNames(agentName, stepNames);
if (invalid.length > 0) {
logWarn(`Unknown setup steps ignored: ${invalid.join(", ")}`);
}
enabledSteps = new Set(valid);
} else {
// --steps "" → disable all optional steps
enabledSteps = new Set();
}
// Skip interactive WhatsApp in headless mode
if (process.env.SPAWN_HEADLESS === "1" && enabledSteps.has("whatsapp")) {
logWarn("WhatsApp requires interactive QR scanning — skipping in headless mode");
enabledSteps.delete("whatsapp");
}
}
// 10b. Agent-specific configuration
@ -274,7 +291,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}`);

View file

@ -0,0 +1,56 @@
// shared/spawn-config.ts — Load and validate --config JSON files
import { readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import * as v from "valibot";
import { parseJsonWith } from "./parse.js";
import { logWarn } from "./ui.js";
const SpawnConfigSetupSchema = v.object({
telegram_bot_token: v.optional(v.string()),
github_token: v.optional(v.string()),
});
const SpawnConfigSchema = v.object({
model: v.optional(v.string()),
steps: v.optional(v.array(v.string())),
name: v.optional(v.string()),
setup: v.optional(SpawnConfigSetupSchema),
});
export type SpawnConfig = v.InferOutput<typeof SpawnConfigSchema>;
/** Maximum config file size (1 MB) */
const MAX_CONFIG_SIZE = 1024 * 1024;
/**
* Load and validate a spawn config file.
* Returns null on parse failure (with warning to stderr).
* Throws on missing file or security violations.
*/
export function loadSpawnConfig(filePath: string): SpawnConfig | null {
// Security: reject null bytes before any filesystem operations
if (filePath.includes("\0")) {
throw new Error("Config file path contains null bytes");
}
const resolved = resolve(filePath);
const stats = statSync(resolved);
if (!stats.isFile()) {
throw new Error(`Config path is not a file: ${resolved}`);
}
if (stats.size > MAX_CONFIG_SIZE) {
throw new Error(`Config file too large (${stats.size} bytes, max ${MAX_CONFIG_SIZE})`);
}
const content = readFileSync(resolved, "utf-8");
const parsed = parseJsonWith(content, SpawnConfigSchema);
if (!parsed) {
logWarn(`Invalid config file: ${resolved} — ignoring`);
return null;
}
return parsed;
}

View file

@ -625,6 +625,46 @@ verify_junie() {
return "${failures}"
}
# ---------------------------------------------------------------------------
# Setup step verification helpers
# ---------------------------------------------------------------------------
verify_setup_github() {
local app="$1"
log_step "Checking GitHub CLI setup..."
if cloud_exec "${app}" "PATH=\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH command -v gh && gh auth status" >/dev/null 2>&1; then
log_ok "GitHub CLI installed and authenticated"
return 0
else
log_warn "GitHub CLI not authenticated (non-fatal)"
return 0
fi
}
verify_setup_browser() {
local app="$1"
log_step "Checking Chrome browser..."
if cloud_exec "${app}" "command -v google-chrome-stable >/dev/null 2>&1 || command -v google-chrome >/dev/null 2>&1" >/dev/null 2>&1; then
log_ok "Chrome browser installed"
return 0
else
log_err "Chrome browser not found"
return 1
fi
}
verify_setup_telegram() {
local app="$1"
log_step "Checking openclaw Telegram config..."
if cloud_exec "${app}" "PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH openclaw config get channels.telegram.botToken 2>/dev/null | grep -v '^$'" >/dev/null 2>&1; then
log_ok "Telegram bot token configured"
return 0
else
log_warn "Telegram bot token not configured (non-fatal)"
return 0
fi
}
# ---------------------------------------------------------------------------
# verify_agent AGENT APP_NAME
#