Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions internal/appctx/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
Expand Down Expand Up @@ -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)

Comment thread
robzolkos marked this conversation as resolved.
// 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")
Comment thread
robzolkos marked this conversation as resolved.
// 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
Expand Down
13 changes: 9 additions & 4 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
robzolkos marked this conversation as resolved.
}

// Check if stdout is a character device (e.g. a terminal)
fi, err := os.Stdout.Stat()
if err != nil {
return false
Expand Down
20 changes: 20 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"os"
"testing"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 15 additions & 8 deletions internal/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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() {
Expand Down
8 changes: 8 additions & 0 deletions internal/commands/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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\"")

Expand Down
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
}
}
16 changes: 12 additions & 4 deletions internal/tui/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,26 @@ 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 {
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
Expand Down
2 changes: 2 additions & 0 deletions skills/basecamp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` for the todoset case above, or `--in <project>` / `--list <id>` 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 '<expr>'` (built-in jq filter — see below).

### CLI Introspection
Expand Down
Loading