#!/usr/bin/env node /** * Dirigent plugin installer/uninstaller/updater with delta-tracking. * * Usage: * node install-dirigent-openclaw.mjs --install Install (or reinstall) plugin * node install-dirigent-openclaw.mjs --uninstall Remove plugin config & files * node install-dirigent-openclaw.mjs --update Pull latest from git and reinstall * * OpenClaw directory resolution (priority order): * 1. --openclaw-profile-path CLI argument * 2. $OPENCLAW_DIR environment variable * 3. ~/.openclaw (fallback) */ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { execFileSync, spawnSync } from "node:child_process"; // ── Arg parsing ─────────────────────────────────────────────────────────── const VALID_MODES = ["--install", "--uninstall", "--update"]; let modeArg = null; let argOpenClawDir = null; for (let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; if (VALID_MODES.includes(arg)) { modeArg = arg; } else if (arg === "--openclaw-profile-path" && i + 1 < process.argv.length) { argOpenClawDir = process.argv[++i]; } else if (arg.startsWith("--openclaw-profile-path=")) { argOpenClawDir = arg.split("=").slice(1).join("="); } } if (!modeArg) { console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall | --update [--openclaw-profile-path ]"); process.exit(2); } const mode = modeArg.slice(2); // "install" | "uninstall" | "update" // ── OpenClaw directory resolution ───────────────────────────────────────── // Priority: --openclaw-profile-path arg > $OPENCLAW_DIR env > ~/.openclaw function resolveOpenClawDir() { // 1. CLI argument if (argOpenClawDir) { const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir()); if (fs.existsSync(dir)) return dir; console.error(`[dirigent] --openclaw-profile-path=${dir} does not exist`); process.exit(1); } // 2. Environment variable if (process.env.OPENCLAW_DIR) { const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir()); if (fs.existsSync(dir)) return dir; console.warn(`[dirigent] OPENCLAW_DIR=${dir} does not exist, falling back...`); } // 3. Fallback const fallback = path.join(os.homedir(), ".openclaw"); if (fs.existsSync(fallback)) return fallback; console.error("[dirigent] cannot resolve OpenClaw directory. Use --openclaw-profile-path or set OPENCLAW_DIR."); process.exit(1); } const OPENCLAW_DIR = resolveOpenClawDir(); console.log(`[dirigent] OpenClaw dir: ${OPENCLAW_DIR}`); const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(OPENCLAW_DIR, "openclaw.json"); if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) { console.error(`[dirigent] config not found: ${OPENCLAW_CONFIG_PATH}`); process.exit(1); } const env = process.env; const __dirname = path.dirname(new URL(import.meta.url).pathname); const REPO_ROOT = path.resolve(__dirname, ".."); // ── Update mode: git pull then reinstall ────────────────────────────────── const GIT_REPO_URL = env.DIRIGENT_GIT_URL || "https://git.hangman-lab.top/nav/Dirigent.git"; const GIT_BRANCH = env.DIRIGENT_GIT_BRANCH || "latest"; if (mode === "update") { console.log(`[dirigent] updating from ${GIT_REPO_URL} branch=${GIT_BRANCH} ...`); // Check if we're in a git repo const gitDir = path.join(REPO_ROOT, ".git"); if (!fs.existsSync(gitDir)) { console.error("[dirigent] not a git repo — cannot update. Clone the repo first."); process.exit(1); } // Fetch and checkout latest try { execFileSync("git", ["fetch", "origin", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" }); execFileSync("git", ["checkout", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" }); execFileSync("git", ["pull", "origin", GIT_BRANCH], { cwd: REPO_ROOT, stdio: "inherit" }); console.log("[dirigent] source updated successfully"); } catch (err) { console.error(`[dirigent] git update failed: ${String(err)}`); process.exit(1); } // Re-exec as install (the updated script may differ), pass through openclaw dir const updatedScript = path.join(REPO_ROOT, "scripts", "install-dirigent-openclaw.mjs"); const installArgs = [updatedScript, "--install", "--openclaw-profile-path", OPENCLAW_DIR]; const result = spawnSync(process.execPath, installArgs, { env: process.env, stdio: "inherit", cwd: REPO_ROOT, }); process.exit(result.status ?? 1); } // ── Build: copy plugin + no-reply-api to dist ───────────────────────────── const PLUGIN_SRC_DIR = path.resolve(REPO_ROOT, "plugin"); const NO_REPLY_API_SRC_DIR = path.resolve(REPO_ROOT, "no-reply-api"); const DIST_PLUGIN_DIR = path.resolve(REPO_ROOT, "dist", "dirigent"); const DIST_NO_REPLY_DIR = path.resolve(REPO_ROOT, "dist", "no-reply-api"); function syncDir(srcDir, destDir) { fs.mkdirSync(destDir, { recursive: true }); for (const f of fs.readdirSync(srcDir)) { const srcFile = path.join(srcDir, f); if (fs.statSync(srcFile).isFile()) { fs.copyFileSync(srcFile, path.join(destDir, f)); } } } // ── Determine plugin install path ───────────────────────────────────────── const PLUGINS_DIR = path.join(OPENCLAW_DIR, "plugins"); const PLUGIN_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent"); const NO_REPLY_INSTALL_DIR = path.join(PLUGINS_DIR, "dirigent-no-reply-api"); // ── Config helpers ──────────────────────────────────────────────────────── 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 || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json")); 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 || path.join(OPENCLAW_DIR, "dirigent-install-records"); const LATEST_RECORD_LINK = env.LATEST_RECORD_LINK || path.join(OPENCLAW_DIR, "dirigent-install-record-latest.json"); 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, openclawDir: OPENCLAW_DIR, openclawConfigPath: OPENCLAW_CONFIG_PATH, backupPath: BACKUP_PATH, pluginInstallDir: PLUGIN_INSTALL_DIR, delta, }; 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 ""; } function clone(v) { if (v === undefined) return undefined; return JSON.parse(JSON.stringify(v)); } // ═══════════════════════════════════════════════════════════════════════════ // INSTALL // ═══════════════════════════════════════════════════════════════════════════ 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; const result = spawnSync(process.execPath, [import.meta.filename, "--uninstall"], { env: { ...process.env, OPENCLAW_DIR }, stdio: "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..."); } // 1. Build dist console.log("[dirigent] building dist..."); syncDir(PLUGIN_SRC_DIR, DIST_PLUGIN_DIR); syncDir(NO_REPLY_API_SRC_DIR, DIST_NO_REPLY_DIR); // 2. Copy to plugins dir console.log(`[dirigent] installing plugin to ${PLUGIN_INSTALL_DIR}`); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); syncDir(DIST_PLUGIN_DIR, PLUGIN_INSTALL_DIR); // Also install no-reply-api next to plugin (plugin expects ../no-reply-api/) console.log(`[dirigent] installing no-reply-api to ${NO_REPLY_INSTALL_DIR}`); syncDir(DIST_NO_REPLY_DIR, NO_REPLY_INSTALL_DIR); // 3. Backup config fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); // 4. Initialize channel policies file 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: {}, _prev: {} }; try { // ── plugins.load.paths ──────────────────────────────────────────────── const plugins = getJson("plugins") || {}; const oldPaths = clone(plugins.load?.paths) || []; const newPaths = clone(oldPaths); if (!newPaths.includes(PLUGIN_INSTALL_DIR)) { newPaths.push(PLUGIN_INSTALL_DIR); delta.added[PATH_PLUGINS_LOAD] = PLUGIN_INSTALL_DIR; } 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[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] plugin dir: ${PLUGIN_INSTALL_DIR}`); 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 if (mode === "uninstall") { 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: {} }; const installedPluginDir = rec.pluginInstallDir || PLUGIN_INSTALL_DIR; try { // 1. Remove from allow list 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. Remove plugin 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. Remove plugin path from load paths if (delta.added[PATH_PLUGINS_LOAD] !== undefined) { const plugins = getJson("plugins") || {}; const paths = plugins.load?.paths || []; const pluginPath = delta.added[PATH_PLUGINS_LOAD]; const idx = paths.indexOf(pluginPath); 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. 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 (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"); } // 5. Remove installed plugin files if (fs.existsSync(installedPluginDir)) { fs.rmSync(installedPluginDir, { recursive: true, force: true }); console.log(`[dirigent] removed plugin dir: ${installedPluginDir}`); } // Also remove no-reply-api dir const noReplyDir = path.join(path.dirname(installedPluginDir), "dirigent-no-reply-api"); if (fs.existsSync(noReplyDir)) { fs.rmSync(noReplyDir, { recursive: true, force: true }); console.log(`[dirigent] removed no-reply-api dir: ${noReplyDir}`); } 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); } }