#!/usr/bin/env node /** * Dirigent plugin installer/uninstaller with delta-tracking. * Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys. */ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { execFileSync, spawnSync } from "node:child_process"; const modeArg = process.argv[2]; if (modeArg !== "--install" && modeArg !== "--uninstall") { console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall"); process.exit(2); } const mode = modeArg === "--install" ? "install" : "uninstall"; const env = process.env; const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); const __dirname = path.dirname(new URL(import.meta.url).pathname); // Ensure dist/dirigent exists (handles git clone without npm prepare) const DIST_DIR = path.resolve(__dirname, "..", "dist", "dirigent"); const PLUGIN_SRC_DIR = path.resolve(__dirname, "..", "plugin"); const NO_REPLY_API_SRC_DIR = path.resolve(__dirname, "..", "no-reply-api"); const NO_REPLY_API_DIST_DIR = path.resolve(__dirname, "..", "dist", "no-reply-api"); if (mode === "install") { // Copy plugin files to dist/dirigent if (!fs.existsSync(DIST_DIR)) { console.log("[dirigent] dist/dirigent/ not found, syncing from plugin/..."); fs.mkdirSync(DIST_DIR, { recursive: true }); for (const f of fs.readdirSync(PLUGIN_SRC_DIR)) { fs.copyFileSync(path.join(PLUGIN_SRC_DIR, f), path.join(DIST_DIR, f)); } } // Copy no-reply-api files to dist/no-reply-api if (!fs.existsSync(NO_REPLY_API_DIST_DIR)) { console.log("[dirigent] dist/no-reply-api/ not found, syncing from no-reply-api/..."); fs.mkdirSync(NO_REPLY_API_DIST_DIR, { recursive: true }); for (const f of fs.readdirSync(NO_REPLY_API_SRC_DIR)) { fs.copyFileSync(path.join(NO_REPLY_API_SRC_DIR, f), path.join(NO_REPLY_API_DIST_DIR, f)); } } } const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR; const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "dirigentway"; const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1"; const NO_REPLY_API_KEY = env.NO_REPLY_API_KEY || "wg-local-test-token"; const LIST_MODE = env.LIST_MODE || "human-list"; const HUMAN_LIST_JSON = env.HUMAN_LIST_JSON || '["561921120408698910","1474088632750047324"]'; const AGENT_LIST_JSON = env.AGENT_LIST_JSON || "[]"; const CHANNEL_POLICIES_FILE = (env.CHANNEL_POLICIES_FILE || "~/.openclaw/dirigent-channel-policies.json").replace(/^~(?=$|\/)/, os.homedir()); const CHANNEL_POLICIES_JSON = env.CHANNEL_POLICIES_JSON || "{}"; const END_SYMBOLS_JSON = env.END_SYMBOLS_JSON || '["🔚"]'; const SCHEDULING_IDENTIFIER = env.SCHEDULING_IDENTIFIER || "➡️"; const STATE_DIR = (env.STATE_DIR || "~/.openclaw/dirigent-install-records").replace(/^~(?=$|\/)/, os.homedir()); const LATEST_RECORD_LINK = (env.LATEST_RECORD_LINK || "~/.openclaw/dirigent-install-record-latest.json").replace(/^~(?=$|\/)/, os.homedir()); const ts = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); const BACKUP_PATH = `${OPENCLAW_CONFIG_PATH}.bak-dirigent-${mode}-${ts}`; const RECORD_PATH = path.join(STATE_DIR, `dirigent-${ts}.json`); const PATH_PLUGINS_LOAD = "plugins.load.paths"; const PATH_PLUGIN_ENTRY = "plugins.entries.dirigent"; const PATH_PROVIDERS = "models.providers"; const PATH_PROVIDER_ENTRY = `models.providers.${NO_REPLY_PROVIDER_ID}`; const PATH_PLUGINS_ALLOW = "plugins.allow"; function runOpenclaw(args, { allowFail = false } = {}) { try { return execFileSync("openclaw", args, { encoding: "utf8" }).trim(); } catch (e) { if (allowFail) return null; throw e; } } function getJson(pathKey) { const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); if (out == null || out === "" || out === "undefined") return undefined; try { return JSON.parse(out); } catch { return undefined; } } function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); } function unsetPath(pathKey) { runOpenclaw(["config", "unset", pathKey], { allowFail: true }); } function writeRecord(modeName, delta) { fs.mkdirSync(STATE_DIR, { recursive: true }); const rec = { mode: modeName, timestamp: ts, openclawConfigPath: OPENCLAW_CONFIG_PATH, backupPath: BACKUP_PATH, delta, // { added: {...}, replaced: {...}, removed: {...} } }; fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2)); fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK); return rec; } function readRecord(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } function findLatestInstallRecord() { if (!fs.existsSync(STATE_DIR)) return ""; const files = fs .readdirSync(STATE_DIR) .filter((f) => /^dirigent-\d+\.json$/.test(f)) .sort() .reverse(); for (const f of files) { const p = path.join(STATE_DIR, f); try { const rec = readRecord(p); if (rec?.mode === "install") return p; } catch { // ignore broken records } } return ""; } // Deep clone function clone(v) { if (v === undefined) return undefined; return JSON.parse(JSON.stringify(v)); } // Check if two values are deeply equal function deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); } if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`); process.exit(1); } // ═══════════════════════════════════════════════════════════════════════════ // INSTALL (with auto-reinstall if already installed) // ═══════════════════════════════════════════════════════════════════════════ if (mode === "install") { // Check if already installed - if so, uninstall first const existingRecord = findLatestInstallRecord(); if (existingRecord) { console.log("[dirigent] existing installation detected, uninstalling first..."); process.env.RECORD_FILE = existingRecord; // Re-exec ourselves in uninstall mode const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], { env: process.env, stdio: ["inherit", "inherit", "inherit"], }); if (result.status !== 0) { console.error("[dirigent] reinstall failed during uninstall phase"); process.exit(1); } console.log("[dirigent] previous installation removed, proceeding with fresh install..."); } fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); } const delta = { added: {}, replaced: {}, removed: {} }; try { // ── plugins.load.paths ──────────────────────────────────────────────── const plugins = getJson("plugins") || {}; const oldPaths = clone(plugins.load?.paths) || []; const newPaths = clone(oldPaths); const pathIndex = newPaths.indexOf(PLUGIN_PATH); if (pathIndex === -1) { newPaths.push(PLUGIN_PATH); delta.added[PATH_PLUGINS_LOAD] = PLUGIN_PATH; // added this path } else { // already present, no change } // save old paths for potential future rollback of this specific change delta._prev = delta._prev || {}; delta._prev[PATH_PLUGINS_LOAD] = oldPaths; plugins.load = plugins.load || {}; plugins.load.paths = newPaths; // ── plugins.entries.dirigent ────────────────────────────────────────── const oldEntry = clone(plugins.entries?.dirigent); const newEntry = { enabled: true, config: { enabled: true, discordOnly: true, listMode: LIST_MODE, humanList: JSON.parse(HUMAN_LIST_JSON), agentList: JSON.parse(AGENT_LIST_JSON), channelPoliciesFile: CHANNEL_POLICIES_FILE, endSymbols: JSON.parse(END_SYMBOLS_JSON), schedulingIdentifier: SCHEDULING_IDENTIFIER, noReplyProvider: NO_REPLY_PROVIDER_ID, noReplyModel: NO_REPLY_MODEL_ID, }, }; if (oldEntry === undefined) { delta.added[PATH_PLUGIN_ENTRY] = newEntry; } else { delta.replaced[PATH_PLUGIN_ENTRY] = oldEntry; } plugins.entries = plugins.entries || {}; plugins.entries.dirigent = newEntry; setJson("plugins", plugins); // ── models.providers. ───────────────────────────────────── const providers = getJson(PATH_PROVIDERS) || {}; const oldProvider = clone(providers[NO_REPLY_PROVIDER_ID]); const newProvider = { baseUrl: NO_REPLY_BASE_URL, apiKey: NO_REPLY_API_KEY, api: "openai-completions", models: [ { id: NO_REPLY_MODEL_ID, name: `${NO_REPLY_MODEL_ID} (Custom Provider)`, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192, }, ], }; if (oldProvider === undefined) { delta.added[PATH_PROVIDER_ENTRY] = newProvider; } else { delta.replaced[PATH_PROVIDER_ENTRY] = oldProvider; } providers[NO_REPLY_PROVIDER_ID] = newProvider; setJson(PATH_PROVIDERS, providers); // ── plugins.allow ───────────────────────────────────────────────────── const allowList = getJson(PATH_PLUGINS_ALLOW) || []; const oldAllow = clone(allowList); if (!allowList.includes("dirigent")) { allowList.push("dirigent"); delta.added[PATH_PLUGINS_ALLOW] = "dirigent"; delta._prev = delta._prev || {}; delta._prev[PATH_PLUGINS_ALLOW] = oldAllow; setJson(PATH_PLUGINS_ALLOW, allowList); console.log("[dirigent] added 'dirigent' to plugins.allow"); } writeRecord("install", delta); console.log("[dirigent] install ok (config written)"); console.log(`[dirigent] record: ${RECORD_PATH}`); console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart"); } catch (e) { fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); console.error(`[dirigent] install failed; rollback complete: ${String(e)}`); process.exit(1); } } // ═══════════════════════════════════════════════════════════════════════════ // UNINSTALL // ═══════════════════════════════════════════════════════════════════════════ else { const recFile = env.RECORD_FILE || findLatestInstallRecord(); if (!recFile || !fs.existsSync(recFile)) { console.log("[dirigent] no install record found, nothing to uninstall."); process.exit(0); } fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); const rec = readRecord(recFile); const delta = rec.delta || { added: {}, replaced: {}, removed: {} }; try { // ── IMPORTANT: Order matters for OpenClaw config validation ──────────── // 1. First remove from allow (before deleting entry, otherwise validation fails) if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) { const allowList = getJson(PATH_PLUGINS_ALLOW) || []; const idx = allowList.indexOf("dirigent"); if (idx !== -1) { allowList.splice(idx, 1); setJson(PATH_PLUGINS_ALLOW, allowList); console.log("[dirigent] removed 'dirigent' from plugins.allow"); } } // 2. Then remove entry if (delta.added[PATH_PLUGIN_ENTRY] !== undefined || delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) { unsetPath(PATH_PLUGIN_ENTRY); console.log("[dirigent] removed plugins.entries.dirigent"); } // 3. Then remove plugin path (after entry is gone) if (delta.added[PATH_PLUGINS_LOAD] !== undefined) { const plugins = getJson("plugins") || {}; const paths = plugins.load?.paths || []; const idx = paths.indexOf(PLUGIN_PATH); if (idx !== -1) { paths.splice(idx, 1); plugins.load.paths = paths; setJson("plugins", plugins); console.log("[dirigent] removed plugin path from plugins.load.paths"); } } // 4. Finally remove provider if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) { const providers = getJson(PATH_PROVIDERS) || {}; delete providers[NO_REPLY_PROVIDER_ID]; setJson(PATH_PROVIDERS, providers); console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`); } // ── Handle REPLACED provider: restore old value ─────────────────────── if (delta.replaced[PATH_PROVIDER_ENTRY] !== undefined) { const providers = getJson(PATH_PROVIDERS) || {}; providers[NO_REPLY_PROVIDER_ID] = delta.replaced[PATH_PROVIDER_ENTRY]; setJson(PATH_PROVIDERS, providers); console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`); } // Handle plugins.load.paths restoration (if it was replaced, not added) if (delta._prev?.[PATH_PLUGINS_LOAD] && delta.added[PATH_PLUGINS_LOAD] === undefined) { const plugins = getJson("plugins") || {}; plugins.load = plugins.load || {}; plugins.load.paths = delta._prev[PATH_PLUGINS_LOAD]; setJson("plugins", plugins); console.log("[dirigent] restored previous plugins.load.paths"); } writeRecord("uninstall", delta); console.log("[dirigent] uninstall ok"); console.log(`[dirigent] record: ${RECORD_PATH}`); console.log("[dirigent] >>> restart gateway to apply: openclaw gateway restart"); } catch (e) { fs.copyFileSync(BACKUP_PATH, OPENCLAW_CONFIG_PATH); console.error(`[dirigent] uninstall failed; rollback complete: ${String(e)}`); process.exit(1); } }