Files
ai-group-chat/scripts/install.mjs
T

217 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);