diff --git a/scripts/install-dirigent-openclaw.mjs b/scripts/install-dirigent-openclaw.mjs index 05d81bb..efe10a0 100755 --- a/scripts/install-dirigent-openclaw.mjs +++ b/scripts/install-dirigent-openclaw.mjs @@ -1,4 +1,8 @@ #!/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"; @@ -37,6 +41,7 @@ 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()); @@ -48,6 +53,7 @@ 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}`; function runOpenclaw(args, { allowFail = false } = {}) { try { @@ -58,11 +64,10 @@ function runOpenclaw(args, { allowFail = false } = {}) { } } - function getJson(pathKey) { const out = runOpenclaw(["config", "get", pathKey, "--json"], { allowFail: true }); - if (out == null || out === "") return { exists: false }; - return { exists: true, value: JSON.parse(out) }; + if (out == null || out === "") return undefined; + return JSON.parse(out); } function setJson(pathKey, value) { @@ -73,18 +78,18 @@ function unsetPath(pathKey) { runOpenclaw(["config", "unset", pathKey], { allowFail: true }); } -function writeRecord(modeName, before, after) { +function writeRecord(modeName, delta) { fs.mkdirSync(STATE_DIR, { recursive: true }); const rec = { mode: modeName, timestamp: ts, openclawConfigPath: OPENCLAW_CONFIG_PATH, backupPath: BACKUP_PATH, - paths: before, - applied: after, + 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) { @@ -110,14 +115,27 @@ function findLatestInstallRecord() { return ""; } +// Deep clone +function clone(v) { + 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 +// ═══════════════════════════════════════════════════════════════════════════ if (mode === "install") { fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); - console.log(`[dirigent] backup: ${BACKUP_PATH}`); + console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); @@ -125,21 +143,29 @@ if (mode === "install") { console.log(`[dirigent] initialized channel policies file: ${CHANNEL_POLICIES_FILE}`); } - const before = { - [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), - [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), - }; + const delta = { added: {}, replaced: {}, removed: {} }; try { - const pluginsNow = getJson("plugins").value || {}; - const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; - plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; - const paths = Array.isArray(plugins.load.paths) ? plugins.load.paths : []; - if (!paths.includes(PLUGIN_PATH)) paths.push(PLUGIN_PATH); - plugins.load.paths = paths; - plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; - plugins.entries.dirigent = { + // ── 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, @@ -149,15 +175,24 @@ if (mode === "install") { 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); - const providersNow = getJson(PATH_PROVIDERS).value || {}; - const providers = typeof providersNow === "object" ? providersNow : {}; - providers[NO_REPLY_PROVIDER_ID] = { + // ── 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", @@ -173,14 +208,15 @@ if (mode === "install") { }, ], }; + 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); - const after = { - [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), - [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), - }; - writeRecord("install", before, after); + 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"); @@ -189,7 +225,12 @@ if (mode === "install") { console.error(`[dirigent] install failed; rollback complete: ${String(e)}`); process.exit(1); } -} else { +} + +// ═══════════════════════════════════════════════════════════════════════════ +// UNINSTALL +// ═══════════════════════════════════════════════════════════════════════════ +else { const recFile = env.RECORD_FILE || findLatestInstallRecord(); if (!recFile || !fs.existsSync(recFile)) { console.error("[dirigent] no install record found. set RECORD_FILE= to an install record."); @@ -197,37 +238,69 @@ if (mode === "install") { } fs.copyFileSync(OPENCLAW_CONFIG_PATH, BACKUP_PATH); - console.log(`[dirigent] backup before uninstall: ${BACKUP_PATH}`); + console.log(`[dirigent] safety backup: ${BACKUP_PATH}`); const rec = readRecord(recFile); - const before = rec.applied || {}; - const target = rec.paths || {}; + const delta = rec.delta || { added: {}, replaced: {}, removed: {} }; try { - const pluginsNow = getJson("plugins").value || {}; - const plugins = typeof pluginsNow === "object" ? pluginsNow : {}; - plugins.load = plugins.load && typeof plugins.load === "object" ? plugins.load : {}; - plugins.entries = plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {}; + // ── Handle ADDED entries: remove them ───────────────────────────────── + if (delta.added[PATH_PLUGIN_ENTRY] !== undefined) { + const plugins = getJson("plugins") || {}; + plugins.entries = plugins.entries || {}; + delete plugins.entries.dirigent; + setJson("plugins", plugins); + console.log("[dirigent] removed plugins.entries.dirigent"); + } - if (target[PATH_PLUGINS_LOAD]?.exists) plugins.load.paths = target[PATH_PLUGINS_LOAD].value; - else delete plugins.load.paths; + 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}`); + } - if (target[PATH_PLUGIN_ENTRY]?.exists) plugins.entries.dirigent = target[PATH_PLUGIN_ENTRY].value; - else delete plugins.entries.dirigent; + 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"); + } + } - setJson("plugins", plugins); + // ── Handle REPLACED entries: restore old value ──────────────────────── + if (delta.replaced[PATH_PLUGIN_ENTRY] !== undefined) { + const plugins = getJson("plugins") || {}; + plugins.entries = plugins.entries || {}; + plugins.entries.dirigent = delta.replaced[PATH_PLUGIN_ENTRY]; + setJson("plugins", plugins); + console.log("[dirigent] restored previous plugins.entries.dirigent"); + } - if (target[PATH_PROVIDERS]?.exists) setJson(PATH_PROVIDERS, target[PATH_PROVIDERS].value); - else unsetPath(PATH_PROVIDERS); + 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}`); + } - const after = { - [PATH_PLUGINS_LOAD]: getJson(PATH_PLUGINS_LOAD), - [PATH_PLUGIN_ENTRY]: getJson(PATH_PLUGIN_ENTRY), - [PATH_PROVIDERS]: getJson(PATH_PROVIDERS), - }; - writeRecord("uninstall", before, after); + // 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)}`); diff --git a/scripts/install-dirigent-openclaw.sh b/scripts/install-dirigent-openclaw.sh deleted file mode 100755 index 84606cd..0000000 --- a/scripts/install-dirigent-openclaw.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -exec node "$SCRIPT_DIR/install-dirigent-openclaw.mjs" "$@"