#!/usr/bin/env node
/**
* Dialectic.OpenclawPlugin installer (modeled on Fabric's install.mjs).
*
* node scripts/install.mjs --install build + install + configure
* node scripts/install.mjs --build-only build only
* node scripts/install.mjs --uninstall remove plugin + config
*
* Flags:
* --skip-check skip Node version check
* --verbose / -v verbose build output
* --openclaw-profile-path
override ~/.openclaw target
* --backend-url seed channels.dialectic / plugin
* backendUrl config (default left unset
* so the manifest's default applies)
*/
import { execSync } from 'child_process';
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const PLUGIN_ID = 'dialectic';
// tsconfig.plugin.json emits here. Build artifacts NEVER live alongside
// .ts sources — this avoids jiti picking a stale .js when the .ts has
// been updated (the bug that silently shipped wrong code several times
// during the sim e2e). See tsconfig.plugin.json comment block.
const DIST_DIR = join(__dirname, 'dist', PLUGIN_ID);
const args = process.argv.slice(2);
const opt = {
install: args.includes('--install'),
buildOnly: args.includes('--build-only'),
skipCheck: args.includes('--skip-check'),
verbose: args.includes('--verbose') || args.includes('-v'),
uninstall: args.includes('--uninstall'),
};
const pIdx = args.indexOf('--openclaw-profile-path');
const profileOverride = pIdx !== -1 && args[pIdx + 1] ? resolve(args[pIdx + 1]) : null;
const bIdx = args.indexOf('--backend-url');
const backendUrl = bIdx !== -1 && args[bIdx + 1] ? args[bIdx + 1] : null;
function openclawPath() {
if (profileOverride) return profileOverride;
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
return join(homedir(), '.openclaw');
}
const c = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
const log = (m, k = 'reset') => console.log(`${c[k]}${m}${c.reset}`);
const step = (n, t, m) => log(`[${n}/${t}] ${m}`, 'cyan');
const ok = (m) => log(` ✓ ${m}`, 'green');
const warn = (m) => log(` ⚠ ${m}`, 'yellow');
const err = (m) => log(` ✗ ${m}`, 'red');
function exec(cmd, o = {}) {
return execSync(cmd, {
cwd: __dirname,
stdio: o.silent ? 'pipe' : 'inherit',
encoding: 'utf8',
...o,
});
}
function cfgGet(key, def) {
try {
const out = exec(
`openclaw config get ${key} --json 2>/dev/null || echo undefined`,
{ silent: true },
).trim();
return out === 'undefined' || out === '' ? def : JSON.parse(out);
} catch {
return def;
}
}
function cfgSet(key, val) {
exec(`openclaw config set ${key} '${JSON.stringify(val)}' --json`, { silent: true });
}
function cfgUnset(key) {
try {
exec(`openclaw config unset ${key}`, { silent: true });
} catch {
/* ignore */
}
}
function copyDir(src, dest) {
mkdirSync(dest, { recursive: true });
for (const e of readdirSync(src, { withFileTypes: true })) {
if (e.name === 'node_modules') continue;
const s = join(src, e.name);
const d = join(dest, e.name);
e.isDirectory() ? copyDir(s, d) : copyFileSync(s, d);
}
}
function detect() {
step(1, 5, 'Detecting environment...');
let node = null;
try {
node = exec('node --version', { silent: true }).trim();
ok(`Node ${node}`);
} catch {
err('Node not found');
}
try {
ok(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`);
} catch {
warn('openclaw CLI not in PATH');
}
return { node };
}
function checkDeps(env) {
if (opt.skipCheck) return;
step(2, 5, 'Checking dependencies...');
if (!env.node || parseInt(env.node.slice(1), 10) < 18) {
err('Node 18+ required');
process.exit(1);
}
ok('deps OK');
}
function build() {
step(3, 5, 'Building plugin...');
// Wipe dist first so the build never silently mixes new + stale files.
rmSync(join(__dirname, 'dist'), { recursive: true, force: true });
exec('npm install', { silent: !opt.verbose });
exec('npm run build', { silent: !opt.verbose });
if (!existsSync(join(DIST_DIR, 'index.js'))) {
throw new Error(`build produced no ${join('dist', PLUGIN_ID, 'index.js')}`);
}
ok(`compiled -> dist/${PLUGIN_ID}`);
}
function clearInstall(base) {
const dest = join(base, 'plugins', PLUGIN_ID);
if (existsSync(dest)) {
rmSync(dest, { recursive: true, force: true });
ok(`removed ${dest}`);
}
}
function cleanupConfig(base) {
const dest = join(base, 'plugins', PLUGIN_ID);
const allow = cfgGet('plugins.allow', []);
if (Array.isArray(allow) && allow.includes(PLUGIN_ID)) {
cfgSet('plugins.allow', allow.filter((x) => x !== PLUGIN_ID));
ok('removed from plugins.allow');
}
const paths = cfgGet('plugins.load.paths', []);
if (Array.isArray(paths) && paths.includes(dest)) {
cfgSet('plugins.load.paths', paths.filter((x) => x !== dest));
ok('removed from plugins.load.paths');
}
cfgUnset(`plugins.entries.${PLUGIN_ID}`);
ok('removed plugin entry');
}
function install() {
step(4, 5, 'Installing...');
const base = openclawPath();
const dest = join(base, 'plugins', PLUGIN_ID);
log(` OpenClaw path: ${base}`, 'blue');
if (existsSync(dest)) {
warn('existing install -> replacing');
clearInstall(base);
}
mkdirSync(dirname(dest), { recursive: true });
// Only dist/dialectic/ ships — pure compiled JS + no .ts left behind
// to confuse jiti's extension resolver. Manifest + package.json are
// copied explicitly because tsc doesn't touch non-.ts files.
copyDir(DIST_DIR, dest);
copyFileSync(
join(__dirname, 'plugin', 'openclaw.plugin.json'),
join(dest, 'openclaw.plugin.json'),
);
copyFileSync(
join(__dirname, 'plugin', 'package.json'),
join(dest, 'package.json'),
);
ok(`plugin files -> ${dest}`);
exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose });
ok('runtime deps installed');
return { base, dest };
}
function configure(base, dest) {
step(5, 5, 'Configuring OpenClaw...');
const paths = cfgGet('plugins.load.paths', []);
if (Array.isArray(paths) && !paths.includes(dest)) {
cfgSet('plugins.load.paths', [...paths, dest]);
}
ok(`plugins.load.paths includes ${dest}`);
const allow = cfgGet('plugins.allow', []);
if (Array.isArray(allow) && !allow.includes(PLUGIN_ID)) {
cfgSet('plugins.allow', [...allow, PLUGIN_ID]);
}
ok(`plugins.allow includes ${PLUGIN_ID}`);
if (cfgGet(`plugins.entries.${PLUGIN_ID}.enabled`, undefined) === undefined) {
cfgSet(`plugins.entries.${PLUGIN_ID}.enabled`, true);
}
ok('plugin entry configured');
// Seed backendUrl only when explicitly requested via --backend-url.
// Otherwise the manifest's default (https://dialectic-api.hangman-lab.top)
// applies, and operators can override later via:
// openclaw config set plugins.entries.dialectic.config.backendUrl
if (backendUrl) {
cfgSet(`plugins.entries.${PLUGIN_ID}.config.backendUrl`, backendUrl);
ok(`backendUrl = ${backendUrl}`);
}
}
function main() {
console.log('');
log('Dialectic.OpenclawPlugin installer', 'cyan');
console.log('');
try {
const env = detect();
if (opt.uninstall) {
const base = openclawPath();
clearInstall(base);
cleanupConfig(base);
log('\nRun: openclaw gateway restart', 'yellow');
return;
}
checkDeps(env);
build();
if (opt.buildOnly) {
log('\nbuild-only — not installed.', 'yellow');
return;
}
const { base, dest } = install();
configure(base, dest);
console.log('');
log('Install complete. Next:', 'blue');
log(' 1. Provision agent api key (one per agent):', 'cyan');
log(' AGENT_ID= AGENT_WORKSPACE= AGENT_VERIFY=… \\', 'cyan');
log(' secret-mgr set --key dialectic-agent-apikey --secret ', 'cyan');
log(' (raw key minted via the backend admin endpoint, see', 'cyan');
log(' skills/dialectic-hangman-lab/dialectic-ctrl)', 'cyan');
log(' 2. openclaw gateway restart', 'cyan');
log(' 3. (sim/test only) set DIALECTIC_PLUGIN_BYPASS_HF=1 in the', 'cyan');
log(' gateway env to skip the HF on_call coverage check.', 'cyan');
console.log('');
} catch (e) {
log(`\nInstall failed: ${e.message}`, 'red');
process.exit(1);
}
}
main();