From 3e71a081563f26f9b915be150ebe29a4e92d8124 Mon Sep 17 00:00:00 2001 From: jnjpng Date: Fri, 23 Jan 2026 15:45:15 -0800 Subject: [PATCH] feat: regex tool name matching for hooks (#660) --- src/hooks/loader.ts | 20 +++++++++++--------- src/tests/hooks/loader.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index a1bb85d..d6cb424 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -147,8 +147,10 @@ export async function loadHooks( * Check if a tool name matches a matcher pattern * Patterns: * - "*" or "": matches all tools - * - "ToolName": exact match - * - "Tool1|Tool2|Tool3": matches any of the listed 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 @@ -156,14 +158,14 @@ export function matchesTool(pattern: string, toolName: string): boolean { return true; } - // Check for pipe-separated list - if (pattern.includes("|")) { - const tools = pattern.split("|").map((t) => t.trim()); - return tools.includes(toolName); + // Treat pattern as regex (anchored to match full tool name) + try { + const regex = new RegExp(`^(?:${pattern})$`); + return regex.test(toolName); + } catch { + // Invalid regex, fall back to exact match + return pattern === toolName; } - - // Exact match - return pattern === toolName; } /** diff --git a/src/tests/hooks/loader.test.ts b/src/tests/hooks/loader.test.ts index dfbbaf6..589c858 100644 --- a/src/tests/hooks/loader.test.ts +++ b/src/tests/hooks/loader.test.ts @@ -168,6 +168,34 @@ describe("Hooks Loader", () => { expect(matchesTool("Edit|Write", "Bash")).toBe(false); expect(matchesTool("Edit|Write|Read", "Read")).toBe(true); }); + + test("regex patterns work", () => { + // .* suffix pattern + expect(matchesTool("Notebook.*", "Notebook")).toBe(true); + expect(matchesTool("Notebook.*", "NotebookEdit")).toBe(true); + expect(matchesTool("Notebook.*", "NotebookRead")).toBe(true); + expect(matchesTool("Notebook.*", "Edit")).toBe(false); + + // Prefix pattern + expect(matchesTool(".*Edit", "NotebookEdit")).toBe(true); + expect(matchesTool(".*Edit", "Edit")).toBe(true); + expect(matchesTool(".*Edit", "Write")).toBe(false); + + // Character class + expect(matchesTool("Task|Bash", "Task")).toBe(true); + expect(matchesTool("Task|Bash", "Bash")).toBe(true); + + // More complex patterns + expect(matchesTool("Web.*", "WebFetch")).toBe(true); + expect(matchesTool("Web.*", "WebSearch")).toBe(true); + expect(matchesTool("Web.*", "Bash")).toBe(false); + }); + + test("invalid regex falls back to exact match", () => { + // Unclosed bracket is invalid regex + expect(matchesTool("[invalid", "[invalid")).toBe(true); + expect(matchesTool("[invalid", "invalid")).toBe(false); + }); }); describe("getMatchingHooks", () => {