refactor: remove turn tools, rename discord tools, rewrite installer

- 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)
This commit is contained in:
zhi
2026-03-07 17:24:36 +00:00
parent 7b93db3ed9
commit e4454bfc1a
7 changed files with 214 additions and 168 deletions

View File

@@ -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}`);