feat: support plugin-managed hooks

This commit is contained in:
Peter Steinberger
2026-01-18 05:56:59 +00:00
parent 88b37e80fc
commit e2c10a2b7a
16 changed files with 436 additions and 26 deletions

View File

@@ -73,11 +73,12 @@ export function shouldIncludeHook(params: {
const { entry, config, eligibility } = params;
const hookKey = resolveHookKey(entry.hook.name, entry);
const hookConfig = resolveHookConfig(config, hookKey);
const pluginManaged = entry.hook.source === "clawdbot-plugin";
const osList = entry.clawdbot?.os ?? [];
const remotePlatforms = eligibility?.remote?.platforms ?? [];
// Check if explicitly disabled
if (hookConfig?.enabled === false) return false;
if (!pluginManaged && hookConfig?.enabled === false) return false;
// Check OS requirement
if (

View File

@@ -23,6 +23,7 @@ export type HookStatusEntry = {
name: string;
description: string;
source: string;
pluginId?: string;
filePath: string;
baseDir: string;
handlerPath: string;
@@ -33,6 +34,7 @@ export type HookStatusEntry = {
always: boolean;
disabled: boolean;
eligible: boolean;
managedByPlugin: boolean;
requirements: {
bins: string[];
anyBins: string[];
@@ -94,7 +96,8 @@ function buildHookStatus(
): HookStatusEntry {
const hookKey = resolveHookKey(entry);
const hookConfig = resolveHookConfig(config, hookKey);
const disabled = hookConfig?.enabled === false;
const managedByPlugin = entry.hook.source === "clawdbot-plugin";
const disabled = managedByPlugin ? false : hookConfig?.enabled === false;
const always = entry.clawdbot?.always === true;
const emoji = entry.clawdbot?.emoji ?? entry.frontmatter.emoji;
const homepageRaw =
@@ -171,6 +174,7 @@ function buildHookStatus(
name: entry.hook.name,
description: entry.hook.description,
source: entry.hook.source,
pluginId: entry.hook.pluginId,
filePath: entry.hook.filePath,
baseDir: entry.hook.baseDir,
handlerPath: entry.hook.handlerPath,
@@ -181,6 +185,7 @@ function buildHookStatus(
always,
disabled,
eligible,
managedByPlugin,
requirements: {
bins: requiredBins,
anyBins: requiredAnyBins,

115
src/hooks/plugin-hooks.ts Normal file
View File

@@ -0,0 +1,115 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { ClawdbotPluginApi } from "../plugins/types.js";
import type { HookEntry } from "./types.js";
import { shouldIncludeHook } from "./config.js";
import { loadHookEntriesFromDir } from "./workspace.js";
import type { InternalHookHandler } from "./internal-hooks.js";
export type PluginHookLoadResult = {
hooks: HookEntry[];
loaded: number;
skipped: number;
errors: string[];
};
function resolveHookDir(api: ClawdbotPluginApi, dir: string): string {
if (path.isAbsolute(dir)) return dir;
return path.resolve(path.dirname(api.source), dir);
}
function normalizePluginHookEntry(api: ClawdbotPluginApi, entry: HookEntry): HookEntry {
return {
...entry,
hook: {
...entry.hook,
source: "clawdbot-plugin",
pluginId: api.id,
},
clawdbot: {
...entry.clawdbot,
hookKey: entry.clawdbot?.hookKey ?? `${api.id}:${entry.hook.name}`,
events: entry.clawdbot?.events ?? [],
},
};
}
async function loadHookHandler(
entry: HookEntry,
api: ClawdbotPluginApi,
): Promise<InternalHookHandler | null> {
try {
const url = pathToFileURL(entry.hook.handlerPath).href;
const cacheBustedUrl = `${url}?t=${Date.now()}`;
const mod = (await import(cacheBustedUrl)) as Record<string, unknown>;
const exportName = entry.clawdbot?.export ?? "default";
const handler = mod[exportName];
if (typeof handler === "function") {
return handler as InternalHookHandler;
}
api.logger.warn?.(`[hooks] ${entry.hook.name} handler is not a function`);
return null;
} catch (err) {
api.logger.warn?.(`[hooks] Failed to load ${entry.hook.name}: ${String(err)}`);
return null;
}
}
export async function registerPluginHooksFromDir(
api: ClawdbotPluginApi,
dir: string,
): Promise<PluginHookLoadResult> {
const resolvedDir = resolveHookDir(api, dir);
const hooks = loadHookEntriesFromDir({
dir: resolvedDir,
source: "clawdbot-plugin",
pluginId: api.id,
});
const result: PluginHookLoadResult = {
hooks,
loaded: 0,
skipped: 0,
errors: [],
};
for (const entry of hooks) {
const normalizedEntry = normalizePluginHookEntry(api, entry);
const events = normalizedEntry.clawdbot?.events ?? [];
if (events.length === 0) {
api.logger.warn?.(`[hooks] ${entry.hook.name} has no events; skipping`);
api.registerHook(events, async () => undefined, {
entry: normalizedEntry,
register: false,
});
result.skipped += 1;
continue;
}
const handler = await loadHookHandler(entry, api);
if (!handler) {
result.errors.push(`[hooks] Failed to load ${entry.hook.name}`);
api.registerHook(events, async () => undefined, {
entry: normalizedEntry,
register: false,
});
result.skipped += 1;
continue;
}
const eligible = shouldIncludeHook({ entry: normalizedEntry, config: api.config });
api.registerHook(events, handler, {
entry: normalizedEntry,
register: eligible,
});
if (eligible) {
result.loaded += 1;
} else {
result.skipped += 1;
}
}
return result;
}

View File

@@ -35,12 +35,15 @@ export type ParsedHookFrontmatter = Record<string, string>;
export type Hook = {
name: string;
description: string;
source: "clawdbot-bundled" | "clawdbot-managed" | "clawdbot-workspace";
source: "clawdbot-bundled" | "clawdbot-managed" | "clawdbot-workspace" | "clawdbot-plugin";
pluginId?: string;
filePath: string; // Path to HOOK.md
baseDir: string; // Directory containing hook
handlerPath: string; // Path to handler module (handler.ts/js)
};
export type HookSource = Hook["source"];
export type HookEntry = {
hook: Hook;
frontmatter: ParsedHookFrontmatter;

View File

@@ -15,6 +15,7 @@ import type {
HookEligibilityContext,
HookEntry,
HookSnapshot,
HookSource,
ParsedHookFrontmatter,
} from "./types.js";
@@ -50,7 +51,8 @@ function resolvePackageHooks(manifest: HookPackageManifest): string[] {
function loadHookFromDir(params: {
hookDir: string;
source: string;
source: HookSource;
pluginId?: string;
nameHint?: string;
}): Hook | null {
const hookMdPath = path.join(params.hookDir, "HOOK.md");
@@ -82,6 +84,7 @@ function loadHookFromDir(params: {
name,
description,
source: params.source as Hook["source"],
pluginId: params.pluginId,
filePath: hookMdPath,
baseDir: params.hookDir,
handlerPath,
@@ -95,8 +98,8 @@ function loadHookFromDir(params: {
/**
* Scan a directory for hooks (subdirectories containing HOOK.md)
*/
function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
const { dir, source } = params;
function loadHooksFromDir(params: { dir: string; source: HookSource; pluginId?: string }): Hook[] {
const { dir, source, pluginId } = params;
if (!fs.existsSync(dir)) return [];
@@ -119,6 +122,7 @@ function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
const hook = loadHookFromDir({
hookDir: resolvedHookDir,
source,
pluginId,
nameHint: path.basename(resolvedHookDir),
});
if (hook) hooks.push(hook);
@@ -126,13 +130,50 @@ function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
continue;
}
const hook = loadHookFromDir({ hookDir, source, nameHint: entry.name });
const hook = loadHookFromDir({
hookDir,
source,
pluginId,
nameHint: entry.name,
});
if (hook) hooks.push(hook);
}
return hooks;
}
export function loadHookEntriesFromDir(params: {
dir: string;
source: HookSource;
pluginId?: string;
}): HookEntry[] {
const hooks = loadHooksFromDir({
dir: params.dir,
source: params.source,
pluginId: params.pluginId,
});
return hooks.map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
try {
const raw = fs.readFileSync(hook.filePath, "utf-8");
frontmatter = parseFrontmatter(raw);
} catch {
// ignore malformed hooks
}
const entry: HookEntry = {
hook: {
...hook,
source: params.source,
pluginId: params.pluginId,
},
frontmatter,
clawdbot: resolveClawdbotMetadata(frontmatter),
invocation: resolveHookInvocationPolicy(frontmatter),
};
return entry;
});
}
function loadHookEntries(
workspaceDir: string,
opts?: {
@@ -178,7 +219,7 @@ function loadHookEntries(
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) => {
return Array.from(merged.values()).map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
try {
const raw = fs.readFileSync(hook.filePath, "utf-8");
@@ -193,7 +234,6 @@ function loadHookEntries(
invocation: resolveHookInvocationPolicy(frontmatter),
};
});
return hookEntries;
}
export function buildWorkspaceHookSnapshot(