Skip to content

Commit a32e6d8

Browse files
committed
feat: handle pipelines with | head/tail and add MCP-aware suggestions
Pipelines ending in | head -n N or | tail -n N are now recognized and mapped to the appropriate aifr limit parameter per command: - cat file | head -n N → aifr read --lines=1:N file - git log | head -n N → aifr log --max-count=N - grep pat . | head -N → aifr search --max-matches=N pat . - find/ls | head -N → --limit=N Suggestions now carry MCP tool metadata (tool name + args). When --mcp is set or an aifr MCP server is detected in .mcp.json / $AIFR_MCP, the deny reason references the MCP tool call instead of a CLI command. https://claude.ai/code/session_017inmawi6PUgMy9zSu6EXKv
1 parent cd49901 commit a32e6d8

File tree

8 files changed

+737
-113
lines changed

8 files changed

+737
-113
lines changed

cmd/aifr/cmd_hook_checkcommand.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"go.pennock.tech/aifr/internal/hookcmd"
1212
)
1313

14+
var checkCommandMCP bool
15+
1416
var checkCommandCmd = &cobra.Command{
1517
Use: "check-command",
1618
Short: "Suggest aifr alternatives for Bash tool calls",
@@ -21,6 +23,12 @@ the Bash call and suggesting the aifr alternative.
2123
If the command is not something aifr handles, exits silently (exit 0,
2224
no output) so the Bash call continues through normal permission evaluation.
2325
26+
Pipelines ending in | head -n N or | tail -n N are recognized and mapped
27+
to the appropriate aifr limit parameter (--max-count, --limit, --lines, etc.).
28+
29+
When --mcp is set, or when an aifr MCP server is detected in .mcp.json,
30+
suggestions reference MCP tool calls instead of CLI sub-commands.
31+
2432
Recognized commands: cat, head, tail, grep/rg, find, ls, wc, stat,
2533
diff, sed -n, sha256sum/md5sum, hexdump/xxd, git log, git diff.
2634
@@ -48,7 +56,7 @@ Usage in Claude Code settings:
4856
return err
4957
}
5058

51-
result, err := hookcmd.CheckCommand(input)
59+
result, err := hookcmd.CheckCommand(input, checkCommandMCP)
5260
if err != nil {
5361
return err
5462
}
@@ -63,5 +71,7 @@ Usage in Claude Code settings:
6371
}
6472

6573
func init() {
74+
checkCommandCmd.Flags().BoolVar(&checkCommandMCP, "mcp", false,
75+
"suggest MCP tool calls (auto-detected from .mcp.json and $AIFR_MCP if not set)")
6676
hookCmd.AddCommand(checkCommandCmd)
6777
}

internal/hookcmd/hookcmd.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
// Copyright 2026 — see LICENSE file for terms.
22
package hookcmd
33

4-
import "encoding/json"
4+
import (
5+
"encoding/json"
6+
"fmt"
7+
)
58

