Skip to content
92 changes: 92 additions & 0 deletions e2e/stdin_dash.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bats
# stdin_dash.bats - "-" (read from stdin) support and the tier-2 dash guard.
#
# Tier 1: content inputs accept "-" to read piped stdin. Tier 2: everywhere
# else, a literal "-" combined with piped stdin is a usage error instead of
# silently becoming literal content. Every case resolves locally — usage
# errors before any request, or a config write — so no cassette or server
# is needed.

load test_helper


# Tier 1 — "-" resolves against stdin

@test "todos create - with empty pipe is a usage error, not an empty todo" {
create_credentials
create_global_config '{"account_id": 99999, "project_id": 123}'

run bash -c "printf '' | basecamp todos create - --json"
assert_failure
assert_json_value '.code' 'usage'
assert_output_contains "empty"
}

@test "comments create - on a TTY-like stdin errors immediately instead of hanging" {
create_credentials
create_global_config '{"account_id": 99999, "project_id": 123}'

# /dev/null is a character device — the TTY stand-in. Must not block.
run bash -c "basecamp comments create 123 - --json < /dev/null"
assert_failure
assert_json_value '.code' 'usage'
assert_output_contains "nothing is piped"
}

@test "comments create with a bare pipe and no dash teaches the dash" {
create_credentials
create_global_config '{"account_id": 99999, "project_id": 123}'

run bash -c "printf 'hello' | basecamp comments create 123 --json"
assert_failure
assert_json_value '.code' 'usage'
assert_json_value '.hint | contains("pass \"-\"")' 'true'
}


# Tier 2 — stray literal "-" with a pipe is rejected

@test "projects create - with piped stdin is rejected with the -- escape" {
create_credentials
create_global_config '{"account_id": 99999}'

run bash -c "printf 'x' | basecamp projects create - --json"
assert_failure
assert_json_value '.code' 'usage'
assert_json_value '.error | contains("<name>")' 'true'
assert_json_value '.hint | contains("after the -- separator")' 'true'
}

@test "a -- - after the separator passes the guard and lands literally" {
create_credentials
create_global_config '{"account_id": 99999}'

# config set writes locally — a deterministic success proving the escaped
# "-" passed the guard and was stored as a literal value.
run bash -c "cd '$TEST_PROJECT' && printf 'x' | basecamp config set project_id --json -- -"
assert_success
assert_json_value '.data.value' '-'

run bash -c "cd '$TEST_PROJECT' && basecamp config show --json < /dev/null"
assert_success
assert_json_value '.data.project_id.value' '-'
}

@test "a piped bare dash at the root is rejected, not silently quick-started" {
create_credentials
create_global_config '{"account_id": 99999}'

run bash -c "printf 'x' | basecamp - --json"
assert_failure
assert_json_value '.code' 'usage'
assert_json_value '.error | contains("does not read stdin")' 'true'
}

@test "basecamp unknowncmd still reports an unknown command" {
create_credentials
create_global_config '{"account_id": 99999}'

run bash -c "basecamp unknowncmd --json < /dev/null"
assert_failure
assert_output_contains "unknown command"
}
13 changes: 6 additions & 7 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/observability"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/resilience"
"github.com/basecamp/basecamp-cli/internal/stdinarg"
"github.com/basecamp/basecamp-cli/internal/tui/resolve"
"github.com/basecamp/basecamp-cli/internal/version"
)
Expand Down Expand Up @@ -374,13 +375,11 @@ func (a *App) IsInteractive() bool {
return false
}

// Check if stdout is a terminal
fi, err := os.Stdout.Stat()
if err != nil {
return false
}

return (fi.Mode() & os.ModeCharDevice) != 0
// Both stdout and stdin must be character devices: a TUI draws to stdout
// and reads keystrokes from stdin, so a pipe on either end can never
// drive one — and when the command is consuming piped content (a "-"
// stdin input), a TUI would eat that content as key events.
return stdinarg.InteractiveStdio()
}

// WithApp stores the app in the context.
Expand Down
87 changes: 87 additions & 0 deletions internal/cli/cobra_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package cli

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"

"github.com/basecamp/basecamp-cli/internal/output"
)

