From e4454bfc1a9cff05d0bfa733350224cd7dede42c Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 7 Mar 2026 17:24:36 +0000 Subject: [PATCH] refactor: remove turn tools, rename discord tools, rewrite installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove turn management tools (turn-status/advance/reset) — internal only, accessible via /dirigent slash commands - Rename discord tools: dirigent_discord_channel_create, dirigent_discord_channel_update, dirigent_discord_member_list - Rewrite install script: - Dynamic OpenClaw dir resolution (OPENCLAW_DIR env → openclaw CLI → ~/.openclaw) - Plugin installed to $(openclaw_dir)/plugins/dirigent - New --update mode: git pull from latest branch + reinstall - Cleaner uninstall: removes installed plugin files - Update docs (FEAT.md, README.md, CHANGELOG.md, TASKLIST.md) --- CHANGELOG.md | 12 +- FEAT.md | 26 +-- README.md | 13 +- TASKLIST.md | 12 +- package.json | 3 +- plugin/index.ts | 74 +------- scripts/install-dirigent-openclaw.mjs | 242 ++++++++++++++++++-------- 7 files changed, 214 insertions(+), 168 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c053a..1bd5912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,19 @@ ## 0.3.0 -- **Split `dirigent_tools` into individual tools**: Each action is now a separate tool with focused parameters: - - `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` (Discord control) - - `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete` (policy management) - - `dirigent_turn_status`, `dirigent_turn_advance`, `dirigent_turn_reset` (turn management) +- **Split `dirigent_tools` into 6 individual tools**: + - Discord: `dirigent_discord_channel_create`, `dirigent_discord_channel_update`, `dirigent_discord_member_list` + - Policy: `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete` + - Turn management tools removed (internal plugin logic only; use `/dirigent` slash commands) - **Human @mention override**: When a `humanList` user @mentions specific agents: - Temporarily overrides the speaking order to only mentioned agents - Agents cycle in their original turn-order position - After all mentioned agents have spoken, original order restores and state goes dormant - Handles edge cases: all NO_REPLY, reset, non-agent mentions +- **Installer improvements**: + - Dynamic OpenClaw dir resolution (`$OPENCLAW_DIR` → `openclaw` CLI → `~/.openclaw`) + - Plugin installed to `$(openclaw_dir)/plugins/dirigent` + - New `--update` mode: pulls latest from git `latest` branch and reinstalls ## 0.2.0 diff --git a/FEAT.md b/FEAT.md index 9d4792d..3ae31d5 100644 --- a/FEAT.md +++ b/FEAT.md @@ -66,22 +66,23 @@ All implemented features across all versions. - Sends handoff messages to trigger next speaker's turn ## Individual Tools *(v0.3.0)* -Nine standalone tools (split from former monolithic `dirigent_tools`): +Six standalone tools (split from former monolithic `dirigent_tools`): ### Discord Control -- **`dirigent_channel_create`** — Create private Discord channel with user/role permissions -- **`dirigent_channel_update`** — Update permissions on existing private channel -- **`dirigent_member_list`** — List guild members with pagination and field projection +- **`dirigent_discord_channel_create`** — Create private Discord channel with user/role permissions +- **`dirigent_discord_channel_update`** — Update permissions on existing private channel +- **`dirigent_discord_member_list`** — List guild members with pagination and field projection ### Policy Management - **`dirigent_policy_get`** — Get all channel policies - **`dirigent_policy_set`** — Set/update a channel policy (listMode, humanList, agentList, endSymbols) - **`dirigent_policy_delete`** — Delete a channel policy -### Turn Management -- **`dirigent_turn_status`** — Show turn state (order, current speaker, dormant status, override info) -- **`dirigent_turn_advance`** — Manually advance to next speaker -- **`dirigent_turn_reset`** — Reset turn order (go dormant, clear overrides) +### Turn Management (internal only — not exposed as tools) +Turn management is handled entirely by the plugin. Manual control via slash commands: +- `/dirigent turn-status` — Show turn state +- `/dirigent turn-advance` — Manually advance to next speaker +- `/dirigent turn-reset` — Reset turn order (go dormant, clear overrides) ## Slash Command: `/dirigent` - `status` — Show all channel policies @@ -93,12 +94,15 @@ Nine standalone tools (split from former monolithic `dirigent_tools`): - All plugin ids, tool names, config keys, file paths, docs updated - Legacy `whispergate` config key still supported as fallback -## Installer Script +## Installer Script *(updated v0.3.0)* - `scripts/install-dirigent-openclaw.mjs` -- `--install` / `--uninstall` with delta-tracking +- `--install` / `--uninstall` / `--update` modes +- **Dynamic OpenClaw dir resolution**: `$OPENCLAW_DIR` → `openclaw config get dataDir` → `~/.openclaw` +- Builds dist and copies to `$(openclaw_dir)/plugins/dirigent` +- `--update`: pulls latest from git `latest` branch, then reinstalls - Auto-reinstall (uninstall + install) if already installed - Backup before changes, rollback on failure -- Records stored in `~/.openclaw/dirigent-install-records/` +- Records stored in `$(openclaw_dir)/dirigent-install-records/` ## Discord Control API (Sidecar) - Private channel create/update with permission overwrites diff --git a/README.md b/README.md index 50796fb..bde054b 100644 --- a/README.md +++ b/README.md @@ -78,22 +78,19 @@ Discord extension capabilities: `docs/DISCORD_CONTROL.md`. ## Runtime tools & commands -### Tools (9 individual tools) +### Tools (6 individual tools) **Discord control:** -- `dirigent_channel_create` — Create private channel -- `dirigent_channel_update` — Update channel permissions -- `dirigent_member_list` — List guild members +- `dirigent_discord_channel_create` — Create private channel +- `dirigent_discord_channel_update` — Update channel permissions +- `dirigent_discord_member_list` — List guild members **Policy management:** - `dirigent_policy_get` — Get all policies - `dirigent_policy_set` — Set/update channel policy - `dirigent_policy_delete` — Delete channel policy -**Turn management:** -- `dirigent_turn_status` — Show turn state -- `dirigent_turn_advance` — Advance turn -- `dirigent_turn_reset` — Reset turn order +> Turn management is internal to the plugin (not exposed as tools). > See `FEAT.md` for full feature documentation. diff --git a/TASKLIST.md b/TASKLIST.md index b9968dd..86e78cb 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -41,16 +41,14 @@ ## 6) Split dirigent_tools into Individual Tools ✅ - **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions. -- **After**: 9 individual tools, each with focused parameters: - - `dirigent_channel_create` — Create private Discord channel - - `dirigent_channel_update` — Update channel permissions - - `dirigent_member_list` — List guild members +- **After**: 6 individual tools (Discord tools prefixed `dirigent_discord_*`): + - `dirigent_discord_channel_create` — Create private Discord channel + - `dirigent_discord_channel_update` — Update channel permissions + - `dirigent_discord_member_list` — List guild members - `dirigent_policy_get` — Get all channel policies - `dirigent_policy_set` — Set/update a channel policy - `dirigent_policy_delete` — Delete a channel policy - - `dirigent_turn_status` — Show turn status - - `dirigent_turn_advance` — Manually advance turn - - `dirigent_turn_reset` — Reset turn order +- Turn management (status/advance/reset) NOT exposed as tools — purely internal plugin logic, accessible via `/dirigent` slash commands. - Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication. - **Done**: All tools registered individually with specific parameter schemas. diff --git a/package.json b/package.json index abe34d4..8ce1891 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "scripts": { "prepare": "mkdir -p dist/dirigent && cp plugin/* dist/dirigent/", "postinstall": "node scripts/install-dirigent-openclaw.mjs --install", - "uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall" + "uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall", + "update": "node scripts/install-dirigent-openclaw.mjs --update" }, "keywords": [ "openclaw", diff --git a/plugin/index.ts b/plugin/index.ts index bdcc33c..394a5bf 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -625,7 +625,7 @@ export default { api.registerTool( { - name: "dirigent_channel_create", + name: "dirigent_discord_channel_create", description: "Create a private Discord channel with specific user/role permissions.", parameters: { type: "object", @@ -654,7 +654,7 @@ export default { api.registerTool( { - name: "dirigent_channel_update", + name: "dirigent_discord_channel_update", description: "Update permissions on an existing private Discord channel.", parameters: { type: "object", @@ -679,7 +679,7 @@ export default { api.registerTool( { - name: "dirigent_member_list", + name: "dirigent_discord_member_list", description: "List members of a Discord guild.", parameters: { type: "object", @@ -798,72 +798,8 @@ export default { { optional: false }, ); - // ── Turn management tools ── - - api.registerTool( - { - name: "dirigent_turn_status", - description: "Show turn-based speaking status for a channel.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - channelId: { type: "string" }, - }, - required: ["channelId"], - }, - async execute(_id: string, params: Record) { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] }; - }, - }, - { optional: false }, - ); - - api.registerTool( - { - name: "dirigent_turn_advance", - description: "Manually advance the speaking turn in a channel.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - channelId: { type: "string" }, - }, - required: ["channelId"], - }, - async execute(_id: string, params: Record) { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const next = advanceTurn(channelId); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] }; - }, - }, - { optional: false }, - ); - - api.registerTool( - { - name: "dirigent_turn_reset", - description: "Reset turn order for a channel (go dormant).", - parameters: { - type: "object", - additionalProperties: false, - properties: { - channelId: { type: "string" }, - }, - required: ["channelId"], - }, - async execute(_id: string, params: Record) { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - resetTurn(channelId); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] }; - }, - }, - { optional: false }, - ); + // Turn management is handled internally by the plugin (not exposed as tools). + // Use `/dirigent turn-status`, `/dirigent turn-advance`, `/dirigent turn-reset` for manual control. api.on("message_received", async (event, ctx) => { try { diff --git a/scripts/install-dirigent-openclaw.mjs b/scripts/install-dirigent-openclaw.mjs index 3dfec54..a8a70e5 100755 --- a/scripts/install-dirigent-openclaw.mjs +++ b/scripts/install-dirigent-openclaw.mjs @@ -1,51 +1,143 @@ #!/usr/bin/env node /** - * Dirigent plugin installer/uninstaller with delta-tracking. - * Tracks what was ADDED/REPLACED/REMOVED, so uninstall only affects plugin-managed keys. + * 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 (in order): + * 1. $OPENCLAW_DIR environment variable + * 2. `openclaw config get dataDir` command output + * 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"; +// ── Mode parsing ────────────────────────────────────────────────────────── +const VALID_MODES = ["--install", "--uninstall", "--update"]; const modeArg = process.argv[2]; -if (modeArg !== "--install" && modeArg !== "--uninstall") { - console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall"); +if (!VALID_MODES.includes(modeArg)) { + console.error("Usage: install-dirigent-openclaw.mjs --install | --uninstall | --update"); process.exit(2); } -const mode = modeArg === "--install" ? "install" : "uninstall"; +const mode = modeArg.slice(2); // "install" | "uninstall" | "update" -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)); - } +// ── OpenClaw directory resolution ───────────────────────────────────────── +function resolveOpenClawDir() { + // 1. Explicit env var + 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, trying other methods...`); } - // 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)); + // 2. Ask openclaw CLI + try { + const out = execFileSync("openclaw", ["config", "get", "dataDir"], { + encoding: "utf8", + timeout: 5000, + }).trim(); + if (out && fs.existsSync(out)) return out; + } catch { + // openclaw not found or command failed + } + + // 3. Try openclaw config file location to infer dir + try { + const out = execFileSync("openclaw", ["config", "path"], { + encoding: "utf8", + timeout: 5000, + }).trim(); + if (out) { + const dir = path.dirname(out); + if (fs.existsSync(dir)) return dir; + } + } catch { + // ignore + } + + // 4. Fallback + const fallback = path.join(os.homedir(), ".openclaw"); + if (fs.existsSync(fallback)) return fallback; + + console.error("[dirigent] cannot resolve OpenClaw directory. Set OPENCLAW_DIR or ensure openclaw CLI is available."); + 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) + const updatedScript = path.join(REPO_ROOT, "scripts", "install-dirigent-openclaw.mjs"); + const result = spawnSync(process.execPath, [updatedScript, "--install"], { + env: { ...process.env, OPENCLAW_DIR }, + 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)); } } } -const PLUGIN_PATH = env.PLUGIN_PATH || DIST_DIR; +// ── 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"; @@ -53,13 +145,13 @@ 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_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 || "~/.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 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}`; @@ -103,9 +195,11 @@ function writeRecord(modeName, delta) { const rec = { mode: modeName, timestamp: ts, + openclawDir: OPENCLAW_DIR, openclawConfigPath: OPENCLAW_CONFIG_PATH, backupPath: BACKUP_PATH, - delta, // { added: {...}, replaced: {...}, removed: {...} } + pluginInstallDir: PLUGIN_INSTALL_DIR, + delta, }; fs.writeFileSync(RECORD_PATH, JSON.stringify(rec, null, 2)); fs.copyFileSync(RECORD_PATH, LATEST_RECORD_LINK); @@ -135,24 +229,13 @@ function findLatestInstallRecord() { 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) +// INSTALL // ═══════════════════════════════════════════════════════════════════════════ if (mode === "install") { // Check if already installed - if so, uninstall first @@ -160,10 +243,9 @@ if (mode === "install") { 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"], + env: { ...process.env, OPENCLAW_DIR }, + stdio: "inherit", }); if (result.status !== 0) { console.error("[dirigent] reinstall failed during uninstall phase"); @@ -172,31 +254,42 @@ if (mode === "install") { 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: {} }; + const delta = { added: {}, replaced: {}, removed: {}, _prev: {} }; 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 + if (!newPaths.includes(PLUGIN_INSTALL_DIR)) { + newPaths.push(PLUGIN_INSTALL_DIR); + delta.added[PATH_PLUGINS_LOAD] = PLUGIN_INSTALL_DIR; } - // 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; @@ -260,7 +353,6 @@ if (mode === "install") { 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"); @@ -268,6 +360,7 @@ if (mode === "install") { 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) { @@ -280,7 +373,7 @@ if (mode === "install") { // ═══════════════════════════════════════════════════════════════════════════ // UNINSTALL // ═══════════════════════════════════════════════════════════════════════════ -else { +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."); @@ -292,10 +385,10 @@ else { const rec = readRecord(recFile); const delta = rec.delta || { added: {}, replaced: {}, removed: {} }; + const installedPluginDir = rec.pluginInstallDir || PLUGIN_INSTALL_DIR; try { - // ── IMPORTANT: Order matters for OpenClaw config validation ──────────── - // 1. First remove from allow (before deleting entry, otherwise validation fails) + // 1. Remove from allow list if (delta.added[PATH_PLUGINS_ALLOW] !== undefined) { const allowList = getJson(PATH_PLUGINS_ALLOW) || []; const idx = allowList.indexOf("dirigent"); @@ -306,17 +399,18 @@ else { } } - // 2. Then remove entry + // 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. Then remove plugin path (after entry is gone) + // 3. Remove plugin path from load paths if (delta.added[PATH_PLUGINS_LOAD] !== undefined) { const plugins = getJson("plugins") || {}; const paths = plugins.load?.paths || []; - const idx = paths.indexOf(PLUGIN_PATH); + const pluginPath = delta.added[PATH_PLUGINS_LOAD]; + const idx = paths.indexOf(pluginPath); if (idx !== -1) { paths.splice(idx, 1); plugins.load.paths = paths; @@ -325,7 +419,7 @@ else { } } - // 4. Finally remove provider + // 4. Remove provider if (delta.added[PATH_PROVIDER_ENTRY] !== undefined) { const providers = getJson(PATH_PROVIDERS) || {}; delete providers[NO_REPLY_PROVIDER_ID]; @@ -333,7 +427,7 @@ else { console.log(`[dirigent] removed models.providers.${NO_REPLY_PROVIDER_ID}`); } - // ── Handle REPLACED provider: restore old value ─────────────────────── + // 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]; @@ -341,7 +435,7 @@ else { console.log(`[dirigent] restored previous models.providers.${NO_REPLY_PROVIDER_ID}`); } - // Handle plugins.load.paths restoration (if it was replaced, not added) + // 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 || {}; @@ -350,6 +444,18 @@ else { 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}`);