Files
claude-pet/lib/hooks-installer.js
T
JianMiauandClaude Fable 5 e591475d4c 加上免安裝版,寵物來源改成執行目錄的 pets
摘要:
新增 portable 單檔與 zip 資料夾兩種免安裝產物;寵物一律從執行目錄的 pets/ 掃,設定檔不再累積程式自己算出來的路徑。

根本原因:
1. 只有 NSIS 安裝檔,沒有點兩下就能跑的版本。
2. 內附寵物被 asarUnpack 埋在 resources/app.asar.unpacked/pets,使用者要新增寵物得先鑽進 resources 資料夾。
3. loadConfig 會把算出來的來源路徑寫回 config.json,程式一搬家或在安裝版與 portable 之間切換,petSources 就越積越多失效的絕對路徑(實際已累積出 dist/win-unpacked/resources/app.asar.unpacked/pets 這種殘留)。
4. CODEX_PETS_DIR 用到 HOME,但 const HOME 宣告在它下面,會在啟動時就 TDZ ReferenceError。

影響:
沒有免安裝選項;新增寵物的路徑不直覺;設定檔會長出一堆死路徑;且上一版打包後的程式其實一啟動就會崩潰。

修法:
- win target 加上 portable 與 zip;portable 設 unpackDirName=ClaudePet、requestExecutionLevel=user。
- pets/ 改用 extraFiles 放到 exe 旁邊(不再進 asar),APP_ROOT 打包後取 process.execPath 的目錄。
- portable 的 NSIS 外殼每次啟動都重解壓、結束就刪整個目錄,因此:hook 腳本另外複製到 ~/.claude-pet/hook/ 並註冊該穩定路徑(否則寵物沒開時 Claude Code 每次觸發 hook 都找不到檔案);使用者寵物放那顆 exe 旁邊;開機自動啟動改用 PORTABLE_EXECUTABLE_FILE。
- petSources 語意改為「只存使用者自己加的額外路徑」;執行目錄的 pets 與 ~/.codex/pets 為隱含來源,由 petSearchDirs() 組出實際掃描清單,載入時剔除隱含項與不存在的路徑。同 id 以使用者自己放的優先。
- 把 HOME/CONFIG_DIR 移到常數區最前面,修掉 TDZ。
- 內附第二隻寵物 xiao-nian-realistic。

驗證:
以 ELECTRON_RUN_AS_NODE 執行打包後的 exe(不開視窗):
- 一般模式與模擬 portable 模式下的 pets 路徑、hook 路徑、autostart 指令皆正確。
- 用假的 USERPROFILE 實跑 install/uninstall:hook 腳本確實複製、指令含空白路徑會加引號、其他人的 Stop/PreToolUse hook 與 model 設定在安裝與移除後都完整保留、備份有產生。
- 帶殘留路徑的設定檔會被正確清理,掃描順序為「使用者 pets → 內附 pets → ~/.codex/pets → 額外路徑」。
- 從 app.asar 讀回 main.js 確認出貨的程式碼順序正確。
- 以純 node 載入 main.js,確認常數區與 loadConfig 不再有 TDZ。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:11:20 +08:00

130 lines
4.3 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.
"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 };