From 83c9b57d9ff034ef1c7b9c576336a6ebc3aeb6c3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 3 Sep 2026 16:08:09 -0400 Subject: [PATCH 1/4] Add to-do list template library commands --- .surface | 73 +++ API-COVERAGE.md | 29 +- e2e/smoke/smoke_account.bats | 7 + e2e/smoke/smoke_bookmarks.bats | 8 + e2e/smoke/smoke_lifecycle.bats | 8 + e2e/templates.bats | 29 ++ go.mod | 2 +- go.sum | 4 +- internal/commands/commands.go | 2 +- internal/commands/templates.go | 224 ++++++++- internal/commands/templates_test.go | 212 +++++++++ internal/mcpserver/catalog_test.go | 4 +- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/behavior-model.json | 39 ++ internal/mcpserver/model/openapi.json | 435 +++++++++++++++++- .../mcpserver/testdata/catalog_snapshot.txt | 3 + internal/version/sdk-provenance.json | 10 +- skills/basecamp/SKILL.md | 24 +- 18 files changed, 1076 insertions(+), 41 deletions(-) create mode 100644 internal/commands/templates_test.go diff --git a/.surface b/.surface index 3b1b64d04..bbeb79ab7 100644 --- a/.surface +++ b/.surface @@ -336,6 +336,8 @@ ARG basecamp subscriptions unsubscribe 00 ARG basecamp templates construct 00 ARG basecamp templates construction 00 ARG basecamp templates construction 01 +ARG basecamp templates copy 00 +ARG basecamp templates copy-status 00 ARG basecamp templates create 00 ARG basecamp templates delete 00 ARG basecamp templates show 00 @@ -991,8 +993,11 @@ CMD basecamp subscriptions unsubscribe CMD basecamp templates CMD basecamp templates construct CMD basecamp templates construction +CMD basecamp templates copy +CMD basecamp templates copy-status CMD basecamp templates create CMD basecamp templates delete +CMD basecamp templates library CMD basecamp templates list CMD basecamp templates show CMD basecamp templates update @@ -13296,6 +13301,50 @@ FLAG basecamp templates construction --stats type=bool FLAG basecamp templates construction --styled type=bool FLAG basecamp templates construction --todolist type=string FLAG basecamp templates construction --verbose type=count +FLAG basecamp templates copy --account type=string +FLAG basecamp templates copy --agent type=bool +FLAG basecamp templates copy --cache-dir type=string +FLAG basecamp templates copy --confirm-adding-people type=bool +FLAG basecamp templates copy --count type=bool +FLAG basecamp templates copy --help type=bool +FLAG basecamp templates copy --hints type=bool +FLAG basecamp templates copy --ids-only type=bool +FLAG basecamp templates copy --in type=string +FLAG basecamp templates copy --jq type=string +FLAG basecamp templates copy --json type=bool +FLAG basecamp templates copy --markdown type=bool +FLAG basecamp templates copy --md type=bool +FLAG basecamp templates copy --no-hints type=bool +FLAG basecamp templates copy --no-stats type=bool +FLAG basecamp templates copy --profile type=string +FLAG basecamp templates copy --project type=string +FLAG basecamp templates copy --quiet type=bool +FLAG basecamp templates copy --stats type=bool +FLAG basecamp templates copy --styled type=bool +FLAG basecamp templates copy --todolist type=string +FLAG basecamp templates copy --todoset type=string +FLAG basecamp templates copy --verbose type=count +FLAG basecamp templates copy-status --account type=string +FLAG basecamp templates copy-status --agent type=bool +FLAG basecamp templates copy-status --cache-dir type=string +FLAG basecamp templates copy-status --count type=bool +FLAG basecamp templates copy-status --help type=bool +FLAG basecamp templates copy-status --hints type=bool +FLAG basecamp templates copy-status --ids-only type=bool +FLAG basecamp templates copy-status --in type=string +FLAG basecamp templates copy-status --jq type=string +FLAG basecamp templates copy-status --json type=bool +FLAG basecamp templates copy-status --markdown type=bool +FLAG basecamp templates copy-status --md type=bool +FLAG basecamp templates copy-status --no-hints type=bool +FLAG basecamp templates copy-status --no-stats type=bool +FLAG basecamp templates copy-status --profile type=string +FLAG basecamp templates copy-status --project type=string +FLAG basecamp templates copy-status --quiet type=bool +FLAG basecamp templates copy-status --stats type=bool +FLAG basecamp templates copy-status --styled type=bool +FLAG basecamp templates copy-status --todolist type=string +FLAG basecamp templates copy-status --verbose type=count FLAG basecamp templates create --account type=string FLAG basecamp templates create --agent type=bool FLAG basecamp templates create --cache-dir type=string @@ -13341,6 +13390,27 @@ FLAG basecamp templates delete --stats type=bool FLAG basecamp templates delete --styled type=bool FLAG basecamp templates delete --todolist type=string FLAG basecamp templates delete --verbose type=count +FLAG basecamp templates library --account type=string +FLAG basecamp templates library --agent type=bool +FLAG basecamp templates library --cache-dir type=string +FLAG basecamp templates library --count type=bool +FLAG basecamp templates library --help type=bool +FLAG basecamp templates library --hints type=bool +FLAG basecamp templates library --ids-only type=bool +FLAG basecamp templates library --in type=string +FLAG basecamp templates library --jq type=string +FLAG basecamp templates library --json type=bool +FLAG basecamp templates library --markdown type=bool +FLAG basecamp templates library --md type=bool +FLAG basecamp templates library --no-hints type=bool +FLAG basecamp templates library --no-stats type=bool +FLAG basecamp templates library --profile type=string +FLAG basecamp templates library --project type=string +FLAG basecamp templates library --quiet type=bool +FLAG basecamp templates library --stats type=bool +FLAG basecamp templates library --styled type=bool +FLAG basecamp templates library --todolist type=string +FLAG basecamp templates library --verbose type=count FLAG basecamp templates list --account type=string FLAG basecamp templates list --agent type=bool FLAG basecamp templates list --cache-dir type=string @@ -18426,8 +18496,11 @@ SUB basecamp subscriptions unsubscribe SUB basecamp templates SUB basecamp templates construct SUB basecamp templates construction +SUB basecamp templates copy +SUB basecamp templates copy-status SUB basecamp templates create SUB basecamp templates delete +SUB basecamp templates library SUB basecamp templates list SUB basecamp templates show SUB basecamp templates update diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 412352725..6c3f334fd 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,14 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 50 | 186 | +| ✅ Implemented | 50 | 189 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **54** | **198** | +| **Total tracked** | **54** | **201** | -**186 of 186 tracked in-scope endpoints.** The last gap — `GET +**189 of 189 tracked in-scope endpoints.** SDK v0.16.0 adds the three to-do +list template-library operations, available through `templates library`, +`templates copy`, and `templates copy-status`. The previous last gap — `GET /uploads/:id/versions.json` — closed with the v0.14.0 SDK bump. The command (`files versions`) was written earlier but held: the SDK's `UploadsService.ListVersions` decoded the response as `[]Upload` when the API @@ -43,16 +45,15 @@ Out-of-scope sections are excluded from parity totals and scripts: chatbots (dif > Note: the per-row `Endpoints` column in the Coverage by Section table sums higher than the Summary totals above. The discrepancy predates the BC5 baseline; the row count (48 sections) is authoritative for the `Since` column. Reconciling endpoint counts is pre-existing maintenance, tracked separately. -**SDK version:** v0.15.1 (adds Bubble Up write support; pinned to the -pre-release bubble-up SDK head until the v0.15.1 tag — -`internal/version/sdk-provenance.json` is authoritative). The command surface below largely dates to the v0.12.0 bump, -which added 20 exported Go methods over 13 new backend operations; the extra -seven wrapped endpoints that already existed but were reachable only through -the raw generated client, which the andon-cord rule forbids the CLI from -calling. v0.13.0–v0.15.0 corrected shapes and routes (pointerized optional -fields, page-selection semantics, field-keyed 422 payloads) rather than opening -new sections; their additions here are `files replace` and the un-held -`files versions`, both from v0.14.0's upload work (basecamp/basecamp-sdk#683). +**SDK version:** v0.16.0 (adds the to-do list template library and asynchronous +copy operations; `internal/version/sdk-provenance.json` is authoritative). The +command surface below largely dates to the v0.12.0 bump, which added 20 exported +Go methods over 13 new backend operations; the extra seven wrapped endpoints +that already existed but were reachable only through the raw generated client, +which the andon-cord rule forbids the CLI from calling. v0.13.0–v0.15.0 +corrected shapes and routes (pointerized optional fields, page-selection +semantics, field-keyed 422 payloads) and added `files replace`, `files versions`, +and Bubble Up writes. v0.16.0 adds the three template-library operations. Those methods land as four new command groups (`bookmarks`, `drafts`, `notes`, `calendars`) and three extensions (`assignments` gains the Up Next verbs, @@ -228,7 +229,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | **Webhooks** | | webhooks | 7 | `webhooks` | ✅ | BC4 | - | list, show, create, update, delete | | **Templates** | -| templates | 7 | `templates` | ✅ | BC4 | - | list, show, create, update, delete, construct, construction | +| templates | 10 | `templates` | ✅ | BC4 | - | list, show, create, update, delete, construct, construction, library, copy, copy-status | | **Time Tracking** | | timesheets | 6 | `timesheet` | ✅ | BC4 | - | list, show, create, update, delete | | **Subscriptions** | diff --git a/e2e/smoke/smoke_account.bats b/e2e/smoke/smoke_account.bats index 245469650..f5168b405 100644 --- a/e2e/smoke/smoke_account.bats +++ b/e2e/smoke/smoke_account.bats @@ -21,6 +21,13 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "templates library returns to-do list templates" { + run_smoke basecamp templates library --json + [[ "$status" -ne 0 ]] && mark_unverifiable "Template library not available in this account" + assert_success + assert_json_value '.ok' 'true' +} + @test "people show returns person detail" { run_smoke basecamp people show me --json assert_success diff --git a/e2e/smoke/smoke_bookmarks.bats b/e2e/smoke/smoke_bookmarks.bats index 54b164ad5..bce08f2c4 100644 --- a/e2e/smoke/smoke_bookmarks.bats +++ b/e2e/smoke/smoke_bookmarks.bats @@ -49,3 +49,11 @@ setup_file() { @test "bookmarks remove is out of scope" { mark_out_of_scope "Mutating - exercised by the live add/check/remove round-trip" } + +@test "bubble-up add is out of scope" { + mark_out_of_scope "Mutates the current user's personal readings" +} + +@test "bubble-up remove is out of scope" { + mark_out_of_scope "Mutates the current user's personal readings" +} diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index 5eef8b23a..c0878bb67 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -97,6 +97,14 @@ load smoke_helper mark_out_of_scope "Depends on templates construct (OOS)" } +@test "templates copy is out of scope" { + mark_out_of_scope "Copies a to-do list into a project and may grant project access" +} + +@test "templates copy-status is out of scope" { + mark_out_of_scope "Depends on templates copy (OOS)" +} + @test "templates create is out of scope" { mark_out_of_scope "Account-wide template mutation" } diff --git a/e2e/templates.bats b/e2e/templates.bats index fc57f40fe..92ad572e4 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -131,6 +131,33 @@ load test_helper } +# Template library copy errors + +@test "templates copy without template id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates copy + assert_failure + assert_output_contains "ID required" +} + +@test "templates copy-status without copy id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates copy-status + assert_failure + assert_output_contains "ID required" +} + +@test "templates copy --confirm-adding-people is accepted" { + run basecamp templates copy --help + assert_success + assert_output_contains "--confirm-adding-people" +} + + # Flag parsing @test "templates list --status without value shows error" { @@ -163,6 +190,8 @@ load test_helper assert_output_contains "basecamp templates" assert_output_contains "construct" assert_output_contains "construction" + assert_output_contains "library" + assert_output_contains "copy-status" } diff --git a/go.mod b/go.mod index 66ffb47b5..26de65292 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.9 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2 + github.com/basecamp/basecamp-sdk/go v0.16.0 github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index ded8979f5..4797e2c86 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2 h1:bIaTwvIoWTNirPigLsXsC912abkIYDTFHuOrkSyB96U= -github.com/basecamp/basecamp-sdk/go v0.15.1-0.20260902205921-f439f493caf2/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= +github.com/basecamp/basecamp-sdk/go v0.16.0 h1:6MfdBf+tgDtfzSHLDhJO9yqsCExNvtWUlK6gzTKzMvM= +github.com/basecamp/basecamp-sdk/go v0.16.0/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 244a119f5..6841afd37 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -89,7 +89,7 @@ func CommandCategories() []CommandCategory { Name: "Organization", Commands: []CommandInfo{ {Name: "people", Category: "organization", Description: "Manage people and access", Actions: []string{"list", "show", "update", "out-of-office", "pingable", "add", "remove"}}, - {Name: "templates", Category: "organization", Description: "Manage project templates", Actions: []string{"list", "show", "create", "update", "delete", "construct"}}, + {Name: "templates", Category: "organization", Description: "Manage project and to-do list templates", Actions: []string{"list", "show", "create", "update", "delete", "construct", "construction", "library", "copy", "copy-status"}}, {Name: "webhooks", Category: "organization", Description: "Manage webhooks", Actions: []string{"list", "show", "create", "update", "delete"}}, {Name: "lineup", Category: "organization", Description: "Manage lineup markers", Actions: []string{"list", "create", "update", "delete"}}, }, diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 80ddcce58..b10e77326 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -1,8 +1,10 @@ package commands import ( + "errors" "fmt" "strconv" + "strings" "time" "github.com/spf13/cobra" @@ -13,16 +15,16 @@ import ( "github.com/basecamp/basecamp-cli/internal/output" ) -// NewTemplatesCmd creates the templates command for managing project templates. +// NewTemplatesCmd creates the templates command for managing project and to-do list templates. func NewTemplatesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "templates", - Short: "Manage project templates", - Long: `Manage project templates. + Short: "Manage project and to-do list templates", + Long: `Manage project templates and the account's to-do list template library. -Templates allow you to create new projects with predefined structure, -tools, and content.`, - Annotations: map[string]string{"agent_notes": "Construction from template is asynchronous — poll construction until status=completed to get the new project ID"}, +Project templates create projects with predefined structure, tools, and content. +Library templates copy a reusable to-do list into an existing project.`, + Annotations: map[string]string{"agent_notes": "Project construction and to-do list template copies are asynchronous. Poll construction or copy-status until status=completed. Copy grants referenced people project access only with --confirm-adding-people."}, } cmd.AddCommand( @@ -33,6 +35,9 @@ tools, and content.`, newTemplatesDeleteCmd(), newTemplatesConstructCmd(), newTemplatesConstructionCmd(), + newTemplatesLibraryCmd(), + newTemplatesCopyCmd(), + newTemplatesCopyStatusCmd(), ) return cmd @@ -132,6 +137,213 @@ func runTemplatesList(cmd *cobra.Command, status string) error { ) } +func newTemplatesLibraryCmd() *cobra.Command { + return &cobra.Command{ + Use: "library", + Short: "List to-do list templates", + Long: "List the account's active to-do list templates.", + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + + library, err := app.Account().Templates().GetLibrary(cmd.Context()) + if err != nil { + return convertSDKError(err) + } + + display := make([]struct { + ID int64 `json:"id"` + Title string `json:"title"` + }, len(library.Todolists)) + for i, todolist := range library.Todolists { + display[i].ID = todolist.ID + display[i].Title = todolist.Title + } + + return app.OK(library, + output.WithDisplayData(display), + output.WithSummary(fmt.Sprintf("%d active to-do list templates", len(library.Todolists))), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "copy", + Cmd: "basecamp templates copy --in ", + Description: "Copy a template into a project", + }, + ), + ) + }, + } +} + +func newTemplatesCopyCmd() *cobra.Command { + var project string + var todoset string + var confirmAddingPeople bool + + cmd := &cobra.Command{ + Use: "copy ", + Short: "Copy a to-do list template into a project", + Long: `Start copying a to-do list template into a project's To-dos tool. + +The copy runs asynchronously. Use 'templates copy-status' with the returned +copy ID to check its progress. Referenced people receive project access only +when --confirm-adding-people is explicitly provided.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + templateID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid template ID") + } + if err := requireNumericID(todoset, "todoset ID"); err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + resolvedProjectID, err := resolveProjectID(cmd, app, project) + if err != nil { + return err + } + resolvedTodosetID, err := ensureTodoset(cmd, app, resolvedProjectID, todoset) + if err != nil { + return err + } + destinationParentID, err := strconv.ParseInt(resolvedTodosetID, 10, 64) + if err != nil { + return output.ErrUsage("Invalid todoset ID") + } + + templateCopy, err := app.Account().Templates().CreateLibraryCopy(cmd.Context(), &basecamp.CreateTemplateLibraryCopyRequest{ + TemplateRecordingID: templateID, + DestinationParentID: destinationParentID, + AddingPeopleConfirmed: confirmAddingPeople, + }) + if err != nil { + return templateCopyError(err, templateID, resolvedProjectID, todoset) + } + + return app.OK(templateCopy, + output.WithSummary(fmt.Sprintf("Started template copy #%d (%s)", templateCopy.ID, templateCopy.Status)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "status", + Cmd: fmt.Sprintf("basecamp templates copy-status %d", templateCopy.ID), + Description: "Check copy status", + }, + ), + ) + }, + } + + cmd.Flags().StringVarP(&project, "project", "p", "", "Project ID or name") + cmd.Flags().StringVar(&project, "in", "", "Project ID or name (alias for --project)") + cmd.Flags().StringVar(&todoset, "todoset", "", "To-dos tool ID (auto-detected from project)") + cmd.Flags().BoolVar(&confirmAddingPeople, "confirm-adding-people", false, "Grant referenced people access to the destination project") + + return cmd +} + +func templateCopyError(err error, templateID int64, projectID, todosetID string) error { + var confirmationErr *basecamp.PeopleConfirmationRequiredError + if !errors.As(err, &confirmationErr) { + return convertSDKError(err) + } + + people := make([]string, 0, len(confirmationErr.People)) + for _, person := range confirmationErr.People { + people = append(people, fmt.Sprintf("%s (#%d)", person.Name, person.ID)) + } + + rerun := fmt.Sprintf("basecamp templates copy %d --in %s", templateID, projectID) + if todosetID != "" { + rerun += " --todoset " + todosetID + } + rerun += " --confirm-adding-people" + + converted := output.AsError(err) + return &output.Error{ + Code: converted.Code, + Message: fmt.Sprintf("Adding referenced people requires confirmation: %s", strings.Join(people, ", ")), + Hint: "Review the people above, then rerun with explicit confirmation: " + rerun, + HTTPStatus: converted.HTTPStatus, + Retryable: converted.Retryable, + Cause: err, + } +} + +func newTemplatesCopyStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "copy-status ", + Short: "Check a to-do list template copy", + Long: "Check whether a to-do list template copy is pending, processing, completed, or failed.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + copyID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid copy ID") + } + + app := appctx.FromContext(cmd.Context()) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + templateCopy, err := app.Account().Templates().GetLibraryCopy(cmd.Context(), copyID) + if err != nil { + return convertSDKError(err) + } + + summary, breadcrumbs := templateCopyStatusOutput(templateCopy) + return app.OK(templateCopy, + output.WithSummary(summary), + output.WithBreadcrumbs(breadcrumbs...), + ) + }, + } +} + +func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy) (string, []output.Breadcrumb) { + switch templateCopy.Status { + case "completed": + if templateCopy.DestinationTodolist != nil { + list := templateCopy.DestinationTodolist + breadcrumbs := []output.Breadcrumb{ + { + Action: "show", + Cmd: fmt.Sprintf("basecamp todolists show %d --in %d", list.ID, list.Bucket.ID), + Description: "View copied to-do list", + }, + } + return fmt.Sprintf("Template copy complete: %s (to-do list #%d)", list.Name, list.ID), breadcrumbs + } + return fmt.Sprintf("Template copy #%d completed", templateCopy.ID), nil + case "failed": + return fmt.Sprintf("Template copy #%d failed", templateCopy.ID), []output.Breadcrumb{ + { + Action: "library", + Cmd: "basecamp templates library", + Description: "List available templates", + }, + } + case "pending", "processing": + return fmt.Sprintf("Template copy #%d is %s", templateCopy.ID, templateCopy.Status), []output.Breadcrumb{ + { + Action: "poll", + Cmd: fmt.Sprintf("basecamp templates copy-status %d", templateCopy.ID), + Description: "Check again", + }, + } + default: + return fmt.Sprintf("Template copy #%d status: %s", templateCopy.ID, templateCopy.Status), nil + } +} + func newTemplatesShowCmd() *cobra.Command { return &cobra.Command{ Use: "show ", diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go new file mode 100644 index 000000000..b94398196 --- /dev/null +++ b/internal/commands/templates_test.go @@ -0,0 +1,212 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "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/output" +) + +const templateLibraryJSON = `{ + "bucket":{"id":1,"name":"To-do List Templates","type":"TemplateLibrary"}, + "todoset":{"id":2,"title":"To-do List Templates","type":"Todoset","url":"https://example.test/todoset.json","app_url":"https://example.test/todoset"}, + "todolists":[{"id":3,"status":"active","visible_to_clients":false,"created_at":"2026-08-27T12:00:00Z","updated_at":"2026-08-27T12:00:00Z","title":"Project kickoff","inherits_status":true,"type":"Todolist","url":"https://example.test/list.json","app_url":"https://example.test/list","bubble_up_url":"https://example.test/bubble.json","parent":{"id":2,"title":"To-do List Templates","type":"Todoset","url":"https://example.test/todoset.json","app_url":"https://example.test/todoset"},"bucket":{"id":1,"name":"To-do List Templates","type":"TemplateLibrary"},"creator":{"id":4,"name":"Victor"},"description":"","description_attachments":[],"name":"Project kickoff","color":null,"comments_app_url":"https://example.test/comments"}] +}` + +const templateCopyPendingJSON = `{"id":5,"status":"pending","source_recording_id":3,"destination_parent_id":9,"url":"https://example.test/copies/5.json"}` + +func templateCopyProjectRoute() stubRoute { + return stubRoute{ + method: http.MethodGet, + path: "/99999/projects/123.json", + status: http.StatusOK, + body: `{"id":123,"name":"Test Project","dock":[{"name":"todoset","id":9,"title":"To-dos","enabled":true}]}`, + } +} + +func captureTemplateOutput(app *appctx.App) *bytes.Buffer { + buf := &bytes.Buffer{} + app.Flags.Hints = true + app.Output = output.New(output.Options{Format: output.FormatJSON, Writer: buf}) + return buf +} + +func decodeTemplateEnvelope(t *testing.T, buf *bytes.Buffer) struct { + Summary string `json:"summary"` + Breadcrumbs []output.Breadcrumb `json:"breadcrumbs"` +} { + t.Helper() + var envelope struct { + Summary string `json:"summary"` + Breadcrumbs []output.Breadcrumb `json:"breadcrumbs"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + return envelope +} + +func TestTemplatesLibraryUsesSDKLibraryEndpoint(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/template_library.json", + status: http.StatusOK, + body: templateLibraryJSON, + }) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "library") + require.NoError(t, err) + + request := transport.last(t) + assert.Equal(t, http.MethodGet, request.Method) + assert.Equal(t, "/99999/template_library.json", request.Path) + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "1 active to-do list templates", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp templates copy --in ", envelope.Breadcrumbs[0].Cmd) +} + +func TestTemplatesLibraryHumanOutputShowsCopyableID(t *testing.T) { + app, _ := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/template_library.json", + status: http.StatusOK, + body: templateLibraryJSON, + }) + buf := &bytes.Buffer{} + app.Output = output.New(output.Options{Format: output.FormatStyled, Writer: buf}) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "library") + require.NoError(t, err) + + assert.Contains(t, buf.String(), "Project kickoff") + assert.Contains(t, buf.String(), "3") +} + +func TestTemplatesCopyResolvesProjectAndTodosTool(t *testing.T) { + app, transport := setupRecordingTestApp(t, + projectsRoute(), + templateCopyProjectRoute(), + stubRoute{ + method: http.MethodPost, + path: "/99999/template_library/copies.json", + status: http.StatusCreated, + body: templateCopyPendingJSON, + }, + ) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "copy", "3", "--in", "Test Project") + require.NoError(t, err) + + requests := transport.recorded() + require.Len(t, requests, 3) + assert.Equal(t, "/99999/projects.json", requests[0].Path) + assert.Equal(t, "/99999/projects/123.json", requests[1].Path) + assert.Equal(t, http.MethodPost, requests[2].Method) + assert.Equal(t, "/99999/template_library/copies.json", requests[2].Path) + assert.JSONEq(t, `{"template_recording_id":3,"destination_parent_id":9}`, requests[2].Body) + + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Started template copy #5 (pending)", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp templates copy-status 5", envelope.Breadcrumbs[0].Cmd) +} + +func TestTemplatesCopySendsExplicitPeopleConfirmation(t *testing.T) { + app, transport := setupRecordingTestApp(t, + projectsRoute(), + templateCopyProjectRoute(), + stubRoute{ + method: http.MethodPost, + path: "/99999/template_library/copies.json", + status: http.StatusCreated, + body: templateCopyPendingJSON, + }, + ) + + err := executeRecordingCommand(NewTemplatesCmd(), app, + "copy", "3", "--project", "123", "--confirm-adding-people") + require.NoError(t, err) + + assert.JSONEq(t, + `{"template_recording_id":3,"destination_parent_id":9,"adding_people_confirmed":true}`, + transport.last(t).Body, + ) +} + +func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { + app, _ := setupRecordingTestApp(t, + projectsRoute(), + templateCopyProjectRoute(), + stubRoute{ + method: http.MethodPost, + path: "/99999/template_library/copies.json", + status: http.StatusUnprocessableEntity, + body: `{"error":"Adding people requires confirmation","people":[{"id":4,"name":"Victor","avatar_url":"https://example.test/avatar.png"},{"id":7,"name":"Georgia","avatar_url":"https://example.test/georgia.png"}]}`, + }, + ) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "copy", "3", "--in", "123") + require.Error(t, err) + + var outputErr *output.Error + require.True(t, errors.As(err, &outputErr), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, output.CodeValidation, outputErr.Code) + assert.Contains(t, outputErr.Message, "Victor (#4)") + assert.Contains(t, outputErr.Message, "Georgia (#7)") + assert.Contains(t, outputErr.Hint, "basecamp templates copy 3 --in 123 --confirm-adding-people") + + var confirmationErr *basecamp.PeopleConfirmationRequiredError + assert.True(t, errors.As(err, &confirmationErr), "typed SDK error should remain available") +} + +func TestTemplatesCopyStatusStates(t *testing.T) { + completed := `{ + "id":5,"status":"completed","source_recording_id":3,"destination_parent_id":9,"url":"https://example.test/copies/5.json", + "destination_todolist":{"id":10,"status":"active","visible_to_clients":false,"created_at":"2026-08-27T12:00:00Z","updated_at":"2026-08-27T12:00:00Z","title":"Project kickoff","inherits_status":true,"type":"Todolist","url":"https://example.test/list.json","app_url":"https://example.test/list","bubble_up_url":"https://example.test/bubble.json","parent":{"id":9,"title":"To-dos","type":"Todoset","url":"https://example.test/todoset.json","app_url":"https://example.test/todoset"},"bucket":{"id":123,"name":"Test Project","type":"Project"},"creator":{"id":4,"name":"Victor"},"description":"","description_attachments":[],"name":"Project kickoff","color":null,"comments_app_url":"https://example.test/comments"} + }` + + tests := []struct { + name string + body string + summary string + breadcrumb string + }{ + {name: "pending", body: templateCopyPendingJSON, summary: "Template copy #5 is pending", breadcrumb: "basecamp templates copy-status 5"}, + {name: "processing", body: `{"id":5,"status":"processing","source_recording_id":3,"destination_parent_id":9,"url":"https://example.test/copies/5.json"}`, summary: "Template copy #5 is processing", breadcrumb: "basecamp templates copy-status 5"}, + {name: "completed", body: completed, summary: "Template copy complete: Project kickoff (to-do list #10)", breadcrumb: "basecamp todolists show 10 --in 123"}, + {name: "failed", body: `{"id":5,"status":"failed","source_recording_id":3,"destination_parent_id":9,"url":"https://example.test/copies/5.json"}`, summary: "Template copy #5 failed", breadcrumb: "basecamp templates library"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/template_library/copies/5", + status: http.StatusOK, + body: test.body, + }) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "copy-status", "5") + require.NoError(t, err) + + request := transport.last(t) + assert.Equal(t, http.MethodGet, request.Method) + assert.Equal(t, "/99999/template_library/copies/5", request.Path) + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, test.summary, envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, test.breadcrumb, envelope.Breadcrumbs[0].Cmd) + }) + } +} diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index b2cf4be9a..4f86f4df3 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -61,7 +61,7 @@ func TestCatalogExcludesBinaryUploads(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 253, total, "served operation count") + assert.Equal(t, 256, total, "served operation count") } // TestCatalogIsAccountScoped pins the rescope: the CLI's account-scoped SDK @@ -145,6 +145,6 @@ func TestCatalogModelProvenance(t *testing.T) { require.NoError(t, err) match := regexp.MustCompile(`github\.com/basecamp/basecamp-sdk/go (v\S+)`).FindSubmatch(gomod) require.NotNil(t, match, "basecamp-sdk dependency not found in go.mod") - assert.Equal(t, "go/"+string(match[1]), provenance.Ref, + assert.Equal(t, string(match[1]), strings.TrimPrefix(provenance.Ref, "go/"), "vendored model must match the basecamp-sdk version go.mod pins — run scripts/sync-mcp-model.sh against that checkout") } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index c6ef1f8f3..91654c07d 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "f439f493caf24ec026d7efe4649eed87b82a30a8", - "ref": "go/v0.15.1-0.20260902205921-f439f493caf2", + "commit": "1bb0ae61d62abdc7aff5f7302148d39d69030066", + "ref": "v0.16.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "tags assigned to operations the export leaves untagged (PATCHED_TAGS); binary-upload operations dropped (EXCLUDED_OPERATIONS) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index f555b3972..2783db9f6 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -305,6 +305,17 @@ ] } }, + "CreateTemplateLibraryCopy": { + "retry": { + "max": 2, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateTimesheetEntry": { "retry": { "max": 3, @@ -1640,6 +1651,30 @@ ] } }, + "GetTemplateLibrary": { + "readonly": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, + "GetTemplateLibraryCopy": { + "readonly": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "GetTimesheetEntry": { "readonly": true, "retry": { @@ -3300,6 +3335,10 @@ "PersonCompany": [ "$.name" ], + "TemplateLibraryConfirmationPerson": [ + "$.name", + "$.avatar_url" + ], "UpcomingSchedulePerson": [ "$.name", "$.avatar_url" diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 3509d438d..51bffd709 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-08-31", + "version": "2026-09-02", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -21761,6 +21761,304 @@ } } }, + "/{accountId}/template_library.json": { + "get": { + "description": "Get the account's to-do list template library", + "operationId": "GetTemplateLibrary", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetTemplateLibrary 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTemplateLibraryResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Automation" + ], + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, + "/{accountId}/template_library/copies.json": { + "post": { + "description": "Start copying a to-do list template into a project", + "operationId": "CreateTemplateLibraryCopy", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateLibraryCopyRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "CreateTemplateLibraryCopy 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateLibraryCopyResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "PeopleConfirmationRequiredError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PeopleConfirmationRequiredErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Automation" + ], + "x-basecamp-retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, + "/{accountId}/template_library/copies/{copyId}": { + "get": { + "description": "Get the current status of a to-do list template copy", + "operationId": "GetTemplateLibraryCopy", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "copyId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetTemplateLibraryCopy 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTemplateLibraryCopyResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Automation" + ], + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/templates.json": { "get": { "description": "List all templates visible to the current user\n\n**Pagination**: Uses Link header (RFC5988). Follow the `next` rel URL\nto fetch additional pages. X-Total-Count header provides total count.", @@ -29203,6 +29501,30 @@ "CreateScheduleEntryResponseContent": { "$ref": "#/components/schemas/ScheduleEntry" }, + "CreateTemplateLibraryCopyRequestContent": { + "type": "object", + "properties": { + "template_recording_id": { + "type": "integer", + "format": "int64" + }, + "destination_parent_id": { + "type": "integer", + "format": "int64" + }, + "adding_people_confirmed": { + "type": "boolean", + "description": "Confirm granting destination-project access to people referenced by the template." + } + }, + "required": [ + "destination_parent_id", + "template_recording_id" + ] + }, + "CreateTemplateLibraryCopyResponseContent": { + "$ref": "#/components/schemas/TemplateLibraryCopy" + }, "CreateTemplateRequestContent": { "type": "object", "properties": { @@ -31088,6 +31410,12 @@ "GetSubscriptionResponseContent": { "$ref": "#/components/schemas/Subscription" }, + "GetTemplateLibraryCopyResponseContent": { + "$ref": "#/components/schemas/TemplateLibraryCopy" + }, + "GetTemplateLibraryResponseContent": { + "$ref": "#/components/schemas/TemplateLibrary" + }, "GetTemplateResponseContent": { "$ref": "#/components/schemas/Template" }, @@ -32394,6 +32722,25 @@ } } }, + "PeopleConfirmationRequiredErrorResponseContent": { + "type": "object", + "description": "The copy requires confirmation before granting destination-project access\nto people referenced by the template.", + "properties": { + "error": { + "type": "string" + }, + "people": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TemplateLibraryConfirmationPerson" + } + } + }, + "required": [ + "error", + "people" + ] + }, "Person": { "type": "object", "properties": { @@ -34438,6 +34785,92 @@ "updated_at" ] }, + "TemplateLibrary": { + "type": "object", + "properties": { + "bucket": { + "$ref": "#/components/schemas/RecordingBucket" + }, + "todoset": { + "$ref": "#/components/schemas/RecordingParent" + }, + "todolists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todolist" + } + } + }, + "required": [ + "bucket", + "todolists", + "todoset" + ] + }, + "TemplateLibraryConfirmationPerson": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "format": "password", + "x-basecamp-sensitive": { + "category": "pii", + "redact": true + } + }, + "avatar_url": { + "type": "string", + "format": "password", + "x-basecamp-sensitive": { + "category": "pii", + "redact": true + } + } + }, + "required": [ + "avatar_url", + "id", + "name" + ] + }, + "TemplateLibraryCopy": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string", + "description": "pending|processing|completed|failed" + }, + "source_recording_id": { + "type": "integer", + "format": "int64" + }, + "destination_parent_id": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string" + }, + "destination_todolist": { + "$ref": "#/components/schemas/Todolist" + } + }, + "required": [ + "destination_parent_id", + "id", + "source_recording_id", + "status", + "url" + ] + }, "TimelineAttachment": { "type": "object", "description": "A single timeline-event attachment. This is an optional-field superset over\ntwo wire variants \u2014 a full Upload recording (upload-kind recordings, rendered\nby BC3's uploads/_upload partial: the complete recording projection + rich-\ntext description + the upload body) and a rich-text attachment/blob partial\n(all other recordings) \u2014 so one element type decodes either. Every field is\noptional; a given instance populates only the fields of the variant it\nrepresents. The upload-recording variant enumerates the full documented\nprojection so no documented field is silently dropped on decode.", diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index 0eedc297e..fb438aefc 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -226,6 +226,7 @@ ACTIONS (RO = read-only): - create_project_from_template: Create a project from a template (asynchronous) - create_question: Create a new question in a questionnaire - create_template: Create a new template +- create_template_library_copy: Start copying a to-do list template into a project - create_tool: Create a tool in a project dock - create_webhook: Create a new webhook for a project - delete_lineup_marker: Delete a lineup marker @@ -242,6 +243,8 @@ ACTIONS (RO = read-only): - get_questionnaire (RO): Get a questionnaire (automatic check-ins container) by id - get_search_metadata (RO): Get search metadata (available filter options) - get_template (RO): Get a single template by id +- get_template_library (RO): Get the account's to-do list template library +- get_template_library_copy (RO): Get the current status of a to-do list template copy - get_tool (RO): Get a dock tool by id - get_webhook (RO): Get a single webhook by id - list_answers (RO, paginated): List all answers for a question diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 589d7f387..2907ff32e 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,13 +1,13 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.15.1-0.20260902205921-f439f493caf2", - "revision": "f439f493caf2", - "updated_at": "2026-09-02T20:59:21Z" + "version": "v0.16.0", + "revision": "1bb0ae61d62a", + "updated_at": "2026-09-03T18:18:42Z" }, "api": { "repo": "basecamp/bc3", - "revision": "88549ca619eafc43e88639d0533f0d02e442edec", - "synced_at": "2026-08-31" + "revision": "c680233ba0e7784f08e86405a7319d7ece9cb267", + "synced_at": "2026-09-02" } } diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index fe5b7ed7e..e125459d5 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -81,7 +81,7 @@ argument-hint: "[action] [args...]" # /basecamp - Basecamp Workflow Command -Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, subscriptions, lineup, chat, pings, gauges, assignments, notifications, and accounts. +Full CLI coverage: 189 tracked in-scope endpoints across todos, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, subscriptions, lineup, chat, pings, gauges, assignments, notifications, and accounts. ## Agent Invariants @@ -962,16 +962,26 @@ basecamp recordings visibility --hidden # Hide from clients ### Templates ```bash -basecamp templates --json # List templates -basecamp templates show --json # Template details -basecamp templates create "Template Name" # Create empty template +basecamp templates list --json # List project templates +basecamp templates show --json # Project template details +basecamp templates create "Template Name" # Create empty project template basecamp templates update --name "New Name" -basecamp templates delete # Trash template +basecamp templates delete # Trash project template basecamp templates construct --name "New Project" # Create project (async) -basecamp templates construction # Check status +basecamp templates construction # Check project status + +basecamp templates library --json # List active to-do list templates +basecamp templates copy --in # Start copying into To-dos +basecamp templates copy-status # Check copy status ``` -**Construct returns construction_id - poll until status="completed" to get project.** +**Asynchronous results:** `construct` returns a construction ID; poll `construction` +until `status="completed"` to get the project. `copy` returns a copy ID; poll +`copy-status` through `pending` and `processing` until it is `completed` or `failed`. + +A copy can report the people who need access to the destination project. Show those +people to the user and rerun with `--confirm-adding-people` only after the user +explicitly approves granting that access. Never add this flag automatically. ### Webhooks From 5c3a51b96230b6052a10972eb3bd19e8ebe9983c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 3 Sep 2026 16:15:47 -0400 Subject: [PATCH 2/4] Fix dependency build and security checks --- go.mod | 2 +- go.sum | 4 ++-- nix/package.nix | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 26de65292..d201a85a7 100644 --- a/go.mod +++ b/go.mod @@ -134,7 +134,7 @@ require ( go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect + golang.org/x/crypto v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/term v0.45.0 // indirect diff --git a/go.sum b/go.sum index 4797e2c86..335342f65 100644 --- a/go.sum +++ b/go.sum @@ -474,8 +474,8 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= diff --git a/nix/package.nix b/nix/package.nix index 9b811eb51..ff91f2a73 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-OFz67aZDTfQZMS+4MmZM960KquS9IoXXH3WN6cn778Y="; + vendorHash = "sha256-zNTp8pw3ZViwSmpsxQ78MU5lLgn0P2nb9aAxqp/96Eg="; subPackages = [ "cmd/basecamp" ]; From 698ca19528cb1c0ecea4edcb13795dde7728f1a6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 3 Sep 2026 16:26:00 -0400 Subject: [PATCH 3/4] Keep template copy confirmation scoped --- internal/commands/templates.go | 50 ++++++++++++++++++++--- internal/commands/templates_test.go | 61 +++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/internal/commands/templates.go b/internal/commands/templates.go index b10e77326..a1fd99dbb 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -202,6 +202,7 @@ when --confirm-adding-people is explicitly provided.`, } app := appctx.FromContext(cmd.Context()) + persistentAccount := hasPersistentAccount(app.Config) if err := ensureAccount(cmd, app); err != nil { return err } @@ -210,6 +211,11 @@ when --confirm-adding-people is explicitly provided.`, if err != nil { return err } + if todoset != "" { + if err := validateTemplateCopyTodoset(cmd, app, todoset, resolvedProjectID); err != nil { + return err + } + } resolvedTodosetID, err := ensureTodoset(cmd, app, resolvedProjectID, todoset) if err != nil { return err @@ -225,7 +231,14 @@ when --confirm-adding-people is explicitly provided.`, AddingPeopleConfirmed: confirmAddingPeople, }) if err != nil { - return templateCopyError(err, templateID, resolvedProjectID, todoset) + return templateCopyError( + err, + templateID, + resolvedProjectID, + resolvedTodosetID, + app.Config.ActiveProfile, + replyAccountArg(persistentAccount, app.Config.AccountID), + ) } return app.OK(templateCopy, @@ -249,7 +262,33 @@ when --confirm-adding-people is explicitly provided.`, return cmd } -func templateCopyError(err error, templateID int64, projectID, todosetID string) error { +func validateTemplateCopyTodoset(cmd *cobra.Command, app *appctx.App, todosetID, projectID string) error { + if err := validateTodosetOwnership(cmd, app, todosetID, projectID); err != nil { + return err + } + + enabled, all, err := getDockTools(cmd.Context(), app, projectID, "todoset") + if err != nil { + return err + } + todosetNum, err := strconv.ParseInt(todosetID, 10, 64) + if err != nil { + return output.ErrUsage("Invalid todoset ID") + } + for _, tool := range enabled { + if tool.ID == todosetNum { + return nil + } + } + for _, tool := range all { + if tool.ID == todosetNum { + return output.ErrUsage(fmt.Sprintf("--todoset %s is disabled for project %s", todosetID, projectID)) + } + } + return output.ErrUsage(fmt.Sprintf("--todoset %s is not a To-dos tool in project %s", todosetID, projectID)) +} + +func templateCopyError(err error, templateID int64, projectID, todosetID, profile, accountArg string) error { var confirmationErr *basecamp.PeopleConfirmationRequiredError if !errors.As(err, &confirmationErr) { return convertSDKError(err) @@ -260,10 +299,11 @@ func templateCopyError(err error, templateID int64, projectID, todosetID string) people = append(people, fmt.Sprintf("%s (#%d)", person.Name, person.ID)) } - rerun := fmt.Sprintf("basecamp templates copy %d --in %s", templateID, projectID) - if todosetID != "" { - rerun += " --todoset " + todosetID + rerun := fmt.Sprintf("basecamp templates copy %d --in %s --todoset %s", templateID, projectID, todosetID) + if profile != "" { + rerun += " --profile " + profile } + rerun += accountArg rerun += " --confirm-adding-people" converted := output.AsError(err) diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index b94398196..558d00cab 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "net/http" "testing" @@ -13,6 +14,7 @@ import ( "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/config" "github.com/basecamp/basecamp-cli/internal/output" ) @@ -33,6 +35,15 @@ func templateCopyProjectRoute() stubRoute { } } +func templateCopyTodosetRoute(bucketID int64) stubRoute { + return stubRoute{ + method: http.MethodGet, + path: "/99999/todosets/9", + status: http.StatusOK, + body: fmt.Sprintf(`{"id":9,"bucket":{"id":%d,"name":"Project"}}`, bucketID), + } +} + func captureTemplateOutput(app *appctx.App) *bytes.Buffer { buf := &bytes.Buffer{} app.Flags.Hints = true @@ -124,6 +135,7 @@ func TestTemplatesCopyResolvesProjectAndTodosTool(t *testing.T) { func TestTemplatesCopySendsExplicitPeopleConfirmation(t *testing.T) { app, transport := setupRecordingTestApp(t, projectsRoute(), + templateCopyTodosetRoute(123), templateCopyProjectRoute(), stubRoute{ method: http.MethodPost, @@ -134,13 +146,53 @@ func TestTemplatesCopySendsExplicitPeopleConfirmation(t *testing.T) { ) err := executeRecordingCommand(NewTemplatesCmd(), app, - "copy", "3", "--project", "123", "--confirm-adding-people") + "copy", "3", "--project", "123", "--todoset", "9", "--confirm-adding-people") require.NoError(t, err) + requests := transport.recorded() + require.Len(t, requests, 4) + assert.Equal(t, "/99999/projects.json", requests[0].Path) + assert.Equal(t, "/99999/todosets/9", requests[1].Path) + assert.Equal(t, "/99999/projects/123.json", requests[2].Path) assert.JSONEq(t, `{"template_recording_id":3,"destination_parent_id":9,"adding_people_confirmed":true}`, - transport.last(t).Body, + requests[3].Body, + ) +} + +func TestTemplatesCopyRejectsTodosetFromAnotherProject(t *testing.T) { + app, transport := setupRecordingTestApp(t, projectsRoute(), templateCopyTodosetRoute(456)) + + err := executeRecordingCommand(NewTemplatesCmd(), app, + "copy", "3", "--project", "123", "--todoset", "9") + require.Error(t, err) + + var outputErr *output.Error + require.True(t, errors.As(err, &outputErr)) + assert.Contains(t, outputErr.Message, "--todoset 9 belongs to project 456, not 123") + require.Len(t, transport.recorded(), 2, "copy request must not be sent") +} + +func TestTemplatesCopyRejectsDisabledTodoset(t *testing.T) { + app, transport := setupRecordingTestApp(t, + projectsRoute(), + templateCopyTodosetRoute(123), + stubRoute{ + method: http.MethodGet, + path: "/99999/projects/123.json", + status: http.StatusOK, + body: `{"id":123,"name":"Test Project","dock":[{"name":"todoset","id":9,"title":"To-dos","enabled":false}]}`, + }, ) + + err := executeRecordingCommand(NewTemplatesCmd(), app, + "copy", "3", "--project", "123", "--todoset", "9") + require.Error(t, err) + + var outputErr *output.Error + require.True(t, errors.As(err, &outputErr)) + assert.Contains(t, outputErr.Message, "--todoset 9 is disabled for project 123") + require.Len(t, transport.recorded(), 3, "copy request must not be sent") } func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { @@ -155,6 +207,9 @@ func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { }, ) + app.Config.ActiveProfile = "work" + app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} + err := executeRecordingCommand(NewTemplatesCmd(), app, "copy", "3", "--in", "123") require.Error(t, err) @@ -163,7 +218,7 @@ func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { assert.Equal(t, output.CodeValidation, outputErr.Code) assert.Contains(t, outputErr.Message, "Victor (#4)") assert.Contains(t, outputErr.Message, "Georgia (#7)") - assert.Contains(t, outputErr.Hint, "basecamp templates copy 3 --in 123 --confirm-adding-people") + assert.Contains(t, outputErr.Hint, "basecamp templates copy 3 --in 123 --todoset 9 --profile work --account 99999 --confirm-adding-people") var confirmationErr *basecamp.PeopleConfirmationRequiredError assert.True(t, errors.As(err, &confirmationErr), "typed SDK error should remain available") From 2a8783438dc78a78ea1f3243e8a7a311a743dfd5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 3 Sep 2026 16:34:53 -0400 Subject: [PATCH 4/4] Preserve template command context --- internal/commands/templates.go | 49 ++++++++++++++++------------- internal/commands/templates_test.go | 12 ++++--- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/internal/commands/templates.go b/internal/commands/templates.go index a1fd99dbb..4986a39ab 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -206,6 +206,11 @@ when --confirm-adding-people is explicitly provided.`, if err := ensureAccount(cmd, app); err != nil { return err } + contextArgs := templateCommandContextArgs( + app.Config.ActiveProfile, + persistentAccount, + app.Config.AccountID, + ) resolvedProjectID, err := resolveProjectID(cmd, app, project) if err != nil { @@ -231,14 +236,7 @@ when --confirm-adding-people is explicitly provided.`, AddingPeopleConfirmed: confirmAddingPeople, }) if err != nil { - return templateCopyError( - err, - templateID, - resolvedProjectID, - resolvedTodosetID, - app.Config.ActiveProfile, - replyAccountArg(persistentAccount, app.Config.AccountID), - ) + return templateCopyError(err, templateID, resolvedProjectID, resolvedTodosetID, contextArgs) } return app.OK(templateCopy, @@ -246,7 +244,7 @@ when --confirm-adding-people is explicitly provided.`, output.WithBreadcrumbs( output.Breadcrumb{ Action: "status", - Cmd: fmt.Sprintf("basecamp templates copy-status %d", templateCopy.ID), + Cmd: fmt.Sprintf("basecamp templates copy-status %d%s", templateCopy.ID, contextArgs), Description: "Check copy status", }, ), @@ -288,7 +286,15 @@ func validateTemplateCopyTodoset(cmd *cobra.Command, app *appctx.App, todosetID, return output.ErrUsage(fmt.Sprintf("--todoset %s is not a To-dos tool in project %s", todosetID, projectID)) } -func templateCopyError(err error, templateID int64, projectID, todosetID, profile, accountArg string) error { +func templateCommandContextArgs(profile string, persistentAccount bool, accountID string) string { + args := "" + if profile != "" { + args += " --profile " + shellQuote(profile) + } + return args + replyAccountArg(persistentAccount, accountID) +} + +func templateCopyError(err error, templateID int64, projectID, todosetID, contextArgs string) error { var confirmationErr *basecamp.PeopleConfirmationRequiredError if !errors.As(err, &confirmationErr) { return convertSDKError(err) @@ -299,12 +305,7 @@ func templateCopyError(err error, templateID int64, projectID, todosetID, profil people = append(people, fmt.Sprintf("%s (#%d)", person.Name, person.ID)) } - rerun := fmt.Sprintf("basecamp templates copy %d --in %s --todoset %s", templateID, projectID, todosetID) - if profile != "" { - rerun += " --profile " + profile - } - rerun += accountArg - rerun += " --confirm-adding-people" + rerun := fmt.Sprintf("basecamp templates copy %d --in %s --todoset %s%s --confirm-adding-people", templateID, projectID, todosetID, contextArgs) converted := output.AsError(err) return &output.Error{ @@ -330,16 +331,22 @@ func newTemplatesCopyStatusCmd() *cobra.Command { } app := appctx.FromContext(cmd.Context()) + persistentAccount := hasPersistentAccount(app.Config) if err := ensureAccount(cmd, app); err != nil { return err } + contextArgs := templateCommandContextArgs( + app.Config.ActiveProfile, + persistentAccount, + app.Config.AccountID, + ) templateCopy, err := app.Account().Templates().GetLibraryCopy(cmd.Context(), copyID) if err != nil { return convertSDKError(err) } - summary, breadcrumbs := templateCopyStatusOutput(templateCopy) + summary, breadcrumbs := templateCopyStatusOutput(templateCopy, contextArgs) return app.OK(templateCopy, output.WithSummary(summary), output.WithBreadcrumbs(breadcrumbs...), @@ -348,7 +355,7 @@ func newTemplatesCopyStatusCmd() *cobra.Command { } } -func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy) (string, []output.Breadcrumb) { +func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy, contextArgs string) (string, []output.Breadcrumb) { switch templateCopy.Status { case "completed": if templateCopy.DestinationTodolist != nil { @@ -356,7 +363,7 @@ func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy) (strin breadcrumbs := []output.Breadcrumb{ { Action: "show", - Cmd: fmt.Sprintf("basecamp todolists show %d --in %d", list.ID, list.Bucket.ID), + Cmd: fmt.Sprintf("basecamp todolists show %d --in %d%s", list.ID, list.Bucket.ID, contextArgs), Description: "View copied to-do list", }, } @@ -367,7 +374,7 @@ func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy) (strin return fmt.Sprintf("Template copy #%d failed", templateCopy.ID), []output.Breadcrumb{ { Action: "library", - Cmd: "basecamp templates library", + Cmd: "basecamp templates library" + contextArgs, Description: "List available templates", }, } @@ -375,7 +382,7 @@ func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy) (strin return fmt.Sprintf("Template copy #%d is %s", templateCopy.ID, templateCopy.Status), []output.Breadcrumb{ { Action: "poll", - Cmd: fmt.Sprintf("basecamp templates copy-status %d", templateCopy.ID), + Cmd: fmt.Sprintf("basecamp templates copy-status %d%s", templateCopy.ID, contextArgs), Description: "Check again", }, } diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index 558d00cab..c6d3881c3 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -114,6 +114,8 @@ func TestTemplatesCopyResolvesProjectAndTodosTool(t *testing.T) { }, ) buf := captureTemplateOutput(app) + app.Config.ActiveProfile = "work profile" + app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} err := executeRecordingCommand(NewTemplatesCmd(), app, "copy", "3", "--in", "Test Project") require.NoError(t, err) @@ -129,7 +131,7 @@ func TestTemplatesCopyResolvesProjectAndTodosTool(t *testing.T) { envelope := decodeTemplateEnvelope(t, buf) assert.Equal(t, "Started template copy #5 (pending)", envelope.Summary) require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, "basecamp templates copy-status 5", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "basecamp templates copy-status 5 --profile 'work profile' --account 99999", envelope.Breadcrumbs[0].Cmd) } func TestTemplatesCopySendsExplicitPeopleConfirmation(t *testing.T) { @@ -207,7 +209,7 @@ func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { }, ) - app.Config.ActiveProfile = "work" + app.Config.ActiveProfile = "work profile; echo unsafe" app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} err := executeRecordingCommand(NewTemplatesCmd(), app, "copy", "3", "--in", "123") @@ -218,7 +220,7 @@ func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { assert.Equal(t, output.CodeValidation, outputErr.Code) assert.Contains(t, outputErr.Message, "Victor (#4)") assert.Contains(t, outputErr.Message, "Georgia (#7)") - assert.Contains(t, outputErr.Hint, "basecamp templates copy 3 --in 123 --todoset 9 --profile work --account 99999 --confirm-adding-people") + assert.Contains(t, outputErr.Hint, "basecamp templates copy 3 --in 123 --todoset 9 --profile 'work profile; echo unsafe' --account 99999 --confirm-adding-people") var confirmationErr *basecamp.PeopleConfirmationRequiredError assert.True(t, errors.As(err, &confirmationErr), "typed SDK error should remain available") @@ -251,6 +253,8 @@ func TestTemplatesCopyStatusStates(t *testing.T) { body: test.body, }) buf := captureTemplateOutput(app) + app.Config.ActiveProfile = "work profile" + app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} err := executeRecordingCommand(NewTemplatesCmd(), app, "copy-status", "5") require.NoError(t, err) @@ -261,7 +265,7 @@ func TestTemplatesCopyStatusStates(t *testing.T) { envelope := decodeTemplateEnvelope(t, buf) assert.Equal(t, test.summary, envelope.Summary) require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, test.breadcrumb, envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, test.breadcrumb+" --profile 'work profile' --account 99999", envelope.Breadcrumbs[0].Cmd) }) } }