All files / main hook-installer.ts

90.82% Statements 188/207
82.14% Branches 69/84
100% Functions 16/16
90.82% Lines 188/207

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 3651x                                             1x 1x 1x 1x                                                                       1x 1x 1x 1x 1x 1x 1x   1x 2x 2x 2x               37x 37x 37x 37x   21x 21x       21x 21x 21x     21x   15x 15x 15x 15x   75x 75x 75x 54x 54x 54x 54x   21x     21x 21x 21x 21x   17x 17x 17x 17x               1x 15x 15x   15x 75x           75x   75x 17x 2x 2x 2x 17x 17x       58x 23x 35x 75x 75x   15x 15x             1x 6x 6x   6x 16x 16x   16x 16x 18x 18x 18x   18x 4x 4x   16x 12x 16x 4x 4x 16x       6x 1x 1x   4x 4x   12x 12x 12x   5x 5x 5x 4x 4x 5x 5x       5x                     2x 2x 2x 2x 1x 1x 1x 1x 1x   2x 2x 2x 2x   2x 2x 2x 2x 2x 2x 2x 2x 2x               5x 5x 5x   5x 5x 5x 2x 5x 3x 3x   5x 5x 5x 5x 5x                 5x                 5x 5x 5x 5x 5x 5x 5x 3x 3x 3x   5x 5x 5x 5x   5x 1x 1x 1x   4x 4x 4x 4x   1x 1x 1x 1x 1x 1x 1x           1x 1x     1x 1x   1x         1x 1x 1x 1x             1x 2x 1x 1x 1x       2x 1x 2x 1x 1x  
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { logger } from "./logger";
 
/**
 * Automated install / uninstall of the claude-village hook entries in
 * `~/.claude/settings.json` (or the `CLAUDE_CONFIG_DIR`-overridden path).
 *
 * Pure helpers (`computeMerged`, `computeRemoved`) live here without any
 * filesystem or IPC coupling so they can be unit-tested directly. The
 * filesystem wrappers (`readSettings`, `writeSettingsAtomic`, etc.) are the
 * thin IO layer the IPC bridge calls.
 *
 * Merge rules:
 * - We own hook entries whose `hooks[].command` mentions our port (49251) on
 *   127.0.0.1. Any other user-authored hook is preserved untouched.
 * - Re-installing is idempotent: if an identical (matcher, command) entry
 *   already exists for an event, we skip it rather than duplicate.
 * - Uninstall removes only our owned entries, leaving user hooks intact.
 */
 
export const HOOK_PORT = 49251;
const HOOK_HOST = "127.0.0.1";
const HOOK_URL = `http://${HOOK_HOST}:${HOOK_PORT}/event`;
const HOOK_COMMAND = `curl -s --max-time 2 -X POST -H 'Content-Type: application/json' --data-binary @- ${HOOK_URL} >/dev/null 2>&1 || true`;
 
// The set of Claude Code hook event types we install into. Kept in sync with
// the matchers the hook-server recognises (`SessionStart`, `PreToolUse`,
// `PostToolUse`, `SubagentStart`, `Stop`).
type EventName = "PreToolUse" | "PostToolUse" | "SessionStart" | "SubagentStart" | "Stop";
 
interface HookEntry {
  type: string;
  command: string;
}
 
interface MatcherGroup {
  matcher?: string;
  hooks: HookEntry[];
  [k: string]: unknown;
}
 
interface HooksBlock {
  [event: string]: MatcherGroup[] | undefined;
}
 
interface SettingsShape {
  hooks?: HooksBlock;
  [k: string]: unknown;
}
 
interface DesiredEntry {
  event: EventName;
  matcher?: string;
}
 
// Events that should use the `.*` matcher (tool-use style). Others have no
// matcher key at all (session lifecycle events). Must match the shape of the
// snippet currently shown in SettingsScreen so a user hand-pasting the old
// snippet still gets recognised as "already installed".
const DESIRED: DesiredEntry[] = [
  { event: "PreToolUse", matcher: ".*" },
  { event: "PostToolUse", matcher: ".*" },
  { event: "SessionStart" },
  { event: "SubagentStart" },
  { event: "Stop" }
];
 
export function resolveSettingsPath(env: NodeJS.ProcessEnv = process.env): string {
  const dir = env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
  return path.join(dir, "settings.json");
}
 
/**
 * True when `cmd` is a claude-village hook command (targets our loopback port).
 * We match on the string form rather than parsing because Claude Code runs
 * the command via shell, so anything that POSTs to our port is "ours" for
 * merge / uninstall purposes.
 */
function isOurCommand(cmd: string): boolean {
  if (typeof cmd !== "string") return false;
  return cmd.includes(`${HOOK_HOST}:${HOOK_PORT}`);
}
 
