From e22cfb37e9ae14ee16f0ab624130e09a2cfc4904 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 01:57:39 +0000 Subject: [PATCH 1/7] feat: add `aifr hook check-command` for Claude Code pre-tool-use hooks Adds a new `aifr hook` parent command with a `check-command` sub-command designed for use as a Claude Code PreToolUse hook. When configured for the Bash tool matcher, it reads the hook payload from stdin, analyzes the shell command, and denies it with an aifr alternative suggestion when the operation can be safely handled by aifr. Recognized commands: cat, head, tail, grep/rg, find, ls, wc, stat, diff, sed -n, sha256sum/md5sum/sha1sum, hexdump/xxd, git log, git diff. Complex commands (pipes, chains, subshells) are passed through silently. The `hook` parent command is extensible for future hook integrations. https://claude.ai/code/session_017inmawi6PUgMy9zSu6EXKv --- cmd/aifr/cmd_hook.go | 35 ++ cmd/aifr/cmd_hook_checkcommand.go | 67 ++++ go.mod | 26 -- go.sum | 48 --- internal/hookcmd/hookcmd.go | 62 ++++ internal/hookcmd/hookcmd_test.go | 117 +++++++ internal/hookcmd/shellparse.go | 167 ++++++++++ internal/hookcmd/shellparse_test.go | 122 +++++++ internal/hookcmd/suggest.go | 492 ++++++++++++++++++++++++++++ internal/hookcmd/suggest_test.go | 332 +++++++++++++++++++ 10 files changed, 1394 insertions(+), 74 deletions(-) create mode 100644 cmd/aifr/cmd_hook.go create mode 100644 cmd/aifr/cmd_hook_checkcommand.go create mode 100644 internal/hookcmd/hookcmd.go create mode 100644 internal/hookcmd/hookcmd_test.go create mode 100644 internal/hookcmd/shellparse.go create mode 100644 internal/hookcmd/shellparse_test.go create mode 100644 internal/hookcmd/suggest.go create mode 100644 internal/hookcmd/suggest_test.go diff --git a/cmd/aifr/cmd_hook.go b/cmd/aifr/cmd_hook.go new file mode 100644 index 0000000..b00a43c --- /dev/null +++ b/cmd/aifr/cmd_hook.go @@ -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) +} diff --git a/cmd/aifr/cmd_hook_checkcommand.go b/cmd/aifr/cmd_hook_checkcommand.go new file mode 100644 index 0000000..b0b9389 --- /dev/null +++ b/cmd/aifr/cmd_hook_checkcommand.go @@ -0,0 +1,67 @@ +// 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 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 proceeds normally. + +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) + if err != nil { + return err + } + if result == nil { + return nil + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + }, +} + +func init() { + hookCmd.AddCommand(checkCommandCmd) +} diff --git a/go.mod b/go.mod index 6797d6a..192705f 100644 --- a/go.mod +++ b/go.mod @@ -13,57 +13,31 @@ require ( ) require ( - al.essio.dev/pkg/shellescape v1.5.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/danieljoos/wincred v1.2.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fatih/color v1.18.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/google/go-github/v80 v80.0.0 // indirect - github.com/google/go-github/v82 v82.0.0 // indirect - github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect - github.com/hashicorp/go-version v1.8.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/lmittmann/tint v1.1.2 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect - github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2 // indirect - github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 // indirect - github.com/suzuki-shunsuke/go-exec v0.0.1 // indirect - github.com/suzuki-shunsuke/pinact/v3 v3.9.0 // indirect - github.com/suzuki-shunsuke/slog-error v0.2.2 // indirect - github.com/suzuki-shunsuke/slog-util v0.3.1 // indirect - github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 // indirect - github.com/urfave/cli/v3 v3.6.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.42.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 30b1655..b53785e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= -al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -13,15 +11,11 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= -github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -29,8 +23,6 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -43,27 +35,14 @@ 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/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= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v80 v80.0.0 h1:BTyk3QOHekrk5VF+jIGz1TNEsmeoQG9K/UWaaP+EWQs= -github.com/google/go-github/v80 v80.0.0/go.mod h1:pRo4AIMdHW83HNMGfNysgSAv0vmu+/pkY8nZO9FT9Yo= -github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= -github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -79,12 +58,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= -github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -109,8 +82,6 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -121,28 +92,10 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2 h1:rgGrzb4VDfGSFCXecxKbzJ2PxcJyplfKIu8wkyWGZ1U= -github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2/go.mod h1:RqXFhArJSKR/D+42ptl9pQFQ5ikIexxB7AxiFB1gOOo= -github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 h1:oVXrrYNGBq4POyITQNWKzwsYz7B2nUcqtDbeX4BfeEc= -github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0/go.mod h1:kDFtLeftDiIUUHXGI3xq5eJ+uAOi50FPrxPENTHktJ0= -github.com/suzuki-shunsuke/go-exec v0.0.1 h1:xn/lvYnRQOujUd46ph6f6IT0gVJIC8+3liSZKOjNj44= -github.com/suzuki-shunsuke/go-exec v0.0.1/go.mod h1:KstSwIiQTKY34wEurUcFyKkaJDogBr5E3xxfdkkzvb0= -github.com/suzuki-shunsuke/pinact/v3 v3.9.0 h1:9joYfYEfGSmQepuYZhl3akEgSr12MvU4O8JJR4WTMOw= -github.com/suzuki-shunsuke/pinact/v3 v3.9.0/go.mod h1:v83U2W/fTfVB0kygS2jqoT7hBFY4OAK9TaAwe12QHbA= -github.com/suzuki-shunsuke/slog-error v0.2.2 h1:z8rymlIlZcMA+ERnnhVigQ0Q+X0pxKqBfDzSIyGh6vU= -github.com/suzuki-shunsuke/slog-error v0.2.2/go.mod h1:w45QyO2G0uiEuo9hhrcLqqRl3hmYon9jGgq9CrCxxOY= -github.com/suzuki-shunsuke/slog-util v0.3.1 h1:tp4xgj4y/T2YcZkHtr7N2E49f5CiHl9o47/HKdwRQ4g= -github.com/suzuki-shunsuke/slog-util v0.3.1/go.mod h1:PgZMd+2rC8pA9jBbXDfkI8mTuWYAiaVkKxjrbLtfN5I= -github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 h1:ORT/qQxsKuWwuy2N/z2f2hmbKWmlS346/j4jGhxsxLo= -github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0/go.mod h1:BYtzUgA4oeUVUFoJIONWOquvIUy0cl7DpAeCya3mVJU= -github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= -github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= -github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= @@ -160,7 +113,6 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/internal/hookcmd/hookcmd.go b/internal/hookcmd/hookcmd.go new file mode 100644 index 0000000..9f33ffc --- /dev/null +++ b/internal/hookcmd/hookcmd.go @@ -0,0 +1,62 @@ +// Copyright 2026 — see LICENSE file for terms. +package hookcmd + +import "encoding/json" + +// HookInput is the JSON payload received from a Claude Code hook on stdin. +type HookInput struct { + SessionID string `json:"session_id"` + 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. +func CheckCommand(input []byte) (*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 + } + + return &HookOutput{ + HookSpecificOutput: &HookDecision{ + HookEventName: "PreToolUse", + Decision: "deny", + Reason: "This " + suggestion.Original + + " invocation can be handled by aifr with access controls. Use: " + + suggestion.AifrCommand, + }, + }, nil +} diff --git a/internal/hookcmd/hookcmd_test.go b/internal/hookcmd/hookcmd_test.go new file mode 100644 index 0000000..9933cac --- /dev/null +++ b/internal/hookcmd/hookcmd_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 — see LICENSE file for terms. +package hookcmd + +import ( + "encoding/json" + "testing" +) + +func TestCheckCommand_BashWithSuggestion(t *testing.T) { + input := `{ + "session_id": "test-session", + "tool_name": "Bash", + "tool_input": {"command": "cat main.go"}, + "hook_event_name": "PreToolUse" + }` + + result, err := CheckCommand([]byte(input)) + if err != nil { + t.Fatal(err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + if result.HookSpecificOutput == nil { + t.Fatal("expected HookSpecificOutput, got nil") + } + if result.HookSpecificOutput.Decision != "deny" { + t.Errorf("expected deny, got %q", result.HookSpecificOutput.Decision) + } + if result.HookSpecificOutput.HookEventName != "PreToolUse" { + t.Errorf("expected PreToolUse, got %q", result.HookSpecificOutput.HookEventName) + } +} + +func TestCheckCommand_BashNoSuggestion(t *testing.T) { + input := `{ + "session_id": "test-session", + "tool_name": "Bash", + "tool_input": {"command": "go test ./..."}, + "hook_event_name": "PreToolUse" + }` + + result, err := CheckCommand([]byte(input)) + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Errorf("expected nil, got result with decision %q", result.HookSpecificOutput.Decision) + } +} + +func TestCheckCommand_NonBashTool(t *testing.T) { + input := `{ + "session_id": "test-session", + "tool_name": "Read", + "tool_input": {"file_path": "/tmp/test.go"}, + "hook_event_name": "PreToolUse" + }` + + result, err := CheckCommand([]byte(input)) + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Error("expected nil for non-Bash tool") + } +} + +func TestCheckCommand_InvalidJSON(t *testing.T) { + _, err := CheckCommand([]byte("not json")) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestCheckCommand_OutputFormat(t *testing.T) { + input := `{ + "session_id": "s1", + "tool_name": "Bash", + "tool_input": {"command": "head -50 README.md"}, + "hook_event_name": "PreToolUse" + }` + + result, err := CheckCommand([]byte(input)) + if err != nil { + t.Fatal(err) + } + if result == nil { + t.Fatal("expected result") + } + + // Verify it marshals to valid JSON with expected structure. + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + + hso, ok := decoded["hookSpecificOutput"].(map[string]any) + if !ok { + t.Fatal("missing hookSpecificOutput") + } + if hso["hookEventName"] != "PreToolUse" { + t.Errorf("hookEventName: %v", hso["hookEventName"]) + } + if hso["permissionDecision"] != "deny" { + t.Errorf("permissionDecision: %v", hso["permissionDecision"]) + } + reason, _ := hso["permissionDecisionReason"].(string) + if reason == "" { + t.Error("expected non-empty reason") + } +} diff --git a/internal/hookcmd/shellparse.go b/internal/hookcmd/shellparse.go new file mode 100644 index 0000000..3aa3006 --- /dev/null +++ b/internal/hookcmd/shellparse.go @@ -0,0 +1,167 @@ +// Copyright 2026 — see LICENSE file for terms. + +// Package hookcmd implements command analysis for AI coding agent hooks. +// It parses shell commands from hook payloads and suggests aifr alternatives +// when the command can be safely handled by aifr. +package hookcmd + +import "strings" + +// tokenize splits a shell command into tokens, handling single quotes, +// double quotes, and backslash escaping. It does not expand variables or globs. +func tokenize(s string) []string { + var tokens []string + var cur strings.Builder + inSingle := false + inDouble := false + escaped := false + + for _, r := range s { + if escaped { + cur.WriteRune(r) + escaped = false + continue + } + if r == '\\' && !inSingle { + escaped = true + continue + } + if r == '\'' && !inDouble { + inSingle = !inSingle + continue + } + if r == '"' && !inSingle { + inDouble = !inDouble + continue + } + if (r == ' ' || r == '\t') && !inSingle && !inDouble { + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + continue + } + cur.WriteRune(r) + } + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + } + return tokens +} + +// hasShellOperators reports whether the command contains shell operators +// (pipes, chains, backgrounding) outside of quoted strings. +// Commands with these operators are too complex for simple replacement. +func hasShellOperators(s string) bool { + inSingle := false + inDouble := false + escaped := false + runes := []rune(s) + + for i := 0; i < len(runes); i++ { + r := runes[i] + if escaped { + escaped = false + continue + } + if r == '\\' && !inSingle { + escaped = true + continue + } + if r == '\'' && !inDouble { + inSingle = !inSingle + continue + } + if r == '"' && !inSingle { + inDouble = !inDouble + continue + } + if inSingle || inDouble { + continue + } + switch r { + case '|': + return true + case ';': + return true + case '&': + return true + case '(': + return true + case '`': + return true + case '\n': + return true + } + // $( subshell + if r == '$' && i+1 < len(runes) && runes[i+1] == '(' { + return true + } + } + return false +} + +// baseName returns the last path component of cmd (strips directory prefix). +func baseName(cmd string) string { + if idx := strings.LastIndex(cmd, "/"); idx >= 0 { + return cmd[idx+1:] + } + return cmd +} + +// nonFlags returns tokens that don't start with '-', skipping redirections. +func nonFlags(tokens []string) []string { + var out []string + skipNext := false + for _, t := range tokens { + if skipNext { + skipNext = false + continue + } + // Skip output/input redirections and their targets. + if t == ">" || t == ">>" || t == "<" || t == "<<" || t == "2>" || t == "2>>" || t == "&>" { + skipNext = true + continue + } + // Skip tokens that are redirection targets embedded with operator. + if len(t) > 1 && (t[0] == '>' || (t[0] == '2' && len(t) > 2 && t[1] == '>')) { + continue + } + if strings.HasPrefix(t, "-") { + continue + } + out = append(out, t) + } + return out +} + +// hasFlag reports whether any token exactly matches one of the given flags. +func hasFlag(tokens []string, flags ...string) bool { + for _, t := range tokens { + for _, f := range flags { + if t == f { + return true + } + } + } + return false +} + +// shellQuote returns s quoted for shell use if it contains special characters. +func shellQuote(s string) string { + if s == "" { + return "''" + } + safe := true + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || + r == '/' || r == '.' || r == '_' || r == '-' || r == ':' || r == '~' || r == '+' || r == '@') { + safe = false + break + } + } + if safe { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/internal/hookcmd/shellparse_test.go b/internal/hookcmd/shellparse_test.go new file mode 100644 index 0000000..3c5c15f --- /dev/null +++ b/internal/hookcmd/shellparse_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 — see LICENSE file for terms. +package hookcmd + +import ( + "reflect" + "testing" +) + +func TestTokenize(t *testing.T) { + cases := []struct { + input string + want []string + }{ + {"cat file.go", []string{"cat", "file.go"}}, + {"head -n 50 file.go", []string{"head", "-n", "50", "file.go"}}, + {`grep "hello world" .`, []string{"grep", "hello world", "."}}, + {`cat 'file with spaces.go'`, []string{"cat", "file with spaces.go"}}, + {`echo foo\ bar`, []string{"echo", "foo bar"}}, + {" head -n 10 file.go ", []string{"head", "-n", "10", "file.go"}}, + {"", nil}, + {"/usr/bin/cat file.go", []string{"/usr/bin/cat", "file.go"}}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := tokenize(tc.input) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("tokenize(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestHasShellOperators(t *testing.T) { + cases := []struct { + input string + want bool + }{ + {"cat file.go", false}, + {"cat file.go | head", true}, + {"cd /tmp && ls", true}, + {"echo hello; echo world", true}, + {"echo 'hello | world'", false}, // pipe inside quotes + {`echo "hello && world"`, false}, // && inside quotes + {"cat file.go &", true}, // backgrounding + {"echo $(date)", true}, // subshell + {"echo `date`", true}, // backtick subshell + {"head -n 50 file.go", false}, + {`grep "a;b" file.go`, false}, // semicolon in quotes + {`grep 'a|b' file.go`, false}, // pipe in quotes + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := hasShellOperators(tc.input) + if got != tc.want { + t.Errorf("hasShellOperators(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestBaseName(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"cat", "cat"}, + {"/usr/bin/cat", "cat"}, + {"/bin/grep", "grep"}, + {"./local/bin/rg", "rg"}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := baseName(tc.input) + if got != tc.want { + t.Errorf("baseName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestShellQuote(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"file.go", "file.go"}, + {"src/main.go", "src/main.go"}, + {"path with spaces", "'path with spaces'"}, + {"it's", "'it'\\''s'"}, + {"", "''"}, + {"*.go", "'*.go'"}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := shellQuote(tc.input) + if got != tc.want { + t.Errorf("shellQuote(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestNonFlags(t *testing.T) { + cases := []struct { + input []string + want []string + }{ + {[]string{"-n", "file.go"}, []string{"file.go"}}, + {[]string{"-la", "src/"}, []string{"src/"}}, + {[]string{"file.go", ">", "out.txt"}, []string{"file.go"}}, + {[]string{"file.go", ">>", "out.txt"}, []string{"file.go"}}, + {[]string{"-l", "-w", "file.go"}, []string{"file.go"}}, + } + for _, tc := range cases { + t.Run("", func(t *testing.T) { + got := nonFlags(tc.input) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("nonFlags(%v) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} diff --git a/internal/hookcmd/suggest.go b/internal/hookcmd/suggest.go new file mode 100644 index 0000000..724c0b0 --- /dev/null +++ b/internal/hookcmd/suggest.go @@ -0,0 +1,492 @@ +// Copyright 2026 — see LICENSE file for terms. +package hookcmd + +import ( + "fmt" + "strconv" + "strings" +) + +// Suggestion represents an aifr command that can replace a shell command. +type Suggestion struct { + // Original is the original shell command (or base command name). + Original string + // AifrCommand is the suggested aifr invocation. + AifrCommand string +} + +// AnalyzeCommand checks if a shell command can be replaced by an aifr command. +// Returns nil if no suggestion applies. +func AnalyzeCommand(command string) *Suggestion { + command = strings.TrimSpace(command) + if command == "" { + return nil + } + + // Already an aifr invocation — nothing to suggest. + if strings.HasPrefix(command, "aifr ") || command == "aifr" { + return nil + } + + // Skip complex commands with pipes, chains, or subshells. + if hasShellOperators(command) { + return nil + } + + tokens := tokenize(command) + if len(tokens) == 0 { + return nil + } + + // Skip environment variable assignments (VAR=value cmd ...). + start := 0 + for start < len(tokens) && strings.Contains(tokens[start], "=") && !strings.HasPrefix(tokens[start], "-") { + start++ + } + if start >= len(tokens) { + return nil + } + tokens = tokens[start:] + + baseCmd := baseName(tokens[0]) + args := tokens[1:] + + switch baseCmd { + case "cat": + return suggestCat(args) + case "head": + return suggestHead(args) + case "tail": + return suggestTail(args) + case "grep", "egrep", "fgrep", "rg": + return suggestSearch(baseCmd, args) + case "find": + return suggestFind(args) + case "ls": + return suggestList(args) + case "wc": + return suggestWc(args) + case "stat": + return suggestStat(args) + case "diff": + return suggestDiff(args) + case "sha256sum", "sha1sum", "md5sum", "shasum", "sha384sum", "sha512sum", "b2sum": + return suggestChecksum(baseCmd, args) + case "hexdump", "xxd", "od": + return suggestHexdump(baseCmd, args) + case "sed": + return suggestSed(args) + case "git": + return suggestGit(args) + default: + return nil + } +} + +func makeSuggestion(original, aifrCmd string) *Suggestion { + return &Suggestion{ + Original: original, + AifrCommand: aifrCmd, + } +} + +func suggestCat(args []string) *Suggestion { + files := nonFlags(args) + if len(files) == 0 { + return nil // reading from stdin + } + if len(files) == 1 { + return makeSuggestion("cat", "aifr read "+shellQuote(files[0])) + } + parts := make([]string, len(files)) + for i, f := range files { + parts[i] = shellQuote(f) + } + return makeSuggestion("cat", "aifr cat "+strings.Join(parts, " ")) +} + +func suggestHead(args []string) *Suggestion { + n := 10 // default for head + var file string + + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "-n" && i+1 < len(args): + if v, err := strconv.Atoi(args[i+1]); err == nil { + n = v + } + i++ + case strings.HasPrefix(a, "-n"): + if v, err := strconv.Atoi(a[2:]); err == nil { + n = v + } + case len(a) > 1 && a[0] == '-' && isDigits(a[1:]): + if v, err := strconv.Atoi(a[1:]); err == nil { + n = v + } + case !strings.HasPrefix(a, "-"): + if file == "" { + file = a + } + } + } + if file == "" { + return nil + } + return makeSuggestion("head", fmt.Sprintf("aifr read --lines=1:%d %s", n, shellQuote(file))) +} + +func suggestTail(args []string) *Suggestion { + if hasFlag(args, "-f", "--follow", "-F") { + return nil // tail -f is a live-follow, aifr can't do that + } + + n := 10 // default for tail + var file string + + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "-n" && i+1 < len(args): + if v, err := strconv.Atoi(args[i+1]); err == nil { + n = v + } + i++ + case strings.HasPrefix(a, "-n"): + if v, err := strconv.Atoi(a[2:]); err == nil { + n = v + } + case len(a) > 1 && a[0] == '-' && isDigits(a[1:]): + if v, err := strconv.Atoi(a[1:]); err == nil { + n = v + } + case !strings.HasPrefix(a, "-"): + if file == "" { + file = a + } + } + } + if file == "" { + return nil + } + return makeSuggestion("tail", fmt.Sprintf("aifr read --lines=-%d: %s", n, shellQuote(file))) +} + +func suggestSearch(baseCmd string, args []string) *Suggestion { + // Extract pattern and path from grep/rg arguments. + // We handle: grep [flags] pattern [path...] + // Skip if reading from stdin (no path and no -r/-R). + var pattern string + var path string + recursive := false + + positional := 0 + for i := 0; i < len(args); i++ { + a := args[i] + if strings.HasPrefix(a, "-") { + // Check for recursive flags + if a == "-r" || a == "-R" || a == "--recursive" { + recursive = true + continue + } + // Flags that take a value argument + if flagTakesValue(a) && i+1 < len(args) { + i++ + } + continue + } + switch positional { + case 0: + pattern = a + case 1: + path = a + } + positional++ + } + + if pattern == "" { + return nil + } + + // If no path and not recursive, it's likely reading stdin. + if path == "" && !recursive { + return nil + } + + cmd := "aifr search " + shellQuote(pattern) + if path != "" { + cmd += " " + shellQuote(path) + } else { + cmd += " ." + } + return makeSuggestion(baseCmd, cmd) +} + +func suggestFind(args []string) *Suggestion { + // find [path] [expressions...] + // Common: find . -name "*.go" -type f + var path string + var name string + var ftype string + + for i := 0; i < len(args); i++ { + a := args[i] + switch a { + case "-name", "-iname": + if i+1 < len(args) { + name = args[i+1] + i++ + } + case "-type": + if i+1 < len(args) { + ftype = args[i+1] + i++ + } + default: + if !strings.HasPrefix(a, "-") && path == "" { + path = a + } + } + } + + if path == "" { + path = "." + } + + cmd := "aifr find " + shellQuote(path) + if name != "" { + cmd += " --name=" + shellQuote(name) + } + if ftype != "" { + cmd += " --type=" + ftype + } + return makeSuggestion("find", cmd) +} + +func suggestList(args []string) *Suggestion { + // ls [flags] [path...] + files := nonFlags(args) + path := "." + if len(files) > 0 { + path = files[0] + } + // If using complex ls flags beyond basic listing, skip. + if hasFlag(args, "-R", "--recursive") { + return makeSuggestion("ls", "aifr find "+shellQuote(path)) + } + return makeSuggestion("ls", "aifr list "+shellQuote(path)) +} + +func suggestWc(args []string) *Suggestion { + files := nonFlags(args) + if len(files) == 0 { + return nil // reading from stdin + } + parts := make([]string, len(files)) + for i, f := range files { + parts[i] = shellQuote(f) + } + cmd := "aifr wc" + if hasFlag(args, "-l") { + cmd += " -l" + } + if hasFlag(args, "-w") { + cmd += " -w" + } + if hasFlag(args, "-c") { + cmd += " -c" + } + if hasFlag(args, "-m") { + cmd += " -m" + } + cmd += " " + strings.Join(parts, " ") + return makeSuggestion("wc", cmd) +} + +func suggestStat(args []string) *Suggestion { + files := nonFlags(args) + if len(files) == 0 { + return nil + } + return makeSuggestion("stat", "aifr stat "+shellQuote(files[0])) +} + +func suggestDiff(args []string) *Suggestion { + files := nonFlags(args) + if len(files) != 2 { + return nil // diff needs exactly 2 paths for aifr + } + return makeSuggestion("diff", "aifr diff "+shellQuote(files[0])+" "+shellQuote(files[1])) +} + +func suggestChecksum(baseCmd string, args []string) *Suggestion { + files := nonFlags(args) + if len(files) == 0 { + return nil + } + + algo := "sha256" + switch baseCmd { + case "sha1sum": + algo = "sha1" + case "sha256sum": + algo = "sha256" + case "sha384sum": + algo = "sha384" + case "sha512sum": + algo = "sha512" + case "md5sum": + algo = "md5" + case "b2sum": + // aifr may not support blake2; skip. + return nil + case "shasum": + // shasum defaults to sha1; check for -a flag. + algo = "sha1" + for i := 0; i < len(args); i++ { + if args[i] == "-a" && i+1 < len(args) { + algo = "sha" + args[i+1] + i++ + } + } + } + + parts := make([]string, len(files)) + for i, f := range files { + parts[i] = shellQuote(f) + } + return makeSuggestion(baseCmd, fmt.Sprintf("aifr checksum -a %s %s", algo, strings.Join(parts, " "))) +} + +func suggestHexdump(_ string, args []string) *Suggestion { + files := nonFlags(args) + if len(files) == 0 { + return nil + } + return makeSuggestion("hexdump", "aifr hexdump "+shellQuote(files[0])) +} + +func suggestSed(args []string) *Suggestion { + // Only handle the common read-only pattern: sed -n 'NP' or sed -n 'N,Mp' file + if !hasFlag(args, "-n") { + return nil // without -n, sed may be doing transformations + } + + var script string + var file string + sawN := false + + for i := 0; i < len(args); i++ { + a := args[i] + if a == "-n" { + sawN = true + continue + } + if strings.HasPrefix(a, "-") { + continue + } + if !sawN { + continue + } + if script == "" { + script = a + } else if file == "" { + file = a + } + } + + if script == "" || file == "" { + return nil + } + + // Parse patterns like "5p", "10,20p", "5,10p" + script = strings.TrimSuffix(script, "p") + if script == "" { + return nil + } + + parts := strings.SplitN(script, ",", 2) + if len(parts) == 1 { + // Single line: sed -n '5p' + if _, err := strconv.Atoi(parts[0]); err != nil { + return nil + } + return makeSuggestion("sed", + fmt.Sprintf("aifr read --lines=%s:%s %s", parts[0], parts[0], shellQuote(file))) + } + + // Range: sed -n '5,10p' + if _, err := strconv.Atoi(parts[0]); err != nil { + return nil + } + if _, err := strconv.Atoi(parts[1]); err != nil { + return nil + } + return makeSuggestion("sed", + fmt.Sprintf("aifr read --lines=%s:%s %s", parts[0], parts[1], shellQuote(file))) +} + +func suggestGit(args []string) *Suggestion { + if len(args) == 0 { + return nil + } + switch args[0] { + case "log": + return makeSuggestion("git log", "aifr log") + case "diff": + return suggestGitDiff(args[1:]) + default: + return nil + } +} + +func suggestGitDiff(args []string) *Suggestion { + // git diff [ref] [-- file...] + // Only handle simple cases. + refs := nonFlags(args) + // Filter out "--" separator + var clean []string + for _, r := range refs { + if r != "--" { + clean = append(clean, r) + } + } + if len(clean) == 2 { + return makeSuggestion("git diff", + fmt.Sprintf("aifr diff %s:%s %s:%s", clean[0], ".", clean[1], ".")) + } + return makeSuggestion("git diff", "aifr diff") +} + +// isDigits reports whether s is non-empty and contains only ASCII digits. +func isDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// flagTakesValue reports whether a grep/rg flag expects a following argument. +func flagTakesValue(flag string) bool { + switch flag { + case "-e", "-f", "--regexp", "--file", + "-m", "--max-count", + "-A", "--after-context", + "-B", "--before-context", + "-C", "--context", + "--include", "--exclude", "--exclude-dir", + "--color", "--colour", + "-d", "--directories", + "-D", "--devices", + "--label", + "--binary-files": + return true + } + return false +} diff --git a/internal/hookcmd/suggest_test.go b/internal/hookcmd/suggest_test.go new file mode 100644 index 0000000..d166df4 --- /dev/null +++ b/internal/hookcmd/suggest_test.go @@ -0,0 +1,332 @@ +// Copyright 2026 — see LICENSE file for terms. +package hookcmd + +import "testing" + +func TestAnalyzeCommand_NoSuggestion(t *testing.T) { + cases := []struct { + name string + command string + }{ + {"empty", ""}, + {"aifr invocation", "aifr read file.go"}, + {"aifr bare", "aifr"}, + {"unrecognized command", "go build ./..."}, + {"make", "make test"}, + {"npm", "npm install"}, + {"pipe chain", "cat file.go | head -10"}, + {"double ampersand", "cd /tmp && ls"}, + {"semicolon", "echo hello; echo world"}, + {"subshell", "$(cat file.go)"}, + {"cat from stdin", "cat"}, + {"cat from stdin with flags", "cat -v"}, + {"head from stdin", "head -n 5"}, + {"tail -f", "tail -f server.log"}, + {"tail --follow", "tail --follow server.log"}, + {"grep from stdin", "grep pattern"}, + {"wc from stdin", "wc -l"}, + {"stat no args", "stat"}, + {"sed without -n", "sed 's/foo/bar/' file.go"}, + {"git status", "git status"}, + {"git push", "git push origin main"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s != nil { + t.Errorf("expected nil, got suggestion: %s", s.AifrCommand) + } + }) + } +} + +func TestAnalyzeCommand_Cat(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"cat file.go", "aifr read file.go"}, + {"cat src/main.go", "aifr read src/main.go"}, + {"/usr/bin/cat file.go", "aifr read file.go"}, + {"cat file1.go file2.go", "aifr cat file1.go file2.go"}, + {"cat -n file.go", "aifr read file.go"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + if s.Original != "cat" { + t.Errorf("original: got %q, want %q", s.Original, "cat") + } + }) + } +} + +func TestAnalyzeCommand_Head(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"head file.go", "aifr read --lines=1:10 file.go"}, + {"head -n 50 file.go", "aifr read --lines=1:50 file.go"}, + {"head -n50 file.go", "aifr read --lines=1:50 file.go"}, + {"head -20 file.go", "aifr read --lines=1:20 file.go"}, + {"head -n 100 src/main.go", "aifr read --lines=1:100 src/main.go"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Tail(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"tail file.go", "aifr read --lines=-10: file.go"}, + {"tail -n 20 file.go", "aifr read --lines=-20: file.go"}, + {"tail -n20 file.go", "aifr read --lines=-20: file.go"}, + {"tail -5 file.go", "aifr read --lines=-5: file.go"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Grep(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"grep TODO src/", "aifr search TODO src/"}, + {"grep -r pattern .", "aifr search pattern ."}, + {"grep -rn 'func main' .", "aifr search 'func main' ."}, + {"rg pattern src/", "aifr search pattern src/"}, + {"egrep 'foo|bar' dir/", "aifr search 'foo|bar' dir/"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Find(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"find .", "aifr find ."}, + {"find . -name '*.go'", "aifr find . --name='*.go'"}, + {"find . -name '*.go' -type f", "aifr find . --name='*.go' --type=f"}, + {"find src/ -type d", "aifr find src/ --type=d"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Ls(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"ls", "aifr list ."}, + {"ls src/", "aifr list src/"}, + {"ls -la src/", "aifr list src/"}, + {"ls -R src/", "aifr find src/"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Wc(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"wc file.go", "aifr wc file.go"}, + {"wc -l file.go", "aifr wc -l file.go"}, + {"wc -l -w file.go", "aifr wc -l -w file.go"}, + {"wc file1.go file2.go", "aifr wc file1.go file2.go"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Stat(t *testing.T) { + s := AnalyzeCommand("stat file.go") + if s == nil { + t.Fatal("expected suggestion") + } + if s.AifrCommand != "aifr stat file.go" { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_Diff(t *testing.T) { + s := AnalyzeCommand("diff file1.go file2.go") + if s == nil { + t.Fatal("expected suggestion") + } + if s.AifrCommand != "aifr diff file1.go file2.go" { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_Checksum(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"sha256sum file.go", "aifr checksum -a sha256 file.go"}, + {"md5sum file.go", "aifr checksum -a md5 file.go"}, + {"sha1sum file.go", "aifr checksum -a sha1 file.go"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Hexdump(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"hexdump file.bin", "aifr hexdump file.bin"}, + {"xxd file.bin", "aifr hexdump file.bin"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_Sed(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"sed -n '5p' file.go", "aifr read --lines=5:5 file.go"}, + {"sed -n '10,20p' file.go", "aifr read --lines=10:20 file.go"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + }) + } +} + +func TestAnalyzeCommand_GitLog(t *testing.T) { + s := AnalyzeCommand("git log") + if s == nil { + t.Fatal("expected suggestion") + } + if s.AifrCommand != "aifr log" { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_EnvPrefix(t *testing.T) { + s := AnalyzeCommand("LANG=C cat file.go") + if s == nil { + t.Fatal("expected suggestion") + } + if s.AifrCommand != "aifr read file.go" { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_QuotedPaths(t *testing.T) { + s := AnalyzeCommand(`cat "path with spaces/file.go"`) + if s == nil { + t.Fatal("expected suggestion") + } + if s.AifrCommand != "aifr read 'path with spaces/file.go'" { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_AbsoluteCommandPath(t *testing.T) { + s := AnalyzeCommand("/usr/bin/head -n 5 file.go") + if s == nil { + t.Fatal("expected suggestion") + } + if s.AifrCommand != "aifr read --lines=1:5 file.go" { + t.Errorf("got %q", s.AifrCommand) + } +} From b3483253c53aa35dc78fe8db98352d7474367477 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 02:01:16 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20clarify=20help=20text=20=E2=80=94=20?= =?UTF-8?q?no=20output=20means=20normal=20permission=20evaluation,=20not?= =?UTF-8?q?=20approval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://claude.ai/code/session_017inmawi6PUgMy9zSu6EXKv --- cmd/aifr/cmd_hook_checkcommand.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/aifr/cmd_hook_checkcommand.go b/cmd/aifr/cmd_hook_checkcommand.go index b0b9389..82d3532 100644 --- a/cmd/aifr/cmd_hook_checkcommand.go +++ b/cmd/aifr/cmd_hook_checkcommand.go @@ -19,7 +19,7 @@ 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 proceeds normally. +no output) so the Bash call continues through normal permission evaluation. Recognized commands: cat, head, tail, grep/rg, find, ls, wc, stat, diff, sed -n, sha256sum/md5sum, hexdump/xxd, git log, git diff. From cd49901778aa16c60b9adb508400b62d00ed6ec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 02:12:40 +0000 Subject: [PATCH 3/7] feat: add end-to-end pipeline passthrough test for check-command Verifies that a piped command (git log --oneline | head -n 10) passes through CheckCommand without generating a suggestion, confirming the full wiring from JSON input through shell operator detection. https://claude.ai/code/session_017inmawi6PUgMy9zSu6EXKv --- go.mod | 26 +++++++++++++++++ go.sum | 48 ++++++++++++++++++++++++++++++++ internal/hookcmd/hookcmd_test.go | 22 +++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/go.mod b/go.mod index 192705f..6797d6a 100644 --- a/go.mod +++ b/go.mod @@ -13,31 +13,57 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-github/v80 v80.0.0 // indirect + github.com/google/go-github/v82 v82.0.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lmittmann/tint v1.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2 // indirect + github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 // indirect + github.com/suzuki-shunsuke/go-exec v0.0.1 // indirect + github.com/suzuki-shunsuke/pinact/v3 v3.9.0 // indirect + github.com/suzuki-shunsuke/slog-error v0.2.2 // indirect + github.com/suzuki-shunsuke/slog-util v0.3.1 // indirect + github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 // indirect + github.com/urfave/cli/v3 v3.6.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.42.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b53785e..30b1655 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -11,11 +13,15 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,6 +29,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -35,14 +43,27 @@ 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/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= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v80 v80.0.0 h1:BTyk3QOHekrk5VF+jIGz1TNEsmeoQG9K/UWaaP+EWQs= +github.com/google/go-github/v80 v80.0.0/go.mod h1:pRo4AIMdHW83HNMGfNysgSAv0vmu+/pkY8nZO9FT9Yo= +github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= +github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -58,6 +79,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -82,6 +109,8 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -92,10 +121,28 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2 h1:rgGrzb4VDfGSFCXecxKbzJ2PxcJyplfKIu8wkyWGZ1U= +github.com/suzuki-shunsuke/ghtkn-go-sdk v0.2.2/go.mod h1:RqXFhArJSKR/D+42ptl9pQFQ5ikIexxB7AxiFB1gOOo= +github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 h1:oVXrrYNGBq4POyITQNWKzwsYz7B2nUcqtDbeX4BfeEc= +github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0/go.mod h1:kDFtLeftDiIUUHXGI3xq5eJ+uAOi50FPrxPENTHktJ0= +github.com/suzuki-shunsuke/go-exec v0.0.1 h1:xn/lvYnRQOujUd46ph6f6IT0gVJIC8+3liSZKOjNj44= +github.com/suzuki-shunsuke/go-exec v0.0.1/go.mod h1:KstSwIiQTKY34wEurUcFyKkaJDogBr5E3xxfdkkzvb0= +github.com/suzuki-shunsuke/pinact/v3 v3.9.0 h1:9joYfYEfGSmQepuYZhl3akEgSr12MvU4O8JJR4WTMOw= +github.com/suzuki-shunsuke/pinact/v3 v3.9.0/go.mod h1:v83U2W/fTfVB0kygS2jqoT7hBFY4OAK9TaAwe12QHbA= +github.com/suzuki-shunsuke/slog-error v0.2.2 h1:z8rymlIlZcMA+ERnnhVigQ0Q+X0pxKqBfDzSIyGh6vU= +github.com/suzuki-shunsuke/slog-error v0.2.2/go.mod h1:w45QyO2G0uiEuo9hhrcLqqRl3hmYon9jGgq9CrCxxOY= +github.com/suzuki-shunsuke/slog-util v0.3.1 h1:tp4xgj4y/T2YcZkHtr7N2E49f5CiHl9o47/HKdwRQ4g= +github.com/suzuki-shunsuke/slog-util v0.3.1/go.mod h1:PgZMd+2rC8pA9jBbXDfkI8mTuWYAiaVkKxjrbLtfN5I= +github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 h1:ORT/qQxsKuWwuy2N/z2f2hmbKWmlS346/j4jGhxsxLo= +github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0/go.mod h1:BYtzUgA4oeUVUFoJIONWOquvIUy0cl7DpAeCya3mVJU= +github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= +github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= @@ -113,6 +160,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/internal/hookcmd/hookcmd_test.go b/internal/hookcmd/hookcmd_test.go index 9933cac..7810ac2 100644 --- a/internal/hookcmd/hookcmd_test.go +++ b/internal/hookcmd/hookcmd_test.go @@ -73,6 +73,28 @@ func TestCheckCommand_InvalidJSON(t *testing.T) { } } +// TestCheckCommand_PipelinePassthrough is an end-to-end wiring test verifying +// that a command pipeline (containing shell operators) passes through +// CheckCommand without a suggestion. The full scope of complex pipeline +// detection is covered in suggest_test.go via AnalyzeCommand tests. +func TestCheckCommand_PipelinePassthrough(t *testing.T) { + input := `{ + "session_id": "test-session", + "tool_name": "Bash", + "tool_input": {"command": "git log --oneline | head -n 10"}, + "hook_event_name": "PreToolUse" + }` + + result, err := CheckCommand([]byte(input)) + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Errorf("expected nil for pipeline command, got result with decision %q", + result.HookSpecificOutput.Decision) + } +} + func TestCheckCommand_OutputFormat(t *testing.T) { input := `{ "session_id": "s1", From a32e6d81a4ae9b240b05300576981be91ce73642 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 02:31:49 +0000 Subject: [PATCH 4/7] feat: handle pipelines with | head/tail and add MCP-aware suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/aifr/cmd_hook_checkcommand.go | 12 +- internal/hookcmd/hookcmd.go | 38 ++- internal/hookcmd/hookcmd_test.go | 81 +++++- internal/hookcmd/mcpdetect.go | 52 ++++ internal/hookcmd/shellparse.go | 53 ++++ internal/hookcmd/shellparse_test.go | 23 ++ internal/hookcmd/suggest.go | 421 ++++++++++++++++++++++------ internal/hookcmd/suggest_test.go | 170 ++++++++++- 8 files changed, 737 insertions(+), 113 deletions(-) create mode 100644 internal/hookcmd/mcpdetect.go diff --git a/cmd/aifr/cmd_hook_checkcommand.go b/cmd/aifr/cmd_hook_checkcommand.go index 82d3532..d42c2a5 100644 --- a/cmd/aifr/cmd_hook_checkcommand.go +++ b/cmd/aifr/cmd_hook_checkcommand.go @@ -11,6 +11,8 @@ import ( "go.pennock.tech/aifr/internal/hookcmd" ) +var checkCommandMCP bool + var checkCommandCmd = &cobra.Command{ Use: "check-command", Short: "Suggest aifr alternatives for Bash tool calls", @@ -21,6 +23,12 @@ 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. @@ -48,7 +56,7 @@ Usage in Claude Code settings: return err } - result, err := hookcmd.CheckCommand(input) + result, err := hookcmd.CheckCommand(input, checkCommandMCP) if err != nil { return err } @@ -63,5 +71,7 @@ Usage in Claude Code settings: } 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) } diff --git a/internal/hookcmd/hookcmd.go b/internal/hookcmd/hookcmd.go index 9f33ffc..b185964 100644 --- a/internal/hookcmd/hookcmd.go +++ b/internal/hookcmd/hookcmd.go @@ -1,11 +1,15 @@ // Copyright 2026 — see LICENSE file for terms. package hookcmd -import "encoding/json" +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"` @@ -30,7 +34,11 @@ type HookDecision struct { // CheckCommand parses a PreToolUse hook payload and returns a hook output // denying the command with an aifr suggestion, or nil if no suggestion applies. -func CheckCommand(input []byte) (*HookOutput, error) { +// +// 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 @@ -50,13 +58,33 @@ func CheckCommand(input []byte) (*HookOutput, error) { 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: "This " + suggestion.Original + - " invocation can be handled by aifr with access controls. Use: " + - suggestion.AifrCommand, + 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)) +} diff --git a/internal/hookcmd/hookcmd_test.go b/internal/hookcmd/hookcmd_test.go index 7810ac2..63c08c0 100644 --- a/internal/hookcmd/hookcmd_test.go +++ b/internal/hookcmd/hookcmd_test.go @@ -9,12 +9,13 @@ import ( func TestCheckCommand_BashWithSuggestion(t *testing.T) { input := `{ "session_id": "test-session", + "cwd": "/tmp/nonexistent", "tool_name": "Bash", "tool_input": {"command": "cat main.go"}, "hook_event_name": "PreToolUse" }` - result, err := CheckCommand([]byte(input)) + result, err := CheckCommand([]byte(input), false) if err != nil { t.Fatal(err) } @@ -35,12 +36,13 @@ func TestCheckCommand_BashWithSuggestion(t *testing.T) { func TestCheckCommand_BashNoSuggestion(t *testing.T) { input := `{ "session_id": "test-session", + "cwd": "/tmp/nonexistent", "tool_name": "Bash", "tool_input": {"command": "go test ./..."}, "hook_event_name": "PreToolUse" }` - result, err := CheckCommand([]byte(input)) + result, err := CheckCommand([]byte(input), false) if err != nil { t.Fatal(err) } @@ -52,12 +54,13 @@ func TestCheckCommand_BashNoSuggestion(t *testing.T) { func TestCheckCommand_NonBashTool(t *testing.T) { input := `{ "session_id": "test-session", + "cwd": "/tmp/nonexistent", "tool_name": "Read", "tool_input": {"file_path": "/tmp/test.go"}, "hook_event_name": "PreToolUse" }` - result, err := CheckCommand([]byte(input)) + result, err := CheckCommand([]byte(input), false) if err != nil { t.Fatal(err) } @@ -67,43 +70,52 @@ func TestCheckCommand_NonBashTool(t *testing.T) { } func TestCheckCommand_InvalidJSON(t *testing.T) { - _, err := CheckCommand([]byte("not json")) + _, err := CheckCommand([]byte("not json"), false) if err == nil { t.Error("expected error for invalid JSON") } } -// TestCheckCommand_PipelinePassthrough is an end-to-end wiring test verifying -// that a command pipeline (containing shell operators) passes through -// CheckCommand without a suggestion. The full scope of complex pipeline -// detection is covered in suggest_test.go via AnalyzeCommand tests. -func TestCheckCommand_PipelinePassthrough(t *testing.T) { +// TestCheckCommand_PipelineSuggestion is an end-to-end wiring test verifying +// that a command pipeline with a recognized | head tail produces a suggestion +// with the appropriate per-command limit parameter. The full scope of pipeline +// and complex command analysis is covered in suggest_test.go. +func TestCheckCommand_PipelineSuggestion(t *testing.T) { input := `{ "session_id": "test-session", + "cwd": "/tmp/nonexistent", "tool_name": "Bash", "tool_input": {"command": "git log --oneline | head -n 10"}, "hook_event_name": "PreToolUse" }` - result, err := CheckCommand([]byte(input)) + result, err := CheckCommand([]byte(input), false) if err != nil { t.Fatal(err) } - if result != nil { - t.Errorf("expected nil for pipeline command, got result with decision %q", - result.HookSpecificOutput.Decision) + if result == nil { + t.Fatal("expected suggestion for pipeline command, got nil") + } + if result.HookSpecificOutput.Decision != "deny" { + t.Errorf("expected deny, got %q", result.HookSpecificOutput.Decision) + } + // Verify the reason mentions the aifr log command with --max-count. + reason := result.HookSpecificOutput.Reason + if reason == "" { + t.Error("expected non-empty reason") } } func TestCheckCommand_OutputFormat(t *testing.T) { input := `{ "session_id": "s1", + "cwd": "/tmp/nonexistent", "tool_name": "Bash", "tool_input": {"command": "head -50 README.md"}, "hook_event_name": "PreToolUse" }` - result, err := CheckCommand([]byte(input)) + result, err := CheckCommand([]byte(input), false) if err != nil { t.Fatal(err) } @@ -137,3 +149,44 @@ func TestCheckCommand_OutputFormat(t *testing.T) { t.Error("expected non-empty reason") } } + +func TestCheckCommand_MCPMode(t *testing.T) { + input := `{ + "session_id": "test-session", + "cwd": "/tmp/nonexistent", + "tool_name": "Bash", + "tool_input": {"command": "cat main.go"}, + "hook_event_name": "PreToolUse" + }` + + // CLI mode (forceMCP=false, no .mcp.json in /tmp/nonexistent) + cliResult, err := CheckCommand([]byte(input), false) + if err != nil { + t.Fatal(err) + } + if cliResult == nil { + t.Fatal("expected result") + } + cliReason := cliResult.HookSpecificOutput.Reason + if cliReason == "" { + t.Fatal("expected non-empty CLI reason") + } + + // MCP mode (forceMCP=true) + mcpResult, err := CheckCommand([]byte(input), true) + if err != nil { + t.Fatal(err) + } + if mcpResult == nil { + t.Fatal("expected result") + } + mcpReason := mcpResult.HookSpecificOutput.Reason + if mcpReason == "" { + t.Fatal("expected non-empty MCP reason") + } + + // CLI reason should reference the CLI command. + if cliReason == mcpReason { + t.Error("CLI and MCP reasons should differ") + } +} diff --git a/internal/hookcmd/mcpdetect.go b/internal/hookcmd/mcpdetect.go new file mode 100644 index 0000000..6462c06 --- /dev/null +++ b/internal/hookcmd/mcpdetect.go @@ -0,0 +1,52 @@ +// Copyright 2026 — see LICENSE file for terms. +package hookcmd + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// detectMCPAvailable checks whether an aifr MCP server is likely available +// in the current Claude Code session. +// +// Detection order: +// 1. AIFR_MCP environment variable (any non-empty value → true) +// 2. .mcp.json in the given working directory +func detectMCPAvailable(cwd string) bool { + if os.Getenv("AIFR_MCP") != "" { + return true + } + if cwd != "" { + if checkMCPConfig(filepath.Join(cwd, ".mcp.json")) { + return true + } + } + return false +} + +// checkMCPConfig reads a .mcp.json file and returns true if it contains +// an aifr MCP server entry (matched by server name or command basename). +func checkMCPConfig(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + var config struct { + MCPServers map[string]struct { + Command string `json:"command"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(data, &config); err != nil { + return false + } + for name, server := range config.MCPServers { + if name == "aifr" { + return true + } + if filepath.Base(server.Command) == "aifr" { + return true + } + } + return false +} diff --git a/internal/hookcmd/shellparse.go b/internal/hookcmd/shellparse.go index 3aa3006..d24de1f 100644 --- a/internal/hookcmd/shellparse.go +++ b/internal/hookcmd/shellparse.go @@ -101,6 +101,59 @@ func hasShellOperators(s string) bool { return false } +// splitPipeline splits a command by unquoted pipe operators. +// It recognizes || as a logical OR (kept in the stage, not split) and only +// splits on single | pipe operators. +func splitPipeline(s string) []string { + var stages []string + var cur strings.Builder + inSingle := false + inDouble := false + escaped := false + runes := []rune(s) + + for i := 0; i < len(runes); i++ { + r := runes[i] + if escaped { + cur.WriteRune(r) + escaped = false + continue + } + if r == '\\' && !inSingle { + cur.WriteRune(r) + escaped = true + continue + } + if r == '\'' && !inDouble { + inSingle = !inSingle + cur.WriteRune(r) + continue + } + if r == '"' && !inSingle { + inDouble = !inDouble + cur.WriteRune(r) + continue + } + if r == '|' && !inSingle && !inDouble { + // || is logical OR, not a pipe — keep in current stage. + if i+1 < len(runes) && runes[i+1] == '|' { + cur.WriteRune(r) + cur.WriteRune(runes[i+1]) + i++ + continue + } + stages = append(stages, cur.String()) + cur.Reset() + continue + } + cur.WriteRune(r) + } + if cur.Len() > 0 { + stages = append(stages, cur.String()) + } + return stages +} + // baseName returns the last path component of cmd (strips directory prefix). func baseName(cmd string) string { if idx := strings.LastIndex(cmd, "/"); idx >= 0 { diff --git a/internal/hookcmd/shellparse_test.go b/internal/hookcmd/shellparse_test.go index 3c5c15f..7f922b9 100644 --- a/internal/hookcmd/shellparse_test.go +++ b/internal/hookcmd/shellparse_test.go @@ -100,6 +100,29 @@ func TestShellQuote(t *testing.T) { } } +func TestSplitPipeline(t *testing.T) { + cases := []struct { + input string + want []string + }{ + {"cat file.go", []string{"cat file.go"}}, + {"cat file.go | head -5", []string{"cat file.go ", " head -5"}}, + {"a | b | c", []string{"a ", " b ", " c"}}, + {"grep 'a|b' file", []string{"grep 'a|b' file"}}, // | inside quotes + {`grep "a|b" file`, []string{`grep "a|b" file`}}, // | inside double quotes + {"cmd1 || cmd2", []string{"cmd1 || cmd2"}}, // || is not a pipe + {"cat file | head | tail", []string{"cat file ", " head ", " tail"}}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := splitPipeline(tc.input) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("splitPipeline(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + func TestNonFlags(t *testing.T) { cases := []struct { input []string diff --git a/internal/hookcmd/suggest.go b/internal/hookcmd/suggest.go index 724c0b0..09c6754 100644 --- a/internal/hookcmd/suggest.go +++ b/internal/hookcmd/suggest.go @@ -11,8 +11,23 @@ import ( type Suggestion struct { // Original is the original shell command (or base command name). Original string - // AifrCommand is the suggested aifr invocation. + // AifrCommand is the suggested aifr CLI invocation. AifrCommand string + // ToolName is the MCP tool name (e.g., "aifr_read"). + ToolName string + // ToolArgs are the MCP tool parameters. + ToolArgs map[string]any +} + +// PipelineModifier captures a trailing | head or | tail in a pipeline. +type PipelineModifier struct { + HeadLines int // > 0 if piped to head -n N + TailLines int // > 0 if piped to tail -n N +} + +// IsSet reports whether any pipeline modifier is active. +func (m PipelineModifier) IsSet() bool { + return m.HeadLines > 0 || m.TailLines > 0 } // AnalyzeCommand checks if a shell command can be replaced by an aifr command. @@ -28,12 +43,32 @@ func AnalyzeCommand(command string) *Suggestion { return nil } - // Skip complex commands with pipes, chains, or subshells. - if hasShellOperators(command) { + // Split pipeline and check for trailing head/tail. + stages := splitPipeline(command) + var mod PipelineModifier + var baseStage string + + switch len(stages) { + case 1: + baseStage = stages[0] + case 2: + mod = parsePipeTail(stages[1]) + if !mod.IsSet() { + return nil // unknown pipe target + } + baseStage = stages[0] + default: + return nil // 3+ stage pipelines are too complex + } + + baseStage = strings.TrimSpace(baseStage) + + // Check for non-pipe shell operators in the base stage. + if hasShellOperators(baseStage) { return nil } - tokens := tokenize(command) + tokens := tokenize(baseStage) if len(tokens) == 0 { return nil } @@ -53,62 +88,144 @@ func AnalyzeCommand(command string) *Suggestion { switch baseCmd { case "cat": - return suggestCat(args) + return suggestCat(args, mod) case "head": - return suggestHead(args) + return suggestHead(args, mod) case "tail": - return suggestTail(args) + return suggestTail(args, mod) case "grep", "egrep", "fgrep", "rg": - return suggestSearch(baseCmd, args) + return suggestSearch(baseCmd, args, mod) case "find": - return suggestFind(args) + return suggestFind(args, mod) case "ls": - return suggestList(args) + return suggestList(args, mod) case "wc": - return suggestWc(args) + return suggestWc(args, mod) case "stat": - return suggestStat(args) + return suggestStat(args, mod) case "diff": - return suggestDiff(args) + return suggestDiff(args, mod) case "sha256sum", "sha1sum", "md5sum", "shasum", "sha384sum", "sha512sum", "b2sum": - return suggestChecksum(baseCmd, args) + return suggestChecksum(baseCmd, args, mod) case "hexdump", "xxd", "od": - return suggestHexdump(baseCmd, args) + return suggestHexdump(baseCmd, args, mod) case "sed": - return suggestSed(args) + return suggestSed(args, mod) case "git": - return suggestGit(args) + return suggestGit(args, mod) default: return nil } } -func makeSuggestion(original, aifrCmd string) *Suggestion { +// parsePipeTail parses the tail of a pipeline (the part after |) to detect +// head -n N or tail -n N patterns. +func parsePipeTail(stage string) PipelineModifier { + stage = strings.TrimSpace(stage) + tokens := tokenize(stage) + if len(tokens) == 0 { + return PipelineModifier{} + } + cmd := baseName(tokens[0]) + args := tokens[1:] + + switch cmd { + case "head": + return PipelineModifier{HeadLines: parseHeadTailN(args, 10)} + case "tail": + if hasFlag(args, "-f", "--follow", "-F") { + return PipelineModifier{} + } + return PipelineModifier{TailLines: parseHeadTailN(args, 10)} + default: + return PipelineModifier{} + } +} + +// parseHeadTailN extracts the line count from head/tail arguments. +// Handles -n N, -nN, and -N forms. Returns defaultN if not found. +func parseHeadTailN(args []string, defaultN int) int { + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "-n" && i+1 < len(args): + if v, err := strconv.Atoi(args[i+1]); err == nil { + return v + } + i++ + case strings.HasPrefix(a, "-n"): + if v, err := strconv.Atoi(a[2:]); err == nil { + return v + } + case len(a) > 1 && a[0] == '-' && isDigits(a[1:]): + if v, err := strconv.Atoi(a[1:]); err == nil { + return v + } + } + } + return defaultN +} + +func makeSuggestion(original, aifrCmd, toolName string, toolArgs map[string]any) *Suggestion { return &Suggestion{ Original: original, AifrCommand: aifrCmd, + ToolName: toolName, + ToolArgs: toolArgs, } } -func suggestCat(args []string) *Suggestion { +func suggestCat(args []string, mod PipelineModifier) *Suggestion { files := nonFlags(args) if len(files) == 0 { return nil // reading from stdin } + + if mod.HeadLines > 0 { + if len(files) != 1 { + return nil // multi-file cat with head doesn't map cleanly + } + lines := fmt.Sprintf("1:%d", mod.HeadLines) + return makeSuggestion("cat", + fmt.Sprintf("aifr read --lines=%s %s", lines, shellQuote(files[0])), + "aifr_read", + map[string]any{"path": files[0], "lines": lines}) + } + + if mod.TailLines > 0 { + if len(files) != 1 { + return nil + } + lines := fmt.Sprintf("-%d:", mod.TailLines) + return makeSuggestion("cat", + fmt.Sprintf("aifr read --lines=%s %s", lines, shellQuote(files[0])), + "aifr_read", + map[string]any{"path": files[0], "lines": lines}) + } + if len(files) == 1 { - return makeSuggestion("cat", "aifr read "+shellQuote(files[0])) + return makeSuggestion("cat", + "aifr read "+shellQuote(files[0]), + "aifr_read", + map[string]any{"path": files[0]}) } parts := make([]string, len(files)) for i, f := range files { parts[i] = shellQuote(f) } - return makeSuggestion("cat", "aifr cat "+strings.Join(parts, " ")) + return makeSuggestion("cat", + "aifr cat "+strings.Join(parts, " "), + "aifr_cat", + map[string]any{"paths": files}) } -func suggestHead(args []string) *Suggestion { - n := 10 // default for head - var file string +func suggestHead(args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil // head | head or head | tail is unusual + } + n := 10 + var file string for i := 0; i < len(args); i++ { a := args[i] switch { @@ -134,17 +251,23 @@ func suggestHead(args []string) *Suggestion { if file == "" { return nil } - return makeSuggestion("head", fmt.Sprintf("aifr read --lines=1:%d %s", n, shellQuote(file))) + lines := fmt.Sprintf("1:%d", n) + return makeSuggestion("head", + fmt.Sprintf("aifr read --lines=%s %s", lines, shellQuote(file)), + "aifr_read", + map[string]any{"path": file, "lines": lines}) } -func suggestTail(args []string) *Suggestion { +func suggestTail(args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil // tail | head or tail | tail is unusual + } if hasFlag(args, "-f", "--follow", "-F") { return nil // tail -f is a live-follow, aifr can't do that } - n := 10 // default for tail + n := 10 var file string - for i := 0; i < len(args); i++ { a := args[i] switch { @@ -170,13 +293,18 @@ func suggestTail(args []string) *Suggestion { if file == "" { return nil } - return makeSuggestion("tail", fmt.Sprintf("aifr read --lines=-%d: %s", n, shellQuote(file))) + lines := fmt.Sprintf("-%d:", n) + return makeSuggestion("tail", + fmt.Sprintf("aifr read --lines=%s %s", lines, shellQuote(file)), + "aifr_read", + map[string]any{"path": file, "lines": lines}) } -func suggestSearch(baseCmd string, args []string) *Suggestion { - // Extract pattern and path from grep/rg arguments. - // We handle: grep [flags] pattern [path...] - // Skip if reading from stdin (no path and no -r/-R). +func suggestSearch(baseCmd string, args []string, mod PipelineModifier) *Suggestion { + if mod.TailLines > 0 { + return nil + } + var pattern string var path string recursive := false @@ -185,12 +313,10 @@ func suggestSearch(baseCmd string, args []string) *Suggestion { for i := 0; i < len(args); i++ { a := args[i] if strings.HasPrefix(a, "-") { - // Check for recursive flags if a == "-r" || a == "-R" || a == "--recursive" { recursive = true continue } - // Flags that take a value argument if flagTakesValue(a) && i+1 < len(args) { i++ } @@ -208,24 +334,35 @@ func suggestSearch(baseCmd string, args []string) *Suggestion { if pattern == "" { return nil } - - // If no path and not recursive, it's likely reading stdin. if path == "" && !recursive { - return nil + return nil // likely reading stdin + } + + effectivePath := path + if effectivePath == "" { + effectivePath = "." } - cmd := "aifr search " + shellQuote(pattern) - if path != "" { - cmd += " " + shellQuote(path) - } else { - cmd += " ." + toolArgs := map[string]any{ + "pattern": pattern, + "path": effectivePath, } - return makeSuggestion(baseCmd, cmd) + + cmd := "aifr search" + if mod.HeadLines > 0 { + cmd += fmt.Sprintf(" --max-matches=%d", mod.HeadLines) + toolArgs["max_matches"] = mod.HeadLines + } + cmd += " " + shellQuote(pattern) + " " + shellQuote(effectivePath) + + return makeSuggestion(baseCmd, cmd, "aifr_search", toolArgs) } -func suggestFind(args []string) *Suggestion { - // find [path] [expressions...] - // Common: find . -name "*.go" -type f +func suggestFind(args []string, mod PipelineModifier) *Suggestion { + if mod.TailLines > 0 { + return nil + } + var path string var name string var ftype string @@ -254,35 +391,67 @@ func suggestFind(args []string) *Suggestion { path = "." } + toolArgs := map[string]any{"path": path} cmd := "aifr find " + shellQuote(path) + if name != "" { cmd += " --name=" + shellQuote(name) + toolArgs["name"] = name } if ftype != "" { cmd += " --type=" + ftype + toolArgs["type"] = ftype + } + if mod.HeadLines > 0 { + cmd += fmt.Sprintf(" --limit=%d", mod.HeadLines) + toolArgs["limit"] = mod.HeadLines } - return makeSuggestion("find", cmd) + + return makeSuggestion("find", cmd, "aifr_find", toolArgs) } -func suggestList(args []string) *Suggestion { - // ls [flags] [path...] +func suggestList(args []string, mod PipelineModifier) *Suggestion { + if mod.TailLines > 0 { + return nil + } + files := nonFlags(args) path := "." if len(files) > 0 { path = files[0] } - // If using complex ls flags beyond basic listing, skip. + if hasFlag(args, "-R", "--recursive") { - return makeSuggestion("ls", "aifr find "+shellQuote(path)) + toolArgs := map[string]any{"path": path} + cmd := "aifr find " + shellQuote(path) + if mod.HeadLines > 0 { + cmd += fmt.Sprintf(" --limit=%d", mod.HeadLines) + toolArgs["limit"] = mod.HeadLines + } + return makeSuggestion("ls", cmd, "aifr_find", toolArgs) } - return makeSuggestion("ls", "aifr list "+shellQuote(path)) + + toolArgs := map[string]any{"path": path} + cmd := "aifr list " + shellQuote(path) + if mod.HeadLines > 0 { + cmd += fmt.Sprintf(" --limit=%d", mod.HeadLines) + toolArgs["limit"] = mod.HeadLines + } + return makeSuggestion("ls", cmd, "aifr_list", toolArgs) } -func suggestWc(args []string) *Suggestion { +func suggestWc(args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil // wc output with head/tail doesn't map usefully + } + files := nonFlags(args) if len(files) == 0 { return nil // reading from stdin } + + toolArgs := map[string]any{"paths": files} + parts := make([]string, len(files)) for i, f := range files { parts[i] = shellQuote(f) @@ -290,37 +459,56 @@ func suggestWc(args []string) *Suggestion { cmd := "aifr wc" if hasFlag(args, "-l") { cmd += " -l" + toolArgs["lines"] = true } if hasFlag(args, "-w") { cmd += " -w" + toolArgs["words"] = true } if hasFlag(args, "-c") { cmd += " -c" + toolArgs["bytes"] = true } if hasFlag(args, "-m") { cmd += " -m" + toolArgs["chars"] = true } cmd += " " + strings.Join(parts, " ") - return makeSuggestion("wc", cmd) + return makeSuggestion("wc", cmd, "aifr_wc", toolArgs) } -func suggestStat(args []string) *Suggestion { +func suggestStat(args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil + } files := nonFlags(args) if len(files) == 0 { return nil } - return makeSuggestion("stat", "aifr stat "+shellQuote(files[0])) + return makeSuggestion("stat", + "aifr stat "+shellQuote(files[0]), + "aifr_stat", + map[string]any{"path": files[0]}) } -func suggestDiff(args []string) *Suggestion { +func suggestDiff(args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil + } files := nonFlags(args) if len(files) != 2 { - return nil // diff needs exactly 2 paths for aifr + return nil } - return makeSuggestion("diff", "aifr diff "+shellQuote(files[0])+" "+shellQuote(files[1])) + return makeSuggestion("diff", + "aifr diff "+shellQuote(files[0])+" "+shellQuote(files[1]), + "aifr_diff", + map[string]any{"path_a": files[0], "path_b": files[1]}) } -func suggestChecksum(baseCmd string, args []string) *Suggestion { +func suggestChecksum(baseCmd string, args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil + } files := nonFlags(args) if len(files) == 0 { return nil @@ -339,10 +527,8 @@ func suggestChecksum(baseCmd string, args []string) *Suggestion { case "md5sum": algo = "md5" case "b2sum": - // aifr may not support blake2; skip. - return nil + return nil // aifr may not support blake2 case "shasum": - // shasum defaults to sha1; check for -a flag. algo = "sha1" for i := 0; i < len(args); i++ { if args[i] == "-a" && i+1 < len(args) { @@ -356,19 +542,30 @@ func suggestChecksum(baseCmd string, args []string) *Suggestion { for i, f := range files { parts[i] = shellQuote(f) } - return makeSuggestion(baseCmd, fmt.Sprintf("aifr checksum -a %s %s", algo, strings.Join(parts, " "))) + return makeSuggestion(baseCmd, + fmt.Sprintf("aifr checksum -a %s %s", algo, strings.Join(parts, " ")), + "aifr_checksum", + map[string]any{"paths": files, "algorithm": algo}) } -func suggestHexdump(_ string, args []string) *Suggestion { +func suggestHexdump(_ string, args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil + } files := nonFlags(args) if len(files) == 0 { return nil } - return makeSuggestion("hexdump", "aifr hexdump "+shellQuote(files[0])) + return makeSuggestion("hexdump", + "aifr hexdump "+shellQuote(files[0]), + "aifr_hexdump", + map[string]any{"path": files[0]}) } -func suggestSed(args []string) *Suggestion { - // Only handle the common read-only pattern: sed -n 'NP' or sed -n 'N,Mp' file +func suggestSed(args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil + } if !hasFlag(args, "-n") { return nil // without -n, sed may be doing transformations } @@ -400,7 +597,6 @@ func suggestSed(args []string) *Suggestion { return nil } - // Parse patterns like "5p", "10,20p", "5,10p" script = strings.TrimSuffix(script, "p") if script == "" { return nil @@ -408,44 +604,101 @@ func suggestSed(args []string) *Suggestion { parts := strings.SplitN(script, ",", 2) if len(parts) == 1 { - // Single line: sed -n '5p' if _, err := strconv.Atoi(parts[0]); err != nil { return nil } + lines := parts[0] + ":" + parts[0] return makeSuggestion("sed", - fmt.Sprintf("aifr read --lines=%s:%s %s", parts[0], parts[0], shellQuote(file))) + fmt.Sprintf("aifr read --lines=%s %s", lines, shellQuote(file)), + "aifr_read", + map[string]any{"path": file, "lines": lines}) } - // Range: sed -n '5,10p' if _, err := strconv.Atoi(parts[0]); err != nil { return nil } if _, err := strconv.Atoi(parts[1]); err != nil { return nil } + lines := parts[0] + ":" + parts[1] return makeSuggestion("sed", - fmt.Sprintf("aifr read --lines=%s:%s %s", parts[0], parts[1], shellQuote(file))) + fmt.Sprintf("aifr read --lines=%s %s", lines, shellQuote(file)), + "aifr_read", + map[string]any{"path": file, "lines": lines}) } -func suggestGit(args []string) *Suggestion { +func suggestGit(args []string, mod PipelineModifier) *Suggestion { if len(args) == 0 { return nil } switch args[0] { case "log": - return makeSuggestion("git log", "aifr log") + return suggestGitLog(args[1:], mod) case "diff": - return suggestGitDiff(args[1:]) + return suggestGitDiff(args[1:], mod) default: return nil } } -func suggestGitDiff(args []string) *Suggestion { - // git diff [ref] [-- file...] - // Only handle simple cases. +func suggestGitLog(args []string, mod PipelineModifier) *Suggestion { + if mod.TailLines > 0 { + return nil + } + + oneline := false + var maxCount int + var ref string + + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--oneline": + oneline = true + case a == "-n" && i+1 < len(args): + maxCount, _ = strconv.Atoi(args[i+1]) + i++ + case strings.HasPrefix(a, "-n") && len(a) > 2 && isDigits(a[2:]): + maxCount, _ = strconv.Atoi(a[2:]) + case strings.HasPrefix(a, "--max-count="): + maxCount, _ = strconv.Atoi(strings.TrimPrefix(a, "--max-count=")) + case !strings.HasPrefix(a, "-"): + if ref == "" { + ref = a + } + } + } + + // Pipeline head overrides/sets max-count. + if mod.HeadLines > 0 { + maxCount = mod.HeadLines + } + + toolArgs := map[string]any{} + cmd := "aifr log" + + if oneline { + cmd += " --oneline" + toolArgs["format"] = "oneline" + } + if maxCount > 0 { + cmd += fmt.Sprintf(" --max-count=%d", maxCount) + toolArgs["max_count"] = maxCount + } + if ref != "" { + cmd += " " + shellQuote(ref) + toolArgs["ref"] = ref + } + + return makeSuggestion("git log", cmd, "aifr_log", toolArgs) +} + +func suggestGitDiff(args []string, mod PipelineModifier) *Suggestion { + if mod.IsSet() { + return nil // diff output with head/tail doesn't map cleanly + } + refs := nonFlags(args) - // Filter out "--" separator var clean []string for _, r := range refs { if r != "--" { @@ -454,9 +707,11 @@ func suggestGitDiff(args []string) *Suggestion { } if len(clean) == 2 { return makeSuggestion("git diff", - fmt.Sprintf("aifr diff %s:%s %s:%s", clean[0], ".", clean[1], ".")) + fmt.Sprintf("aifr diff %s:%s %s:%s", clean[0], ".", clean[1], "."), + "aifr_diff", + map[string]any{"path_a": clean[0] + ":.", "path_b": clean[1] + ":."}) } - return makeSuggestion("git diff", "aifr diff") + return makeSuggestion("git diff", "aifr diff", "aifr_diff", map[string]any{}) } // isDigits reports whether s is non-empty and contains only ASCII digits. diff --git a/internal/hookcmd/suggest_test.go b/internal/hookcmd/suggest_test.go index d166df4..b787365 100644 --- a/internal/hookcmd/suggest_test.go +++ b/internal/hookcmd/suggest_test.go @@ -14,7 +14,8 @@ func TestAnalyzeCommand_NoSuggestion(t *testing.T) { {"unrecognized command", "go build ./..."}, {"make", "make test"}, {"npm", "npm install"}, - {"pipe chain", "cat file.go | head -10"}, + {"3-stage pipeline", "cat file | grep pattern | head -5"}, + {"unknown pipe target", "cat file | sort"}, {"double ampersand", "cd /tmp && ls"}, {"semicolon", "echo hello; echo world"}, {"subshell", "$(cat file.go)"}, @@ -29,6 +30,11 @@ func TestAnalyzeCommand_NoSuggestion(t *testing.T) { {"sed without -n", "sed 's/foo/bar/' file.go"}, {"git status", "git status"}, {"git push", "git push origin main"}, + {"wc with head", "wc -l file.go | head -5"}, + {"stat with head", "stat file.go | head -5"}, + {"diff with head", "diff a.go b.go | head -5"}, + {"tail with head", "tail -20 file.go | head -5"}, + {"head with tail", "head -20 file.go | tail -5"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -42,14 +48,15 @@ func TestAnalyzeCommand_NoSuggestion(t *testing.T) { func TestAnalyzeCommand_Cat(t *testing.T) { cases := []struct { - command string - want string + command string + want string + wantTool string }{ - {"cat file.go", "aifr read file.go"}, - {"cat src/main.go", "aifr read src/main.go"}, - {"/usr/bin/cat file.go", "aifr read file.go"}, - {"cat file1.go file2.go", "aifr cat file1.go file2.go"}, - {"cat -n file.go", "aifr read file.go"}, + {"cat file.go", "aifr read file.go", "aifr_read"}, + {"cat src/main.go", "aifr read src/main.go", "aifr_read"}, + {"/usr/bin/cat file.go", "aifr read file.go", "aifr_read"}, + {"cat file1.go file2.go", "aifr cat file1.go file2.go", "aifr_cat"}, + {"cat -n file.go", "aifr read file.go", "aifr_read"}, } for _, tc := range cases { t.Run(tc.command, func(t *testing.T) { @@ -58,10 +65,13 @@ func TestAnalyzeCommand_Cat(t *testing.T) { t.Fatal("expected suggestion, got nil") } if s.AifrCommand != tc.want { - t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + t.Errorf("AifrCommand: got %q, want %q", s.AifrCommand, tc.want) + } + if s.ToolName != tc.wantTool { + t.Errorf("ToolName: got %q, want %q", s.ToolName, tc.wantTool) } if s.Original != "cat" { - t.Errorf("original: got %q, want %q", s.Original, "cat") + t.Errorf("Original: got %q, want %q", s.Original, "cat") } }) } @@ -330,3 +340,143 @@ func TestAnalyzeCommand_AbsoluteCommandPath(t *testing.T) { t.Errorf("got %q", s.AifrCommand) } } + +// --- Pipeline tests --- + +func TestAnalyzeCommand_PipelineHeadCat(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"cat file.go | head -n 50", "aifr read --lines=1:50 file.go"}, + {"cat file.go | head -10", "aifr read --lines=1:10 file.go"}, + {"cat file.go | head", "aifr read --lines=1:10 file.go"}, // default 10 + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + if s.ToolName != "aifr_read" { + t.Errorf("ToolName: got %q, want %q", s.ToolName, "aifr_read") + } + }) + } +} + +func TestAnalyzeCommand_PipelineTailCat(t *testing.T) { + s := AnalyzeCommand("cat file.go | tail -n 20") + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != "aifr read --lines=-20: file.go" { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_PipelineHeadGitLog(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"git log --oneline | head -n 10", "aifr log --oneline --max-count=10"}, + {"git log | head -n 5", "aifr log --max-count=5"}, + {"git log --oneline | head -20", "aifr log --oneline --max-count=20"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + if s.ToolName != "aifr_log" { + t.Errorf("ToolName: got %q, want %q", s.ToolName, "aifr_log") + } + }) + } +} + +func TestAnalyzeCommand_PipelineHeadGrep(t *testing.T) { + s := AnalyzeCommand("grep -rn TODO . | head -n 20") + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != "aifr search --max-matches=20 TODO ." { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_PipelineHeadFind(t *testing.T) { + s := AnalyzeCommand("find . -name '*.go' | head -n 30") + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != "aifr find . --name='*.go' --limit=30" { + t.Errorf("got %q", s.AifrCommand) + } +} + +func TestAnalyzeCommand_PipelineHeadLs(t *testing.T) { + s := AnalyzeCommand("ls -la src/ | head -n 20") + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != "aifr list src/ --limit=20" { + t.Errorf("got %q", s.AifrCommand) + } +} + +// --- MCP tool info tests --- + +func TestAnalyzeCommand_MCPToolArgs(t *testing.T) { + cases := []struct { + name string + command string + wantTool string + wantArg string + wantVal any + }{ + {"cat path", "cat main.go", "aifr_read", "path", "main.go"}, + {"grep pattern", "grep TODO src/", "aifr_search", "pattern", "TODO"}, + {"find name", "find . -name '*.go'", "aifr_find", "name", "*.go"}, + {"git log oneline", "git log --oneline", "aifr_log", "format", "oneline"}, + {"pipeline max_count", "git log | head -5", "aifr_log", "max_count", 5}, + {"pipeline limit", "find . | head -30", "aifr_find", "limit", 30}, + {"diff paths", "diff a.go b.go", "aifr_diff", "path_a", "a.go"}, + {"checksum algo", "sha256sum f.go", "aifr_checksum", "algorithm", "sha256"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.ToolName != tc.wantTool { + t.Errorf("ToolName: got %q, want %q", s.ToolName, tc.wantTool) + } + got, ok := s.ToolArgs[tc.wantArg] + if !ok { + t.Errorf("ToolArgs missing key %q; args=%v", tc.wantArg, s.ToolArgs) + return + } + // Compare with type awareness: int vs float64 from JSON. + switch want := tc.wantVal.(type) { + case int: + if got != want { + t.Errorf("ToolArgs[%q]: got %v (%T), want %v", tc.wantArg, got, got, want) + } + default: + if got != want { + t.Errorf("ToolArgs[%q]: got %v, want %v", tc.wantArg, got, want) + } + } + }) + } +} From f67e31857dbae0a6d6c3d1bc4d67832e94c13be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 02:41:45 +0000 Subject: [PATCH 5/7] feat: replace hand-rolled shell parsing with mvdan.cc/sh/v3 Use a proper shell parser (mvdan/sh) instead of hand-rolled tokenize, splitPipeline, and hasShellOperators functions. The AST-based approach correctly handles all quoting variants, redirections (excluded from args automatically), environment variable prefixes (in CallExpr.Assigns), and pipeline detection via BinaryCmd nodes. https://claude.ai/code/session_017inmawi6PUgMy9zSu6EXKv --- go.mod | 1 + go.sum | 4 + internal/hookcmd/shellparse.go | 310 +++++++++++++--------------- internal/hookcmd/shellparse_test.go | 164 +++++++++------ internal/hookcmd/suggest.go | 98 ++------- internal/hookcmd/suggest_test.go | 1 + 6 files changed, 269 insertions(+), 309 deletions(-) diff --git a/go.mod b/go.mod index 6797d6a..74aafec 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 30b1655..9dfd4d7 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/hookcmd/shellparse.go b/internal/hookcmd/shellparse.go index d24de1f..fc3ddc9 100644 --- a/internal/hookcmd/shellparse.go +++ b/internal/hookcmd/shellparse.go @@ -5,190 +5,178 @@ // when the command can be safely handled by aifr. package hookcmd -import "strings" +import ( + "path/filepath" + "strings" -// tokenize splits a shell command into tokens, handling single quotes, -// double quotes, and backslash escaping. It does not expand variables or globs. -func tokenize(s string) []string { - var tokens []string - var cur strings.Builder - inSingle := false - inDouble := false - escaped := false + "mvdan.cc/sh/v3/syntax" +) - for _, r := range s { - if escaped { - cur.WriteRune(r) - escaped = false - continue - } - if r == '\\' && !inSingle { - escaped = true - continue - } - if r == '\'' && !inDouble { - inSingle = !inSingle - continue - } - if r == '"' && !inSingle { - inDouble = !inDouble - continue - } - if (r == ' ' || r == '\t') && !inSingle && !inDouble { - if cur.Len() > 0 { - tokens = append(tokens, cur.String()) - cur.Reset() - } - continue +// parsedCommand represents a shell command extracted from a parsed AST. +type parsedCommand struct { + Name string // base command name (e.g., "cat", "grep") + Args []string // argument values (unquoted where possible) +} + +// parseShellCommand parses a shell command string into a primary command and +// an optional pipeline modifier. Returns nil if the command is too complex +// to analyze (multiple statements, subshells, control operators, etc.). +// +// Two-stage pipelines where the second stage is head or tail are recognized +// and returned as a modifier on the first stage. +func parseShellCommand(command string) (*parsedCommand, PipelineModifier) { + parser := syntax.NewParser(syntax.Variant(syntax.LangBash)) + file, err := parser.Parse(strings.NewReader(command), "") + if err != nil { + return nil, PipelineModifier{} + } + + if len(file.Stmts) != 1 { + return nil, PipelineModifier{} + } + + stmt := file.Stmts[0] + if stmt.Background || stmt.Negated || stmt.Coprocess { + return nil, PipelineModifier{} + } + + switch cmd := stmt.Cmd.(type) { + case *syntax.CallExpr: + parsed := extractCall(cmd) + return parsed, PipelineModifier{} + + case *syntax.BinaryCmd: + if cmd.Op != syntax.Pipe { + return nil, PipelineModifier{} // &&, || } - cur.WriteRune(r) + return parsePipelineCmd(cmd) + + default: + // if, for, while, case, subshell, function decl, etc. + return nil, PipelineModifier{} + } +} + +// parsePipelineCmd handles a two-stage pipeline (cmd | head/tail). +// Returns nil for 3+ stage pipelines or when the right side isn't head/tail. +func parsePipelineCmd(bc *syntax.BinaryCmd) (*parsedCommand, PipelineModifier) { + // Both sides must be simple commands (not nested pipelines or control structures). + leftCall, ok := bc.X.Cmd.(*syntax.CallExpr) + if !ok { + return nil, PipelineModifier{} + } + rightCall, ok := bc.Y.Cmd.(*syntax.CallExpr) + if !ok { + return nil, PipelineModifier{} + } + + right := extractCall(rightCall) + if right == nil { + return nil, PipelineModifier{} } - if cur.Len() > 0 { - tokens = append(tokens, cur.String()) + + mod := pipeTailModifier(right) + if !mod.IsSet() { + return nil, PipelineModifier{} } - return tokens + + left := extractCall(leftCall) + return left, mod } -// hasShellOperators reports whether the command contains shell operators -// (pipes, chains, backgrounding) outside of quoted strings. -// Commands with these operators are too complex for simple replacement. -func hasShellOperators(s string) bool { - inSingle := false - inDouble := false - escaped := false - runes := []rune(s) - - for i := 0; i < len(runes); i++ { - r := runes[i] - if escaped { - escaped = false - continue - } - if r == '\\' && !inSingle { - escaped = true - continue - } - if r == '\'' && !inDouble { - inSingle = !inSingle - continue - } - if r == '"' && !inSingle { - inDouble = !inDouble - continue - } - if inSingle || inDouble { - continue - } - switch r { - case '|': - return true - case ';': - return true - case '&': - return true - case '(': - return true - case '`': - return true - case '\n': - return true - } - // $( subshell - if r == '$' && i+1 < len(runes) && runes[i+1] == '(' { - return true - } +// pipeTailModifier checks if a parsed command is head or tail and extracts +// the line count as a PipelineModifier. +func pipeTailModifier(cmd *parsedCommand) PipelineModifier { + switch cmd.Name { + case "head": + return PipelineModifier{HeadLines: parseHeadTailN(cmd.Args, 10)} + case "tail": + if hasFlag(cmd.Args, "-f", "--follow", "-F") { + return PipelineModifier{} + } + return PipelineModifier{TailLines: parseHeadTailN(cmd.Args, 10)} + default: + return PipelineModifier{} } - return false } -// splitPipeline splits a command by unquoted pipe operators. -// It recognizes || as a logical OR (kept in the stage, not split) and only -// splits on single | pipe operators. -func splitPipeline(s string) []string { - var stages []string - var cur strings.Builder - inSingle := false - inDouble := false - escaped := false - runes := []rune(s) - - for i := 0; i < len(runes); i++ { - r := runes[i] - if escaped { - cur.WriteRune(r) - escaped = false - continue - } - if r == '\\' && !inSingle { - cur.WriteRune(r) - escaped = true - continue - } - if r == '\'' && !inDouble { - inSingle = !inSingle - cur.WriteRune(r) - continue - } - if r == '"' && !inSingle { - inDouble = !inDouble - cur.WriteRune(r) - continue - } - if r == '|' && !inSingle && !inDouble { - // || is logical OR, not a pipe — keep in current stage. - if i+1 < len(runes) && runes[i+1] == '|' { - cur.WriteRune(r) - cur.WriteRune(runes[i+1]) - i++ - continue - } - stages = append(stages, cur.String()) - cur.Reset() - continue - } - cur.WriteRune(r) +// extractCall extracts a parsedCommand from a CallExpr AST node. +// Variable assignments (LANG=C cmd) are automatically excluded since the +// parser places them in CallExpr.Assigns, not Args. +func extractCall(ce *syntax.CallExpr) *parsedCommand { + if len(ce.Args) == 0 { + return nil } - if cur.Len() > 0 { - stages = append(stages, cur.String()) + + name := wordValue(ce.Args[0]) + if name == "" { + return nil + } + + args := make([]string, len(ce.Args)-1) + for i, w := range ce.Args[1:] { + args[i] = wordValue(w) + } + + return &parsedCommand{ + Name: filepath.Base(name), + Args: args, } - return stages } -// baseName returns the last path component of cmd (strips directory prefix). -func baseName(cmd string) string { - if idx := strings.LastIndex(cmd, "/"); idx >= 0 { - return cmd[idx+1:] +// wordValue extracts the effective string value from a shell Word, +// stripping quotes where possible. For words containing parameter expansions +// or command substitutions, falls back to the printed shell representation. +func wordValue(w *syntax.Word) string { + if s := w.Lit(); s != "" { + return s } - return cmd + + if len(w.Parts) == 1 { + switch p := w.Parts[0].(type) { + case *syntax.Lit: + return p.Value + case *syntax.SglQuoted: + return p.Value + case *syntax.DblQuoted: + return dblQuotedLiteral(p) + } + } + + var buf strings.Builder + syntax.NewPrinter().Print(&buf, w) + return buf.String() } -// nonFlags returns tokens that don't start with '-', skipping redirections. -func nonFlags(tokens []string) []string { +// dblQuotedLiteral extracts the literal content of a double-quoted string. +// If the string contains expansions, falls back to printer output. +func dblQuotedLiteral(dq *syntax.DblQuoted) string { + var sb strings.Builder + for _, p := range dq.Parts { + lit, ok := p.(*syntax.Lit) + if !ok { + var buf strings.Builder + syntax.NewPrinter().Print(&buf, dq) + return buf.String() + } + sb.WriteString(lit.Value) + } + return sb.String() +} + +// nonFlags returns elements of args that don't start with '-'. +// Redirections are not present in args (the parser handles them separately). +func nonFlags(args []string) []string { var out []string - skipNext := false - for _, t := range tokens { - if skipNext { - skipNext = false - continue - } - // Skip output/input redirections and their targets. - if t == ">" || t == ">>" || t == "<" || t == "<<" || t == "2>" || t == "2>>" || t == "&>" { - skipNext = true - continue - } - // Skip tokens that are redirection targets embedded with operator. - if len(t) > 1 && (t[0] == '>' || (t[0] == '2' && len(t) > 2 && t[1] == '>')) { - continue - } - if strings.HasPrefix(t, "-") { - continue + for _, t := range args { + if !strings.HasPrefix(t, "-") { + out = append(out, t) } - out = append(out, t) } return out } -// hasFlag reports whether any token exactly matches one of the given flags. +// hasFlag reports whether any element of tokens exactly matches one of the given flags. func hasFlag(tokens []string, flags ...string) bool { for _, t := range tokens { for _, f := range flags { diff --git a/internal/hookcmd/shellparse_test.go b/internal/hookcmd/shellparse_test.go index 7f922b9..06da0be 100644 --- a/internal/hookcmd/shellparse_test.go +++ b/internal/hookcmd/shellparse_test.go @@ -6,73 +6,126 @@ import ( "testing" ) -func TestTokenize(t *testing.T) { +func TestParseShellCommand_Simple(t *testing.T) { cases := []struct { - input string - want []string + input string + wantName string + wantArgs []string }{ - {"cat file.go", []string{"cat", "file.go"}}, - {"head -n 50 file.go", []string{"head", "-n", "50", "file.go"}}, - {`grep "hello world" .`, []string{"grep", "hello world", "."}}, - {`cat 'file with spaces.go'`, []string{"cat", "file with spaces.go"}}, - {`echo foo\ bar`, []string{"echo", "foo bar"}}, - {" head -n 10 file.go ", []string{"head", "-n", "10", "file.go"}}, - {"", nil}, - {"/usr/bin/cat file.go", []string{"/usr/bin/cat", "file.go"}}, + {"cat file.go", "cat", []string{"file.go"}}, + {"head -n 50 file.go", "head", []string{"-n", "50", "file.go"}}, + {`grep "hello world" .`, "grep", []string{"hello world", "."}}, + {`cat 'file with spaces.go'`, "cat", []string{"file with spaces.go"}}, + {"/usr/bin/cat file.go", "cat", []string{"file.go"}}, + {"ls -la src/", "ls", []string{"-la", "src/"}}, } for _, tc := range cases { t.Run(tc.input, func(t *testing.T) { - got := tokenize(tc.input) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("tokenize(%q) = %v, want %v", tc.input, got, tc.want) + parsed, mod := parseShellCommand(tc.input) + if parsed == nil { + t.Fatal("expected parsed command, got nil") + } + if parsed.Name != tc.wantName { + t.Errorf("Name: got %q, want %q", parsed.Name, tc.wantName) + } + if !reflect.DeepEqual(parsed.Args, tc.wantArgs) { + t.Errorf("Args: got %v, want %v", parsed.Args, tc.wantArgs) + } + if mod.IsSet() { + t.Errorf("expected no modifier, got %+v", mod) } }) } } -func TestHasShellOperators(t *testing.T) { +func TestParseShellCommand_EnvVars(t *testing.T) { + parsed, _ := parseShellCommand("LANG=C cat file.go") + if parsed == nil { + t.Fatal("expected parsed command, got nil") + } + if parsed.Name != "cat" { + t.Errorf("Name: got %q, want %q", parsed.Name, "cat") + } + if !reflect.DeepEqual(parsed.Args, []string{"file.go"}) { + t.Errorf("Args: got %v, want %v", parsed.Args, []string{"file.go"}) + } +} + +func TestParseShellCommand_Redirections(t *testing.T) { + // Redirections should not appear in Args (parser handles them separately). + parsed, _ := parseShellCommand("cat file.go > out.txt") + if parsed == nil { + t.Fatal("expected parsed command, got nil") + } + if parsed.Name != "cat" { + t.Errorf("Name: got %q, want %q", parsed.Name, "cat") + } + if !reflect.DeepEqual(parsed.Args, []string{"file.go"}) { + t.Errorf("Args: got %v, want %v (redirections should be excluded)", parsed.Args, []string{"file.go"}) + } +} + +func TestParseShellCommand_Pipeline(t *testing.T) { cases := []struct { - input string - want bool + input string + wantName string + wantHead int + wantTail int }{ - {"cat file.go", false}, - {"cat file.go | head", true}, - {"cd /tmp && ls", true}, - {"echo hello; echo world", true}, - {"echo 'hello | world'", false}, // pipe inside quotes - {`echo "hello && world"`, false}, // && inside quotes - {"cat file.go &", true}, // backgrounding - {"echo $(date)", true}, // subshell - {"echo `date`", true}, // backtick subshell - {"head -n 50 file.go", false}, - {`grep "a;b" file.go`, false}, // semicolon in quotes - {`grep 'a|b' file.go`, false}, // pipe in quotes + {"cat file.go | head -n 50", "cat", 50, 0}, + {"cat file.go | head -10", "cat", 10, 0}, + {"cat file.go | head", "cat", 10, 0}, // default 10 + {"cat file.go | tail -n 20", "cat", 0, 20}, + {"git log --oneline | head -n 10", "git", 10, 0}, + {"grep TODO . | head -5", "grep", 5, 0}, } for _, tc := range cases { t.Run(tc.input, func(t *testing.T) { - got := hasShellOperators(tc.input) - if got != tc.want { - t.Errorf("hasShellOperators(%q) = %v, want %v", tc.input, got, tc.want) + parsed, mod := parseShellCommand(tc.input) + if parsed == nil { + t.Fatal("expected parsed command, got nil") + } + if parsed.Name != tc.wantName { + t.Errorf("Name: got %q, want %q", parsed.Name, tc.wantName) + } + if mod.HeadLines != tc.wantHead { + t.Errorf("HeadLines: got %d, want %d", mod.HeadLines, tc.wantHead) + } + if mod.TailLines != tc.wantTail { + t.Errorf("TailLines: got %d, want %d", mod.TailLines, tc.wantTail) } }) } } -func TestBaseName(t *testing.T) { +func TestParseShellCommand_Complex(t *testing.T) { + // All of these should return nil (too complex to analyze). cases := []struct { - input string - want string + name string + command string }{ - {"cat", "cat"}, - {"/usr/bin/cat", "cat"}, - {"/bin/grep", "grep"}, - {"./local/bin/rg", "rg"}, + {"empty", ""}, + {"three-stage pipeline", "cat file | grep pattern | head -5"}, + {"unknown pipe target", "cat file | sort"}, + {"double ampersand", "cd /tmp && ls"}, + {"logical or", "cat file || echo fallback"}, + {"semicolon", "echo hello; echo world"}, + {"background", "cat file &"}, + {"subshell", "(cat file)"}, + {"pipe in quotes OK", `grep "a|b" file`}, // single command, not nil } for _, tc := range cases { - t.Run(tc.input, func(t *testing.T) { - got := baseName(tc.input) - if got != tc.want { - t.Errorf("baseName(%q) = %q, want %q", tc.input, got, tc.want) + t.Run(tc.name, func(t *testing.T) { + parsed, _ := parseShellCommand(tc.command) + switch tc.name { + case "pipe in quotes OK": + if parsed == nil { + t.Error("grep with | in pattern should parse as simple command") + } + default: + if parsed != nil { + t.Errorf("expected nil for complex command, got %+v", parsed) + } } }) } @@ -100,29 +153,6 @@ func TestShellQuote(t *testing.T) { } } -func TestSplitPipeline(t *testing.T) { - cases := []struct { - input string - want []string - }{ - {"cat file.go", []string{"cat file.go"}}, - {"cat file.go | head -5", []string{"cat file.go ", " head -5"}}, - {"a | b | c", []string{"a ", " b ", " c"}}, - {"grep 'a|b' file", []string{"grep 'a|b' file"}}, // | inside quotes - {`grep "a|b" file`, []string{`grep "a|b" file`}}, // | inside double quotes - {"cmd1 || cmd2", []string{"cmd1 || cmd2"}}, // || is not a pipe - {"cat file | head | tail", []string{"cat file ", " head ", " tail"}}, - } - for _, tc := range cases { - t.Run(tc.input, func(t *testing.T) { - got := splitPipeline(tc.input) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("splitPipeline(%q) = %v, want %v", tc.input, got, tc.want) - } - }) - } -} - func TestNonFlags(t *testing.T) { cases := []struct { input []string @@ -130,8 +160,6 @@ func TestNonFlags(t *testing.T) { }{ {[]string{"-n", "file.go"}, []string{"file.go"}}, {[]string{"-la", "src/"}, []string{"src/"}}, - {[]string{"file.go", ">", "out.txt"}, []string{"file.go"}}, - {[]string{"file.go", ">>", "out.txt"}, []string{"file.go"}}, {[]string{"-l", "-w", "file.go"}, []string{"file.go"}}, } for _, tc := range cases { diff --git a/internal/hookcmd/suggest.go b/internal/hookcmd/suggest.go index 09c6754..e6dd0ea 100644 --- a/internal/hookcmd/suggest.go +++ b/internal/hookcmd/suggest.go @@ -38,110 +38,48 @@ func AnalyzeCommand(command string) *Suggestion { return nil } - // Already an aifr invocation — nothing to suggest. - if strings.HasPrefix(command, "aifr ") || command == "aifr" { - return nil - } - - // Split pipeline and check for trailing head/tail. - stages := splitPipeline(command) - var mod PipelineModifier - var baseStage string - - switch len(stages) { - case 1: - baseStage = stages[0] - case 2: - mod = parsePipeTail(stages[1]) - if !mod.IsSet() { - return nil // unknown pipe target - } - baseStage = stages[0] - default: - return nil // 3+ stage pipelines are too complex - } - - baseStage = strings.TrimSpace(baseStage) - - // Check for non-pipe shell operators in the base stage. - if hasShellOperators(baseStage) { - return nil - } - - tokens := tokenize(baseStage) - if len(tokens) == 0 { + parsed, mod := parseShellCommand(command) + if parsed == nil { return nil } - // Skip environment variable assignments (VAR=value cmd ...). - start := 0 - for start < len(tokens) && strings.Contains(tokens[start], "=") && !strings.HasPrefix(tokens[start], "-") { - start++ - } - if start >= len(tokens) { + // Already an aifr invocation — nothing to suggest. + if parsed.Name == "aifr" { return nil } - tokens = tokens[start:] - baseCmd := baseName(tokens[0]) - args := tokens[1:] - - switch baseCmd { + switch parsed.Name { case "cat": - return suggestCat(args, mod) + return suggestCat(parsed.Args, mod) case "head": - return suggestHead(args, mod) + return suggestHead(parsed.Args, mod) case "tail": - return suggestTail(args, mod) + return suggestTail(parsed.Args, mod) case "grep", "egrep", "fgrep", "rg": - return suggestSearch(baseCmd, args, mod) + return suggestSearch(parsed.Name, parsed.Args, mod) case "find": - return suggestFind(args, mod) + return suggestFind(parsed.Args, mod) case "ls": - return suggestList(args, mod) + return suggestList(parsed.Args, mod) case "wc": - return suggestWc(args, mod) + return suggestWc(parsed.Args, mod) case "stat": - return suggestStat(args, mod) + return suggestStat(parsed.Args, mod) case "diff": - return suggestDiff(args, mod) + return suggestDiff(parsed.Args, mod) case "sha256sum", "sha1sum", "md5sum", "shasum", "sha384sum", "sha512sum", "b2sum": - return suggestChecksum(baseCmd, args, mod) + return suggestChecksum(parsed.Name, parsed.Args, mod) case "hexdump", "xxd", "od": - return suggestHexdump(baseCmd, args, mod) + return suggestHexdump(parsed.Name, parsed.Args, mod) case "sed": - return suggestSed(args, mod) + return suggestSed(parsed.Args, mod) case "git": - return suggestGit(args, mod) + return suggestGit(parsed.Args, mod) default: return nil } } -// parsePipeTail parses the tail of a pipeline (the part after |) to detect -// head -n N or tail -n N patterns. -func parsePipeTail(stage string) PipelineModifier { - stage = strings.TrimSpace(stage) - tokens := tokenize(stage) - if len(tokens) == 0 { - return PipelineModifier{} - } - cmd := baseName(tokens[0]) - args := tokens[1:] - - switch cmd { - case "head": - return PipelineModifier{HeadLines: parseHeadTailN(args, 10)} - case "tail": - if hasFlag(args, "-f", "--follow", "-F") { - return PipelineModifier{} - } - return PipelineModifier{TailLines: parseHeadTailN(args, 10)} - default: - return PipelineModifier{} - } -} - // parseHeadTailN extracts the line count from head/tail arguments. // Handles -n N, -nN, and -N forms. Returns defaultN if not found. func parseHeadTailN(args []string, defaultN int) int { diff --git a/internal/hookcmd/suggest_test.go b/internal/hookcmd/suggest_test.go index b787365..9a7a239 100644 --- a/internal/hookcmd/suggest_test.go +++ b/internal/hookcmd/suggest_test.go @@ -24,6 +24,7 @@ func TestAnalyzeCommand_NoSuggestion(t *testing.T) { {"head from stdin", "head -n 5"}, {"tail -f", "tail -f server.log"}, {"tail --follow", "tail --follow server.log"}, + {"tail -f pipe", "tail -f server.log | head -5"}, {"grep from stdin", "grep pattern"}, {"wc from stdin", "wc -l"}, {"stat no args", "stat"}, From b503fa744930ae3b9b2efa32e99ced5414e5a8bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 02:46:18 +0000 Subject: [PATCH 6/7] fix: verify pipeline suggestion reason content, not just non-emptiness The end-to-end pipeline test now checks that the deny reason actually mentions "aifr log" / "aifr_log" and "max-count" / "max_count", matching what the comment promised. https://claude.ai/code/session_017inmawi6PUgMy9zSu6EXKv --- internal/hookcmd/hookcmd_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/hookcmd/hookcmd_test.go b/internal/hookcmd/hookcmd_test.go index 63c08c0..03f4b8a 100644 --- a/internal/hookcmd/hookcmd_test.go +++ b/internal/hookcmd/hookcmd_test.go @@ -3,6 +3,7 @@ package hookcmd import ( "encoding/json" + "strings" "testing" ) @@ -99,10 +100,12 @@ func TestCheckCommand_PipelineSuggestion(t *testing.T) { if result.HookSpecificOutput.Decision != "deny" { t.Errorf("expected deny, got %q", result.HookSpecificOutput.Decision) } - // Verify the reason mentions the aifr log command with --max-count. reason := result.HookSpecificOutput.Reason - if reason == "" { - t.Error("expected non-empty reason") + if !strings.Contains(reason, "aifr log") && !strings.Contains(reason, "aifr_log") { + t.Errorf("reason should mention aifr log, got %q", reason) + } + if !strings.Contains(reason, "max-count") && !strings.Contains(reason, "max_count") { + t.Errorf("reason should mention max-count/max_count, got %q", reason) } } From 235b105def988116a3d5581f53200cf6460328a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 02:53:26 +0000 Subject: [PATCH 7/7] feat: recognize | sed -n as pipeline modifier for line range extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipelines ending in sed -n 'N,Mp' or sed -n 'Np' are now recognized as pipeline modifiers. For cat, this maps directly to aifr read --lines: cat file | sed -n '50,100p' → aifr read --lines=50:100 file cat file | sed -n '42p' → aifr read --lines=42:42 file When the sed range starts at line 1, it normalizes to HeadLines so commands with head-style limits (find --limit, search --max-matches, log --max-count) also benefit. https://claude.ai/code/session_017inmawi6PUgMy9zSu6EXKv --- internal/hookcmd/shellparse.go | 71 +++++++++++++++++++++++++++-- internal/hookcmd/shellparse_test.go | 39 ++++++++++++++++ internal/hookcmd/suggest.go | 25 +++++++--- internal/hookcmd/suggest_test.go | 48 +++++++++++++++++++ 4 files changed, 173 insertions(+), 10 deletions(-) diff --git a/internal/hookcmd/shellparse.go b/internal/hookcmd/shellparse.go index fc3ddc9..8b8e065 100644 --- a/internal/hookcmd/shellparse.go +++ b/internal/hookcmd/shellparse.go @@ -7,6 +7,7 @@ package hookcmd import ( "path/filepath" + "strconv" "strings" "mvdan.cc/sh/v3/syntax" @@ -22,8 +23,8 @@ type parsedCommand struct { // an optional pipeline modifier. Returns nil if the command is too complex // to analyze (multiple statements, subshells, control operators, etc.). // -// Two-stage pipelines where the second stage is head or tail are recognized -// and returned as a modifier on the first stage. +// Two-stage pipelines where the second stage is head, tail, or sed -n are +// recognized and returned as a modifier on the first stage. func parseShellCommand(command string) (*parsedCommand, PipelineModifier) { parser := syntax.NewParser(syntax.Variant(syntax.LangBash)) file, err := parser.Parse(strings.NewReader(command), "") @@ -84,8 +85,8 @@ func parsePipelineCmd(bc *syntax.BinaryCmd) (*parsedCommand, PipelineModifier) { return left, mod } -// pipeTailModifier checks if a parsed command is head or tail and extracts -// the line count as a PipelineModifier. +// pipeTailModifier checks if a parsed command is head, tail, or sed -n and +// extracts the line count or range as a PipelineModifier. func pipeTailModifier(cmd *parsedCommand) PipelineModifier { switch cmd.Name { case "head": @@ -95,11 +96,73 @@ func pipeTailModifier(cmd *parsedCommand) PipelineModifier { return PipelineModifier{} } return PipelineModifier{TailLines: parseHeadTailN(cmd.Args, 10)} + case "sed": + return parseSedModifier(cmd.Args) default: return PipelineModifier{} } } +// parseSedModifier parses sed -n 'Np' or 'N,Mp' as a pipeline modifier. +// When the range starts at line 1, it normalizes to HeadLines for +// compatibility with commands that support head-style limits. +func parseSedModifier(args []string) PipelineModifier { + if !hasFlag(args, "-n") { + return PipelineModifier{} + } + + // Find the script argument (first non-flag after -n). + var script string + sawN := false + for _, a := range args { + if a == "-n" { + sawN = true + continue + } + if strings.HasPrefix(a, "-") { + continue + } + if sawN { + script = a + break + } + } + if script == "" { + return PipelineModifier{} + } + + script = strings.TrimSuffix(script, "p") + if script == "" { + return PipelineModifier{} + } + + parts := strings.SplitN(script, ",", 2) + if len(parts) == 1 { + n, err := strconv.Atoi(parts[0]) + if err != nil || n <= 0 { + return PipelineModifier{} + } + if n == 1 { + return PipelineModifier{HeadLines: 1} + } + return PipelineModifier{StartLine: n, EndLine: n} + } + + start, err := strconv.Atoi(parts[0]) + if err != nil || start <= 0 { + return PipelineModifier{} + } + end, err := strconv.Atoi(parts[1]) + if err != nil || end <= 0 { + return PipelineModifier{} + } + // Normalize: start=1 is equivalent to head. + if start == 1 { + return PipelineModifier{HeadLines: end} + } + return PipelineModifier{StartLine: start, EndLine: end} +} + // extractCall extracts a parsedCommand from a CallExpr AST node. // Variable assignments (LANG=C cmd) are automatically excluded since the // parser places them in CallExpr.Assigns, not Args. diff --git a/internal/hookcmd/shellparse_test.go b/internal/hookcmd/shellparse_test.go index 06da0be..322e8f3 100644 --- a/internal/hookcmd/shellparse_test.go +++ b/internal/hookcmd/shellparse_test.go @@ -98,6 +98,45 @@ func TestParseShellCommand_Pipeline(t *testing.T) { } } +func TestParseShellCommand_PipelineSed(t *testing.T) { + cases := []struct { + input string + wantName string + wantHead int + wantStart int + wantEnd int + }{ + // sed -n '1,Np' normalizes to HeadLines. + {"cat file.go | sed -n '1,50p'", "cat", 50, 0, 0}, + {"cat file.go | sed -n '1p'", "cat", 1, 0, 0}, + // Arbitrary ranges set StartLine/EndLine. + {"cat file.go | sed -n '5,10p'", "cat", 0, 5, 10}, + {"cat file.go | sed -n '100,200p'", "cat", 0, 100, 200}, + // Single line extraction. + {"cat file.go | sed -n '42p'", "cat", 0, 42, 42}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + parsed, mod := parseShellCommand(tc.input) + if parsed == nil { + t.Fatal("expected parsed command, got nil") + } + if parsed.Name != tc.wantName { + t.Errorf("Name: got %q, want %q", parsed.Name, tc.wantName) + } + if mod.HeadLines != tc.wantHead { + t.Errorf("HeadLines: got %d, want %d", mod.HeadLines, tc.wantHead) + } + if mod.StartLine != tc.wantStart { + t.Errorf("StartLine: got %d, want %d", mod.StartLine, tc.wantStart) + } + if mod.EndLine != tc.wantEnd { + t.Errorf("EndLine: got %d, want %d", mod.EndLine, tc.wantEnd) + } + }) + } +} + func TestParseShellCommand_Complex(t *testing.T) { // All of these should return nil (too complex to analyze). cases := []struct { diff --git a/internal/hookcmd/suggest.go b/internal/hookcmd/suggest.go index e6dd0ea..39263ed 100644 --- a/internal/hookcmd/suggest.go +++ b/internal/hookcmd/suggest.go @@ -19,15 +19,17 @@ type Suggestion struct { ToolArgs map[string]any } -// PipelineModifier captures a trailing | head or | tail in a pipeline. +// PipelineModifier captures a trailing | head, | tail, or | sed -n in a pipeline. type PipelineModifier struct { HeadLines int // > 0 if piped to head -n N TailLines int // > 0 if piped to tail -n N + StartLine int // > 0 if piped to sed -n extracting from this line (1-based) + EndLine int // > 0 if piped to sed -n extracting to this line (1-based) } // IsSet reports whether any pipeline modifier is active. func (m PipelineModifier) IsSet() bool { - return m.HeadLines > 0 || m.TailLines > 0 + return m.HeadLines > 0 || m.TailLines > 0 || m.StartLine > 0 } // AnalyzeCommand checks if a shell command can be replaced by an aifr command. @@ -141,6 +143,17 @@ func suggestCat(args []string, mod PipelineModifier) *Suggestion { map[string]any{"path": files[0], "lines": lines}) } + if mod.StartLine > 0 { + if len(files) != 1 { + return nil + } + lines := fmt.Sprintf("%d:%d", mod.StartLine, mod.EndLine) + return makeSuggestion("cat", + fmt.Sprintf("aifr read --lines=%s %s", lines, shellQuote(files[0])), + "aifr_read", + map[string]any{"path": files[0], "lines": lines}) + } + if len(files) == 1 { return makeSuggestion("cat", "aifr read "+shellQuote(files[0]), @@ -239,7 +252,7 @@ func suggestTail(args []string, mod PipelineModifier) *Suggestion { } func suggestSearch(baseCmd string, args []string, mod PipelineModifier) *Suggestion { - if mod.TailLines > 0 { + if mod.TailLines > 0 || mod.StartLine > 0 { return nil } @@ -297,7 +310,7 @@ func suggestSearch(baseCmd string, args []string, mod PipelineModifier) *Suggest } func suggestFind(args []string, mod PipelineModifier) *Suggestion { - if mod.TailLines > 0 { + if mod.TailLines > 0 || mod.StartLine > 0 { return nil } @@ -349,7 +362,7 @@ func suggestFind(args []string, mod PipelineModifier) *Suggestion { } func suggestList(args []string, mod PipelineModifier) *Suggestion { - if mod.TailLines > 0 { + if mod.TailLines > 0 || mod.StartLine > 0 { return nil } @@ -580,7 +593,7 @@ func suggestGit(args []string, mod PipelineModifier) *Suggestion { } func suggestGitLog(args []string, mod PipelineModifier) *Suggestion { - if mod.TailLines > 0 { + if mod.TailLines > 0 || mod.StartLine > 0 { return nil } diff --git a/internal/hookcmd/suggest_test.go b/internal/hookcmd/suggest_test.go index 9a7a239..ac45837 100644 --- a/internal/hookcmd/suggest_test.go +++ b/internal/hookcmd/suggest_test.go @@ -434,6 +434,54 @@ func TestAnalyzeCommand_PipelineHeadLs(t *testing.T) { } } +func TestAnalyzeCommand_PipelineSedCat(t *testing.T) { + cases := []struct { + command string + want string + }{ + {"cat file.go | sed -n '5,10p'", "aifr read --lines=5:10 file.go"}, + {"cat file.go | sed -n '42p'", "aifr read --lines=42:42 file.go"}, + {"cat file.go | sed -n '100,200p'", "aifr read --lines=100:200 file.go"}, + // start=1 normalizes to HeadLines, still produces the right suggestion. + {"cat file.go | sed -n '1,50p'", "aifr read --lines=1:50 file.go"}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s == nil { + t.Fatal("expected suggestion, got nil") + } + if s.AifrCommand != tc.want { + t.Errorf("got %q, want %q", s.AifrCommand, tc.want) + } + if s.ToolName != "aifr_read" { + t.Errorf("ToolName: got %q, want %q", s.ToolName, "aifr_read") + } + }) + } +} + +func TestAnalyzeCommand_PipelineSedNoSuggestion(t *testing.T) { + // sed line ranges on non-cat commands don't map to aifr parameters. + cases := []struct { + name string + command string + }{ + {"grep with sed range", "grep TODO . | sed -n '5,10p'"}, + {"find with sed range", "find . -name '*.go' | sed -n '5,20p'"}, + {"git log with sed range", "git log | sed -n '5,10p'"}, + {"sed without -n", "cat file.go | sed '5,10p'"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := AnalyzeCommand(tc.command) + if s != nil { + t.Errorf("expected nil, got suggestion: %s", s.AifrCommand) + } + }) + } +} + // --- MCP tool info tests --- func TestAnalyzeCommand_MCPToolArgs(t *testing.T) {