335 lines
9.4 KiB
TypeScript
335 lines
9.4 KiB
TypeScript
// src/hooks/loader.ts
|
|
// Loads and matches hooks from settings-manager
|
|
|
|
import { settingsManager } from "../settings-manager";
|
|
import { debugLog } from "../utils/debug";
|
|
import {
|
|
type HookCommand,
|
|
type HookEvent,
|
|
type HookMatcher,
|
|
type HooksConfig,
|
|
isToolEvent,
|
|
type SimpleHookEvent,
|
|
type SimpleHookMatcher,
|
|
type ToolHookEvent,
|
|
} from "./types";
|
|
|
|
/**
|
|
* Clear hooks cache - kept for API compatibility with existing callers.
|
|
*/
|
|
export function clearHooksCache(): void {
|
|
// Settings-manager handles caching
|
|
}
|
|
|
|
/**
|
|
* Load global hooks configuration from ~/.letta/settings.json
|
|
* Uses settings-manager cache (loaded at app startup)
|
|
*/
|
|
export function loadGlobalHooks(): HooksConfig {
|
|
try {
|
|
return settingsManager.getSettings().hooks || {};
|
|
} catch (error) {
|
|
// Settings not initialized yet
|
|
debugLog("hooks", "loadGlobalHooks: Settings not initialized yet", error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load project hooks configuration from .letta/settings.json
|
|
* Uses settings-manager cache
|
|
*/
|
|
export async function loadProjectHooks(
|
|
workingDirectory: string = process.cwd(),
|
|
): Promise<HooksConfig> {
|
|
try {
|
|
// Ensure project settings are loaded
|
|
try {
|
|
settingsManager.getProjectSettings(workingDirectory);
|
|
} catch {
|
|
await settingsManager.loadProjectSettings(workingDirectory);
|
|
}
|
|
return settingsManager.getProjectSettings(workingDirectory)?.hooks || {};
|
|
} catch (error) {
|
|
// Settings not available
|
|
debugLog("hooks", "loadProjectHooks: Settings not available", error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load project-local hooks configuration from .letta/settings.local.json
|
|
* Uses settings-manager cache
|
|
*/
|
|
export async function loadProjectLocalHooks(
|
|
workingDirectory: string = process.cwd(),
|
|
): Promise<HooksConfig> {
|
|
try {
|
|
// Ensure local project settings are loaded
|
|
try {
|
|
settingsManager.getLocalProjectSettings(workingDirectory);
|
|
} catch {
|
|
await settingsManager.loadLocalProjectSettings(workingDirectory);
|
|
}
|
|
return (
|
|
settingsManager.getLocalProjectSettings(workingDirectory)?.hooks || {}
|
|
);
|
|
} catch (error) {
|
|
// Settings not available
|
|
debugLog("hooks", "loadProjectLocalHooks: Settings not available", error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge hooks configurations
|
|
* Priority order: project-local > project > global
|
|
* For each event, hooks are ordered by priority (local first, global last)
|
|
*/
|
|
export function mergeHooksConfigs(
|
|
global: HooksConfig,
|
|
project: HooksConfig,
|
|
projectLocal: HooksConfig = {},
|
|
): HooksConfig {
|
|
const merged: HooksConfig = {};
|
|
const allEvents = new Set([
|
|
...Object.keys(global),
|
|
...Object.keys(project),
|
|
...Object.keys(projectLocal),
|
|
]) as Set<HookEvent>;
|
|
|
|
for (const event of allEvents) {
|
|
if (isToolEvent(event)) {
|
|
// Tool events use HookMatcher[]
|
|
const toolEvent = event as ToolHookEvent;
|
|
const globalMatchers = (global[toolEvent] || []) as HookMatcher[];
|
|
const projectMatchers = (project[toolEvent] || []) as HookMatcher[];
|
|
const projectLocalMatchers = (projectLocal[toolEvent] ||
|
|
[]) as HookMatcher[];
|
|
// Project-local runs first, then project, then global
|
|
(merged as Record<ToolHookEvent, HookMatcher[]>)[toolEvent] = [
|
|
...projectLocalMatchers,
|
|
...projectMatchers,
|
|
...globalMatchers,
|
|
];
|
|
} else {
|
|
// Simple events use SimpleHookMatcher[] (same as HookMatcher but without matcher field)
|
|
const simpleEvent = event as SimpleHookEvent;
|
|
const globalMatchers = (global[simpleEvent] || []) as SimpleHookMatcher[];
|
|
const projectMatchers = (project[simpleEvent] ||
|
|
[]) as SimpleHookMatcher[];
|
|
const projectLocalMatchers = (projectLocal[simpleEvent] ||
|
|
[]) as SimpleHookMatcher[];
|
|
// Project-local runs first, then project, then global
|
|
(merged as Record<SimpleHookEvent, SimpleHookMatcher[]>)[simpleEvent] = [
|
|
...projectLocalMatchers,
|
|
...projectMatchers,
|
|
...globalMatchers,
|
|
];
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Load merged hooks configuration (global + project + project-local)
|
|
*/
|
|
export async function loadHooks(
|
|
workingDirectory: string = process.cwd(),
|
|
): Promise<HooksConfig> {
|
|
const [global, project, projectLocal] = await Promise.all([
|
|
Promise.resolve(loadGlobalHooks()),
|
|
loadProjectHooks(workingDirectory),
|
|
loadProjectLocalHooks(workingDirectory),
|
|
]);
|
|
|
|
return mergeHooksConfigs(global, project, projectLocal);
|
|
}
|
|
|
|
/**
|
|
* Check if a tool name matches a matcher pattern
|
|
* Patterns:
|
|
* - "*" or "": matches all tools
|
|
* - "ToolName": exact match (simple alphanumeric strings)
|
|
* - "Edit|Write": regex alternation, matches Edit or Write
|
|
* - "Notebook.*": regex pattern, matches Notebook, NotebookEdit, etc.
|
|
* - Any valid regex pattern is supported (case-sensitive)
|
|
*/
|
|
export function matchesTool(pattern: string, toolName: string): boolean {
|
|
// Empty or "*" matches everything
|
|
if (!pattern || pattern === "*") {
|
|
return true;
|
|
}
|
|
|
|
// Treat pattern as regex (anchored to match full tool name)
|
|
try {
|
|
const regex = new RegExp(`^(?:${pattern})$`);
|
|
return regex.test(toolName);
|
|
} catch (error) {
|
|
// Invalid regex, fall back to exact match
|
|
debugLog(
|
|
"hooks",
|
|
`matchesTool: Invalid regex pattern "${pattern}", falling back to exact match`,
|
|
error,
|
|
);
|
|
return pattern === toolName;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all hooks that match a specific event and tool name
|
|
*/
|
|
export function getMatchingHooks(
|
|
config: HooksConfig,
|
|
event: HookEvent,
|
|
toolName?: string,
|
|
): HookCommand[] {
|
|
if (isToolEvent(event)) {
|
|
// Tool events use HookMatcher[] - need to match against tool name
|
|
const matchers = config[event as ToolHookEvent] as
|
|
| HookMatcher[]
|
|
| undefined;
|
|
if (!matchers || matchers.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const hooks: HookCommand[] = [];
|
|
for (const matcher of matchers) {
|
|
if (!toolName || matchesTool(matcher.matcher, toolName)) {
|
|
hooks.push(...matcher.hooks);
|
|
}
|
|
}
|
|
return hooks;
|
|
} else {
|
|
// Simple events use SimpleHookMatcher[] - extract hooks from each matcher
|
|
const matchers = config[event as SimpleHookEvent] as
|
|
| SimpleHookMatcher[]
|
|
| undefined;
|
|
if (!matchers || matchers.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const hooks: HookCommand[] = [];
|
|
for (const matcher of matchers) {
|
|
hooks.push(...matcher.hooks);
|
|
}
|
|
return hooks;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if there are any hooks configured for a specific event
|
|
*/
|
|
export function hasHooksForEvent(
|
|
config: HooksConfig,
|
|
event: HookEvent,
|
|
): boolean {
|
|
if (isToolEvent(event)) {
|
|
// Tool events use HookMatcher[]
|
|
const matchers = config[event as ToolHookEvent] as
|
|
| HookMatcher[]
|
|
| undefined;
|
|
if (!matchers || matchers.length === 0) {
|
|
return false;
|
|
}
|
|
// Check if any matcher has hooks
|
|
return matchers.some((m) => m.hooks && m.hooks.length > 0);
|
|
} else {
|
|
// Simple events use SimpleHookMatcher[]
|
|
const matchers = config[event as SimpleHookEvent] as
|
|
| SimpleHookMatcher[]
|
|
| undefined;
|
|
if (!matchers || matchers.length === 0) {
|
|
return false;
|
|
}
|
|
// Check if any matcher has hooks
|
|
return matchers.some((m) => m.hooks && m.hooks.length > 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if all hooks are disabled via hooks.disabled across settings levels.
|
|
*
|
|
* Precedence:
|
|
* 1. If user has disabled: false → ENABLED (explicit user override)
|
|
* 2. If user has disabled: true → DISABLED
|
|
* 3. If project OR project-local has disabled: true → DISABLED
|
|
* 4. Default → ENABLED
|
|
*/
|
|
export function areHooksDisabled(
|
|
workingDirectory: string = process.cwd(),
|
|
): boolean {
|
|
try {
|
|
// Check user-level settings first (highest precedence)
|
|
const userDisabled = settingsManager.getSettings().hooks?.disabled;
|
|
if (userDisabled === false) {
|
|
// User explicitly enabled - overrides project settings
|
|
return false;
|
|
}
|
|
if (userDisabled === true) {
|
|
// User explicitly disabled
|
|
return true;
|
|
}
|
|
|
|
// User setting is undefined, check project-level settings
|
|
try {
|
|
const projectDisabled =
|
|
settingsManager.getProjectSettings(workingDirectory)?.hooks?.disabled;
|
|
if (projectDisabled === true) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
// Project settings not loaded, skip
|
|
debugLog(
|
|
"hooks",
|
|
"areHooksDisabled: Project settings not loaded, skipping",
|
|
error,
|
|
);
|
|
}
|
|
|
|
// Check project-local settings
|
|
try {
|
|
const localDisabled =
|
|
settingsManager.getLocalProjectSettings(workingDirectory)?.hooks
|
|
?.disabled;
|
|
if (localDisabled === true) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
// Local project settings not loaded, skip
|
|
debugLog(
|
|
"hooks",
|
|
"areHooksDisabled: Local project settings not loaded, skipping",
|
|
error,
|
|
);
|
|
}
|
|
|
|
return false;
|
|
} catch (error) {
|
|
debugLog(
|
|
"hooks",
|
|
"areHooksDisabled: Failed to check hooks disabled status",
|
|
error,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience function to load hooks and get matching ones for an event
|
|
*/
|
|
export async function getHooksForEvent(
|
|
event: HookEvent,
|
|
toolName?: string,
|
|
workingDirectory: string = process.cwd(),
|
|
): Promise<HookCommand[]> {
|
|
// Check if all hooks are disabled
|
|
if (areHooksDisabled(workingDirectory)) {
|
|
return [];
|
|
}
|
|
|
|
const config = await loadHooks(workingDirectory);
|
|
return getMatchingHooks(config, event, toolName);
|
|
}
|