From 23abee9eea764c7ad138fdccc0ef511deceaad8f Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 14 May 2026 08:44:15 -0400 Subject: [PATCH 1/2] Read comment content from stdin when using dash --- internal/commands/comment.go | 35 +++++++++++-- internal/commands/comment_test.go | 82 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 7b7a33b2..cd8d6d12 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -3,6 +3,7 @@ package commands import ( "errors" "fmt" + "io" "os" "strconv" "strings" @@ -198,7 +199,10 @@ func newCommentsUpdateCmd() *cobra.Command { You can pass either a comment ID or a Basecamp URL: basecamp comments update 789 "new text" - basecamp comments update https://3.basecamp.com/123/buckets/456/todos/111#__recording_789 "new text"`, + basecamp comments update https://3.basecamp.com/123/buckets/456/todos/111#__recording_789 "new text" + +Use - as the content argument to read the updated content from stdin: + basecamp comments update 789 - < body.md`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return missingArg(cmd, "") @@ -207,6 +211,11 @@ You can pass either a comment ID or a Basecamp URL: return missingArg(cmd, "") } + content, err := contentArgOrStdin(cmd, args[1:]) + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) if err := ensureAccount(cmd, app); err != nil { return err @@ -216,8 +225,6 @@ You can pass either a comment ID or a Basecamp URL: // Uses extractCommentWithProject to prefer CommentID from URL fragments commentIDStr, _ := extractCommentWithProject(args[0]) - content := strings.Join(args[1:], " ") - commentID, err := strconv.ParseInt(commentIDStr, 10, 64) if err != nil { return output.ErrUsage("Invalid comment ID") @@ -299,7 +306,10 @@ Comma-separated IDs add the same comment to multiple items: basecamp comment https://3.basecamp.com/123/buckets/456/todos/789 "Looks good!" Content supports Markdown and @mentions (@Name or @First.Last): - basecamp comment 789 "Hey @Jane.Smith, **please review**"`, + basecamp comment 789 "Hey @Jane.Smith, **please review**" + +Use - as the content argument to read content from stdin: + basecamp comment 789 - < body.md`, Annotations: map[string]string{"agent_notes": "Comments are flat — reply to parent item, not to other comments\nURL fragments (#__recording_456) are comment IDs — comment on the parent recording_id, not the comment_id\nComments are on items (todos, messages, cards, etc.) — not on other comments"}, RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -314,7 +324,11 @@ Content supports Markdown and @mentions (@Name or @First.Last): var content string if len(args) > 1 { - content = strings.Join(args[1:], " ") + var err error + content, err = contentArgOrStdin(cmd, args[1:]) + if err != nil { + return err + } } if edit && content != "" { @@ -479,3 +493,14 @@ Content supports Markdown and @mentions (@Name or @First.Last): return cmd } + +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 +} diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index 963c26de..1b22e989 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -1,10 +1,19 @@ package commands import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" "testing" + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/names" ) // TestCommentShortcutAcceptsInFlag tests that the top-level 'comment' shortcut @@ -56,3 +65,76 @@ func TestCommentsGroupAcceptsInFlag(t *testing.T) { assert.NotContains(t, err.Error(), "unknown flag") assert.NotContains(t, err.Error(), "unknown shorthand") } + +func TestCommentsCreateReadsDashContentFromStdin(t *testing.T) { + transport := &mockCommentWriteTransport{} + app, _ := setupCommentsWriteTestApp(t, transport) + + cmd := newCommentsCreateCmd() + cmd.SetIn(strings.NewReader("Hello from stdin\n\n**works**\n")) + + err := executeCommand(cmd, app, "789", "-") + require.NoError(t, err) + require.Len(t, transport.capturedBodies, 1) + + var body map[string]string + require.NoError(t, json.Unmarshal(transport.capturedBodies[0], &body)) + assert.Contains(t, body["content"], "Hello from stdin") + assert.Contains(t, body["content"], "works") + assert.NotEqual(t, "

-

", body["content"]) +} + +func TestCommentsUpdateReadsDashContentFromStdin(t *testing.T) { + transport := &mockCommentWriteTransport{} + app, _ := setupCommentsWriteTestApp(t, transport) + + cmd := NewCommentsCmd() + cmd.SetIn(strings.NewReader("Updated from stdin\n")) + + err := executeCommand(cmd, app, "update", "1234", "-") + require.NoError(t, err) + require.Len(t, transport.capturedBodies, 1) + + var body map[string]string + require.NoError(t, json.Unmarshal(transport.capturedBodies[0], &body)) + assert.Equal(t, "

Updated from stdin

", body["content"]) +} + +type mockCommentWriteTransport struct { + capturedBodies [][]byte +} + +func (t *mockCommentWriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Body != nil { + body, _ := io.ReadAll(req.Body) + _ = req.Body.Close() + t.capturedBodies = append(t.capturedBodies, body) + } + + header := make(http.Header) + header.Set("Content-Type", "application/json") + + status := http.StatusOK + if req.Method == http.MethodPost { + status = http.StatusCreated + } + + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(`{"id":1234,"content":"ok","status":"active"}`)), + Header: header, + }, nil +} + +func setupCommentsWriteTestApp(t *testing.T, transport http.RoundTripper) (*appctx.App, *bytes.Buffer) { + t.Helper() + + app, buf := setupTestApp(t) + sdkClient := basecamp.NewClient(&basecamp.Config{}, &testTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(1), + ) + app.SDK = sdkClient + app.Names = names.NewResolver(sdkClient, app.Auth, app.Config.AccountID) + return app, buf +} From 286fed091e6369609e213cd2580d530bd3f7fdc6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 14 May 2026 08:51:44 -0400 Subject: [PATCH 2/2] Reject comment edit before reading dash content --- internal/commands/comment.go | 8 ++++---- internal/commands/edit_test.go | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/commands/comment.go b/internal/commands/comment.go index cd8d6d12..1db4b123 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -322,6 +322,10 @@ Use - as the content argument to read content from stdin: // First arg is always the recording ID(s) recordingArg := args[0] + if edit && len(args) > 1 { + return output.ErrUsage("cannot combine --edit and positional content") + } + var content string if len(args) > 1 { var err error @@ -330,10 +334,6 @@ Use - as the content argument to read content from stdin: return err } } - - if edit && content != "" { - return output.ErrUsage("cannot combine --edit and positional content") - } if edit { fi, err := os.Stdin.Stat() if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 { diff --git a/internal/commands/edit_test.go b/internal/commands/edit_test.go index 1c1567f0..1825b666 100644 --- a/internal/commands/edit_test.go +++ b/internal/commands/edit_test.go @@ -37,6 +37,19 @@ func TestEditContentMutualExclusion(t *testing.T) { } }) + t.Run("comment --edit with dash content", func(t *testing.T) { + err := runCmdWithFlagsAndArgs(NewCommentCmd, + map[string]string{"edit": "true"}, + []string{"12345", "-"}, + ) + if err == nil { + t.Fatal("expected error for --edit + dash content, got nil") + } + if !strings.Contains(err.Error(), "cannot combine") { + t.Errorf("error = %q, want 'cannot combine' message", err) + } + }) + t.Run("message --edit with positional body", func(t *testing.T) { err := runCmdWithFlagsAndArgs(NewMessageCmd, map[string]string{"edit": "true"},