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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,20 @@ type TestOnly interface {
IsTestOnly() bool
}

// NonInteractiveDetector is optionally implemented by agents whose hook
// subprocesses inherit the user's TTY but can't respond to interactive
// prompts. Returns the env var name and value that indicate a non-interactive
// subprocess (e.g., "GEMINI_CLI", "" means any non-empty value).
type NonInteractiveDetector interface {
Agent

// NonInteractiveEnvVar returns the environment variable name that signals
// a non-interactive subprocess. If value is empty, any non-empty env var
// value triggers non-interactive mode. If value is non-empty, only that
// exact value triggers it.
NonInteractiveEnvVar() (name string, value string)
}

// SubagentAwareExtractor provides methods for extracting files and tokens including subagents.
// Agents that support spawning subagents (like Claude Code's Task tool) should implement this
// to ensure subagent contributions are included in checkpoints.
Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/agent/copilotcli/copilotcli.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ func (c *CopilotCLIAgent) Description() string {
// IsPreview returns true because this is a new integration.
func (c *CopilotCLIAgent) IsPreview() bool { return true }

// NonInteractiveEnvVar returns the env var that signals a Copilot CLI subprocess.
// Copilot sets COPILOT_CLI=1 when running hook subprocesses (v0.0.421+).
// The subprocess may inherit the user's TTY but can't respond to prompts.
func (c *CopilotCLIAgent) NonInteractiveEnvVar() (string, string) {
return "COPILOT_CLI", ""
}

// DetectPresence checks if Entire hooks are installed in the Copilot CLI config.
// Delegates to AreHooksInstalled which checks .github/hooks/entire.json for Entire hook entries.
func (c *CopilotCLIAgent) DetectPresence(ctx context.Context) (bool, error) {
Expand Down
8 changes: 8 additions & 0 deletions cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ func (f *FactoryAIDroidAgent) Description() string {
// IsPreview returns true as Factory AI Droid integration is in preview.
func (f *FactoryAIDroidAgent) IsPreview() bool { return true }

// NonInteractiveEnvVar returns the env var that signals a non-interactive subprocess.
// Factory AI Droid (and other CI environments) set GIT_TERMINAL_PROMPT=0 to disable
// git's own terminal prompts. Since hooks run as git subprocesses, this signals
// that interactive prompting should be skipped.
func (f *FactoryAIDroidAgent) NonInteractiveEnvVar() (string, string) {
return "GIT_TERMINAL_PROMPT", "0"
}

// ProtectedDirs returns directories that Factory AI Droid uses for config/state.
func (f *FactoryAIDroidAgent) ProtectedDirs() []string { return []string{".factory"} }

Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/agent/geminicli/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ func (g *GeminiCLIAgent) Description() string {

func (g *GeminiCLIAgent) IsPreview() bool { return true }

// NonInteractiveEnvVar returns the env var that signals a Gemini CLI subprocess.
// Gemini sets GEMINI_CLI=1 when running shell commands. The subprocess may
// inherit the user's TTY but can't respond to interactive prompts.
func (g *GeminiCLIAgent) NonInteractiveEnvVar() (string, string) {
return "GEMINI_CLI", ""
}

// DetectPresence checks if Gemini CLI is configured in the repository.
func (g *GeminiCLIAgent) DetectPresence(ctx context.Context) (bool, error) {
// Get worktree root to check for .gemini directory
Expand Down
28 changes: 28 additions & 0 deletions cmd/entire/cli/agent/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agent
import (
"context"
"fmt"
"os"
"slices"
"sync"

Expand Down Expand Up @@ -170,6 +171,33 @@ func AllProtectedDirs() []string {
return dirs
}

// IsNonInteractiveEnv checks whether any registered agent signals that the
// current process is a non-interactive subprocess (e.g., a git hook run by
// an agent that inherits the user's TTY but can't respond to prompts).
func IsNonInteractiveEnv() bool {
registryMu.RLock()
factories := make([]Factory, 0, len(registry))
for _, f := range registry {
factories = append(factories, f)
}
registryMu.RUnlock()

for _, factory := range factories {
ag := factory()
if detector, ok := ag.(NonInteractiveDetector); ok {
envName, envValue := detector.NonInteractiveEnvVar()
actual := os.Getenv(envName)
if envValue == "" && actual != "" {
return true
}
if envValue != "" && actual == envValue {
return true
}
}
}
return false
}

// Default returns the default agent.
// Returns nil if the default agent is not registered.
//
Expand Down
78 changes: 78 additions & 0 deletions cmd/entire/cli/agent/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,84 @@ func TestAllProtectedDirs(t *testing.T) {
})
}

func TestIsNonInteractiveEnv(t *testing.T) {
// Save original registry state
originalRegistry := make(map[types.AgentName]Factory)
registryMu.Lock()
for k, v := range registry {
originalRegistry[k] = v
}
registry = make(map[types.AgentName]Factory)
registryMu.Unlock()

defer func() {
registryMu.Lock()
registry = originalRegistry
registryMu.Unlock()
}()

t.Run("returns false with no agents", func(t *testing.T) {
if IsNonInteractiveEnv() {
t.Error("expected false with empty registry")
}
})

t.Run("returns false when env var not set", func(t *testing.T) {
registryMu.Lock()
registry = make(map[types.AgentName]Factory)
registryMu.Unlock()

Register(types.AgentName("ni-agent"), func() Agent {
return &nonInteractiveAgent{envName: "TEST_NI_AGENT_ENV_XYZ", envValue: ""}
})

if IsNonInteractiveEnv() {
t.Error("expected false when env var is not set")
}
})

t.Run("returns true when any-value env var is set", func(t *testing.T) {
t.Setenv("TEST_NI_AGENT_ENV_XYZ", "1")

if !IsNonInteractiveEnv() {
t.Error("expected true when env var is set")
}
})

t.Run("returns true when exact-value env var matches", func(t *testing.T) {
registryMu.Lock()
registry = make(map[types.AgentName]Factory)
registryMu.Unlock()

Register(types.AgentName("exact-agent"), func() Agent {
return &nonInteractiveAgent{envName: "TEST_EXACT_ENV_XYZ", envValue: "0"}
})

t.Setenv("TEST_EXACT_ENV_XYZ", "0")
if !IsNonInteractiveEnv() {
t.Error("expected true when env var matches exact value")
}
})

t.Run("returns false when exact-value env var does not match", func(t *testing.T) {
t.Setenv("TEST_EXACT_ENV_XYZ", "1")
if IsNonInteractiveEnv() {
t.Error("expected false when env var does not match exact value")
}
})
}

// nonInteractiveAgent is a mock that implements NonInteractiveDetector.
type nonInteractiveAgent struct {
mockAgent
envName string
envValue string
}

func (n *nonInteractiveAgent) NonInteractiveEnvVar() (string, string) {
return n.envName, n.envValue
}

// protectedDirAgent is a mock that returns configurable protected dirs.
type protectedDirAgent struct {
mockAgent
Expand Down
24 changes: 4 additions & 20 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,26 +47,10 @@ func hasTTY() bool {
return v == "1"
}

// Gemini CLI sets GEMINI_CLI=1 when running shell commands.
// Gemini subprocesses may have access to the user's TTY, but they can't
// actually respond to interactive prompts. Treat them as non-TTY.
// See: https://geminicli.com/docs/tools/shell/
if os.Getenv("GEMINI_CLI") != "" {
return false
}

// Copilot CLI sets COPILOT_CLI=1 when running hook subprocesses (v0.0.421+).
// Like Gemini, the subprocess may inherit the user's TTY but can't respond
// to interactive prompts.
if os.Getenv("COPILOT_CLI") != "" {
return false
}

// GIT_TERMINAL_PROMPT=0 disables git's own terminal prompts.
// Factory AI Droid (and other non-interactive environments like CI) set this.
// Since we run as a git hook, respect it — if the environment doesn't want
// git prompting, our hook shouldn't prompt either.
if os.Getenv("GIT_TERMINAL_PROMPT") == "0" {
// Check if any registered agent signals a non-interactive subprocess.
// Agents whose hook subprocesses inherit the user's TTY but can't respond
// to prompts implement NonInteractiveDetector with their env var.
if agent.IsNonInteractiveEnv() {
return false
}

Expand Down