diff --git a/e2e/stdin_dash.bats b/e2e/stdin_dash.bats new file mode 100644 index 000000000..636d41335 --- /dev/null +++ b/e2e/stdin_dash.bats @@ -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("")' '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" +} diff --git a/internal/appctx/context.go b/internal/appctx/context.go index 53bacef99..54906af87 100644 --- a/internal/appctx/context.go +++ b/internal/appctx/context.go @@ -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" ) @@ -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. diff --git a/internal/cli/cobra_error_test.go b/internal/cli/cobra_error_test.go new file mode 100644 index 000000000..f44ae1b5c --- /dev/null +++ b/internal/cli/cobra_error_test.go @@ -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()) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 49a6c7846..4c22fe7ea 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "sort" "strings" + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/itchyny/gojq" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -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" ) @@ -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() @@ -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 @@ -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. @@ -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" @@ -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`) @@ -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" { @@ -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, ", ") +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 636d03c78..b98763166 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -12,6 +12,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/commands" "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/stdinarg" "github.com/basecamp/basecamp-cli/internal/version" ) @@ -285,3 +286,85 @@ func TestVersionWithJQReturnsUsageError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "--jq is not supported by the version command") } + +// The profile picker runs from PersistentPreRunE, before any command reads its +// own input. Piped stdin can never drive a TUI, and when the invocation is +// feeding a "-" content input the picker would eat that body as keystrokes — +// so a terminal stdout is not on its own enough to open one. +func TestIsInteractiveTTYRequiresUnpipedStdin(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{}), "char-device stdio is interactive") + + reader, writer, err := os.Pipe() + require.NoError(t, err) + origStdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = origStdin + reader.Close() + writer.Close() + }) + + assert.False(t, isInteractiveTTY(appctx.GlobalFlags{}), + "piped stdin must not open the profile picker") +} + +// The root's dash guard hangs off RunE (its Args must stay nil for cobra's +// unknown-command handling), so quick-start's own interactive paths run in the +// same window. The e2e suite always has a piped stdout, which takes the +// machine-output branch and never reaches them — this covers the other side: +// a character-device stdout, the terminal stand-in, with a piped stdin. +func TestRootDashGuardWithTerminalStdout(t *testing.T) { + isolateRootTest(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", "") + + reader, writer, err := os.Pipe() + require.NoError(t, err) + origStdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = origStdin + reader.Close() + writer.Close() + }) + _, _ = writer.WriteString("piped body") + writer.Close() + + // Terminal stdout plus piped stdin: no TUI may open, and the stray "-" + // must be rejected rather than quietly quick-starting. + assert.False(t, stdinarg.InteractiveStdio(), + "piped stdin must close every TUI gate even with a terminal stdout") + + root := NewRootCmd() + commands.InstallDashGuard(root) + root.SetIn(reader) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"-"}) + + err = root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "does not read stdin") +} diff --git a/internal/commands/api.go b/internal/commands/api.go index 959df7628..fa70ee683 100644 --- a/internal/commands/api.go +++ b/internal/commands/api.go @@ -74,7 +74,10 @@ func newAPIPostCmd() *cobra.Command { cmd := &cobra.Command{ Use: "post ", Short: "POST request to API", - Long: "Make a raw POST request to any Basecamp API endpoint.", + Long: `Make a raw POST request to any Basecamp API endpoint. + +Use --data - to read the JSON body from stdin: + printf '{"content":"Buy milk"}' | basecamp api post buckets/1/todolists/2/todos.json --data -`, Example: ` basecamp api post buckets/123/todolists/456/todos.json -d '{"content":"Buy milk"}' basecamp api post buckets/123/message_boards/789/messages.json -d '{"subject":"Hello","content":"

World

