/** * `ao setup openclaw` — interactive wizard + non-interactive mode * for wiring AO notifications to an OpenClaw gateway. * * Interactive: ao setup openclaw (human in terminal) * Programmatic: ao setup openclaw --url X --token Y --non-interactive * (OpenClaw agent calling via exec tool) */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { parse as yamlParse, parseDocument } from "yaml"; import { findConfigFile } from "@composio/ao-core"; import { probeGateway, validateToken, detectOpenClawInstallation, DEFAULT_OPENCLAW_URL, HOOKS_PATH, } from "../lib/openclaw-probe.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export class SetupAbortedError extends Error { constructor( message: string, public readonly exitCode: number = 1, ) { super(message); this.name = "SetupAbortedError"; } } interface SetupOptions { url?: string; token?: string; nonInteractive?: boolean; routingPreset?: OpenClawRoutingPreset; } type OpenClawRoutingPreset = "urgent-only" | "urgent-action" | "all"; interface ResolvedConfig { url: string; token: string; routingPreset: OpenClawRoutingPreset; } const OPENCLAW_ROUTING_PRESETS = { "urgent-only": ["urgent"], "urgent-action": ["urgent", "action"], all: ["urgent", "action", "warning", "info"], } as const; const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const; function isRoutingPreset(value: string | undefined): value is OpenClawRoutingPreset { return value === "urgent-only" || value === "urgent-action" || value === "all"; } function normalizeOpenClawHooksUrl(url: string): string { const normalized = url.trim().replace(/\/+$/, ""); return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`; } // --------------------------------------------------------------------------- // Interactive prompts (dynamic import to keep @clack/prompts optional) // --------------------------------------------------------------------------- async function interactiveSetup(existingUrl?: string): Promise { const clack = await import("@clack/prompts"); clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw "))); // --- Step 1: Gateway URL --------------------------------------------------- const defaultUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`; let detectedUrl: string | undefined; const spin = clack.spinner(); spin.start("Detecting OpenClaw gateway on localhost..."); const probe = await probeGateway(DEFAULT_OPENCLAW_URL); if (probe.reachable) { detectedUrl = defaultUrl; spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`); } else { spin.stop("No OpenClaw gateway detected on localhost"); } const urlInput = await clack.text({ message: "OpenClaw webhook URL:", placeholder: defaultUrl, initialValue: existingUrl ?? detectedUrl ?? defaultUrl, validate: (v) => { if (!v) return "URL is required"; if (!v.startsWith("http://") && !v.startsWith("https://")) return "Must start with http:// or https://"; }, }); if (clack.isCancel(urlInput)) { clack.cancel("Setup cancelled."); throw new SetupAbortedError("Setup cancelled.", 0); } // Normalize: ensure URL ends with /hooks/agent const url = normalizeOpenClawHooksUrl(urlInput as string); // --- Step 2: Token --------------------------------------------------------- const envToken = process.env["OPENCLAW_HOOKS_TOKEN"]; let tokenValue: string; if (envToken) { const useEnv = await clack.confirm({ message: `Found OPENCLAW_HOOKS_TOKEN in environment. Use it?`, initialValue: true, }); if (clack.isCancel(useEnv)) { clack.cancel("Setup cancelled."); throw new SetupAbortedError("Setup cancelled.", 0); } if (useEnv) { tokenValue = envToken; } else { const input = await clack.password({ message: "Enter your OpenClaw hooks token:", validate: (v) => (!v ? "Token is required" : undefined), }); if (clack.isCancel(input)) { clack.cancel("Setup cancelled."); throw new SetupAbortedError("Setup cancelled.", 0); } tokenValue = input as string; } } else { const generatedToken = randomBytes(32).toString("base64url"); const tokenChoice = await clack.select({ message: "How would you like to set the hooks token?", options: [ { value: "generate", label: "Auto-generate a secure token (recommended)" }, { value: "manual", label: "Enter an existing token manually" }, ], }); if (clack.isCancel(tokenChoice)) { clack.cancel("Setup cancelled."); throw new SetupAbortedError("Setup cancelled.", 0); } if (tokenChoice === "manual") { const input = await clack.password({ message: "Enter your OpenClaw hooks token:", validate: (v) => (!v ? "Token is required" : undefined), }); if (clack.isCancel(input)) { clack.cancel("Setup cancelled."); throw new SetupAbortedError("Setup cancelled.", 0); } tokenValue = input as string; } else { tokenValue = generatedToken; clack.log.success(`Generated token: ${chalk.dim(tokenValue.slice(0, 8))}...`); } } // --- Step 3: Validate ------------------------------------------------------ spin.start("Validating token against gateway..."); const validation = await validateToken(url, tokenValue); if (validation.valid) { spin.stop("Token validated — connection works!"); } else { spin.stop(`Validation failed: ${validation.error}`); const cont = await clack.confirm({ message: "Save config anyway? (you can fix the token later)", initialValue: false, }); if (clack.isCancel(cont) || !cont) { clack.cancel("Setup cancelled. Fix the issue and retry."); throw new SetupAbortedError("Setup cancelled. Fix the issue and retry."); } } const routingPreset = await clack.select({ message: "Which notifications should OpenClaw receive?", initialValue: "urgent-action", options: [ { value: "urgent-action", label: "Urgent + action (recommended)" }, { value: "urgent-only", label: "Urgent only" }, { value: "all", label: "All priorities" }, ], }); if (clack.isCancel(routingPreset)) { clack.cancel("Setup cancelled."); throw new SetupAbortedError("Setup cancelled.", 0); } return { url, token: tokenValue, routingPreset: routingPreset as OpenClawRoutingPreset, }; } // --------------------------------------------------------------------------- // Non-interactive path // --------------------------------------------------------------------------- async function nonInteractiveSetup(opts: SetupOptions): Promise { let rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"]; // Auto-detect OpenClaw if no URL was given explicitly if (!rawUrl) { const installation = await detectOpenClawInstallation(); if (installation.state === "running") { rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`; console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`)); } else { throw new SetupAbortedError( "Error: OpenClaw gateway not reachable and no --url provided.\n" + " Start OpenClaw first, or pass --url explicitly:\n" + " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive", ); } } let url = rawUrl; if (!url.startsWith("http://") && !url.startsWith("https://")) { throw new SetupAbortedError("Error: --url must start with http:// or https://"); } // Normalize: ensure URL ends with /hooks/agent url = normalizeOpenClawHooksUrl(url); const resolvedToken = token ?? randomBytes(32).toString("base64url"); if (!token) { console.log(chalk.dim("No token provided — auto-generated a secure token.")); } // Skip pre-write validation — on fresh installs the gateway won't have the // token yet. We write both configs first, then the user restarts the gateway. console.log(chalk.dim("Skipping pre-validation (token will be written to both configs).")); const routingPreset = isRoutingPreset(opts.routingPreset) ? opts.routingPreset : "urgent-action"; return { url, token: resolvedToken, routingPreset }; } // --------------------------------------------------------------------------- // Config writer // --------------------------------------------------------------------------- function writeOpenClawConfig( configPath: string, resolved: ResolvedConfig, nonInteractive: boolean, ): void { const rawYaml = readFileSync(configPath, "utf-8"); // Use parseDocument to preserve YAML comments during round-trip const doc = parseDocument(rawYaml); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rawConfig = (doc.toJS() as Record) ?? {}; // Write the env-var placeholder so the raw token is never committed to // version control. ao setup openclaw exports the real value to the shell // profile; the notifier plugin resolves it at runtime (env var → openclaw.json // fallback for daemon contexts where the shell profile isn't sourced). if (!rawConfig.notifiers) rawConfig.notifiers = {}; rawConfig.notifiers.openclaw = { plugin: "openclaw", url: resolved.url, token: "$" + "{OPENCLAW_HOOKS_TOKEN}", // env-var placeholder, not a JS template retries: 3, retryDelayMs: 1000, wakeMode: "now", }; // Add "openclaw" to defaults.notifiers if not already present if (!rawConfig.defaults) rawConfig.defaults = {}; if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = []; if (!Array.isArray(rawConfig.defaults.notifiers)) { rawConfig.defaults.notifiers = [rawConfig.defaults.notifiers]; } if (!rawConfig.defaults.notifiers.includes("openclaw")) { rawConfig.defaults.notifiers.push("openclaw"); } const defaultsWithoutOpenClaw = (rawConfig.defaults.notifiers as string[]).filter( (name) => name !== "openclaw", ); // AO routes notifications by priority. Preserve existing notifiers and only // adjust OpenClaw membership across the standard priority buckets. if (!rawConfig.notificationRouting) { const base = [...new Set(defaultsWithoutOpenClaw)]; rawConfig.notificationRouting = { urgent: [...base], action: [...base], warning: [...base], info: [...base], }; } else if (typeof rawConfig.notificationRouting === "object") { for (const priority of NOTIFICATION_PRIORITIES) { if (!Array.isArray(rawConfig.notificationRouting[priority])) { rawConfig.notificationRouting[priority] = []; } } } const selectedPriorities = new Set(OPENCLAW_ROUTING_PRESETS[resolved.routingPreset]); for (const priority of NOTIFICATION_PRIORITIES) { const list = rawConfig.notificationRouting[priority] as string[]; rawConfig.notificationRouting[priority] = list.filter((name) => name !== "openclaw"); if (selectedPriorities.has(priority)) { rawConfig.notificationRouting[priority].push("openclaw"); } } // Update the document tree from the modified plain object while preserving comments doc.setIn(["notifiers"], rawConfig.notifiers); doc.setIn(["defaults"], rawConfig.defaults); doc.setIn(["notificationRouting"], rawConfig.notificationRouting); writeFileSync(configPath, doc.toString({ indent: 2 })); if (nonInteractive) { console.log(chalk.green(`✓ Config written to ${configPath}`)); } } /** * Write the hooks block into ~/.openclaw/openclaw.json. * Returns true on success, false on failure (caller should fall back to * printing manual instructions). */ function writeOpenClawJsonConfig(token: string): boolean { try { const openclawDir = join(homedir(), ".openclaw"); const openclawJsonPath = join(openclawDir, "openclaw.json"); let config: Record = {}; if (existsSync(openclawJsonPath)) { const raw = readFileSync(openclawJsonPath, "utf-8"); config = JSON.parse(raw) as Record; } else if (!existsSync(openclawDir)) { mkdirSync(openclawDir, { recursive: true }); } // Merge the hooks block (preserve other existing keys in hooks if any) const existingHooks = (config.hooks as Record | undefined) ?? {}; const existingPrefixes = Array.isArray(existingHooks.allowedSessionKeyPrefixes) ? existingHooks.allowedSessionKeyPrefixes.filter( (prefix): prefix is string => typeof prefix === "string", ) : []; const allowedSessionKeyPrefixes = existingPrefixes.includes("hook:") ? existingPrefixes : [...existingPrefixes, "hook:"]; config.hooks = { ...existingHooks, enabled: true, token, allowRequestSessionKey: true, allowedSessionKeyPrefixes, }; writeFileSync(openclawJsonPath, JSON.stringify(config, null, 2) + "\n"); return true; } catch { return false; } } /** * Append `export OPENCLAW_HOOKS_TOKEN=...` to the user's shell profile * (~/.zshrc or ~/.bashrc). Skips if the export line already exists. * Returns the profile path on success, undefined on failure. */ function writeShellExport(token: string): string | undefined { try { const shell = process.env["SHELL"] ?? ""; const profileName = shell.endsWith("/zsh") ? ".zshrc" : ".bashrc"; const profilePath = join(homedir(), profileName); // Sanitize token: escape shell-special characters to prevent injection // when the profile is sourced. Single-quote the value and escape any // embedded single quotes (the only character that breaks '...' quoting). const safeToken = token.replace(/'/g, "'\\''"); const exportLine = `export OPENCLAW_HOOKS_TOKEN='${safeToken}'`; // Check if it already exists (use the same regex for detection and replacement // to avoid silent no-ops when the line is commented, lacks the export prefix, // or has leading whitespace) // Negative lookahead excludes commented lines (e.g. # export OPENCLAW_HOOKS_TOKEN=...) const existingExportRegex = /^(?!\s*#)\s*(?:export\s+)?OPENCLAW_HOOKS_TOKEN=.*$/m; if (existsSync(profilePath)) { const content = readFileSync(profilePath, "utf-8"); if (existingExportRegex.test(content)) { // Replace the existing line const updated = content.replace(existingExportRegex, exportLine); writeFileSync(profilePath, updated); return profilePath; } } // Append const prefix = existsSync(profilePath) ? "\n" : ""; writeFileSync(profilePath, `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, { flag: "a", }); return profilePath; } catch { return undefined; } } function printOpenClawInstructions( nonInteractive: boolean, openclawConfigWritten: boolean, shellProfilePath: string | undefined, ): void { if (openclawConfigWritten) { // Both configs written automatically if (nonInteractive) { console.log( chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)"), ); if (shellProfilePath) { console.log(chalk.green(`✓ OPENCLAW_HOOKS_TOKEN exported in ${shellProfilePath}`)); } console.log("Restart OpenClaw gateway to apply."); } else { console.log(`\n${chalk.green.bold("Done — both configs written.")}`); console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block")); console.log(chalk.dim(" ~/.openclaw/openclaw.json — hooks block")); if (shellProfilePath) { console.log(chalk.dim(` ${shellProfilePath} — OPENCLAW_HOOKS_TOKEN export`)); } console.log(`\n${chalk.yellow("Restart OpenClaw gateway to apply.")}`); } } else { // Fallback: could not write OpenClaw config, print manual instructions const instructions = ` ${chalk.bold("OpenClaw-side config required")} AO config was written successfully. Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}): ${chalk.cyan(`{ "hooks": { "enabled": true, "token": "", "allowRequestSessionKey": true, "allowedSessionKeyPrefixes": ["hook:"] } }`)} `; if (nonInteractive) { console.log("\nOpenClaw-side config required:"); console.log("AO config was written successfully. Add to ~/.openclaw/openclaw.json:"); console.log(" hooks.enabled: true"); console.log(' hooks.token: ""'); console.log(" hooks.allowRequestSessionKey: true"); console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]'); } else { console.log(instructions); } } } // --------------------------------------------------------------------------- // Command registration // --------------------------------------------------------------------------- export function registerSetup(program: Command): void { const setup = program.command("setup").description("Set up integrations with external services"); setup .command("openclaw") .description("Connect AO notifications to an OpenClaw gateway") .option("--url ", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)") .option("--token ", "OpenClaw hooks auth token") .option( "--routing-preset ", "OpenClaw routing preset: urgent-only | urgent-action | all", ) .option("--non-interactive", "Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)") .action(async (opts: SetupOptions) => { try { await runSetupAction(opts); } catch (err) { if (err instanceof SetupAbortedError) { console.error(err.message); process.exit(err.exitCode); } throw err; } }); } export async function runSetupAction(opts: SetupOptions): Promise { const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; // --- Find existing config ------------------------------------------------ let configPath: string | undefined; try { const found = findConfigFile(); configPath = found ?? undefined; } catch { // no config found } if (!configPath) { throw new SetupAbortedError( "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", ); } // --- Check for existing openclaw config ---------------------------------- const rawYaml = readFileSync(configPath, "utf-8"); const rawConfig = yamlParse(rawYaml) ?? {}; const existingOpenClaw = rawConfig?.notifiers?.openclaw; const existingUrl = existingOpenClaw?.url as string | undefined; if (existingOpenClaw && !nonInteractive) { const clack = await import("@clack/prompts"); const reconfigure = await clack.confirm({ message: "OpenClaw is already configured. Reconfigure?", initialValue: false, }); if (clack.isCancel(reconfigure) || !reconfigure) { console.log(chalk.dim("Keeping existing config.")); return; } } // --- Run setup ----------------------------------------------------------- let resolved: ResolvedConfig; if (nonInteractive) { resolved = await nonInteractiveSetup(opts); } else { resolved = await interactiveSetup(existingUrl); } // --- Write AO config ----------------------------------------------------- writeOpenClawConfig(configPath, resolved, nonInteractive); // --- Write OpenClaw config ----------------------------------------------- const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token); if (openclawConfigWritten && nonInteractive) { console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json")); } // --- Write shell export -------------------------------------------------- const shellProfilePath = writeShellExport(resolved.token); if (shellProfilePath && nonInteractive) { console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`)); } // --- Print instructions -------------------------------------------------- printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath); // --- Done ---------------------------------------------------------------- if (!nonInteractive) { const clack = await import("@clack/prompts"); clack.outro( `${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` + chalk.dim(" Run 'ao doctor' to verify the full setup.\n") + chalk.dim(" Restart AO with 'ao stop && ao start' to activate."), ); } else { console.log(chalk.green("\n✓ OpenClaw setup complete.")); console.log(chalk.dim("Restart AO to activate: ao stop && ao start")); } }