// Cobra's arity messages are usage failures by construction. They used to fall
// through to the default classification (api_error, exit 7), which tells an
// agent to retry a call that can never succeed.
func TestTransformCobraErrorClassifiesArityAsUsage(t *testing.T) {
for _, msg := range []string{
"accepts at most 2 arg(s), received 3",
"accepts 1 arg(s), received 2",
"accepts between 1 and 2 arg(s), received 4",
} {
t.Run(msg, func(t *testing.T) {
err := transformCobraError(errors.New(msg))

var outErr *output.Error
require.True(t, errors.As(err, &outErr), "expected *output.Error, got %T", err)
assert.Equal(t, output.CodeUsage, outErr.Code)
assert.Equal(t, msg, outErr.Message, "the wording is already clear; only the code was wrong")
})
}
}

// The zero-arg case keeps its friendlier rewrite.
func TestTransformCobraErrorKeepsZeroArgRewrite(t *testing.T) {
err := transformCobraError(errors.New("accepts 1 arg(s), received 0"))

var outErr *output.Error
require.True(t, errors.As(err, &outErr))
assert.Equal(t, output.CodeUsage, outErr.Code)
assert.Equal(t, "ID required", outErr.Message)
}

// A typed error already carries a code, an HTTP status and a retryable flag.
// Matching on its rendered text would flatten all of that into a bare usage
// string — so an API error that merely quotes an arity phrase is left alone.
func TestTransformCobraErrorPreservesTypedErrors(t *testing.T) {
t.Run("SDK error", func(t *testing.T) {
original := &basecamp.Error{
Code: basecamp.CodeAPI,
Message: "server rejected the payload: accepts 1 arg(s), received 2",
HTTPStatus: 422,
Retryable: true,
}

err := transformCobraError(original)

var sdkErr *basecamp.Error
require.True(t, errors.As(err, &sdkErr), "expected the SDK error to survive, got %T", err)
assert.Equal(t, basecamp.CodeAPI, sdkErr.Code)
assert.Equal(t, 422, sdkErr.HTTPStatus)
assert.True(t, sdkErr.Retryable)
})

t.Run("output error", func(t *testing.T) {
original := output.ErrNotFound("todo", "123")

err := transformCobraError(original)

var outErr *output.Error
require.True(t, errors.As(err, &outErr))
assert.Equal(t, output.CodeNotFound, outErr.Code, "must not be reclassified as usage")
})
}

