import fs from "node:fs"; import path from "node:path"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { resolveBundledHooksDir } from "./bundled-dir.js"; import { shouldIncludeHook } from "./config.js"; import { parseFrontmatter, resolveClawdbotMetadata, resolveHookInvocationPolicy, } from "./frontmatter.js"; import type { Hook, HookEligibilityContext, HookEntry, HookSnapshot, ParsedHookFrontmatter, } from "./types.js"; function filterHookEntries( entries: HookEntry[], config?: ClawdbotConfig, eligibility?: HookEligibilityContext, ): HookEntry[] { return entries.filter((entry) => shouldIncludeHook({ entry, config, eligibility })); } /** * Scan a directory for hooks (subdirectories containing HOOK.md) */ function loadHooksFromDir(params: { dir: string; source: string }): Hook[] { const { dir, source } = params; // Check if directory exists if (!fs.existsSync(dir)) return []; const stat = fs.statSync(dir); if (!stat.isDirectory()) return []; const hooks: Hook[] = []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const hookDir = path.join(dir, entry.name); const hookMdPath = path.join(hookDir, "HOOK.md"); // Skip if no HOOK.md file if (!fs.existsSync(hookMdPath)) continue; try { // Read HOOK.md to extract name and description const content = fs.readFileSync(hookMdPath, "utf-8"); const frontmatter = parseFrontmatter(content); const name = frontmatter.name || entry.name; const description = frontmatter.description || ""; // Locate handler file (handler.ts, handler.js, index.ts, index.js) const handlerCandidates = ["handler.ts", "handler.js", "index.ts", "index.js"]; let handlerPath: string | undefined; for (const candidate of handlerCandidates) { const candidatePath = path.join(hookDir, candidate); if (fs.existsSync(candidatePath)) { handlerPath = candidatePath; break; } } // Skip if no handler file found if (!handlerPath) { console.warn(`[hooks] Hook "${name}" has HOOK.md but no handler file in ${hookDir}`); continue; } hooks.push({ name, description, source: source as Hook["source"], filePath: hookMdPath, baseDir: hookDir, handlerPath, }); } catch (err) { console.warn(`[hooks] Failed to load hook from ${hookDir}:`, err); continue; } } return hooks; } function loadHookEntries( workspaceDir: string, opts?: { config?: ClawdbotConfig; managedHooksDir?: string; bundledHooksDir?: string; }, ): HookEntry[] { const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks"); const workspaceHooksDir = path.join(workspaceDir, "hooks"); const bundledHooksDir = opts?.bundledHooksDir ?? resolveBundledHooksDir(); const extraDirsRaw = opts?.config?.hooks?.internal?.load?.extraDirs ?? []; const extraDirs = extraDirsRaw .map((d) => (typeof d === "string" ? d.trim() : "")) .filter(Boolean); const bundledHooks = bundledHooksDir ? loadHooksFromDir({ dir: bundledHooksDir, source: "clawdbot-bundled", }) : []; const extraHooks = extraDirs.flatMap((dir) => { const resolved = resolveUserPath(dir); return loadHooksFromDir({ dir: resolved, source: "clawdbot-workspace", // Extra dirs treated as workspace }); }); const managedHooks = loadHooksFromDir({ dir: managedHooksDir, source: "clawdbot-managed", }); const workspaceHooks = loadHooksFromDir({ dir: workspaceHooksDir, source: "clawdbot-workspace", }); const merged = new Map(); // Precedence: extra < bundled < managed < workspace (workspace wins) for (const hook of extraHooks) merged.set(hook.name, hook); for (const hook of bundledHooks) merged.set(hook.name, hook); for (const hook of managedHooks) merged.set(hook.name, hook); for (const hook of workspaceHooks) merged.set(hook.name, hook); const hookEntries: HookEntry[] = Array.from(merged.values()).map((hook) => { let frontmatter: ParsedHookFrontmatter = {}; try { const raw = fs.readFileSync(hook.filePath, "utf-8"); frontmatter = parseFrontmatter(raw); } catch { // ignore malformed hooks } return { hook, frontmatter, clawdbot: resolveClawdbotMetadata(frontmatter), invocation: resolveHookInvocationPolicy(frontmatter), }; }); return hookEntries; } export function buildWorkspaceHookSnapshot( workspaceDir: string, opts?: { config?: ClawdbotConfig; managedHooksDir?: string; bundledHooksDir?: string; entries?: HookEntry[]; eligibility?: HookEligibilityContext; snapshotVersion?: number; }, ): HookSnapshot { const hookEntries = opts?.entries ?? loadHookEntries(workspaceDir, opts); const eligible = filterHookEntries(hookEntries, opts?.config, opts?.eligibility); return { hooks: eligible.map((entry) => ({ name: entry.hook.name, events: entry.clawdbot?.events ?? [], })), resolvedHooks: eligible.map((entry) => entry.hook), version: opts?.snapshotVersion, }; } export function loadWorkspaceHookEntries( workspaceDir: string, opts?: { config?: ClawdbotConfig; managedHooksDir?: string; bundledHooksDir?: string; }, ): HookEntry[] { return loadHookEntries(workspaceDir, opts); }