From 4d50826f2abb49b48239ea753ef79314d56f500a Mon Sep 17 00:00:00 2001 From: orion Date: Sun, 8 Mar 2026 06:38:33 +0000 Subject: [PATCH] refactor(installer): rename to install.mjs, remove record/restore flow, add --no-reply-port and wire config port --- docs/INTEGRATION.md | 25 +- package.json | 8 +- plugin/core/live-config.ts | 1 + plugin/index.ts | 3 +- plugin/openclaw.plugin.json | 1 + plugin/rules.ts | 1 + scripts/install-dirigent-openclaw.mjs | 481 -------------------------- scripts/install.mjs | 247 +++++++++++++ 8 files changed, 266 insertions(+), 501 deletions(-) delete mode 100755 scripts/install-dirigent-openclaw.mjs create mode 100755 scripts/install.mjs diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index e04cbb5..6eb7378 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -27,25 +27,22 @@ The script prints JSON for: You can merge this snippet manually into your `openclaw.json`. -## Installer script (with rollback) - -For production-like install with automatic rollback on error (Node-only installer): +## Installer script ```bash -node ./scripts/install-dirigent-openclaw.mjs --install +node ./scripts/install.mjs --install +# optional port override +node ./scripts/install.mjs --install --no-reply-port 8787 # or wrapper ./scripts/install-dirigent-openclaw.sh --install ``` -Uninstall (revert all recorded config changes): +Uninstall: ```bash -node ./scripts/install-dirigent-openclaw.mjs --uninstall +node ./scripts/install.mjs --uninstall # or wrapper ./scripts/install-dirigent-openclaw.sh --uninstall -# or specify a record explicitly -# RECORD_FILE=~/.openclaw/dirigent-install-records/dirigent-YYYYmmddHHMMSS.json \ -# node ./scripts/install-dirigent-openclaw.mjs --uninstall ``` Environment overrides: @@ -64,12 +61,10 @@ Environment overrides: The script: - writes via `openclaw config set ... --json` -- creates config backup first -- restores backup automatically if any install step fails -- restarts gateway during install, then validates `dirigentway/no-reply` is visible via `openclaw models list/status` -- writes a change record for every install/uninstall: - - directory: `~/.openclaw/dirigent-install-records/` - - latest pointer: `~/.openclaw/dirigent-install-record-latest.json` +- installs plugin + no-reply-api into `~/.openclaw/plugins` +- updates `plugins.entries.dirigent` and `models.providers.` +- supports `--no-reply-port` (also written into `plugins.entries.dirigent.config.noReplyPort`) +- does not maintain install/uninstall record files Policy state semantics: - channel policy file is loaded once into memory on startup diff --git a/package.json b/package.json index f52b91a..cc009cf 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "no-reply-api/", "docs/", - "scripts/install-dirigent-openclaw.mjs", + "scripts/install.mjs", "docker-compose.yml", "Makefile", "README.md", @@ -18,9 +18,9 @@ ], "scripts": { "prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/", - "postinstall": "node scripts/install-dirigent-openclaw.mjs --install", - "uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall", - "update": "node scripts/install-dirigent-openclaw.mjs --update" + "postinstall": "node scripts/install.mjs --install", + "uninstall": "node scripts/install.mjs --uninstall", + "update": "node scripts/install.mjs --update" }, "keywords": [ "openclaw", diff --git a/plugin/core/live-config.ts b/plugin/core/live-config.ts index b778c26..8da04af 100644 --- a/plugin/core/live-config.ts +++ b/plugin/core/live-config.ts @@ -13,6 +13,7 @@ export function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentCo enableDirigentPolicyTool: true, enableDebugLogs: false, debugLogChannelIds: [], + noReplyPort: 8787, schedulingIdentifier: "➡️", waitIdentifier: "👤", ...cfg, diff --git a/plugin/index.ts b/plugin/index.ts index 83483fa..897423e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -58,6 +58,7 @@ export default { enableDirigentPolicyTool: true, schedulingIdentifier: "➡️", waitIdentifier: "👤", + noReplyPort: 8787, ...(api.pluginConfig || {}), } as DirigentConfig & { enableDiscordControlTool: boolean; @@ -89,7 +90,7 @@ export default { api.logger.warn(`dirigent: cannot read parent dir: ${String(e)}`); } - startNoReplyApi(api.logger, pluginDir); + startNoReplyApi(api.logger, pluginDir, Number(live.noReplyPort || 8787)); const live = getLivePluginConfig(api, baseConfig as DirigentConfig); api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`); diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 91cd1d6..ba0c07f 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -20,6 +20,7 @@ "waitIdentifier": { "type": "string", "default": "👤" }, "noReplyProvider": { "type": "string" }, "noReplyModel": { "type": "string" }, + "noReplyPort": { "type": "number", "default": 8787 }, "enableDiscordControlTool": { "type": "boolean", "default": true }, "enableDirigentPolicyTool": { "type": "boolean", "default": true }, "enableDebugLogs": { "type": "boolean", "default": false }, diff --git a/plugin/rules.ts b/plugin/rules.ts index aa1dd64..3750562 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -14,6 +14,7 @@ export type DirigentConfig = { waitIdentifier?: string; noReplyProvider: string; noReplyModel: string; + noReplyPort?: number; /** Discord bot token for the moderator bot (used for turn handoff messages) */ moderatorBotToken?: string; }; diff --git a/scripts/install-dirigent-openclaw.mjs b/scripts/install-dirigent-openclaw.mjs deleted file mode 100755 index 18aeb88..0000000 --- a/scripts/install-dirigent-openclaw.mjs +++ /dev/null @@ -1,481 +0,0 @@ -#!/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, "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)); -} - -function isDirigentRegistered() { - const entry = getJson(PATH_PLUGIN_ENTRY); - return !!(entry && typeof entry === "object"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// INSTALL -// ═══════════════════════════════════════════════════════════════════════════ -if (mode === "install") { - // Check if plugin is already registered in config. - // Prefer record-based uninstall when record exists; otherwise continue with in-place overwrite. - const registered = isDirigentRegistered(); - const existingRecord = findLatestInstallRecord(); - if (registered && existingRecord) { - console.log("[dirigent] existing plugin registration detected (with install record), 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..."); - } else if (registered) { - console.log("[dirigent] existing plugin registration detected (no install record). Proceeding with in-place reinstall..."); - } - - // 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), "no-reply-api"); - if (fs.existsSync(noReplyDir)) { - fs.rmSync(noReplyDir, { recursive: true, force: true }); - console.log(`[dirigent] removed no-reply-api dir: ${noReplyDir}`); - } - // Backward-compat cleanup for older mistaken install path - const legacyNoReplyDir = path.join(path.dirname(installedPluginDir), "dirigent-no-reply-api"); - if (fs.existsSync(legacyNoReplyDir)) { - fs.rmSync(legacyNoReplyDir, { recursive: true, force: true }); - console.log(`[dirigent] removed legacy no-reply-api dir: ${legacyNoReplyDir}`); - } - - 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); - } -} diff --git a/scripts/install.mjs b/scripts/install.mjs new file mode 100755 index 0000000..56d6089 --- /dev/null +++ b/scripts/install.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execFileSync, spawnSync } from "node:child_process"; + +const VALID_MODES = new Set(["--install", "--uninstall", "--update"]); +let modeArg = null; +let argOpenClawDir = null; +let argNoReplyPort = 8787; + +for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (VALID_MODES.has(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("="); + } else if (arg === "--no-reply-port" && i + 1 < process.argv.length) { + argNoReplyPort = Number(process.argv[++i]); + } else if (arg.startsWith("--no-reply-port=")) { + argNoReplyPort = Number(arg.split("=").slice(1).join("=")); + } +} + +if (!modeArg) { + console.error("Usage: node scripts/install.mjs --install|--uninstall|--update [--openclaw-profile-path ] [--no-reply-port ]"); + process.exit(2); +} + +if (!Number.isFinite(argNoReplyPort) || argNoReplyPort < 1 || argNoReplyPort > 65535) { + console.error("[dirigent] invalid --no-reply-port (1-65535)"); + process.exit(2); +} + +const mode = modeArg.slice(2); + +function step(msg) { console.log(`→ ${msg}`); } +function ok(msg) { console.log(`✓ ${msg}`); } +function warn(msg) { console.log(`! ${msg}`); } + +function resolveOpenClawDir() { + if (argOpenClawDir) { + const dir = argOpenClawDir.replace(/^~(?=$|\/)/, os.homedir()); + if (!fs.existsSync(dir)) throw new Error(`--openclaw-profile-path not found: ${dir}`); + return dir; + } + if (process.env.OPENCLAW_DIR) { + const dir = process.env.OPENCLAW_DIR.replace(/^~(?=$|\/)/, os.homedir()); + if (fs.existsSync(dir)) return dir; + warn(`OPENCLAW_DIR not found: ${dir}, fallback to ~/.openclaw`); + } + const fallback = path.join(os.homedir(), ".openclaw"); + if (!fs.existsSync(fallback)) throw new Error("cannot resolve OpenClaw dir"); + return fallback; +} + +const OPENCLAW_DIR = resolveOpenClawDir(); +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 __dirname = path.dirname(new URL(import.meta.url).pathname); +const REPO_ROOT = path.resolve(__dirname, ".."); +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, "no-reply-api"); + +const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigentway"; +const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply"; +const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort); +const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`; +const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token"; +const LIST_MODE = process.env.LIST_MODE || "human-list"; +const HUMAN_LIST_JSON = process.env.HUMAN_LIST_JSON || "[]"; +const AGENT_LIST_JSON = process.env.AGENT_LIST_JSON || "[]"; +const CHANNEL_POLICIES_FILE = process.env.CHANNEL_POLICIES_FILE || path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"); +const CHANNEL_POLICIES_JSON = process.env.CHANNEL_POLICIES_JSON || "{}"; +const END_SYMBOLS_JSON = process.env.END_SYMBOLS_JSON || '["🔚"]'; +const SCHEDULING_IDENTIFIER = process.env.SCHEDULING_IDENTIFIER || "➡️"; + +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"], true); + if (!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], true); +} + +function syncDirRecursive(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + fs.cpSync(src, dest, { recursive: true, force: true }); +} + +function isRegistered() { + const entry = getJson("plugins.entries.dirigent"); + return !!(entry && typeof entry === "object"); +} + +if (mode === "update") { + const branch = process.env.DIRIGENT_GIT_BRANCH || "latest"; + step(`update source branch=${branch}`); + execFileSync("git", ["fetch", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" }); + execFileSync("git", ["checkout", branch], { cwd: REPO_ROOT, stdio: "inherit" }); + execFileSync("git", ["pull", "origin", branch], { cwd: REPO_ROOT, stdio: "inherit" }); + ok("source updated"); + + const script = path.join(REPO_ROOT, "scripts", "install.mjs"); + const args = [script, "--install", "--openclaw-profile-path", OPENCLAW_DIR, "--no-reply-port", String(NO_REPLY_PORT)]; + const ret = spawnSync(process.execPath, args, { cwd: REPO_ROOT, stdio: "inherit", env: process.env }); + process.exit(ret.status ?? 1); +} + +if (mode === "install") { + step(`OpenClaw dir: ${OPENCLAW_DIR}`); + + if (isRegistered()) { + warn("plugins.entries.dirigent exists; reinstalling in-place"); + } + + step("build dist assets"); + const pluginSrc = path.resolve(REPO_ROOT, "plugin"); + const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api"); + const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent"); + const distNoReply = path.resolve(REPO_ROOT, "dist", "no-reply-api"); + syncDirRecursive(pluginSrc, distPlugin); + syncDirRecursive(noReplySrc, distNoReply); + + step(`install plugin -> ${PLUGIN_INSTALL_DIR}`); + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR); + syncDirRecursive(distNoReply, NO_REPLY_INSTALL_DIR); + + if (!fs.existsSync(CHANNEL_POLICIES_FILE)) { + fs.mkdirSync(path.dirname(CHANNEL_POLICIES_FILE), { recursive: true }); + fs.writeFileSync(CHANNEL_POLICIES_FILE, `${CHANNEL_POLICIES_JSON}\n`); + ok(`init channel policies file: ${CHANNEL_POLICIES_FILE}`); + } + + const plugins = getJson("plugins") || {}; + const loadPaths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : []; + if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) loadPaths.push(PLUGIN_INSTALL_DIR); + plugins.load = plugins.load || {}; + plugins.load.paths = loadPaths; + + plugins.entries = plugins.entries || {}; + plugins.entries.dirigent = { + 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, + noReplyPort: NO_REPLY_PORT, + }, + }; + setJson("plugins", plugins); + + const providers = getJson("models.providers") || {}; + providers[NO_REPLY_PROVIDER_ID] = { + 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, + }, + ], + }; + setJson("models.providers", providers); + + const allow = getJson("plugins.allow") || []; + if (!allow.includes("dirigent")) { + allow.push("dirigent"); + setJson("plugins.allow", allow); + } + + ok(`installed (no-reply port: ${NO_REPLY_PORT})`); + console.log("↻ restart gateway: openclaw gateway restart"); + process.exit(0); +} + +if (mode === "uninstall") { + step(`OpenClaw dir: ${OPENCLAW_DIR}`); + + const allow = getJson("plugins.allow") || []; + const idx = allow.indexOf("dirigent"); + if (idx >= 0) { + allow.splice(idx, 1); + setJson("plugins.allow", allow); + ok("removed from plugins.allow"); + } + + unsetPath("plugins.entries.dirigent"); + ok("removed plugins.entries.dirigent"); + + const plugins = getJson("plugins") || {}; + const paths = Array.isArray(plugins?.load?.paths) ? plugins.load.paths : []; + plugins.load = plugins.load || {}; + plugins.load.paths = paths.filter((p) => p !== PLUGIN_INSTALL_DIR); + setJson("plugins", plugins); + ok("removed plugin path from plugins.load.paths"); + + const providers = getJson("models.providers") || {}; + delete providers[NO_REPLY_PROVIDER_ID]; + setJson("models.providers", providers); + ok(`removed provider ${NO_REPLY_PROVIDER_ID}`); + + if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true }); + if (fs.existsSync(NO_REPLY_INSTALL_DIR)) fs.rmSync(NO_REPLY_INSTALL_DIR, { recursive: true, force: true }); + const legacyNoReply = path.join(PLUGINS_DIR, "dirigent-no-reply-api"); + if (fs.existsSync(legacyNoReply)) fs.rmSync(legacyNoReply, { recursive: true, force: true }); + ok("removed installed files"); + + console.log("↻ restart gateway: openclaw gateway restart"); + process.exit(0); +}