function cloneSettings(input: unknown): SettingsShape {
  if (!input || typeof input !== "object") return {};
  // `structuredClone` is fine here - settings files are small and never
  // contain non-cloneable values like functions. Falls back to JSON on
  // older Node just in case.
  try {
    return structuredClone(input) as SettingsShape;
  } catch {
    return JSON.parse(JSON.stringify(input)) as SettingsShape;
  }
}
 
function ensureHooks(s: SettingsShape): HooksBlock {
  if (!s.hooks || typeof s.hooks !== "object") s.hooks = {};
  return s.hooks;
}
 
function ensureEventArr(hooks: HooksBlock, event: EventName): MatcherGroup[] {
  const existing = hooks[event];
  if (Array.isArray(existing)) return existing;
  const arr: MatcherGroup[] = [];
  hooks[event] = arr;
  return arr;
}
 
function groupMatches(g: MatcherGroup, matcher?: string): boolean {
  // Treat missing matcher as equivalent to `undefined`. Claude Code itself
  // treats an absent matcher as "no filter", same as our desired behaviour.
  const a = g.matcher ?? undefined;
  const b = matcher ?? undefined;
  return a === b;
}
 
function groupHasOurCommand(g: MatcherGroup): boolean {
  if (!Array.isArray(g.hooks)) return false;
  return g.hooks.some((h) => h && typeof h === "object" && isOurCommand(h.command));
}
 
/**
 * Return a new settings object with our hook entries installed.
 * Idempotent: running on an already-installed file returns an equivalent
 * structure (same JSON stringification up to key order of existing user
 * entries).
 */
export function computeMerged(current: unknown): SettingsShape {
  const next = cloneSettings(current);
  const hooks = ensureHooks(next);
 
  for (const d of DESIRED) {
    const arr = ensureEventArr(hooks, d.event);
 
    // Look for an existing group with the same matcher. If it already has
    // our command, we leave it alone. If it has user commands but not ours,
    // we append ours to the same group rather than creating a sibling, which
    // keeps the file tidy.
    const sameMatcherGroup = arr.find((g) => groupMatches(g, d.matcher));
 
    if (sameMatcherGroup) {
      if (!groupHasOurCommand(sameMatcherGroup)) {
        if (!Array.isArray(sameMatcherGroup.hooks)) sameMatcherGroup.hooks = [];
        sameMatcherGroup.hooks.push({ type: "command", command: HOOK_COMMAND });
      }
      continue;
    }
 
    // No group with that matcher exists. Add a fresh group (with or without
    // a matcher key to match the shape of the legacy snippet).
    const group: MatcherGroup = d.matcher
      ? { matcher: d.matcher, hooks: [{ type: "command", command: HOOK_COMMAND }] }
      : { hooks: [{ type: "command", command: HOOK_COMMAND }] };
    arr.push(group);
  }
 
  return next;
}
 
/**
 * Return a new settings object with only claude-village hook entries removed.
 * Leaves unrelated hooks and unrelated top-level keys untouched. Cleans up
 * now-empty groups and empty event arrays so the file stays minimal.
 */
export function computeRemoved(current: unknown): SettingsShape {
  const next = cloneSettings(current);
  if (!next.hooks || typeof next.hooks !== "object") return next;
 
  for (const event of Object.keys(next.hooks)) {
    const arr = next.hooks[event];
    if (!Array.isArray(arr)) continue;
 
    const cleaned: MatcherGroup[] = [];
    for (const g of arr) {
      if (!g || typeof g !== "object") continue;
      const keptHooks = Array.isArray(g.hooks)
        ? g.hooks.filter((h) => !isOurCommand(h?.command))
        : [];
      if (keptHooks.length === 0) continue;
      cleaned.push({ ...g, hooks: keptHooks });
    }
 
    if (cleaned.length === 0) {
      delete next.hooks[event];
    } else {
      next.hooks[event] = cleaned;
    }
  }
 
  // If hooks is now empty, drop the key entirely so we don't leave a dangling
  // `"hooks": {}` behind.
  if (Object.keys(next.hooks).length === 0) {
    delete next.hooks;
  }
 
  return next;
}
 
function formatJson(value: unknown): string {
  return JSON.stringify(value, null, 2) + "\n";
}
 
function parseSettingsText(text: string): SettingsShape {
  const trimmed = text.trim();
  if (trimmed === "") return {};
  try {
    const parsed = JSON.parse(trimmed);
    return parsed && typeof parsed === "object" ? (parsed as SettingsShape) : {};
  } catch (err) {
    const e = err instanceof Error ? err : new Error(String(err));
    throw new Error(`settings.json is not valid JSON: ${e.message}`);
  }
}
 
