feat: add discord-guilds skill with table merge on install

This commit is contained in:
2026-04-05 18:48:10 +00:00
parent 895cfe3bab
commit c9bed19689
3 changed files with 235 additions and 200 deletions

View File

@@ -2,7 +2,75 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync, spawnSync } from "node:child_process";
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;
@@ -11,46 +79,24 @@ 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 (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) {
fail("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) {
fail("invalid --no-reply-port (1-65535)");
console.error("Usage: node scripts/install.mjs --install|--uninstall [--openclaw-profile-path <path>] [--no-reply-port <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",
};
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 warn(msg) { console.log(color(`\t${msg}`, "yellow")); }
function fail(msg) { console.log(color(`\t${msg}`, "red")); }
function resolveOpenClawDir() {
if (argOpenClawDir) {
@@ -58,218 +104,127 @@ function resolveOpenClawDir() {
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)) {
fail(`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(PLUGIN_INSTALL_DIR, "no-reply-api");
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}/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;
}
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 isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function setJson(pathKey, value) { runOpenclaw(["config", "set", pathKey, JSON.stringify(value), "--json"]); }
function isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
function mergePreservingExisting(base, updates) {
if (!isPlainObject(updates)) return updates;
const out = isPlainObject(base) ? { ...base } : {};
for (const [key, nextValue] of Object.entries(updates)) {
const currentValue = out[key];
if (nextValue === undefined) continue;
if (isPlainObject(nextValue)) {
out[key] = mergePreservingExisting(currentValue, nextValue);
continue;
}
if (nextValue === null) {
if (currentValue === undefined) out[key] = null;
continue;
}
if (typeof nextValue === "string") {
if (nextValue === "" && currentValue !== undefined) continue;
out[key] = nextValue;
continue;
}
if (Array.isArray(nextValue)) {
if (nextValue.length === 0 && Array.isArray(currentValue) && currentValue.length > 0) continue;
out[key] = nextValue;
continue;
}
if (isPlainObject(nextValue)) { out[key] = mergePreservingExisting(currentValue, nextValue); continue; }
if (nextValue === null) { if (currentValue === undefined) out[key] = null; continue; }
if (typeof nextValue === "string") { if (nextValue === "" && currentValue !== undefined) continue; out[key] = nextValue; continue; }
if (Array.isArray(nextValue)) { if (nextValue.length === 0 && Array.isArray(currentValue) && currentValue.length > 0) continue; out[key] = nextValue; continue; }
out[key] = nextValue;
}
return out;
}
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") {
title("Update");
const branch = process.env.DIRIGENT_GIT_BRANCH || "latest";
step(1, 2, `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");
step(2, 2, "run install after update");
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") {
title("Install");
step(1, 6, `environment: ${OPENCLAW_DIR}`);
title("Install Dirigent with Skills");
if (isRegistered()) {
warn("plugins.entries.dirigent exists; reinstalling in-place");
}
step(2, 6, "build dist assets");
step(1, 7, "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", "dirigent", "no-reply-api");
syncDirRecursive(pluginSrc, distPlugin);
syncDirRecursive(noReplySrc, distNoReply);
syncDirRecursive(noReplySrc, path.join(distPlugin, "no-reply-api"));
ok("dist assets built");
step(3, 6, `install files -> ${PLUGIN_INSTALL_DIR}`);
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
syncDirRecursive(distPlugin, PLUGIN_INSTALL_DIR);
syncDirRecursive(distNoReply, NO_REPLY_INSTALL_DIR);
ok("plugin files installed");
// cleanup old layout from previous versions
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
if (fs.existsSync(oldTopLevelNoReply)) {
fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
ok(`removed legacy path: ${oldTopLevelNoReply}`);
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`);
}
}
}
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}`);
}
step(4, 6, "configure plugin entry/path");
step(4, 7, "configure plugin entry");
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.load = plugins.load || {}; plugins.load.paths = loadPaths;
plugins.entries = plugins.entries || {};
const existingDirigentEntry = isPlainObject(plugins.entries.dirigent) ? plugins.entries.dirigent : {};
const desiredDirigentEntry = {
const existingDirigent = isPlainObject(plugins.entries.dirigent) ? plugins.entries.dirigent : {};
const desired = {
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,
enabled: true, discordOnly: true, listMode: "human-list",
humanList: [], agentList: [],
channelPoliciesFile: path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"),
endSymbols: ["🔚"], schedulingIdentifier: "➡️",
noReplyProvider: NO_REPLY_PROVIDER_ID, noReplyModel: NO_REPLY_MODEL_ID, noReplyPort: NO_REPLY_PORT,
},
};
plugins.entries.dirigent = mergePreservingExisting(existingDirigentEntry, desiredDirigentEntry);
plugins.entries.dirigent = mergePreservingExisting(existingDirigent, desired);
setJson("plugins", plugins);
ok("plugin configured");
step(5, 6, "configure no-reply provider");
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,
},
],
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");
// Add no-reply model to agents.defaults.models allowlist
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(6, 6, "enable plugin in allowlist");
step(7, 7, "enable plugin in allowlist");
const allow = getJson("plugins.allow") || [];
if (!allow.includes("dirigent")) {
allow.push("dirigent");
setJson("plugins.allow", 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);
@@ -277,43 +232,38 @@ if (mode === "install") {
if (mode === "uninstall") {
title("Uninstall");
step(1, 5, `environment: ${OPENCLAW_DIR}`);
step(2, 5, "remove allowlist + plugin entry");
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");
}
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");
unsetPath("plugins.entries.dirigent");
ok("removed plugins.entries.dirigent");
step(3, 5, "remove plugin load path");
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);
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");
ok("removed load path");
step(4, 5, "remove no-reply provider");
const providers = getJson("models.providers") || {};
delete providers[NO_REPLY_PROVIDER_ID];
setJson("models.providers", providers);
ok(`removed provider ${NO_REPLY_PROVIDER_ID}`);
step(5, 5, "remove installed files");
step(3, 4, "remove installed files");
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 });
const oldTopLevelNoReply = path.join(PLUGINS_DIR, "no-reply-api");
if (fs.existsSync(oldTopLevelNoReply)) fs.rmSync(oldTopLevelNoReply, { recursive: true, force: true });
ok("removed installed files");
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);

