Skip to content

Commit b726959

Browse files
matlehhmans
andauthored
feat: add body modification flags and GraphQL mutations (#60)
## Summary Adds CLI flags and GraphQL mutations for partial body modifications, enabling agents to update bean content without direct file access. Builds on #59 (ETag support) and supersedes #57. ## Changes ### CLI: Body Modification Flags - `--body-replace-old` / `--body-replace-new` for exact text replacement (must match exactly once) - `--body-append` for appending content (supports stdin with `-`) - Mutual exclusivity with existing `--body`/`--body-file` flags ### GraphQL: Partial Body Mutations - `replaceInBody(id, old, new, ifMatch)` - replace exactly one occurrence of text - `appendToBody(id, content, ifMatch)` - append content with blank line separator - Both support `ifMatch` for optimistic locking ### Shared Logic - Extract `ReplaceOnce` and `AppendWithSeparator` to `internal/bean/content.go` - Reused by both CLI and GraphQL resolvers ### Documentation - Add GraphQL API section to README with examples ## Usage ```bash # CLI: Check off a task beans update <id> --body-replace-old "- [ ] Task" --body-replace-new "- [x] Task" # CLI: Append notes beans update <id> --body-append "## Notes\n\nSome notes" # GraphQL: Check off a task beans query 'mutation { replaceInBody(id: "bean-xxx", old: "- [ ] Task", new: "- [x] Task") { body } }' # GraphQL: Append content beans query 'mutation { appendToBody(id: "bean-xxx", content: "## Notes") { body } }' ``` ## Why This Matters for Agents Agents can now modify bean bodies entirely through CLI/GraphQL without needing: - Direct file access to the `.beans/` directory - Knowledge of where beans are stored (which can be configured outside the repo) - Special file permissions ## Testing - Comprehensive tests for shared logic in `internal/bean/content_test.go` - GraphQL resolver tests for both mutations - CLI tests updated for new behavior --------- Co-authored-by: Hendrik Mans <[email protected]>
1 parent 9f578f7 commit b726959

File tree

10 files changed

+1161
-22
lines changed

10 files changed

+1161
-22
lines changed

cmd/content.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,26 @@ func mergeTags(existing, add, remove []string) []string {
8282
}
8383
return result
8484
}
85+
86+
// applyBodyReplace replaces exactly one occurrence of old with new.
87+
// Returns an error if old is not found or found multiple times.
88+
func applyBodyReplace(body, old, new string) (string, error) {
89+
return bean.ReplaceOnce(body, old, new)
90+
}
91+
92+
// applyBodyAppend appends text to the body with a newline separator.
93+
func applyBodyAppend(body, text string) string {
94+
return bean.AppendWithSeparator(body, text)
95+
}
96+
97+
// resolveAppendContent handles --append value, supporting stdin with "-".
98+
func resolveAppendContent(value string) (string, error) {
99+
if value == "-" {
100+
data, err := io.ReadAll(os.Stdin)
101+
if err != nil {
102+
return "", fmt.Errorf("reading stdin: %w", err)
103+
}
104+
return strings.TrimRight(string(data), "\n"), nil
105+
}
106+
return value, nil
107+
}