export interface HookReadResult {
  settingsPath: string;
  currentText: string;
  currentParsed: SettingsShape;
  mergedText: string;
  diffText: string;
  isInstalled: boolean;
}
 
export async function readSettings(settingsPath = resolveSettingsPath()): Promise<HookReadResult> {
  let currentText = "";
  try {
    currentText = await fsp.readFile(settingsPath, "utf8");
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code !== "ENOENT") throw err;
    currentText = "";
  }
 
  const currentParsed = parseSettingsText(currentText);
  const merged = computeMerged(currentParsed);
  const mergedText = formatJson(merged);
  const normalizedCurrent = currentText.trim() === "" ? "{}\n" : formatJson(currentParsed);
 
  return {
    settingsPath,
    currentText,
    currentParsed,
    mergedText,
    diffText: buildDiff(normalizedCurrent, mergedText),
    isInstalled: normalizedCurrent === mergedText
  };
}
 
/**
 * Atomic write: write to a sibling temp file in the same directory, then
 * rename. Rename is atomic within a filesystem on POSIX, so readers never
 * see a half-written file. Preserves the file mode of the existing
 * settings.json if it exists.
 */
export async function writeSettingsAtomic(settingsPath: string, contents: string): Promise<void> {
  const dir = path.dirname(settingsPath);
  await fsp.mkdir(dir, { recursive: true });
 
  let mode: number | undefined;
  try {
    const st = await fsp.stat(settingsPath);
    mode = st.mode & 0o777;
  } catch {
    mode = 0o600;
  }
 
  const tmp = path.join(dir, `.settings.json.${process.pid}.${Date.now()}.tmp`);
  await fsp.writeFile(tmp, contents, { encoding: "utf8", mode });
  try {
    await fsp.rename(tmp, settingsPath);
  } catch (err) {
    // Best-effort cleanup of the temp file before re-throwing.
    try {
      await fsp.unlink(tmp);
    } catch {
      /* ignore */
    }
    throw err;
  }
}
 
export interface HookMutationResult {
  settingsPath: string;
  previousText: string;
  nextText: string;
  changed: boolean;
}
 
export async function installHook(
  settingsPath = resolveSettingsPath()
): Promise<HookMutationResult> {
  let previousText = "";
  try {
    previousText = await fsp.readFile(settingsPath, "utf8");
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code !== "ENOENT") throw err;
  }
 
  const parsed = previousText.trim() === "" ? {} : parseSettingsText(previousText);
  const merged = computeMerged(parsed);
  const nextText = formatJson(merged);
  const normalizedPrev = previousText.trim() === "" ? "{}\n" : formatJson(parsed);
 
  if (normalizedPrev === nextText && fs.existsSync(settingsPath)) {
    logger.info("hook-installer install: already installed, no write");
    return { settingsPath, previousText, nextText, changed: false };
  }
 
  await writeSettingsAtomic(settingsPath, nextText);
  logger.info("hook-installer install: wrote settings", { settingsPath });
  return { settingsPath, previousText, nextText, changed: true };
}
 
export async function uninstallHook(
  settingsPath = resolveSettingsPath()
): Promise<HookMutationResult> {
  let previousText = "";
  try {
    previousText = await fsp.readFile(settingsPath, "utf8");
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code !== "ENOENT") throw err;
    return { settingsPath, previousText: "", nextText: "", changed: false };
  }
 
  const parsed = parseSettingsText(previousText);
  const removed = computeRemoved(parsed);
  // Preserve "empty object" form rather than writing literally "{}\n" when
  // the user started with a more complex file that only contained our hooks.
  const nextText = formatJson(removed);
  const normalizedPrev = formatJson(parsed);
 
  if (normalizedPrev === nextText) {
    logger.info("hook-installer uninstall: nothing to remove");
    return { settingsPath, previousText, nextText, changed: false };
  }
 
  await writeSettingsAtomic(settingsPath, nextText);
  logger.info("hook-installer uninstall: wrote settings", { settingsPath });
  return { settingsPath, previousText, nextText, changed: true };
}
 
/**
 * Minimal unified-diff-ish renderer. We avoid pulling in a diff library to
 * stay under the "no new runtime deps" constraint, and a simple line-by-line
 * `- old / + new` view is plenty for the confirmation dialog.
 */
export function buildDiff(before: string, after: string): string {
  if (before === after) return "(no changes)";
  const b = before.split("\n");
  const a = after.split("\n");
  const out: string[] = [];
  // Emit both blocks verbatim with +/- prefixes. This is not an LCS diff but
  // it gives the user an unambiguous before/after they can eyeball, and the
  // renderer also shows the full merged JSON separately.
  for (const line of b) out.push(line === "" ? "-" : `- ${line}`);
  out.push("---");
  for (const line of a) out.push(line === "" ? "+" : `+ ${line}`);
  return out.join("\n");
}