mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
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:
parent
ff8bff4c02
commit
f683dd857b
14 changed files with 795 additions and 35 deletions
42
README.md
42
README.md
|
|
@ -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:
|
||||
|
|
|
|||
213
packages/cli/src/__tests__/config-priority.test.ts
Normal file
213
packages/cli/src/__tests__/config-priority.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
102
packages/cli/src/__tests__/spawn-config.test.ts
Normal file
102
packages/cli/src/__tests__/spawn-config.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
111
packages/cli/src/__tests__/steps-flag.test.ts
Normal file
111
packages/cli/src/__tests__/steps-flag.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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)`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
56
packages/cli/src/shared/spawn-config.ts
Normal file
56
packages/cli/src/shared/spawn-config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue