Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a6f62a8
Add inline "update now?" prompt after version notification
gtrrz-victor Apr 21, 2026
ce3acf9
Default confirm prompt to Yes
gtrrz-victor Apr 22, 2026
5872166
Gate auto-update prompt on /dev/tty, not stdout
gtrrz-victor Apr 22, 2026
b7d13a4
Add CI guardrail to CanPromptInteractively
gtrrz-victor Apr 22, 2026
55c7f89
Drop docs/architecture/auto-update.md from PR
gtrrz-victor Apr 22, 2026
dfa4f39
Merge branch 'main' into auto-update
gtrrz-victor Apr 22, 2026
1d1e012
Set ENTIRE_TEST_TTY=1 in pty-based integration tests
gtrrz-victor Apr 22, 2026
7c3d8a7
Consolidate TTY gating on interactive.HasTTY
gtrrz-victor Apr 22, 2026
f67d983
Rename interactive.HasTTY back to CanPromptInteractively
gtrrz-victor Apr 22, 2026
fc3d0e3
Skip interactive prompts when CI env var is set
gtrrz-victor Apr 23, 2026
f2e0063
Invalidate version-check cache when installer fails
gtrrz-victor Apr 23, 2026
bd23fc5
Merge remote-tracking branch 'origin/main' into auto-update
gtrrz-victor Apr 23, 2026
879a3cf
Simplify auto-update decline/failure UX
gtrrz-victor Apr 23, 2026
f666a09
Force ENTIRE_TEST_TTY=1 in pty-based integration tests
gtrrz-victor Apr 23, 2026
649bd22
Allow overriding version-check URLs via env vars for local testing
gtrrz-victor Apr 23, 2026
7fbc348
Tighten auto-update banner and failure-hint wording
gtrrz-victor Apr 23, 2026
69db6fa
Drop ENTIRE_VERSION_CHECK_URL env overrides
gtrrz-victor Apr 23, 2026
99b5315
Merge branch 'main' into auto-update
gtrrz-victor Apr 23, 2026
eb78adf
Print manual-update hint when prompt can't be shown
gtrrz-victor Apr 23, 2026
176ca2d
Fix build: qualify isTerminalWriter in activity_cmd.go
gtrrz-victor Apr 23, 2026
59918d0
Reword manual-update hint to "To update, run:"
gtrrz-victor Apr 23, 2026
eb9b0df
Treat CI=false as "not CI" for prompt gating
gtrrz-victor Apr 23, 2026
c8d4abf
Skip auto-update prompts on redirected output
gtrrz-victor Apr 23, 2026
d20d773
Skip auto-run on Windows when install manager is unknown
gtrrz-victor Apr 23, 2026
28c05ec
Merge branch 'main' into auto-update
gtrrz-victor Apr 23, 2026
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
4 changes: 2 additions & 2 deletions cmd/entire/cli/integration_test/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -1116,11 +1116,11 @@ func (env *TestEnv) gitCommitWithShadowHooks(message string, simulateTTY bool, f
prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile, "message")
prepCmd.Dir = env.RepoDir
if simulateTTY {
// Simulate human at terminal: ENTIRE_TEST_TTY=1 makes hasTTY() return true
// Simulate human at terminal: ENTIRE_TEST_TTY=1 makes CanPromptInteractively() return true
// and askConfirmTTY() return defaultYes without reading from /dev/tty.
prepCmd.Env = env.gitHookEnv("ENTIRE_TEST_TTY=1")
} else {
// Simulate agent: ENTIRE_TEST_TTY=0 makes hasTTY() return false,
// Simulate agent: ENTIRE_TEST_TTY=0 makes CanPromptInteractively() return false,
// triggering the fast path that adds trailers for ACTIVE sessions.
prepCmd.Env = env.gitHookEnv("ENTIRE_TEST_TTY=0")
}
Expand Down
47 changes: 36 additions & 11 deletions cmd/entire/cli/interactive/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,47 @@ package interactive

