From b81a070f076357e035e185b17b17818efe0a7677 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 18 Aug 2026 20:45:36 -0700 Subject: [PATCH 1/8] Honor - (stdin) on every content input; reject stray - when piped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents instinctively pass - to mean "read content from stdin", but only comments create/update honored it — everywhere else the hyphen landed as literal content (a todo titled "-", a message body of "-"). Tier 1: - now reads stdin on every content-kind positional (comments create/update, checkins answer create/update, todos create, messages create [body], cards create [body], docs create [content], chat post/update, boost create, notes set) and content flag (--data on api post/put, --body, --content, --description, --comment on todos sweep, --file on notes set). Resolution lives in internal/commands/stdin.go; the shared vocabulary (allow_dash annotation, pipe detection) in the new internal/stdinarg leaf package, since internal/cli needs it too for agent help. Tier 2: everywhere else, a literal - combined with piped stdin is ambiguous — the caller almost certainly meant the pipe — so a central guard wrapped around every RunE in the tree rejects it with a usage error naming the offender, pointing at where the command does accept stdin, and teaching the -- escape for a literal hyphen. On a TTY, literal - stays legal everywhere. --out - (attachments/files download) is exempted as the stdout idiom. Behavior changes: - "comments create 123 - extra" — was a silent literal "- extra" comment, now a usage error. - "-" with TTY stdin — was hang-until-Ctrl-D, now an immediate usage error teaching the escapes (pipe, heredoc, cat |, --edit where it exists). No new --stdin flag: - is the universal idiom, and --stdin in the wild means other things (git plumbing, kubectl). - Bare-pipe auto-read removed from comments create and notes set: a pipe without - errors with a hint instead of being silently consumed. Pipes are only ever a source through an explicit -; an unclaimed pipe alongside a named source is ignored, the CLI-wide rule. - "notes set -" (piped) — was a bogus two-source error, now works; "notes set --file -" — was ENOENT on a file named -, now stdin. - Piped scripts passing literal - as a title/name/path now error; -- is the documented escape. - Stdin content gets trailing newlines trimmed (Markdown doesn't care; titles and boost's 16-rune limit do). Agent help auto-documents each command's stdin inputs from the allow_dash annotation; SKILL.md generalizes the - idiom it previously over-promised. --- e2e/stdin_dash.bats | 69 ++++++ internal/cli/root.go | 42 ++++ internal/commands/api.go | 32 ++- internal/commands/attachments.go | 3 + internal/commands/boost.go | 8 +- internal/commands/cards.go | 40 ++- internal/commands/chat.go | 35 ++- internal/commands/checkins.go | 14 +- internal/commands/commands_test.go | 1 + internal/commands/comment.go | 45 ++-- internal/commands/comment_test.go | 29 ++- internal/commands/dash_guard_test.go | 151 ++++++++++++ internal/commands/files.go | 53 +++- internal/commands/gauges.go | 17 +- internal/commands/helpers.go | 20 -- internal/commands/messages.go | 26 +- internal/commands/notes.go | 64 +++-- internal/commands/notes_test.go | 74 +++++- internal/commands/projects.go | 18 +- internal/commands/schedule.go | 19 +- internal/commands/stdin.go | 255 ++++++++++++++++++++ internal/commands/stdin_integration_test.go | 151 ++++++++++++ internal/commands/stdin_test.go | 160 ++++++++++++ internal/commands/templates.go | 27 ++- internal/commands/todolists.go | 18 +- internal/commands/todos.go | 39 ++- internal/stdinarg/stdinarg.go | 93 +++++++ internal/stdinarg/stdinarg_test.go | 67 +++++ skills/basecamp/SKILL.md | 22 +- 29 files changed, 1444 insertions(+), 148 deletions(-) create mode 100644 e2e/stdin_dash.bats create mode 100644 internal/commands/dash_guard_test.go create mode 100644 internal/commands/stdin.go create mode 100644 internal/commands/stdin_integration_test.go create mode 100644 internal/commands/stdin_test.go create mode 100644 internal/stdinarg/stdinarg.go create mode 100644 internal/stdinarg/stdinarg_test.go diff --git a/e2e/stdin_dash.bats b/e2e/stdin_dash.bats new file mode 100644 index 000000000..b56f4c7d3 --- /dev/null +++ b/e2e/stdin_dash.bats @@ -0,0 +1,69 @@ +#!/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. All cases here fail before any HTTP, 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 "projects create -- - with piped stdin passes the guard" { + # The -- separator makes the "-" literal; the command proceeds past the + # guard and fails on the (unreachable) API instead of on usage. + export BASECAMP_BASE_URL="http://127.0.0.1:1" + create_credentials + create_global_config '{"account_id": 99999}' + + run bash -c "printf 'x' | basecamp projects create --json -- -" + assert_failure + [[ "$output" != *'does not read stdin'* ]] +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 49a6c7846..ccc028354 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -20,6 +20,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 +353,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() @@ -762,6 +767,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 +837,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/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..0c8469d8b 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -851,7 +851,10 @@ 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"`, RunE: func(cmd *cobra.Command, args []string) error { @@ -866,7 +869,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 +1058,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()) @@ -1097,6 +1106,11 @@ You can pass either a card ID or a Basecamp URL: if title != "" { req.Title = &title } + content, err = resolveContentValue(cmd, content, -1, "--body") + if err != nil { + return err + } + var mentionNotice string var html string if content != "" { @@ -1160,7 +1174,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 +1183,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 +2038,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 +2106,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,6 +2131,11 @@ You can pass either a column ID or a Basecamp URL: return noChanges(cmd) } + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { @@ -2138,7 +2166,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..66d89ea97 100644 --- a/internal/commands/chat.go +++ b/internal/commands/chat.go @@ -308,15 +308,28 @@ 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. The + // positional wins over --content before "-" resolution, so only + // the winning source can consume stdin. messageContent := content + argIndex, what := -1, "--content" if len(args) > 0 { 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 +345,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 +781,19 @@ edit to rich text.`, return missingArg(cmd, "<id|url>") } + // The positional wins over --content before "-" resolution, so + // only the winning source can consume stdin. messageContent := content + argIndex, what := -1, "--content" if len(args) > 1 { 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 +983,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/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..b67d61177 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,7 @@ as backslash-n.`, return missingArg(cmd, "<content>") } - content, err := contentArgOrStdin(cmd, args[1:]) + content, err := resolveContentArg(cmd, args[1:], 1) if err != nil { return err } @@ -1114,6 +1114,8 @@ as backslash-n.`, }, } + allowDash(cmd, "arg:1+") + return cmd } @@ -1132,8 +1134,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 +1166,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 +1182,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 +1336,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..296d55575 --- /dev/null +++ b/internal/commands/dash_guard_test.go @@ -0,0 +1,151 @@ +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") +} + +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()) + } +} diff --git a/internal/commands/files.go b/internal/commands/files.go index 3f543348b..2a16f531c 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,10 @@ 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 create "Title" - --in my-project < body.md`, RunE: func(cmd *cobra.Command, args []string) error { // Show help when invoked with no arguments if len(args) == 0 { @@ -1229,7 +1245,11 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command { } content := "" if len(args) > 1 { - content = args[1] + var contentErr error + content, contentErr = resolveContentValue(cmd, args[1], 1, "[content]") + if contentErr != nil { + return contentErr + } } // Resolve subscription flags before project (fail fast on bad input) @@ -1339,6 +1359,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 +1715,13 @@ 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()) + + // 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 } @@ -1805,9 +1834,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 +1897,13 @@ 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 { + // 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) @@ -2061,9 +2099,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 +2320,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..d90fcdfe3 100644 --- a/internal/commands/gauges.go +++ b/internal/commands/gauges.go @@ -208,6 +208,10 @@ func newGaugesCreateCmd(project *string) *cobra.Command { req.Color = color } if description != "" { + description, err = resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } req.Description = description } if notify != "" { @@ -240,10 +244,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 } @@ -273,6 +279,11 @@ func newGaugesUpdateCmd() *cobra.Command { return output.ErrUsage("No changes specified (use --description)") } + description, err = resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + req := &basecamp.UpdateGaugeNeedleRequest{ Description: basecamp.Ptr(description), } @@ -295,7 +306,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/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..a9355fa39 100644 --- a/internal/commands/messages.go +++ b/internal/commands/messages.go @@ -431,7 +431,10 @@ 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" -`, RunE: func(cmd *cobra.Command, args []string) error { // Show help when invoked with no title if len(args) == 0 { @@ -449,10 +452,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 +601,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,6 +624,12 @@ You can pass either a message ID or a Basecamp URL: return noChanges(cmd) } + var err error + body, err = resolveContentValue(cmd, body, -1, "--body") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { @@ -672,7 +690,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..27e3383cc 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,6 +331,11 @@ Examples: 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 @@ -372,7 +384,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..c1c7e667b 100644 --- a/internal/commands/schedule.go +++ b/internal/commands/schedule.go @@ -446,6 +446,11 @@ func newScheduleCreateCmd(project, scheduleID *string) *cobra.Command { return err } + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + return runScheduleCreate(cmd, app, *project, *scheduleID, entrySummary, startsAt, endsAt, description, allDay, notify, visibleToClients, participants, subscribe, noSubscribe, attachFiles) }, } @@ -456,13 +461,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,6 +620,12 @@ 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()) + + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + if err := ensureAccount(cmd, app); err != nil { return err } @@ -764,7 +777,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 +785,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..43e499df3 --- /dev/null +++ b/internal/commands/stdin.go @@ -0,0 +1,255 @@ +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 are trimmed: Markdown bodies don't care, but titles and +// boosts (16-rune limit) do, and virtually every pipe ends with one. +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), + ) + } + 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), "\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. +func stdinEscapeHint(cmd *cobra.Command) string { + path := cmd.CommandPath() + hint := fmt.Sprintf( + "Pipe the content (printf '...' | %[1]s ... -), use a heredoc (%[1]s ... - <<'EOF'), or run cat | %[1]s ... - and type the content, ending with Ctrl-D", + path) + 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 RunE rather than hooking PersistentPreRunE because +// cobra runs only the innermost PersistentPreRunE — the agent hook already +// shadows the root's, and any future subtree would silently lose the guard. +// Wrapping RunE also runs after flag parsing and Args validation, with +// ArgsLenAtDash available. +func InstallDashGuard(root *cobra.Command) { + if run := root.RunE; run != nil { + root.RunE = func(cmd *cobra.Command, args []string) error { + if err := guardDashArgs(cmd, args); err != nil { + return err + } + return run(cmd, args) + } + } + 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 disallowed []string + + for i, a := range args { + if a != "-" || afterDashSeparator(cmd, i) { + continue + } + if allow.Arg(i) { + allowed++ + } else { + disallowed = append(disallowed, positionalName(cmd, i)) + } + } + + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if !f.Changed { + return + } + dashes := 0 + switch f.Value.Type() { + case "string": + if f.Value.String() == "-" { + dashes = 1 + } + case "stringArray", "stringSlice": + if sv, ok := f.Value.(pflag.SliceValue); ok { + for _, v := range sv.GetSlice() { + if v == "-" { + dashes++ + } + } + } + } + if dashes == 0 { + return + } + if allow.Flag(f.Name) { + allowed += dashes + } else { + disallowed = append(disallowed, "--"+f.Name) + } + }) + + if allowed > 1 { + return output.ErrUsage(`only one input can read from stdin ("-") at a time`) + } + if len(disallowed) > 0 && stdinarg.IsPiped(cmd.InOrStdin()) { + msg := fmt.Sprintf(`%s does not read stdin via "-" for %s`, + cmd.CommandPath(), strings.Join(disallowed, ", ")) + hint := `For a literal "-", pass it after the -- separator` + if accepts := describeAllowed(cmd, allow); accepts != "" { + hint += "; this command reads stdin when \"-\" is given as " + accepts + } + return output.ErrUsageHint(msg, hint) + } + return nil +} + +// 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..df20696f5 --- /dev/null +++ b/internal/commands/stdin_integration_test.go @@ -0,0 +1,151 @@ +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" + "strings" + "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/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") +} diff --git a/internal/commands/stdin_test.go b/internal/commands/stdin_test.go new file mode 100644 index 000000000..891adedd7 --- /dev/null +++ b/internal/commands/stdin_test.go @@ -0,0 +1,160 @@ +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) +} + +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") +} + +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..3717009bd 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 } @@ -265,6 +272,11 @@ func newTemplatesUpdateCmd() *cobra.Command { return noChanges(cmd) } + description, err = resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + // SDK requires name for update, fetch current if not provided updateName := name if updateName == "" { @@ -299,9 +311,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 } @@ -375,6 +389,11 @@ which can be polled via 'templates construction' until the status is "completed" return output.ErrUsage("--name is required (project name)") } + projectDesc, err := resolveContentValue(cmd, projectDesc, -1, "--description") + if err != nil { + return err + } + construction, err := app.Account().Templates().CreateProject(cmd.Context(), templateID, projectName, projectDesc) if err != nil { return convertSDKError(err) @@ -394,10 +413,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..476cd4283 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,6 +402,11 @@ You can pass either a todolist ID or a Basecamp URL: 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 } @@ -457,7 +469,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..cd8db6bef 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,12 @@ Set or clear the people notified when the todo is completed: return noChanges(cmd) } + // 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") @@ -1664,7 +1683,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 +1701,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 +1894,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 +2047,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..d22d5e4ed --- /dev/null +++ b/internal/stdinarg/stdinarg.go @@ -0,0 +1,93 @@ +// 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 +} diff --git a/internal/stdinarg/stdinarg_test.go b/internal/stdinarg/stdinarg_test.go new file mode 100644 index 000000000..34ccbb135 --- /dev/null +++ b/internal/stdinarg/stdinarg_test.go @@ -0,0 +1,67 @@ +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)) +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 35138fed9..a7a7f4c00 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -108,6 +108,23 @@ 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 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 it after the `--` separator: `basecamp projects create -- -`. + - `-` 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 +1103,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 From cfb99594f7218e2083d069a58e466768cb1bac8e Mon Sep 17 00:00:00 2001 From: Jeremy Daer <jeremy@37signals.com> Date: Tue, 18 Aug 2026 22:17:22 -0700 Subject: [PATCH 2/8] Address review: guard at Args time, alias dedupe, honest hints, CRLF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes to the dash guard's contract from review: - Run the guard at Args-validation time instead of wrapping RunE. Cobra runs ValidateArgs after flag parsing (Changed and ArgsLenAtDash are available) but before the persistent pre-run chain, PreRunE, and required-flag validation — so the stray-dash error fires before any lifecycle side effect (config hardening, the update check) and before a competing usage error can shadow it. The root command stays unwrapped: its nil Args is load-bearing — cobra's Find() rejects unknown subcommands (legacyArgs) only while Args == nil, and wrapping it turned "basecamp unknowncmd" into a quickstart run (caught by core.bats). Nothing is lost: root positionals are subcommand names, and a bare "basecamp -" runs quickstart, which posts no content. - Dedupe alias flags by their shared pflag.Value. --description and --desc wrap one backing variable, and pflag hands both the same Value instance; counting each spelling separately made "--description old --desc -" a false "two stdin inputs" error. One logical value now counts once, in both flag orders. - Stop advertising -- as the escape for flag values — it only escapes positionals. Positional offenders keep the -- hint; flag offenders get the honest remedy (run without piped stdin, append </dev/tty). SKILL.md updated to match. - Trim trailing CRLF, not just LF, from stdin content: a Windows-style pipe left \r behind, counting a phantom rune against boost's 16-rune limit. Also replace the weak final e2e case (which contradicted the file's no-network header by dialing localhost) with a deterministic local success: config set ... -- - stores a literal "-", read back via config show. --- e2e/stdin_dash.bats | 22 +++++---- internal/commands/dash_guard_test.go | 71 ++++++++++++++++++++++++++++ internal/commands/stdin.go | 68 +++++++++++++++++++------- internal/commands/stdin_test.go | 11 +++++ skills/basecamp/SKILL.md | 4 +- 5 files changed, 148 insertions(+), 28 deletions(-) diff --git a/e2e/stdin_dash.bats b/e2e/stdin_dash.bats index b56f4c7d3..ac8cc4d50 100644 --- a/e2e/stdin_dash.bats +++ b/e2e/stdin_dash.bats @@ -3,8 +3,9 @@ # # 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. All cases here fail before any HTTP, so -# no cassette or server is needed. +# 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 @@ -56,14 +57,17 @@ load test_helper assert_json_value '.hint | contains("after the -- separator")' 'true' } -@test "projects create -- - with piped stdin passes the guard" { - # The -- separator makes the "-" literal; the command proceeds past the - # guard and fails on the (unreachable) API instead of on usage. - export BASECAMP_BASE_URL="http://127.0.0.1:1" +@test "a -- - after the separator passes the guard and lands literally" { create_credentials create_global_config '{"account_id": 99999}' - run bash -c "printf 'x' | basecamp projects create --json -- -" - assert_failure - [[ "$output" != *'does not read stdin'* ]] + # 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' '-' } diff --git a/internal/commands/dash_guard_test.go b/internal/commands/dash_guard_test.go index 296d55575..9634afed9 100644 --- a/internal/commands/dash_guard_test.go +++ b/internal/commands/dash_guard_test.go @@ -65,6 +65,77 @@ func TestDashGuardRejectsUnlistedFlagWhenPiped(t *testing.T) { 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, "/dev/tty") +} + +// 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) { diff --git a/internal/commands/stdin.go b/internal/commands/stdin.go index 43e499df3..df87d8353 100644 --- a/internal/commands/stdin.go +++ b/internal/commands/stdin.go @@ -44,8 +44,9 @@ func allowDash(cmd *cobra.Command, tokens ...string) { // never an intentional write, and for update-style commands it would be an // implicit clear. // -// Trailing newlines are trimmed: Markdown bodies don't care, but titles and -// boosts (16-rune limit) do, and virtually every pipe ends with one. +// 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( @@ -57,7 +58,7 @@ func readStdinContent(cmd *cobra.Command, what string) (string, error) { if err != nil { return "", output.ErrUsage(fmt.Sprintf("failed to read %s from stdin: %v", what, err)) } - content := strings.TrimRight(string(data), "\n") + content := strings.TrimRight(string(data), "\r\n") if strings.TrimSpace(content) == "" { return "", output.ErrUsage(fmt.Sprintf("stdin for %s is empty", what)) } @@ -120,18 +121,33 @@ func afterDashSeparator(cmd *cobra.Command, index int) bool { } // InstallDashGuard wraps every runnable command in the tree with the tier-2 -// dash guard. It wraps RunE rather than hooking PersistentPreRunE because -// cobra runs only the innermost PersistentPreRunE — the agent hook already -// shadows the root's, and any future subtree would silently lose the guard. -// Wrapping RunE also runs after flag parsing and Args validation, with -// ArgsLenAtDash available. +// 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) { - if run := root.RunE; run != nil { - root.RunE = func(cmd *cobra.Command, args []string) error { + // 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. Leaving the root + // unguarded loses nothing: its positionals are subcommand names, and a + // bare "basecamp -" just runs quickstart, which posts no content for a + // literal "-" to corrupt. + skipRoot := root.Args == nil && !root.HasParent() && root.HasSubCommands() + if root.Runnable() && !skipRoot { + existing := root.Args + root.Args = func(cmd *cobra.Command, args []string) error { if err := guardDashArgs(cmd, args); err != nil { return err } - return run(cmd, args) + if existing != nil { + return existing(cmd, args) + } + return nil } } for _, sub := range root.Commands() { @@ -152,7 +168,7 @@ func guardDashArgs(cmd *cobra.Command, args []string) error { allow := stdinarg.ParseAllow(cmd.Annotations[stdinarg.AnnotationAllowDash]) allowed := 0 - var disallowed []string + var disallowedArgs, disallowedFlags []string for i, a := range args { if a != "-" || afterDashSeparator(cmd, i) { @@ -161,12 +177,16 @@ func guardDashArgs(cmd *cobra.Command, args []string) error { if allow.Arg(i) { allowed++ } else { - disallowed = append(disallowed, positionalName(cmd, i)) + disallowedArgs = append(disallowedArgs, positionalName(cmd, i)) } } + // Alias flags (--description/--desc) share one backing value, and pflag + // hands each alias the same Value instance — dedupe on it, or a value set + // through both spellings would count as two stdin inputs. + seen := map[pflag.Value]bool{} cmd.Flags().VisitAll(func(f *pflag.Flag) { - if !f.Changed { + if !f.Changed || seen[f.Value] { return } dashes := 0 @@ -183,28 +203,40 @@ func guardDashArgs(cmd *cobra.Command, args []string) error { } } } + default: + return } + seen[f.Value] = true if dashes == 0 { return } if allow.Flag(f.Name) { allowed += dashes } else { - disallowed = append(disallowed, "--"+f.Name) + disallowedFlags = append(disallowedFlags, "--"+f.Name) } }) 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, ", ")) - hint := `For a literal "-", pass it after the -- separator` + // -- only escapes positionals; a flag value has no in-line escape, so + // the honest remedy there is an unpiped stdin. + 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 without piped stdin (append </dev/tty)`) + } if accepts := describeAllowed(cmd, allow); accepts != "" { - hint += "; this command reads stdin when \"-\" is given as " + accepts + hints = append(hints, "this command reads stdin when \"-\" is given as "+accepts) } - return output.ErrUsageHint(msg, hint) + return output.ErrUsageHint(msg, strings.Join(hints, "; ")) } return nil } diff --git a/internal/commands/stdin_test.go b/internal/commands/stdin_test.go index 891adedd7..28e5bf444 100644 --- a/internal/commands/stdin_test.go +++ b/internal/commands/stdin_test.go @@ -41,6 +41,17 @@ func TestReadStdinContentTrimsTrailingNewlines(t *testing.T) { 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) diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index a7a7f4c00..017141a06 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -119,7 +119,9 @@ Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule, 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 it after the `--` separator: `basecamp projects create -- -`. + piped**. Escape a positional after the `--` separator + (`basecamp projects create -- -`); a flag value has no in-line escape — run + it without the pipe (append `</dev/tty`). - `-` with nothing piped (interactive TTY) errors immediately instead of hanging; use a pipe, a heredoc (`basecamp comments create <id> - <<'EOF'`), or `--edit` where offered. From b8f09d7a7dbc482c7d2c65e74513faf34499ee19 Mon Sep 17 00:00:00 2001 From: Jeremy Daer <jeremy@37signals.com> Date: Wed, 19 Aug 2026 13:24:38 -0700 Subject: [PATCH 3/8] Address review: source-preserving stdin hints, bounded create arity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TTY hint for a flag-borne "-" suggested a bare trailing "-", which would exceed the command's positional arity — it now repeats the flag ("api post ... --data -"). messages/cards/docs create took unbounded positionals, so a stray third token was silently dropped after "-" had already drained stdin. All three now bound at MaximumNArgs(2), which runs before the read; the other exact-positional consumers were already bounded. Cobra's arity errors classified as api_error, telling agents to retry a call that can never succeed. They are usage errors by construction. Drop the concrete </dev/tty redirect from the literal-dash hint: it is unusable on Windows and on headless runners with no controlling terminal. The remedy stays, minus the platform-specific spelling. --- internal/cli/cobra_error_test.go | 41 ++++++++++ internal/cli/root.go | 8 ++ internal/commands/cards.go | 3 + internal/commands/dash_guard_test.go | 2 +- internal/commands/files.go | 3 + internal/commands/messages.go | 3 + internal/commands/stdin.go | 21 +++-- internal/commands/stdin_integration_test.go | 89 +++++++++++++++++++++ internal/commands/stdin_test.go | 13 +++ skills/basecamp/SKILL.md | 2 +- 10 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 internal/cli/cobra_error_test.go diff --git a/internal/cli/cobra_error_test.go b/internal/cli/cobra_error_test.go new file mode 100644 index 000000000..0132ed8c4 --- /dev/null +++ b/internal/cli/cobra_error_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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 4), 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) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index ccc028354..c86e3a52a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -664,6 +664,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 strings.Contains(msg, "arg(s), received ") { + 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`) diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 0c8469d8b..0022abad9 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -857,6 +857,9 @@ 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 { diff --git a/internal/commands/dash_guard_test.go b/internal/commands/dash_guard_test.go index 9634afed9..3ac0c7cbf 100644 --- a/internal/commands/dash_guard_test.go +++ b/internal/commands/dash_guard_test.go @@ -67,7 +67,7 @@ func TestDashGuardRejectsUnlistedFlagWhenPiped(t *testing.T) { 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, "/dev/tty") + assert.Contains(t, outErr.Hint, "without piped stdin") } // The guard runs at Args-validation time: before the command's own Args diff --git a/internal/commands/files.go b/internal/commands/files.go index 2a16f531c..2724226d9 100644 --- a/internal/commands/files.go +++ b/internal/commands/files.go @@ -1230,6 +1230,9 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command { Use - as the content argument to read the document body from stdin: basecamp docs 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 { diff --git a/internal/commands/messages.go b/internal/commands/messages.go index a9355fa39..6e5460996 100644 --- a/internal/commands/messages.go +++ b/internal/commands/messages.go @@ -435,6 +435,9 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command 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 { diff --git a/internal/commands/stdin.go b/internal/commands/stdin.go index df87d8353..3eb2db466 100644 --- a/internal/commands/stdin.go +++ b/internal/commands/stdin.go @@ -51,7 +51,7 @@ 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), + stdinEscapeHint(cmd, what), ) } data, err := io.ReadAll(cmd.InOrStdin()) @@ -67,11 +67,18 @@ func readStdinContent(cmd *cobra.Command, what string) (string, error) { // stdinEscapeHint lists the ways to satisfy a "-" from an interactive // terminal, mentioning --edit only where the command has it. -func stdinEscapeHint(cmd *cobra.Command) string { +// +// 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 ... -), use a heredoc (%[1]s ... - <<'EOF'), or run cat | %[1]s ... - and type the content, ending with Ctrl-D", - path) + "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" } @@ -225,13 +232,15 @@ func guardDashArgs(cmd *cobra.Command, args []string) error { 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. + // 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 without piped stdin (append </dev/tty)`) + 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) diff --git a/internal/commands/stdin_integration_test.go b/internal/commands/stdin_integration_test.go index df20696f5..5dec0a764 100644 --- a/internal/commands/stdin_integration_test.go +++ b/internal/commands/stdin_integration_test.go @@ -7,9 +7,11 @@ import ( "bytes" "encoding/json" "errors" + "net/http" "strings" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -149,3 +151,90 @@ func TestTodosUpdateDescriptionDashReadsStdin(t *testing.T) { } 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) +} diff --git a/internal/commands/stdin_test.go b/internal/commands/stdin_test.go index 28e5bf444..229d81e54 100644 --- a/internal/commands/stdin_test.go +++ b/internal/commands/stdin_test.go @@ -63,6 +63,19 @@ func TestReadStdinContentTTYIsUsageErrorWithEscapeHints(t *testing.T) { 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, "") diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 017141a06..2ec8e0957 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -121,7 +121,7 @@ Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule, - 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 - it without the pipe (append `</dev/tty`). + 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. From 9310135a9a68abc098c7eb5bdfefe2316e1d54ad Mon Sep 17 00:00:00 2001 From: Jeremy Daer <jeremy@37signals.com> Date: Wed, 19 Aug 2026 14:10:32 -0700 Subject: [PATCH 4/8] Address review: gate pickers on stdin, reject dual chat sources, fix docs path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolver.IsInteractive now requires stdin to be a character device too: 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. This closes the ordering hazard for every picker at the mechanism rather than reordering each stdin-enabled RunE (gauges create, docs, cards, schedule, templates). - chat post/update reject a positional message combined with --content instead of the positional silently winning; with "-" in play the losing source would discard piped content unread. - SKILL.md and the docs-create example referenced 'docs create', which does not exist; the registered path is 'docs documents create'. --- internal/commands/chat.go | 18 ++++++++--- internal/commands/chat_test.go | 16 ++++++++++ internal/commands/files.go | 2 +- internal/tui/resolve/resolve.go | 20 +++++++++--- internal/tui/resolve/resolve_test.go | 46 ++++++++++++++++++++++++++++ skills/basecamp/SKILL.md | 2 +- 6 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 internal/tui/resolve/resolve_test.go diff --git a/internal/commands/chat.go b/internal/commands/chat.go index 66d89ea97..26e931a00 100644 --- a/internal/commands/chat.go +++ b/internal/commands/chat.go @@ -316,12 +316,16 @@ Use - as the message argument to read the message from stdin: RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) - // Validate user input first, before checking account. The - // positional wins over --content before "-" resolution, so only - // the winning source can consume stdin. + // 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>" } @@ -781,11 +785,15 @@ edit to rich text.`, return missingArg(cmd, "<id|url>") } - // The positional wins over --content before "-" resolution, so - // only the winning source can consume stdin. + // 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]" } 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/files.go b/internal/commands/files.go index 2724226d9..1aae47149 100644 --- a/internal/commands/files.go +++ b/internal/commands/files.go @@ -1229,7 +1229,7 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command { 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 create "Title" - --in my-project < body.md`, + 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), diff --git a/internal/tui/resolve/resolve.go b/internal/tui/resolve/resolve.go index 3018737aa..f66249791 100644 --- a/internal/tui/resolve/resolve.go +++ b/internal/tui/resolve/resolve.go @@ -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. @@ -125,6 +125,18 @@ func (r *Resolver) IsInteractive() bool { if err != nil { return false } + if fi.Mode()&os.ModeCharDevice == 0 { + return false + } + + // Stdin must be a character device too: pickers read keystrokes from + // stdin, so a pipe or redirected file can never drive one — and when the + // command is consuming piped content (a "-" stdin input), a picker would + // eat that content as key events. + fi, err = os.Stdin.Stat() + if err != nil { + return false + } return (fi.Mode() & os.ModeCharDevice) != 0 } 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 2ec8e0957..cf89c67b9 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -110,7 +110,7 @@ Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule, ``` `-` means "read from stdin" on every content input: content-kind positionals (`comments create/update`, `messages create [body]`, `cards create [body]`, - `todos create`, `docs create [content]`, `chat post/update`, `boost create`, + `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 From 1322a368b9b8638249569bb10283fd79d181613c Mon Sep 17 00:00:00 2001 From: Jeremy Daer <jeremy@37signals.com> Date: Wed, 19 Aug 2026 15:29:01 -0700 Subject: [PATCH 5/8] Address review: gate first-run wizard (all TUIs) on stdin too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root skip's justification held for quickstart's summary but missed the first-run wizard: isFirstRun gates on App.IsInteractive, which only looked at stdout, so piped stdin plus a terminal stdout on a first run launched the wizard reading the pipe as keystrokes. Extract the stdout+stdin character-device check into stdinarg.InteractiveStdio — the second copy of this logic — and use it from both App.IsInteractive and resolve.Resolver.IsInteractive. Every TUI gate (wizard, pickers, animations, update notice) now takes its non-interactive path when either end of stdio is piped. --- internal/appctx/context.go | 13 +++++----- internal/commands/stdin.go | 4 +++- internal/stdinarg/stdinarg.go | 16 +++++++++++++ internal/stdinarg/stdinarg_test.go | 38 ++++++++++++++++++++++++++++++ internal/tui/resolve/resolve.go | 25 +++++--------------- 5 files changed, 69 insertions(+), 27 deletions(-) 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/commands/stdin.go b/internal/commands/stdin.go index 3eb2db466..1cd458517 100644 --- a/internal/commands/stdin.go +++ b/internal/commands/stdin.go @@ -143,7 +143,9 @@ func InstallDashGuard(root *cobra.Command) { // turn "basecamp unknowncmd" into a quickstart run. Leaving the root // unguarded loses nothing: its positionals are subcommand names, and a // bare "basecamp -" just runs quickstart, which posts no content for a - // literal "-" to corrupt. + // literal "-" to corrupt and whose TUI paths (the first-run wizard) are + // stdin-gated by stdinarg.InteractiveStdio — piped stdin routes to the + // non-interactive summary instead of a wizard that would eat the pipe. skipRoot := root.Args == nil && !root.HasParent() && root.HasSubCommands() if root.Runnable() && !skipRoot { existing := root.Args diff --git a/internal/stdinarg/stdinarg.go b/internal/stdinarg/stdinarg.go index d22d5e4ed..193643bc8 100644 --- a/internal/stdinarg/stdinarg.go +++ b/internal/stdinarg/stdinarg.go @@ -91,3 +91,19 @@ func IsPiped(r io.Reader) bool { } 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 index 34ccbb135..d80c06579 100644 --- a/internal/stdinarg/stdinarg_test.go +++ b/internal/stdinarg/stdinarg_test.go @@ -65,3 +65,41 @@ func TestIsPipedRegularFile(t *testing.T) { 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 f66249791..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" ) @@ -120,24 +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 - } - if fi.Mode()&os.ModeCharDevice == 0 { - return false - } - - // Stdin must be a character device too: pickers read keystrokes from - // stdin, so a pipe or redirected file can never drive one — and when the - // command is consuming piped content (a "-" stdin input), a picker would - // eat that content as key events. - fi, err = os.Stdin.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 From 6a98dd7ef587c171a3e110442f1c1097a3a3646b Mon Sep 17 00:00:00 2001 From: Jeremy Daer <jeremy@37signals.com> Date: Wed, 19 Aug 2026 15:28:19 -0700 Subject: [PATCH 6/8] Address review: source ordering, chat precedence, alias groups, anchored arity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat post/update let a positional and an explicit --content coexist, with the positional silently winning — so --content - dropped the flag and left the pipe unread. Both now reject two explicit sources. Seven commands resolved "-" after account or project work, so a stdin mistake surfaced as "--account is required" instead of the stdin hint. The order is now local validation, then stdin, then account/network at every resolveContentValue site; an audit script confirms none remain. Long help and SKILL.md taught "basecamp docs create", which resolves to the docs group and exits 0 showing help. The real path is "docs documents create". A new test resolves every help example through Find and fails when a group swallows a leftover subcommand name. transformCobraError matched arity text anywhere in any error, flattening typed errors that merely quoted the phrase. It now returns typed errors untouched and anchors on cobra's exact arity formats. The guard named one alias of a shared value, reporting --in for a caller who wrote --project. Parsed state cannot say which spelling was typed, so the error names the group (--in/--project). --- internal/cli/cobra_error_test.go | 48 +++++++++- internal/cli/root.go | 22 ++++- internal/commands/cards.go | 11 ++- internal/commands/dash_guard_test.go | 33 +++++++ internal/commands/files.go | 13 ++- internal/commands/gauges.go | 45 +++++---- internal/commands/help_paths_test.go | 101 ++++++++++++++++++++ internal/commands/schedule.go | 12 ++- internal/commands/stdin.go | 78 +++++++++++---- internal/commands/stdin_integration_test.go | 89 +++++++++++++++++ internal/commands/templates.go | 40 ++++---- 11 files changed, 421 insertions(+), 71 deletions(-) create mode 100644 internal/commands/help_paths_test.go diff --git a/internal/cli/cobra_error_test.go b/internal/cli/cobra_error_test.go index 0132ed8c4..f44ae1b5c 100644 --- a/internal/cli/cobra_error_test.go +++ b/internal/cli/cobra_error_test.go @@ -7,11 +7,13 @@ import ( "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 4), which tells an +// 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{ @@ -39,3 +41,47 @@ func TestTransformCobraErrorKeepsZeroArgRewrite(t *testing.T) { 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 c86e3a52a..7431a4102 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" @@ -619,9 +621,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" @@ -668,7 +688,7 @@ func transformCobraError(err error) error { // 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 strings.Contains(msg, "arg(s), received ") { + if cobraArityError.MatchString(msg) { return output.ErrUsage(msg) } diff --git a/internal/commands/cards.go b/internal/commands/cards.go index 0022abad9..764d363ac 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1091,6 +1091,13 @@ You can pass either a card ID or a Basecamp URL: return noChanges(cmd) } + // Resolve "-" before any account or network work, so a bad stdin + // gets the stdin error rather than "--account is required". + content, err := resolveContentValue(cmd, content, -1, "--body") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { @@ -1109,10 +1116,6 @@ You can pass either a card ID or a Basecamp URL: if title != "" { req.Title = &title } - content, err = resolveContentValue(cmd, content, -1, "--body") - if err != nil { - return err - } var mentionNotice string var html string diff --git a/internal/commands/dash_guard_test.go b/internal/commands/dash_guard_test.go index 3ac0c7cbf..75b0eb702 100644 --- a/internal/commands/dash_guard_test.go +++ b/internal/commands/dash_guard_test.go @@ -220,3 +220,36 @@ func TestDownloadCommandsExemptOutFlag(t *testing.T) { 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, "/--") +} diff --git a/internal/commands/files.go b/internal/commands/files.go index 1aae47149..ff0367916 100644 --- a/internal/commands/files.go +++ b/internal/commands/files.go @@ -1241,11 +1241,8 @@ Use - as the content argument to read the document body from stdin: title := args[0] - app := appctx.FromContext(cmd.Context()) - - if err := ensureAccount(cmd, app); err != nil { - return err - } + // 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 @@ -1255,6 +1252,12 @@ Use - as the content argument to read the document body from stdin: } } + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + // Resolve subscription flags before project (fail fast on bad input) subs, err := applySubscribeFlags(cmd.Context(), app.Names, subscribe, cmd.Flags().Changed("subscribe"), noSubscribe) if err != nil { diff --git a/internal/commands/gauges.go b/internal/commands/gauges.go index d90fcdfe3..33f55facd 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, } @@ -208,10 +215,6 @@ func newGaugesCreateCmd(project *string) *cobra.Command { req.Color = color } if description != "" { - description, err = resolveContentValue(cmd, description, -1, "--description") - if err != nil { - return err - } req.Description = description } if notify != "" { @@ -264,6 +267,17 @@ func newGaugesUpdateCmd() *cobra.Command { basecamp gauges update 12345 --description "Updated status"`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("description") { + return output.ErrUsage("No changes specified (use --description)") + } + + // Resolve "-" before any account or network work, so 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 { @@ -275,15 +289,6 @@ func newGaugesUpdateCmd() *cobra.Command { return output.ErrUsage("Invalid needle ID") } - if !cmd.Flags().Changed("description") { - return output.ErrUsage("No changes specified (use --description)") - } - - description, err = resolveContentValue(cmd, description, -1, "--description") - if err != nil { - return err - } - req := &basecamp.UpdateGaugeNeedleRequest{ Description: basecamp.Ptr(description), } 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/schedule.go b/internal/commands/schedule.go index c1c7e667b..e4fd808c9 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,11 +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) }, } diff --git a/internal/commands/stdin.go b/internal/commands/stdin.go index 1cd458517..1e0efe076 100644 --- a/internal/commands/stdin.go +++ b/internal/commands/stdin.go @@ -190,22 +190,15 @@ func guardDashArgs(cmd *cobra.Command, args []string) error { } } - // Alias flags (--description/--desc) share one backing value, and pflag - // hands each alias the same Value instance — dedupe on it, or a value set - // through both spellings would count as two stdin inputs. - seen := map[pflag.Value]bool{} - cmd.Flags().VisitAll(func(f *pflag.Flag) { - if !f.Changed || seen[f.Value] { - return - } + for _, group := range changedFlagGroups(cmd) { dashes := 0 - switch f.Value.Type() { + switch group.value.Type() { case "string": - if f.Value.String() == "-" { + if group.value.String() == "-" { dashes = 1 } case "stringArray", "stringSlice": - if sv, ok := f.Value.(pflag.SliceValue); ok { + if sv, ok := group.value.(pflag.SliceValue); ok { for _, v := range sv.GetSlice() { if v == "-" { dashes++ @@ -213,18 +206,17 @@ func guardDashArgs(cmd *cobra.Command, args []string) error { } } default: - return + continue } - seen[f.Value] = true if dashes == 0 { - return + continue } - if allow.Flag(f.Name) { + if group.allowed(allow) { allowed += dashes } else { - disallowedFlags = append(disallowedFlags, "--"+f.Name) + disallowedFlags = append(disallowedFlags, group.label()) } - }) + } if allowed > 1 { return output.ErrUsage(`only one input can read from stdin ("-") at a time`) @@ -252,6 +244,58 @@ func guardDashArgs(cmd *cobra.Command, args []string) error { 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 { diff --git a/internal/commands/stdin_integration_test.go b/internal/commands/stdin_integration_test.go index 5dec0a764..c367851ee 100644 --- a/internal/commands/stdin_integration_test.go +++ b/internal/commands/stdin_integration_test.go @@ -238,3 +238,92 @@ func TestAPIPostDataDashOnTTYHintPreservesTheFlag(t *testing.T) { 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) + }) + } +} diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 3717009bd..da9f0da87 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -257,6 +257,17 @@ 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 { + if name == "" && description == "" { + return noChanges(cmd) + } + + // 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 { @@ -268,15 +279,6 @@ func newTemplatesUpdateCmd() *cobra.Command { return output.ErrUsage("Invalid template ID") } - if name == "" && description == "" { - return noChanges(cmd) - } - - description, err = resolveContentValue(cmd, description, -1, "--description") - if err != nil { - return err - } - // SDK requires name for update, fetch current if not provided updateName := name if updateName == "" { @@ -374,6 +376,17 @@ 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 { + if projectName == "" { + return output.ErrUsage("--name is required (project name)") + } + + // Local validation, then "-", then account: a bad stdin gets the + // stdin error rather than "--account is required". + projectDesc, err := resolveContentValue(cmd, projectDesc, -1, "--description") + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { @@ -385,15 +398,6 @@ 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)") - } - - projectDesc, err := resolveContentValue(cmd, projectDesc, -1, "--description") - if err != nil { - return err - } - construction, err := app.Account().Templates().CreateProject(cmd.Context(), templateID, projectName, projectDesc) if err != nil { return convertSDKError(err) From d73b2c3debbd1291dbed73e8e70ed8f19fc7274f Mon Sep 17 00:00:00 2001 From: Jeremy Daer <jeremy@37signals.com> Date: Wed, 19 Aug 2026 15:49:02 -0700 Subject: [PATCH 7/8] Gate the profile picker on stdin; guard the root's stray dash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile picker runs from PersistentPreRunE and reads keystrokes, but isInteractiveTTY only looked at stdout. With multiple profiles and no default, "printf body | basecamp todos create -" opened the picker on a terminal stdout and let it eat the piped body. It now uses the same stdinarg.InteractiveStdio predicate as App.IsInteractive and the project resolver — the third and last TUI-launch gate; the remaining ModeCharDevice checks pick an output format and never read keys. The root was skipped entirely by the dash guard, so a piped "basecamp -" ran quick-start and ignored both the dash and the pipe. Its Args must stay nil for cobra's unknown-command handling, so the guard hangs off RunE there instead. Regressions cover all four root behaviors: piped dash errors, -- keeps it literal, unknown commands still error, bare still runs. --- e2e/stdin_dash.bats | 19 ++++++++ internal/cli/root.go | 17 +++---- internal/cli/root_test.go | 33 +++++++++++++ internal/commands/dash_guard_test.go | 69 ++++++++++++++++++++++++++++ internal/commands/stdin.go | 28 +++++++---- 5 files changed, 150 insertions(+), 16 deletions(-) diff --git a/e2e/stdin_dash.bats b/e2e/stdin_dash.bats index ac8cc4d50..636d41335 100644 --- a/e2e/stdin_dash.bats +++ b/e2e/stdin_dash.bats @@ -71,3 +71,22 @@ load test_helper 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/cli/root.go b/internal/cli/root.go index 7431a4102..4c22fe7ea 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -520,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 @@ -532,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. diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 636d03c78..b8a4fd9ea 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -285,3 +285,36 @@ 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") +} diff --git a/internal/commands/dash_guard_test.go b/internal/commands/dash_guard_test.go index 75b0eb702..95840d786 100644 --- a/internal/commands/dash_guard_test.go +++ b/internal/commands/dash_guard_test.go @@ -253,3 +253,72 @@ func TestDashGuardNamesASoloFlagPlainly(t *testing.T) { 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/stdin.go b/internal/commands/stdin.go index 1e0efe076..38986e780 100644 --- a/internal/commands/stdin.go +++ b/internal/commands/stdin.go @@ -140,14 +140,26 @@ func afterDashSeparator(cmd *cobra.Command, index int) bool { 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. Leaving the root - // unguarded loses nothing: its positionals are subcommand names, and a - // bare "basecamp -" just runs quickstart, which posts no content for a - // literal "-" to corrupt and whose TUI paths (the first-run wizard) are - // stdin-gated by stdinarg.InteractiveStdio — piped stdin routes to the - // non-interactive summary instead of a wizard that would eat the pipe. - skipRoot := root.Args == nil && !root.HasParent() && root.HasSubCommands() - if root.Runnable() && !skipRoot { + // 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 { From c30aa366e3142bbfbaf8426d4b2cde5a55262fb0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer <jeremy@37signals.com> Date: Wed, 19 Aug 2026 21:28:52 -0700 Subject: [PATCH 8/8] Validate target IDs before reading stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading stdin blocks on the producer and cannot be undone, so anything knowable from the arguments alone has to be decided first. Thirteen commands read a "-" content input before parsing the ID out of args[0], so a typo'd ID waited on the pipe, and a blank pipe answered "stdin is empty" instead of naming the bad ID. The order is now syntactic checks, then stdin, then account and network. files replace also hoists its file-path validation; its URL identity checks need the session account, so they necessarily still follow. schedule update only hoists the extraction — it never rejected a malformed entry ID locally, which predates this and is left alone. An AST test holds the ordering for call sites not written yet: within any RunE, no resolveContentValue/resolveContentArg call may precede a syntactic use of args (extractID, extractWithProject, extractCommentWithProject, strconv.ParseInt). Coverage is bounded to those names, stated in the test. Eleven commands additionally assert, through a stdin reader that records reads and a counting transport, that a malformed ID drains nothing and issues no request. Also covers the root guard with a character-device stdout, the case the piped-stdout e2e suite can never reach. --- internal/cli/root_test.go | 50 ++++++++ internal/commands/cards.go | 36 +++--- internal/commands/comment.go | 21 ++-- internal/commands/files.go | 49 ++++---- internal/commands/gauges.go | 13 +-- internal/commands/messages.go | 20 ++-- internal/commands/projects.go | 11 +- internal/commands/schedule.go | 11 +- internal/commands/stdin_integration_test.go | 40 +++++++ internal/commands/stdin_ordering_test.go | 119 ++++++++++++++++++++ internal/commands/templates.go | 26 ++--- internal/commands/todolists.go | 21 ++-- internal/commands/todos.go | 19 ++-- 13 files changed, 334 insertions(+), 102 deletions(-) create mode 100644 internal/commands/stdin_ordering_test.go diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index b8a4fd9ea..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" ) @@ -318,3 +319,52 @@ func TestIsInteractiveTTYRequiresUnpipedStdin(t *testing.T) { 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/cards.go b/internal/commands/cards.go index 764d363ac..f6ef184b1 100644 --- a/internal/commands/cards.go +++ b/internal/commands/cards.go @@ -1091,8 +1091,17 @@ You can pass either a card ID or a Basecamp URL: return noChanges(cmd) } - // Resolve "-" before any account or network work, so a bad stdin - // gets the stdin error rather than "--account is required". + // Extract ID from URL if provided + cardIDStr := extractID(args[0]) + + cardID, err := strconv.ParseInt(cardIDStr, 10, 64) + if err != nil { + 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 @@ -1104,14 +1113,6 @@ You can pass either a card ID or a Basecamp URL: return err } - // Extract ID from URL if provided - cardIDStr := extractID(args[0]) - - cardID, err := strconv.ParseInt(cardIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid card ID") - } - req := &basecamp.UpdateCardRequest{} if title != "" { req.Title = &title @@ -2137,6 +2138,14 @@ You can pass either a column ID or a Basecamp URL: return noChanges(cmd) } + // Extract ID from URL if provided + columnIDStr := extractID(args[0]) + columnID, err := strconv.ParseInt(columnIDStr, 10, 64) + if err != nil { + 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 @@ -2148,13 +2157,6 @@ You can pass either a column ID or a Basecamp URL: return err } - // Extract ID from URL if provided - columnIDStr := extractID(args[0]) - columnID, err := strconv.ParseInt(columnIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid column ID") - } - req := &basecamp.UpdateColumnRequest{ Title: title, Description: description, diff --git a/internal/commands/comment.go b/internal/commands/comment.go index b67d61177..27baacf54 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -1049,6 +1049,18 @@ as backslash-n.`, return missingArg(cmd, "<content>") } + // 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) diff --git a/internal/commands/files.go b/internal/commands/files.go index ff0367916..591fc03a9 100644 --- a/internal/commands/files.go +++ b/internal/commands/files.go @@ -1722,7 +1722,22 @@ You can pass either an upload ID or a Basecamp URL: RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) - // Only an exact "-" reads stdin; --description "" stays the clear idiom. + 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 @@ -1761,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 @@ -1903,7 +1907,18 @@ 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 { - // Only an exact "-" reads stdin; --content "" stays the clear idiom. + // 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 { @@ -1951,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 != "" { diff --git a/internal/commands/gauges.go b/internal/commands/gauges.go index 33f55facd..26b9b9ff3 100644 --- a/internal/commands/gauges.go +++ b/internal/commands/gauges.go @@ -271,8 +271,12 @@ func newGaugesUpdateCmd() *cobra.Command { return output.ErrUsage("No changes specified (use --description)") } - // Resolve "-" before any account or network work, so a bad stdin - // gets the stdin error rather than "--account is required". + needleID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid needle ID") + } + + // Syntactic checks first, then "-", then account and network. description, err := resolveContentValue(cmd, description, -1, "--description") if err != nil { return err @@ -284,11 +288,6 @@ func newGaugesUpdateCmd() *cobra.Command { return err } - needleID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return output.ErrUsage("Invalid needle ID") - } - req := &basecamp.UpdateGaugeNeedleRequest{ Description: basecamp.Ptr(description), } diff --git a/internal/commands/messages.go b/internal/commands/messages.go index 6e5460996..c5fe1116a 100644 --- a/internal/commands/messages.go +++ b/internal/commands/messages.go @@ -627,7 +627,17 @@ You can pass either a message ID or a Basecamp URL: return noChanges(cmd) } - var err error + // Extract ID from URL if provided + messageIDStr := extractID(args[0]) + + messageID, err := strconv.ParseInt(messageIDStr, 10, 64) + if err != nil { + 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 @@ -639,14 +649,6 @@ You can pass either a message ID or a Basecamp URL: return err } - // Extract ID from URL if provided - messageIDStr := extractID(args[0]) - - messageID, err := strconv.ParseInt(messageIDStr, 10, 64) - if err != nil { - return output.ErrUsage("Invalid message ID") - } - // Build SDK request // Convert Markdown content to HTML for Basecamp's rich text fields html := richtext.MarkdownToHTML(body) diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 27e3383cc..e696eaeef 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -331,6 +331,12 @@ Examples: return fmt.Errorf("app not initialized") } + 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 @@ -341,11 +347,6 @@ Examples: return err } - projectID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return output.ErrUsage("Invalid project ID") - } - // For update, we need to provide name (required by SDK) // If only description is provided, we need to fetch current name first updateName := name diff --git a/internal/commands/schedule.go b/internal/commands/schedule.go index e4fd808c9..f4f7db832 100644 --- a/internal/commands/schedule.go +++ b/internal/commands/schedule.go @@ -623,6 +623,14 @@ You can pass either an entry ID or a Basecamp URL: RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) + // 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 @@ -632,9 +640,6 @@ You can pass either an entry ID or a Basecamp URL: return err } - // Extract ID and project from URL if provided - entryID, urlProjectID := extractWithProject(args[0]) - // Resolve project - use URL > flag > config, with interactive fallback projectID := *project if projectID == "" && urlProjectID != "" { diff --git a/internal/commands/stdin_integration_test.go b/internal/commands/stdin_integration_test.go index c367851ee..382fa147a 100644 --- a/internal/commands/stdin_integration_test.go +++ b/internal/commands/stdin_integration_test.go @@ -327,3 +327,43 @@ func TestChatRejectsPositionalAlongsideContentFlag(t *testing.T) { }) } } + +// 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/templates.go b/internal/commands/templates.go index da9f0da87..80ddcce58 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -261,8 +261,12 @@ func newTemplatesUpdateCmd() *cobra.Command { return noChanges(cmd) } - // Local validation, then "-", then account: a bad stdin gets the - // stdin error rather than "--account is required". + templateID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid template ID") + } + + // Syntactic checks first, then "-", then account and network. description, err := resolveContentValue(cmd, description, -1, "--description") if err != nil { return err @@ -274,11 +278,6 @@ func newTemplatesUpdateCmd() *cobra.Command { return err } - templateID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return output.ErrUsage("Invalid template ID") - } - // SDK requires name for update, fetch current if not provided updateName := name if updateName == "" { @@ -380,8 +379,12 @@ which can be polled via 'templates construction' until the status is "completed" return output.ErrUsage("--name is required (project name)") } - // Local validation, then "-", then account: a bad stdin gets the - // stdin error rather than "--account is required". + templateID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid template ID") + } + + // Syntactic checks first, then "-", then account and network. projectDesc, err := resolveContentValue(cmd, projectDesc, -1, "--description") if err != nil { return err @@ -393,11 +396,6 @@ which can be polled via 'templates construction' until the status is "completed" return err } - templateID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return output.ErrUsage("Invalid template ID") - } - construction, err := app.Account().Templates().CreateProject(cmd.Context(), templateID, projectName, projectDesc) if err != nil { return convertSDKError(err) diff --git a/internal/commands/todolists.go b/internal/commands/todolists.go index 476cd4283..b6c271e30 100644 --- a/internal/commands/todolists.go +++ b/internal/commands/todolists.go @@ -402,6 +402,18 @@ You can pass either a todolist ID or a Basecamp URL: return fmt.Errorf("app not initialized") } + // 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 @@ -411,9 +423,6 @@ You can pass either a todolist ID or a Basecamp URL: return err } - // Extract ID and project from URL if provided - todolistIDStr, urlProjectID := extractWithProject(args[0]) - // Resolve project - use URL > flag > config, with interactive fallback projectID := *project if projectID == "" && urlProjectID != "" { @@ -431,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, diff --git a/internal/commands/todos.go b/internal/commands/todos.go index cd8db6bef..0fb7f9052 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1550,7 +1550,17 @@ Set or clear the people notified when the todo is completed: return noChanges(cmd) } - // Only an exact "-" reads stdin; --description "" stays the clear idiom. + // 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 @@ -1565,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.