Add isInteractive helper to detect tty and env var

Amolith created

The helper detects whether keld is running in an interactive environment
by checking if stdin is a terminal and whether KELD_NONINTERACTIVE is
set. This mirrors the same check bubbletea performs internally and will
guard the TUI entry points against non-interactive invocations like
systemd timers.

The function is injectable for testing via the isStdinTerminal package
variable.

Task: td-47ZDQ11

Change summary

cmd/interactive.go      | 23 +++++++++++++
cmd/interactive_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++
go.mod                  | 10 ++--
3 files changed, 102 insertions(+), 5 deletions(-)

Detailed changes

cmd/interactive.go 🔗

@@ -0,0 +1,23 @@
+package cmd
+
+import (
+	"os"
+
+	"github.com/charmbracelet/x/term"
+)
+
+// isStdinTerminal is a package-level variable so tests can override it.
+// Production code uses term.IsTerminal(os.Stdin.Fd()).
+var isStdinTerminal = func() bool {
+	return term.IsTerminal(os.Stdin.Fd())
+}
+
+// isInteractive reports whether keld should launch its TUI.
+// It returns false when stdin is not a terminal or when
+// KELD_NONINTERACTIVE is set to a non-empty value.
+func isInteractive() bool {
+	if os.Getenv("KELD_NONINTERACTIVE") != "" {
+		return false
+	}
+	return isStdinTerminal()
+}

cmd/interactive_test.go 🔗

@@ -0,0 +1,74 @@
+package cmd
+
+import (
+	"testing"
+)
+
+func TestIsInteractive(t *testing.T) {
+	tests := []struct {
+		name            string
+		stdinIsTerminal bool
+		envValue        string // empty string means env is cleared/unset
+		want            bool
+	}{
+		{
+			name:            "terminal with empty env returns true",
+			stdinIsTerminal: true,
+			envValue:        "",
+			want:            true,
+		},
+		{
+			name:            "terminal with env=1 returns false",
+			stdinIsTerminal: true,
+			envValue:        "1",
+			want:            false,
+		},
+		{
+			name:            "terminal with env=true returns false",
+			stdinIsTerminal: true,
+			envValue:        "true",
+			want:            false,
+		},
+		{
+			name:            "non-terminal with empty env returns false",
+			stdinIsTerminal: false,
+			envValue:        "",
+			want:            false,
+		},
+		{
+			name:            "non-terminal with env=1 returns false",
+			stdinIsTerminal: false,
+			envValue:        "1",
+			want:            false,
+		},
+		{
+			name:            "terminal with env=0 returns false",
+			stdinIsTerminal: true,
+			envValue:        "0",
+			want:            false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Save and restore the original function.
+			original := isStdinTerminal
+			t.Cleanup(func() {
+				isStdinTerminal = original
+			})
+
+			// Inject fake terminal check.
+			isStdinTerminal = func() bool {
+				return tt.stdinIsTerminal
+			}
+
+			// Set env var. t.Setenv("", "") clears/unsets it.
+			t.Setenv("KELD_NONINTERACTIVE", tt.envValue)
+
+			got := isInteractive()
+			if got != tt.want {
+				t.Errorf("isInteractive() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

go.mod 🔗

@@ -3,16 +3,20 @@ module git.secluded.site/keld
 go 1.26.1
 
 require (
+	charm.land/bubbles/v2 v2.0.0
 	charm.land/bubbletea/v2 v2.0.2
 	charm.land/fang/v2 v2.0.1
 	charm.land/huh/v2 v2.0.3
 	charm.land/lipgloss/v2 v2.0.2
 	github.com/BurntSushi/toml v1.6.0
+	github.com/charmbracelet/x/term v0.2.2
+	github.com/dustin/go-humanize v1.0.1
+	github.com/lrstanley/bubbletint/v2 v2.0.1
 	github.com/spf13/cobra v1.10.2
+	github.com/spf13/pflag v1.0.9
 )
 
 require (
-	charm.land/bubbles/v2 v2.0.0 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/catppuccin/go v0.3.0 // indirect
 	github.com/charmbracelet/colorprofile v0.4.2 // indirect
@@ -21,14 +25,11 @@ require (
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
 	github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
 	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
-	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/clipperhouse/displaywidth v0.11.0 // indirect
 	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
-	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
-	github.com/lrstanley/bubbletint/v2 v2.0.1 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/mattn/go-runewidth v0.0.20 // indirect
 	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
@@ -38,7 +39,6 @@ require (
 	github.com/muesli/mango-pflag v0.1.0 // indirect
 	github.com/muesli/roff v0.1.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
-	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/sync v0.19.0 // indirect
 	golang.org/x/sys v0.42.0 // indirect