UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

244 lines 8.56 kB
/** * Claude Code aiwg-hooks installer (PUW-010 / #1111). * * When `aiwg use --provider claude` runs and the operator hasn't opted * out via `--no-hooks`, this module: * 1. Copies the aiwg-hooks addon's JS handler scripts to .claude/hooks/ * 2. Reads existing .claude/settings.json (backing up first if no AIWG * marker is present, per ADR-3 §5) * 3. Merges AIWG hook entries with `_aiwg_managed: true` tagging * 4. Writes settings.json atomically * * Schema: Claude Code requires `hooks` to be an object keyed by event * name, each value an array of matcher groups. See #107. * * { * "hooks": { * "<EventName>": [ * { "matcher": "<optional regex>", * "hooks": [{ "type": "command", "command": "..." }] } * ] * } * } * * The merge preserves operator-authored entries. Legacy array-shaped * hook fields written by older AIWG builds are migrated to the object * shape on read. `aiwg refresh --restore-hooks` reads the most-recent * .bak.<timestamp> file and restores it. */ import { promises as fs } from 'node:fs'; import * as path from 'node:path'; /** * Hook script → Claude Code hook event mapping. * * Per the script-source comments at agentic/code/addons/aiwg-hooks/hooks/. */ const HOOK_SCRIPTS = [ { file: 'aiwg-permissions.cjs', events: ['PermissionRequest'] }, { file: 'aiwg-session.cjs', events: ['SessionStart'] }, { file: 'aiwg-trace.cjs', events: ['SubagentStart', 'SubagentStop'] }, ]; /** * Migrate a legacy array-shaped `hooks` field to the object form Claude * Code expects. Returns the normalized object. */ function migrateLegacyHooks(legacy) { const out = {}; for (const m of legacy) { if (!m || typeof m.matcher !== 'string' || !Array.isArray(m.hooks)) continue; if (!out[m.matcher]) out[m.matcher] = []; out[m.matcher].push({ hooks: m.hooks }); } return out; } /** * Normalize the hooks field to the object form, migrating legacy arrays. * Returns `[hooks, didMigrate]`. */ function normalizeHooks(raw) { if (Array.isArray(raw)) return [migrateLegacyHooks(raw), true]; if (raw && typeof raw === 'object') return [raw, false]; return [{}, false]; } /** * Detect whether the existing settings carries the AIWG signature * (any hook entry tagged `_aiwg_managed: true`). Accepts both the * current object form and the legacy array form. */ function hasAiwgMarker(settings) { const hooksField = settings.hooks; if (!hooksField) return false; const groups = Array.isArray(hooksField) ? hooksField.map((m) => ({ hooks: m.hooks })) : Object.values(hooksField).flat(); return groups.some((g) => Array.isArray(g.hooks) && g.hooks.some((h) => h && h._aiwg_managed === true)); } /** * Atomic write via tmpfile+rename so partial state never persists. */ async function atomicWrite(filePath, content) { const dir = path.dirname(filePath); const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}`); await fs.writeFile(tmp, content, 'utf8'); try { await fs.rename(tmp, filePath); } catch (err) { await fs.unlink(tmp).catch(() => undefined); throw err; } } /** * Install AIWG hooks for Claude Code. * * Returns null if the addon source isn't present (e.g., aiwg-hooks not * installed). Otherwise returns an InstallResult describing what was * written. */ export async function installAiwgHooks(opts) { const sourceHooksDir = path.join(opts.frameworkRoot, 'agentic', 'code', 'addons', 'aiwg-hooks', 'hooks'); let sourceFiles; try { sourceFiles = await fs.readdir(sourceHooksDir); } catch (err) { if (err.code === 'ENOENT') return null; throw err; } const result = { installedScripts: [], settingsPath: path.join(opts.projectPath, '.claude', 'settings.json'), registeredEvents: [], warnings: [], }; // 1. Copy hook scripts to .claude/hooks/ const claudeHooksDir = path.join(opts.projectPath, '.claude', 'hooks'); if (!opts.dryRun) { await fs.mkdir(claudeHooksDir, { recursive: true }); } for (const { file } of HOOK_SCRIPTS) { if (!sourceFiles.includes(file)) { result.warnings.push(`hook script not found in addon source: ${file}`); continue; } const src = path.join(sourceHooksDir, file); const dst = path.join(claudeHooksDir, file); if (opts.dryRun) { result.installedScripts.push(`${dst} (dry-run)`); continue; } await fs.copyFile(src, dst); try { await fs.chmod(dst, 0o755); } catch { // Non-POSIX } result.installedScripts.push(dst); } // 2. Read existing settings.json let settings = {}; try { const raw = await fs.readFile(result.settingsPath, 'utf8'); settings = JSON.parse(raw); } catch (err) { if (err.code !== 'ENOENT') { throw err; } } // 3. Backup if existing settings.json has no AIWG marker if (!opts.dryRun) { try { const exists = await fs .access(result.settingsPath) .then(() => true) .catch(() => false); if (exists && !hasAiwgMarker(settings)) { const backup = `${result.settingsPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`; await fs.copyFile(result.settingsPath, backup); result.backupPath = backup; result.warnings.push(`Backed up pre-existing settings.json to ${backup}`); } } catch (err) { result.warnings.push(`Backup failed: ${err instanceof Error ? err.message : String(err)}`); } } // 4. Normalize (migrate legacy array shape → object shape) and merge const [hooksObj, migrated] = normalizeHooks(settings.hooks); if (migrated) { result.migratedFromLegacy = true; result.warnings.push('Migrated legacy array-shaped hooks field to object form (#107).'); } settings.hooks = hooksObj; for (const { file, events } of HOOK_SCRIPTS) { if (!sourceFiles.includes(file)) continue; const command = `node ${path.join('.claude', 'hooks', file)}`; const hookId = file.replace(/\.(cjs|js)$/, ''); for (const event of events) { if (!hooksObj[event]) hooksObj[event] = []; const groups = hooksObj[event]; // Skip if AIWG entry for this hookId is already present anywhere // in this event's groups. const alreadyPresent = groups.some((g) => Array.isArray(g.hooks) && g.hooks.some((h) => h._aiwg_id === hookId)); if (alreadyPresent) continue; groups.push({ hooks: [ { type: 'command', command, _aiwg_managed: true, _aiwg_id: hookId, }, ], }); result.registeredEvents.push(`${event}${hookId}`); } } // 5. Atomic write if (!opts.dryRun) { await fs.mkdir(path.dirname(result.settingsPath), { recursive: true }); await atomicWrite(result.settingsPath, JSON.stringify(settings, null, 2) + '\n'); } return result; } /** * Restore the most-recent settings.json backup (per ADR-3 §5 rollback). * * Reads `.claude/settings.json.bak.<RFC3339>` files and restores the * lexicographically-latest one (timestamp ordering). Returns the path * restored or null if no backup exists. */ export async function restoreSettingsBackup(projectPath) { const claudeDir = path.join(projectPath, '.claude'); const settingsPath = path.join(claudeDir, 'settings.json'); let entries; try { entries = await fs.readdir(claudeDir); } catch (err) { if (err.code === 'ENOENT') return null; throw err; } const backups = entries .filter((e) => e.startsWith('settings.json.bak.')) .sort(); if (backups.length === 0) return null; const latest = backups[backups.length - 1]; const src = path.join(claudeDir, latest); await fs.copyFile(src, settingsPath); return src; } //# sourceMappingURL=claude-hooks-installer.js.map