Merge hooks and custom-tools into unified extensions system (#454)

Breaking changes:
- Settings: 'hooks' and 'customTools' arrays replaced with 'extensions'
- CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e'
- API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom'
- API: FileSlashCommand renamed to PromptTemplate
- API: discoverSlashCommands() renamed to discoverPromptTemplates()
- Directories: commands/ renamed to prompts/ for prompt templates

Migration:
- Session version bumped to 3 (auto-migrates v2 sessions)
- Old 'hookMessage' role entries converted to 'custom'

Structural changes:
- src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/
- src/core/slash-commands.ts renamed to src/core/prompt-templates.ts
- examples/hooks/ and examples/custom-tools/ merged into examples/extensions/
- docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md

New test coverage:
- test/extensions-runner.test.ts (10 tests)
- test/extensions-discovery.test.ts (26 tests)
- test/prompt-templates.test.ts
This commit is contained in:
Mario Zechner 2026-01-05 01:43:35 +01:00
parent 9794868b38
commit c6fc084534
112 changed files with 2842 additions and 6747 deletions

View file

@ -1,7 +1,7 @@
/**
* Minimal SDK Usage
*
* Uses all defaults: discovers skills, hooks, tools, context files
* Uses all defaults: discovers skills, extensions, tools, context files
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
*/

View file

@ -1,27 +1,28 @@
/**
* Tools Configuration
*
* Use built-in tool sets, individual tools, or add custom tools.
* Use built-in tool sets or individual tools.
*
* IMPORTANT: When using a custom `cwd`, you must use the tool factory functions
* (createCodingTools, createReadOnlyTools, createReadTool, etc.) to ensure
* tools resolve paths relative to your cwd, not process.cwd().
*
* For custom tools, see 06-extensions.ts - custom tools are now registered
* via the extensions system using pi.registerTool().
*/
import {
bashTool, // read, bash, edit, write - uses process.cwd()
type CustomTool,
bashTool,
createAgentSession,
createBashTool,
createCodingTools, // Factory: creates tools for specific cwd
createCodingTools,
createGrepTool,
createReadTool,
grepTool,
readOnlyTools, // read, grep, find, ls - uses process.cwd()
readOnlyTools,
readTool,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Read-only mode (no edit/write) - uses process.cwd()
await createAgentSession({
@ -53,38 +54,3 @@ await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
console.log("Specific tools with custom cwd session created");
// Inline custom tool (needs TypeBox schema)
const weatherTool: CustomTool = {
name: "get_weather",
label: "Get Weather",
description: "Get current weather for a city",
parameters: Type.Object({
city: Type.String({ description: "City name" }),
}),
execute: async (_toolCallId, params) => ({
content: [{ type: "text", text: `Weather in ${(params as { city: string }).city}: 22°C, sunny` }],
details: {},
}),
};
const { session } = await createAgentSession({
customTools: [{ tool: weatherTool }],
sessionManager: SessionManager.inMemory(),
});
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("What's the weather in Tokyo?");
console.log();
// Merge with discovered tools from cwd/.pi/tools and ~/.pi/agent/tools:
// const discovered = await discoverCustomTools();
// customTools: [...discovered, { tool: myTool }]
// Or add paths without replacing discovery:
// additionalCustomToolPaths: ["/extra/tools"]

View file

@ -0,0 +1,81 @@
/**
* Extensions Configuration
*
* Extensions intercept agent events and can register custom tools.
* They provide a unified system for extensions, custom tools, commands, and more.
*
* Extension files are discovered from:
* - ~/.pi/agent/extensions/
* - <cwd>/.pi/extensions/
* - Paths specified in settings.json "extensions" array
* - Paths passed via --extension CLI flag
*
* An extension is a TypeScript file that exports a default function:
* export default function (pi: ExtensionAPI) { ... }
*/
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
// Extensions are loaded from disk, not passed inline to createAgentSession.
// Use the discovery mechanism:
// 1. Place extension files in ~/.pi/agent/extensions/ or .pi/extensions/
// 2. Add paths to settings.json: { "extensions": ["./my-extension.ts"] }
// 3. Use --extension flag: pi --extension ./my-extension.ts
// To add additional extension paths beyond discovery:
const { session } = await createAgentSession({
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
sessionManager: SessionManager.inMemory(),
});
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("List files in the current directory.");
console.log();
// Example extension file (./my-logging-extension.ts):
/*
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("agent_start", async () => {
console.log("[Extension] Agent starting");
});
pi.on("tool_call", async (event) => {
console.log(\`[Extension] Tool: \${event.toolName}\`);
// Return { block: true, reason: "..." } to block execution
return undefined;
});
pi.on("agent_end", async (event) => {
console.log(\`[Extension] Done, \${event.messages.length} messages\`);
});
// Register a custom tool
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "Does something useful",
parameters: Type.Object({
input: Type.String(),
}),
execute: async (_toolCallId, params, _onUpdate, _ctx, _signal) => ({
content: [{ type: "text", text: \`Processed: \${params.input}\` }],
details: {},
}),
});
// Register a command
pi.registerCommand("mycommand", {
description: "Do something",
handler: async (args, ctx) => {
ctx.ui.notify(\`Command executed with: \${args}\`);
},
});
}
*/

View file

@ -1,61 +0,0 @@
/**
* Hooks Configuration
*
* Hooks intercept agent events for logging, blocking, or modification.
*/
import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent";
// Logging hook
const loggingHook: HookFactory = (api) => {
api.on("agent_start", async () => {
console.log("[Hook] Agent starting");
});
api.on("tool_call", async (event) => {
console.log(`[Hook] Tool: ${event.toolName}`);
return undefined; // Don't block
});
api.on("agent_end", async (event) => {
console.log(`[Hook] Done, ${event.messages.length} messages`);
});
};
// Blocking hook (returns { block: true, reason: "..." })
const safetyHook: HookFactory = (api) => {
api.on("tool_call", async (event) => {
if (event.toolName === "bash") {
const cmd = (event.input as { command?: string }).command ?? "";
if (cmd.includes("rm -rf")) {
return { block: true, reason: "Dangerous command blocked" };
}
}
return undefined;
});
};
// Use inline hooks
const { session } = await createAgentSession({
hooks: [{ factory: loggingHook }, { factory: safetyHook }],
sessionManager: SessionManager.inMemory(),
});
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("List files in the current directory.");
console.log();
// Disable all hooks:
// hooks: []
// Merge with discovered hooks:
// const discovered = await discoverHooks();
// hooks: [...discovered, { factory: myHook }]
// Add paths without replacing discovery:
// additionalHookPaths: ["/extra/hooks"]

View file

@ -0,0 +1,42 @@
/**
* Prompt Templates
*
* File-based templates that inject content when invoked with /templatename.
*/
import {
createAgentSession,
discoverPromptTemplates,
type PromptTemplate,
SessionManager,
} from "@mariozechner/pi-coding-agent";
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
const discovered = discoverPromptTemplates();
console.log("Discovered prompt templates:");
for (const template of discovered) {
console.log(` /${template.name}: ${template.description}`);
}
// Define custom templates
const deployTemplate: PromptTemplate = {
name: "deploy",
description: "Deploy the application",
source: "(custom)",
content: `# Deploy Instructions
1. Build: npm run build
2. Test: npm test
3. Deploy: npm run deploy`,
};
// Use discovered + custom templates
await createAgentSession({
promptTemplates: [...discovered, deployTemplate],
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} prompt templates`);
// Disable prompt templates:
// promptTemplates: []

View file

@ -1,42 +0,0 @@
/**
* Slash Commands
*
* File-based commands that inject content when invoked with /commandname.
*/
import {
createAgentSession,
discoverSlashCommands,
type FileSlashCommand,
SessionManager,
} from "@mariozechner/pi-coding-agent";
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
const discovered = discoverSlashCommands();
console.log("Discovered slash commands:");
for (const cmd of discovered) {
console.log(` /${cmd.name}: ${cmd.description}`);
}
// Define custom commands
const deployCommand: FileSlashCommand = {
name: "deploy",
description: "Deploy the application",
source: "(custom)",
content: `# Deploy Instructions
1. Build: npm run build
2. Test: npm test
3. Deploy: npm run deploy`,
};
// Use discovered + custom commands
await createAgentSession({
slashCommands: [...discovered, deployCommand],
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} slash commands`);
// Disable slash commands:
// slashCommands: []

View file

@ -6,21 +6,22 @@
* IMPORTANT: When providing `tools` with a custom `cwd`, use the tool factory
* functions (createReadTool, createBashTool, etc.) to ensure tools resolve
* paths relative to your cwd.
*
* NOTE: Extensions (extensions, custom tools) are always loaded via discovery.
* To use custom extensions, place them in the extensions directory or
* pass paths via additionalExtensionPaths.
*/
import { getModel } from "@mariozechner/pi-ai";
import {
AuthStorage,
type CustomTool,
createAgentSession,
createBashTool,
createReadTool,
type HookFactory,
ModelRegistry,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Custom auth storage location
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
@ -33,27 +34,7 @@ if (process.env.MY_ANTHROPIC_KEY) {
// Model registry with no custom models.json
const modelRegistry = new ModelRegistry(authStorage);
// Inline hook
const auditHook: HookFactory = (api) => {
api.on("tool_call", async (event) => {
console.log(`[Audit] ${event.toolName}`);
return undefined;
});
};
// Inline custom tool
const statusTool: CustomTool = {
name: "status",
label: "Status",
description: "Get system status",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: `Uptime: ${process.uptime()}s, Node: ${process.version}` }],
details: {},
}),
};
const model = getModel("anthropic", "claude-opus-4-5");
const model = getModel("anthropic", "claude-sonnet-4-20250514");
if (!model) throw new Error("Model not found");
// In-memory settings with overrides
@ -73,14 +54,14 @@ const { session } = await createAgentSession({
authStorage,
modelRegistry,
systemPrompt: `You are a minimal assistant.
Available: read, bash, status. Be concise.`,
Available: read, bash. Be concise.`,
// Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)],
customTools: [{ tool: statusTool }],
hooks: [{ factory: auditHook }],
// Extensions are loaded from disk - use additionalExtensionPaths to add custom ones
// additionalExtensionPaths: ["./my-extension.ts"],
skills: [],
contextFiles: [],
slashCommands: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(),
settingsManager,
});
@ -91,5 +72,5 @@ session.subscribe((event) => {
}
});
await session.prompt("Get status and list files.");
await session.prompt("List files in the current directory.");
console.log();

View file

@ -11,7 +11,7 @@ Programmatic usage of pi-coding-agent via `createAgentSession()`.
| `03-custom-prompt.ts` | Replace or modify system prompt |
| `04-skills.ts` | Discover, filter, or replace skills |
| `05-tools.ts` | Built-in tools, custom tools |
| `06-hooks.ts` | Logging, blocking, result modification |
| `06-extensions.ts` | Logging, blocking, result modification |
| `07-context-files.ts` | AGENTS.md context files |
| `08-slash-commands.ts` | File-based slash commands |
| `09-api-keys-and-oauth.ts` | API key resolution, OAuth config |
@ -36,7 +36,7 @@ import {
discoverAuthStorage,
discoverModels,
discoverSkills,
discoverHooks,
discoverExtensions,
discoverCustomTools,
discoverContextFiles,
discoverSlashCommands,
@ -89,7 +89,7 @@ const { session } = await createAgentSession({
systemPrompt: "You are helpful.",
tools: [readTool, bashTool],
customTools: [{ tool: myTool }],
hooks: [{ factory: myHook }],
extensions: [{ factory: myExtension }],
skills: [],
contextFiles: [],
slashCommands: [],
@ -119,8 +119,8 @@ await session.prompt("Hello");
| `tools` | `codingTools` | Built-in tools |
| `customTools` | Discovered | Replaces discovery |
| `additionalCustomToolPaths` | `[]` | Merge with discovery |
| `hooks` | Discovered | Replaces discovery |
| `additionalHookPaths` | `[]` | Merge with discovery |
| `extensions` | Discovered | Replaces discovery |
| `additionalExtensionPaths` | `[]` | Merge with discovery |
| `skills` | Discovered | Skills for prompt |
| `contextFiles` | Discovered | AGENTS.md files |
| `slashCommands` | Discovered | File commands |