diff --git a/scripts/install.mjs b/scripts/install.mjs index 271f72c..2a6a894 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -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 ] [--no-reply-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 ] [--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", -}; - +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}`); - - if (isRegistered()) { - warn("plugins.entries.dirigent exists; reinstalling in-place"); - } - - step(2, 6, "build dist assets"); + title("Install Dirigent with Skills"); + + 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); diff --git a/skills/discord-guilds/SKILL.md b/skills/discord-guilds/SKILL.md new file mode 100644 index 0000000..742adf7 --- /dev/null +++ b/skills/discord-guilds/SKILL.md @@ -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 +``` + +Example: +```bash +~/.openclaw/skills/discord-guilds/scripts/add-guild "123456789012345678" "Production server" +``` diff --git a/skills/discord-guilds/scripts/add-guild b/skills/discord-guilds/scripts/add-guild new file mode 100755 index 0000000..67e548d --- /dev/null +++ b/skills/discord-guilds/scripts/add-guild @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Add a guild entry to the discord-guilds SKILL.md table + * Usage: add-guild + */ + +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 "); + 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();