refactor(installer): rename to install.mjs, remove record/restore flow, add --no-reply-port and wire config port

This commit is contained in:
2026-03-08 06:38:33 +00:00
parent 7640e80373
commit 4d50826f2a
8 changed files with 266 additions and 501 deletions

View File

@@ -27,25 +27,22 @@ The script prints JSON for:
You can merge this snippet manually into your `openclaw.json`. You can merge this snippet manually into your `openclaw.json`.
## Installer script (with rollback) ## Installer script
For production-like install with automatic rollback on error (Node-only installer):
```bash ```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 # or wrapper
./scripts/install-dirigent-openclaw.sh --install ./scripts/install-dirigent-openclaw.sh --install
``` ```
Uninstall (revert all recorded config changes): Uninstall:
```bash ```bash
node ./scripts/install-dirigent-openclaw.mjs --uninstall node ./scripts/install.mjs --uninstall
# or wrapper # or wrapper
./scripts/install-dirigent-openclaw.sh --uninstall ./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: Environment overrides:
@@ -64,12 +61,10 @@ Environment overrides:
The script: The script:
- writes via `openclaw config set ... --json` - writes via `openclaw config set ... --json`
- creates config backup first - installs plugin + no-reply-api into `~/.openclaw/plugins`
- restores backup automatically if any install step fails - updates `plugins.entries.dirigent` and `models.providers.<no-reply-provider>`
- restarts gateway during install, then validates `dirigentway/no-reply` is visible via `openclaw models list/status` - supports `--no-reply-port` (also written into `plugins.entries.dirigent.config.noReplyPort`)
- writes a change record for every install/uninstall: - does not maintain install/uninstall record files
- directory: `~/.openclaw/dirigent-install-records/`
- latest pointer: `~/.openclaw/dirigent-install-record-latest.json`
Policy state semantics: Policy state semantics:
- channel policy file is loaded once into memory on startup - channel policy file is loaded once into memory on startup

View File

@@ -9,7 +9,7 @@
"no-reply-api/", "no-reply-api/",
"docs/", "docs/",
"scripts/install-dirigent-openclaw.mjs", "scripts/install.mjs",
"docker-compose.yml", "docker-compose.yml",
"Makefile", "Makefile",
"README.md", "README.md",
@@ -18,9 +18,9 @@
], ],
"scripts": { "scripts": {
"prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/", "prepare": "mkdir -p dist/dirigent && cp -r plugin/* dist/dirigent/",
"postinstall": "node scripts/install-dirigent-openclaw.mjs --install", "postinstall": "node scripts/install.mjs --install",
"uninstall": "node scripts/install-dirigent-openclaw.mjs --uninstall", "uninstall": "node scripts/install.mjs --uninstall",
"update": "node scripts/install-dirigent-openclaw.mjs --update" "update": "node scripts/install.mjs --update"
}, },
"keywords": [ "keywords": [
"openclaw", "openclaw",

View File

@@ -13,6 +13,7 @@ export function getLivePluginConfig(api: OpenClawPluginApi, fallback: DirigentCo
enableDirigentPolicyTool: true, enableDirigentPolicyTool: true,
enableDebugLogs: false, enableDebugLogs: false,
debugLogChannelIds: [], debugLogChannelIds: [],
noReplyPort: 8787,
schedulingIdentifier: "➡️", schedulingIdentifier: "➡️",
waitIdentifier: "👤", waitIdentifier: "👤",
...cfg, ...cfg,

View File

@@ -58,6 +58,7 @@ export default {
enableDirigentPolicyTool: true, enableDirigentPolicyTool: true,
schedulingIdentifier: "➡️", schedulingIdentifier: "➡️",
waitIdentifier: "👤", waitIdentifier: "👤",
noReplyPort: 8787,
...(api.pluginConfig || {}), ...(api.pluginConfig || {}),
} as DirigentConfig & { } as DirigentConfig & {
enableDiscordControlTool: boolean; enableDiscordControlTool: boolean;
@@ -89,7 +90,7 @@ export default {
api.logger.warn(`dirigent: cannot read parent dir: ${String(e)}`); 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); const live = getLivePluginConfig(api, baseConfig as DirigentConfig);
api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`); api.logger.info(`dirigent: config loaded, moderatorBotToken=${live.moderatorBotToken ? "[set]" : "[not set]"}`);

View File

@@ -20,6 +20,7 @@
"waitIdentifier": { "type": "string", "default": "👤" }, "waitIdentifier": { "type": "string", "default": "👤" },
"noReplyProvider": { "type": "string" }, "noReplyProvider": { "type": "string" },
"noReplyModel": { "type": "string" }, "noReplyModel": { "type": "string" },
"noReplyPort": { "type": "number", "default": 8787 },
"enableDiscordControlTool": { "type": "boolean", "default": true }, "enableDiscordControlTool": { "type": "boolean", "default": true },
"enableDirigentPolicyTool": { "type": "boolean", "default": true }, "enableDirigentPolicyTool": { "type": "boolean", "default": true },
"enableDebugLogs": { "type": "boolean", "default": false }, "enableDebugLogs": { "type": "boolean", "default": false },

View File

@@ -14,6 +14,7 @@ export type DirigentConfig = {
waitIdentifier?: string; waitIdentifier?: string;
noReplyProvider: string; noReplyProvider: string;
noReplyModel: string; noReplyModel: string;
noReplyPort?: number;
/** Discord bot token for the moderator bot (used for turn handoff messages) */ /** Discord bot token for the moderator bot (used for turn handoff messages) */
moderatorBotToken?: string; moderatorBotToken?: string;
}; };

View File

@@ -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 <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 <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.<providerId> ─────────────────────────────────────
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);
}
}

247
scripts/install.mjs Executable file
View File

@@ -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 <path>] [--no-reply-port <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);
}