"use strict"; // 把 Claude Pet 的 hook 合併進 ~/.claude/settings.json(保留使用者原本的設定與其他 hooks) const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { unpacked, isPortable } = require("./paths"); const APP_DIR = path.resolve(__dirname, ".."); const CLAUDE_DIR = path.join(os.homedir(), ".claude"); const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json"); const BACKUP_DIR = path.join(CLAUDE_DIR, "backups"); // node 執行不了 asar 內的檔案,打包後要指到解開的那份 const BUNDLED_HOOK_SCRIPT = unpacked(path.join(APP_DIR, "hook", "claude-pet-hook.js")); // portable 版整包解在 %TEMP%、關掉程式就被刪,hook 路徑不能指在那裡(寵物沒開時 // Claude Code 會找不到檔案而每次噴錯),所以複製一份到不會被清掉的地方。 const STABLE_HOOK_SCRIPT = path.join(os.homedir(), ".claude-pet", "hook", "claude-pet-hook.js"); function hookScript() { return isPortable() ? STABLE_HOOK_SCRIPT : BUNDLED_HOOK_SCRIPT; } // 只有 portable 需要;每次安裝 hook 都重新複製,版本才不會走鐘 function syncHookScript() { if (!isPortable()) return; fs.mkdirSync(path.dirname(STABLE_HOOK_SCRIPT), { recursive: true }); fs.copyFileSync(BUNDLED_HOOK_SCRIPT, STABLE_HOOK_SCRIPT); } const MARKER = "claude-pet-hook"; // Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態) const EVENTS = [ "SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "Notification", "Stop", "StopFailure", "SubagentStart", "SubagentStop", "PreCompact", ]; function hookCommand() { const p = hookScript().replace(/\\/g, "/"); return /\s/.test(p) ? `node "${p}"` : `node ${p}`; } function readSettings() { try { const text = fs.readFileSync(SETTINGS_PATH, "utf8"); return text.trim() ? JSON.parse(text) : {}; } catch (err) { if (err.code === "ENOENT") return {}; throw new Error(`無法解析 ${SETTINGS_PATH}:${err.message}`); } } function writeSettings(settings) { fs.mkdirSync(CLAUDE_DIR, { recursive: true }); const tmp = `${SETTINGS_PATH}.${process.pid}.tmp`; fs.writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); fs.renameSync(tmp, SETTINGS_PATH); } function backup() { if (!fs.existsSync(SETTINGS_PATH)) return null; fs.mkdirSync(BACKUP_DIR, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const dest = path.join(BACKUP_DIR, `settings.claude-pet.${stamp}.json`); fs.copyFileSync(SETTINGS_PATH, dest); return dest; } function isPetHook(entry) { try { return JSON.stringify(entry).includes(MARKER); } catch { return false; } } function normalize(list) { if (Array.isArray(list)) return list; if (list && typeof list === "object") return [list]; return []; } function install() { syncHookScript(); const settings = readSettings(); const backupPath = backup(); settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {}; for (const event of EVENTS) { const kept = normalize(settings.hooks[event]).filter((e) => !isPetHook(e)); kept.push({ hooks: [{ type: "command", command: hookCommand(), timeout: 5 }] }); settings.hooks[event] = kept; } writeSettings(settings); return { events: EVENTS, settingsPath: SETTINGS_PATH, backupPath, command: hookCommand() }; } function uninstall() { const settings = readSettings(); if (!settings.hooks || typeof settings.hooks !== "object") return { removed: 0 }; const backupPath = backup(); let removed = 0; for (const event of Object.keys(settings.hooks)) { const before = normalize(settings.hooks[event]); const after = before.filter((e) => !isPetHook(e)); removed += before.length - after.length; if (after.length) settings.hooks[event] = after; else delete settings.hooks[event]; } if (Object.keys(settings.hooks).length === 0) delete settings.hooks; writeSettings(settings); return { removed, settingsPath: SETTINGS_PATH, backupPath }; } function isInstalled() { try { const settings = readSettings(); return normalize(settings.hooks?.Stop).some(isPetHook); } catch { return false; } } module.exports = { install, uninstall, isInstalled, hookCommand, hookScript, EVENTS, SETTINGS_PATH };