From 80fa89eff5005bb7b6470dd33783056f173e6a6f Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 8 Apr 2026 18:51:28 -0400 Subject: [PATCH 1/3] Add stdin support for comment content --- internal/commands/comment.go | 21 ++++-- internal/commands/comment_test.go | 115 ++++++++++++++++++++++++++++++ internal/commands/helpers.go | 17 +++++ 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 7b7a33b2..fae434a7 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -298,6 +298,9 @@ Comma-separated IDs add the same comment to multiple items: basecamp comment 789,012,345 "Looks good!" basecamp comment https://3.basecamp.com/123/buckets/456/todos/789 "Looks good!" +Content can also be piped from stdin: + printf 'Looks good!' | basecamp comment 789 + Content supports Markdown and @mentions (@Name or @First.Last): basecamp comment 789 "Hey @Jane.Smith, **please review**"`, 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"}, @@ -331,6 +334,20 @@ Content supports Markdown and @mentions (@Name or @First.Last): } } + if err := ensureAccount(cmd, app); err != nil { + return err + } + + if !edit && strings.TrimSpace(content) == "" { + stdinContent, hasPipedStdin, err := readPipedStdin() + if err != nil { + return err + } + if hasPipedStdin { + content = stdinContent + } + } + // Show help when invoked with no content; keep error if editor was opened if strings.TrimSpace(content) == "" { if edit { @@ -339,10 +356,6 @@ Content supports Markdown and @mentions (@Name or @First.Last): return missingArg(cmd, "") } - if err := ensureAccount(cmd, app); err != nil { - return err - } - // Expand comma-separated IDs and extract from URLs expandedIDs := extractIDs([]string{recordingArg}) diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index 963c26de..cc7c2e6b 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -1,6 +1,12 @@ package commands import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -56,3 +62,112 @@ func TestCommentsGroupAcceptsInFlag(t *testing.T) { assert.NotContains(t, err.Error(), "unknown flag") assert.NotContains(t, err.Error(), "unknown shorthand") } + +type mockCommentCreateTransport struct { + capturedBody []byte +} + +func (t *mockCommentCreateTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + + if req.Method != http.MethodPost { + return nil, errors.New("unexpected request") + } + if !strings.HasSuffix(req.URL.Path, "/comments.json") { + return nil, errors.New("unexpected path: " + req.URL.Path) + } + + if req.Body != nil { + body, _ := io.ReadAll(req.Body) + t.capturedBody = body + req.Body.Close() + } + + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{"id": 999, "content": "

hello from stdin

", "status": "active"}`)), + Header: header, + }, nil +} + +func TestCommentCreateReadsContentFromStdin(t *testing.T) { + transport := &mockCommentCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = io.WriteString(w, "hello from stdin") + require.NoError(t, err) + require.NoError(t, w.Close()) + + origStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = origStdin + r.Close() + }) + + cmd := NewCommentCmd() + err = executeCommand(cmd, app, "123") + 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, "

hello from stdin

", body["content"]) +} + +func TestCommentCreatePrefersPositionalContentOverStdin(t *testing.T) { + transport := &mockCommentCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = io.WriteString(w, "ignored stdin") + require.NoError(t, err) + require.NoError(t, w.Close()) + + origStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = origStdin + r.Close() + }) + + cmd := NewCommentCmd() + err = executeCommand(cmd, app, "123", "hello from args") + 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, "

hello from args

", body["content"]) +} + +func TestCommentsCreateReadsContentFromStdin(t *testing.T) { + transport := &mockCommentCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = io.WriteString(w, "hello from stdin") + require.NoError(t, err) + require.NoError(t, w.Close()) + + origStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = origStdin + r.Close() + }) + + cmd := NewCommentsCmd() + err = executeCommand(cmd, app, "create", "123") + 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, "

hello from stdin

