Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions cmd/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,20 @@ 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{
Use: "hook",
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]",
Expand Down Expand Up @@ -104,24 +110,31 @@ 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)
enc.SetEscapeHTML(false)
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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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,
),
},
Expand Down
73 changes: 56 additions & 17 deletions cmd/hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand All @@ -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) {},
Expand All @@ -107,16 +141,16 @@ 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
}
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")
}
})
}
Expand All @@ -127,22 +161,25 @@ 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
}
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) {
input := preToolUseInput{
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")
}
Expand All @@ -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")
}
})
}
Expand All @@ -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)
}
Expand All @@ -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")
}
Expand Down Expand Up @@ -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) {},
)
Expand Down Expand Up @@ -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) {},
Expand Down
4 changes: 2 additions & 2 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand All @@ -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"
}
]
}
Expand Down
Loading