diff --git a/cmd/entire/cli/interactive/interactive.go b/cmd/entire/cli/interactive/interactive.go index e0b0eca8ae..434568330b 100644 --- a/cmd/entire/cli/interactive/interactive.go +++ b/cmd/entire/cli/interactive/interactive.go @@ -15,7 +15,7 @@ import ( // - EnvTestTTY=1 → CanPromptInteractively returns true. // - EnvTestTTY set to any other value → returns false. // - EnvTestTTY unset → real detection via testing.Testing(), agent -// sentinels, CI, then /dev/tty probe. +// sentinels, CI, then a platform-specific prompt terminal probe. const EnvTestTTY = "ENTIRE_TEST_TTY" // CanPromptInteractively reports whether interactive confirmation prompts @@ -30,7 +30,7 @@ const EnvTestTTY = "ENTIRE_TEST_TTY" // Subprocess tests must spawn via execx.NonInteractive (or set EnvTestTTY). // 3. Agent sentinels — vendor-set by agent subprocesses. // 4. CI= — de-facto CI convention. -// 5. /dev/tty probe. +// 5. Platform-specific prompt terminal probe. func CanPromptInteractively() bool { if v := os.Getenv(EnvTestTTY); v != "" { return v == "1" @@ -50,7 +50,7 @@ func CanPromptInteractively() bool { return false } - tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + tty, err := OpenPromptTTY() if err != nil { return false } @@ -60,8 +60,8 @@ func CanPromptInteractively() bool { // UnderTest reports whether the process is running in a test context — either // inside `go test` (testing.Testing()) or with EnvTestTTY explicitly set. Use -// to skip operations that read from the real terminal (e.g. opening /dev/tty) -// even when CanPromptInteractively() returns true. +// to skip operations that read from the real terminal even when +// CanPromptInteractively() returns true. func UnderTest() bool { return testing.Testing() || os.Getenv(EnvTestTTY) != "" } diff --git a/cmd/entire/cli/interactive/tty.go b/cmd/entire/cli/interactive/tty.go new file mode 100644 index 0000000000..5c94f03929 --- /dev/null +++ b/cmd/entire/cli/interactive/tty.go @@ -0,0 +1,41 @@ +package interactive + +import ( + "fmt" + "os" +) + +// PromptTTY is a platform prompt terminal opened for both input and output. +type PromptTTY struct { + in *os.File + out *os.File +} + +// Read reads prompt input. +func (t *PromptTTY) Read(p []byte) (int, error) { + n, err := t.in.Read(p) + if err != nil { + return n, fmt.Errorf("read prompt terminal: %w", err) + } + return n, nil +} + +// Write writes prompt output. +func (t *PromptTTY) Write(p []byte) (int, error) { + n, err := t.out.Write(p) + if err != nil { + return n, fmt.Errorf("write prompt terminal: %w", err) + } + return n, nil +} + +// Close closes the prompt terminal handles. +func (t *PromptTTY) Close() error { + err := t.in.Close() + if t.out != t.in { + if outErr := t.out.Close(); err == nil { + err = outErr + } + } + return err +} diff --git a/cmd/entire/cli/interactive/tty_other.go b/cmd/entire/cli/interactive/tty_other.go new file mode 100644 index 0000000000..dda15f3193 --- /dev/null +++ b/cmd/entire/cli/interactive/tty_other.go @@ -0,0 +1,12 @@ +//go:build !unix && !windows + +package interactive + +import ( + "errors" +) + +// OpenPromptTTY opens the platform prompt terminal for interactive prompts. +func OpenPromptTTY() (*PromptTTY, error) { + return nil, errors.New("prompt terminal is unsupported on this platform") +} diff --git a/cmd/entire/cli/interactive/tty_unix.go b/cmd/entire/cli/interactive/tty_unix.go new file mode 100644 index 0000000000..579315c57f --- /dev/null +++ b/cmd/entire/cli/interactive/tty_unix.go @@ -0,0 +1,17 @@ +//go:build unix + +package interactive + +import ( + "fmt" + "os" +) + +// OpenPromptTTY opens the controlling terminal for interactive prompts. +func OpenPromptTTY() (*PromptTTY, error) { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return nil, fmt.Errorf("open /dev/tty: %w", err) + } + return &PromptTTY{in: tty, out: tty}, nil +} diff --git a/cmd/entire/cli/interactive/tty_windows.go b/cmd/entire/cli/interactive/tty_windows.go new file mode 100644 index 0000000000..c4c060f29e --- /dev/null +++ b/cmd/entire/cli/interactive/tty_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package interactive + +import ( + "fmt" + "os" +) + +// OpenPromptTTY opens the Windows console devices for interactive prompts. +func OpenPromptTTY() (*PromptTTY, error) { + in, err := os.OpenFile("CONIN$", os.O_RDONLY, 0) + if err != nil { + return nil, fmt.Errorf("open CONIN$: %w", err) + } + out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) + if err != nil { + _ = in.Close() + return nil, fmt.Errorf("open CONOUT$: %w", err) + } + return &PromptTTY{in: in, out: out}, nil +} diff --git a/cmd/entire/cli/login.go b/cmd/entire/cli/login.go index e6d799986e..aa622b2dd9 100644 --- a/cmd/entire/cli/login.go +++ b/cmd/entire/cli/login.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/url" - "os" "os/exec" "runtime" "time" @@ -81,7 +80,7 @@ func runLogin(ctx context.Context, outW, errW io.Writer, client deviceAuthClient fmt.Fprintf(outW, "Login URL: %s\n\n", approvalURL) fmt.Fprintf(outW, "Press Enter to open in browser...") - // Read from /dev/tty so we get a real keypress and don't consume piped stdin. + // Read from the prompt terminal so we get a real keypress and don't consume piped stdin. if err := waitForEnter(ctx); err != nil { return fmt.Errorf("wait for input: %w", err) } @@ -250,13 +249,13 @@ func waitForApproval(ctx context.Context, poller deviceAuthClient, deviceCode st } } -// waitForEnter reads a line from /dev/tty, blocking until the user presses Enter. -// If /dev/tty cannot be opened (e.g. on Windows), it returns immediately. +// waitForEnter reads a line from the prompt terminal, blocking until the user presses Enter. +// If the prompt terminal cannot be opened, it returns immediately. // Returns ctx.Err() if the context is cancelled before the user presses Enter. func waitForEnter(ctx context.Context) error { - tty, err := os.Open("/dev/tty") + tty, err := interactive.OpenPromptTTY() if err != nil { - return nil //nolint:nilerr // tty unavailable (e.g. Windows) — skip prompt silently + return nil //nolint:nilerr // tty unavailable — skip prompt silently } done := make(chan error, 1) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 61f77240f6..f923de59b5 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -47,7 +47,7 @@ const ( ttyResultLinkAlways // Link and remember: add trailer + save "always" preference ) -// askConfirmTTY prompts the user via /dev/tty whether to link a commit to session context. +// askConfirmTTY prompts the user via the prompt terminal whether to link a commit to session context. // This requires a controlling terminal — callers must check // interactive.CanPromptInteractively() first and handle the no-TTY case // (agent subprocesses, CI) themselves. @@ -65,10 +65,10 @@ func askConfirmTTY(header string, details []string, prompt string, defaultYes bo return defaultResult } - // Open /dev/tty for both reading and writing. - // This is the controlling terminal, which works even when stdin/stderr are redirected - // (e.g., human runs git commit -m where stdin is not a pipe). - tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + // Open the prompt terminal for both reading and writing. This works even + // when stdin/stderr are redirected (e.g., human runs git commit -m where + // stdin is not a pipe). + tty, err := interactive.OpenPromptTTY() if err != nil { return defaultResult }