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

@@ -144,7 +144,7 @@ describe("handleCommands identity", () => {
});
});
describe("handleCommands internal hooks", () => {
describe("handleCommands hooks", () => {
it("triggers hooks for /new with arguments", async () => {
const cfg = {
commands: { text: true },

View File

@@ -96,7 +96,7 @@ export type MsgContext = {
*/
OriginatingTo?: string;
/**
* Messages from internal hooks to be included in the response.
* Messages from hooks to be included in the response.
* Used for hook confirmation messages like "Session context saved to memory".
*/
HookMessages?: string[];

54
src/cli/hooks-cli.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { HookStatusReport } from "../hooks/hooks-status.js";
import { formatHooksCheck, formatHooksList } from "./hooks-cli.js";
const report: HookStatusReport = {
workspaceDir: "/tmp/workspace",
managedHooksDir: "/tmp/hooks",
hooks: [
{
name: "session-memory",
description: "Save session context to memory",
source: "clawdbot-bundled",
filePath: "/tmp/hooks/session-memory/HOOK.md",
baseDir: "/tmp/hooks/session-memory",
handlerPath: "/tmp/hooks/session-memory/handler.js",
hookKey: "session-memory",
emoji: "💾",
homepage: "https://docs.clawd.bot/hooks#session-memory",
events: ["command:new"],
always: false,
disabled: false,
eligible: true,
requirements: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
configChecks: [],
install: [],
},
],
};
describe("hooks cli formatting", () => {
it("labels hooks list output", () => {
const output = formatHooksList(report, {});
expect(output).toContain("Hooks");
expect(output).not.toContain("Internal Hooks");
});
it("labels hooks status output", () => {
const output = formatHooksCheck(report, {});
expect(output).toContain("Hooks Status");
});
});

View File

@@ -125,9 +125,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
const notEligible = hooks.filter((h) => !h.eligible);
const lines: string[] = [];
lines.push(
chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`),
);
lines.push(chalk.bold.cyan("Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`));
lines.push("");
if (eligible.length > 0) {
@@ -273,7 +271,7 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
const notEligible = report.hooks.filter((h) => !h.eligible);
const lines: string[] = [];
lines.push(chalk.bold.cyan("Internal Hooks Status"));
lines.push(chalk.bold.cyan("Hooks Status"));
lines.push("");
lines.push(`Total hooks: ${report.hooks.length}`);
lines.push(chalk.green(`Ready: ${eligible.length}`));
@@ -373,7 +371,7 @@ export function registerHooksCli(program: Command): void {
hooks
.command("list")
.description("List all internal hooks")
.description("List all hooks")
.option("--eligible", "Show only eligible hooks", false)
.option("--json", "Output as JSON", false)
.option("-v, --verbose", "Show more details including missing requirements", false)

View File

@@ -59,7 +59,7 @@ describe("onboard-hooks", () => {
});
describe("setupInternalHooks", () => {
it("should enable internal hooks when user selects them", async () => {
it("should enable hooks when user selects them", async () => {
const { buildWorkspaceHookStatus } = await import("../hooks/hooks-status.js");
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport());
@@ -75,7 +75,7 @@ describe("onboard-hooks", () => {
});
expect(prompter.note).toHaveBeenCalledTimes(2);
expect(prompter.multiselect).toHaveBeenCalledWith({
message: "Enable internal hooks?",
message: "Enable hooks?",
options: [
{ value: "__skip__", label: "Skip for now" },
{
@@ -173,8 +173,8 @@ describe("onboard-hooks", () => {
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
expect(noteCalls).toHaveLength(2);
// First note should explain what internal hooks are
expect(noteCalls[0][0]).toContain("Internal hooks");
// First note should explain what hooks are
expect(noteCalls[0][0]).toContain("Hooks let you automate actions");
expect(noteCalls[0][0]).toContain("automate actions");
// Second note should confirm configuration

View File

@@ -11,12 +11,12 @@ export async function setupInternalHooks(
): Promise<ClawdbotConfig> {
await prompter.note(
[
"Internal hooks let you automate actions when agent commands are issued.",
"Hooks let you automate actions when agent commands are issued.",
"Example: Save session context to memory when you issue /new.",
"",
"Learn more: https://docs.clawd.bot/internal-hooks",
"Learn more: https://docs.clawd.bot/hooks",
].join("\n"),
"Internal Hooks",
"Hooks",
);
// Discover available hooks using the hook discovery system
@@ -35,7 +35,7 @@ export async function setupInternalHooks(
}
const toEnable = await prompter.multiselect({
message: "Enable internal hooks?",
message: "Enable hooks?",
options: [
{ value: "__skip__", label: "Skip for now" },
...recommendedHooks.map((hook) => ({

View File

@@ -90,7 +90,7 @@ export type HookInstallRecord = {
};
export type InternalHooksConfig = {
/** Enable internal hooks system */
/** Enable hooks system */
enabled?: boolean;
/** Legacy: List of internal hook handlers to register (still supported) */
handlers?: InternalHookHandlerConfig[];

View File

@@ -103,7 +103,7 @@ export async function startGatewaySidecars(params: {
);
}
} catch (err) {
params.logHooks.error(`failed to load internal hooks: ${String(err)}`);
params.logHooks.error(`failed to load hooks: ${String(err)}`);
}
// Launch configured channels so gateway replies via the surface the message came from.

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),
);
}

68
src/infra/archive.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar";
import { afterEach, describe, expect, it } from "vitest";
import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./archive.js";
const tempDirs: string[] = [];
async function makeTempDir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-archive-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
try {
await fs.rm(dir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
});
describe("archive utils", () => {
it("detects archive kinds", () => {
expect(resolveArchiveKind("/tmp/file.zip")).toBe("zip");
expect(resolveArchiveKind("/tmp/file.tgz")).toBe("tar");
expect(resolveArchiveKind("/tmp/file.tar.gz")).toBe("tar");
expect(resolveArchiveKind("/tmp/file.tar")).toBe("tar");
expect(resolveArchiveKind("/tmp/file.txt")).toBeNull();
});
it("extracts zip archives", async () => {
const workDir = await makeTempDir();
const archivePath = path.join(workDir, "bundle.zip");
const extractDir = path.join(workDir, "extract");
const zip = new JSZip();
zip.file("package/hello.txt", "hi");
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await fs.mkdir(extractDir, { recursive: true });
await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 });
const rootDir = await resolvePackedRootDir(extractDir);
const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8");
expect(content).toBe("hi");
});
it("extracts tar archives", async () => {
const workDir = await makeTempDir();
const archivePath = path.join(workDir, "bundle.tar");
const extractDir = path.join(workDir, "extract");
const packageDir = path.join(workDir, "package");
await fs.mkdir(packageDir, { recursive: true });
await fs.writeFile(path.join(packageDir, "hello.txt"), "yo");
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
await fs.mkdir(extractDir, { recursive: true });
await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 });
const rootDir = await resolvePackedRootDir(extractDir);
const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8");
expect(content).toBe("yo");
});
});

View File

@@ -405,7 +405,7 @@ export async function runOnboardingWizard(
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
}
// Setup internal hooks (session memory on /new)
// Setup hooks (session memory on /new)
nextConfig = await setupInternalHooks(nextConfig, runtime, prompter);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });