agent-orchestrator/packages/cli/src/commands/setup.ts

592 lines
21 KiB
TypeScript

/**
* `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<ResolvedConfig> {
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<ResolvedConfig> {
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<string, any>) ?? {};
// 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<string, unknown> = {};
if (existsSync(openclawJsonPath)) {
const raw = readFileSync(openclawJsonPath, "utf-8");
config = JSON.parse(raw) as Record<string, unknown>;
} 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<string, unknown> | 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": "<same-token-you-entered-above>",
"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: "<your-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 <url>", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)")
.option("--token <token>", "OpenClaw hooks auth token")
.option(
"--routing-preset <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<void> {
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"));
}
}