Skip to content

Commit fea17f4

Browse files
authored
Add --subscribe / --no-subscribe to recording creation commands (#187)
* Bump SDK to v0.2.2 Picks up Subscriptions field on create request types (CreateMessageRequest, CreateDocumentRequest, CreateScheduleEntryRequest). * Add --subscribe / --no-subscribe to recording creation commands Controls who gets subscribed and notified when creating messages, docs, and schedule entries. Critical for bot/agent use cases that need silent creation. --no-subscribe subscribe nobody (no notifications) --subscribe "X,Y" subscribe specific people (names, emails, IDs, "me") Mutually exclusive. Neither flag preserves server default (everyone). Explicit --subscribe with no resolvable people is a hard error, including --subscribe "" (via cmd.Flags().Changed detection). Available on: message, messages create, docs create, schedule create. Closes #182 * Address PR review feedback - Wrap ParseInt error in resolvePersonIDs for better diagnostics - Add happy-path tests verifying --no-subscribe sends empty subscriptions array and default omits the field entirely - Update --no-subscribe help text to "Don't subscribe anyone else" since the server always auto-subscribes the creator
1 parent 8990b3d commit fea17f4

File tree

12 files changed

+512
-23
lines changed

12 files changed

+512
-23
lines changed

API-COVERAGE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif
2525
| todosets | 3 | `todosets` || - | Container for todolists, accessed via project dock |
2626
| todolist_groups | 8 | `todolistgroups` || - | list, show, create, update, position |
2727
| **Communication** |
28-
| messages | 10 | `messages`, `message` || - | list, show, create, update, pin, unpin |
28+
| messages | 10 | `messages`, `message` || - | list, show, create, update, pin, unpin. Create supports `--subscribe`/`--no-subscribe` |
2929
| message_boards | 3 | `messageboards` || - | Container, accessed via project dock |
3030
| message_types | 5 | `messagetypes` || - | list, show, create, update, delete |
3131
| campfires | 14 | `campfire` || - | list, messages, post, line show/delete |
@@ -44,11 +44,11 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif
4444
| **Files & Documents** |
4545
| uploads | 8 | `files`, `uploads` || - | list, show |
4646
| vaults | 8 | `files`, `vaults` || - | list, show, create |
47-
| documents | 8 | `files`, `docs` || - | list, show, create, update |
47+
| documents | 8 | `files`, `docs` || - | list, show, create, update. Create supports `--subscribe`/`--no-subscribe` |
4848
| attachments | 1 | `uploads` || - | Attachment metadata |
4949
| **Schedule** |
5050
| schedules | 2 | `schedule` || - | Schedule container + settings |
51-
| schedule_entries | 5 | `schedule` || - | list, show, create, update, occurrences |
51+
| schedule_entries | 5 | `schedule` || - | list, show, create, update, occurrences. Create supports `--subscribe`/`--no-subscribe` |
5252
| events | 1 | `events` || - | Recording change audit trail |
5353
| **Webhooks** |
5454
| webhooks | 7 | `webhooks` || - | list, show, create, update, delete |

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/basecamp/basecamp-cli
33
go 1.26
44

55
require (
6-
github.com/basecamp/basecamp-sdk/go v0.0.0-20260228105634-f97b325fe599
6+
github.com/basecamp/basecamp-sdk/go v0.2.2
77
github.com/charmbracelet/bubbles v1.0.0
88
github.com/charmbracelet/bubbletea v1.3.10
99
github.com/charmbracelet/glamour v0.10.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
1919
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
2020
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2121
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
22-
github.com/basecamp/basecamp-sdk/go v0.0.0-20260228105634-f97b325fe599 h1:wev4lDLYB5vzlYLURqdu8GZYld9cJgRQNkdgFZ5HqkI=
23-
github.com/basecamp/basecamp-sdk/go v0.0.0-20260228105634-f97b325fe599/go.mod h1:WmckHy36EAqP+BW//1J9QdMi16l3PNx2XP0vt/kSlXE=
22+
github.com/basecamp/basecamp-sdk/go v0.2.2 h1:wfMrjTytLCLsBG2SrQh5UDvGgj3QHVwg6KRvkL+ayeg=
23+
github.com/basecamp/basecamp-sdk/go v0.2.2/go.mod h1:WmckHy36EAqP+BW//1J9QdMi16l3PNx2XP0vt/kSlXE=
2424
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
2525
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
2626
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=

internal/commands/files.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,8 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command {
702702
var title string
703703
var content string
704704
var draft bool
705+
var subscribe string
706+
var noSubscribe bool
705707

706708
cmd := &cobra.Command{
707709
Use: "create",
@@ -717,6 +719,12 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command {
717719
return output.ErrUsage("--title is required")
718720
}
719721

722+
// Resolve subscription flags before project (fail fast on bad input)
723+
subs, err := applySubscribeFlags(cmd.Context(), app.Names, subscribe, cmd.Flags().Changed("subscribe"), noSubscribe)
724+
if err != nil {
725+
return err
726+
}
727+
720728
// Resolve project, with interactive fallback
721729
projectID := *project
722730
if projectID == "" {
@@ -753,8 +761,9 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command {
753761

754762
// Create document using SDK
755763
req := &basecamp.CreateDocumentRequest{
756-
Title: title,
757-
Content: content,
764+
Title: title,
765+
Content: content,
766+
Subscriptions: subs,
758767
}
759768
if draft {
760769
req.Status = "drafted"
@@ -788,6 +797,8 @@ func newDocsCreateCmd(project, vaultID *string) *cobra.Command {
788797
cmd.Flags().StringVarP(&title, "title", "t", "", "Document title (required)")
789798
cmd.Flags().StringVarP(&content, "content", "c", "", "Document content")
790799
cmd.Flags().BoolVar(&draft, "draft", false, "Create as draft (default: published)")
800+
cmd.Flags().StringVar(&subscribe, "subscribe", "", "Subscribe specific people (comma-separated names, emails, IDs, or \"me\")")
801+
cmd.Flags().BoolVar(&noSubscribe, "no-subscribe", false, "Don't subscribe anyone else (silent, no notifications)")
791802
_ = cmd.MarkFlagRequired("title")
792803

793804
return cmd

internal/commands/files_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package commands
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/basecamp/basecamp-cli/internal/output"
11+
)
12+
13+
// TestDocsCreateHasSubscribeFlags tests that docs create has --subscribe and --no-subscribe flags.
14+
func TestDocsCreateHasSubscribeFlags(t *testing.T) {
15+
cmd := NewFilesCmd()
16+
17+
// Navigate: files -> documents -> create
18+
docsCmd, _, err := cmd.Find([]string{"documents", "create"})
19+
require.NoError(t, err)
20+
21+
flag := docsCmd.Flags().Lookup("subscribe")
22+
require.NotNil(t, flag, "expected --subscribe flag on docs create")
23+
24+
flag = docsCmd.Flags().Lookup("no-subscribe")
25+
require.NotNil(t, flag, "expected --no-subscribe flag on docs create")
26+
}
27+
28+
// TestDocsCreateSubscribeEmptyIsError tests that --subscribe "" is rejected on docs create.
29+
func TestDocsCreateSubscribeEmptyIsError(t *testing.T) {
30+
app, _ := setupMessagesTestApp(t)
31+
app.Config.ProjectID = "123"
32+
33+
cmd := NewFilesCmd()
34+
35+
err := executeMessagesCommand(cmd, app, "documents", "create", "--title", "Test", "--subscribe", "")
36+
require.Error(t, err)
37+
38+
var e *output.Error
39+
require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err)
40+
assert.Contains(t, e.Message, "at least one person")
41+
}
42+
43+
// TestDocsCreateSubscribeMutualExclusion tests that --subscribe and --no-subscribe are mutually exclusive.
44+
func TestDocsCreateSubscribeMutualExclusion(t *testing.T) {
45+
app, _ := setupMessagesTestApp(t)
46+
app.Config.ProjectID = "123"
47+
48+
cmd := NewFilesCmd()
49+
50+
err := executeMessagesCommand(cmd, app, "documents", "create", "--title", "Test", "--subscribe", "me", "--no-subscribe")
51+
require.Error(t, err)
52+
53+
var e *output.Error
54+
require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err)
55+
assert.Contains(t, e.Message, "mutually exclusive")
56+
}

internal/commands/helpers.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strconv"
78
"strings"
89

910
"github.com/spf13/cobra"
1011

1112
"github.com/basecamp/basecamp-cli/internal/appctx"
13+
"github.com/basecamp/basecamp-cli/internal/names"
1214
"github.com/basecamp/basecamp-cli/internal/output"
1315
"github.com/basecamp/basecamp-cli/internal/urlarg"
1416
)
@@ -222,3 +224,56 @@ func extractCommentWithProject(arg string) (id, projectID string) {
222224
func extractIDs(args []string) []string {
223225
return urlarg.ExtractIDs(args)
224226
}
227+
228+
// resolvePersonIDs splits a comma-separated input string and resolves each
229+
// token (name, email, ID, or "me") to a person ID via the name resolver.
230+
func resolvePersonIDs(ctx context.Context, resolver *names.Resolver, input string) ([]int64, error) {
231+
var ids []int64
232+
for token := range strings.SplitSeq(input, ",") {
233+
token = strings.TrimSpace(token)
234+
if token == "" {
235+
continue
236+
}
237+
idStr, _, err := resolver.ResolvePerson(ctx, token)
238+
if err != nil {
239+
return nil, fmt.Errorf("resolving %q: %w", token, err)
240+
}
241+
id, err := strconv.ParseInt(idStr, 10, 64)
242+
if err != nil {
243+
return nil, fmt.Errorf("invalid person ID %q for %q: %w", idStr, token, err)
244+
}
245+
ids = append(ids, id)
246+
}
247+
return ids, nil
248+
}
249+
250+
// applySubscribeFlags interprets --subscribe / --no-subscribe flag values and
251+
// returns the SDK Subscriptions pointer:
252+
// - Both set → usage error (mutually exclusive)
253+
// - --no-subscribe → &[]int64{} (empty list, no one else subscribed)
254+
// - --subscribe "X,Y" → resolve each → &[]int64{id1, id2}
255+
// - --subscribe "" (explicitly set but empty) → usage error
256+
// - Neither → nil (omit, server default: everyone)
257+
//
258+
// subscribeChanged should be true when the --subscribe flag was explicitly
259+
// provided on the command line (i.e. cmd.Flags().Changed("subscribe")).
260+
func applySubscribeFlags(ctx context.Context, resolver *names.Resolver, subscribe string, subscribeChanged, noSubscribe bool) (*[]int64, error) {
261+
if subscribeChanged && noSubscribe {
262+
return nil, output.ErrUsage("--subscribe and --no-subscribe are mutually exclusive")
263+
}
264+
if noSubscribe {
265+
empty := []int64{}
266+
return &empty, nil
267+
}
268+
if subscribeChanged {
269+
ids, err := resolvePersonIDs(ctx, resolver, subscribe)
270+
if err != nil {
271+
return nil, err
272+
}
273+
if len(ids) == 0 {
274+
return nil, output.ErrUsage("--subscribe requires at least one person")
275+
}
276+
return &ids, nil
277+
}
278+
return nil, nil
279+
}

internal/commands/helpers_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package commands
22

33
import (
4+
"context"
5+
"errors"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/basecamp/basecamp-cli/internal/output"
712
)
813

914
func TestIsNumeric(t *testing.T) {
@@ -36,3 +41,68 @@ func TestIsNumeric(t *testing.T) {
3641
})
3742
}
3843
}
44+
45+
func TestApplySubscribeFlags_MutualExclusion(t *testing.T) {
46+
ctx := context.Background()
47+
// subscribeChanged=true, noSubscribe=true
48+
_, err := applySubscribeFlags(ctx, nil, "someone", true, true)
49+
50+
require.Error(t, err)
51+
var e *output.Error
52+
require.True(t, errors.As(err, &e), "expected *output.Error, got %T", err)
53+
assert.Contains(t, e.Message, "mutually exclusive")
54+
}
55+
56+
func TestApplySubscribeFlags_NoSubscribe(t *testing.T) {
57+
ctx := context.Background()
58+
// subscribeChanged=false, noSubscribe=true
59+
result, err := applySubscribeFlags(ctx, nil, "", false, true)
60+
61+
require.NoError(t, err)
62+
require.NotNil(t, result, "expected non-nil pointer for --no-subscribe")
63+
assert.Empty(t, *result, "expected empty slice for --no-subscribe")
64+
}
65+
66+
func TestApplySubscribeFlags_Neither(t *testing.T) {
67+
ctx := context.Background()
68+
// subscribeChanged=false, noSubscribe=false
69+
result, err := applySubscribeFlags(ctx, nil, "", false, false)
70+
71+
require.NoError(t, err)
72+
assert.Nil(t, result, "expected nil when neither flag is set")
73+
}
74+
75+
func TestApplySubscribeFlags_ExplicitEmptyString(t *testing.T) {
76+
// --subscribe "" (explicitly set but empty value) should be a hard error
77+
ctx := context.Background()
78+
// subscribeChanged=true (flag was explicitly passed), value=""
79+
_, err := applySubscribeFlags(ctx, nil, "", true, false)
80+
81+
require.Error(t, err)
82+
var e *output.Error
83+
require.True(t, errors.As(err, &e), "expected *output.Error, got %T", err)
84+
assert.Contains(t, e.Message, "at least one person")
85+
}
86+
87+
func TestApplySubscribeFlags_WhitespaceOnlyRequiresAtLeastOne(t *testing.T) {
88+
ctx := context.Background()
89+
// subscribeChanged=true, value=" "
90+
_, err := applySubscribeFlags(ctx, nil, " ", true, false)
91+
92+
require.Error(t, err)
93+
var e *output.Error
94+
require.True(t, errors.As(err, &e), "expected *output.Error, got %T", err)
95+
assert.Contains(t, e.Message, "at least one person")
96+
}
97+
98+
func TestApplySubscribeFlags_CommaOnlyRequiresAtLeastOne(t *testing.T) {
99+
// --subscribe ",,," should fail: only delimiters, no actual tokens
100+
ctx := context.Background()
101+
// subscribeChanged=true, value=",,,"
102+
_, err := applySubscribeFlags(ctx, nil, ",,,", true, false)
103+
104+
require.Error(t, err)
105+
var e *output.Error
106+
require.True(t, errors.As(err, &e), "expected *output.Error, got %T", err)
107+
assert.Contains(t, e.Message, "at least one person")
108+
}

internal/commands/messages.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command
215215
var subject string
216216
var content string
217217
var draft bool
218+
var subscribe string
219+
var noSubscribe bool
218220

219221
cmd := &cobra.Command{
220222
Use: "create",
@@ -231,6 +233,12 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command
231233
return output.ErrUsage("--subject is required")
232234
}
233235

236+
// Resolve subscription flags before project (fail fast on bad input)
237+
subs, err := applySubscribeFlags(cmd.Context(), app.Names, subscribe, cmd.Flags().Changed("subscribe"), noSubscribe)
238+
if err != nil {
239+
return err
240+
}
241+
234242
// Resolve project, with interactive fallback
235243
projectID := *project
236244
if projectID == "" {
@@ -265,8 +273,9 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command
265273
// Build SDK request
266274
// Convert Markdown content to HTML for Basecamp's rich text fields
267275
req := &basecamp.CreateMessageRequest{
268-
Subject: subject,
269-
Content: richtext.MarkdownToHTML(content),
276+
Subject: subject,
277+
Content: richtext.MarkdownToHTML(content),
278+
Subscriptions: subs,
270279
}
271280

272281
// Default to active (published) status unless --draft is specified
@@ -303,6 +312,8 @@ func newMessagesCreateCmd(project *string, messageBoard *string) *cobra.Command
303312
cmd.Flags().StringVarP(&content, "content", "b", "", "Message body content")
304313
cmd.Flags().StringVar(&content, "body", "", "Message body content (alias for --content)")
305314
cmd.Flags().BoolVar(&draft, "draft", false, "Create as draft (don't publish)")
315+
cmd.Flags().StringVar(&subscribe, "subscribe", "", "Subscribe specific people (comma-separated names, emails, IDs, or \"me\")")
316+
cmd.Flags().BoolVar(&noSubscribe, "no-subscribe", false, "Don't subscribe anyone else (silent, no notifications)")
306317
_ = cmd.MarkFlagRequired("subject")
307318

308319
return cmd
@@ -485,6 +496,8 @@ func NewMessageCmd() *cobra.Command {
485496
var project string
486497
var messageBoard string
487498
var draft bool
499+
var subscribe string
500+
var noSubscribe bool
488501

489502
cmd := &cobra.Command{
490503
Use: "message",
@@ -501,6 +514,12 @@ func NewMessageCmd() *cobra.Command {
501514
return output.ErrUsage("--subject is required")
502515
}
503516

517+
// Resolve subscription flags before project (fail fast on bad input)
518+
subs, err := applySubscribeFlags(cmd.Context(), app.Names, subscribe, cmd.Flags().Changed("subscribe"), noSubscribe)
519+
if err != nil {
520+
return err
521+
}
522+
504523
// Resolve project, with interactive fallback
505524
projectID := project
506525
if projectID == "" {
@@ -535,8 +554,9 @@ func NewMessageCmd() *cobra.Command {
535554
// Build SDK request
536555
// Convert Markdown content to HTML for Basecamp's rich text fields
537556
req := &basecamp.CreateMessageRequest{
538-
Subject: subject,
539-
Content: richtext.MarkdownToHTML(content),
557+
Subject: subject,
558+
Content: richtext.MarkdownToHTML(content),
559+
Subscriptions: subs,
540560
}
541561
if draft {
542562
req.Status = "drafted"
@@ -574,6 +594,8 @@ func NewMessageCmd() *cobra.Command {
574594
cmd.Flags().StringVar(&project, "in", "", "Project ID (alias for --project)")
575595
cmd.Flags().StringVar(&messageBoard, "message-board", "", "Message board ID (required if project has multiple)")
576596
cmd.Flags().BoolVar(&draft, "draft", false, "Create as draft (don't publish)")
597+
cmd.Flags().StringVar(&subscribe, "subscribe", "", "Subscribe specific people (comma-separated names, emails, IDs, or \"me\")")
598+
cmd.Flags().BoolVar(&noSubscribe, "no-subscribe", false, "Don't subscribe anyone else (silent, no notifications)")
577599
_ = cmd.MarkFlagRequired("subject")
578600

579601
return cmd

0 commit comments

Comments
 (0)