feat: support plugin-managed hooks
This commit is contained in:
@@ -11,6 +11,7 @@ Manage agent hooks (event-driven automations for commands like `/new`, `/reset`,
|
||||
|
||||
Related:
|
||||
- Hooks: [Hooks](/hooks)
|
||||
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
|
||||
|
||||
## List All Hooks
|
||||
|
||||
@@ -118,6 +119,9 @@ clawdbot hooks enable <name>
|
||||
|
||||
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
||||
|
||||
**Note:** Hooks managed by plugins show `plugin:<id>` in `clawdbot hooks list` and
|
||||
can’t be enabled/disabled here. Enable/disable the plugin instead.
|
||||
|
||||
**Arguments:**
|
||||
- `<name>`: Hook name (e.g., `session-memory`)
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ Hooks are small scripts that run when something happens. There are two kinds:
|
||||
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||
|
||||
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
|
||||
|
||||
Common uses:
|
||||
- Save a memory snapshot when you reset a session
|
||||
|
||||
@@ -215,6 +215,27 @@ Plugins export either:
|
||||
- A function: `(api) => { ... }`
|
||||
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
||||
|
||||
## Plugin hooks
|
||||
|
||||
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
|
||||
event-driven automation without a separate hook pack install.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
|
||||
|
||||
export default function register(api) {
|
||||
registerPluginHooksFromDir(api, "./hooks");
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
|
||||
- Hook eligibility rules still apply (OS/bins/env/config requirements).
|
||||
- Plugin-managed hooks show up in `clawdbot hooks list` with `plugin:<id>`.
|
||||
- You cannot enable/disable plugin-managed hooks via `clawdbot hooks`; enable/disable the plugin instead.
|
||||
|
||||
## Provider plugins (model auth)
|
||||
|
||||
Plugins can register **model provider auth** flows so users can run OAuth or
|
||||
|
||||
@@ -10,6 +10,7 @@ const report: HookStatusReport = {
|
||||
name: "session-memory",
|
||||
description: "Save session context to memory",
|
||||
source: "clawdbot-bundled",
|
||||
pluginId: undefined,
|
||||
filePath: "/tmp/hooks/session-memory/HOOK.md",
|
||||
baseDir: "/tmp/hooks/session-memory",
|
||||
handlerPath: "/tmp/hooks/session-memory/handler.js",
|
||||
@@ -20,6 +21,7 @@ const report: HookStatusReport = {
|
||||
always: false,
|
||||
disabled: false,
|
||||
eligible: true,
|
||||
managedByPlugin: false,
|
||||
requirements: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
@@ -51,4 +53,49 @@ describe("hooks cli formatting", () => {
|
||||
const output = formatHooksCheck(report, {});
|
||||
expect(output).toContain("Hooks Status");
|
||||
});
|
||||
|
||||
it("labels plugin-managed hooks with plugin id", () => {
|
||||
const pluginReport: HookStatusReport = {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedHooksDir: "/tmp/hooks",
|
||||
hooks: [
|
||||
{
|
||||
name: "plugin-hook",
|
||||
description: "Hook from plugin",
|
||||
source: "clawdbot-plugin",
|
||||
pluginId: "voice-call",
|
||||
filePath: "/tmp/hooks/plugin-hook/HOOK.md",
|
||||
baseDir: "/tmp/hooks/plugin-hook",
|
||||
handlerPath: "/tmp/hooks/plugin-hook/handler.js",
|
||||
hookKey: "plugin-hook",
|
||||
emoji: "🔗",
|
||||
homepage: undefined,
|
||||
events: ["command:new"],
|
||||
always: false,
|
||||
disabled: false,
|
||||
eligible: true,
|
||||
managedByPlugin: true,
|
||||
requirements: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
missing: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
configChecks: [],
|
||||
install: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const output = formatHooksList(pluginReport, {});
|
||||
expect(output).toContain("plugin:voice-call");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
type HookStatusEntry,
|
||||
type HookStatusReport,
|
||||
} from "../hooks/hooks-status.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/io.js";
|
||||
import {
|
||||
installHooksFromNpmSpec,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
resolveHookInstallDir,
|
||||
} from "../hooks/install.js";
|
||||
import { recordHookInstall } from "../hooks/installs.js";
|
||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
@@ -42,6 +45,29 @@ export type HooksUpdateOptions = {
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
function mergeHookEntries(
|
||||
pluginEntries: HookEntry[],
|
||||
workspaceEntries: HookEntry[],
|
||||
): HookEntry[] {
|
||||
const merged = new Map<string, HookEntry>();
|
||||
for (const entry of pluginEntries) {
|
||||
merged.set(entry.hook.name, entry);
|
||||
}
|
||||
for (const entry of workspaceEntries) {
|
||||
merged.set(entry.hook.name, entry);
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function buildHooksReport(config: ClawdbotConfig): HookStatusReport {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const workspaceEntries = loadWorkspaceHookEntries(workspaceDir, { config });
|
||||
const pluginReport = buildPluginStatusReport({ config, workspaceDir });
|
||||
const pluginEntries = pluginReport.hooks.map((hook) => hook.entry);
|
||||
const entries = mergeHookEntries(pluginEntries, workspaceEntries);
|
||||
return buildWorkspaceHookStatus(workspaceDir, { config, entries });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single hook for display in the list
|
||||
*/
|
||||
@@ -58,6 +84,9 @@ function formatHookLine(hook: HookStatusEntry, verbose = false): string {
|
||||
const desc = chalk.gray(
|
||||
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
|
||||
);
|
||||
const sourceLabel = hook.managedByPlugin
|
||||
? chalk.magenta(`plugin:${hook.pluginId ?? "unknown"}`)
|
||||
: "";
|
||||
|
||||
if (verbose) {
|
||||
const missing: string[] = [];
|
||||
@@ -77,10 +106,12 @@ function formatHookLine(hook: HookStatusEntry, verbose = false): string {
|
||||
missing.push(`os: ${hook.missing.os.join(", ")}`);
|
||||
}
|
||||
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
|
||||
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
|
||||
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
|
||||
return `${emoji} ${name} ${status}${missingStr}\n ${desc}${sourceSuffix}`;
|
||||
}
|
||||
|
||||
return `${emoji} ${name} ${status} - ${desc}`;
|
||||
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
|
||||
return `${emoji} ${name} ${status} - ${desc}${sourceSuffix}`;
|
||||
}
|
||||
|
||||
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||
@@ -110,9 +141,11 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
|
||||
eligible: h.eligible,
|
||||
disabled: h.disabled,
|
||||
source: h.source,
|
||||
pluginId: h.pluginId,
|
||||
events: h.events,
|
||||
homepage: h.homepage,
|
||||
missing: h.missing,
|
||||
managedByPlugin: h.managedByPlugin,
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(jsonReport, null, 2);
|
||||
@@ -186,7 +219,11 @@ export function formatHookInfo(
|
||||
|
||||
// Details
|
||||
lines.push(chalk.bold("Details:"));
|
||||
lines.push(` Source: ${hook.source}`);
|
||||
if (hook.managedByPlugin) {
|
||||
lines.push(` Source: ${hook.source} (${hook.pluginId ?? "unknown"})`);
|
||||
} else {
|
||||
lines.push(` Source: ${hook.source}`);
|
||||
}
|
||||
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
|
||||
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
|
||||
if (hook.homepage) {
|
||||
@@ -195,6 +232,9 @@ export function formatHookInfo(
|
||||
if (hook.events.length > 0) {
|
||||
lines.push(` Events: ${hook.events.join(", ")}`);
|
||||
}
|
||||
if (hook.managedByPlugin) {
|
||||
lines.push(` Managed by plugin; enable/disable via hooks CLI not available.`);
|
||||
}
|
||||
|
||||
// Requirements
|
||||
const hasRequirements =
|
||||
@@ -302,14 +342,19 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
|
||||
|
||||
export async function enableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const report = buildHooksReport(config);
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
if (hook.managedByPlugin) {
|
||||
throw new Error(
|
||||
`Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hook.eligible) {
|
||||
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
|
||||
}
|
||||
@@ -336,14 +381,19 @@ export async function enableHook(hookName: string): Promise<void> {
|
||||
|
||||
export async function disableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const report = buildHooksReport(config);
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
if (hook.managedByPlugin) {
|
||||
throw new Error(
|
||||
`Hook "${hookName}" is managed by plugin "${hook.pluginId ?? "unknown"}" and cannot be enabled/disabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update config
|
||||
const entries = { ...config.hooks?.internal?.entries };
|
||||
entries[hookName] = { ...entries[hookName], enabled: false };
|
||||
@@ -382,8 +432,7 @@ export function registerHooksCli(program: Command): void {
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const report = buildHooksReport(config);
|
||||
console.log(formatHooksList(report, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
@@ -398,8 +447,7 @@ export function registerHooksCli(program: Command): void {
|
||||
.action(async (name, opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const report = buildHooksReport(config);
|
||||
console.log(formatHookInfo(report, name, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
@@ -414,8 +462,7 @@ export function registerHooksCli(program: Command): void {
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const report = buildHooksReport(config);
|
||||
console.log(formatHooksCheck(report, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
@@ -765,8 +812,7 @@ export function registerHooksCli(program: Command): void {
|
||||
hooks.action(async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const report = buildHooksReport(config);
|
||||
console.log(formatHooksList(report, {}));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
|
||||
@@ -48,12 +48,34 @@ describe("onboard-hooks", () => {
|
||||
name: "session-memory",
|
||||
description: "Save session context to memory when /new command is issued",
|
||||
source: "clawdbot-bundled",
|
||||
pluginId: undefined,
|
||||
filePath: "/mock/workspace/hooks/session-memory/HOOK.md",
|
||||
baseDir: "/mock/workspace/hooks/session-memory",
|
||||
handlerPath: "/mock/workspace/hooks/session-memory/handler.js",
|
||||
hookKey: "session-memory",
|
||||
emoji: "💾",
|
||||
events: ["command:new"],
|
||||
homepage: undefined,
|
||||
always: false,
|
||||
disabled: false,
|
||||
eligible,
|
||||
requirements: { config: ["workspace.dir"] },
|
||||
missing: {},
|
||||
managedByPlugin: false,
|
||||
requirements: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: ["workspace.dir"],
|
||||
os: [],
|
||||
},
|
||||
missing: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: eligible ? [] : ["workspace.dir"],
|
||||
os: [],
|
||||
},
|
||||
configChecks: [],
|
||||
install: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
const base: PluginRegistry = {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
115
src/hooks/plugin-hooks.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -162,3 +162,5 @@ export { createMemoryGetTool, createMemorySearchTool } from "../agents/tools/mem
|
||||
export { registerMemoryCli } from "../cli/memory-cli.js";
|
||||
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export type { HookEntry } from "../hooks/types.js";
|
||||
export { registerPluginHooksFromDir } from "../hooks/plugin-hooks.js";
|
||||
|
||||
@@ -264,6 +264,7 @@ function createPluginRecord(params: {
|
||||
enabled: params.enabled,
|
||||
status: params.enabled ? "loaded" : "disabled",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
gatewayMethods: [],
|
||||
|
||||
@@ -5,12 +5,14 @@ import type {
|
||||
GatewayRequestHandler,
|
||||
GatewayRequestHandlers,
|
||||
} from "../gateway/server-methods/types.js";
|
||||
import { registerInternalHook } from "../hooks/internal-hooks.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type {
|
||||
ClawdbotPluginApi,
|
||||
ClawdbotPluginChannelRegistration,
|
||||
ClawdbotPluginCliRegistrar,
|
||||
ClawdbotPluginHttpHandler,
|
||||
ClawdbotPluginHookOptions,
|
||||
ProviderPlugin,
|
||||
ClawdbotPluginService,
|
||||
ClawdbotPluginToolContext,
|
||||
@@ -22,6 +24,8 @@ import type {
|
||||
PluginKind,
|
||||
} from "./types.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import path from "node:path";
|
||||
|
||||
export type PluginToolRegistration = {
|
||||
pluginId: string;
|
||||
@@ -57,6 +61,13 @@ export type PluginProviderRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginHookRegistration = {
|
||||
pluginId: string;
|
||||
entry: HookEntry;
|
||||
events: string[];
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginServiceRegistration = {
|
||||
pluginId: string;
|
||||
service: ClawdbotPluginService;
|
||||
@@ -76,6 +87,7 @@ export type PluginRecord = {
|
||||
status: "loaded" | "disabled" | "error";
|
||||
error?: string;
|
||||
toolNames: string[];
|
||||
hookNames: string[];
|
||||
channelIds: string[];
|
||||
providerIds: string[];
|
||||
gatewayMethods: string[];
|
||||
@@ -90,6 +102,7 @@ export type PluginRecord = {
|
||||
export type PluginRegistry = {
|
||||
plugins: PluginRecord[];
|
||||
tools: PluginToolRegistration[];
|
||||
hooks: PluginHookRegistration[];
|
||||
channels: PluginChannelRegistration[];
|
||||
providers: PluginProviderRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
@@ -109,6 +122,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const registry: PluginRegistry = {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
@@ -150,6 +164,76 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
};
|
||||
|
||||
const registerHook = (
|
||||
record: PluginRecord,
|
||||
events: string | string[],
|
||||
handler: Parameters<typeof registerInternalHook>[1],
|
||||
opts: ClawdbotPluginHookOptions | undefined,
|
||||
config: ClawdbotPluginApi["config"],
|
||||
) => {
|
||||
const eventList = Array.isArray(events) ? events : [events];
|
||||
const normalizedEvents = eventList.map((event) => event.trim()).filter(Boolean);
|
||||
const entry = opts?.entry ?? null;
|
||||
const name = entry?.hook.name ?? opts?.name?.trim();
|
||||
if (!name) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "hook registration missing name",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const description = entry?.hook.description ?? opts?.description ?? "";
|
||||
const hookEntry: HookEntry = entry
|
||||
? {
|
||||
...entry,
|
||||
hook: {
|
||||
...entry.hook,
|
||||
name,
|
||||
description,
|
||||
source: "clawdbot-plugin",
|
||||
pluginId: record.id,
|
||||
},
|
||||
clawdbot: {
|
||||
...entry.clawdbot,
|
||||
events: normalizedEvents,
|
||||
},
|
||||
}
|
||||
: {
|
||||
hook: {
|
||||
name,
|
||||
description,
|
||||
source: "clawdbot-plugin",
|
||||
pluginId: record.id,
|
||||
filePath: record.source,
|
||||
baseDir: path.dirname(record.source),
|
||||
handlerPath: record.source,
|
||||
},
|
||||
frontmatter: {},
|
||||
clawdbot: { events: normalizedEvents },
|
||||
invocation: { enabled: true },
|
||||
};
|
||||
|
||||
record.hookNames.push(name);
|
||||
registry.hooks.push({
|
||||
pluginId: record.id,
|
||||
entry: hookEntry,
|
||||
events: normalizedEvents,
|
||||
source: record.source,
|
||||
});
|
||||
|
||||
const hookSystemEnabled = config?.hooks?.internal?.enabled === true;
|
||||
if (!hookSystemEnabled || opts?.register === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const event of normalizedEvents) {
|
||||
registerInternalHook(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
const registerGatewayMethod = (
|
||||
record: PluginRecord,
|
||||
method: string,
|
||||
@@ -287,6 +371,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
runtime: registryParams.runtime,
|
||||
logger: normalizeLogger(registryParams.logger),
|
||||
registerTool: (tool, opts) => registerTool(record, tool, opts),
|
||||
registerHook: (events, handler, opts) =>
|
||||
registerHook(record, events, handler, opts, params.config),
|
||||
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
|
||||
registerChannel: (registration) => registerChannel(record, registration),
|
||||
registerProvider: (provider) => registerProvider(record, provider),
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { InternalHookHandler } from "../hooks/internal-hooks.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
@@ -71,6 +73,13 @@ export type ClawdbotPluginToolOptions = {
|
||||
optional?: boolean;
|
||||
};
|
||||
|
||||
export type ClawdbotPluginHookOptions = {
|
||||
entry?: HookEntry;
|
||||
name?: string;
|
||||
description?: string;
|
||||
register?: boolean;
|
||||
};
|
||||
|
||||
export type ProviderAuthKind = "oauth" | "api_key" | "token" | "device_code" | "custom";
|
||||
|
||||
export type ProviderAuthResult = {
|
||||
@@ -179,6 +188,11 @@ export type ClawdbotPluginApi = {
|
||||
tool: AnyAgentTool | ClawdbotPluginToolFactory,
|
||||
opts?: ClawdbotPluginToolOptions,
|
||||
) => void;
|
||||
registerHook: (
|
||||
events: string | string[],
|
||||
handler: InternalHookHandler,
|
||||
opts?: ClawdbotPluginHookOptions,
|
||||
) => void;
|
||||
registerHttpHandler: (handler: ClawdbotPluginHttpHandler) => void;
|
||||
registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;
|
||||
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
|
||||
|
||||
Reference in New Issue
Block a user