refactor: rename hooks docs and add tests

This commit is contained in:
Peter Steinberger
2026-01-17 07:32:50 +00:00
parent 0c0d9e1d22
commit 34d59d7913
25 changed files with 384 additions and 85 deletions

View File

@@ -1,6 +1,6 @@
# Bundled Internal Hooks
# Bundled Hooks
This directory contains internal hooks that ship with Clawdbot. These hooks are automatically discovered and can be enabled/disabled via CLI or configuration.
This directory contains hooks that ship with Clawdbot. These hooks are automatically discovered and can be enabled/disabled via CLI or configuration.
## Available Hooks
@@ -53,7 +53,7 @@ session-memory/
---
name: my-hook
description: "Short description"
homepage: https://docs.clawd.bot/hooks/my-hook
homepage: https://docs.clawd.bot/hooks#my-hook
metadata:
{ "clawdbot": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
---
@@ -161,9 +161,9 @@ interface InternalHookEvent {
Example handler:
```typescript
import type { InternalHookHandler } from "../../src/hooks/internal-hooks.js";
import type { HookHandler } from "../../src/hooks/hooks.js";
const myHandler: InternalHookHandler = async (event) => {
const myHandler: HookHandler = async (event) => {
if (event.type !== "command" || event.action !== "new") {
return;
}
@@ -190,4 +190,4 @@ Test your hooks by:
## Documentation
Full documentation: https://docs.clawd.bot/internal-hooks
Full documentation: https://docs.clawd.bot/hooks

View File

@@ -1,7 +1,7 @@
---
name: command-logger
description: "Log all command events to a centralized audit file"
homepage: https://docs.clawd.bot/internal-hooks#command-logger
homepage: https://docs.clawd.bot/hooks#command-logger
metadata:
{
"clawdbot":

View File

@@ -1,5 +1,5 @@
/**
* Example internal hook handler: Log all commands to a file
* Example hook handler: Log all commands to a file
*
* This handler demonstrates how to create a hook that logs all command events
* to a centralized log file for audit/debugging purposes.
@@ -26,12 +26,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import type { InternalHookHandler } from "../../internal-hooks.js";
import type { HookHandler } from "../../hooks.js";
/**
* Log all command events to a file
*/
const logCommand: InternalHookHandler = async (event) => {
const logCommand: HookHandler = async (event) => {
// Only trigger on command events
if (event.type !== "command") {
return;

View File

@@ -1,7 +1,7 @@
---
name: session-memory
description: "Save session context to memory when /new command is issued"
homepage: https://docs.clawd.bot/internal-hooks#session-memory
homepage: https://docs.clawd.bot/hooks#session-memory
metadata:
{
"clawdbot":

View File

@@ -11,7 +11,7 @@ import os from "node:os";
import type { ClawdbotConfig } from "../../../config/config.js";
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
import type { InternalHookHandler } from "../../internal-hooks.js";
import type { HookHandler } from "../../hooks.js";
/**
* Read recent messages from session file for slug generation
@@ -57,7 +57,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
/**
* Save session context to memory when /new command is triggered
*/
const saveSessionToMemory: InternalHookHandler = async (event) => {
const saveSessionToMemory: HookHandler = async (event) => {
// Only trigger on 'new' command
if (event.type !== "command" || event.action !== "new") {
return;

View File

@@ -0,0 +1,116 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const tempDirs: string[] = [];
async function makeTempDir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hooks-e2e-"));
tempDirs.push(dir);
return dir;
}
describe("hooks install (e2e)", () => {
let prevStateDir: string | undefined;
let prevBundledDir: string | undefined;
let workspaceDir: string;
beforeEach(async () => {
const baseDir = await makeTempDir();
workspaceDir = path.join(baseDir, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
prevStateDir = process.env.CLAWDBOT_STATE_DIR;
prevBundledDir = process.env.CLAWDBOT_BUNDLED_HOOKS_DIR;
process.env.CLAWDBOT_STATE_DIR = path.join(baseDir, "state");
process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = path.join(baseDir, "bundled-none");
vi.resetModules();
});
afterEach(async () => {
if (prevStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
}
if (prevBundledDir === undefined) {
delete process.env.CLAWDBOT_BUNDLED_HOOKS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = prevBundledDir;
}
vi.resetModules();
for (const dir of tempDirs.splice(0)) {
try {
await fs.rm(dir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
});
it("installs a hook pack and triggers the handler", async () => {
const baseDir = await makeTempDir();
const packDir = path.join(baseDir, "hook-pack");
const hookDir = path.join(packDir, "hooks", "hello-hook");
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(
path.join(packDir, "package.json"),
JSON.stringify(
{
name: "@acme/hello-hooks",
version: "0.0.0",
clawdbot: { hooks: ["./hooks/hello-hook"] },
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(hookDir, "HOOK.md"),
[
"---",
'name: "hello-hook"',
'description: "Test hook"',
'metadata: {"clawdbot":{"events":["command:new"]}}',
"---",
"",
"# Hello Hook",
"",
].join("\n"),
"utf-8",
);
await fs.writeFile(
path.join(hookDir, "handler.js"),
"export default async function(event) { event.messages.push('hook-ok'); }\n",
"utf-8",
);
const { installHooksFromPath } = await import("./install.js");
const installResult = await installHooksFromPath({ path: packDir });
expect(installResult.ok).toBe(true);
if (!installResult.ok) return;
const { clearInternalHooks, createInternalHookEvent, triggerInternalHook } = await import(
"./internal-hooks.js"
);
const { loadInternalHooks } = await import("./loader.js");
clearInternalHooks();
const loaded = await loadInternalHooks(
{ hooks: { internal: { enabled: true } } },
workspaceDir,
);
expect(loaded).toBe(1);
const event = createInternalHookEvent("command", "new", "test-session");
await triggerInternalHook(event);
expect(event.messages).toContain("hook-ok");
});
});

14
src/hooks/hooks.ts Normal file
View File

@@ -0,0 +1,14 @@
export * from "./internal-hooks.js";
export type HookEventType = import("./internal-hooks.js").InternalHookEventType;
export type HookEvent = import("./internal-hooks.js").InternalHookEvent;
export type HookHandler = import("./internal-hooks.js").InternalHookHandler;
export {
registerInternalHook as registerHook,
unregisterInternalHook as unregisterHook,
clearInternalHooks as clearHooks,
getRegisteredEventKeys as getRegisteredHookEventKeys,
triggerInternalHook as triggerHook,
createInternalHookEvent as createHookEvent,
} from "./internal-hooks.js";

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar";
import { afterEach, describe, expect, it, vi } from "vitest";
const tempDirs: string[] = [];
@@ -85,6 +86,54 @@ describe("installHooksFromArchive", () => {
fs.existsSync(path.join(result.targetDir, "hooks", "zip-hook", "HOOK.md")),
).toBe(true);
});
it("installs hook packs from tar archives", async () => {
const stateDir = makeTempDir();
const workDir = makeTempDir();
const archivePath = path.join(workDir, "hooks.tar");
const pkgDir = path.join(workDir, "package");
fs.mkdirSync(path.join(pkgDir, "hooks", "tar-hook"), { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify({
name: "@clawdbot/tar-hooks",
version: "0.0.1",
clawdbot: { hooks: ["./hooks/tar-hook"] },
}),
"utf-8",
);
fs.writeFileSync(
path.join(pkgDir, "hooks", "tar-hook", "HOOK.md"),
[
"---",
"name: tar-hook",
"description: Tar hook",
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
"---",
"",
"# Tar Hook",
].join("\n"),
"utf-8",
);
fs.writeFileSync(
path.join(pkgDir, "hooks", "tar-hook", "handler.ts"),
"export default async () => {};\n",
"utf-8",
);
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
const result = await withStateDir(stateDir, async () => {
const { installHooksFromArchive } = await import("./install.js");
return await installHooksFromArchive({ archivePath });
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.hookPackId).toBe("tar-hooks");
expect(result.hooks).toContain("tar-hook");
expect(result.targetDir).toBe(path.join(stateDir, "hooks", "tar-hooks"));
});
});
describe("installHooksFromPath", () => {

View File

@@ -9,7 +9,7 @@ import {
type InternalHookEvent,
} from "./internal-hooks.js";
describe("internal-hooks", () => {
describe("hooks", () => {
beforeEach(() => {
clearInternalHooks();
});
@@ -131,7 +131,7 @@ describe("internal-hooks", () => {
expect(errorHandler).toHaveBeenCalled();
expect(successHandler).toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Internal hook error"),
expect.stringContaining("Hook error"),
expect.stringContaining("Handler failed"),
);

View File

@@ -1,7 +1,7 @@
/**
* Internal hook system for clawdbot agent events
* Hook system for clawdbot agent events
*
* Provides an extensible event-driven hook system for internal agent events
* Provides an extensible event-driven hook system for agent events
* like command processing, session lifecycle, etc.
*/
@@ -91,7 +91,7 @@ export function getRegisteredEventKeys(): string[] {
}
/**
* Trigger an internal hook event
* Trigger a hook event
*
* Calls all handlers registered for:
* 1. The general event type (e.g., 'command')
@@ -117,7 +117,7 @@ export async function triggerInternalHook(event: InternalHookEvent): Promise<voi
await handler(event);
} catch (err) {
console.error(
`Internal hook error [${event.type}:${event.action}]:`,
`Hook error [${event.type}:${event.action}]:`,
err instanceof Error ? err.message : String(err),
);
}
@@ -125,7 +125,7 @@ export async function triggerInternalHook(event: InternalHookEvent): Promise<voi
}
/**
* Create an internal hook event with common fields filled in
* Create a hook event with common fields filled in
*
* @param type - The event type
* @param action - The action within that type

View File

@@ -43,7 +43,7 @@ describe("loader", () => {
});
describe("loadInternalHooks", () => {
it("should return 0 when internal hooks are not enabled", async () => {
it("should return 0 when hooks are not enabled", async () => {
const cfg: ClawdbotConfig = {
hooks: {
internal: {
@@ -170,7 +170,7 @@ describe("loader", () => {
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Failed to load internal hook handler"),
expect.stringContaining("Failed to load hook handler"),
expect.any(String),
);

View File

@@ -1,5 +1,5 @@
/**
* Dynamic loader for internal hook handlers
* Dynamic loader for hook handlers
*
* Loads hook handlers from external modules based on configuration
* and from directory-based discovery (bundled, managed, workspace)
@@ -15,7 +15,7 @@ import { resolveHookConfig } from "./config.js";
import { shouldIncludeHook } from "./config.js";
/**
* Load and register all internal hook handlers
* Load and register all hook handlers
*
* Loads hooks from both:
* 1. Directory-based discovery (bundled, managed, workspace)
@@ -30,14 +30,14 @@ import { shouldIncludeHook } from "./config.js";
* const config = await loadConfig();
* const workspaceDir = resolveAgentWorkspaceDir(config, agentId);
* const count = await loadInternalHooks(config, workspaceDir);
* console.log(`Loaded ${count} internal hook handlers`);
* console.log(`Loaded ${count} hook handlers`);
* ```
*/
export async function loadInternalHooks(
cfg: ClawdbotConfig,
workspaceDir: string,
): Promise<number> {
// Check if internal hooks are enabled
// Check if hooks are enabled
if (!cfg.hooks?.internal?.enabled) {
return 0;
}
@@ -71,7 +71,7 @@ export async function loadInternalHooks(
if (typeof handler !== "function") {
console.error(
`Internal hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`,
`Hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`,
);
continue;
}
@@ -80,7 +80,7 @@ export async function loadInternalHooks(
const events = entry.clawdbot?.events ?? [];
if (events.length === 0) {
console.warn(
`Internal hook warning: Hook '${entry.hook.name}' has no events defined in metadata`,
`Hook warning: Hook '${entry.hook.name}' has no events defined in metadata`,
);
continue;
}
@@ -90,12 +90,12 @@ export async function loadInternalHooks(
}
console.log(
`Registered internal hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
`Registered hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
);
loadedCount++;
} catch (err) {
console.error(
`Failed to load internal hook ${entry.hook.name}:`,
`Failed to load hook ${entry.hook.name}:`,
err instanceof Error ? err.message : String(err),
);
}
@@ -127,7 +127,7 @@ export async function loadInternalHooks(
if (typeof handler !== "function") {
console.error(
`Internal hook error: Handler '${exportName}' from ${modulePath} is not a function`,
`Hook error: Handler '${exportName}' from ${modulePath} is not a function`,
);
continue;
}
@@ -135,12 +135,12 @@ export async function loadInternalHooks(
// Register the handler
registerInternalHook(handlerConfig.event, handler as InternalHookHandler);
console.log(
`Registered internal hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
`Registered hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
);
loadedCount++;
} catch (err) {
console.error(
`Failed to load internal hook handler from ${handlerConfig.module}:`,
`Failed to load hook handler from ${handlerConfig.module}:`,
err instanceof Error ? err.message : String(err),
);
}