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

@ -16,11 +16,9 @@ import { selectSession } from "./cli/session-picker.js";
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import type { AgentSession } from "./core/agent-session.js";
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
import { createEventBus } from "./core/event-bus.js";
import { exportFromFile } from "./core/export-html/index.js";
import { discoverAndLoadHooks } from "./core/hooks/index.js";
import type { HookUIContext } from "./core/index.js";
import { discoverAndLoadExtensions, type ExtensionUIContext, type LoadedExtension } from "./core/extensions/index.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js";
@ -62,13 +60,13 @@ async function runInteractiveMode(
migratedProviders: string[],
versionCheckPromise: Promise<string | undefined>,
initialMessages: string[],
customTools: LoadedCustomTool[],
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
extensions: LoadedExtension[],
setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
initialMessage?: string,
initialImages?: ImageContent[],
fdPath: string | undefined = undefined,
): Promise<void> {
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
const mode = new InteractiveMode(session, version, changelogMarkdown, extensions, setExtensionUIContext, fdPath);
await mode.init();
@ -214,7 +212,7 @@ function buildSessionOptions(
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
preloadedHooks?: import("./core/hooks/index.js").LoadedHook[],
preloadedExtensions?: LoadedExtension[],
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
@ -273,14 +271,9 @@ function buildSessionOptions(
options.skills = [];
}
// Pre-loaded hooks (from early CLI flag discovery)
if (preloadedHooks && preloadedHooks.length > 0) {
options.preloadedHooks = preloadedHooks;
}
// Additional custom tool paths from CLI
if (parsed.customTools && parsed.customTools.length > 0) {
options.additionalCustomToolPaths = parsed.customTools;
// Pre-loaded extensions (from early CLI flag discovery)
if (preloadedExtensions && preloadedExtensions.length > 0) {
options.preloadedExtensions = preloadedExtensions;
}
return options;
@ -297,35 +290,35 @@ export async function main(args: string[]) {
const modelRegistry = discoverModels(authStorage);
time("discoverModels");
// First pass: parse args to get --hook paths
// First pass: parse args to get --extension paths
const firstPass = parseArgs(args);
time("parseArgs-firstPass");
// Early load hooks to discover their CLI flags
// Early load extensions to discover their CLI flags
const cwd = process.cwd();
const agentDir = getAgentDir();
const eventBus = createEventBus();
const hookPaths = firstPass.hooks ?? [];
const { hooks: loadedHooks } = await discoverAndLoadHooks(hookPaths, cwd, agentDir, eventBus);
time("discoverHookFlags");
const extensionPaths = firstPass.extensions ?? [];
const { extensions: loadedExtensions } = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus);
time("discoverExtensionFlags");
// Collect all hook flags
const hookFlags = new Map<string, { type: "boolean" | "string" }>();
for (const hook of loadedHooks) {
for (const [name, flag] of hook.flags) {
hookFlags.set(name, { type: flag.type });
// Collect all extension flags
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
for (const ext of loadedExtensions) {
for (const [name, flag] of ext.flags) {
extensionFlags.set(name, { type: flag.type });
}
}
// Second pass: parse args with hook flags
const parsed = parseArgs(args, hookFlags);
// Second pass: parse args with extension flags
const parsed = parseArgs(args, extensionFlags);
time("parseArgs");
// Pass flag values to hooks
// Pass flag values to extensions
for (const [name, value] of parsed.unknownFlags) {
for (const hook of loadedHooks) {
if (hook.flags.has(name)) {
hook.setFlagValue(name, value);
for (const ext of loadedExtensions) {
if (ext.flags.has(name)) {
ext.setFlagValue(name, value);
}
}
}
@ -401,7 +394,7 @@ export async function main(args: string[]) {
sessionManager = SessionManager.open(selectedPath);
}
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedHooks);
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, loadedExtensions);
sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry;
sessionOptions.eventBus = eventBus;
@ -416,7 +409,7 @@ export async function main(args: string[]) {
}
time("buildSessionOptions");
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
const { session, extensionsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
time("createAgentSession");
if (!isInteractive && !session.model) {
@ -469,8 +462,8 @@ export async function main(args: string[]) {
migratedProviders,
versionCheckPromise,
parsed.messages,
customToolsResult.tools,
customToolsResult.setUIContext,
extensionsResult.extensions,
extensionsResult.setUIContext,
initialMessage,
initialImages,
fdPath,