diff --git a/cmd/hook.go b/cmd/hook.go index 1f54c519..94e65420 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -48,6 +48,8 @@ func init() { hookCmd.AddCommand(hookPreToolUseCmd) hookSessionStartCmd.Flags().StringVar(&hookSessionStartHost, "host", hookHostClaude, "Hook host output format") + hookSessionStartCmd.Flags().StringVar(&hookSessionStartPluginName, "plugin-name", "", "Claude Code plugin name") + hookPreToolUseCmd.Flags().StringVar(&hookPreToolUsePluginName, "plugin-name", "", "Claude Code plugin name") } var hookCmd = &cobra.Command{ @@ -55,7 +57,11 @@ var hookCmd = &cobra.Command{ Short: "Hook handlers for AI coding agent integration", } -var hookSessionStartHost string +var ( + hookSessionStartHost string + hookSessionStartPluginName string + hookPreToolUsePluginName string +) var hookSessionStartCmd = &cobra.Command{ Use: "session-start [mcp-name]", @@ -104,7 +110,7 @@ func runHookSessionStart(_ *cobra.Command, args []string) error { cwd, _ = os.Getwd() } - content := generateSessionContextForHost(host, mcpName, cwd) + content := generateSessionContextForHost(host, mcpName, hookSessionStartPluginName, cwd) out := sessionStartOutput(host, content) enc := json.NewEncoder(os.Stdout) @@ -112,16 +118,23 @@ func runHookSessionStart(_ *cobra.Command, args []string) error { return enc.Encode(out) } -func generateSessionContextForHost(host, mcpName, cwd string) string { - return generateSessionContextInternalWithDirective(sessionStartDirective(host, mcpName), cwd, config.FindDonorIndex, spawnBackgroundIndexer) +func generateSessionContextForHost(host, mcpName, pluginName, cwd string) string { + return generateSessionContextInternalWithDirective(sessionStartDirective(host, mcpName, pluginName), cwd, config.FindDonorIndex, spawnBackgroundIndexer) } -func sessionStartDirective(host, mcpName string) string { +func sessionStartDirective(host, mcpName, pluginName string) string { if host == hookHostCursor { return "Use the Lumen semantic_search tool first for any code discovery task — before Grep, Bash, or Read." } - toolRef := "mcp__" + mcpName + "__semantic_search" - return "Call " + toolRef + " first for any code discovery task — before Grep, Bash, or Read." + toolRef := mcpToolReference(pluginName, mcpName, "semantic_search") + return "Load and call " + toolRef + " first for any code discovery task — before Grep, Bash, or Read." +} + +func mcpToolReference(pluginName, mcpName, toolName string) string { + if pluginName != "" { + mcpName = "plugin_" + pluginName + "_" + mcpName + } + return "mcp__" + mcpName + "__" + toolName } func generateSessionContextInternalWithDirective(directive, cwd string, findDonor func(string, string) string, bgIndexer func(string)) string { @@ -190,7 +203,7 @@ func generateSessionContextInternalWithDirective(directive, cwd string, findDono // findDonor and bgIndexer are injected so tests can verify behaviour without // spawning real processes or requiring a live git repository. func generateSessionContextInternal(cwd string, findDonor func(string, string) string, bgIndexer func(string)) string { - return generateSessionContextInternalWithDirective(sessionStartDirective(hookHostClaude, "lumen"), cwd, findDonor, bgIndexer) + return generateSessionContextInternalWithDirective(sessionStartDirective(hookHostClaude, "lumen", ""), cwd, findDonor, bgIndexer) } func normalizeHookHost(host string) (string, error) { @@ -243,7 +256,7 @@ func runHookPreToolUse(_ *cobra.Command, args []string) error { return nil } - result := evaluateToolCall(input, mcpName) + result := evaluateToolCall(input, mcpName, hookPreToolUsePluginName) if result == nil { // Silent allow — exit 0 with no stdout. return nil @@ -257,7 +270,7 @@ func runHookPreToolUse(_ *cobra.Command, args []string) error { // evaluateToolCall determines whether a tool call should be intercepted // with a suggestion to use semantic search instead. // Returns nil for silent allow (no output), or a hookOutput with a suggestion. -func evaluateToolCall(input preToolUseInput, mcpName string) *hookOutput { +func evaluateToolCall(input preToolUseInput, mcpName, pluginName string) *hookOutput { switch input.ToolName { case "Grep", "Glob": // Always suggest semantic search for any file/code search. @@ -270,12 +283,12 @@ func evaluateToolCall(input preToolUseInput, mcpName string) *hookOutput { return nil } - toolRef := "mcp__" + mcpName + "__semantic_search" + toolRef := mcpToolReference(pluginName, mcpName, "semantic_search") return &hookOutput{ HookSpecificOutput: hookSpecificOutput{ HookEventName: "PreToolUse", AdditionalContext: fmt.Sprintf( - "Use %s instead of Grep/Glob/find/rg for significantly faster and better search results to reduce context window use and give better quality results.", + "Load and call %s instead of Grep/Glob/find/rg for significantly faster and better search results to reduce context window use and give better quality results.", toolRef, ), }, diff --git a/cmd/hook_test.go b/cmd/hook_test.go index d0a6be0b..880b273a 100644 --- a/cmd/hook_test.go +++ b/cmd/hook_test.go @@ -62,7 +62,7 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -func TestGenerateSessionContext_NoIndex(t *testing.T) { +func TestGenerateSessionContext_NoIndexLegacyToolName(t *testing.T) { // Use the internal version with a no-op bgIndexer to avoid spawning the // test binary as a background process (which would trigger a fork bomb: // the spawned binary runs all tests, which spawn more binaries, etc.) @@ -71,16 +71,50 @@ func TestGenerateSessionContext_NoIndex(t *testing.T) { func(_ string) {}, ) if !strings.Contains(content, "mcp__lumen__semantic_search") { - t.Error("content should reference the semantic_search tool") + t.Error("legacy content should reference the standalone semantic_search tool") } if strings.Contains(content, "EXTREMELY_IMPORTANT") { t.Error("content should not contain EXTREMELY_IMPORTANT directives") } } +func TestMCPToolReference(t *testing.T) { + tests := []struct { + name string + pluginName string + want string + }{ + { + name: "plugin qualified", + pluginName: "lumen", + want: "mcp__plugin_lumen_lumen__semantic_search", + }, + { + name: "standalone", + want: "mcp__lumen__semantic_search", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mcpToolReference(tt.pluginName, "lumen", "semantic_search") + if got != tt.want { + t.Fatalf("mcpToolReference() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSessionStartDirectiveClaudePlugin(t *testing.T) { + const want = "Load and call mcp__plugin_lumen_lumen__semantic_search first for any code discovery task — before Grep, Bash, or Read." + if got := sessionStartDirective(hookHostClaude, "lumen", "lumen"); got != want { + t.Fatalf("sessionStartDirective() = %q, want %q", got, want) + } +} + func TestGenerateSessionContextForCursor_NoIndex(t *testing.T) { content := generateSessionContextInternalWithDirective( - sessionStartDirective(hookHostCursor, "lumen"), + sessionStartDirective(hookHostCursor, "lumen", "lumen"), "/nonexistent/path", func(_, _ string) string { return "" }, func(_ string) {}, @@ -107,7 +141,7 @@ func TestEvaluateToolCall_GrepAlwaysSuggests(t *testing.T) { ToolName: "Grep", Input: map[string]any{"pattern": pattern}, } - result := evaluateToolCall(input, "lumen") + result := evaluateToolCall(input, "lumen", "lumen") if result == nil { t.Fatal("expected suggestion for Grep, got nil") return @@ -115,8 +149,8 @@ func TestEvaluateToolCall_GrepAlwaysSuggests(t *testing.T) { if result.HookSpecificOutput.PermissionDecision != "" { t.Errorf("expected no permissionDecision, got %q", result.HookSpecificOutput.PermissionDecision) } - if !strings.Contains(result.HookSpecificOutput.AdditionalContext, "mcp__lumen__semantic_search") { - t.Error("additionalContext should reference semantic_search tool") + if !strings.Contains(result.HookSpecificOutput.AdditionalContext, "mcp__plugin_lumen_lumen__semantic_search") { + t.Error("additionalContext should reference the plugin-qualified semantic_search tool") } }) } @@ -127,7 +161,7 @@ func TestEvaluateToolCall_GlobAlwaysSuggests(t *testing.T) { ToolName: "Glob", Input: map[string]any{"pattern": "**/*.go"}, } - result := evaluateToolCall(input, "lumen") + result := evaluateToolCall(input, "lumen", "lumen") if result == nil { t.Fatal("expected suggestion for Glob, got nil") return @@ -135,6 +169,9 @@ func TestEvaluateToolCall_GlobAlwaysSuggests(t *testing.T) { if result.HookSpecificOutput.PermissionDecision != "" { t.Errorf("expected no permissionDecision, got %q", result.HookSpecificOutput.PermissionDecision) } + if !strings.Contains(result.HookSpecificOutput.AdditionalContext, "mcp__plugin_lumen_lumen__semantic_search") { + t.Error("additionalContext should reference the plugin-qualified semantic_search tool") + } } func TestEvaluateToolCall_OtherToolSilentAllow(t *testing.T) { @@ -142,7 +179,7 @@ func TestEvaluateToolCall_OtherToolSilentAllow(t *testing.T) { ToolName: "Read", Input: map[string]any{"path": "/some/file.go"}, } - result := evaluateToolCall(input, "lumen") + result := evaluateToolCall(input, "lumen", "lumen") if result != nil { t.Errorf("expected nil (silent allow) for Read, got suggestion") } @@ -160,13 +197,13 @@ func TestEvaluateToolCall_BashGrepSuggests(t *testing.T) { ToolName: "Bash", Input: map[string]any{"command": cmd}, } - result := evaluateToolCall(input, "lumen") + result := evaluateToolCall(input, "lumen", "lumen") if result == nil { t.Fatal("expected suggestion for bash grep, got nil") return } - if !strings.Contains(result.HookSpecificOutput.AdditionalContext, "mcp__lumen__semantic_search") { - t.Error("additionalContext should reference semantic_search tool") + if !strings.Contains(result.HookSpecificOutput.AdditionalContext, "mcp__plugin_lumen_lumen__semantic_search") { + t.Error("additionalContext should reference the plugin-qualified semantic_search tool") } }) } @@ -184,7 +221,7 @@ func TestEvaluateToolCall_BashNonSearchSilentAllow(t *testing.T) { ToolName: "Bash", Input: map[string]any{"command": cmd}, } - result := evaluateToolCall(input, "lumen") + result := evaluateToolCall(input, "lumen", "lumen") if result != nil { t.Errorf("expected nil for non-search bash command %q, got suggestion", cmd) } @@ -196,7 +233,7 @@ func TestPreToolUseOutputJSON(t *testing.T) { result := evaluateToolCall(preToolUseInput{ ToolName: "Grep", Input: map[string]any{"pattern": "error handling middleware"}, - }, "lumen") + }, "lumen", "lumen") if result == nil { t.Fatal("expected non-nil result") } @@ -432,7 +469,9 @@ func TestGenerateSessionContextInternal_NonGitUsesParentIndex(t *testing.T) { func TestHookOutputJSON(t *testing.T) { // Use the internal version with a no-op bgIndexer — same fork-bomb reason // as in TestGenerateSessionContext_NoIndex. - content := generateSessionContextInternal("/nonexistent/path", + content := generateSessionContextInternalWithDirective( + sessionStartDirective(hookHostClaude, "lumen", "lumen"), + "/nonexistent/path", func(_, _ string) string { return "" }, func(_ string) {}, ) @@ -461,14 +500,14 @@ func TestHookOutputJSON(t *testing.T) { t.Errorf("hookEventName = %v, want SessionStart", hso["hookEventName"]) } ctx, ok := hso["additionalContext"].(string) - if !ok || !strings.Contains(ctx, "mcp__lumen__semantic_search") { - t.Error("additionalContext should contain tool reference") + if !ok || !strings.Contains(ctx, "mcp__plugin_lumen_lumen__semantic_search") { + t.Error("additionalContext should contain the plugin-qualified tool reference") } } func TestSessionStartOutputCursorJSON(t *testing.T) { content := generateSessionContextInternalWithDirective( - sessionStartDirective(hookHostCursor, "lumen"), + sessionStartDirective(hookHostCursor, "lumen", "lumen"), "/nonexistent/path", func(_, _ string) string { return "" }, func(_ string) {}, diff --git a/hooks/hooks.json b/hooks/hooks.json index 21aea78a..410c2f2b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/run\" hook session-start lumen --host claude" + "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/run\" hook session-start lumen --host claude --plugin-name lumen" } ] } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/run\" hook pre-tool-use lumen" + "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/run\" hook pre-tool-use lumen --plugin-name lumen" } ] }