#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { execFileSync } from "node:child_process"; // === Skill merge utilities === function extractGuildTable(skillMdContent) { const tableMatch = skillMdContent.match(/\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/); if (!tableMatch) return null; const lines = tableMatch[0].split("\n"); const dataRows = []; for (const line of lines) { if (line.includes("guild-id") && line.includes("description")) continue; if (/^\|[-\s|]+\|$/.test(line)) continue; const match = line.match(/^\| \s*(\d+) \s*\| \s*(.+?) \s*\|$/); if (match) dataRows.push({ guildId: match[1].trim(), description: match[2].trim() }); } return dataRows; } function buildGuildTable(rows) { if (rows.length === 0) return "| guild-id | description |\n|----------|-------------|"; const header = "| guild-id | description |\n|----------|-------------|"; const dataLines = rows.map(r => `| ${r.guildId} | ${r.description} |`).join("\n"); return `${header}\n${dataLines}`; } function mergeGuildTables(existingRows, newRows) { const seen = new Set(); const merged = []; for (const row of existingRows) { if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); } } for (const row of newRows) { if (!seen.has(row.guildId)) { seen.add(row.guildId); merged.push(row); } } return merged; } function installSkillWithMerge(skillName, pluginSkillDir, openClawSkillsDir) { const targetSkillDir = path.join(openClawSkillsDir, skillName); const sourceSkillMd = path.join(pluginSkillDir, "SKILL.md"); if (fs.existsSync(targetSkillDir)) { const existingSkillMd = path.join(targetSkillDir, "SKILL.md"); if (fs.existsSync(existingSkillMd) && fs.existsSync(sourceSkillMd)) { const existingContent = fs.readFileSync(existingSkillMd, "utf8"); const newContent = fs.readFileSync(sourceSkillMd, "utf8"); const existingRows = extractGuildTable(existingContent) || []; const newRows = extractGuildTable(newContent) || []; if (existingRows.length > 0 || newRows.length > 0) { const mergedRows = mergeGuildTables(existingRows, newRows); const mergedTable = buildGuildTable(mergedRows); const finalContent = newContent.replace( /\| guild-id \| description \|[\s\S]*?(?=\n## |\n# |\n*$)/, mergedTable ); fs.rmSync(targetSkillDir, { recursive: true, force: true }); fs.mkdirSync(targetSkillDir, { recursive: true }); fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true }); fs.writeFileSync(path.join(targetSkillDir, "SKILL.md"), finalContent, "utf8"); return { merged: true, rowCount: mergedRows.length }; } } } fs.mkdirSync(openClawSkillsDir, { recursive: true }); fs.cpSync(pluginSkillDir, targetSkillDir, { recursive: true, force: true }); return { merged: false }; } // === End skill merge utilities === 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 [--openclaw-profile-path ] [--no-reply-port ]"); process.exit(2); } const mode = modeArg.slice(2); const C = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m" }; function color(t, c = "reset") { return `${C[c] || ""}${t}${C.reset}`; } function title(t) { console.log(color(`\n[dirigent] ${t}`, "cyan")); } function step(n, total, msg) { console.log(color(`[${n}/${total}] ${msg}`, "blue")); } function ok(msg) { console.log(color(`\t✓ ${msg}`, "green")); } 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; } 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 __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 SKILLS_DIR = path.join(OPENCLAW_DIR, "skills"); const PLUGIN_SKILLS_DIR = path.join(REPO_ROOT, "skills"); const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent"; 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}/no-reply/v1`; const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token"; 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 syncDirRecursive(src, dest) { fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true, force: true }); } if (mode === "install") { title("Install Dirigent with Skills"); step(1, 7, "build dist assets"); const pluginSrc = path.resolve(REPO_ROOT, "plugin"); const sidecarSrc = path.resolve(REPO_ROOT, "services"); const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent"); fs.rmSync(distPlugin, { recursive: true, force: true }); syncDirRecursive(pluginSrc, distPlugin); syncDirRecursive(sidecarSrc, path.join(distPlugin, "services")); ok("dist assets built"); step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR); ok("plugin files installed"); step(3, 7, "install skills with merge"); if (fs.existsSync(PLUGIN_SKILLS_DIR)) { const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => { const full = path.join(PLUGIN_SKILLS_DIR, d); return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, "SKILL.md")); }); for (const skillName of skills) { const pluginSkillDir = path.join(PLUGIN_SKILLS_DIR, skillName); const result = installSkillWithMerge(skillName, pluginSkillDir, SKILLS_DIR); if (result.merged) { ok(`skill ${skillName} installed (merged ${result.rowCount} guild entries)`); } else { ok(`skill ${skillName} installed`); } } } step(4, 7, "configure plugin entry"); // Plugin load path — safe to read/write (not sensitive) const loadPaths = getJson("plugins.load.paths") || []; if (!loadPaths.includes(PLUGIN_INSTALL_DIR)) { loadPaths.push(PLUGIN_INSTALL_DIR); setJson("plugins.load.paths", loadPaths); } // For each config field: only write the default if the field has no value. // Sensitive fields (e.g. moderatorBotToken) are never touched — user sets them manually. // `getJson` returns undefined if the field is unset; __OPENCLAW_REDACTED__ counts as "set". function setIfMissing(pathKey, defaultVal) { const existing = getJson(pathKey); if (existing === undefined || existing === null) setJson(pathKey, defaultVal); } setIfMissing("plugins.entries.dirigent.enabled", true); const cp = "plugins.entries.dirigent.config"; setIfMissing(`${cp}.scheduleIdentifier`, "➡️"); setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID); setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID); setIfMissing(`${cp}.sideCarPort`, NO_REPLY_PORT); // moderatorBotToken: intentionally not touched — set manually via: // openclaw config set plugins.entries.dirigent.config.moderatorBotToken "" ok("plugin configured"); step(5, 7, "configure no-reply provider"); 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); ok("provider configured"); step(6, 7, "add no-reply model to allowlist"); const agentsDefaultsModels = getJson("agents.defaults.models") || {}; agentsDefaultsModels[`${NO_REPLY_PROVIDER_ID}/${NO_REPLY_MODEL_ID}`] = {}; setJson("agents.defaults.models", agentsDefaultsModels); ok("model allowlisted"); step(7, 7, "enable plugin in allowlist"); 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") { title("Uninstall"); step(1, 4, "remove allowlist + plugin entry"); 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"); } runOpenclaw(["config", "unset", "plugins.entries.dirigent"], true); ok("removed plugin entry"); step(2, 4, "remove plugin load path"); 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 load path"); step(3, 4, "remove installed files"); if (fs.existsSync(PLUGIN_INSTALL_DIR)) fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true, force: true }); ok("removed plugin files"); step(4, 4, "remove skills"); if (fs.existsSync(PLUGIN_SKILLS_DIR)) { const skills = fs.readdirSync(PLUGIN_SKILLS_DIR).filter(d => fs.statSync(path.join(PLUGIN_SKILLS_DIR, d)).isDirectory()); for (const skillName of skills) { const targetSkillDir = path.join(SKILLS_DIR, skillName); if (fs.existsSync(targetSkillDir)) { fs.rmSync(targetSkillDir, { recursive: true, force: true }); ok(`removed skill ${skillName}`); } } } console.log("↻ restart gateway: openclaw gateway restart"); process.exit(0); } console.error("Unknown mode:", mode); process.exit(2);