"}'`, Args: apiPathArgs, @@ -84,6 +87,11 @@ func newAPIPostCmd() *cobra.Command { return missingArg(cmd, "--data") } + data, err := resolveContentValue(cmd, data, -1, "--data") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { return err @@ -116,7 +124,9 @@ func newAPIPostCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&data, "data", "d", "", "JSON request body (required)") + cmd.Flags().StringVarP(&data, "data", "d", "", "JSON request body (required); use - to read from stdin") + + allowDash(cmd, "flag:data") return cmd } @@ -125,9 +135,12 @@ func newAPIPutCmd() *cobra.Command { var data string cmd := &cobra.Command{ - Use: "put ", - Short: "PUT request to API", - Long: "Make a raw PUT request to any Basecamp API endpoint.", + Use: "put ", + Short: "PUT request to API", + Long: `Make a raw PUT request to any Basecamp API endpoint. + +Use --data - to read the JSON body from stdin: + printf '{"content":"Updated"}' | basecamp api put buckets/1/todos/2.json --data -`, Example: ` basecamp api put buckets/123/todos/456.json -d '{"content":"Updated todo"}'`, Args: apiPathArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -136,6 +149,11 @@ func newAPIPutCmd() *cobra.Command { return missingArg(cmd, "--data") } + data, err := resolveContentValue(cmd, data, -1, "--data") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { return err @@ -168,7 +186,9 @@ func newAPIPutCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&data, "data", "d", "", "JSON request body (required)") + cmd.Flags().StringVarP(&data, "data", "d", "", "JSON request body (required); use - to read from stdin") + + allowDash(cmd, "flag:data") return cmd } diff --git a/internal/commands/attachments.go b/internal/commands/attachments.go index e1b383294..44bf3baa3 100644 --- a/internal/commands/attachments.go +++ b/internal/commands/attachments.go @@ -395,6 +395,9 @@ Options: cmd.Flags().IntVar(&index, "index", 0, "Select attachment by 1-based index") cmd.Flags().StringVarP(&recordType, "type", "t", "", "Recording type hint (todo, todolist, message, comment, card, card-table, document, schedule-entry, checkin, answer, forward, upload)") + // --out - means stream to stdout — exempt from the stdin dash guard. + allowDash(cmd, "flag:out") + return cmd } diff --git a/internal/commands/boost.go b/internal/commands/boost.go index 66d05e879..3e64c845e 100644 --- a/internal/commands/boost.go +++ b/internal/commands/boost.go @@ -271,15 +271,21 @@ Use --event to boost a specific event within the item.`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) + content, err := resolveContentValue(cmd, args[1], 1, "") + if err != nil { + return err + } if err := ensureAccount(cmd, app); err != nil { return err } - return runBoostCreate(cmd, app, args[0], *project, args[1], eventID) + return runBoostCreate(cmd, app, args[0], *project, content, eventID) }, } cmd.Flags().StringVar(&eventID, "event", "", "Event ID (for event-specific boosts)") + allowDash(cmd, "arg:1") + return cmd } diff --git a/internal/commands/cards.go b/internal/commands/cards.go index bfb38631e..f6ef184b1 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -851,9 +851,15 @@ func newCardsCreateCmd(project, cardTable *string) *cobra.Command { cmd := &cobra.Command{ Use: "create [body]", Short: "Create a new card", - Long: "Create a new card in a project's card table.", + Long: `Create a new card in a project's card table. + +Use - as the body argument to read the body from stdin: + printf 'Card body' | basecamp cards create "My card" - --in myproject`, Example: ` basecamp cards create "My card" --in myproject basecamp cards create --in myproject -- "--title with dashes"`, + // Bounded so a stray third token is a usage error rather than being + // silently dropped after "-" has already drained stdin. + Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { // Show help when invoked with no title if len(args) == 0 { @@ -866,7 +872,11 @@ func newCardsCreateCmd(project, cardTable *string) *cobra.Command { } var content string if len(args) > 1 { - content = args[1] + var err error + content, err = resolveContentValue(cmd, args[1], 1, "[body]") + if err != nil { + return err + } } app := appctx.FromContext(cmd.Context()) @@ -1051,6 +1061,8 @@ func newCardsCreateCmd(project, cardTable *string) *cobra.Command { cmd.Flags().StringVar(&assignee, "to", "", "Assignee (alias for --assignee)") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") + allowDash(cmd, "arg:1") + completer := completion.NewCompleter(nil) _ = cmd.RegisterFlagCompletionFunc("assignee", completer.PeopleNameCompletion()) _ = cmd.RegisterFlagCompletionFunc("to", completer.PeopleNameCompletion()) @@ -1079,12 +1091,6 @@ You can pass either a card ID or a Basecamp URL: return noChanges(cmd) } - app := appctx.FromContext(cmd.Context()) - - if err := ensureAccount(cmd, app); err != nil { - return err - } - // Extract ID from URL if provided cardIDStr := extractID(args[0]) @@ -1093,10 +1099,25 @@ You can pass either a card ID or a Basecamp URL: return output.ErrUsage("Invalid card ID") } + // Syntactic checks first, then "-", then account and network: a + // malformed ID is answered without waiting on the producer, and a + // blank pipe cannot mask it. + content, err := resolveContentValue(cmd, content, -1, "--body") + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + req := &basecamp.UpdateCardRequest{} if title != "" { req.Title = &title } + var mentionNotice string var html string if content != "" { @@ -1160,7 +1181,7 @@ You can pass either a card ID or a Basecamp URL: } cmd.Flags().StringVarP(&title, "title", "t", "", "New title") - cmd.Flags().StringVarP(&content, "body", "b", "", "New body content") + cmd.Flags().StringVarP(&content, "body", "b", "", "New body content; use - to read from stdin") cmd.Flags().StringVarP(&due, "due", "d", "", "Due date (natural language or YYYY-MM-DD)") cmd.Flags().StringVar(&assignee, "assignee", "", "Assignee ID or name") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") @@ -1169,6 +1190,8 @@ You can pass either a card ID or a Basecamp URL: completer := completion.NewCompleter(nil) _ = cmd.RegisterFlagCompletionFunc("assignee", completer.PeopleNameCompletion()) + allowDash(cmd, "flag:body") + return cmd } @@ -2022,6 +2045,11 @@ func newCardsColumnCreateCmd(project, cardTable *string) *cobra.Command { app := appctx.FromContext(cmd.Context()) + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { return err } @@ -2085,7 +2113,9 @@ func newCardsColumnCreateCmd(project, cardTable *string) *cobra.Command { }, } - cmd.Flags().StringVarP(&description, "description", "d", "", "Column description") + cmd.Flags().StringVarP(&description, "description", "d", "", "Column description; use - to read from stdin") + + allowDash(cmd, "flag:description") return cmd } @@ -2108,12 +2138,6 @@ You can pass either a column ID or a Basecamp URL: return noChanges(cmd) } - app := appctx.FromContext(cmd.Context()) - - if err := ensureAccount(cmd, app); err != nil { - return err - } - // Extract ID from URL if provided columnIDStr := extractID(args[0]) columnID, err := strconv.ParseInt(columnIDStr, 10, 64) @@ -2121,6 +2145,18 @@ You can pass either a column ID or a Basecamp URL: return output.ErrUsage("Invalid column ID") } + // Syntactic checks first, then "-", then account and network. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + req := &basecamp.UpdateColumnRequest{ Title: title, Description: description, @@ -2138,7 +2174,9 @@ You can pass either a column ID or a Basecamp URL: } cmd.Flags().StringVarP(&title, "title", "t", "", "New title") - cmd.Flags().StringVarP(&description, "description", "d", "", "New description") + cmd.Flags().StringVarP(&description, "description", "d", "", "New description; use - to read from stdin") + + allowDash(cmd, "flag:description") return cmd } diff --git a/internal/commands/chat.go b/internal/commands/chat.go index 47f765353..26e931a00 100644 --- a/internal/commands/chat.go +++ b/internal/commands/chat.go @@ -308,15 +308,32 @@ By default, messages are sent as plain text. Use --content-type text/html for rich text (HTML) messages. @mentions (@Name or @First.Last) are resolved automatically and the -content type is promoted to text/html when mentions are present.`, +content type is promoted to text/html when mentions are present. + +Use - as the message argument to read the message from stdin: + printf 'Build is green' | basecamp chat post - --in my-project`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) - // Validate user input first, before checking account + // Validate user input first, before checking account. A + // positional and --content are the same input, so supplying both + // is rejected rather than one silently winning — with "-" in + // play, the losing source would discard piped content unread. messageContent := content + argIndex, what := -1, "--content" if len(args) > 0 { + if cmd.Flags().Changed("content") { + return output.ErrUsage("cannot combine a <message> argument with --content") + } messageContent = args[0] + argIndex, what = 0, "<message>" + } + + var err error + messageContent, err = resolveContentValue(cmd, messageContent, argIndex, what) + if err != nil { + return err } // Show help when invoked with no message content @@ -332,10 +349,12 @@ content type is promoted to text/html when mentions are present.`, }, } - cmd.Flags().StringVar(&content, "content", "", "Message content") + cmd.Flags().StringVar(&content, "content", "", "Message content; use - to read from stdin") cmd.Flags().StringVar(contentType, "content-type", "", "Content type (text/html for rich text)") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") + allowDash(cmd, "arg:0", "flag:content") + return cmd } @@ -766,9 +785,23 @@ edit to rich text.`, return missingArg(cmd, "<id|url>") } + // A positional and --content are the same input, so supplying + // both is rejected rather than one silently winning — with "-" + // in play, the losing source would discard piped content unread. messageContent := content + argIndex, what := -1, "--content" if len(args) > 1 { + if cmd.Flags().Changed("content") { + return output.ErrUsage("cannot combine a [content] argument with --content") + } messageContent = args[1] + argIndex, what = 1, "[content]" + } + + var contentErr error + messageContent, contentErr = resolveContentValue(cmd, messageContent, argIndex, what) + if contentErr != nil { + return contentErr } if strings.TrimSpace(messageContent) == "" { @@ -958,9 +991,11 @@ edit to rich text.`, }, } - cmd.Flags().StringVar(&content, "content", "", "New message content") + cmd.Flags().StringVar(&content, "content", "", "New message content; use - to read from stdin") cmd.Flags().StringVar(contentType, "content-type", "", "Input handling: text/html (supply HTML) or text/plain (verbatim); applied locally, edits always render as rich text") + allowDash(cmd, "arg:1", "flag:content") + return cmd } diff --git a/internal/commands/chat_test.go b/internal/commands/chat_test.go index f58cafd4f..8d126f7d5 100644 --- a/internal/commands/chat_test.go +++ b/internal/commands/chat_test.go @@ -1796,3 +1796,19 @@ func TestChatRoomShorthandFlag(t *testing.T) { require.Len(t, envelope.Data, 1) assert.Equal(t, "Engineering", envelope.Data[0]["title"]) } + +// TestChatPostRejectsPositionalWithContentFlag verifies that a positional +// message and --content are rejected together instead of one silently +// winning — with "-" in play, the losing source would discard piped +// content unread. +func TestChatPostRejectsPositionalWithContentFlag(t *testing.T) { + app := &appctx.App{Config: &config.Config{}} + + err := executeChatCommand(NewChatCmd(), app, "post", "literal", "--content", "other") + require.Error(t, err) + require.Contains(t, err.Error(), "cannot combine") + + err = executeChatCommand(NewChatCmd(), app, "update", "123", "literal", "--content", "other") + require.Error(t, err) + require.Contains(t, err.Error(), "cannot combine") +} diff --git a/internal/commands/checkins.go b/internal/commands/checkins.go index ee4a327fa..90d614b12 100644 --- a/internal/commands/checkins.go +++ b/internal/commands/checkins.go @@ -1269,7 +1269,10 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { } questionID := args[0] - content := strings.Join(args[1:], " ") + content, err := resolveContentArg(cmd, args[1:], 1) + if err != nil { + return err + } app := appctx.FromContext(cmd.Context()) @@ -1359,6 +1362,8 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { cmd.Flags().StringVar(&groupOn, "date", "", "Date to group answer (ISO 8601, e.g., 2024-01-22; defaults to today)") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") + allowDash(cmd, "arg:1+") + return cmd } @@ -1381,7 +1386,10 @@ You can pass either an answer ID or a Basecamp URL: // Extract ID and project from URL if provided answerIDStr, urlProjectID := extractWithProject(args[0]) - content := strings.Join(args[1:], " ") + content, err := resolveContentArg(cmd, args[1:], 1) + if err != nil { + return err + } app := appctx.FromContext(cmd.Context()) @@ -1461,6 +1469,8 @@ You can pass either an answer ID or a Basecamp URL: }, } + allowDash(cmd, "arg:1+") + return cmd } diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index f9a8dc975..85336c486 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -119,6 +119,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewTUICmd()) root.AddCommand(commands.NewProfileCmd()) root.AddCommand(commands.NewBonfireCmd()) + commands.InstallDashGuard(root) root.InitDefaultHelpCmd() return root } diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 05ec36564..27baacf54 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "os" "sort" @@ -23,6 +22,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/hostutil" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/richtext" + "github.com/basecamp/basecamp-cli/internal/stdinarg" "github.com/basecamp/basecamp-cli/internal/urlarg" ) @@ -1049,7 +1049,19 @@ as backslash-n.`, return missingArg(cmd, "<content>") } - content, err := contentArgOrStdin(cmd, args[1:]) + // Extract comment ID from URL if provided + // Uses extractCommentWithProject to prefer CommentID from URL fragments + commentIDStr, _ := extractCommentWithProject(args[0]) + + commentID, err := strconv.ParseInt(commentIDStr, 10, 64) + if err != nil { + return output.ErrUsage("Invalid comment ID") + } + + // Syntactic checks first, then "-", then account and network: a + // malformed ID is answered without waiting on the producer, and a + // blank pipe cannot mask it. + content, err := resolveContentArg(cmd, args[1:], 1) if err != nil { return err } @@ -1062,15 +1074,6 @@ as backslash-n.`, return err } - // Extract comment ID from URL if provided - // Uses extractCommentWithProject to prefer CommentID from URL fragments - commentIDStr, _ := extractCommentWithProject(args[0]) - - commentID, err := strconv.ParseInt(commentIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid comment ID") - } - // Convert Markdown content to HTML for Basecamp's rich text fields html := richtext.MarkdownToHTML(content) @@ -1114,6 +1117,8 @@ as backslash-n.`, }, } + allowDash(cmd, "arg:1+") + return cmd } @@ -1132,8 +1137,8 @@ Comma-separated IDs add the same comment to multiple items: basecamp comments create 789,012,345 "Looks good!" basecamp comments create https://3.basecamp.com/123/buckets/456/todos/789 "Looks good!" -Content can also be piped from stdin: - printf 'Looks good!' | basecamp comments create 789 +Content can be piped from stdin by passing - as the content argument: + printf 'Looks good!' | basecamp comments create 789 - Content supports Markdown and @mentions (@Name or @First.Last): basecamp comments create 789 "Hey @Jane.Smith, **please review**" @@ -1164,7 +1169,7 @@ busybox-ash) it posts a literal leading $ and keeps \n as backslash-n: var content string if len(args) > 1 { var err error - content, err = contentArgOrStdin(cmd, args[1:]) + content, err = resolveContentArg(cmd, args[1:], 1) if err != nil { return err } @@ -1180,21 +1185,19 @@ busybox-ash) it posts a literal leading $ and keeps \n as backslash-n: } } - if !edit && strings.TrimSpace(content) == "" { - stdinContent, hasPipedStdin, err := readPipedStdin(cmd) - if err != nil { - return err - } - if hasPipedStdin { - content = stdinContent - } - } - - // Show help when invoked with no content; keep error if editor was opened + // Show help when invoked with no content; keep error if editor was opened. + // A pipe without "-" is deliberately not consumed: teach the explicit + // placeholder instead of silently reading stdin. if strings.TrimSpace(content) == "" { if edit { return output.ErrUsage("Comment content required") } + if stdinarg.IsPiped(cmd.InOrStdin()) { + return output.ErrUsageHint( + "<content> required", + fmt.Sprintf(`To read the piped stdin, pass "-" as the content: %s %s -`, cmd.CommandPath(), recordingArg), + ) + } return missingArg(cmd, "<content>") } @@ -1336,16 +1339,7 @@ busybox-ash) it posts a literal leading $ and keeps \n as backslash-n: cmd.Flags().BoolVar(&edit, "edit", false, "Open $EDITOR to compose content") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") - return cmd -} + allowDash(cmd, "arg:1+") -func contentArgOrStdin(cmd *cobra.Command, args []string) (string, error) { - if len(args) == 1 && args[0] == "-" { - b, err := io.ReadAll(cmd.InOrStdin()) - if err != nil { - return "", output.ErrUsage(fmt.Sprintf("failed to read content from stdin: %v", err)) - } - return string(b), nil - } - return strings.Join(args, " "), nil + return cmd } diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index a44f8c4f5..9c40d2203 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -84,23 +84,26 @@ func TestCommentsUpdateRejectsEmptyDashContent(t *testing.T) { var outErr *output.Error require.True(t, errors.As(err, &outErr), "expected *output.Error, got %T: %v", err, err) assert.Equal(t, output.CodeUsage, outErr.Code) - assert.Equal(t, "<content> required", outErr.Message) + assert.Equal(t, "stdin for <content> is empty", outErr.Message) assert.Empty(t, transport.capturedBodies) } -func TestCommentsCreateReadsContentFromStdin(t *testing.T) { +// A pipe without "-" is no longer consumed implicitly: the error teaches the +// explicit placeholder instead, and nothing reaches the server. +func TestCommentsCreateBarePipeErrorsWithDashHint(t *testing.T) { transport := &mockCommentWriteTransport{} app, _ := setupCommentsWriteTestApp(t, transport) + app.Flags.JSON = true cmd := NewCommentsCmd() cmd.SetIn(strings.NewReader("hello from stdin")) err := executeCommand(cmd, app, "create", "123") - require.NoError(t, err) - require.Len(t, transport.capturedBodies, 1) - - var body map[string]any - require.NoError(t, json.Unmarshal(transport.capturedBodies[0], &body)) - assert.Equal(t, "<p>hello from stdin</p>", body["content"]) + require.Error(t, err) + var outErr *output.Error + require.True(t, errors.As(err, &outErr)) + assert.Equal(t, output.CodeUsage, outErr.Code) + assert.Contains(t, outErr.Hint, `"-"`) + assert.Empty(t, transport.capturedBodies) } func TestCommentsCreatePrefersPositionalContentOverStdin(t *testing.T) { @@ -140,7 +143,7 @@ func TestCommentsCreateMissingContentReturnsUsageBeforeAccountResolution(t *test assert.NotContains(t, err.Error(), "account") } -func TestReadPipedStdinIgnoresUnreadableStdin(t *testing.T) { +func TestReadStdinContentUnreadableStdinIsUsageError(t *testing.T) { r, w, err := os.Pipe() require.NoError(t, err) require.NoError(t, r.Close()) @@ -148,10 +151,12 @@ func TestReadPipedStdinIgnoresUnreadableStdin(t *testing.T) { cmd := newCommentsCreateCmd() cmd.SetIn(r) - content, hasPipedStdin, err := readPipedStdin(cmd) - require.NoError(t, err) + content, err := readStdinContent(cmd, "<content>") + require.Error(t, err) + var outErr *output.Error + require.True(t, errors.As(err, &outErr)) + assert.Equal(t, output.CodeUsage, outErr.Code) assert.Empty(t, content) - assert.False(t, hasPipedStdin) } func setupCommentsWriteTestApp(t *testing.T, transport http.RoundTripper) (*appctx.App, *bytes.Buffer) { diff --git a/internal/commands/dash_guard_test.go b/internal/commands/dash_guard_test.go new file mode 100644 index 000000000..95840d786 --- /dev/null +++ b/internal/commands/dash_guard_test.go @@ -0,0 +1,324 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/stdinarg" +) + +// dashProbe records whether a guarded command's original RunE ran, and with +// which args — the passthrough half of the guard's contract. +type dashProbe struct { + ran bool + args []string +} + +func newDashProbeCmd(probe *dashProbe, tokens ...string) *cobra.Command { + cmd := &cobra.Command{ + Use: "probe <name>", + RunE: func(cmd *cobra.Command, args []string) error { + probe.ran = true + probe.args = args + return nil + }, + } + cmd.Flags().String("title", "", "") + cmd.Flags().String("out", "", "") + cmd.Flags().StringArray("attach", nil, "") + if len(tokens) > 0 { + allowDash(cmd, tokens...) + } + InstallDashGuard(cmd) + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + return cmd +} + +func TestDashGuardRejectsUnlistedPositionalWhenPiped(t *testing.T) { + app, _ := setupTestApp(t) + app.Flags.JSON = true + + cmd := NewProjectsCmd() + InstallDashGuard(cmd) + cmd.SetIn(strings.NewReader("piped")) + + err := executeCommand(cmd, app, "create", "-") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "<name>") + assert.Contains(t, outErr.Hint, "--") + assert.Contains(t, outErr.Hint, "--description", "hint should point at where stdin is accepted") +} + +func TestDashGuardRejectsUnlistedFlagWhenPiped(t *testing.T) { + app, _ := setupTestApp(t) + app.Flags.JSON = true + + cmd := NewTodosCmd() + InstallDashGuard(cmd) + cmd.SetIn(strings.NewReader("piped")) + + err := executeCommand(cmd, app, "update", "1", "--title", "-") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "--title") + // -- doesn't escape flag values, so the hint must not claim it does. + assert.NotContains(t, outErr.Hint, "-- separator") + assert.Contains(t, outErr.Hint, "without piped stdin") +} + +// The guard runs at Args-validation time: before the command's own Args +// check, PreRunE, and required-flag validation, so the stray-dash error is +// what the caller sees instead of a competing usage error — and no pre-run +// side effect happens first. +func TestDashGuardFiresBeforeArgsPreRunAndRequiredFlags(t *testing.T) { + preRunRan := false + cmd := &cobra.Command{ + Use: "probe <name> <other>", + Args: cobra.ExactArgs(2), + PreRunE: func(cmd *cobra.Command, args []string) error { + preRunRan = true + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().String("title", "", "") + require.NoError(t, cmd.MarkFlagRequired("title")) + InstallDashGuard(cmd) + cmd.SetIn(strings.NewReader("piped")) + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + cmd.SetArgs([]string{"-"}) // one arg: ExactArgs(2) and the missing --title would both error later + + err := cmd.Execute() + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "<name>") + assert.False(t, preRunRan, "guard must fire before PreRunE") +} + +// Alias flags share one backing value; a value set through both spellings is +// one logical input, not two. +func TestDashGuardAliasFlagsCountOnce(t *testing.T) { + newAliasCmd := func(probe *dashProbe) *cobra.Command { + var description string + cmd := &cobra.Command{ + Use: "probe", + RunE: func(cmd *cobra.Command, args []string) error { + probe.ran = true + probe.args = []string{description} + return nil + }, + } + cmd.Flags().StringVar(&description, "description", "", "") + cmd.Flags().StringVar(&description, "desc", "", "") + allowDash(cmd, "flag:description", "flag:desc") + InstallDashGuard(cmd) + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + return cmd + } + + // Dash last: the merged value is "-", one allowed stdin input. + probe := &dashProbe{} + cmd := newAliasCmd(probe) + cmd.SetIn(strings.NewReader("piped")) + cmd.SetArgs([]string{"--description", "old", "--desc", "-"}) + require.NoError(t, cmd.Execute()) + assert.Equal(t, []string{"-"}, probe.args) + + // Dash first: the literal value wins, no dash in play at all. + probe = &dashProbe{} + cmd = newAliasCmd(probe) + cmd.SetIn(strings.NewReader("piped")) + cmd.SetArgs([]string{"--desc", "-", "--description", "old"}) + require.NoError(t, cmd.Execute()) + assert.Equal(t, []string{"old"}, probe.args) +} + +func TestDashGuardPassesLiteralDashOnTTY(t *testing.T) { + probe := &dashProbe{} + cmd := newDashProbeCmd(probe) + devNullStdin(t, cmd) + cmd.SetArgs([]string{"-", "--title", "-"}) + + require.NoError(t, cmd.Execute()) + assert.True(t, probe.ran) + assert.Equal(t, []string{"-"}, probe.args) +} + +func TestDashGuardExemptsOutFlag(t *testing.T) { + probe := &dashProbe{} + cmd := newDashProbeCmd(probe, "flag:out") + cmd.SetIn(strings.NewReader("piped")) + cmd.SetArgs([]string{"name", "--out", "-"}) + + require.NoError(t, cmd.Execute()) + assert.True(t, probe.ran) +} + +func TestDashGuardRejectsTwoAllowedDashes(t *testing.T) { + app, _ := setupTestApp(t) + app.Flags.JSON = true + + cmd := NewChatCmd() + InstallDashGuard(cmd) + cmd.SetIn(strings.NewReader("piped")) + + err := executeCommand(cmd, app, "post", "-", "--content", "-") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "only one input") +} + +// Two allowed dashes can never both be satisfied, even on a TTY. +func TestDashGuardRejectsTwoAllowedDashesOnTTY(t *testing.T) { + app, _ := setupTestApp(t) + app.Flags.JSON = true + + cmd := NewChatCmd() + InstallDashGuard(cmd) + devNullStdin(t, cmd) + + err := executeCommand(cmd, app, "post", "-", "--content", "-") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "only one input") +} + +func TestDashGuardRejectsUnlistedAttachAlongsideAllowedBody(t *testing.T) { + app, _ := setupTestApp(t) + app.Flags.JSON = true + + cmd := NewMessagesCmd() + InstallDashGuard(cmd) + cmd.SetIn(strings.NewReader("piped")) + + err := executeCommand(cmd, app, "create", "title", "-", "--attach", "-") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "--attach") +} + +func TestDashGuardSeparatorEscapesLiteralDash(t *testing.T) { + probe := &dashProbe{} + cmd := newDashProbeCmd(probe) + cmd.SetIn(strings.NewReader("piped")) + cmd.SetArgs([]string{"--", "-"}) + + require.NoError(t, cmd.Execute()) + assert.True(t, probe.ran) + assert.Equal(t, []string{"-"}, probe.args) +} + +// The download commands carry the --out exemption so "-" (stdout) never trips +// the stdin guard. +func TestDownloadCommandsExemptOutFlag(t *testing.T) { + for _, cmd := range []*cobra.Command{NewAttachmentsCmd(), NewFilesCmd()} { + download := findSubcommand(cmd, "download") + require.NotNil(t, download, "%s download not found", cmd.Name()) + allow := stdinarg.ParseAllow(download.Annotations[stdinarg.AnnotationAllowDash]) + assert.True(t, allow.Flag("out"), "%s download should exempt --out", cmd.Name()) + } +} + +// Parsed state keeps only the merged value, never which spelling the caller +// typed — so naming one alias is a coin flip that reports "--in" for a caller +// who wrote "--project -". Name the whole group instead. +func TestDashGuardNamesTheWholeAliasGroup(t *testing.T) { + app, _ := setupTestApp(t) + app.Flags.JSON = true + + // --project/--in are two spellings of one persistent value on the group. + cmd := NewCardsCmd() + InstallDashGuard(cmd) + cmd.SetIn(strings.NewReader("piped")) + + err := executeCommand(cmd, app, "create", "Title", "--in", "old", "--project", "-") + outErr := requireUsageErr(t, err) + // pflag visits flags in sorted order, so the group label is stable. + assert.Contains(t, outErr.Message, "--in/--project") +} + +// A flag with no alias still reads as a single spelling. +func TestDashGuardNamesASoloFlagPlainly(t *testing.T) { + app, _ := setupTestApp(t) + app.Flags.JSON = true + + cmd := NewTodosCmd() + InstallDashGuard(cmd) + cmd.SetIn(strings.NewReader("piped")) + + err := executeCommand(cmd, app, "update", "1", "--title", "-") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "--title") + assert.NotContains(t, outErr.Message, "/--") +} + +// The root keeps nil Args so cobra's legacyArgs still rejects unknown +// subcommands, so its guard hangs off RunE instead. All four root behaviors +// have to survive together. +func TestDashGuardOnRootPreservesUnknownCommandHandling(t *testing.T) { + newRoot := func(probe *dashProbe) *cobra.Command { + root := &cobra.Command{ + Use: "basecamp", + RunE: func(cmd *cobra.Command, args []string) error { + probe.ran = true + probe.args = args + return nil + }, + } + root.AddCommand(&cobra.Command{ + Use: "todos", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + }) + InstallDashGuard(root) + require.Nil(t, root.Args, "root Args must stay nil for legacyArgs") + root.SetOut(&strings.Builder{}) + root.SetErr(&strings.Builder{}) + return root + } + + t.Run("piped dash is a usage error", func(t *testing.T) { + probe := &dashProbe{} + root := newRoot(probe) + root.SetIn(strings.NewReader("piped")) + root.SetArgs([]string{"-"}) + + outErr := requireUsageErr(t, root.Execute()) + assert.Contains(t, outErr.Message, "does not read stdin") + assert.False(t, probe.ran, "quick-start must not run on a stray dash") + }) + + t.Run("separator keeps the dash literal", func(t *testing.T) { + probe := &dashProbe{} + root := newRoot(probe) + root.SetIn(strings.NewReader("piped")) + root.SetArgs([]string{"--", "-"}) + + require.NoError(t, root.Execute()) + assert.True(t, probe.ran) + assert.Equal(t, []string{"-"}, probe.args) + }) + + t.Run("unknown subcommand still errors", func(t *testing.T) { + probe := &dashProbe{} + root := newRoot(probe) + root.SetIn(strings.NewReader("piped")) + root.SetArgs([]string{"unknowncmd"}) + + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown command") + assert.False(t, probe.ran) + }) + + t.Run("bare invocation still runs", func(t *testing.T) { + probe := &dashProbe{} + root := newRoot(probe) + root.SetIn(strings.NewReader("piped")) + root.SetArgs(nil) + + require.NoError(t, root.Execute()) + assert.True(t, probe.ran) + }) +} diff --git a/internal/commands/files.go b/internal/commands/files.go index 3f543348b..591fc03a9 100644 --- a/internal/commands/files.go +++ b/internal/commands/files.go @@ -887,13 +887,19 @@ as an upload in the target folder (vault).`, basecamp uploads create ./photo.png --folder 123 --description "Site photo"`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } return runUploadFile(cmd, *project, *vaultID, args[0], description, visibleToClients) }, } - cmd.Flags().StringVar(&description, "description", "", "Upload description (Markdown)") + cmd.Flags().StringVar(&description, "description", "", "Upload description (Markdown); use - to read from stdin") cmd.Flags().BoolVar(&visibleToClients, "visible-to-clients", false, "Make the upload visible to clients (root Docs & Files folder only; a nested folder inherits its folder's visibility). Omit for the server default.") + allowDash(cmd, "flag:description") + return cmd } @@ -915,6 +921,10 @@ attachment and then created as an upload in the target folder.`, basecamp upload ./photo.png --folder 123 --description "Site photo"`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } return runUploadFile(cmd, project, vaultID, args[0], description, visibleToClients) }, } @@ -923,9 +933,11 @@ attachment and then created as an upload in the target folder.`, cmd.Flags().StringVar(&project, "in", "", "Project ID (alias for --project)") cmd.Flags().StringVar(&vaultID, "vault", "", "Folder ID (default: root)") cmd.Flags().StringVar(&vaultID, "folder", "", "Folder ID (alias for --vault)") - cmd.Flags().StringVar(&description, "description", "", "Upload description (Markdown)") + cmd.Flags().StringVar(&description, "description", "", "Upload description (Markdown); use - to read from stdin") cmd.Flags().BoolVar(&visibleToClients, "visible-to-clients", false, "Make the upload visible to clients (root Docs & Files folder only; a nested folder inherits its folder's visibility). Omit for the server default.") + allowDash(cmd, "flag:description") + return cmd } @@ -1214,6 +1226,13 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command { cmd := &cobra.Command{ Use: "create <title> [content]", Short: "Create a new document", + Long: `Create a new document in a project's Docs & Files area. + +Use - as the content argument to read the document body from stdin: + basecamp docs documents create "Title" - --in my-project < body.md`, + // Bounded so a stray third token is a usage error rather than being + // silently dropped after "-" has already drained stdin. + Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { // Show help when invoked with no arguments if len(args) == 0 { @@ -1222,15 +1241,22 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command { title := args[0] + // Resolve "-" before any account or network work, so a bad stdin + // gets the stdin error rather than "--account is required". + content := "" + if len(args) > 1 { + var contentErr error + content, contentErr = resolveContentValue(cmd, args[1], 1, "[content]") + if contentErr != nil { + return contentErr + } + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { return err } - content := "" - if len(args) > 1 { - content = args[1] - } // Resolve subscription flags before project (fail fast on bad input) subs, err := applySubscribeFlags(cmd.Context(), app.Names, subscribe, cmd.Flags().Changed("subscribe"), noSubscribe) @@ -1339,6 +1365,8 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command { cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") cmd.Flags().BoolVar(&visibleToClients, "visible-to-clients", false, "Make the document visible to clients (root Docs & Files folder only; a nested folder inherits its folder's visibility). Omit for the server default.") + allowDash(cmd, "arg:1") + return cmd } @@ -1693,6 +1721,28 @@ You can pass either an upload ID or a Basecamp URL: Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) + + uploadIDStr := extractID(args[0]) + uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64) + if err != nil || uploadID <= 0 { + return output.ErrUsage("Invalid upload ID") + } + + filePath := richtext.NormalizeDragPath(args[1]) + if err := richtext.ValidateFile(filePath); err != nil { + return fmt.Errorf("%s: %w", filePath, err) + } + + // Syntactic checks first, then "-", then account and network: a + // malformed ID or missing file is answered without waiting on the + // producer. The URL identity checks below need the session account, + // so they necessarily follow. Only an exact "-" reads stdin; + // --description "" stays the clear idiom. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { return err } @@ -1726,17 +1776,6 @@ You can pass either an upload ID or a Basecamp URL: } } - uploadIDStr := extractID(args[0]) - uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64) - if err != nil || uploadID <= 0 { - return output.ErrUsage("Invalid upload ID") - } - - filePath := richtext.NormalizeDragPath(args[1]) - if err := richtext.ValidateFile(filePath); err != nil { - return fmt.Errorf("%s: %w", filePath, err) - } - // Resolve the description first: its local-image references can // fail deterministically, and staging a large replacement before // finding that out wastes the whole transfer. A nil Description @@ -1805,9 +1844,11 @@ You can pass either an upload ID or a Basecamp URL: }, } - cmd.Flags().StringVar(&description, "description", "", "New description (Markdown); omit to carry the current one forward") + cmd.Flags().StringVar(&description, "description", "", "New description (Markdown); omit to carry the current one forward; use - to read from stdin") cmd.Flags().StringVar(&baseName, "base-name", "", "Rename the file (without extension); omit to keep the uploaded file's name") + allowDash(cmd, "flag:description") + return cmd } @@ -1866,6 +1907,24 @@ You can pass either an item ID or a Basecamp URL: Annotations: map[string]string{"agent_notes": "Document updates preserve untouched title/content by fetching current state first because BC3 rebuilds documents from permitted params on PUT; explicit clears via --title \"\"/--content \"\" work because the SDK strips empty strings to absent fields, which the controller then nulls. Upload/vault updates do not clear by omission, so empty-valued flags are rejected CLI-side."}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Extract ID and project from URL if provided + itemIDStr, urlProjectID := extractWithProject(args[0]) + + itemID, err := strconv.ParseInt(itemIDStr, 10, 64) + if err != nil { + return output.ErrUsage("Invalid item ID") + } + + // Syntactic checks first, then "-", then account and network: a + // malformed ID is answered without waiting on the producer, and a + // blank pipe cannot mask it. Only an exact "-" reads stdin; + // --content "" stays the clear idiom. + var contentErr error + content, contentErr = resolveContentValue(cmd, content, -1, "--content") + if contentErr != nil { + return contentErr + } + titleChanged := cmd.Flags().Changed("title") contentChanged := cmd.Flags().Changed("content") titleTrimmed := strings.TrimSpace(title) @@ -1907,14 +1966,6 @@ You can pass either an item ID or a Basecamp URL: return err } - // Extract ID and project from URL if provided - itemIDStr, urlProjectID := extractWithProject(args[0]) - - itemID, err := strconv.ParseInt(itemIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid item ID") - } - // Resolve project - use URL > flag > config, with interactive fallback projectID := *project if projectID == "" && urlProjectID != "" { @@ -2061,9 +2112,11 @@ You can pass either an item ID or a Basecamp URL: } cmd.Flags().StringVarP(&title, "title", "t", "", "New title") - cmd.Flags().StringVarP(&content, "content", "c", "", "New content") + cmd.Flags().StringVarP(&content, "content", "c", "", "New content; use - to read from stdin") cmd.Flags().StringVar(&itemType, "type", "", "Item type (vault, document, upload)") + allowDash(cmd, "flag:content") + return cmd } @@ -2280,6 +2333,9 @@ Use --out - to stream the file to stdout (for piping to other commands).`, cmd.Flags().StringVarP(&outDir, "out", "o", "", "Output directory (default: current directory)") + // --out - means stream to stdout — exempt from the stdin dash guard. + allowDash(cmd, "flag:out") + return cmd } diff --git a/internal/commands/gauges.go b/internal/commands/gauges.go index f90e0aae5..26b9b9ff3 100644 --- a/internal/commands/gauges.go +++ b/internal/commands/gauges.go @@ -178,6 +178,20 @@ func newGaugesCreateCmd(project *string) *cobra.Command { basecamp gauges create --position 75 --color green --in MyProject basecamp gauges create --position 50 --color yellow --description "Halfway there" --in MyProject`, RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("position") { + return output.ErrUsage("--position is required") + } + if position < 0 || position > 100 { + return output.ErrUsage("--position must be between 0 and 100") + } + + // Local validation, then "-", then account: a bad stdin gets the + // stdin error rather than "--account is required". + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { @@ -194,13 +208,6 @@ func newGaugesCreateCmd(project *string) *cobra.Command { return output.ErrUsage("Invalid project ID") } - if !cmd.Flags().Changed("position") { - return output.ErrUsage("--position is required") - } - if position < 0 || position > 100 { - return output.ErrUsage("--position must be between 0 and 100") - } - req := &basecamp.CreateGaugeNeedleRequest{ Position: position, } @@ -240,10 +247,12 @@ func newGaugesCreateCmd(project *string) *cobra.Command { cmd.Flags().Int32Var(&position, "position", 0, "Position on gauge (0-100, required)") cmd.Flags().StringVar(&color, "color", "", "Needle color: green, yellow, or red") - cmd.Flags().StringVar(&description, "description", "", "Description (rich text HTML)") + cmd.Flags().StringVar(&description, "description", "", "Description (rich text HTML); use - to read from stdin") cmd.Flags().StringVar(¬ify, "notify", "", "Notification mode: everyone, working_on, or custom") cmd.Flags().Int64SliceVar(&subscriptions, "subscriptions", nil, "Person IDs to notify (used with --notify custom)") + allowDash(cmd, "flag:description") + return cmd } @@ -258,10 +267,8 @@ func newGaugesUpdateCmd() *cobra.Command { basecamp gauges update 12345 --description "Updated status"`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - app := appctx.FromContext(cmd.Context()) - - if err := ensureAccount(cmd, app); err != nil { - return err + if !cmd.Flags().Changed("description") { + return output.ErrUsage("No changes specified (use --description)") } needleID, err := strconv.ParseInt(args[0], 10, 64) @@ -269,8 +276,16 @@ func newGaugesUpdateCmd() *cobra.Command { return output.ErrUsage("Invalid needle ID") } - if !cmd.Flags().Changed("description") { - return output.ErrUsage("No changes specified (use --description)") + // Syntactic checks first, then "-", then account and network. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err } req := &basecamp.UpdateGaugeNeedleRequest{ @@ -295,7 +310,9 @@ func newGaugesUpdateCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&description, "description", "", "New description (rich text HTML)") + cmd.Flags().StringVar(&description, "description", "", "New description (rich text HTML); use - to read from stdin") + + allowDash(cmd, "flag:description") return cmd } diff --git a/internal/commands/help_paths_test.go b/internal/commands/help_paths_test.go new file mode 100644 index 000000000..194ef9e83 --- /dev/null +++ b/internal/commands/help_paths_test.go @@ -0,0 +1,101 @@ +package commands_test + +import ( + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A help example that names a path cobra cannot resolve exits 0 showing group +// help, so nothing fails and the wrong path keeps getting taught. That is how +// "basecamp docs create" survived: Find() landed on the `docs` group with +// "create" left over. +// +// Coverage is bounded and deliberately so: only the leading run of bare +// lowercase words after "basecamp" is resolved (stopping at the first flag, +// placeholder, quote, or shell metacharacter), and a leftover token is only +// reported when it is the name or alias of some command in the tree — which is +// what distinguishes a mistyped subcommand from a literal argument like a +// project name. +func TestHelpExampleCommandPathsResolveExactly(t *testing.T) { + root := buildRootWithAllCommands() + + commandWords := map[string]bool{} + var collect func(*cobra.Command) + collect = func(cmd *cobra.Command) { + for _, sub := range cmd.Commands() { + commandWords[sub.Name()] = true + for _, alias := range sub.Aliases { + commandWords[alias] = true + } + collect(sub) + } + } + collect(root) + + word := regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + + var checked int + var walk func(*cobra.Command) + walk = func(cmd *cobra.Command) { + for _, text := range []string{cmd.Long, cmd.Example} { + for _, line := range strings.Split(text, "\n") { + _, after, found := strings.Cut(strings.TrimSpace(line), "basecamp ") + if !found { + continue + } + var path []string + for _, token := range strings.Fields(after) { + if !word.MatchString(token) { + break + } + path = append(path, token) + } + // Prose ("basecamp is a CLI tool ...") is not an invocation: + // require the first word to be a real top-level command. + if len(path) == 0 || root.Commands() == nil || !isTopLevel(root, path[0]) { + continue + } + + target, remaining, err := root.Find(path) + require.NoError(t, err, "line %q", line) + checked++ + + // Only a *group* can swallow a mistyped subcommand: it shows + // its help and exits 0. A leaf's leftovers are its arguments + // ("assignments due overdue"), even when the word happens to + // name a command elsewhere in the tree. + if len(remaining) > 0 && target.HasSubCommands() && commandWords[remaining[0]] { + assert.Fail(t, + "example names a path that does not resolve", + "%s: %q resolves to the %q group with %q left over — it exits 0 showing group help", + cmd.CommandPath(), strings.TrimSpace(line), target.CommandPath(), remaining[0]) + } + } + } + for _, sub := range cmd.Commands() { + walk(sub) + } + } + walk(root) + + require.Greater(t, checked, 100, "expected the help corpus to yield many command paths") +} + +func isTopLevel(root *cobra.Command, name string) bool { + for _, sub := range root.Commands() { + if sub.Name() == name { + return true + } + for _, alias := range sub.Aliases { + if alias == name { + return true + } + } + } + return false +} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 0e81dd215..54f2d1e02 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "os" "strconv" "strings" @@ -87,25 +86,6 @@ func isMachineOutput(cmd *cobra.Command) bool { return false } -func readPipedStdin(cmd *cobra.Command) (string, bool, error) { - stdin := cmd.InOrStdin() - if f, ok := stdin.(*os.File); ok { - fi, _ := f.Stat() - if fi == nil { - return "", false, nil - } - if (fi.Mode() & os.ModeCharDevice) != 0 { - return "", false, nil - } - } - - data, err := io.ReadAll(stdin) - if err != nil { - return "", false, fmt.Errorf("failed to read stdin: %w", err) - } - return string(data), true, nil -} - // DockTool represents a tool in a project's dock. type DockTool struct { Name string `json:"name"` diff --git a/internal/commands/messages.go b/internal/commands/messages.go index 82014f678..c5fe1116a 100644 --- a/internal/commands/messages.go +++ b/internal/commands/messages.go @@ -431,7 +431,13 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command cmd := &cobra.Command{ Use: "create <title> [body]", Short: "Create a new message", - Long: "Post a new message to a project's message board.", + Long: `Post a new message to a project's message board. + +Use - as the body argument to read the body from stdin: + printf 'Long **Markdown** body' | basecamp messages create "Title" -`, + // Bounded so a stray third token is a usage error rather than being + // silently dropped after "-" has already drained stdin. + Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { // Show help when invoked with no title if len(args) == 0 { @@ -449,10 +455,17 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command return cmd.Help() } - // Validate user input first, before checking account + // Validate user input first, before checking account. The --edit + // exclusion runs before "-" resolution so --edit … - errors + // without consuming stdin. if edit && body != "" { return output.ErrUsage("cannot combine --edit and body argument") } + var err error + body, err = resolveContentValue(cmd, body, 1, "[body]") + if err != nil { + return err + } if edit { fi, err := os.Stdin.Stat() if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 { @@ -591,6 +604,8 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") cmd.Flags().BoolVar(&visibleToClients, "visible-to-clients", false, "Make the message visible to clients on the project (omit for the server default; client-authenticated callers always post client-visible)") + allowDash(cmd, "arg:1") + return cmd } @@ -612,12 +627,6 @@ You can pass either a message ID or a Basecamp URL: return noChanges(cmd) } - app := appctx.FromContext(cmd.Context()) - - if err := ensureAccount(cmd, app); err != nil { - return err - } - // Extract ID from URL if provided messageIDStr := extractID(args[0]) @@ -626,6 +635,20 @@ You can pass either a message ID or a Basecamp URL: return output.ErrUsage("Invalid message ID") } + // Syntactic checks first, then "-", then account and network: a + // malformed ID is answered without waiting on the producer, and a + // blank pipe cannot mask it. + body, err = resolveContentValue(cmd, body, -1, "--body") + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + // Build SDK request // Convert Markdown content to HTML for Basecamp's rich text fields html := richtext.MarkdownToHTML(body) @@ -672,7 +695,9 @@ You can pass either a message ID or a Basecamp URL: } cmd.Flags().StringVarP(&title, "title", "t", "", "New title") - cmd.Flags().StringVarP(&body, "body", "b", "", "New body content") + cmd.Flags().StringVarP(&body, "body", "b", "", "New body content; use - to read from stdin") + + allowDash(cmd, "flag:body") return cmd } diff --git a/internal/commands/notes.go b/internal/commands/notes.go index f43d41f35..0405e2769 100644 --- a/internal/commands/notes.go +++ b/internal/commands/notes.go @@ -31,7 +31,7 @@ person, so there is nothing to list and no id to pass. basecamp notes show basecamp notes set "Remember to follow up on the Q3 rollout" basecamp notes set --file notes.md - cat notes.md | basecamp notes set`, + cat notes.md | basecamp notes set -`, Annotations: map[string]string{ "agent_notes": "Account-wide and personal — no --in <project> needed.\n" + "Singleton: no id. 'set' replaces the whole note; it does not append.", @@ -108,16 +108,17 @@ func newNotesSetCmd() *cobra.Command { Short: "Replace your personal note", Long: `Replace your personal note with new content. -Content comes from a positional argument, --file, or piped stdin. Markdown is -converted to HTML, since the note is a rich text field — passing raw text -through would store escaped markup rather than formatting. +Content comes from a positional argument or --file; either accepts - to read +from stdin. Markdown is converted to HTML, since the note is a rich text +field — passing raw text through would store escaped markup rather than +formatting. This replaces the whole note; it does not append. The first write creates the note, so there is no separate "create" step. basecamp notes set "Follow up with Ann on the rollout" basecamp notes set --file notes.md - cat notes.md | basecamp notes set + cat notes.md | basecamp notes set - Attachments are out of scope: this writes the note body only. @@ -163,44 +164,37 @@ a destructive verb deserves its own review, not a rider on a bump.`, }, } - cmd.Flags().StringVarP(&file, "file", "f", "", "Read note content from a file") + cmd.Flags().StringVarP(&file, "file", "f", "", "Read note content from a file; use - to read from stdin") + + allowDash(cmd, "arg:0", "flag:file") return cmd } -// notesContent resolves the note body from exactly one of the three inputs. -// -// Naming two sources is a usage error rather than a silent precedence rule: a +// notesContent resolves the note body from exactly one of two inputs: the +// positional argument (where "-" reads stdin) or --file (where "-" also reads +// stdin). Naming both is a usage error rather than a silent precedence rule: a // caller who passes both an argument and --file has a wrong expectation about // which one wins, and this command overwrites the whole note. // -// All three sources are detected before any of them is chosen. Checking stdin -// only after an argument and --file had been ruled out made -// `generate | basecamp notes set --file fallback.md` overwrite the note from -// the file and discard the generated body without a word — the precise failure -// this function exists to prevent, in the one command that replaces everything. +// A pipe without "-" is deliberately not consumed as an implicit third source. +// Reading it silently made `generate | basecamp notes set --file fallback.md` +// a coin-flip over which body survives; requiring the explicit "-" makes the +// caller name the source in the one command that replaces everything. func notesContent(cmd *cobra.Command, args []string, file string) (string, error) { positional := strings.Join(args, " ") - piped, ok, err := readPipedStdin(cmd) - if err != nil { - return "", err - } - // An empty pipe is not a source. A redirected-but-empty stdin carries no - // body to lose, so it must not turn a valid `--file` call into an error. - hasPipe := ok && strings.TrimSpace(piped) != "" - - named := 0 - for _, present := range []bool{file != "", positional != "", hasPipe} { - if present { - named++ - } - } - if named > 1 { - return "", output.ErrUsage("pass note content as an argument, with --file, or on stdin — not more than one") + if file != "" && positional != "" { + return "", output.ErrUsage("pass note content as an argument or with --file — not both") } switch { + case file == "-": + content, err := readStdinContent(cmd, "--file") + if err != nil { + return "", err + } + return notesRequireContent(content) case file != "": data, err := os.ReadFile(file) if err != nil { @@ -208,14 +202,16 @@ func notesContent(cmd *cobra.Command, args []string, file string) (string, error } return notesRequireContent(string(data)) case positional != "": - return notesRequireContent(positional) - case hasPipe: - return notesRequireContent(piped) + content, err := resolveContentValue(cmd, positional, 0, "[content]") + if err != nil { + return "", err + } + return notesRequireContent(content) } return "", output.ErrUsageHint( "note content is required", - `Pass it as an argument, with --file, or on stdin: basecamp notes set "..."`, + `Pass it as an argument, with --file, or pipe it and pass "-": basecamp notes set -`, ) } diff --git a/internal/commands/notes_test.go b/internal/commands/notes_test.go index e89b12932..4b960158d 100644 --- a/internal/commands/notes_test.go +++ b/internal/commands/notes_test.go @@ -124,13 +124,13 @@ func TestNotesSetReadsFromAFile(t *testing.T) { assert.NotContains(t, body.Note.Content, "# Heading", "raw Markdown must not reach the wire") } -func TestNotesSetReadsPipedStdin(t *testing.T) { +func TestNotesSetReadsDashFromStdin(t *testing.T) { app, transport, _ := setupPersonalFeedApp(t, notesUpdateRoute()) cmd := NewNotesCmd() cmd.SetIn(strings.NewReader("piped note body")) - require.NoError(t, executeRecordingCommand(cmd, app, "set")) + require.NoError(t, executeRecordingCommand(cmd, app, "set", "-")) var body struct { Note struct { @@ -141,6 +141,38 @@ func TestNotesSetReadsPipedStdin(t *testing.T) { assert.Contains(t, body.Note.Content, "piped note body") } +func TestNotesSetReadsDashFileFromStdin(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, notesUpdateRoute()) + + cmd := NewNotesCmd() + cmd.SetIn(strings.NewReader("piped note body")) + + require.NoError(t, executeRecordingCommand(cmd, app, "set", "--file", "-")) + + var body struct { + Note struct { + Content string `json:"content"` + } `json:"note"` + } + require.NoError(t, json.Unmarshal([]byte(transport.last(t).Body), &body)) + assert.Contains(t, body.Note.Content, "piped note body") +} + +// A pipe without "-" is not consumed. With no other source named, the error +// teaches the explicit placeholder instead of silently reading the pipe. +func TestNotesSetBarePipeErrorsWithDashHint(t *testing.T) { + app, transport, _ := setupPersonalFeedApp(t, notesUpdateRoute()) + + cmd := NewNotesCmd() + cmd.SetIn(strings.NewReader("piped note body")) + + err := executeRecordingCommand(cmd, app, "set") + + outErr := requireBookmarksUsageError(t, err) + assert.Contains(t, outErr.Hint, "notes set -") + assert.Empty(t, transport.recorded(), "a rejected write must not reach the server") +} + // set replaces the whole note, so the failure modes that would silently erase // it are rejected before the request rather than written through. func TestNotesSetRejectsAmbiguousOrEmptyInput(t *testing.T) { @@ -169,19 +201,20 @@ func TestNotesSetRejectsAmbiguousOrEmptyInput(t *testing.T) { } } -// A piped body must never lose to a flag. `generate | basecamp notes set --file -// fallback.md` used to overwrite the note from the file and throw the generated -// body away, because stdin was only consulted after --file had been ruled out. -func TestNotesSetRejectsPipedContentAlongsideAnotherSource(t *testing.T) { +// A pipe is only ever a source through an explicit "-". When another source is +// named, the unclaimed pipe is ignored — the CLI-wide rule since bare-pipe +// reads were removed — rather than triggering the old ambiguity error. +func TestNotesSetIgnoresUnclaimedPipeWhenSourceIsNamed(t *testing.T) { populated := filepath.Join(t.TempDir(), "note.md") require.NoError(t, os.WriteFile(populated, []byte("from the file"), 0o600)) for _, tc := range []struct { name string args []string + want string }{ - {"pipe and --file together", []string{"set", "--file", populated}}, - {"pipe and an argument together", []string{"set", "inline"}}, + {"pipe and --file together", []string{"set", "--file", populated}, "from the file"}, + {"pipe and an argument together", []string{"set", "inline"}, "inline"}, } { t.Run(tc.name, func(t *testing.T) { app, transport, _ := setupPersonalFeedApp(t, notesUpdateRoute()) @@ -189,14 +222,33 @@ func TestNotesSetRejectsPipedContentAlongsideAnotherSource(t *testing.T) { cmd := NewNotesCmd() cmd.SetIn(strings.NewReader("piped note body")) - err := executeRecordingCommand(cmd, app, tc.args...) + require.NoError(t, executeRecordingCommand(cmd, app, tc.args...)) - requireBookmarksUsageError(t, err) - assert.Empty(t, transport.recorded(), "a rejected write must not reach the server") + var body struct { + Note struct { + Content string `json:"content"` + } `json:"note"` + } + require.NoError(t, json.Unmarshal([]byte(transport.last(t).Body), &body)) + assert.Contains(t, body.Note.Content, tc.want) + assert.NotContains(t, body.Note.Content, "piped note body") }) } } +// Naming both explicit sources is still an ambiguity error. +func TestNotesSetRejectsArgumentAndFileTogether(t *testing.T) { + populated := filepath.Join(t.TempDir(), "note.md") + require.NoError(t, os.WriteFile(populated, []byte("from the file"), 0o600)) + + app, transport, _ := setupPersonalFeedApp(t, notesUpdateRoute()) + + err := executeRecordingCommand(NewNotesCmd(), app, "set", "inline", "--file", populated) + + requireBookmarksUsageError(t, err) + assert.Empty(t, transport.recorded(), "a rejected write must not reach the server") +} + // An empty pipe carries no body to lose, so it must not turn an otherwise valid // --file call into an ambiguity error. func TestNotesSetIgnoresAnEmptyPipeAlongsideAFile(t *testing.T) { diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 0ec4164e6..e696eaeef 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -274,6 +274,11 @@ func newProjectsCreateCmd() *cobra.Command { return fmt.Errorf("app not initialized") } + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + // Resolve account if not configured (enables interactive prompt) if err := ensureAccount(cmd, app); err != nil { return err @@ -296,7 +301,9 @@ func newProjectsCreateCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&description, "description", "d", "", "Project description") + cmd.Flags().StringVarP(&description, "description", "d", "", "Project description; use - to read from stdin") + + allowDash(cmd, "flag:description") return cmd } @@ -324,16 +331,22 @@ Examples: return fmt.Errorf("app not initialized") } - // Resolve account if not configured (enables interactive prompt) - if err := ensureAccount(cmd, app); err != nil { - return err - } - projectID, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return output.ErrUsage("Invalid project ID") } + // Syntactic checks first, then "-", then account and network. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + + // Resolve account if not configured (enables interactive prompt) + if err := ensureAccount(cmd, app); err != nil { + return err + } + // For update, we need to provide name (required by SDK) // If only description is provided, we need to fetch current name first updateName := name @@ -372,7 +385,9 @@ Examples: } cmd.Flags().StringVarP(&name, "name", "n", "", "New name") - cmd.Flags().StringVarP(&description, "description", "d", "", "New description") + cmd.Flags().StringVarP(&description, "description", "d", "", "New description; use - to read from stdin") + + allowDash(cmd, "flag:description") return cmd } diff --git a/internal/commands/schedule.go b/internal/commands/schedule.go index 743b495ef..f4f7db832 100644 --- a/internal/commands/schedule.go +++ b/internal/commands/schedule.go @@ -428,11 +428,6 @@ func newScheduleCreateCmd(project, scheduleID *string) *cobra.Command { return missingArg(cmd, "<summary>") } - app := appctx.FromContext(cmd.Context()) - if err := ensureAccount(cmd, app); err != nil { - return err - } - if startsAt == "" { return output.ErrUsage("--starts-at required (ISO 8601 datetime)") } @@ -446,6 +441,18 @@ func newScheduleCreateCmd(project, scheduleID *string) *cobra.Command { return err } + // Local validation, then "-", then account: a bad stdin gets the + // stdin error rather than "--account is required". + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + return runScheduleCreate(cmd, app, *project, *scheduleID, entrySummary, startsAt, endsAt, description, allDay, notify, visibleToClients, participants, subscribe, noSubscribe, attachFiles) }, } @@ -456,13 +463,15 @@ func newScheduleCreateCmd(project, scheduleID *string) *cobra.Command { cmd.Flags().StringVar(&startsAt, "start", "", "Start time (alias)") cmd.Flags().StringVar(&endsAt, "ends-at", "", "End time (ISO 8601)") cmd.Flags().StringVar(&endsAt, "end", "", "End time (alias)") - cmd.Flags().StringVar(&description, "description", "", "Detailed description") + cmd.Flags().StringVar(&description, "description", "", "Detailed description; use - to read from stdin") cmd.Flags().StringVar(&description, "desc", "", "Description (alias)") cmd.Flags().BoolVar(&allDay, "all-day", false, "Mark as all-day event") cmd.Flags().BoolVar(¬ify, "notify", false, "Notify participants") cmd.Flags().StringVar(&participants, "participants", "", "Comma-separated person IDs") cmd.Flags().StringVar(&participants, "people", "", "Person IDs (alias)") cmd.Flags().StringVar(&subscribe, "subscribe", "", "Subscribe specific people (comma-separated names, emails, IDs, or \"me\")") + + allowDash(cmd, "flag:description", "flag:desc") cmd.Flags().BoolVar(&noSubscribe, "no-subscribe", false, "Don't subscribe anyone else (silent, no notifications)") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") cmd.Flags().BoolVar(&visibleToClients, "visible-to-clients", false, "Make the schedule entry visible to clients on the project (omit for the server default; client-authenticated callers always post client-visible)") @@ -613,12 +622,23 @@ You can pass either an entry ID or a Basecamp URL: Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) - if err := ensureAccount(cmd, app); err != nil { + + // Extract ID and project from URL if provided. Purely syntactic, + // so it precedes the stdin read like every other target-ID check. + // (This command never rejects a malformed entry ID locally — the + // ParseInt below discards its error and the server answers. That + // predates this change and is left alone.) + entryID, urlProjectID := extractWithProject(args[0]) + + // Syntactic checks first, then "-", then account and network. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { return err } - // Extract ID and project from URL if provided - entryID, urlProjectID := extractWithProject(args[0]) + if err := ensureAccount(cmd, app); err != nil { + return err + } // Resolve project - use URL > flag > config, with interactive fallback projectID := *project @@ -764,7 +784,7 @@ You can pass either an entry ID or a Basecamp URL: cmd.Flags().StringVar(&startsAt, "start", "", "Start time (alias)") cmd.Flags().StringVar(&endsAt, "ends-at", "", "End time (ISO 8601)") cmd.Flags().StringVar(&endsAt, "end", "", "End time (alias)") - cmd.Flags().StringVar(&description, "description", "", "Detailed description") + cmd.Flags().StringVar(&description, "description", "", "Detailed description; use - to read from stdin") cmd.Flags().StringVar(&description, "desc", "", "Description (alias)") cmd.Flags().BoolVar(&allDay, "all-day", false, "Mark as all-day event") cmd.Flags().BoolVar(¬ify, "notify", false, "Notify participants") @@ -772,6 +792,8 @@ You can pass either an entry ID or a Basecamp URL: cmd.Flags().StringVar(&participants, "people", "", "Person IDs (alias)") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") + allowDash(cmd, "flag:description", "flag:desc") + return cmd } diff --git a/internal/commands/stdin.go b/internal/commands/stdin.go new file mode 100644 index 000000000..38986e780 --- /dev/null +++ b/internal/commands/stdin.go @@ -0,0 +1,354 @@ +package commands + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/stdinarg" +) + +// This file is the single home for "-" (read from stdin) handling. +// +// Tier 1: commands that accept content register where "-" is honored via +// allowDash and resolve it with resolveContentArg / resolveContentValue. +// Tier 2: every other exact "-" — positional or flag value — is caught by the +// dash guard installed over the whole command tree: when stdin is piped, a +// stray "-" is ambiguous (the caller almost certainly meant "read the pipe"), +// so it fails as a usage error instead of landing as literal content. On a +// TTY, a literal "-" stays legal everywhere. + +// allowDash marks where cmd accepts "-" as "read from stdin", merging with any +// tokens already registered. Tokens: "arg:0" (exact positional index), +// "arg:1+" (that index and beyond), "flag:data" (a flag value). +func allowDash(cmd *cobra.Command, tokens ...string) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + merged := strings.Join(tokens, " ") + if existing := cmd.Annotations[stdinarg.AnnotationAllowDash]; existing != "" { + merged = existing + " " + merged + } + cmd.Annotations[stdinarg.AnnotationAllowDash] = merged +} + +// readStdinContent reads content for a "-" placeholder from piped stdin. +// +// Nothing piped (a TTY) is a usage error rather than a silent read: waiting on +// an interactive terminal looks like a hang, so the error teaches the escape +// hatches instead. A piped-but-blank stdin is also refused — blank content is +// never an intentional write, and for update-style commands it would be an +// implicit clear. +// +// Trailing newlines — LF and CRLF alike — are trimmed: Markdown bodies don't +// care, but titles and boosts (16-rune limit) do, and virtually every pipe +// ends with one. Interior line breaks are untouched. +func readStdinContent(cmd *cobra.Command, what string) (string, error) { + if !stdinarg.IsPiped(cmd.InOrStdin()) { + return "", output.ErrUsageHint( + fmt.Sprintf(`%s is "-" (read from stdin) but nothing is piped`, what), + stdinEscapeHint(cmd, what), + ) + } + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return "", output.ErrUsage(fmt.Sprintf("failed to read %s from stdin: %v", what, err)) + } + content := strings.TrimRight(string(data), "\r\n") + if strings.TrimSpace(content) == "" { + return "", output.ErrUsage(fmt.Sprintf("stdin for %s is empty", what)) + } + return content, nil +} + +// stdinEscapeHint lists the ways to satisfy a "-" from an interactive +// terminal, mentioning --edit only where the command has it. +// +// The examples repeat the input that actually carried the "-": suggesting a +// bare trailing "-" for a flag would exceed the command's positional arity. +func stdinEscapeHint(cmd *cobra.Command, what string) string { + path := cmd.CommandPath() + source := "-" + if strings.HasPrefix(what, "--") { + source = what + " -" + } + hint := fmt.Sprintf( + "Pipe the content (printf '...' | %[1]s ... %[2]s), use a heredoc (%[1]s ... %[2]s <<'EOF'), or run cat | %[1]s ... %[2]s and type the content, ending with Ctrl-D", + path, source) + if cmd.Flags().Lookup("edit") != nil { + hint += "; or compose with --edit" + } + return hint +} + +// resolveContentArg resolves the join-all positional content pattern: exactly +// ["-"] reads stdin, any other args join with spaces. A "-" mixed in with +// other tokens is a usage error — it can't be both stdin and part of the +// joined text, and pre-guard versions silently posted the literal join. +// Every join-all site names its positional <content>, so errors do too. +// +// argsOffset is the index of args[0] in the command's full positional list, +// so a "-" placed at or after the "--" separator stays literal. +func resolveContentArg(cmd *cobra.Command, args []string, argsOffset int) (string, error) { + dashes := 0 + for i, a := range args { + if a == "-" && !afterDashSeparator(cmd, argsOffset+i) { + dashes++ + } + } + switch { + case dashes == 0: + return strings.Join(args, " "), nil + case len(args) == 1: + return readStdinContent(cmd, "<content>") + default: + return "", output.ErrUsage(`"-" (stdin) must be the only <content> argument`) + } +} + +// resolveContentValue resolves a single content value — an exact positional +// (pass its index) or a flag value (pass argIndex -1). Exactly "-" reads +// stdin; a positional "-" at or after the "--" separator stays literal. +func resolveContentValue(cmd *cobra.Command, value string, argIndex int, what string) (string, error) { + if value != "-" || (argIndex >= 0 && afterDashSeparator(cmd, argIndex)) { + return value, nil + } + return readStdinContent(cmd, what) +} + +// afterDashSeparator reports whether the positional at index came after the +// "--" separator, making it literal by definition. +func afterDashSeparator(cmd *cobra.Command, index int) bool { + lenAtDash := cmd.ArgsLenAtDash() + return lenAtDash >= 0 && index >= lenAtDash +} + +// InstallDashGuard wraps every runnable command in the tree with the tier-2 +// dash guard. It wraps the Args validator: cobra runs ValidateArgs after flag +// parsing (so Changed and ArgsLenAtDash are available) but before the +// persistent pre-run chain, PreRunE, and required-flag validation — so a +// stray "-" is rejected before any lifecycle side effect (config hardening, +// the update check) and before a competing usage error can shadow it. It is +// not a PersistentPreRunE hook because cobra runs only the innermost one — +// the agent hook already shadows the root's, and any future subtree would +// silently lose the guard. A nil Args means ArbitraryArgs (always nil), so +// wrapping it is behavior-preserving. +func InstallDashGuard(root *cobra.Command) { + // The root's nil Args is load-bearing: cobra's Find() rejects unknown + // subcommands (legacyArgs) only while Args == nil, so wrapping it would + // turn "basecamp unknowncmd" into a quickstart run. Guard its RunE + // instead, so "printf x | basecamp -" still gets the stray-dash error + // rather than silently running quickstart and ignoring the pipe. RunE is + // later than Args validation, but the root's pre-run work (config + // hardening, the update check) neither reads stdin nor writes content — + // and its one TUI path, the first-run wizard, is stdin-gated by + // stdinarg.InteractiveStdio, so piped stdin routes to the non-interactive + // summary instead of a wizard that would eat the pipe. + skipRootArgs := root.Args == nil && !root.HasParent() && root.HasSubCommands() + switch { + case !root.Runnable(): + case skipRootArgs: + existing := root.RunE + root.RunE = func(cmd *cobra.Command, args []string) error { + if err := guardDashArgs(cmd, args); err != nil { + return err + } + return existing(cmd, args) + } + default: + existing := root.Args + root.Args = func(cmd *cobra.Command, args []string) error { + if err := guardDashArgs(cmd, args); err != nil { + return err + } + if existing != nil { + return existing(cmd, args) + } + return nil + } + } + for _, sub := range root.Commands() { + InstallDashGuard(sub) + } +} + +// guardDashArgs enforces the tier-2 policy for one invocation: +// +// 1. Collect every exact "-" — positionals before the "--" separator, plus +// changed string-ish flags whose value (or element) is exactly "-". +// 2. More than one allowed "-" can never be satisfied by one stdin, so that +// fails regardless of pipe state. +// 3. A disallowed "-" combined with piped stdin is ambiguous — the caller +// meant the pipe — so it fails with a hint naming the offender. +// 4. Otherwise pass through: a TTY literal "-" stays legal everywhere. +func guardDashArgs(cmd *cobra.Command, args []string) error { + allow := stdinarg.ParseAllow(cmd.Annotations[stdinarg.AnnotationAllowDash]) + + allowed := 0 + var disallowedArgs, disallowedFlags []string + + for i, a := range args { + if a != "-" || afterDashSeparator(cmd, i) { + continue + } + if allow.Arg(i) { + allowed++ + } else { + disallowedArgs = append(disallowedArgs, positionalName(cmd, i)) + } + } + + for _, group := range changedFlagGroups(cmd) { + dashes := 0 + switch group.value.Type() { + case "string": + if group.value.String() == "-" { + dashes = 1 + } + case "stringArray", "stringSlice": + if sv, ok := group.value.(pflag.SliceValue); ok { + for _, v := range sv.GetSlice() { + if v == "-" { + dashes++ + } + } + } + default: + continue + } + if dashes == 0 { + continue + } + if group.allowed(allow) { + allowed += dashes + } else { + disallowedFlags = append(disallowedFlags, group.label()) + } + } + + if allowed > 1 { + return output.ErrUsage(`only one input can read from stdin ("-") at a time`) + } + disallowed := append(append([]string{}, disallowedArgs...), disallowedFlags...) + if len(disallowed) > 0 && stdinarg.IsPiped(cmd.InOrStdin()) { + msg := fmt.Sprintf(`%s does not read stdin via "-" for %s`, + cmd.CommandPath(), strings.Join(disallowed, ", ")) + // -- only escapes positionals; a flag value has no in-line escape, so + // the honest remedy there is an unpiped stdin. Naming a concrete + // redirect would be wrong on Windows and on headless runners with no + // controlling terminal, so the hint stays at the shape of the fix. + var hints []string + if len(disallowedArgs) > 0 { + hints = append(hints, `For a literal "-" argument, pass it after the -- separator`) + } + if len(disallowedFlags) > 0 { + hints = append(hints, `For a literal "-" flag value, run the command without piped stdin`) + } + if accepts := describeAllowed(cmd, allow); accepts != "" { + hints = append(hints, "this command reads stdin when \"-\" is given as "+accepts) + } + return output.ErrUsageHint(msg, strings.Join(hints, "; ")) + } + return nil +} + +// flagGroup is one logical input: every spelling pflag has bound to the same +// backing value. Aliases (--description/--desc, --in/--project) share a single +// pflag.Value instance, so grouping on it keeps a value set through two +// spellings from counting as two stdin inputs. +type flagGroup struct { + names []string + value pflag.Value + changed bool +} + +// label names the group for a guard error. Parsed state does not record which +// spelling the caller typed — only the merged value survives — so an alias +// group names every spelling rather than guessing one and being wrong. +func (g *flagGroup) label() string { + return "--" + strings.Join(g.names, "/--") +} + +// allowed reports whether any spelling of this input is registered for stdin. +// One value, one policy: registering a single alias covers the group. +func (g *flagGroup) allowed(allow stdinarg.Allow) bool { + for _, name := range g.names { + if allow.Flag(name) { + return true + } + } + return false +} + +// changedFlagGroups returns the groups the caller actually set, in flag- +// declaration order within each group. +func changedFlagGroups(cmd *cobra.Command) []*flagGroup { + var groups []*flagGroup + index := map[pflag.Value]*flagGroup{} + cmd.Flags().VisitAll(func(f *pflag.Flag) { + group, ok := index[f.Value] + if !ok { + group = &flagGroup{value: f.Value} + index[f.Value] = group + groups = append(groups, group) + } + group.names = append(group.names, f.Name) + group.changed = group.changed || f.Changed + }) + changed := groups[:0] + for _, group := range groups { + if group.changed { + changed = append(changed, group) + } + } + return changed +} + +// positionalName names a positional for guard errors, preferring the +// placeholder from the Use string ("<name>") over a bare ordinal. +func positionalName(cmd *cobra.Command, index int) string { + if placeholders := usePlaceholders(cmd); index < len(placeholders) { + return placeholders[index] + } + return fmt.Sprintf("argument %d", index+1) +} + +// describeAllowed renders the allow set for hints: placeholder names for +// positionals, --name for flags. +func describeAllowed(cmd *cobra.Command, allow stdinarg.Allow) string { + var parts []string + placeholders := usePlaceholders(cmd) + for i, p := range placeholders { + if allow.Arg(i) { + parts = append(parts, p) + } + } + for _, token := range strings.Fields(cmd.Annotations[stdinarg.AnnotationAllowDash]) { + // --out is exempted for "-" meaning stdout, not stdin — listing it + // under "reads stdin" would teach the wrong thing. + if name, ok := strings.CutPrefix(token, "flag:"); ok && name != "out" { + parts = append(parts, "--"+name) + } + } + return strings.Join(parts, ", ") +} + +// usePlaceholders extracts the positional placeholders ("<id|url>", +// "[content]") from the command's Use string, in order. +func usePlaceholders(cmd *cobra.Command) []string { + fields := strings.Fields(cmd.Use) + if len(fields) == 0 { + return nil + } + var placeholders []string + for _, f := range fields[1:] { + if strings.HasPrefix(f, "<") || strings.HasPrefix(f, "[") { + placeholders = append(placeholders, f) + } + } + return placeholders +} diff --git a/internal/commands/stdin_integration_test.go b/internal/commands/stdin_integration_test.go new file mode 100644 index 000000000..382fa147a --- /dev/null +++ b/internal/commands/stdin_integration_test.go @@ -0,0 +1,369 @@ +package commands + +// Integration coverage for the "-" (stdin) tier-1 patterns: one command per +// resolver shape, driven through a real Execute with a mock transport. + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/auth" + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/names" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// Pattern 2: exact positional — messages create <title> [body]. +func TestMessagesCreateBodyDashReadsStdin(t *testing.T) { + transport := &mockMessageCreateTransport{} + app, _ := setupMessagesMockApp(t, transport) + + cmd := NewMessagesCmd() + cmd.SetIn(strings.NewReader("Body **from stdin**\n")) + + err := executeMessagesCommand(cmd, app, "create", "Title", "-") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBody) + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.capturedBody, &body)) + assert.Equal(t, "Title", body["subject"]) + content, _ := body["content"].(string) + assert.Contains(t, content, "<strong>from stdin</strong>") +} + +// Pattern 3: content flag — api post --data -. +func TestAPIPostDataDashReadsStdin(t *testing.T) { + transport := &mockCommentWriteTransport{} + app, _ := setupCommentsWriteTestApp(t, transport) + + cmd := NewAPICmd() + cmd.SetIn(strings.NewReader(`{"content":"from stdin"}` + "\n")) + + err := executeCommand(cmd, app, "post", "/buckets/1/todos.json", "--data", "-") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBodies) + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.capturedBodies[0], &body)) + assert.Equal(t, "from stdin", body["content"]) +} + +// Pattern 1: join-all positionals — todos create <content>. +func TestTodosCreateDashReadsStdin(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockTodoCreateTransport{} + cfg := &config.Config{AccountID: "99999", ProjectID: "123", TodolistID: "456"} + sdkClient := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, &todosTestTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(1), + ) + authMgr := auth.NewManager(cfg, nil) + app := &appctx.App{ + Config: cfg, + Auth: authMgr, + SDK: sdkClient, + Names: names.NewResolver(sdkClient, authMgr, cfg.AccountID), + Output: output.New(output.Options{Format: output.FormatJSON, Writer: &bytes.Buffer{}}), + } + + cmd := NewTodosCmd() + cmd.SetIn(strings.NewReader("Call the vendor back\n")) + + err := executeTodosCommand(cmd, app, "create", "-") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBody) + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.capturedBody, &body)) + assert.Equal(t, "Call the vendor back", body["content"], + "the trailing newline must be trimmed from a piped title") +} + +// Boost content from stdin: the trailing newline is trimmed before the 16-rune +// limit is applied, so a printf'd emoji doesn't burn a rune. +func TestBoostCreateDashReadsStdin(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockBoostTransport{} + app, _ := newBoostTestApp(transport) + + cmd := NewBoostsCmd() + cmd.SetIn(strings.NewReader("🎉\n")) + + err := executeBoostCommand(cmd, app, "create", "456", "-") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBody) + + var body map[string]any + require.NoError(t, json.Unmarshal(transport.capturedBody, &body)) + assert.Equal(t, "🎉", body["content"]) +} + +func TestBoostCreateDashOverLimitStillRejected(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + + transport := &mockBoostTransport{} + app, _ := newBoostTestApp(transport) + + cmd := NewBoostsCmd() + cmd.SetIn(strings.NewReader("seventeen chars!!\n")) + + err := executeBoostCommand(cmd, app, "create", "456", "-") + require.Error(t, err) + var e *output.Error + require.True(t, errors.As(err, &e)) + assert.Contains(t, e.Message, "Boost content too long") +} + +// Pattern 3 on an update: todos update --description -. +func TestTodosUpdateDescriptionDashReadsStdin(t *testing.T) { + transport := &mockCommentWriteTransport{} + app, _ := setupCommentsWriteTestApp(t, transport) + + cmd := NewTodosCmd() + cmd.SetIn(strings.NewReader("New **details**\n")) + + err := executeCommand(cmd, app, "update", "789", "--description", "-") + require.NoError(t, err) + require.NotEmpty(t, transport.capturedBodies) + + found := false + for _, captured := range transport.capturedBodies { + var body map[string]any + if json.Unmarshal(captured, &body) == nil { + if desc, _ := body["description"].(string); strings.Contains(desc, "<strong>details</strong>") { + found = true + } + } + } + assert.True(t, found, "the piped description should reach the wire as HTML") +} + +// countingTransport records whether any request escaped the command. +type countingTransport struct{ calls int } + +func (t *countingTransport) RoundTrip(*http.Request) (*http.Response, error) { + t.calls++ + return nil, errors.New("network disabled in tests") +} + +// trackingReader records whether stdin was ever read. +type trackingReader struct { + r *strings.Reader + read bool +} + +func (t *trackingReader) Read(p []byte) (int, error) { + t.read = true + return t.r.Read(p) +} + +func setupTransportTestApp(t *testing.T, transport http.RoundTripper) *appctx.App { + t.Helper() + t.Setenv("BASECAMP_NO_KEYRING", "1") + + cfg := &config.Config{AccountID: "99999", ProjectID: "123"} + authMgr := auth.NewManager(cfg, nil) + sdkClient := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, &testTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(1), + ) + return &appctx.App{ + Config: cfg, + Auth: authMgr, + SDK: sdkClient, + Names: names.NewResolver(sdkClient, authMgr, cfg.AccountID), + Output: output.New(output.Options{Format: output.FormatJSON, Writer: &bytes.Buffer{}}), + } +} + +// The <title> [body] creates bound their arity, so a stray trailing token is a +// usage error at Args-validation time — before "-" drains stdin and before any +// request is built. Without the bound the extra token was silently dropped +// *after* stdin had already been consumed. +func TestExactPositionalCreatesRejectExtraArgsBeforeConsumingStdin(t *testing.T) { + for _, tc := range []struct { + name string + cmd func() *cobra.Command + path []string + }{ + {"messages", NewMessagesCmd, []string{"create"}}, + {"cards", NewCardsCmd, []string{"create"}}, + {"docs", NewDocsCmd, []string{"documents", "create"}}, + } { + t.Run(tc.name, func(t *testing.T) { + transport := &countingTransport{} + app := setupTransportTestApp(t, transport) + + stdin := &trackingReader{r: strings.NewReader("body from stdin")} + cmd := tc.cmd() + InstallDashGuard(cmd) + cmd.SetIn(stdin) + + args := append(append([]string{}, tc.path...), "Title", "-", "unexpected") + err := executeCommand(cmd, app, args...) + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts at most 2 arg") + assert.False(t, stdin.read, "stdin must not be consumed before arity validation") + assert.Zero(t, transport.calls, "no request may be issued") + }) + } +} + +// A flag-borne "-" with nothing piped must suggest an escape that parses: +// "api post ... --data -", never a bare positional "-" (api post takes one). +func TestAPIPostDataDashOnTTYHintPreservesTheFlag(t *testing.T) { + transport := &countingTransport{} + app := setupTransportTestApp(t, transport) + + cmd := NewAPICmd() + InstallDashGuard(cmd) + devNullStdin(t, cmd) + + err := executeCommand(cmd, app, "post", "/buckets/1/todos.json", "--data", "-") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Hint, "api post ... --data -") + assert.Zero(t, transport.calls) +} + +// setupNoAccountApp builds an app with no account configured, so any command +// that reaches account resolution fails with "--account is required". +func setupNoAccountApp(t *testing.T, transport http.RoundTripper) *appctx.App { + t.Helper() + t.Setenv("BASECAMP_NO_KEYRING", "1") + + cfg := &config.Config{} + authMgr := auth.NewManager(cfg, nil) + sdkClient := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, &testTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(1), + ) + return &appctx.App{ + Config: cfg, + Auth: authMgr, + SDK: sdkClient, + Names: names.NewResolver(sdkClient, authMgr, cfg.AccountID), + Output: output.New(output.Options{Format: output.FormatJSON, Writer: &bytes.Buffer{}}), + } +} + +// Every "-" must be diagnosed before account, project or network work, so the +// caller gets the stdin error the feature promises instead of "--account is +// required" — or, worse, a resolution round-trip for an invocation that was +// never going to run. Driven on a TTY stdin because that error is produced by +// the resolver itself, which pins where in the sequence it ran. +func TestStdinResolvesBeforeAccountAndProject(t *testing.T) { + for _, tc := range []struct { + name string + cmd func() *cobra.Command + args []string + }{ + {"cards update --body", NewCardsCmd, []string{"update", "1", "--body", "-"}}, + {"docs create [content]", NewDocsCmd, []string{"documents", "create", "Title", "-"}}, + {"gauges create --description", NewGaugesCmd, []string{"create", "--position", "50", "--description", "-"}}, + {"gauges update --description", NewGaugesCmd, []string{"update", "1", "--description", "-"}}, + {"schedule create --description", NewScheduleCmd, []string{ + "create", "Title", "--starts-at", "2026-01-01T10:00:00Z", "--ends-at", "2026-01-01T11:00:00Z", "--description", "-", + }}, + {"templates update --description", NewTemplatesCmd, []string{"update", "1", "--description", "-"}}, + {"templates construct --description", NewTemplatesCmd, []string{"construct", "1", "--name", "P", "--description", "-"}}, + } { + t.Run(tc.name, func(t *testing.T) { + transport := &countingTransport{} + app := setupNoAccountApp(t, transport) + + cmd := tc.cmd() + InstallDashGuard(cmd) + devNullStdin(t, cmd) + + err := executeCommand(cmd, app, tc.args...) + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "nothing is piped", + "expected the stdin error, got %q", outErr.Message) + assert.NotContains(t, outErr.Message, "account") + assert.Zero(t, transport.calls, "no request may be issued") + }) + } +} + +// Two explicit content sources used to resolve by silent precedence: the +// positional won and --content was dropped, so "--content -" left the pipe +// unread and posted the positional instead. +func TestChatRejectsPositionalAlongsideContentFlag(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {"post", []string{"post", "hello", "--content", "-"}}, + {"update", []string{"update", "123", "hello", "--content", "-"}}, + } { + t.Run(tc.name, func(t *testing.T) { + transport := &countingTransport{} + app := setupTransportTestApp(t, transport) + + stdin := &trackingReader{r: strings.NewReader("from stdin")} + cmd := NewChatCmd() + InstallDashGuard(cmd) + cmd.SetIn(stdin) + + err := executeCommand(cmd, app, tc.args...) + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "--content") + assert.False(t, stdin.read, "the discarded source must not consume stdin") + assert.Zero(t, transport.calls) + }) + } +} + +// A malformed target ID is knowable from the arguments alone, so it must be +// reported without first draining the pipe: reading blocks on the producer, and +// a blank pipe would answer "stdin is empty" instead of naming the bad ID. +func TestMalformedIDRejectedBeforeReadingStdin(t *testing.T) { + for _, tc := range []struct { + name string + cmd func() *cobra.Command + args []string + }{ + {"cards update", NewCardsCmd, []string{"update", "nope", "--body", "-"}}, + {"cards column update", NewCardsCmd, []string{"column", "update", "nope", "--description", "-"}}, + {"gauges update", NewGaugesCmd, []string{"update", "nope", "--description", "-"}}, + {"templates update", NewTemplatesCmd, []string{"update", "nope", "--description", "-"}}, + {"templates construct", NewTemplatesCmd, []string{"construct", "nope", "--name", "P", "--description", "-"}}, + {"messages update", NewMessagesCmd, []string{"update", "nope", "--body", "-"}}, + {"todos update", NewTodosCmd, []string{"update", "nope", "--description", "-"}}, + {"todolists update", NewTodolistsCmd, []string{"update", "nope", "--description", "-"}}, + {"projects update", NewProjectsCmd, []string{"update", "nope", "--description", "-"}}, + {"comments update", NewCommentsCmd, []string{"update", "nope", "-"}}, + {"files update", NewFilesCmd, []string{"update", "nope", "--content", "-"}}, + } { + t.Run(tc.name, func(t *testing.T) { + transport := &countingTransport{} + app := setupTransportTestApp(t, transport) + + stdin := &trackingReader{r: strings.NewReader("body from stdin")} + cmd := tc.cmd() + InstallDashGuard(cmd) + cmd.SetIn(stdin) + + err := executeCommand(cmd, app, tc.args...) + outErr := requireUsageErr(t, err) + assert.Contains(t, strings.ToLower(outErr.Message), "invalid", + "expected the malformed-ID error, got %q", outErr.Message) + assert.False(t, stdin.read, "stdin must not be drained before the ID is validated") + assert.Zero(t, transport.calls, "no request may be issued") + }) + } +} diff --git a/internal/commands/stdin_ordering_test.go b/internal/commands/stdin_ordering_test.go new file mode 100644 index 000000000..006883231 --- /dev/null +++ b/internal/commands/stdin_ordering_test.go @@ -0,0 +1,119 @@ +package commands_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Reading stdin is the one step in a RunE that cannot be undone and cannot be +// hurried: it blocks until the producer is done. Everything that can reject the +// invocation from the arguments alone — parsing the target ID out of args[0], +// validating a file path — has to happen first, or a typo'd ID waits on a slow +// pipe and a blank one reports "stdin is empty" instead of "Invalid card ID". +// +// This was fixed at thirteen call sites by hand; the ordering is a property of +// every future one too, which is what this test holds. It reads the AST rather +// than running the commands, so it covers call sites no test exercises — but +// only syntactic forms it can recognize, listed in syntacticArgUse below. +func TestSyntacticArgChecksPrecedeStdinReads(t *testing.T) { + dir := "." + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + fset := token.NewFileSet() + var checked int + + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0) + require.NoError(t, err) + + ast.Inspect(file, func(n ast.Node) bool { + kv, ok := n.(*ast.KeyValueExpr) + if !ok { + return true + } + if key, ok := kv.Key.(*ast.Ident); !ok || (key.Name != "RunE" && key.Name != "PreRunE") { + return true + } + + firstStdinRead, firstArgCheck := token.NoPos, token.NoPos + ast.Inspect(kv.Value, func(inner ast.Node) bool { + call, ok := inner.(*ast.CallExpr) + if !ok { + return true + } + switch { + case stdinResolver(call) && !firstStdinRead.IsValid(): + firstStdinRead = call.Pos() + case syntacticArgUse(call) && !firstArgCheck.IsValid(): + firstArgCheck = call.Pos() + } + return true + }) + + if !firstStdinRead.IsValid() || !firstArgCheck.IsValid() { + return true + } + checked++ + assert.Less(t, int(firstArgCheck), int(firstStdinRead), + "%s: this command reads stdin at %s before validating its arguments at %s — "+ + "hoist the argument check above the resolver", + name, fset.Position(firstStdinRead), fset.Position(firstArgCheck)) + return true + }) + } + + require.Greater(t, checked, 10, "expected many commands to both read stdin and check args") +} + +// stdinResolver matches the two functions that can read from stdin. +func stdinResolver(call *ast.CallExpr) bool { + name, ok := call.Fun.(*ast.Ident) + return ok && (name.Name == "resolveContentValue" || name.Name == "resolveContentArg") +} + +// syntacticArgUse matches a call that derives something from args without any +// account, config, or network dependency — the checks that must come first. +// Recognizing a form by name is exactly as wide as the names listed; a new +// helper needs adding here, which is why the coverage claim above is bounded. +func syntacticArgUse(call *ast.CallExpr) bool { + name, ok := call.Fun.(*ast.Ident) + if !ok { + if sel, isSel := call.Fun.(*ast.SelectorExpr); isSel { + pkg, isPkg := sel.X.(*ast.Ident) + if !isPkg || pkg.Name != "strconv" || sel.Sel.Name != "ParseInt" { + return false + } + } else { + return false + } + } else { + switch name.Name { + case "extractID", "extractWithProject", "extractCommentWithProject": + default: + return false + } + } + + // Only when it reads the positional arguments directly. + for _, arg := range call.Args { + if index, ok := arg.(*ast.IndexExpr); ok { + if ident, ok := index.X.(*ast.Ident); ok && ident.Name == "args" { + return true + } + } + } + return false +} diff --git a/internal/commands/stdin_test.go b/internal/commands/stdin_test.go new file mode 100644 index 000000000..229d81e54 --- /dev/null +++ b/internal/commands/stdin_test.go @@ -0,0 +1,184 @@ +package commands + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// devNullStdin wires a command's stdin to /dev/null, a character device — the +// established TTY stand-in (see edit_test.go). +func devNullStdin(t *testing.T, cmd *cobra.Command) { + t.Helper() + devNull, err := os.Open(os.DevNull) + require.NoError(t, err) + t.Cleanup(func() { devNull.Close() }) + cmd.SetIn(devNull) +} + +func requireUsageErr(t *testing.T, err error) *output.Error { + t.Helper() + require.Error(t, err) + var outErr *output.Error + require.True(t, errors.As(err, &outErr), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, output.CodeUsage, outErr.Code) + return outErr +} + +func TestReadStdinContentTrimsTrailingNewlines(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetIn(strings.NewReader("🎉\n")) + + content, err := readStdinContent(cmd, "<content>") + require.NoError(t, err) + assert.Equal(t, "🎉", content) +} + +// CRLF pipes (Windows tools, curl -w) must not leave a stray \r behind — it +// would count against boost's 16-rune limit and corrupt titles. +func TestReadStdinContentTrimsTrailingCRLF(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetIn(strings.NewReader("exactly16chars!!\r\n")) + + content, err := readStdinContent(cmd, "<content>") + require.NoError(t, err) + assert.Equal(t, "exactly16chars!!", content) +} + +func TestReadStdinContentTTYIsUsageErrorWithEscapeHints(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + devNullStdin(t, cmd) + + _, err := readStdinContent(cmd, "<content>") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "nothing is piped") + assert.Contains(t, outErr.Hint, "heredoc") + assert.NotContains(t, outErr.Hint, "--edit", "no --edit flag on this command") +} + +// The hint must name the input that actually carried the "-". Suggesting a +// bare trailing "-" for a flag-borne dash would exceed the command's +// positional arity — an escape the caller cannot use. +func TestReadStdinContentTTYHintNamesTheFlag(t *testing.T) { + cmd := &cobra.Command{Use: "post <path>"} + devNullStdin(t, cmd) + + _, err := readStdinContent(cmd, "--data") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Hint, "--data -") + assert.NotContains(t, outErr.Hint, "... -)", "a bare positional dash would break arity") +} + +func TestReadStdinContentTTYHintMentionsEditWhenAvailable(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.Flags().Bool("edit", false, "") + devNullStdin(t, cmd) + + _, err := readStdinContent(cmd, "<content>") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Hint, "--edit") +} + +func TestReadStdinContentBlankPipeIsUsageError(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetIn(strings.NewReader(" \n\n")) + + _, err := readStdinContent(cmd, "<content>") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "empty") +} + +func TestResolveContentArgJoinsLiteralArgs(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + content, err := resolveContentArg(cmd, []string{"hello", "world"}, 1) + require.NoError(t, err) + assert.Equal(t, "hello world", content) +} + +func TestResolveContentArgLoneDashReadsStdin(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetIn(strings.NewReader("from stdin\n")) + + content, err := resolveContentArg(cmd, []string{"-"}, 1) + require.NoError(t, err) + assert.Equal(t, "from stdin", content) +} + +func TestResolveContentArgDashAmongOthersIsUsageError(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetIn(strings.NewReader("from stdin")) + + _, err := resolveContentArg(cmd, []string{"-", "extra"}, 1) + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "only") +} + +// A "-" placed after the -- separator is literal: parsed through a real +// Execute so ArgsLenAtDash is set. +func TestResolveContentArgDashAfterSeparatorIsLiteral(t *testing.T) { + var content string + cmd := &cobra.Command{ + Use: "x <id> <content>", + RunE: func(cmd *cobra.Command, args []string) error { + var err error + content, err = resolveContentArg(cmd, args[1:], 1) + return err + }, + } + cmd.SetIn(strings.NewReader("must not be read")) + cmd.SetArgs([]string{"123", "--", "-"}) + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "-", content) +} + +func TestResolveContentValueFlagDashReadsStdin(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetIn(strings.NewReader("flag body\n")) + + content, err := resolveContentValue(cmd, "-", -1, "--data") + require.NoError(t, err) + assert.Equal(t, "flag body", content) +} + +func TestResolveContentValueLiteralPassesThrough(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + content, err := resolveContentValue(cmd, "plain", -1, "--data") + require.NoError(t, err) + assert.Equal(t, "plain", content) +} + +func TestResolveContentValuePositionalDashAfterSeparatorIsLiteral(t *testing.T) { + var body string + cmd := &cobra.Command{ + Use: "x <title> [body]", + RunE: func(cmd *cobra.Command, args []string) error { + var err error + body, err = resolveContentValue(cmd, args[1], 1, "[body]") + return err + }, + } + cmd.SetIn(strings.NewReader("must not be read")) + cmd.SetArgs([]string{"--", "title", "-"}) + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "-", body) +} + +func TestAllowDashMergesTokens(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + allowDash(cmd, "arg:0") + allowDash(cmd, "flag:description") + assert.Equal(t, "arg:0 flag:description", cmd.Annotations["allow_dash"]) +} diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 654ba376b..80ddcce58 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -201,6 +201,11 @@ func newTemplatesCreateCmd() *cobra.Command { app := appctx.FromContext(cmd.Context()) + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { return err } @@ -234,9 +239,11 @@ func newTemplatesCreateCmd() *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "Template name") - cmd.Flags().StringVar(&description, "description", "", "Template description") + cmd.Flags().StringVar(&description, "description", "", "Template description; use - to read from stdin") cmd.Flags().StringVar(&description, "desc", "", "Template description (alias)") + allowDash(cmd, "flag:description", "flag:desc") + return cmd } @@ -250,10 +257,8 @@ func newTemplatesUpdateCmd() *cobra.Command { Long: "Update an existing template's name or description.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - app := appctx.FromContext(cmd.Context()) - - if err := ensureAccount(cmd, app); err != nil { - return err + if name == "" && description == "" { + return noChanges(cmd) } templateID, err := strconv.ParseInt(args[0], 10, 64) @@ -261,8 +266,16 @@ func newTemplatesUpdateCmd() *cobra.Command { return output.ErrUsage("Invalid template ID") } - if name == "" && description == "" { - return noChanges(cmd) + // Syntactic checks first, then "-", then account and network. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err } // SDK requires name for update, fetch current if not provided @@ -299,9 +312,11 @@ func newTemplatesUpdateCmd() *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "New name") - cmd.Flags().StringVar(&description, "description", "", "New description") + cmd.Flags().StringVar(&description, "description", "", "New description; use - to read from stdin") cmd.Flags().StringVar(&description, "desc", "", "New description (alias)") + allowDash(cmd, "flag:description", "flag:desc") + return cmd } @@ -360,10 +375,8 @@ This is an asynchronous operation. The command returns a construction ID which can be polled via 'templates construction' until the status is "completed".`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - app := appctx.FromContext(cmd.Context()) - - if err := ensureAccount(cmd, app); err != nil { - return err + if projectName == "" { + return output.ErrUsage("--name is required (project name)") } templateID, err := strconv.ParseInt(args[0], 10, 64) @@ -371,8 +384,16 @@ which can be polled via 'templates construction' until the status is "completed" return output.ErrUsage("Invalid template ID") } - if projectName == "" { - return output.ErrUsage("--name is required (project name)") + // Syntactic checks first, then "-", then account and network. + projectDesc, err := resolveContentValue(cmd, projectDesc, -1, "--description") + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err } construction, err := app.Account().Templates().CreateProject(cmd.Context(), templateID, projectName, projectDesc) @@ -394,10 +415,12 @@ which can be polled via 'templates construction' until the status is "completed" } cmd.Flags().StringVar(&projectName, "name", "", "Project name (required)") - cmd.Flags().StringVar(&projectDesc, "description", "", "Project description") + cmd.Flags().StringVar(&projectDesc, "description", "", "Project description; use - to read from stdin") cmd.Flags().StringVar(&projectDesc, "desc", "", "Project description (alias)") _ = cmd.MarkFlagRequired("name") + allowDash(cmd, "flag:description", "flag:desc") + return cmd } diff --git a/internal/commands/todolists.go b/internal/commands/todolists.go index 9405e69a7..b6c271e30 100644 --- a/internal/commands/todolists.go +++ b/internal/commands/todolists.go @@ -288,6 +288,11 @@ func newTodolistsCreateCmd(project, todosetID *string) *cobra.Command { return fmt.Errorf("app not initialized") } + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { return err } @@ -366,9 +371,11 @@ func newTodolistsCreateCmd(project, todosetID *string) *cobra.Command { } cmd.Flags().StringVarP(todosetID, "todoset", "t", "", "Todoset ID (for projects with multiple todosets)") - cmd.Flags().StringVarP(&description, "description", "d", "", "Todolist description") + cmd.Flags().StringVarP(&description, "description", "d", "", "Todolist description; use - to read from stdin") cmd.Flags().BoolVar(&visibleToClients, "visible-to-clients", false, "Make the todolist visible to clients on the project (omit for the server default; client-authenticated callers always post client-visible)") + allowDash(cmd, "flag:description") + return cmd } @@ -395,12 +402,26 @@ You can pass either a todolist ID or a Basecamp URL: return fmt.Errorf("app not initialized") } - if err := ensureAccount(cmd, app); err != nil { + // Extract ID and project from URL if provided + todolistIDStr, urlProjectID := extractWithProject(args[0]) + + // Parse todolist ID as int64 + todolistID, err := strconv.ParseInt(todolistIDStr, 10, 64) + if err != nil { + return output.ErrUsage("Invalid todolist ID") + } + + // Syntactic checks first, then "-", then account and network: a + // malformed ID is answered without waiting on the producer, and a + // blank pipe cannot mask it. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { return err } - // Extract ID and project from URL if provided - todolistIDStr, urlProjectID := extractWithProject(args[0]) + if err := ensureAccount(cmd, app); err != nil { + return err + } // Resolve project - use URL > flag > config, with interactive fallback projectID := *project @@ -419,12 +440,6 @@ You can pass either a todolist ID or a Basecamp URL: } } - // Parse todolist ID as int64 - todolistID, err := strconv.ParseInt(todolistIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid todolist ID") - } - // Build SDK request req := &basecamp.UpdateTodolistRequest{ Name: name, @@ -457,7 +472,9 @@ You can pass either a todolist ID or a Basecamp URL: } cmd.Flags().StringVarP(&name, "name", "n", "", "New name") - cmd.Flags().StringVarP(&description, "description", "d", "", "New description") + cmd.Flags().StringVarP(&description, "description", "d", "", "New description; use - to read from stdin") + + allowDash(cmd, "flag:description") return cmd } diff --git a/internal/commands/todos.go b/internal/commands/todos.go index 6850ed403..0fb7f9052 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1243,7 +1243,10 @@ project's to-do set instead, outside any list: basecamp todos create "Call the vendor back" --loose --in <project> ---loose needs no list, so it neither prompts for one nor accepts --list.`, +--loose needs no list, so it neither prompts for one nor accepts --list. + +Use - as the content argument to read the todo title from stdin: + printf 'Call the vendor back' | basecamp todos create - --in <project>`, RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) if app == nil { @@ -1254,11 +1257,19 @@ project's to-do set instead, outside any list: if len(args) == 0 { return missingArg(cmd, "<content>") } - content := strings.Join(args, " ") + content, err := resolveContentArg(cmd, args, 0) + if err != nil { + return err + } if strings.TrimSpace(content) == "" { return cmd.Help() } + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { return err } @@ -1437,13 +1448,15 @@ project's to-do set instead, outside any list: cmd.Flags().StringVar(&assignee, "assignee", "", "Assignee ID") cmd.Flags().StringVar(&assignee, "to", "", "Assignee ID (alias for --assignee)") cmd.Flags().StringVarP(&due, "due", "d", "", "Due date (YYYY-MM-DD)") - cmd.Flags().StringVar(&description, "description", "", "Extended description (Markdown)") + cmd.Flags().StringVar(&description, "description", "", "Extended description (Markdown); use - to read from stdin") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") cmd.Flags().StringVar(¬ifyOnCompletion, "notify-on-completion", "", "People to notify when done (names or IDs, comma-separated)") // Not --todoset: that flag already means "which to-do set", and this one // means "no list at all". cmd.Flags().BoolVar(&loose, "loose", false, "Create on the to-do set, outside any list") + allowDash(cmd, "arg:0+", "flag:description") + // Register tab completion for flags completer := completion.NewCompleter(nil) _ = cmd.RegisterFlagCompletionFunc("project", completer.ProjectNameCompletion()) @@ -1537,6 +1550,22 @@ Set or clear the people notified when the todo is completed: return noChanges(cmd) } + // Extract ID from URL if provided + todoIDStr := extractID(args[0]) + todoID, err := strconv.ParseInt(todoIDStr, 10, 64) + if err != nil { + return output.ErrUsage("Invalid todo ID") + } + + // Syntactic checks first, then "-", then account and network: a + // malformed ID is answered without waiting on the producer, and a + // blank pipe cannot mask it. Only an exact "-" reads stdin; + // --description "" stays the clear idiom. + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if app == nil { return fmt.Errorf("app not initialized") @@ -1546,13 +1575,6 @@ Set or clear the people notified when the todo is completed: return err } - // Extract ID from URL if provided - todoIDStr := extractID(args[0]) - todoID, err := strconv.ParseInt(todoIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid todo ID") - } - // Pre-Edit validation and resolution — no todo HTTP happens here. // Image uploads are deferred into the Edit closure so a missing // todo can't orphan uploaded attachments. @@ -1664,7 +1686,7 @@ Set or clear the people notified when the todo is completed: } cmd.Flags().StringVarP(&title, "title", "t", "", "Todo title (plain text)") - cmd.Flags().StringVar(&description, "description", "", "Extended description (Markdown)") + cmd.Flags().StringVar(&description, "description", "", "Extended description (Markdown); use - to read from stdin") cmd.Flags().StringVar(&assignee, "assignee", "", "Assignees (names or IDs, comma-separated)") cmd.Flags().StringVar(&assignee, "to", "", "Assignees (alias for --assignee)") cmd.Flags().StringVarP(&due, "due", "d", "", "Due date (natural language or YYYY-MM-DD)") @@ -1682,6 +1704,8 @@ Set or clear the people notified when the todo is completed: _ = cmd.RegisterFlagCompletionFunc("to", completer.PeopleNameCompletion()) _ = cmd.RegisterFlagCompletionFunc("notify-on-completion", completer.PeopleNameCompletion()) + allowDash(cmd, "flag:description") + return cmd } @@ -1873,6 +1897,12 @@ Examples: basecamp todos sweep --in <project> --assignee me --comment "Following up"`, RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) + + comment, err := resolveContentValue(cmd, comment, -1, "--comment") + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { return err } @@ -2020,11 +2050,13 @@ Examples: cmd.Flags().StringVarP(&todoset, "todoset", "t", "", "Todoset ID (for projects with multiple todosets)") cmd.Flags().StringVar(&assignee, "assignee", "", "Filter by assignee") cmd.Flags().BoolVar(&overdueOnly, "overdue", false, "Filter overdue todos") - cmd.Flags().StringVarP(&comment, "comment", "c", "", "Comment to add to matching todos") + cmd.Flags().StringVarP(&comment, "comment", "c", "", "Comment to add to matching todos; use - to read from stdin") cmd.Flags().BoolVar(&complete, "complete", false, "Mark matching todos as complete") cmd.Flags().BoolVar(&complete, "done", false, "Mark matching todos as complete (alias)") cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + allowDash(cmd, "flag:comment") + // Register tab completion for flags completer := completion.NewCompleter(nil) _ = cmd.RegisterFlagCompletionFunc("project", completer.ProjectNameCompletion()) diff --git a/internal/stdinarg/stdinarg.go b/internal/stdinarg/stdinarg.go new file mode 100644 index 000000000..193643bc8 --- /dev/null +++ b/internal/stdinarg/stdinarg.go @@ -0,0 +1,109 @@ +// Package stdinarg carries the shared vocabulary for "-" (read from stdin) +// argument handling: the cobra annotation that marks where a command accepts +// "-", and pipe detection for deciding whether a stray "-" is ambiguous. +// +// It is a leaf package because both internal/commands (which resolves "-" and +// installs the guard) and internal/cli (which surfaces the annotation in agent +// help) need the same annotation key, and cli already depends on commands. +package stdinarg + +import ( + "io" + "os" + "strconv" + "strings" +) + +// AnnotationAllowDash is the cmd.Annotations key marking where a command +// accepts "-" as "read from stdin". The value is a space-separated list of +// tokens: "arg:0" (exact positional index), "arg:1+" (that index and beyond), +// "flag:data" (the --data flag). Everything not listed is guarded: a literal +// "-" there combined with piped stdin is rejected as ambiguous. +const AnnotationAllowDash = "allow_dash" + +// Allow is the parsed form of an AnnotationAllowDash value. +type Allow struct { + args map[int]bool + argsFrom int // "arg:N+" allows every index >= argsFrom; -1 when absent + flags map[string]bool +} + +// ParseAllow parses a space-separated token list ("arg:0 arg:1+ flag:data") +// into an Allow. Unrecognized tokens are ignored rather than failing: the +// annotation is authored in-repo and covered by tests, so a typo shows up as +// a guarded (rejected) input, not a silent bypass. +func ParseAllow(s string) Allow { + allow := Allow{argsFrom: -1} + for _, token := range strings.Fields(s) { + switch { + case strings.HasPrefix(token, "arg:"): + spec := strings.TrimPrefix(token, "arg:") + open := strings.HasSuffix(spec, "+") + if n, err := strconv.Atoi(strings.TrimSuffix(spec, "+")); err == nil { + if open { + if allow.argsFrom == -1 || n < allow.argsFrom { + allow.argsFrom = n + } + } else { + if allow.args == nil { + allow.args = map[int]bool{} + } + allow.args[n] = true + } + } + case strings.HasPrefix(token, "flag:"): + if allow.flags == nil { + allow.flags = map[string]bool{} + } + allow.flags[strings.TrimPrefix(token, "flag:")] = true + } + } + return allow +} + +// Arg reports whether "-" is allowed at positional index i. +func (a Allow) Arg(i int) bool { + return a.args[i] || (a.argsFrom != -1 && i >= a.argsFrom) +} + +// Flag reports whether "-" is allowed as the named flag's value. +func (a Allow) Flag(name string) bool { + return a.flags[name] +} + +// Empty reports whether the Allow permits "-" nowhere. +func (a Allow) Empty() bool { + return len(a.args) == 0 && a.argsFrom == -1 && len(a.flags) == 0 +} + +// IsPiped reports whether the reader carries piped (redirected) input rather +// than an interactive terminal. A non-*os.File reader — the cmd.SetIn test +// seam — always counts as piped. For a real file, a character device means a +// terminal; anything else (pipe, regular file redirect) is piped input. +func IsPiped(r io.Reader) bool { + f, ok := r.(*os.File) + if !ok { + return true + } + fi, err := f.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice == 0 +} + +// InteractiveStdio reports whether both stdout and stdin are character +// devices — the floor for launching anything that draws to the terminal and +// reads keystrokes. A TUI (picker, wizard) reads key events from stdin, so a +// pipe or redirected file can never drive one — and when the command is +// consuming piped content (a "-" stdin input), a TUI would eat that content +// as key events. +func InteractiveStdio() bool { + for _, f := range []*os.File{os.Stdout, os.Stdin} { + fi, err := f.Stat() + if err != nil || fi.Mode()&os.ModeCharDevice == 0 { + return false + } + } + return true +} diff --git a/internal/stdinarg/stdinarg_test.go b/internal/stdinarg/stdinarg_test.go new file mode 100644 index 000000000..d80c06579 --- /dev/null +++ b/internal/stdinarg/stdinarg_test.go @@ -0,0 +1,105 @@ +package stdinarg + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAllowExactArg(t *testing.T) { + allow := ParseAllow("arg:0") + assert.True(t, allow.Arg(0)) + assert.False(t, allow.Arg(1)) + assert.False(t, allow.Flag("data")) + assert.False(t, allow.Empty()) +} + +func TestParseAllowOpenEndedArg(t *testing.T) { + allow := ParseAllow("arg:1+") + assert.False(t, allow.Arg(0)) + assert.True(t, allow.Arg(1)) + assert.True(t, allow.Arg(5)) +} + +func TestParseAllowFlags(t *testing.T) { + allow := ParseAllow("flag:data flag:out") + assert.True(t, allow.Flag("data")) + assert.True(t, allow.Flag("out")) + assert.False(t, allow.Flag("body")) + assert.False(t, allow.Arg(0)) +} + +func TestParseAllowMixed(t *testing.T) { + allow := ParseAllow("arg:0 arg:2+ flag:description") + assert.True(t, allow.Arg(0)) + assert.False(t, allow.Arg(1)) + assert.True(t, allow.Arg(2)) + assert.True(t, allow.Arg(3)) + assert.True(t, allow.Flag("description")) +} + +func TestParseAllowEmptyAndGarbage(t *testing.T) { + assert.True(t, ParseAllow("").Empty()) + assert.True(t, ParseAllow("arg:x bogus flag").Empty()) +} + +func TestIsPipedNonFileReader(t *testing.T) { + assert.True(t, IsPiped(strings.NewReader("piped"))) +} + +func TestIsPipedCharDevice(t *testing.T) { + devNull, err := os.Open(os.DevNull) + require.NoError(t, err) + defer devNull.Close() + + assert.False(t, IsPiped(devNull)) +} + +func TestIsPipedRegularFile(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "stdin") + require.NoError(t, err) + defer f.Close() + + assert.True(t, IsPiped(f)) +} + +// TestInteractiveStdio proves TUIs are gated off when stdin is piped: a +// wizard or picker reads keystrokes from stdin, so piped stdin would be +// consumed as key events — including piped content meant for a "-" input. +func TestInteractiveStdio(t *testing.T) { + devnull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer devnull.Close() + + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer pipeR.Close() + defer pipeW.Close() + + origOut, origIn := os.Stdout, os.Stdin + t.Cleanup(func() { os.Stdout, os.Stdin = origOut, origIn }) + + // /dev/null is a character device, standing in for a terminal on both + // ends without needing a PTY. + os.Stdout, os.Stdin = devnull, devnull + if !InteractiveStdio() { + t.Fatal("expected interactive with char-device stdout and stdin") + } + + os.Stdin = pipeR + if InteractiveStdio() { + t.Fatal("expected non-interactive with piped stdin") + } + + os.Stdout, os.Stdin = pipeW, devnull + if InteractiveStdio() { + t.Fatal("expected non-interactive with piped stdout") + } +} diff --git a/internal/tui/resolve/resolve.go b/internal/tui/resolve/resolve.go index 3018737aa..65a9496dd 100644 --- a/internal/tui/resolve/resolve.go +++ b/internal/tui/resolve/resolve.go @@ -5,12 +5,12 @@ package resolve import ( "context" - "os" "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/basecamp/basecamp-cli/internal/auth" "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/stdinarg" "github.com/basecamp/basecamp-cli/internal/tui" ) @@ -101,11 +101,11 @@ func (r *Resolver) Flags() *Flags { } // IsInteractive returns true if interactive prompts can be shown. -// This checks both stdout and machine-output flags. +// This checks stdout, stdin, 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). +// set (--agent, --json, --quiet, --ids-only, --count), or if stdout or stdin 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. @@ -120,12 +120,11 @@ func (r *Resolver) IsInteractive() bool { } } - // 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 + // Both stdout and stdin must be character devices: pickers draw to + // stdout and read 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 picker would eat that content as key events. + return stdinarg.InteractiveStdio() } // ResolvedValue represents a value that was resolved, along with metadata diff --git a/internal/tui/resolve/resolve_test.go b/internal/tui/resolve/resolve_test.go new file mode 100644 index 000000000..bdd1a0235 --- /dev/null +++ b/internal/tui/resolve/resolve_test.go @@ -0,0 +1,46 @@ +package resolve + +import ( + "os" + "testing" +) + +// TestIsInteractiveRequiresStdinCharDevice proves pickers are gated off when +// stdin is piped: a Bubble Tea picker reads keystrokes from stdin, so piped +// stdin can never drive one — and when a command is consuming piped content +// (a "-" stdin input), a picker would eat that content as key events. +func TestIsInteractiveRequiresStdinCharDevice(t *testing.T) { + t.Setenv("BASECAMP_NONINTERACTIVE", "") + + devnull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer devnull.Close() + + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer pipeR.Close() + defer pipeW.Close() + + origOut, origIn := os.Stdout, os.Stdin + t.Cleanup(func() { os.Stdout, os.Stdin = origOut, origIn }) + + // /dev/null is a character device, so it stands in for a terminal on + // both ends without needing a PTY. + os.Stdout = devnull + + r := New(nil, nil, nil) + + os.Stdin = devnull + if !r.IsInteractive() { + t.Fatal("expected interactive with char-device stdout and stdin") + } + + os.Stdin = pipeR + if r.IsInteractive() { + t.Fatal("expected non-interactive with piped stdin: a picker would consume the pipe as key events") + } +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 35138fed9..cf89c67b9 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -108,6 +108,25 @@ Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule, ```bash printf '%s\n' '海报 mockup 方向稿:' '' '<bc-attachment ...>' | basecamp comments create <recording_id> - --in <project> --json ``` + `-` means "read from stdin" on every content input: content-kind positionals + (`comments create/update`, `messages create [body]`, `cards create [body]`, + `todos create`, `docs documents create [content]`, `chat post/update`, `boost create`, + `checkins answer create/update`, `notes set`) and content flags (`--data` on + `api post/put`, `--body`, `--content`, `--description`, `--comment` on + `todos sweep`, `--file` on `notes set`). Each command's `--agent` help lists + its stdin inputs. Rules: + - A pipe is **never consumed implicitly** — without `-` it is ignored (or, where + content is required and missing, the error teaches `-`). + - Only one input can read stdin per invocation. + - A literal `-` anywhere else (a title, a name, a path) **errors when stdin is + piped**. Escape a positional after the `--` separator + (`basecamp projects create -- -`); a flag value has no in-line escape — run + the command without piped stdin. + - `-` with nothing piped (interactive TTY) errors immediately instead of + hanging; use a pipe, a heredoc (`basecamp comments create <id> - <<'EOF'`), + or `--edit` where offered. + - Trailing newlines are trimmed from stdin content, so `printf 'x\n' | ... -` + posts `x` (this keeps `boost create -` inside its 16-rune limit). 6. **Project scope is mandatory for most commands** — via `--in <project>` or `.basecamp/config.json`. Cross-project exceptions: `basecamp reports assigned` for assigned work, `basecamp assignments` for structured assignment views, `basecamp reports overdue` for overdue todos, `basecamp reports schedule` for upcoming schedule across all projects, `basecamp recordings <type>` for browsing by type, `basecamp notifications` for notifications, `basecamp gauges list` for account-wide gauges, and the seven list commands covered in item 7. 7. **Account-wide listing.** `basecamp todos list --all-projects --json` lists across every project; the same flag does the same on `cards list`, `messages list`, `comments list`, `files list`, `forwards list`, and `checkins answers`. It overrides a configured project, and with no project in scope those commands already list account-wide rather than prompting. Flags that name something inside a single project are rejected there rather than silently ignored. Account-wide listings return **the first 100 items by default** — account-wide "all" is the whole account, not one project's worth. Use `--limit N` to raise the cap (it walks pages until N are collected) or `--all` for everything. `--page N` fetches exactly one page, but only on the paginated listings. @@ -1086,8 +1105,9 @@ at 250 server-side. `notes` is a single private scratchpad — one per person, no id, nothing to list. Before your first write it renders empty rather than 404ing. `set` **replaces** -the whole note (it does not append) and takes content from an argument, -`--file`, or piped stdin; Markdown is converted to HTML. +the whole note (it does not append) and takes content from an argument or +`--file` — either accepts `-` to read stdin (`cat notes.md | basecamp notes set -`); +a pipe without `-` is not consumed. Markdown is converted to HTML. ### Calendars