摘要: 新增 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>
130 lines
4.3 KiB
JavaScript
130 lines
4.3 KiB
JavaScript
"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 };
|