cmd/content_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,205 @@ func TestFormatCycle(t *testing.T) {
101101
}
102102
}
103103
}
104+
105+
func TestApplyBodyReplace(t *testing.T) {
106+
tests := []struct {
107+
name string
108+
body string
109+
old string
110+
new string
111+
want string
112+
wantErr string
113+
}{
114+
{
115+
name: "successful replacement",
116+
body: "- [ ] Task 1\n- [ ] Task 2",
117+
old: "- [ ] Task 1",
118+
new: "- [x] Task 1",
119+
want: "- [x] Task 1\n- [ ] Task 2",
120+
},
121+
{
122+
name: "delete text with empty new",
123+
body: "Hello world",
124+
old: " world",
125+
new: "",
126+
want: "Hello",
127+
},
128+
{
129+
name: "replace in middle of text",
130+
body: "Line 1\nLine 2\nLine 3",
131+
old: "Line 2",
132+
new: "Modified Line 2",
133+
want: "Line 1\nModified Line 2\nLine 3",
134+
},
135+
{
136+
name: "replace entire body",
137+
body: "Old content",
138+
old: "Old content",
139+
new: "New content",
140+
want: "New content",
141+
},
142+
{
143+
name: "text not found",
144+
body: "Hello world",
145+
old: "foo",
146+
new: "bar",
147+
wantErr: "text not found in body",
148+
},
149+
{
150+
name: "multiple matches",
151+
body: "foo foo foo",
152+
old: "foo",
153+
new: "bar",
154+
wantErr: "text found 3 times in body (must be unique)",
155+
},
156+
{
157+
name: "empty old string",
158+
body: "Hello",
159+
old: "",
160+
new: "world",
161+
wantErr: "old text cannot be empty",
162+
},
163+
{
164+
name: "partial match only once",
165+
body: "Task 1\nTask 2\nTask 3",
166+
old: "Task 2",
167+
new: "Completed Task 2",
168+
want: "Task 1\nCompleted Task 2\nTask 3",
169+
},
170+
}
171+
172+
for _, tt := range tests {
173+
t.Run(tt.name, func(t *testing.T) {
174+
got, err := applyBodyReplace(tt.body, tt.old, tt.new)
175+
176+
if tt.wantErr != "" {
177+
if err == nil {
178+
t.Errorf("applyBodyReplace() expected error containing %q, got nil", tt.wantErr)
179+
return
180+
}
181+
if !contains(err.Error(), tt.wantErr) {
182+
t.Errorf("applyBodyReplace() error = %q, want error containing %q", err.Error(), tt.wantErr)
183+
}
184+
return
185+
}
186+
187+
if err != nil {
188+
t.Errorf("applyBodyReplace() unexpected error: %v", err)
189+
return
190+
}
191+
192+
if got != tt.want {
193+
t.Errorf("applyBodyReplace() = %q, want %q", got, tt.want)
194+
}
195+
})
196+
}
197+
}
198+
199+
func TestApplyBodyAppend(t *testing.T) {
200+
tests := []struct {
201+
name string
202+
body string
203+
text string
204+
want string
205+
}{
206+
{
207+
name: "append to empty body",
208+
body: "",
209+
text: "New content",
210+
want: "New content",
211+
},
212+
{
213+
name: "append to existing body",
214+
body: "Existing content",
215+
text: "New content",
216+
want: "Existing content\n\nNew content",
217+
},
218+
{
219+
name: "append strips trailing newlines from body",
220+
body: "Existing\n\n\n",
221+
text: "New",
222+
want: "Existing\n\nNew",
223+
},
224+
{
225+
name: "append multiline content",
226+
body: "Header",
227+
text: "## Section\n\nParagraph",
228+
want: "Header\n\n## Section\n\nParagraph",
229+
},
230+
{
231+
name: "append to body with single trailing newline",
232+
body: "Content\n",
233+
text: "More",
234+
want: "Content\n\nMore",
235+
},
236+
{
237+
name: "append empty text is no-op",
238+
body: "Content",
239+
text: "",
240+
want: "Content",
241+
},
242+
}
243+
244+
for _, tt := range tests {
245+
t.Run(tt.name, func(t *testing.T) {
246+
got := applyBodyAppend(tt.body, tt.text)
247+
if got != tt.want {
248+
t.Errorf("applyBodyAppend() = %q, want %q", got, tt.want)
249+
}
250+
})
251+
}
252+
}
253+
254+
func TestResolveAppendContent(t *testing.T) {
255+
tests := []struct {
256+
name string
257+
value string
258+
want string
259+
}{
260+
{
261+
name: "direct value",
262+
value: "some text",
263+
want: "some text",
264+
},
265+
{
266+
name: "direct multiline value",
267+
value: "line 1\nline 2",
268+
want: "line 1\nline 2",
269+
},
270+
{
271+
name: "empty value",
272+
value: "",
273+
want: "",
274+
},
275+
// Note: stdin case ("-") is tested in integration tests
276+
// as it's difficult to mock in unit tests
277+
}
278+
279+
for _, tt := range tests {
280+
t.Run(tt.name, func(t *testing.T) {
281+
got, err := resolveAppendContent(tt.value)
282+
if err != nil {
283+
t.Errorf("resolveAppendContent() unexpected error: %v", err)
284+
return
285+
}
286+
if got != tt.want {
287+
t.Errorf("resolveAppendContent() = %q, want %q", got, tt.want)
288+
}
289+
})
290+
}
291+
}
292+
293+
// Helper function to check if a string contains a substring
294+
func contains(s, substr string) bool {
295+
return len(s) >= len(substr) && (s == substr || len(substr) == 0 || indexOfSubstring(s, substr) >= 0)
296+
}
297+
298+
func indexOfSubstring(s, substr string) int {
299+
for i := 0; i <= len(s)-len(substr); i++ {
300+
if s[i:i+len(substr)] == substr {
301+
return i
302+
}
303+
}
304+
return -1
305+
}

