Files
claude-pet/lib/hooks-installer.js
T

130 lines
4.3 KiB
JavaScript
Raw Permalink Normal View History

"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");
2026-08-21 11:03:11 +08:00
// 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 };