diff --git a/internal/appctx/context.go b/internal/appctx/context.go index 9c4197f65..3107418ce 100644 --- a/internal/appctx/context.go +++ b/internal/appctx/context.go @@ -340,6 +340,12 @@ func (a *App) printStatsToStderr(stats *observability.SessionMetrics) { // IsInteractive returns true if the terminal supports interactive TUI. func (a *App) IsInteractive() bool { + // Explicit escape hatch: BASECAMP_NONINTERACTIVE forces non-interactive mode + // even under a PTY, without changing the output format. + if config.NonInteractiveEnv() { + return false + } + // Not interactive if any non-interactive output mode is set if a.Flags.Agent || a.Flags.JSON || a.Flags.Quiet || a.Flags.IDsOnly || a.Flags.Count || a.Flags.JQFilter != "" { return false diff --git a/internal/appctx/context_test.go b/internal/appctx/context_test.go index e0a894ebb..c72c5c601 100644 --- a/internal/appctx/context_test.go +++ b/internal/appctx/context_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" @@ -168,6 +169,37 @@ func TestIsInteractiveWithCountMode(t *testing.T) { assert.False(t, app.IsInteractive(), "should not be interactive in count mode") } +func TestIsInteractiveWithNonInteractiveEnv(t *testing.T) { + // Swap os.Stdout to the null device — a char device that passes the + // ModeCharDevice guard — so IsInteractive() would otherwise return true. + // Without this, go test's piped stdout makes IsInteractive() false regardless + // of the env var, and the assertion would pass even if the short-circuit were + // removed. + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Skip(os.DevNull + " not available") + } + origStdout := os.Stdout + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = origStdout + devNull.Close() + }) + + cfg := &config.Config{} + app := NewApp(cfg) + + // Baseline: char-device stdout, no env/flags → interactive. + t.Setenv("BASECAMP_NONINTERACTIVE", "") + require.True(t, app.IsInteractive(), "char-device stdout should be interactive without the escape hatch") + + // The env escape hatch forces non-interactive even with an interactive stdout. + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + assert.False(t, app.IsInteractive(), "BASECAMP_NONINTERACTIVE should force non-interactive") + // Output format is untouched — the escape hatch only disables prompts. + assert.False(t, app.IsMachineOutput(), "escape hatch must not change output mode") +} + func TestNewAppWithFormatConfig(t *testing.T) { tests := []struct { format string diff --git a/internal/cli/root.go b/internal/cli/root.go index 34dfe31d5..3c8b89986 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -474,14 +474,19 @@ func profileNames(cfg *config.Config) string { return strings.Join(names, ", ") } -// isInteractiveTTY returns true if stdout is a terminal and no machine-output mode is set. +// isInteractiveTTY returns true if stdout is a character device (e.g. a +// terminal) and no noninteractive mode is set. func isInteractiveTTY(flags appctx.GlobalFlags) bool { - // Not interactive if any machine-output mode is set - if flags.Agent || flags.JSON || flags.Quiet || flags.IDsOnly || flags.Count { + if config.NonInteractiveEnv() { return false } - // Check if stdout is a terminal + // Not interactive if any machine-output mode is set (--jq implies --json) + if flags.Agent || flags.JSON || flags.Quiet || flags.IDsOnly || flags.Count || flags.JQFilter != "" { + return false + } + + // Check if stdout is a character device (e.g. a terminal) fi, err := os.Stdout.Stat() if err != nil { return false diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 5f3fd9052..ad8a02035 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "os" "testing" "github.com/spf13/cobra" @@ -138,6 +139,25 @@ func isolateRootTest(t *testing.T) { t.Setenv("XDG_CACHE_HOME", t.TempDir()) } +func TestIsInteractiveTTYWithNonInteractiveEnv(t *testing.T) { + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Skip(os.DevNull + " not available") + } + origStdout := os.Stdout + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = origStdout + devNull.Close() + }) + + t.Setenv("BASECAMP_NONINTERACTIVE", "") + require.True(t, isInteractiveTTY(appctx.GlobalFlags{})) + + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + assert.False(t, isInteractiveTTY(appctx.GlobalFlags{})) +} + func TestJQInvalidExpressionRejectedBeforeRunE(t *testing.T) { isolateRootTest(t) diff --git a/internal/commands/chat.go b/internal/commands/chat.go index 206536e29..5a8e1d770 100644 --- a/internal/commands/chat.go +++ b/internal/commands/chat.go @@ -798,7 +798,7 @@ You can pass either a line ID or a Basecamp line URL: } // Confirm destructive action in interactive mode - if !force && !isMachineOutput(cmd) { + if !force && !isNonInteractiveCommand(cmd) { confirmed, err := tui.ConfirmDangerous("Permanently delete this chat line?") if err != nil { return nil //nolint:nilerr // user canceled prompt diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 5e3d457e7..0e81dd215 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -13,18 +13,19 @@ import ( "github.com/spf13/cobra" "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/config" "github.com/basecamp/basecamp-cli/internal/names" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/richtext" "github.com/basecamp/basecamp-cli/internal/urlarg" ) -// missingArg shows help in interactive TTY mode, returns a structured -// usage error naming the missing argument in machine/agent mode. +// missingArg shows help in interactive TTY mode, returns a structured usage +// error naming the missing argument in non-interactive command mode. // The hint includes both the usage pattern and a concrete example // (if cmd.Example is set). func missingArg(cmd *cobra.Command, arg string) error { - if isMachineOutput(cmd) { + if isNonInteractiveCommand(cmd) { hint := "Usage: " + cmd.UseLine() if cmd.Example != "" { if first, _, ok := strings.Cut(cmd.Example, "\n"); ok { @@ -38,10 +39,10 @@ func missingArg(cmd *cobra.Command, arg string) error { return cmd.Help() } -// noChanges shows help in interactive TTY mode, returns a structured -// usage error in machine/agent mode when an update command has no fields. +// noChanges shows help in interactive TTY mode, returns a structured usage +// error in non-interactive command mode when an update command has no fields. func noChanges(cmd *cobra.Command) error { - if isMachineOutput(cmd) { + if isNonInteractiveCommand(cmd) { hint := "Usage: " + cmd.UseLine() if cmd.Example != "" { if first, _, ok := strings.Cut(cmd.Example, "\n"); ok { @@ -55,8 +56,14 @@ func noChanges(cmd *cobra.Command) error { return cmd.Help() } -// isMachineOutput returns true when the command is running in a non-interactive -// context: --agent, --json, --quiet, piped stdout, etc. +// isNonInteractiveCommand returns true when command-level flows should avoid +// human prompts or help screens, without implying a machine output format. +func isNonInteractiveCommand(cmd *cobra.Command) bool { + return config.NonInteractiveEnv() || isMachineOutput(cmd) +} + +// isMachineOutput returns true when the command output is intended for machine +// consumption: --agent, --json, --quiet, piped stdout, etc. func isMachineOutput(cmd *cobra.Command) bool { if app := appctx.FromContext(cmd.Context()); app != nil { if app.IsMachineOutput() { diff --git a/internal/commands/helpers_test.go b/internal/commands/helpers_test.go index 24dc9facd..80cb5a596 100644 --- a/internal/commands/helpers_test.go +++ b/internal/commands/helpers_test.go @@ -209,6 +209,14 @@ func TestIsMachineOutput_JSONFlag(t *testing.T) { assert.True(t, isMachineOutput(cmd)) } +func TestIsNonInteractiveCommand_NonInteractiveEnv(t *testing.T) { + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + + cmd := newTestCmd(false, "") + assert.True(t, isNonInteractiveCommand(cmd)) + assert.False(t, isMachineOutput(cmd)) +} + func TestMissingArg_MultiLineExample(t *testing.T) { cmd := newTestCmd(true, " basecamp test \"first\"\n basecamp test \"second\"") diff --git a/internal/config/config.go b/internal/config/config.go index ac649f202..e9662ac8c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -390,6 +390,21 @@ func LoadFromEnv(cfg *Config) { } } +// NonInteractiveEnv reports whether BASECAMP_NONINTERACTIVE is set to a truthy +// value. When true, the CLI must not show interactive prompts regardless of TTY +// detection. This is an escape hatch for agents and harnesses that run the CLI +// under an allocated PTY (where stdout looks like a terminal) and want to avoid +// a selection prompt wedging the session — without forcing a machine output +// format the way --agent does. +func NonInteractiveEnv() bool { + if v := os.Getenv("BASECAMP_NONINTERACTIVE"); v != "" { + if b, ok := parseEnvBool(v); ok { + return b + } + } + return false +} + // parseEnvBool parses a boolean environment variable strictly. // Returns (value, true) for recognized values, (false, false) for unrecognized. // Unrecognized values are ignored to preserve three-state pointer semantics. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4ecf98950..60d9244b9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1061,3 +1061,28 @@ func TestPreferencesUnsetInFile(t *testing.T) { assert.Nil(t, cfg.Stats) assert.Nil(t, cfg.Verbose) } + +func TestNonInteractiveEnv(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {"1", true}, + {"true", true}, + {"TRUE", true}, + {"0", false}, + {"false", false}, + {"banana", false}, // unrecognized values are ignored + {"", false}, // unset + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + // t.Setenv restores the prior value automatically. NonInteractiveEnv + // treats an empty value as unset, so "" covers the unset case without + // os.Unsetenv (which would bypass testing.T's restoration). + t.Setenv("BASECAMP_NONINTERACTIVE", tt.value) + assert.Equal(t, tt.want, NonInteractiveEnv()) + }) + } +} diff --git a/internal/tui/resolve/resolve.go b/internal/tui/resolve/resolve.go index 60e36a425..3018737aa 100644 --- a/internal/tui/resolve/resolve.go +++ b/internal/tui/resolve/resolve.go @@ -101,10 +101,18 @@ func (r *Resolver) Flags() *Flags { } // IsInteractive returns true if interactive prompts can be shown. -// This checks both TTY status and machine-output flags. -// Returns false if any machine-output flag is set (--agent, --json, --quiet, --ids-only, --count) -// or if stdout is not a terminal. +// This checks both stdout and machine-output flags. +// Returns false if BASECAMP_NONINTERACTIVE is set, if any machine-output flag is +// set (--agent, --json, --quiet, --ids-only, --count), or if stdout is not a +// character device (the guard treats any char device — a terminal, /dev/null, +// etc. — as interactive-capable). func (r *Resolver) IsInteractive() bool { + // Explicit escape hatch: BASECAMP_NONINTERACTIVE forces non-interactive mode + // even under a PTY, without changing the output format. + if config.NonInteractiveEnv() { + return false + } + // Check machine-output flags first if r.flags != nil { if r.flags.Agent || r.flags.JSON || r.flags.Quiet || r.flags.IDsOnly || r.flags.Count { @@ -112,7 +120,7 @@ func (r *Resolver) IsInteractive() bool { } } - // Check if stdout is a terminal + // Check if stdout is a character device (e.g. a terminal) fi, err := os.Stdout.Stat() if err != nil { return false diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index e5d5b78a2..769ec22a4 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -112,6 +112,8 @@ Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule, Always pass `--json` or `--md` explicitly — auto-detection depends on config and may not produce the format you expect. Use `--md` when composing reports, summarizing data, or displaying results inline. `--agent` is for headless integration scripts. +**Avoiding interactive prompts.** The flags `--agent`/`--json`/`--quiet`/`--ids-only`/`--count` and the environment variable `BASECAMP_NONINTERACTIVE=1` suppress interactive selection prompts. `--md` does **not** — if a required target is ambiguous (e.g. a project with multiple todosets and no `--todoset`), and the CLI is attached to a terminal, it will show a blocking picker. When you need Markdown output *and* no prompts, either pass the flag that names whatever is ambiguous (`--todoset ` for the todoset case above, or `--in ` / `--list ` when the project or list is ambiguous) or set `BASECAMP_NONINTERACTIVE=1` in the environment. `BASECAMP_NONINTERACTIVE` disables all prompts (they become actionable errors instead) without changing the output format — an escape hatch for agents running under a PTY. + **Other modes:** `--quiet` (success: raw JSON, no envelope; errors: `{ok:false,...}`), `--ids-only`, `--count`, `--stats` (session statistics), `--styled` (force ANSI), `-v` / `-vv` (verbose/trace), `--jq ''` (built-in jq filter — see below). ### CLI Introspection