cmd/prompt.tmpl

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@ beans show --json <id> [id...]
5454
# Create a bean (always specify -t type)
5555
beans create --json "Title" -t task -d "Description..." -s todo
5656
57-
# Update a bean
58-
beans update --json <id> -s in-progress # Change status
59-
beans update --json <id> --parent <other-id> # Set parent relationship
60-
beans update --json <id> --blocking <other-id> # Mark as blocking another bean
57+
# Update a bean (metadata, body, or both)
58+
beans update --json <id> -s in-progress # Change status
59+
beans update --json <id> --parent <other-id> # Set parent relationship
60+
beans update --json <id> --blocking <other-id> # Mark as blocking another bean
61+
beans update --json <id> --body-replace-old "old" --body-replace-new "new" # Replace text
62+
beans update --json <id> --body-append "## Notes" # Append to body
63+
beans update --json <id> -s completed --body-replace-old "- [ ] Task" --body-replace-new "- [x] Task" # Combined
6164
6265
# Archive completed/scrapped beans (only when user requests)
6366
beans archive
@@ -93,13 +96,40 @@ Beans can have an optional priority. Use `-p` when creating or `--priority` when
9396

9497
Beans without a priority are treated as `normal` priority for sorting purposes.
9598

99+
## Modifying Bean Body Content
100+
101+
Use `beans update` to modify body content along with metadata changes:
102+
103+
**Replace text (exact match, must occur exactly once):**
104+
```bash
105+
beans update <id> --body-replace-old "- [ ] Task 1" --body-replace-new "- [x] Task 1"
106+
```
107+
- Errors if text not found or found multiple times
108+
- Use empty string to delete the matched text
109+
110+
**Append content:**
111+
```bash
112+
beans update <id> --body-append "## Notes\n\nAdded content"
113+
echo "Multi-line content" | beans update <id> --body-append -
114+
```
115+
- Adds text to end of body with blank line separator
116+
- Use `-` to read from stdin
117+
118+
**Combined with metadata changes:**
119+
```bash
120+
beans update <id> \
121+
--body-replace-old "- [ ] Deploy to prod" --body-replace-new "- [x] Deploy to prod" \
122+
--status completed
123+
```
124+
96125
## Concurrency Control
97126

98-
Use etags with `--if-match` for optimistic locking:
127+
Use etags with `--if-match`:
99128
```bash
100129
ETAG=$(beans show <id> --etag-only)
101-
beans update <id> --if-match "$ETAG" -s completed
130+
beans update <id> --if-match "$ETAG" ...
102131
```
132+
103133
On conflict, returns an error with the current etag.
104134

105135
## GraphQL Queries

