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
35 changes: 35 additions & 0 deletions cmd/aifr/cmd_hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright 2026 — see LICENSE file for terms.
package main

import "github.com/spf13/cobra"

var hookCmd = &cobra.Command{
Use: "hook",
Short: "Hooks for AI coding agent integration",
Long: `Commands designed for use as hooks in AI coding agents such as Claude Code.

These sub-commands read hook payloads from stdin and write hook responses
to stdout, following the agent's hook protocol.

Example Claude Code configuration:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "aifr hook check-command"
}
]
}
]
}
}`,
}

func init() {
rootCmd.AddCommand(hookCmd)
}
77 changes: 77 additions & 0 deletions cmd/aifr/cmd_hook_checkcommand.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2026 — see LICENSE file for terms.
package main

import (
"encoding/json"
"io"
"os"

"github.com/spf13/cobra"

"go.pennock.tech/aifr/internal/hookcmd"
)

var checkCommandMCP bool

var checkCommandCmd = &cobra.Command{
Use: "check-command",
Short: "Suggest aifr alternatives for Bash tool calls",
Long: `Reads a Claude Code PreToolUse hook payload from stdin, analyzes the
shell command, and if aifr can handle it, outputs a hook response denying
the Bash call and suggesting the aifr alternative.

If the command is not something aifr handles, exits silently (exit 0,
no output) so the Bash call continues through normal permission evaluation.

Pipelines ending in | head -n N or | tail -n N are recognized and mapped
to the appropriate aifr limit parameter (--max-count, --limit, --lines, etc.).

When --mcp is set, or when an aifr MCP server is detected in .mcp.json,
suggestions reference MCP tool calls instead of CLI sub-commands.

Recognized commands: cat, head, tail, grep/rg, find, ls, wc, stat,
diff, sed -n, sha256sum/md5sum, hexdump/xxd, git log, git diff.

Usage in Claude Code settings:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "aifr hook check-command"
}
]
}
]
}
}`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
input, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}

result, err := hookcmd.CheckCommand(input, checkCommandMCP)
if err != nil {
return err
}
if result == nil {
return nil
}

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
},
}

func init() {
checkCommandCmd.Flags().BoolVar(&checkCommandMCP, "mcp", false,
"suggest MCP tool calls (auto-detected from .mcp.json and $AIFR_MCP if not set)")
hookCmd.AddCommand(checkCommandCmd)
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/pelletier/go-toml/v2 v2.3.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0
mvdan.cc/sh/v3 v3.13.1
)

require (
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
Expand Down Expand Up @@ -182,3 +184,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
90 changes: 90 additions & 0 deletions internal/hookcmd/hookcmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2026 — see LICENSE file for terms.
package hookcmd

import (
"encoding/json"
"fmt"
)

// HookInput is the JSON payload received from a Claude Code hook on stdin.
type HookInput struct {
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
ToolName string `json:"tool_name"`
ToolInput json.RawMessage `json:"tool_input"`
HookEventName string `json:"hook_event_name"`
}

// BashInput is the tool_input for a Bash tool call.
type BashInput struct {
Command string `json:"command"`
}

// HookOutput is the JSON response for a Claude Code hook.
type HookOutput struct {
HookSpecificOutput *HookDecision `json:"hookSpecificOutput"`
}

// HookDecision describes the hook's permission decision.
type HookDecision struct {
HookEventName string `json:"hookEventName"`
Decision string `json:"permissionDecision"`
Reason string `json:"permissionDecisionReason,omitempty"`
}

// CheckCommand parses a PreToolUse hook payload and returns a hook output
// denying the command with an aifr suggestion, or nil if no suggestion applies.
//
// When forceMCP is true, suggestions always reference MCP tool calls.
// Otherwise, MCP availability is auto-detected from the working directory's
// .mcp.json and the AIFR_MCP environment variable.
func CheckCommand(input []byte, forceMCP bool) (*HookOutput, error) {
var hi HookInput
if err := json.Unmarshal(input, &hi); err != nil {
return nil, err
}

if hi.ToolName != "Bash" {
return nil, nil
}

var bi BashInput
if err := json.Unmarshal(hi.ToolInput, &bi); err != nil {
return nil, err
}

suggestion := AnalyzeCommand(bi.Command)
if suggestion == nil {
return nil, nil
}

mcpMode := forceMCP || detectMCPAvailable(hi.CWD)

var reason string
if mcpMode {
reason = formatMCPReason(suggestion)
} else {
reason = formatCLIReason(suggestion)
}

return &HookOutput{
HookSpecificOutput: &HookDecision{
HookEventName: "PreToolUse",
Decision: "deny",
Reason: reason,
},
}, nil
}

func formatCLIReason(s *Suggestion) string {
return "This " + s.Original +
" invocation can be handled by aifr with access controls. Use: " +
s.AifrCommand
}

func formatMCPReason(s *Suggestion) string {
argsJSON, _ := json.Marshal(s.ToolArgs)
return fmt.Sprintf(
"This %s invocation can be handled by aifr with access controls. Use the %s tool: %s",
s.Original, s.ToolName, string(argsJSON))
}
Loading