", body["content"]) +} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index d355f870..39c81be6 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "strconv" "strings" @@ -79,6 +80,22 @@ func isMachineOutput(cmd *cobra.Command) bool { return false } +func readPipedStdin() (string, bool, error) { + fi, err := os.Stdin.Stat() + if err != nil { + return "", false, fmt.Errorf("failed to inspect stdin: %w", err) + } + if (fi.Mode() & os.ModeCharDevice) != 0 { + return "", false, nil + } + + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", true, 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"` From 65e405f14242c92ed43fc5d591199e366998deac Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 8 Apr 2026 19:04:55 -0400 Subject: [PATCH 2/3] Handle empty comment input before account resolution --- internal/commands/comment.go | 13 ++++------ internal/commands/comment_test.go | 41 +++++++++++++++++++++++++++++++ internal/commands/helpers.go | 10 ++++---- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/internal/commands/comment.go b/internal/commands/comment.go index fae434a7..530c3275 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -334,15 +334,8 @@ Content supports Markdown and @mentions (@Name or @First.Last): } } - if err := ensureAccount(cmd, app); err != nil { - return err - } - if !edit && strings.TrimSpace(content) == "" { - stdinContent, hasPipedStdin, err := readPipedStdin() - if err != nil { - return err - } + stdinContent, hasPipedStdin := readPipedStdin() if hasPipedStdin { content = stdinContent } @@ -356,6 +349,10 @@ Content supports Markdown and @mentions (@Name or @First.Last): return missingArg(cmd, "") } + if err := ensureAccount(cmd, app); err != nil { + return err + } + // Expand comma-separated IDs and extract from URLs expandedIDs := extractIDs([]string{recordingArg}) diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index cc7c2e6b..087cbd7e 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -171,3 +171,44 @@ func TestCommentsCreateReadsContentFromStdin(t *testing.T) { require.NoError(t, json.Unmarshal(transport.capturedBody, &body)) assert.Equal(t, "

hello from stdin

", body["content"]) } + +func TestCommentCreateMissingContentReturnsUsageBeforeAccountResolution(t *testing.T) { + app, _ := setupTestApp(t) + app.Config.AccountID = "" + app.Flags.JSON = true + + devNull, err := os.Open("/dev/null") + if err != nil { + t.Skip("/dev/null not available") + } + + origStdin := os.Stdin + os.Stdin = devNull + t.Cleanup(func() { + os.Stdin = origStdin + devNull.Close() + }) + + cmd := NewCommentCmd() + err = executeCommand(cmd, app, "123") + require.Error(t, err) + assert.Contains(t, err.Error(), " required") + assert.NotContains(t, err.Error(), "account") +} + +func TestReadPipedStdinIgnoresUnreadableStdin(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + require.NoError(t, r.Close()) + require.NoError(t, w.Close()) + + origStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = origStdin + }) + + content, hasPipedStdin := readPipedStdin() + assert.Empty(t, content) + assert.False(t, hasPipedStdin) +} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 39c81be6..4c873db2 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -80,20 +80,20 @@ func isMachineOutput(cmd *cobra.Command) bool { return false } -func readPipedStdin() (string, bool, error) { +func readPipedStdin() (string, bool) { fi, err := os.Stdin.Stat() if err != nil { - return "", false, fmt.Errorf("failed to inspect stdin: %w", err) + return "", false } if (fi.Mode() & os.ModeCharDevice) != 0 { - return "", false, nil + return "", false } data, err := io.ReadAll(os.Stdin) if err != nil { - return "", true, fmt.Errorf("failed to read stdin: %w", err) + return "", false } - return string(data), true, nil + return string(data), true } // DockTool represents a tool in a project's dock. From d108c9d5bcfce1cde352cef99797510dd222f11c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 8 Apr 2026 19:44:24 -0400 Subject: [PATCH 3/3] Use os.DevNull in comment stdin test --- internal/commands/comment_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/commands/comment_test.go b/internal/commands/comment_test.go index 087cbd7e..d83a4bca 100644 --- a/internal/commands/comment_test.go +++ b/internal/commands/comment_test.go @@ -177,9 +177,9 @@ func TestCommentCreateMissingContentReturnsUsageBeforeAccountResolution(t *testi app.Config.AccountID = "" app.Flags.JSON = true - devNull, err := os.Open("/dev/null") + devNull, err := os.Open(os.DevNull) if err != nil { - t.Skip("/dev/null not available") + t.Skip("dev null not available") } origStdin := os.Stdin