cmd/update.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ var (
2121
updateTitle string
2222
updateBody string
2323
updateBodyFile string
24+
updateBodyReplaceOld string
25+
updateBodyReplaceNew string
26+
updateBodyAppend string
2427
updateParent string
2528
updateRemoveParent bool
2629
updateBlocking []string
@@ -72,7 +75,7 @@ var updateCmd = &cobra.Command{
7275
}
7376

7477
// Build and validate field updates
75-
input, fieldChanges, err := buildUpdateInput(cmd, b.Tags)
78+
input, fieldChanges, err := buildUpdateInput(cmd, b.Tags, b.Body)
7679
if err != nil {
7780
return cmdError(updateJSON, output.ErrValidation, "%s", err)
7881
}
@@ -147,7 +150,7 @@ var updateCmd = &cobra.Command{
147150
}
148151

149152
// buildUpdateInput constructs the GraphQL input from flags and returns which fields changed.
150-
func buildUpdateInput(cmd *cobra.Command, existingTags []string) (model.UpdateBeanInput, []string, error) {
153+
func buildUpdateInput(cmd *cobra.Command, existingTags []string, currentBody string) (model.UpdateBeanInput, []string, error) {
151154
var input model.UpdateBeanInput
152155
var changes []string
153156

@@ -180,14 +183,30 @@ func buildUpdateInput(cmd *cobra.Command, existingTags []string) (model.UpdateBe
180183
changes = append(changes, "title")
181184
}
182185

183-
// Handle body modifications
186+
// Handle body modifications (mutually exclusive)
184187
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
185188
body, err := resolveContent(updateBody, updateBodyFile)
186189
if err != nil {
187190
return input, nil, err
188191
}
189192
input.Body = &body
190193
changes = append(changes, "body")
194+
} else if cmd.Flags().Changed("body-replace-old") {
195+
// --body-replace-old requires --body-replace-new (enforced by MarkFlagsRequiredTogether)
196+
newBody, err := applyBodyReplace(currentBody, updateBodyReplaceOld, updateBodyReplaceNew)
197+
if err != nil {
198+
return input, nil, err
199+
}
200+
input.Body = &newBody
201+
changes = append(changes, "body")
202+
} else if cmd.Flags().Changed("body-append") {
203+
appendText, err := resolveAppendContent(updateBodyAppend)
204+
if err != nil {
205+
return input, nil, err
206+
}
207+
newBody := applyBodyAppend(currentBody, appendText)
208+
input.Body = &newBody
209+
changes = append(changes, "body")
191210
}
192211

193212
if len(updateTag) > 0 || len(updateRemoveTag) > 0 {
@@ -240,6 +259,9 @@ func init() {
240259
updateCmd.Flags().StringVar(&updateTitle, "title", "", "New title")
241260
updateCmd.Flags().StringVarP(&updateBody, "body", "d", "", "New body (use '-' to read from stdin)")
242261
updateCmd.Flags().StringVar(&updateBodyFile, "body-file", "", "Read body from file")
262+
updateCmd.Flags().StringVar(&updateBodyReplaceOld, "body-replace-old", "", "Text to find and replace (requires --body-replace-new)")
263+
updateCmd.Flags().StringVar(&updateBodyReplaceNew, "body-replace-new", "", "Replacement text (requires --body-replace-old)")
264+
updateCmd.Flags().StringVar(&updateBodyAppend, "body-append", "", "Text to append to body (use '-' for stdin)")
243265
updateCmd.Flags().StringVar(&updateParent, "parent", "", "Set parent bean ID")
244266
updateCmd.Flags().BoolVar(&updateRemoveParent, "remove-parent", false, "Remove parent")
245267
updateCmd.Flags().StringArrayVar(&updateBlocking, "blocking", nil, "ID of bean this blocks (can be repeated)")
@@ -249,6 +271,7 @@ func init() {
249271
updateCmd.Flags().StringVar(&updateIfMatch, "if-match", "", "Only update if etag matches (optimistic locking)")
250272
updateCmd.MarkFlagsMutuallyExclusive("parent", "remove-parent")
251273
updateCmd.Flags().BoolVar(&updateJSON, "json", false, "Output as JSON")
252-
updateCmd.MarkFlagsMutuallyExclusive("body", "body-file")
274+
updateCmd.MarkFlagsMutuallyExclusive("body", "body-file", "body-replace-old", "body-append")
275+
updateCmd.MarkFlagsRequiredTogether("body-replace-old", "body-replace-new")
253276
rootCmd.AddCommand(updateCmd)
254277
}

0 commit comments

Comments
 (0)