/** * acr install-harness — 把「用戶 CC harness」裝進當前專案。 * * 對象:在「用 arcrun 開發」的專案裡工作的 CC + 使用者。讓使用者的 CC 自動載入 arcrun 防護: * - CLAUDE.md 區塊(事前提醒:別自寫 Python) * - .claude/skills/arcrun-mindset/(世界觀 + 資源去哪取) * - .claude/commands/arcrun.md(/arcrun slash command) * - .claude/hooks/arcrun-guard.sh + settings.json(做錯被糾正) * * 冪等:重裝不重複、不破壞使用者既有 CLAUDE.md / settings。 * SDD:.agents/specs/user-cc-harness/design.md §2 */ import { fileURLToPath } from 'node:url'; import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, } from 'node:fs'; import { join, dirname } from 'node:path'; import chalk from 'chalk'; /** harness 素材根目錄(內嵌 npm 套件,SSOT=cli/harness/)。 * build 後此檔在 dist/commands/,harness/ 在套件根 → ../../harness。 */ function harnessRoot(): string { const here = dirname(fileURLToPath(import.meta.url)); // .../dist/commands return join(here, '..', '..', 'harness'); // .../harness } const START = ''; const END = ''; export async function cmdInstallHarness(): Promise { const cwd = process.cwd(); const src = harnessRoot(); if (!existsSync(src)) { console.error(chalk.red(`找不到 harness 素材(${src})。套件安裝可能不完整,請重裝 arcrun。`)); process.exit(1); } console.log(chalk.bold('\n 安裝 arcrun harness 到當前專案\n')); // 1. CLAUDE.md:append/取代 arcrun 區塊(標記包夾,冪等) installClaudeBlock(cwd, src); // 2. mindset Skill copyTree(join(src, 'skills'), join(cwd, '.claude', 'skills')); console.log(chalk.green(' ✓ .claude/skills/arcrun-mindset/')); // 3. /arcrun command copyTree(join(src, 'commands'), join(cwd, '.claude', 'commands')); console.log(chalk.green(' ✓ .claude/commands/arcrun.md')); // 4. hook const hookDst = join(cwd, '.claude', 'hooks', 'arcrun-guard.sh'); mkdirSync(dirname(hookDst), { recursive: true }); copyFileSync(join(src, 'hooks', 'arcrun-guard.sh'), hookDst); chmodSync(hookDst, 0o755); console.log(chalk.green(' ✓ .claude/hooks/arcrun-guard.sh')); // 5. settings.json:合併 hook 註冊(不覆蓋使用者既有設定) mergeSettings(cwd, src); console.log(chalk.green(' ✓ .claude/settings.json(已合併 arcrun guard hook)')); console.log(chalk.gray('\n 提示:')); console.log(chalk.gray(' • 首次在此專案開 Claude Code 會要求「信任工作區」,按信任 hook 才生效。')); console.log(chalk.gray(' • 之後跟 CC 說需求即可(或打 /arcrun <你的需求>)。')); console.log(chalk.gray(' • CC 偏好 MCP?可另跑 acr update 連 arcrun MCP(MCP 對齊中,optional)。\n')); } /** CLAUDE.md:無→建;有 arcrun 區塊→取代;有但無區塊→append。標記包夾,冪等。 */ function installClaudeBlock(cwd: string, src: string): void { const block = readFileSync(join(src, 'CLAUDE.block.md'), 'utf8').trim(); const path = join(cwd, 'CLAUDE.md'); if (!existsSync(path)) { writeFileSync(path, block + '\n', 'utf8'); console.log(chalk.green(' ✓ CLAUDE.md(已建立,含 arcrun 區塊)')); return; } const cur = readFileSync(path, 'utf8'); if (cur.includes(START) && cur.includes(END)) { // 取代既有區塊 const re = new RegExp(escapeRe(START) + '[\\s\\S]*?' + escapeRe(END)); writeFileSync(path, cur.replace(re, block), 'utf8'); console.log(chalk.green(' ✓ CLAUDE.md(已更新 arcrun 區塊)')); } else { writeFileSync(path, cur.replace(/\s*$/, '') + '\n\n' + block + '\n', 'utf8'); console.log(chalk.green(' ✓ CLAUDE.md(已附加 arcrun 區塊,未動既有內容)')); } } /** 把 settings.fragment.json 的 hook 合併進專案 settings.json(不覆蓋使用者既有 hooks/設定)。 */ function mergeSettings(cwd: string, src: string): void { const fragment = JSON.parse(readFileSync(join(src, 'settings.fragment.json'), 'utf8')); const path = join(cwd, '.claude', 'settings.json'); mkdirSync(dirname(path), { recursive: true }); let settings: Record = {}; if (existsSync(path)) { try { settings = JSON.parse(readFileSync(path, 'utf8')); } catch { settings = {}; } } const hooks = (settings.hooks ?? {}) as Record; const fragHooks = fragment.hooks as Record; for (const [event, entries] of Object.entries(fragHooks)) { const existing = Array.isArray(hooks[event]) ? hooks[event] : []; // 去重:避免重裝重複加 arcrun-guard const serialized = new Set(existing.map(e => JSON.stringify(e))); for (const e of entries) { if (!serialized.has(JSON.stringify(e))) existing.push(e); } hooks[event] = existing; } settings.hooks = hooks; writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8'); } /** 建置期產物的來源片段(`SKILL.md.head` / `.tail`),只給 build-harness-skill.mjs 用, * 不該被鋪進使用者專案(使用者拿到的是拼接好的 `SKILL.md`)。 */ function isBuildSource(name: string): boolean { return name.endsWith('.head') || name.endsWith('.tail'); } /** 遞迴複製目錄樹(覆蓋同名檔;跳過建置期來源片段)。 */ function copyTree(srcDir: string, dstDir: string): void { if (!existsSync(srcDir)) return; mkdirSync(dstDir, { recursive: true }); for (const name of readdirSync(srcDir, { withFileTypes: true })) { if (isBuildSource(name.name)) continue; const s = join(srcDir, name.name); const d = join(dstDir, name.name); if (name.isDirectory()) copyTree(s, d); else copyFileSync(s, d); } } function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }