From 0d89d52b79050bb45cc655dab5b9d063c58a393b Mon Sep 17 00:00:00 2001 From: anantvardhanpandey Date: Thu, 30 Jul 2026 19:29:15 +0530 Subject: [PATCH] fix(cli): clean up hooks from Claude settings on package postuninstall (#20) --- __tests__/scripts/postuninstall.test.ts | 67 +++++++++++++++++++++++++ package.json | 1 + scripts/postuninstall.mjs | 63 +++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 __tests__/scripts/postuninstall.test.ts create mode 100644 scripts/postuninstall.mjs diff --git a/__tests__/scripts/postuninstall.test.ts b/__tests__/scripts/postuninstall.test.ts new file mode 100644 index 00000000..dccd588e --- /dev/null +++ b/__tests__/scripts/postuninstall.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { cleanClaudeSettings } from "../../scripts/postuninstall.mjs"; + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + let mockStore: Record = {}; + return { + ...actual, + existsSync: vi.fn((path: string) => path in mockStore), + readFileSync: vi.fn((path: string) => mockStore[path] ?? "{}"), + writeFileSync: vi.fn((path: string, content: string) => { + mockStore[path] = content; + }), + __resetMockStore: () => { + mockStore = {}; + }, + __setMockFile: (path: string, content: string) => { + mockStore[path] = content; + }, + __getMockFile: (path: string) => mockStore[path], + }; +}); + +describe("scripts/postuninstall", () => { + beforeEach(async () => { + vi.resetAllMocks(); + const fs = await import("node:fs"); + (fs as unknown as { __resetMockStore: () => void }).__resetMockStore(); + }); + + it("returns 0 when settings file does not exist", async () => { + const count = cleanClaudeSettings("/nonexistent/settings.json"); + expect(count).toBe(0); + }); + + it("removes failproofai hook entries from Claude settings and writes clean file", async () => { + const fs = await import("node:fs"); + const testPath = "/mock/.claude/settings.json"; + const initialSettings = { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: "command", + command: "failproofai --hook PreToolUse", + is_failproofai: true, + }, + ], + }, + ], + }, + }; + (fs as unknown as { __setMockFile: (p: string, c: string) => void }).__setMockFile( + testPath, + JSON.stringify(initialSettings), + ); + + const count = cleanClaudeSettings(testPath); + expect(count).toBe(1); + + const updatedRaw = (fs as unknown as { __getMockFile: (p: string) => string }).__getMockFile(testPath); + const updated = JSON.parse(updatedRaw); + expect(updated.hooks).toBeUndefined(); + }); +}); diff --git a/package.json b/package.json index 4adb2a15..599e92ff 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:run": "vitest run", "lint": "eslint . --config eslint.config.mjs", "prepare": "bun run build", + "postuninstall": "node scripts/postuninstall.mjs", "test:e2e": "vitest run --config vitest.config.e2e.mts", "test:e2e:watch": "vitest --config vitest.config.e2e.mts", "translate": "bun scripts/translate-docs/cli.ts", diff --git a/scripts/postuninstall.mjs b/scripts/postuninstall.mjs new file mode 100644 index 00000000..bc1faebb --- /dev/null +++ b/scripts/postuninstall.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Postuninstall script for failproofai. + * Automatically cleans up failproofai hook entries from Claude Code & CLI settings files + * when `npm uninstall -g failproofai` or `bun remove -g failproofai` is executed. + */ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; + +function isFailproofaiHook(hook) { + if (!hook || typeof hook !== "object") return false; + if (hook.is_failproofai === true || hook.__failproofai_hook === true) return true; + const cmd = typeof hook.command === "string" ? hook.command : ""; + return cmd.includes("failproofai") && cmd.includes("--hook"); +} + +export function cleanClaudeSettings(settingsPath) { + if (!existsSync(settingsPath)) return 0; + try { + const raw = readFileSync(settingsPath, "utf8"); + const settings = JSON.parse(raw); + if (!settings.hooks) return 0; + + let removed = 0; + for (const eventType of Object.keys(settings.hooks)) { + const matchers = settings.hooks[eventType]; + if (!Array.isArray(matchers)) continue; + for (let i = matchers.length - 1; i >= 0; i--) { + const matcher = matchers[i]; + if (!matcher.hooks) continue; + const before = matcher.hooks.length; + matcher.hooks = matcher.hooks.filter((h) => !isFailproofaiHook(h)); + removed += before - matcher.hooks.length; + if (matcher.hooks.length === 0) matchers.splice(i, 1); + } + if (matchers.length === 0) delete settings.hooks[eventType]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8"); + return removed; + } catch { + return 0; + } +} + +try { + const userClaudePath = resolve(homedir(), ".claude", "settings.json"); + const projectClaudePath = resolve(process.cwd(), ".claude", "settings.json"); + const localClaudePath = resolve(process.cwd(), ".claude", "settings.local.json"); + + let count = 0; + count += cleanClaudeSettings(userClaudePath); + count += cleanClaudeSettings(projectClaudePath); + count += cleanClaudeSettings(localClaudePath); + + if (count > 0) { + console.log(`[failproofai] Postuninstall: Cleaned up ${count} hook entry(ies) from Claude settings.`); + } +} catch { + // Fail-open: postuninstall script must never throw +}