feat: add internal hooks system

This commit is contained in:
Peter Steinberger
2026-01-17 01:31:39 +00:00
parent a76cbc43bb
commit faba508fe0
39 changed files with 4241 additions and 28 deletions

197
src/hooks/workspace.ts Normal file
View File

@@ -0,0 +1,197 @@
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<string, Hook>();
// 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);
}