From 3694571010802e707c1be77b4fba1027a14e258b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 2 Jul 2026 22:14:42 -0400 Subject: [PATCH 1/6] Add BASECAMP_NONINTERACTIVE escape hatch for prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the machine-output flags (--agent/--json/--quiet/--ids-only/--count) suppress interactive selection prompts. --md does not, so an agent that requests Markdown output and runs under an allocated PTY (where stdout looks like a terminal) can be wedged by a blocking picker when a target is ambiguous — e.g. a project with multiple todosets and no --todoset. Add BASECAMP_NONINTERACTIVE as an explicit escape hatch. When set to a truthy value it forces IsInteractive() to false in both gates (appctx and resolve), turning ambiguous resolutions into actionable errors instead of prompts — without changing the output format the way --agent does. Setting it is the environment/harness's responsibility, not the model's, so it does not rely on the LLM remembering a flag per invocation. Refs #395 --- internal/appctx/context.go | 6 ++++++ internal/appctx/context_test.go | 11 +++++++++++ internal/config/config.go | 15 +++++++++++++++ internal/config/config_test.go | 26 ++++++++++++++++++++++++++ internal/tui/resolve/resolve.go | 10 ++++++++-- skills/basecamp/SKILL.md | 2 ++ 6 files changed, 68 insertions(+), 2 deletions(-) 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..10ffa1a1d 100644 --- a/internal/appctx/context_test.go +++ b/internal/appctx/context_test.go @@ -168,6 +168,17 @@ func TestIsInteractiveWithCountMode(t *testing.T) { assert.False(t, app.IsInteractive(), "should not be interactive in count mode") } +func TestIsInteractiveWithNonInteractiveEnv(t *testing.T) { + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + cfg := &config.Config{} + app := NewApp(cfg) + + // No machine-output flag set, but the env escape hatch forces non-interactive. + 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/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..ee518d0f0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1061,3 +1061,29 @@ 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) { + if tt.value == "" { + os.Unsetenv("BASECAMP_NONINTERACTIVE") + } else { + 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..b6a5d6b4f 100644 --- a/internal/tui/resolve/resolve.go +++ b/internal/tui/resolve/resolve.go @@ -102,9 +102,15 @@ 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. +// 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 terminal. 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 { diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index e5d5b78a2..ab98d5afd 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.** Only `--agent`/`--json`/`--quiet`/`--ids-only`/`--count` 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 disambiguating flag (`--todoset `, `--list `, `--in `) 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 From 986e96c19eb84b28a52cf36f21415be7a0756b21 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 2 Jul 2026 22:24:02 -0400 Subject: [PATCH 2/6] Address PR review: harden tests and fix SKILL wording - context_test: swap stdout to /dev/null (a char device) so the test actually exercises the BASECAMP_NONINTERACTIVE short-circuit instead of passing because go test's stdout is a pipe. Baseline require.True asserts the interactive path, then the env var flips it to false. - config_test: use t.Setenv("") for the unset case instead of os.Unsetenv, so testing.T restores the prior value (no env leak into other tests). - SKILL.md: include BASECAMP_NONINTERACTIVE in the prompt-suppression list to remove the "Only suppress prompts" contradiction. --- internal/appctx/context_test.go | 24 ++++++++++++++++++++++-- internal/config/config_test.go | 9 ++++----- skills/basecamp/SKILL.md | 2 +- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/internal/appctx/context_test.go b/internal/appctx/context_test.go index 10ffa1a1d..b48bababf 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" @@ -169,11 +170,30 @@ func TestIsInteractiveWithCountMode(t *testing.T) { } func TestIsInteractiveWithNonInteractiveEnv(t *testing.T) { - t.Setenv("BASECAMP_NONINTERACTIVE", "1") + // Swap os.Stdout to /dev/null — 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("/dev/null") + if err != nil { + t.Skip("/dev/null not available") + } + origStdout := os.Stdout + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = origStdout + devNull.Close() + }) + cfg := &config.Config{} app := NewApp(cfg) - // No machine-output flag set, but the env escape hatch forces non-interactive. + // 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") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ee518d0f0..60d9244b9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1078,11 +1078,10 @@ func TestNonInteractiveEnv(t *testing.T) { for _, tt := range tests { t.Run(tt.value, func(t *testing.T) { - if tt.value == "" { - os.Unsetenv("BASECAMP_NONINTERACTIVE") - } else { - t.Setenv("BASECAMP_NONINTERACTIVE", tt.value) - } + // 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/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index ab98d5afd..68ee10eda 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -112,7 +112,7 @@ 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.** Only `--agent`/`--json`/`--quiet`/`--ids-only`/`--count` 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 disambiguating flag (`--todoset `, `--list `, `--in `) 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. +**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 disambiguating flag (`--todoset `, `--list `, `--in `) 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). From 6dc8510c2e83331d02db35be6e885213ad7c1e83 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 2 Jul 2026 22:44:30 -0400 Subject: [PATCH 3/6] Honor noninteractive env for profile picker --- internal/cli/root.go | 7 ++++++- internal/cli/root_test.go | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 34dfe31d5..3c249b107 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -474,8 +474,13 @@ 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 terminal and no noninteractive +// mode is set. func isInteractiveTTY(flags appctx.GlobalFlags) bool { + if config.NonInteractiveEnv() { + return false + } + // Not interactive if any machine-output mode is set if flags.Agent || flags.JSON || flags.Quiet || flags.IDsOnly || flags.Count { return false diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 5f3fd9052..86603e761 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("/dev/null") + if err != nil { + t.Skip("/dev/null 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) From e4dfbf7dda60dda91e0046199b22c9e197425401 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 2 Jul 2026 23:09:37 -0400 Subject: [PATCH 4/6] Address PR review: --jq gates profile picker; use os.DevNull - root.go: isInteractiveTTY now treats --jq (JQFilter) as machine output, matching App.IsInteractive. Without it, --jq + multiple profiles could still hit the interactive profile picker despite --jq implying --json. - root_test.go, context_test.go: use os.DevNull instead of a hard-coded "/dev/null" so the tests stay meaningful on non-Unix platforms. --- internal/appctx/context_test.go | 13 +++++++------ internal/cli/root.go | 4 ++-- internal/cli/root_test.go | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/appctx/context_test.go b/internal/appctx/context_test.go index b48bababf..c72c5c601 100644 --- a/internal/appctx/context_test.go +++ b/internal/appctx/context_test.go @@ -170,13 +170,14 @@ func TestIsInteractiveWithCountMode(t *testing.T) { } func TestIsInteractiveWithNonInteractiveEnv(t *testing.T) { - // Swap os.Stdout to /dev/null — 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("/dev/null") + // 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("/dev/null not available") + t.Skip(os.DevNull + " not available") } origStdout := os.Stdout os.Stdout = devNull diff --git a/internal/cli/root.go b/internal/cli/root.go index 3c249b107..42a56929e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -481,8 +481,8 @@ func isInteractiveTTY(flags appctx.GlobalFlags) bool { return false } - // Not interactive if any machine-output mode is set - if flags.Agent || flags.JSON || flags.Quiet || flags.IDsOnly || flags.Count { + // 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 } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 86603e761..ad8a02035 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -140,9 +140,9 @@ func isolateRootTest(t *testing.T) { } func TestIsInteractiveTTYWithNonInteractiveEnv(t *testing.T) { - devNull, err := os.Open("/dev/null") + devNull, err := os.Open(os.DevNull) if err != nil { - t.Skip("/dev/null not available") + t.Skip(os.DevNull + " not available") } origStdout := os.Stdout os.Stdout = devNull From ae27765ea6846bb4ad0fa7570c220b8578e9f7d8 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 2 Jul 2026 23:37:22 -0400 Subject: [PATCH 5/6] Honor noninteractive env for command prompts --- internal/commands/chat.go | 2 +- internal/commands/helpers.go | 23 +++++++++++++++-------- internal/commands/helpers_test.go | 8 ++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) 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\"") From 58180e5b7e4d9208d2485e18c8a2222038d6e02a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 2 Jul 2026 23:43:28 -0400 Subject: [PATCH 6/6] Address PR review: clarify char-device wording in docs - resolve.go, root.go: doc/inline comments said "terminal", but the guard checks os.ModeCharDevice, which also matches non-terminal char devices like /dev/null (the tests rely on this). Reword to say "character device (e.g. a terminal)". - SKILL.md: the disambiguation list implied --in resolves the "multiple todosets" ambiguity. Reword so each flag maps to the ambiguity it actually resolves. --- internal/cli/root.go | 6 +++--- internal/tui/resolve/resolve.go | 8 +++++--- skills/basecamp/SKILL.md | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 42a56929e..3c8b89986 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -474,8 +474,8 @@ func profileNames(cfg *config.Config) string { return strings.Join(names, ", ") } -// isInteractiveTTY returns true if stdout is a terminal and no noninteractive -// 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 { if config.NonInteractiveEnv() { return false @@ -486,7 +486,7 @@ func isInteractiveTTY(flags appctx.GlobalFlags) bool { return false } - // 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/internal/tui/resolve/resolve.go b/internal/tui/resolve/resolve.go index b6a5d6b4f..3018737aa 100644 --- a/internal/tui/resolve/resolve.go +++ b/internal/tui/resolve/resolve.go @@ -101,9 +101,11 @@ func (r *Resolver) Flags() *Flags { } // IsInteractive returns true if interactive prompts can be shown. -// This checks both TTY status and machine-output flags. +// 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 terminal. +// 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. @@ -118,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 68ee10eda..769ec22a4 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -112,7 +112,7 @@ 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 disambiguating flag (`--todoset `, `--list `, `--in `) 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. +**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).