install-skills.js
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// install-skills.js — copy the bundled OpenVoiceUI agent skills (./skills) into
// OpenClaw's managed skills directory, under a subfolder this script owns:
//
// <openclaw state dir>/skills/openvoiceui/<skill>/SKILL.md
//
// OpenClaw scans <state dir>/skills (default ~/.openclaw/skills) on its own, including
// one level of grouping folders, so no openclaw.json change is needed.
//
// Who uses this:
// - Native installs (setup-sudo.sh runs it; re-run it after `git pull` to update).
// - Anyone running OpenClaw outside Docker who wants the skills.
// Docker / Pinokio / npm CLI installs do NOT copy anything: docker-compose mounts ./skills
// read-only at /root/.openclaw/skills/openvoiceui, so `git pull` updates them.
// Pinokio's install and update steps call it with --check to validate the bundle.
//
// Update rules (safe to re-run any number of times):
// - new file -> copied
// - unchanged since the last install (hash matches the install manifest) -> updated
// - edited locally -> KEPT, with a warning (use --force to overwrite; the local
// copy is saved next to it as <file>.local-<timestamp> first)
// - nothing is ever deleted; skills that are no longer bundled are left in place.
// Your own skills belong in <workspace>/skills/<name>/ or ~/.openclaw/skills/<name>/,
// never inside the openvoiceui/ subfolder. See docs/SKILLS.md.
//
// Usage:
// node install-skills.js [--target DIR] [--dry-run] [--force] [--quiet]
// node install-skills.js --check [--mountpoint DIR]
// --check validates the bundle only; --mountpoint also creates the (empty) Docker mountpoint
// dir, e.g. openclaw-data/skills/openvoiceui, so Docker does not create it root-owned.
//
// Exit codes: 0 ok · 1 install error · 2 invalid bundle · 64 bad arguments
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
const BUNDLE_DIR = path.resolve(__dirname, "skills");
const SUBDIR = "openvoiceui";
const MANIFEST = ".openvoiceui-skills.json";
const MAX_SKILL_MD_BYTES = 256000; // OpenClaw's default skills.limits.maxSkillFileBytes
function parseArgs(argv) {
const opts = { target: "", mountpoint: "", dryRun: false, force: false, check: false, quiet: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--dry-run") opts.dryRun = true;
else if (a === "--force") opts.force = true;
else if (a === "--check") opts.check = true;
else if (a === "--quiet") opts.quiet = true;
else if (a === "--target") opts.target = argv[++i] || "";
else if (a.startsWith("--target=")) opts.target = a.slice("--target=".length);
else if (a === "--mountpoint") opts.mountpoint = argv[++i] || "";
else if (a === "-h" || a === "--help") opts.help = true;
else { opts.bad = a; }
}
return opts;
}
function expandHome(p, home) {
if (p === "~") return home;
if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(home, p.slice(2));
return p;
}
// Mirrors OpenClaw's state-dir resolution: OPENCLAW_STATE_DIR, else the directory of
// OPENCLAW_CONFIG_PATH, else <OPENCLAW_HOME or OS home>/.openclaw.
function resolveStateDir(env) {
const osHome = os.homedir();
const home = env.OPENCLAW_HOME && env.OPENCLAW_HOME.trim()
? path.resolve(expandHome(env.OPENCLAW_HOME.trim(), osHome))
: osHome;
if (env.OPENCLAW_STATE_DIR && env.OPENCLAW_STATE_DIR.trim()) {
return path.resolve(expandHome(env.OPENCLAW_STATE_DIR.trim(), home));
}
if (env.OPENCLAW_CONFIG_PATH && env.OPENCLAW_CONFIG_PATH.trim()) {
return path.dirname(path.resolve(expandHome(env.OPENCLAW_CONFIG_PATH.trim(), home)));
}
return path.join(home, ".openclaw");
}
function defaultTarget(env) {
return path.join(resolveStateDir(env), "skills", SUBDIR);
}
function sha256(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
// Every regular file under dir, as POSIX-style relative paths. Symlinks are rejected:
// OpenClaw refuses skill paths that resolve outside their root, so the bundle must be real files.
function listFiles(dir, rel = "", out = [], problems = []) {
for (const ent of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
if (ent.name === "__pycache__" || ent.name === ".DS_Store") continue;
const r = rel ? rel + "/" + ent.name : ent.name;
if (ent.isSymbolicLink()) problems.push(`${r}: symlinks are not allowed in the bundle`);
else if (ent.isDirectory()) listFiles(dir, r, out, problems);
else if (ent.isFile()) out.push(r);
}
return out;
}
function readFrontmatter(text) {
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text);
if (!m) return null;
const fm = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
if (kv) fm[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();
}
return fm;
}
// Validate the bundle. Returns { skills: [names], problems: [strings] }.
function validateBundle(bundleDir) {
const problems = [];
if (!fs.existsSync(bundleDir) || !fs.statSync(bundleDir).isDirectory()) {
return { skills: [], problems: [`bundle directory not found: ${bundleDir}`] };
}
const skills = [];
for (const ent of fs.readdirSync(bundleDir, { withFileTypes: true })) {
if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "__pycache__") continue;
const md = path.join(bundleDir, ent.name, "SKILL.md");
if (!fs.existsSync(md)) { problems.push(`${ent.name}/: missing SKILL.md`); continue; }
const size = fs.statSync(md).size;
if (size > MAX_SKILL_MD_BYTES) problems.push(`${ent.name}/SKILL.md: ${size} bytes exceeds ${MAX_SKILL_MD_BYTES}`);
const fm = readFrontmatter(fs.readFileSync(md, "utf8"));
if (!fm) { problems.push(`${ent.name}/SKILL.md: no YAML frontmatter`); continue; }
if (fm.name !== ent.name) problems.push(`${ent.name}/SKILL.md: frontmatter name "${fm.name || ""}" must equal the folder name`);
if (!fm.description) problems.push(`${ent.name}/SKILL.md: frontmatter description is empty`);
skills.push(ent.name);
}
listFiles(bundleDir, "", [], problems);
if (skills.length === 0) problems.push("no skills found in bundle");
return { skills: skills.sort(), problems };
}
function readManifest(target) {
try {
const m = JSON.parse(fs.readFileSync(path.join(target, MANIFEST), "utf8"));
return m && typeof m.files === "object" && m.files ? m : { files: {} };
} catch {
return { files: {} };
}
}
function sameDir(a, b) {
try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return false; }
}
function copyAtomic(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
const tmp = `${dest}.tmp-${process.pid}`;
fs.copyFileSync(src, tmp);
fs.chmodSync(tmp, fs.statSync(src).mode & 0o777);
fs.renameSync(tmp, dest);
}
function install({ bundleDir = BUNDLE_DIR, target, dryRun = false, force = false, log = console.log }) {
const result = { added: [], updated: [], unchanged: [], kept: [], forced: [], retired: [] };
if (sameDir(bundleDir, target)) {
log(` Target is the bundle itself (${target}); nothing to copy.`);
return result;
}
const manifest = readManifest(target);
const files = listFiles(bundleDir).sort();
const newHashes = Object.assign({}, manifest.files);
const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
for (const rel of files) {
const src = path.join(bundleDir, rel);
const dest = path.join(target, rel);
const srcHash = sha256(src);
if (!fs.existsSync(dest)) {
result.added.push(rel);
if (!dryRun) copyAtomic(src, dest);
newHashes[rel] = srcHash;
continue;
}
const destHash = sha256(dest);
if (destHash === srcHash) { result.unchanged.push(rel); newHashes[rel] = srcHash; continue; }
if (manifest.files[rel] && destHash === manifest.files[rel]) {
result.updated.push(rel);
if (!dryRun) copyAtomic(src, dest);
newHashes[rel] = srcHash;
continue;
}
// Locally edited (or a pre-existing file this script never installed).
if (force) {
result.forced.push(rel);
if (!dryRun) {
fs.copyFileSync(dest, `${dest}.local-${stamp}`);
copyAtomic(src, dest);
}
newHashes[rel] = srcHash;
} else {
result.kept.push(rel);
}
}
const bundled = new Set(files);
for (const rel of Object.keys(manifest.files)) {
if (!bundled.has(rel) && fs.existsSync(path.join(target, rel))) result.retired.push(rel);
}
if (!dryRun) {
fs.mkdirSync(target, { recursive: true });
const out = {
note: "Managed by OpenVoiceUI install-skills.js. Files here are replaced on update " +
"unless you edited them. Put your own skills in ../<name>/ or <workspace>/skills/, not here.",
version: 1,
installed_at: new Date().toISOString(),
files: newHashes,
};
fs.writeFileSync(path.join(target, MANIFEST), JSON.stringify(out, null, 2) + "\n");
}
return result;
}
function main() {
const opts = parseArgs(process.argv.slice(2));
const log = opts.quiet ? () => {} : console.log;
if (opts.help || opts.bad) {
if (opts.bad) console.error(`Unknown argument: ${opts.bad}`);
console.log("Usage: node install-skills.js [--target DIR] [--dry-run] [--force] [--quiet]\n" +
" node install-skills.js --check [--mountpoint DIR]");
process.exit(opts.bad ? 64 : 0);
}
const { skills, problems } = validateBundle(BUNDLE_DIR);
if (problems.length) {
console.error(" Bundled skills are invalid:");
for (const p of problems) console.error(` - ${p}`);
process.exit(2);
}
if (opts.check) {
log(` Bundled OpenVoiceUI skills OK (${skills.length}): ${skills.join(", ")}`);
if (opts.mountpoint) {
fs.mkdirSync(path.resolve(opts.mountpoint), { recursive: true });
log(` Mountpoint ready: ${opts.mountpoint}`);
}
log(" Docker installs mount them read-only at /root/.openclaw/skills/openvoiceui");
return;
}
const target = path.resolve(opts.target ? expandHome(opts.target, os.homedir()) : defaultTarget(process.env));
log(` ${opts.dryRun ? "[dry run] " : ""}Installing ${skills.length} OpenVoiceUI skills into ${target}`);
let r;
try {
r = install({ target, dryRun: opts.dryRun, force: opts.force, log });
} catch (e) {
console.error(` Skill install failed: ${e.message}`);
process.exit(1);
}
for (const f of r.added) log(` + ${f}`);
for (const f of r.updated) log(` ~ ${f}`);
for (const f of r.forced) log(` ! ${f} (overwritten; your copy saved as ${path.basename(f)}.local-*)`);
for (const f of r.kept) console.warn(` = ${f} (kept: edited locally; re-run with --force to replace it)`);
for (const f of r.retired) console.warn(` - ${f} (no longer bundled; left in place)`);
log(` Done: ${r.added.length} added, ${r.updated.length} updated, ${r.unchanged.length} unchanged, ` +
`${r.kept.length} kept, ${r.forced.length} forced, ${r.retired.length} retired.`);
if (!opts.dryRun) log(" OpenClaw picks up skill changes automatically (skills watcher), or on its next restart.");
}
if (require.main === module) main();
module.exports = { install, validateBundle, resolveStateDir, defaultTarget, BUNDLE_DIR, SUBDIR, MANIFEST };