View File

@@ -0,0 +1,29 @@
---
name: discord-guilds
description: When calling tools that require Discord guild ID/server ID, refer to this skill for available guild IDs and their descriptions.
---
# Discord Guilds
Use this skill when a tool or command requires a Discord `guildId` (also called server ID).
## Available Guilds
| guild-id | description |
|----------|-------------|
| 1480860737902743686 | Main test guild for HarborForge/Dirigent development |
## Usage
When calling tools like `dirigent_discord_control` that require a `guildId` parameter, look up the appropriate guild ID from the table above based on the context.
To add a new guild to this list, use the `add-guild` script:
```bash
{skill}/scripts/add-guild <guild-id> <description>
```
Example:
```bash
~/.openclaw/skills/discord-guilds/scripts/add-guild "123456789012345678" "Production server"
```

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* Add a guild entry to the discord-guilds SKILL.md table
* Usage: add-guild <guild-id> <description>
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_PATH = path.resolve(__dirname, "../SKILL.md");
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error("Usage: add-guild <guild-id> <description>");
process.exit(1);
}
const guildId = args[0];
const description = args.slice(1).join(" ");
// Validate guild ID is numeric
if (!/^\d+$/.test(guildId)) {
console.error("Error: guild-id must be numeric (Discord snowflake)");
process.exit(1);
}
// Read existing SKILL.md
let content = fs.readFileSync(SKILL_PATH, "utf8");
// Find the table and insert new row
const newRow = `| ${guildId} | ${description} |`;
// Look for the table pattern and insert after the header
const tablePattern = /(\| guild-id \| description \|\n\|[-\s|]+\|)/;
if (!tablePattern.test(content)) {
console.error("Error: Could not find guild table in SKILL.md");
process.exit(1);
}
// Insert new row after the table header
content = content.replace(tablePattern, `$1\n${newRow}`);
// Write back
fs.writeFileSync(SKILL_PATH, content, "utf8");
console.log(`✓ Added guild: ${guildId} - ${description}`);
}
main();