#!/usr/bin/env node /** * install.mjs — 把 ai-group-chat 技能装到本机各个 agent 的技能目录。 * * 目标目录(本机实测): * agents ~/.agents/skills/ai-group-chat ← DSH 与 opencode 都会自动加载(一份覆盖两家) * workbuddy ~/.workbuddy/skills/ai-group-chat ← WorkBuddy 桌面端 * codebuddy ~/.codebuddy/skills/ai-group-chat ← WorkBuddy 随附的 CodeBuddy CLI * opencode ~/.config/opencode/skill/ai-group-chat ← 仅在关闭了 external skills 时才需要 * * 用法: * node install.mjs # 装到 agents + workbuddy + codebuddy * node install.mjs --host all # 同上,另外也装 opencode 专用目录 * node install.mjs --host workbuddy # 只装一家 * node install.mjs --check # 只体检,不写 * node install.mjs --link # 用目录联接(junction)替代复制,全部宿主共用一份源码 * node install.mjs --uninstall # 卸载 */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SKILL_ROOT = path.resolve(HERE, '..'); const SKILL_NAME = 'ai-group-chat'; const VERSION = '1.0.0'; const argv = process.argv.slice(2); const has = (f) => argv.includes(`--${f}`); const val = (f, d) => { const i = argv.indexOf(`--${f}`); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; }; const HOME = os.homedir(); const TARGETS = { agents: { dir: path.join(HOME, '.agents', 'skills', SKILL_NAME), hosts: ['dsh', 'opencode'], why: 'DSH 与 opencode 的自动加载目录' }, workbuddy: { dir: path.join(HOME, '.workbuddy', 'skills', SKILL_NAME), hosts: ['workbuddy'], why: 'WorkBuddy 桌面端用户技能目录' }, codebuddy: { dir: path.join(HOME, '.codebuddy', 'skills', SKILL_NAME), hosts: ['workbuddy'], why: 'CodeBuddy CLI(WorkBuddy 的终端形态)' }, opencode: { dir: path.join(HOME, '.config', 'opencode', 'skill', SKILL_NAME), hosts: ['opencode'], why: 'opencode 专属技能目录(一般不需要,.agents 已覆盖)' }, }; const REQUESTED = String(val('host', has('all') ? 'all' : 'default')).split(',').map((s) => s.trim()).filter(Boolean); const chosen = REQUESTED.includes('all') ? Object.keys(TARGETS) : REQUESTED.includes('default') ? ['agents', 'workbuddy', 'codebuddy'] : REQUESTED; const unknown = chosen.filter((c) => !TARGETS[c]); if (unknown.length) { process.stderr.write(`install: 未知目标 ${unknown.join(', ')}(可选:${Object.keys(TARGETS).join(' / ')} / all)\n`); process.exit(2); } const DRY = has('dry-run'); const LINK = has('link'); const CHECK = has('check'); const UNINSTALL = has('uninstall'); /* --------------------------------------------------------------- 工具 */ const COPY_ITEMS = ['SKILL.md', 'scripts', 'references', 'README.md', 'VERIFY.md']; function rmrf(p) { fs.rmSync(p, { recursive: true, force: true }); } function copyInto(dest) { fs.mkdirSync(dest, { recursive: true }); for (const item of COPY_ITEMS) { const src = path.join(SKILL_ROOT, item); if (!fs.existsSync(src)) continue; const dst = path.join(dest, item); rmrf(dst); fs.cpSync(src, dst, { recursive: true }); } } function junction(linkPath, targetPath) { fs.mkdirSync(path.dirname(linkPath), { recursive: true }); const r = spawnSync('cmd.exe', ['/d', '/s', '/c', 'mklink', '/J', linkPath, targetPath], { encoding: 'utf8', windowsHide: true }); if (r.status !== 0) throw new Error((r.stderr || r.stdout || 'mklink 失败').trim()); } function frontmatterName(file) { try { const txt = fs.readFileSync(file, 'utf8'); const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!m) return null; const n = m[1].match(/^name:\s*(.+)$/m); return n ? n[1].trim() : null; } catch { return null; } } /* -------------------------------------------- WorkBuddy 沙箱白名单 */ function patchWorkbuddySettings() { const file = path.join(HOME, '.workbuddy', 'settings.json'); const wanted = '~/.ai-groups/'; if (!fs.existsSync(file)) return { ok: false, detail: `没找到 ${file}(WorkBuddy 可能还没启动过)` }; const raw = fs.readFileSync(file, 'utf8'); let cfg; try { cfg = JSON.parse(raw); } catch (e) { return { ok: false, detail: `settings.json 不是合法 JSON:${e.message}` }; } cfg.sandbox = cfg.sandbox || {}; cfg.sandbox.extraAllowWrite = cfg.sandbox.extraAllowWrite || []; if (cfg.sandbox.extraAllowWrite.includes(wanted)) return { ok: true, detail: '已在白名单里,未改动' }; if (DRY) return { ok: true, detail: `[dry-run] 会把 ${wanted} 加进 sandbox.extraAllowWrite` }; const backup = `${file}.bak-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`; fs.copyFileSync(file, backup); cfg.sandbox.extraAllowWrite.push(wanted); fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); return { ok: true, detail: `已加入 ${wanted}(备份 ${path.basename(backup)})` }; } /* --------------------------------------------------------------- 检查 */ function inspect(name) { const t = TARGETS[name]; const skillMd = path.join(t.dir, 'SKILL.md'); const cli = path.join(t.dir, 'scripts', 'aig.mjs'); const marker = path.join(t.dir, '.aig-host.json'); const exists = fs.existsSync(skillMd); const nameInFm = exists ? frontmatterName(skillMd) : null; let link = null; try { if (fs.lstatSync(t.dir).isSymbolicLink()) link = fs.readlinkSync(t.dir); } catch {} return { name, dir: t.dir, why: t.why, hosts: t.hosts, installed: exists, cli: fs.existsSync(cli), name_ok: nameInFm === SKILL_NAME, frontmatter: nameInFm, marker: fs.existsSync(marker) ? JSON.parse(fs.readFileSync(marker, 'utf8')) : null, junction: link, }; } function printCheck(rows) { const head = ['目标', '状态', 'SKILL.md name', 'CLI', '位置']; const body = rows.map((r) => [ r.name, r.installed ? (r.junction ? '已装(链接)' : '已装') : '未装', r.installed ? (r.name_ok ? '✔' : `✘ ${r.frontmatter || '读取失败'}`) : '-', r.installed ? (r.cli ? '✔' : '✘') : '-', r.dir, ]); const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => String(b[i]).length))); const line = (c) => c.map((x, i) => String(x).padEnd(w[i])).join(' '); process.stdout.write(`\n${line(head)}\n${line(w.map((n) => '─'.repeat(n)))}\n`); for (const b of body) process.stdout.write(`${line(b)}\n`); } /* --------------------------------------------------------------- main */ process.stdout.write(`ai-group-chat 安装器 v${VERSION}\n源目录:${SKILL_ROOT}\n${LINK ? '模式:目录联接(所有宿主共用这一份源码)\n' : '模式:复制\n'}`); if (CHECK) { const rows = Object.keys(TARGETS).map(inspect); printCheck(rows); const missing = rows.filter((r) => chosen.includes(r.name) && !r.installed); process.stdout.write(missing.length ? `\n→ ${missing.map((m) => m.name).join(', ')} 还没装:node install.mjs --host ${missing.map((m) => m.name).join(',')}\n` : '\n→ 选中的目标都已安装\n'); process.exit(0); } if (UNINSTALL) { for (const name of chosen) { const t = TARGETS[name]; if (!fs.existsSync(t.dir)) { process.stdout.write(`· ${name}:本来就没有\n`); continue; } if (DRY) { process.stdout.write(`· ${name}:[dry-run] 会删除 ${t.dir}\n`); continue; } rmrf(t.dir); process.stdout.write(`· ${name}:已删除 ${t.dir}\n`); } process.exit(0); } let failures = 0; for (const name of chosen) { const t = TARGETS[name]; try { if (DRY) { process.stdout.write(`· ${name}:[dry-run] 会写到 ${t.dir}\n`); continue; } if (fs.existsSync(t.dir)) { const keep = path.join(t.dir, '.aig-host.json'); const prev = fs.existsSync(keep) ? fs.readFileSync(keep, 'utf8') : null; rmrf(t.dir); fs.mkdirSync(t.dir, { recursive: true }); if (prev) fs.writeFileSync(keep, prev); } if (LINK) junction(t.dir, SKILL_ROOT); else copyInto(t.dir); const marker = { skill: SKILL_NAME, version: VERSION, host: t.hosts[0], hosts: t.hosts, installedAt: new Date().toISOString(), source: SKILL_ROOT, mode: LINK ? 'junction' : 'copy', note: t.why, }; fs.writeFileSync(path.join(t.dir, '.aig-host.json'), `${JSON.stringify(marker, null, 2)}\n`, 'utf8'); process.stdout.write(`· ${name}:已安装 → ${t.dir}${LINK ? '(链接)' : ''}\n`); } catch (e) { failures++; process.stdout.write(`· ${name}:失败 — ${e.message}\n`); } } if (chosen.includes('workbuddy') && !DRY && !has('no-wb-settings')) { const r = patchWorkbuddySettings(); process.stdout.write(`· WorkBuddy 沙箱白名单:${r.ok ? '✔' : '✘'} ${r.detail}\n`); } const rows = Object.keys(TARGETS).map(inspect); printCheck(rows); process.stdout.write(` 接下来怎么用: 1) 自检(不花钱、不调模型): node "${path.join(SKILL_ROOT, 'scripts', 'selfcheck.mjs')}" 2) 体检(房间 + 三个成员的唤醒通道 + 安装位置): node "${path.join(chosen[0] ? TARGETS[chosen[0]].dir : SKILL_ROOT, 'scripts', 'aig.mjs')}" doctor 3) 开群并试一句(会真的调用一次免费模型): node "${path.join(chosen[0] ? TARGETS[chosen[0]].dir : SKILL_ROOT, 'scripts', 'aig.mjs')}" init node "${path.join(chosen[0] ? TARGETS[chosen[0]].dir : SKILL_ROOT, 'scripts', 'aig.mjs')}" ask opencode "报个到" --as dsh 4) 各宿主里怎么触发这个技能: DSH —— 直接说「拉个群,让 opencode 看看这个改动」 opencode —— 同一个说法即可(它从 ~/.agents/skills 自动加载) WorkBuddy—— 同一个说法即可(技能来自 ~/.workbuddy/skills) `); process.exit(failures ? 1 : 0);