feat(hooks): propagate CRUSH/AGENT env vars to builtin shell

Christian Rocha created

Share CRUSH=1/AGENT=crush/AI_AGENT=crush between the bash tool's Shell
and the hook runner so the two surfaces can't drift.

Change summary

internal/hooks/hooks_test.go |  7 +++++++
internal/hooks/input.go      |  2 ++
internal/shell/shell.go      | 21 +++++++++++++++------
3 files changed, 24 insertions(+), 6 deletions(-)

Detailed changes

internal/hooks/hooks_test.go 🔗

@@ -195,6 +195,13 @@ func TestBuildEnv(t *testing.T) {
 	require.Equal(t, "/project", envMap["CRUSH_PROJECT_DIR"])
 	require.Equal(t, "ls", envMap["CRUSH_TOOL_INPUT_COMMAND"])
 	require.Equal(t, "/tmp/f.txt", envMap["CRUSH_TOOL_INPUT_FILE_PATH"])
+
+	// Shared Crush markers must be present so hook-authored scripts can
+	// detect they're running under Crush the same way bash-tool-invoked
+	// scripts can.
+	require.Equal(t, "1", envMap["CRUSH"])
+	require.Equal(t, "crush", envMap["AGENT"])
+	require.Equal(t, "crush", envMap["AI_AGENT"])
 }
 
 func splitFirst(s, sep string) []string {

internal/hooks/input.go 🔗

@@ -7,6 +7,7 @@ import (
 	"os"
 	"strings"
 
+	"github.com/charmbracelet/crush/internal/shell"
 	"github.com/tidwall/gjson"
 )
 
@@ -51,6 +52,7 @@ func BuildPayload(eventName, sessionID, cwd, toolName, toolInputJSON string) []b
 // It includes all current process env vars plus hook-specific ones.
 func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
 	env := os.Environ()
+	env = append(env, shell.CrushEnvMarkers()...)
 	env = append(env,
 		fmt.Sprintf("CRUSH_EVENT=%s", eventName),
 		fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),

internal/shell/shell.go 🔗

@@ -34,6 +34,20 @@ const (
 	ShellTypePowerShell
 )
 
+// CrushEnvMarkers returns a fresh slice of the environment variables that
+// Crush unconditionally sets on every shell it spawns — both the interactive
+// bash tool's [Shell] and the hook runner's [Run] calls. Tools that want to
+// detect "am I being invoked by an AI agent?" can check any of these.
+// Keeping them in one place guarantees the two shell surfaces cannot drift.
+// A fresh slice is returned on every call so callers may append freely.
+func CrushEnvMarkers() []string {
+	return []string{
+		"CRUSH=1",
+		"AGENT=crush",
+		"AI_AGENT=crush",
+	}
+}
+
 // Logger interface for optional logging
 type Logger interface {
 	InfoPersist(msg string, keysAndValues ...any)
@@ -81,12 +95,7 @@ func NewShell(opts *Options) *Shell {
 	}
 
 	// Allow tools to detect execution by Crush.
-	env = append(
-		env,
-		"CRUSH=1",
-		"AGENT=crush",
-		"AI_AGENT=crush",
-	)
+	env = append(env, CrushEnvMarkers()...)
 
 	logger := opts.Logger
 	if logger == nil {