import "os"

// CanPromptInteractively reports whether interactive confirmation prompts
// (huh forms, yes/no questions, etc.) can be shown. Returns false in CI,
// tests without ENTIRE_TEST_TTY=1, and other environments without a
// controlling TTY.
// CanPromptInteractively checks if /dev/tty is available for interactive prompts.
// Returns false when running as an agent subprocess (no controlling terminal).
//
// ENTIRE_TEST_TTY overrides /dev/tty detection so tests can exercise both
// interactive and non-interactive paths deterministically without needing
// a real pty:
// - ENTIRE_TEST_TTY=1 forces interactive mode on
// - any other non-empty value forces interactive mode off
// - unset falls through to /dev/tty availability
// In test environments, ENTIRE_TEST_TTY overrides the real check:
// - ENTIRE_TEST_TTY=1 → simulate human (TTY available)
// - ENTIRE_TEST_TTY=0 → simulate agent (no TTY)
func CanPromptInteractively() bool {
if v, ok := os.LookupEnv("ENTIRE_TEST_TTY"); ok {
if v := os.Getenv("ENTIRE_TEST_TTY"); v != "" {
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
}

// Pi Coding Agent sets PI_CODING_AGENT=true when running shell commands.
// Like other agents, the subprocess may inherit the TTY but can't respond
// to interactive prompts.
if os.Getenv("PI_CODING_AGENT") != "" {
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" {
return false
}

tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return false
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/setup_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ func ghFlagsProvided(opts GitHubBootstrapOptions) bool {

// confirmCreateGitHubRepo asks the user whether they want to also create
// a matching GitHub repository. Interactive-only; callers gate on
// CanPromptInteractively.
// interactive.CanPromptInteractively.
func confirmCreateGitHubRepo() (bool, error) {
confirmed := true
form := NewAccessibleForm(
Expand Down
58 changes: 5 additions & 53 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/remote"
"github.com/entireio/cli/cmd/entire/cli/gitops"
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
Expand All @@ -37,55 +38,6 @@ import (
"github.com/go-git/go-git/v6/utils/binary"
)

// hasTTY checks if /dev/tty is available for interactive prompts.
// Returns false when running as an agent subprocess (no controlling terminal).
//
// In test environments, ENTIRE_TEST_TTY overrides the real check:
// - ENTIRE_TEST_TTY=1 → simulate human (TTY available)
// - ENTIRE_TEST_TTY=0 → simulate agent (no TTY)
func hasTTY() bool {
if v := os.Getenv("ENTIRE_TEST_TTY"); v != "" {
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
}

// Pi Coding Agent sets PI_CODING_AGENT=true when running shell commands.
// Like other agents, the subprocess may inherit the TTY but can't respond
// to interactive prompts.
if os.Getenv("PI_CODING_AGENT") != "" {
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" {
return false
}

tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return false
}
_ = tty.Close()
return true
}

// ttyResult represents the outcome of a TTY confirmation prompt.
type ttyResult int

Expand All @@ -96,7 +48,7 @@ const (
)

// askConfirmTTY prompts the user via /dev/tty whether to link a commit to session context.
// This requires a controlling terminal — callers must check hasTTY() first and handle
// This requires a controlling terminal — callers must check interactive.CanPromptInteractively() first and handle
// the no-TTY case (agent subprocesses, CI) themselves.
//
// header is displayed as the first line (e.g., "Entire: Active Claude Code session").
Expand All @@ -108,7 +60,7 @@ func askConfirmTTY(header string, details []string, prompt string, defaultYes bo
}

// In test mode, don't try to interact with the real TTY — just use the default.
// ENTIRE_TEST_TTY=1 simulates "a human is present" for the hasTTY() check
// ENTIRE_TEST_TTY=1 simulates "a human is present" for the CanPromptInteractively() check
// but we can't actually read from the TTY in tests.
if os.Getenv("ENTIRE_TEST_TTY") != "" {
return defaultResult
Expand Down Expand Up @@ -546,7 +498,7 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(ctx context.Context, commitMsgFi
case "message":
// Using -m or -F: behavior depends on TTY availability and commit_linking setting
switch {
case !hasTTY():
case !interactive.CanPromptInteractively():
// No TTY (agent subprocess, CI) — auto-link without prompting
message = addCheckpointTrailer(message, checkpointID)
case commitLinking == settings.CommitLinkingAlways:
Expand Down Expand Up @@ -2051,7 +2003,7 @@ func (s *ManualCommitStrategy) warnIfAttributionDiverged(ctx context.Context, se
// have /dev/tty but can't respond to prompts, and content detection fails
// since the shadow branch doesn't exist yet).
func (s *ManualCommitStrategy) tryAgentCommitFastPath(ctx context.Context, commitMsgFile string, sessions []*SessionState, source string) bool {
noTTY := !hasTTY()
noTTY := !interactive.CanPromptInteractively()
skipContentDetection := noTTY
if !skipContentDetection {
if stngs, err := settings.Load(ctx); err == nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/preparecommitmsg_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func benchSetupPrepareCommitMsgRepo(b *testing.B, fileCount, sessionCount int) (
b.Fatalf("write commit msg: %v", err)
}

// Set ENTIRE_TEST_TTY=0 so hasTTY() returns false (simulates agent subprocess).
// Set ENTIRE_TEST_TTY=0 so CanPromptInteractively() returns false (simulates agent subprocess).
// This avoids interactive TTY prompts during benchmarks.
b.Setenv("ENTIRE_TEST_TTY", "0")

Expand Down
96 changes: 96 additions & 0 deletions cmd/entire/cli/versioncheck/autoupdate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package versioncheck

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"

"github.com/charmbracelet/huh"

"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/logging"
)

// envKillSwitch disables the interactive update prompt regardless of TTY.
const envKillSwitch = "ENTIRE_NO_AUTO_UPDATE"

// Test seams.
var (
runInstaller = realRunInstaller
confirmUpdate = realConfirmUpdate
)

// MaybeAutoUpdate offers an interactive upgrade after the standard
// "version available" notification has been printed. Silent on every
// failure path — it must never interrupt the CLI.
func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion string) {
if os.Getenv(envKillSwitch) != "" {
return
}
if !interactive.CanPromptInteractively() {
return
}
Comment thread
gtrrz-victor marked this conversation as resolved.

confirmed, err := confirmUpdate()
if err != nil {
logging.Debug(ctx, "auto-update: prompt failed", "error", err.Error())
return
}
if !confirmed {
return
}

cmdStr := updateCommand(currentVersion)
fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", cmdStr)
if err := runInstaller(ctx, cmdStr); err != nil {
fmt.Fprintf(w, "Update failed: %v\n", err)
return
}
fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.")
}

func realConfirmUpdate() (bool, error) {
// Pre-select "Yes" so pressing Enter accepts — matches the (Y/n) UX.
confirmed := true
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Install the new version now?").
Affirmative("Yes").
Negative("No").
Value(&confirmed),
),
Comment thread
gtrrz-victor marked this conversation as resolved.
).WithTheme(huh.ThemeDracula())
if os.Getenv("ACCESSIBLE") != "" {
form = form.WithAccessible(true)
}
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) {
return false, nil
}
return false, fmt.Errorf("confirm form: %w", err)
}
return confirmed, nil
}

// realRunInstaller shells out to the installer command, streaming stdin/stdout/stderr
// so password prompts and progress output reach the user.
func realRunInstaller(ctx context.Context, cmdStr string) error {
var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.CommandContext(ctx, "cmd", "/C", cmdStr)
} else {
c = exec.CommandContext(ctx, "sh", "-c", cmdStr)
}
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
if err := c.Run(); err != nil {
return fmt.Errorf("installer exited: %w", err)
}
return nil
}
Loading
Loading