// Anchored: a command's own error that merely contains the phrase is not an
// arity failure and must keep its own classification path.
func TestTransformCobraErrorIgnoresUnanchoredArityText(t *testing.T) {
msg := "the API said: accepts 1 arg(s), received 2 (and then some)"

err := transformCobraError(errors.New(msg))

var outErr *output.Error
assert.False(t, errors.As(err, &outErr), "should be left untouched, got %T", err)
assert.Equal(t, msg, err.Error())
}
87 changes: 79 additions & 8 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package cli

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/itchyny/gojq"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand All @@ -20,6 +22,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/harness"
"github.com/basecamp/basecamp-cli/internal/hostutil"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/stdinarg"
"github.com/basecamp/basecamp-cli/internal/tui"
"github.com/basecamp/basecamp-cli/internal/version"
)
Expand Down Expand Up @@ -352,6 +355,10 @@ func Execute() {
cmd.AddCommand(commands.NewBonfireCmd())
cmd.AddCommand(commands.NewAgentHookCmd())

// Tier-2 stdin guard: reject a stray literal "-" when stdin is piped,
// everywhere a command doesn't explicitly accept it.
commands.InstallDashGuard(cmd)

// Use ExecuteC to get the executed command (for correct context access)
executedCmd, err := cmd.ExecuteC()

Expand Down Expand Up @@ -513,8 +520,14 @@ func profileNames(cfg *config.Config) string {
return strings.Join(names, ", ")
}

// isInteractiveTTY returns true if stdout is a character device (e.g. a
// terminal) and no noninteractive mode is set.
// isInteractiveTTY reports whether the profile picker may run: no
// noninteractive mode set, and both ends of stdio are character devices.
//
// Stdin counts because the picker is a TUI reading key events, and this runs
// from PersistentPreRunE — before any command touches its own input. Gating on
// stdout alone let "printf body | basecamp todos create -" open the picker on
// a terminal stdout and consume the piped body as keystrokes. Same predicate
// as App.IsInteractive and resolve.Resolver.IsInteractive.
func isInteractiveTTY(flags appctx.GlobalFlags) bool {
if config.NonInteractiveEnv() {
return false
Expand All @@ -525,12 +538,7 @@ func isInteractiveTTY(flags appctx.GlobalFlags) bool {
return false
}

// Check if stdout is a character device (e.g. a terminal)
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
return stdinarg.InteractiveStdio()
}

// promptForProfile shows an interactive picker for profile selection.
Expand Down Expand Up @@ -614,9 +622,27 @@ func isMachineConsumer(root *cobra.Command) bool {
return false
}

// cobraArityError matches cobra's four arity messages exactly (ExactArgs,
// MaximumNArgs, RangeArgs; MinimumNArgs is handled by an earlier rule). Anchored
// so a command's own error that merely quotes the phrase is left alone.
var cobraArityError = regexp.MustCompile(`^accepts (\d+|at most \d+|between \d+ and \d+) arg\(s\), received \d+$`)

// transformCobraError transforms Cobra's default error messages to match the
// Bash CLI format for consistency with existing tests and user expectations.
//
// Only untyped errors are rewritten. An error that already carries a code, an
// HTTP status, a hint or a retryable flag is ours or the SDK's, and matching on
// its rendered text would flatten that metadata into a bare usage string.
func transformCobraError(err error) error {
var outErr *output.Error
if errors.As(err, &outErr) {
return err
}
var sdkErr *basecamp.Error
if errors.As(err, &sdkErr) {
return err
}

msg := err.Error()

// Transform "flag needs an argument: --FLAG" → "--FLAG requires a value"
Expand Down Expand Up @@ -659,6 +685,14 @@ func transformCobraError(err error) error {
return output.ErrUsage("ID required")
}

// Every other cobra arity message ("accepts at most 2 arg(s), received 3")
// is a usage error by construction — only the code was wrong, so agents
// branching on it saw api_error and could retry a call that will never
// succeed. The wording is already clear; keep it and fix the code.
if cobraArityError.MatchString(msg) {
return output.ErrUsage(msg)
}

// Transform "required flag(s) X not set" → more specific message
if strings.HasPrefix(msg, "required flag(s) ") {
re := regexp.MustCompile(`required flag\(s\) "(\w+)" not set`)
Expand Down Expand Up @@ -762,6 +796,12 @@ func emitAgentHelp(cmd *cobra.Command) {
}
}

// Synthesize the stdin note from the allow_dash annotation, so every
// command that accepts "-" auto-documents it.
if note := stdinDashNote(cmd, info.Args); note != "" {
info.Notes = append(info.Notes, note)
}

// Subcommands (include aliases so the CLI surface snapshot tracks them)
for _, sub := range cmd.Commands() {
if sub.IsAvailableCommand() || sub.Name() == "help" {
Expand Down Expand Up @@ -826,3 +866,34 @@ func emitAgentHelp(cmd *cobra.Command) {

_ = json.NewEncoder(cmd.OutOrStdout()).Encode(info)
}

// stdinDashNote renders the "-" (stdin) inputs a command accepts, from its
// allow_dash annotation: positionals by their Use-string names, flags by
// --name. Returns "" when the command reads no stdin input — --out's "-"
// means stdout, so it never appears here.
func stdinDashNote(cmd *cobra.Command, args []ArgInfo) string {
allow := stdinarg.ParseAllow(cmd.Annotations[stdinarg.AnnotationAllowDash])
if allow.Empty() {
return ""
}

var parts []string
for i, a := range args {
if allow.Arg(i) {
if a.Required {
parts = append(parts, "<"+a.Name+">")
} else {
parts = append(parts, "["+a.Name+"]")
}
}
}
for _, token := range strings.Fields(cmd.Annotations[stdinarg.AnnotationAllowDash]) {
if name, ok := strings.CutPrefix(token, "flag:"); ok && name != "out" {
parts = append(parts, "--"+name)
}
}
if len(parts) == 0 {
return ""
}
return "Pass - to read from stdin: " + strings.Join(parts, ", ")
}
Loading
Loading