69
// HookInput is the JSON payload received from a Claude Code hook on stdin.
710
type HookInput struct {
811
SessionID string `json:"session_id"`
12+
CWD string `json:"cwd"`
913
ToolName string `json:"tool_name"`
1014
ToolInput json.RawMessage `json:"tool_input"`
1115
HookEventName string `json:"hook_event_name"`
@@ -30,7 +34,11 @@ type HookDecision struct {
3034

3135
// CheckCommand parses a PreToolUse hook payload and returns a hook output
3236
// denying the command with an aifr suggestion, or nil if no suggestion applies.
33-
func CheckCommand(input []byte) (*HookOutput, error) {
37+
//
38+
// When forceMCP is true, suggestions always reference MCP tool calls.
39+
// Otherwise, MCP availability is auto-detected from the working directory's
40+
// .mcp.json and the AIFR_MCP environment variable.
41+
func CheckCommand(input []byte, forceMCP bool) (*HookOutput, error) {
3442
var hi HookInput
3543
if err := json.Unmarshal(input, &hi); err != nil {
3644
return nil, err
@@ -50,13 +58,33 @@ func CheckCommand(input []byte) (*HookOutput, error) {
5058
return nil, nil
5159
}
5260

61+
mcpMode := forceMCP || detectMCPAvailable(hi.CWD)
62+
63+
var reason string
64+
if mcpMode {
65+
reason = formatMCPReason(suggestion)
66+
} else {
67+
reason = formatCLIReason(suggestion)
68+
}
69+
5370
return &HookOutput{
5471
HookSpecificOutput: &HookDecision{
5572
HookEventName: "PreToolUse",
5673
Decision: "deny",
57-
Reason: "This " + suggestion.Original +
58-
" invocation can be handled by aifr with access controls. Use: " +
59-
suggestion.AifrCommand,
74+
Reason: reason,
6075
},
6176
}, nil
6277
}
78+
79+
func formatCLIReason(s *Suggestion) string {
80+
return "This " + s.Original +
81+
" invocation can be handled by aifr with access controls. Use: " +
82+
s.AifrCommand
83+
}
84+
85+
func formatMCPReason(s *Suggestion) string {
86+
argsJSON, _ := json.Marshal(s.ToolArgs)
87+
return fmt.Sprintf(
88+
"This %s invocation can be handled by aifr with access controls. Use the %s tool: %s",
89+
s.Original, s.ToolName, string(argsJSON))
90+
}

internal/hookcmd/hookcmd_test.go

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import (
99
func TestCheckCommand_BashWithSuggestion(t *testing.T) {
1010
input := `{
1111
"session_id": "test-session",
12+
"cwd": "/tmp/nonexistent",
1213
"tool_name": "Bash",
1314
"tool_input": {"command": "cat main.go"},
1415
"hook_event_name": "PreToolUse"
1516
}`
1617

17-
result, err := CheckCommand([]byte(input))
18+
result, err := CheckCommand([]byte(input), false)
1819
if err != nil {
1920
t.Fatal(err)
2021
}
@@ -35,12 +36,13 @@ func TestCheckCommand_BashWithSuggestion(t *testing.T) {
3536
func TestCheckCommand_BashNoSuggestion(t *testing.T) {
3637
input := `{
3738
"session_id": "test-session",
39+
"cwd": "/tmp/nonexistent",
3840
"tool_name": "Bash",
3941
"tool_input": {"command": "go test ./..."},
4042
"hook_event_name": "PreToolUse"
4143
}`
4244

43-
result, err := CheckCommand([]byte(input))
45+
result, err := CheckCommand([]byte(input), false)
4446
if err != nil {
4547
t.Fatal(err)
4648
}
@@ -52,12 +54,13 @@ func TestCheckCommand_BashNoSuggestion(t *testing.T) {
5254
func TestCheckCommand_NonBashTool(t *testing.T) {
5355
input := `{
5456
"session_id": "test-session",
57+
"cwd": "/tmp/nonexistent",
5558
"tool_name": "Read",
5659
"tool_input": {"file_path": "/tmp/test.go"},
5760
"hook_event_name": "PreToolUse"
5861
}`
5962

60-
result, err := CheckCommand([]byte(input))
63+
result, err := CheckCommand([]byte(input), false)
6164
if err != nil {
6265
t.Fatal(err)
6366
}
@@ -67,43 +70,52 @@ func TestCheckCommand_NonBashTool(t *testing.T) {
6770
}
6871

6972
func TestCheckCommand_InvalidJSON(t *testing.T) {
70-
_, err := CheckCommand([]byte("not json"))
73+
_, err := CheckCommand([]byte("not json"), false)
7174
if err == nil {
7275
t.Error("expected error for invalid JSON")
7376
}
7477
}
7578

76-
// TestCheckCommand_PipelinePassthrough is an end-to-end wiring test verifying
77-
// that a command pipeline (containing shell operators) passes through
78-
// CheckCommand without a suggestion. The full scope of complex pipeline
79-
// detection is covered in suggest_test.go via AnalyzeCommand tests.
80-
func TestCheckCommand_PipelinePassthrough(t *testing.T) {
79+
// TestCheckCommand_PipelineSuggestion is an end-to-end wiring test verifying
80+
// that a command pipeline with a recognized | head tail produces a suggestion
81+
// with the appropriate per-command limit parameter. The full scope of pipeline
82+
// and complex command analysis is covered in suggest_test.go.
83+
func TestCheckCommand_PipelineSuggestion(t *testing.T) {
8184
input := `{
8285
"session_id": "test-session",
86+
"cwd": "/tmp/nonexistent",
8387
"tool_name": "Bash",
8488
"tool_input": {"command": "git log --oneline | head -n 10"},
8589
"hook_event_name": "PreToolUse"
8690
}`
8791

88-
result, err := CheckCommand([]byte(input))
92+
result, err := CheckCommand([]byte(input), false)
8993
if err != nil {
9094
t.Fatal(err)
9195
}
92-
if result != nil {
93-
t.Errorf("expected nil for pipeline command, got result with decision %q",
94-
result.HookSpecificOutput.Decision)
96+
if result == nil {
97+
t.Fatal("expected suggestion for pipeline command, got nil")
98+
}
99+
if result.HookSpecificOutput.Decision != "deny" {
100+
t.Errorf("expected deny, got %q", result.HookSpecificOutput.Decision)
101+
}
102+
// Verify the reason mentions the aifr log command with --max-count.
103+
reason := result.HookSpecificOutput.Reason
104+
if reason == "" {
105+
t.Error("expected non-empty reason")
95106
}
96107
}
97108

98109
func TestCheckCommand_OutputFormat(t *testing.T) {
99110
input := `{
100111
"session_id": "s1",
112+
"cwd": "/tmp/nonexistent",
101113
"tool_name": "Bash",
102114
"tool_input": {"command": "head -50 README.md"},
103115
"hook_event_name": "PreToolUse"
104116
}`
105117

106-
result, err := CheckCommand([]byte(input))
118+
result, err := CheckCommand([]byte(input), false)
107119
if err != nil {
108120
t.Fatal(err)
109121
}
@@ -137,3 +149,44 @@ func TestCheckCommand_OutputFormat(t *testing.T) {
137149
t.Error("expected non-empty reason")
138150
}
139151
}
152+
153+
func TestCheckCommand_MCPMode(t *testing.T) {
154+
input := `{
155+
"session_id": "test-session",
156+
"cwd": "/tmp/nonexistent",
157+
"tool_name": "Bash",
158+
"tool_input": {"command": "cat main.go"},
159+
"hook_event_name": "PreToolUse"
160+
}`
161+
162+
// CLI mode (forceMCP=false, no .mcp.json in /tmp/nonexistent)
163+
cliResult, err := CheckCommand([]byte(input), false)
164+
if err != nil {
165+
t.Fatal(err)
166+
}
167+
if cliResult == nil {
168+
t.Fatal("expected result")
169+
}
170+
cliReason := cliResult.HookSpecificOutput.Reason
171+
if cliReason == "" {
172+
t.Fatal("expected non-empty CLI reason")
173+
}
174+
175+
// MCP mode (forceMCP=true)
176+
mcpResult, err := CheckCommand([]byte(input), true)
177+
if err != nil {
178+
t.Fatal(err)
179+
}
180+
if mcpResult == nil {
181+
t.Fatal("expected result")
182+
}
183+
mcpReason := mcpResult.HookSpecificOutput.Reason
184+
if mcpReason == "" {
185+
t.Fatal("expected non-empty MCP reason")
186+
}
187+
188+
// CLI reason should reference the CLI command.
189+
if cliReason == mcpReason {
190+
t.Error("CLI and MCP reasons should differ")
191+
}
192+
}

internal/hookcmd/mcpdetect.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2026 — see LICENSE file for terms.
2+
package hookcmd
3+
4+
import (
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// detectMCPAvailable checks whether an aifr MCP server is likely available
11+
// in the current Claude Code session.
12+
//
13+
// Detection order:
14+
// 1. AIFR_MCP environment variable (any non-empty value → true)
15+
// 2. .mcp.json in the given working directory
16+
func detectMCPAvailable(cwd string) bool {
17+
if os.Getenv("AIFR_MCP") != "" {
18+
return true
19+
}
20+
if cwd != "" {
21+
if checkMCPConfig(filepath.Join(cwd, ".mcp.json")) {
22+
return true
23+
}
24+
}
25+
return false
26+
}
27+
28+
// checkMCPConfig reads a .mcp.json file and returns true if it contains
29+
// an aifr MCP server entry (matched by server name or command basename).
30+
func checkMCPConfig(path string) bool {
31+
data, err := os.ReadFile(path)
32+
if err != nil {
33+
return false
34+
}
35+
var config struct {
36+
MCPServers map[string]struct {
37+
Command string `json:"command"`
38+
} `json:"mcpServers"`
39+
}
40+
if err := json.Unmarshal(data, &config); err != nil {
41+
return false
42+
}
43+
for name, server := range config.MCPServers {
44+
if name == "aifr" {
45+
return true
46+
}
47+
if filepath.Base(server.Command) == "aifr" {
48+
return true
49+
}
50+
}
51+
return false
52+
}

internal/hookcmd/shellparse.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,59 @@ func hasShellOperators(s string) bool {
101101
return false
102102
}
103103

104+
// splitPipeline splits a command by unquoted pipe operators.
105+
// It recognizes || as a logical OR (kept in the stage, not split) and only
106+
// splits on single | pipe operators.
107+
func splitPipeline(s string) []string {
108+
var stages []string
109+
var cur strings.Builder
110+
inSingle := false
111+
inDouble := false
112+
escaped := false
113+
runes := []rune(s)
114+
115+
for i := 0; i < len(runes); i++ {
116+
r := runes[i]
117+
if escaped {
118+
cur.WriteRune(r)
119+
escaped = false
120+
continue
121+
}
122+
if r == '\\' && !inSingle {
123+
cur.WriteRune(r)
124+
escaped = true
125+
continue
126+
}
127+
if r == '\'' && !inDouble {
128+
inSingle = !inSingle
129+
cur.WriteRune(r)
130+
continue
131+
}
132+
if r == '"' && !inSingle {
133+
inDouble = !inDouble
134+
cur.WriteRune(r)
135+
continue
136+
}
137+
if r == '|' && !inSingle && !inDouble {
138+
// || is logical OR, not a pipe — keep in current stage.
139+
if i+1 < len(runes) && runes[i+1] == '|' {
140+
cur.WriteRune(r)
141+
cur.WriteRune(runes[i+1])
142+
i++
143+
continue
144+
}
145+
stages = append(stages, cur.String())
146+
cur.Reset()
147+
continue
148+
}
149+
cur.WriteRune(r)
150+
}
151+
if cur.Len() > 0 {
152+
stages = append(stages, cur.String())
153+
}
154+
return stages
155+
}
156+
104157
// baseName returns the last path component of cmd (strips directory prefix).
105158
func baseName(cmd string) string {
106159
if idx := strings.LastIndex(cmd, "/"); idx >= 0 {

0 commit comments

Comments
 (0)