From 8e9ba23a0a56db90de2c66a43f6bfbb964781461 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 11 Sep 2026 18:05:06 -0700 Subject: [PATCH 1/8] Tell people which kind of template each templates verb acts on The templates command flattened two kinds of template into one list of verbs, so a command's name never said whether it acted on a project template or a to-do list template. The only way to find out was to run one and read what came back. Basecamp's own template library keeps the kinds apart, and the CLI now does too. Duplicating follows the product, which calls the operation duplicating wherever it appears. The API resource stays a copy. Every spelling that shipped before still works. Keeping them visible rather than hidden is deliberate: the surface snapshot skips hidden commands, so hiding them would record them as removed while they still work. --- .surface | 348 ++++++++++++++++++++++++++++ e2e/smoke/smoke_account.bats | 26 +++ e2e/smoke/smoke_lifecycle.bats | 36 +++ e2e/templates.bats | 85 +++++++ internal/commands/commands.go | 2 +- internal/commands/templates.go | 279 +++++++++++++++------- internal/commands/templates_test.go | 30 ++- skills/basecamp/SKILL.md | 39 ++-- 8 files changed, 729 insertions(+), 116 deletions(-) diff --git a/.surface b/.surface index d24af0f97..7ae5aac18 100644 --- a/.surface +++ b/.surface @@ -343,7 +343,18 @@ ARG basecamp templates copy 00 ARG basecamp templates copy-status 00 ARG basecamp templates create 00 ARG basecamp templates delete 00 +ARG basecamp templates projects construct 00 +ARG basecamp templates projects construction 00 +ARG basecamp templates projects construction 01 +ARG basecamp templates projects create 00 +ARG basecamp templates projects delete 00 +ARG basecamp templates projects show 00 +ARG basecamp templates projects update 00 ARG basecamp templates show 00 +ARG basecamp templates todolists copy 00 +ARG basecamp templates todolists copy-status 00 +ARG basecamp templates todolists duplicate 00 +ARG basecamp templates todolists duplication 00 ARG basecamp templates update 00 ARG basecamp timeline 00 [me] ARG basecamp timesheet item 00 @@ -1011,7 +1022,21 @@ CMD basecamp templates create CMD basecamp templates delete CMD basecamp templates library CMD basecamp templates list +CMD basecamp templates projects +CMD basecamp templates projects construct +CMD basecamp templates projects construction +CMD basecamp templates projects create +CMD basecamp templates projects delete +CMD basecamp templates projects list +CMD basecamp templates projects show +CMD basecamp templates projects update CMD basecamp templates show +CMD basecamp templates todolists +CMD basecamp templates todolists copy +CMD basecamp templates todolists copy-status +CMD basecamp templates todolists duplicate +CMD basecamp templates todolists duplication +CMD basecamp templates todolists list CMD basecamp templates update CMD basecamp timeline CMD basecamp timesheet @@ -13645,6 +13670,185 @@ FLAG basecamp templates list --status type=string FLAG basecamp templates list --styled type=bool FLAG basecamp templates list --todolist type=string FLAG basecamp templates list --verbose type=count +FLAG basecamp templates projects --account type=string +FLAG basecamp templates projects --agent type=bool +FLAG basecamp templates projects --cache-dir type=string +FLAG basecamp templates projects --count type=bool +FLAG basecamp templates projects --help type=bool +FLAG basecamp templates projects --hints type=bool +FLAG basecamp templates projects --ids-only type=bool +FLAG basecamp templates projects --in type=string +FLAG basecamp templates projects --jq type=string +FLAG basecamp templates projects --json type=bool +FLAG basecamp templates projects --markdown type=bool +FLAG basecamp templates projects --md type=bool +FLAG basecamp templates projects --no-hints type=bool +FLAG basecamp templates projects --no-stats type=bool +FLAG basecamp templates projects --profile type=string +FLAG basecamp templates projects --project type=string +FLAG basecamp templates projects --quiet type=bool +FLAG basecamp templates projects --stats type=bool +FLAG basecamp templates projects --styled type=bool +FLAG basecamp templates projects --todolist type=string +FLAG basecamp templates projects --verbose type=count +FLAG basecamp templates projects construct --account type=string +FLAG basecamp templates projects construct --agent type=bool +FLAG basecamp templates projects construct --cache-dir type=string +FLAG basecamp templates projects construct --count type=bool +FLAG basecamp templates projects construct --desc type=string +FLAG basecamp templates projects construct --description type=string +FLAG basecamp templates projects construct --help type=bool +FLAG basecamp templates projects construct --hints type=bool +FLAG basecamp templates projects construct --ids-only type=bool +FLAG basecamp templates projects construct --in type=string +FLAG basecamp templates projects construct --jq type=string +FLAG basecamp templates projects construct --json type=bool +FLAG basecamp templates projects construct --markdown type=bool +FLAG basecamp templates projects construct --md type=bool +FLAG basecamp templates projects construct --name type=string +FLAG basecamp templates projects construct --no-hints type=bool +FLAG basecamp templates projects construct --no-stats type=bool +FLAG basecamp templates projects construct --profile type=string +FLAG basecamp templates projects construct --project type=string +FLAG basecamp templates projects construct --quiet type=bool +FLAG basecamp templates projects construct --start-date type=string +FLAG basecamp templates projects construct --stats type=bool +FLAG basecamp templates projects construct --styled type=bool +FLAG basecamp templates projects construct --todolist type=string +FLAG basecamp templates projects construct --verbose type=count +FLAG basecamp templates projects construction --account type=string +FLAG basecamp templates projects construction --agent type=bool +FLAG basecamp templates projects construction --cache-dir type=string +FLAG basecamp templates projects construction --count type=bool +FLAG basecamp templates projects construction --help type=bool +FLAG basecamp templates projects construction --hints type=bool +FLAG basecamp templates projects construction --ids-only type=bool +FLAG basecamp templates projects construction --in type=string +FLAG basecamp templates projects construction --jq type=string +FLAG basecamp templates projects construction --json type=bool +FLAG basecamp templates projects construction --markdown type=bool +FLAG basecamp templates projects construction --md type=bool +FLAG basecamp templates projects construction --no-hints type=bool +FLAG basecamp templates projects construction --no-stats type=bool +FLAG basecamp templates projects construction --profile type=string +FLAG basecamp templates projects construction --project type=string +FLAG basecamp templates projects construction --quiet type=bool +FLAG basecamp templates projects construction --stats type=bool +FLAG basecamp templates projects construction --styled type=bool +FLAG basecamp templates projects construction --todolist type=string +FLAG basecamp templates projects construction --verbose type=count +FLAG basecamp templates projects create --account type=string +FLAG basecamp templates projects create --agent type=bool +FLAG basecamp templates projects create --cache-dir type=string +FLAG basecamp templates projects create --count type=bool +FLAG basecamp templates projects create --desc type=string +FLAG basecamp templates projects create --description type=string +FLAG basecamp templates projects create --help type=bool +FLAG basecamp templates projects create --hints type=bool +FLAG basecamp templates projects create --ids-only type=bool +FLAG basecamp templates projects create --in type=string +FLAG basecamp templates projects create --jq type=string +FLAG basecamp templates projects create --json type=bool +FLAG basecamp templates projects create --markdown type=bool +FLAG basecamp templates projects create --md type=bool +FLAG basecamp templates projects create --name type=string +FLAG basecamp templates projects create --no-hints type=bool +FLAG basecamp templates projects create --no-stats type=bool +FLAG basecamp templates projects create --profile type=string +FLAG basecamp templates projects create --project type=string +FLAG basecamp templates projects create --quiet type=bool +FLAG basecamp templates projects create --stats type=bool +FLAG basecamp templates projects create --styled type=bool +FLAG basecamp templates projects create --todolist type=string +FLAG basecamp templates projects create --verbose type=count +FLAG basecamp templates projects delete --account type=string +FLAG basecamp templates projects delete --agent type=bool +FLAG basecamp templates projects delete --cache-dir type=string +FLAG basecamp templates projects delete --count type=bool +FLAG basecamp templates projects delete --help type=bool +FLAG basecamp templates projects delete --hints type=bool +FLAG basecamp templates projects delete --ids-only type=bool +FLAG basecamp templates projects delete --in type=string +FLAG basecamp templates projects delete --jq type=string +FLAG basecamp templates projects delete --json type=bool +FLAG basecamp templates projects delete --markdown type=bool +FLAG basecamp templates projects delete --md type=bool +FLAG basecamp templates projects delete --no-hints type=bool +FLAG basecamp templates projects delete --no-stats type=bool +FLAG basecamp templates projects delete --profile type=string +FLAG basecamp templates projects delete --project type=string +FLAG basecamp templates projects delete --quiet type=bool +FLAG basecamp templates projects delete --stats type=bool +FLAG basecamp templates projects delete --styled type=bool +FLAG basecamp templates projects delete --todolist type=string +FLAG basecamp templates projects delete --verbose type=count +FLAG basecamp templates projects list --account type=string +FLAG basecamp templates projects list --agent type=bool +FLAG basecamp templates projects list --cache-dir type=string +FLAG basecamp templates projects list --count type=bool +FLAG basecamp templates projects list --help type=bool +FLAG basecamp templates projects list --hints type=bool +FLAG basecamp templates projects list --ids-only type=bool +FLAG basecamp templates projects list --in type=string +FLAG basecamp templates projects list --jq type=string +FLAG basecamp templates projects list --json type=bool +FLAG basecamp templates projects list --markdown type=bool +FLAG basecamp templates projects list --md type=bool +FLAG basecamp templates projects list --no-hints type=bool +FLAG basecamp templates projects list --no-stats type=bool +FLAG basecamp templates projects list --profile type=string +FLAG basecamp templates projects list --project type=string +FLAG basecamp templates projects list --quiet type=bool +FLAG basecamp templates projects list --stats type=bool +FLAG basecamp templates projects list --status type=string +FLAG basecamp templates projects list --styled type=bool +FLAG basecamp templates projects list --todolist type=string +FLAG basecamp templates projects list --verbose type=count +FLAG basecamp templates projects show --account type=string +FLAG basecamp templates projects show --agent type=bool +FLAG basecamp templates projects show --cache-dir type=string +FLAG basecamp templates projects show --count type=bool +FLAG basecamp templates projects show --help type=bool +FLAG basecamp templates projects show --hints type=bool +FLAG basecamp templates projects show --ids-only type=bool +FLAG basecamp templates projects show --in type=string +FLAG basecamp templates projects show --jq type=string +FLAG basecamp templates projects show --json type=bool +FLAG basecamp templates projects show --markdown type=bool +FLAG basecamp templates projects show --md type=bool +FLAG basecamp templates projects show --no-hints type=bool +FLAG basecamp templates projects show --no-stats type=bool +FLAG basecamp templates projects show --profile type=string +FLAG basecamp templates projects show --project type=string +FLAG basecamp templates projects show --quiet type=bool +FLAG basecamp templates projects show --stats type=bool +FLAG basecamp templates projects show --styled type=bool +FLAG basecamp templates projects show --todolist type=string +FLAG basecamp templates projects show --verbose type=count +FLAG basecamp templates projects update --account type=string +FLAG basecamp templates projects update --agent type=bool +FLAG basecamp templates projects update --cache-dir type=string +FLAG basecamp templates projects update --count type=bool +FLAG basecamp templates projects update --desc type=string +FLAG basecamp templates projects update --description type=string +FLAG basecamp templates projects update --help type=bool +FLAG basecamp templates projects update --hints type=bool +FLAG basecamp templates projects update --ids-only type=bool +FLAG basecamp templates projects update --in type=string +FLAG basecamp templates projects update --jq type=string +FLAG basecamp templates projects update --json type=bool +FLAG basecamp templates projects update --markdown type=bool +FLAG basecamp templates projects update --md type=bool +FLAG basecamp templates projects update --name type=string +FLAG basecamp templates projects update --no-hints type=bool +FLAG basecamp templates projects update --no-stats type=bool +FLAG basecamp templates projects update --profile type=string +FLAG basecamp templates projects update --project type=string +FLAG basecamp templates projects update --quiet type=bool +FLAG basecamp templates projects update --stats type=bool +FLAG basecamp templates projects update --styled type=bool +FLAG basecamp templates projects update --todolist type=string +FLAG basecamp templates projects update --verbose type=count FLAG basecamp templates show --account type=string FLAG basecamp templates show --agent type=bool FLAG basecamp templates show --cache-dir type=string @@ -13666,6 +13870,136 @@ FLAG basecamp templates show --stats type=bool FLAG basecamp templates show --styled type=bool FLAG basecamp templates show --todolist type=string FLAG basecamp templates show --verbose type=count +FLAG basecamp templates todolists --account type=string +FLAG basecamp templates todolists --agent type=bool +FLAG basecamp templates todolists --cache-dir type=string +FLAG basecamp templates todolists --count type=bool +FLAG basecamp templates todolists --help type=bool +FLAG basecamp templates todolists --hints type=bool +FLAG basecamp templates todolists --ids-only type=bool +FLAG basecamp templates todolists --in type=string +FLAG basecamp templates todolists --jq type=string +FLAG basecamp templates todolists --json type=bool +FLAG basecamp templates todolists --markdown type=bool +FLAG basecamp templates todolists --md type=bool +FLAG basecamp templates todolists --no-hints type=bool +FLAG basecamp templates todolists --no-stats type=bool +FLAG basecamp templates todolists --profile type=string +FLAG basecamp templates todolists --project type=string +FLAG basecamp templates todolists --quiet type=bool +FLAG basecamp templates todolists --stats type=bool +FLAG basecamp templates todolists --styled type=bool +FLAG basecamp templates todolists --todolist type=string +FLAG basecamp templates todolists --verbose type=count +FLAG basecamp templates todolists copy --account type=string +FLAG basecamp templates todolists copy --agent type=bool +FLAG basecamp templates todolists copy --cache-dir type=string +FLAG basecamp templates todolists copy --confirm-adding-people type=bool +FLAG basecamp templates todolists copy --count type=bool +FLAG basecamp templates todolists copy --help type=bool +FLAG basecamp templates todolists copy --hints type=bool +FLAG basecamp templates todolists copy --ids-only type=bool +FLAG basecamp templates todolists copy --in type=string +FLAG basecamp templates todolists copy --jq type=string +FLAG basecamp templates todolists copy --json type=bool +FLAG basecamp templates todolists copy --markdown type=bool +FLAG basecamp templates todolists copy --md type=bool +FLAG basecamp templates todolists copy --no-hints type=bool +FLAG basecamp templates todolists copy --no-stats type=bool +FLAG basecamp templates todolists copy --profile type=string +FLAG basecamp templates todolists copy --project type=string +FLAG basecamp templates todolists copy --quiet type=bool +FLAG basecamp templates todolists copy --stats type=bool +FLAG basecamp templates todolists copy --styled type=bool +FLAG basecamp templates todolists copy --todolist type=string +FLAG basecamp templates todolists copy --todoset type=string +FLAG basecamp templates todolists copy --verbose type=count +FLAG basecamp templates todolists copy-status --account type=string +FLAG basecamp templates todolists copy-status --agent type=bool +FLAG basecamp templates todolists copy-status --cache-dir type=string +FLAG basecamp templates todolists copy-status --count type=bool +FLAG basecamp templates todolists copy-status --help type=bool +FLAG basecamp templates todolists copy-status --hints type=bool +FLAG basecamp templates todolists copy-status --ids-only type=bool +FLAG basecamp templates todolists copy-status --in type=string +FLAG basecamp templates todolists copy-status --jq type=string +FLAG basecamp templates todolists copy-status --json type=bool +FLAG basecamp templates todolists copy-status --markdown type=bool +FLAG basecamp templates todolists copy-status --md type=bool +FLAG basecamp templates todolists copy-status --no-hints type=bool +FLAG basecamp templates todolists copy-status --no-stats type=bool +FLAG basecamp templates todolists copy-status --profile type=string +FLAG basecamp templates todolists copy-status --project type=string +FLAG basecamp templates todolists copy-status --quiet type=bool +FLAG basecamp templates todolists copy-status --stats type=bool +FLAG basecamp templates todolists copy-status --styled type=bool +FLAG basecamp templates todolists copy-status --todolist type=string +FLAG basecamp templates todolists copy-status --verbose type=count +FLAG basecamp templates todolists duplicate --account type=string +FLAG basecamp templates todolists duplicate --agent type=bool +FLAG basecamp templates todolists duplicate --cache-dir type=string +FLAG basecamp templates todolists duplicate --confirm-adding-people type=bool +FLAG basecamp templates todolists duplicate --count type=bool +FLAG basecamp templates todolists duplicate --help type=bool +FLAG basecamp templates todolists duplicate --hints type=bool +FLAG basecamp templates todolists duplicate --ids-only type=bool +FLAG basecamp templates todolists duplicate --in type=string +FLAG basecamp templates todolists duplicate --jq type=string +FLAG basecamp templates todolists duplicate --json type=bool +FLAG basecamp templates todolists duplicate --markdown type=bool +FLAG basecamp templates todolists duplicate --md type=bool +FLAG basecamp templates todolists duplicate --no-hints type=bool +FLAG basecamp templates todolists duplicate --no-stats type=bool +FLAG basecamp templates todolists duplicate --profile type=string +FLAG basecamp templates todolists duplicate --project type=string +FLAG basecamp templates todolists duplicate --quiet type=bool +FLAG basecamp templates todolists duplicate --stats type=bool +FLAG basecamp templates todolists duplicate --styled type=bool +FLAG basecamp templates todolists duplicate --todolist type=string +FLAG basecamp templates todolists duplicate --todoset type=string +FLAG basecamp templates todolists duplicate --verbose type=count +FLAG basecamp templates todolists duplication --account type=string +FLAG basecamp templates todolists duplication --agent type=bool +FLAG basecamp templates todolists duplication --cache-dir type=string +FLAG basecamp templates todolists duplication --count type=bool +FLAG basecamp templates todolists duplication --help type=bool +FLAG basecamp templates todolists duplication --hints type=bool +FLAG basecamp templates todolists duplication --ids-only type=bool +FLAG basecamp templates todolists duplication --in type=string +FLAG basecamp templates todolists duplication --jq type=string +FLAG basecamp templates todolists duplication --json type=bool +FLAG basecamp templates todolists duplication --markdown type=bool +FLAG basecamp templates todolists duplication --md type=bool +FLAG basecamp templates todolists duplication --no-hints type=bool +FLAG basecamp templates todolists duplication --no-stats type=bool +FLAG basecamp templates todolists duplication --profile type=string +FLAG basecamp templates todolists duplication --project type=string +FLAG basecamp templates todolists duplication --quiet type=bool +FLAG basecamp templates todolists duplication --stats type=bool +FLAG basecamp templates todolists duplication --styled type=bool +FLAG basecamp templates todolists duplication --todolist type=string +FLAG basecamp templates todolists duplication --verbose type=count +FLAG basecamp templates todolists list --account type=string +FLAG basecamp templates todolists list --agent type=bool +FLAG basecamp templates todolists list --cache-dir type=string +FLAG basecamp templates todolists list --count type=bool +FLAG basecamp templates todolists list --help type=bool +FLAG basecamp templates todolists list --hints type=bool +FLAG basecamp templates todolists list --ids-only type=bool +FLAG basecamp templates todolists list --in type=string +FLAG basecamp templates todolists list --jq type=string +FLAG basecamp templates todolists list --json type=bool +FLAG basecamp templates todolists list --markdown type=bool +FLAG basecamp templates todolists list --md type=bool +FLAG basecamp templates todolists list --no-hints type=bool +FLAG basecamp templates todolists list --no-stats type=bool +FLAG basecamp templates todolists list --profile type=string +FLAG basecamp templates todolists list --project type=string +FLAG basecamp templates todolists list --quiet type=bool +FLAG basecamp templates todolists list --stats type=bool +FLAG basecamp templates todolists list --styled type=bool +FLAG basecamp templates todolists list --todolist type=string +FLAG basecamp templates todolists list --verbose type=count FLAG basecamp templates update --account type=string FLAG basecamp templates update --agent type=bool FLAG basecamp templates update --cache-dir type=string @@ -18723,7 +19057,21 @@ SUB basecamp templates create SUB basecamp templates delete SUB basecamp templates library SUB basecamp templates list +SUB basecamp templates projects +SUB basecamp templates projects construct +SUB basecamp templates projects construction +SUB basecamp templates projects create +SUB basecamp templates projects delete +SUB basecamp templates projects list +SUB basecamp templates projects show +SUB basecamp templates projects update SUB basecamp templates show +SUB basecamp templates todolists +SUB basecamp templates todolists copy +SUB basecamp templates todolists copy-status +SUB basecamp templates todolists duplicate +SUB basecamp templates todolists duplication +SUB basecamp templates todolists list SUB basecamp templates update SUB basecamp timeline SUB basecamp timesheet diff --git a/e2e/smoke/smoke_account.bats b/e2e/smoke/smoke_account.bats index f5168b405..f0f0efc5d 100644 --- a/e2e/smoke/smoke_account.bats +++ b/e2e/smoke/smoke_account.bats @@ -28,6 +28,19 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "templates projects list returns templates" { + run_smoke basecamp templates projects list --json + assert_success + assert_json_value '.ok' 'true' +} + +@test "templates todolists list returns to-do list templates" { + run_smoke basecamp templates todolists list --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 @@ -102,3 +115,16 @@ setup_file() { @test "account is out of scope" { mark_out_of_scope "Alias for accounts — tested via canonical form" } + +@test "templates projects show returns template detail" { + local out + out=$(basecamp templates projects list --json 2>/dev/null) || mark_unverifiable "Cannot list templates" + local tmpl_id + tmpl_id=$(echo "$out" | jq -r '.data[0].id // empty') + [[ -n "$tmpl_id" ]] || mark_unverifiable "No templates found" + + run_smoke basecamp templates projects show "$tmpl_id" --json + assert_success + assert_json_value '.ok' 'true' + assert_json_not_null '.data.id' +} diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index 238e6de08..a874da465 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -121,6 +121,42 @@ load smoke_helper mark_out_of_scope "Account-wide template mutation" } +@test "templates projects construct is out of scope" { + mark_out_of_scope "Creates project from template — account-wide mutation" +} + +@test "templates projects construction is out of scope" { + mark_out_of_scope "Depends on templates projects construct (OOS)" +} + +@test "templates projects create is out of scope" { + mark_out_of_scope "Account-wide template mutation" +} + +@test "templates projects update is out of scope" { + mark_out_of_scope "Account-wide template mutation" +} + +@test "templates projects delete is out of scope" { + mark_out_of_scope "Account-wide template mutation" +} + +@test "templates todolists duplicate is out of scope" { + mark_out_of_scope "Duplicates a to-do list into a project and may grant project access" +} + +@test "templates todolists duplication is out of scope" { + mark_out_of_scope "Depends on templates todolists duplicate (OOS)" +} + +@test "templates todolists copy is out of scope" { + mark_out_of_scope "Alias of templates todolists duplicate (OOS)" +} + +@test "templates todolists copy-status is out of scope" { + mark_out_of_scope "Alias of templates todolists duplication (OOS)" +} + @test "messagetypes create is out of scope" { mark_out_of_scope "Account-wide message type mutation" } diff --git a/e2e/templates.bats b/e2e/templates.bats index 435618c67..c8709cb3b 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -213,3 +213,88 @@ load test_helper run basecamp templates foobar # Command may show help or require project - just verify it runs } + + +# Kind subgroups + +@test "templates --help lists the kind subgroups" { + run basecamp templates --help + assert_success + assert_output_contains "projects" + assert_output_contains "todolists" +} + +@test "templates projects without subcommand shows help" { + run basecamp templates projects + assert_success + assert_output_contains "construct" + assert_output_contains "construction" +} + +@test "templates todolists without subcommand shows help" { + run basecamp templates todolists + assert_success + assert_output_contains "duplicate" + assert_output_contains "duplication" +} + +@test "templates projects construct without project name shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates projects construct 123 + assert_failure + assert_output_contains "name required" +} + +@test "templates projects list --status with invalid value shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates projects list --status bogus + assert_failure + assert_output_contains "unknown --status value" +} + +@test "templates todolists duplicate without template id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates todolists duplicate + assert_failure + assert_output_contains "ID required" +} + +@test "templates todolists duplicate accepts --in and --confirm-adding-people" { + run basecamp templates todolists duplicate --help + assert_success + assert_output_contains "--in" + assert_output_contains "--confirm-adding-people" +} + +@test "templates todolists duplication without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates todolists duplication + assert_failure + assert_output_contains "ID required" +} + +@test "templates todolists copy is an alias for duplicate" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates todolists copy + assert_failure + assert_output_contains "ID required" +} + +@test "templates todolists copy-status is an alias for duplication" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates todolists copy-status + assert_failure + assert_output_contains "ID required" +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 904216535..4e072c240 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", "clients"}}, - {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: "templates", Category: "organization", Description: "Manage project and to-do list templates", Actions: []string{"projects", "todolists"}}, {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 a8482b3dc..1f731ac99 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -21,11 +21,33 @@ func NewTemplatesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "templates", Short: "Manage project and to-do list templates", - Long: `Manage project templates and the account's to-do list template library. + Long: `Manage the account's template library, grouped by kind of template. -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. construct --start-date anchors the template's relative dates to the Sunday of that week; without it they anchor to the week of construction. Copy grants referenced people project access only with --confirm-adding-people."}, + projects Project templates: construct a whole project from a blueprint. + todolists To-do list templates: duplicate a reusable list into a project. + +The pre-grouping spellings (templates list, templates library, templates copy, +and the rest) still work and resolve to the same commands.`, + Annotations: map[string]string{"agent_notes": "Project construction and to-do list template duplication are asynchronous. Poll templates projects construction or templates todolists duplication until status=completed. construct --start-date anchors the template's relative dates to the Sunday of that week; without it they anchor to the week of construction. Duplicating grants referenced people project access only with --confirm-adding-people."}, + } + + cmd.AddCommand( + newTemplatesProjectsCmd(), + newTemplatesTodolistsCmd(), + ) + cmd.AddCommand(templatesBackCompatCmds()...) + + return cmd +} + +func newTemplatesProjectsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "projects", + Short: "Manage project templates", + Long: `Manage project templates. + +A project template is a blueprint for a whole project: its tools, structure, +and starting content. Constructing one creates a new project.`, } cmd.AddCommand( @@ -36,14 +58,104 @@ Library templates copy a reusable to-do list into an existing project.`, newTemplatesDeleteCmd(), newTemplatesConstructCmd(), newTemplatesConstructionCmd(), - newTemplatesLibraryCmd(), - newTemplatesCopyCmd(), - newTemplatesCopyStatusCmd(), ) return cmd } +func newTemplatesTodolistsCmd() *cobra.Command { + kind := todolistTemplateKind() + + cmd := &cobra.Command{ + Use: "todolists", + Short: "Manage to-do list templates", + Long: `Manage the account's to-do list template library. + +A to-do list template is a reusable list. Duplicating one adds it to an +existing project's To-dos tool.`, + } + + cmd.AddCommand( + newTemplatesLibraryListCmd("list"), + newTemplatesDuplicateCmd(kind, "duplicate ", "copy"), + newTemplatesDuplicationCmd(kind, "duplication ", "copy-status"), + ) + + return cmd +} + +func templatesBackCompatCmds() []*cobra.Command { + kind := todolistTemplateKind() + + // Registered a second time rather than aliased: cobra aliases rename a + // command where it sits, and these moved a level down the tree. + return []*cobra.Command{ + newTemplatesListCmd(), + newTemplatesShowCmd(), + newTemplatesCreateCmd(), + newTemplatesUpdateCmd(), + newTemplatesDeleteCmd(), + newTemplatesConstructCmd(), + newTemplatesConstructionCmd(), + newTemplatesLibraryListCmd("library"), + newTemplatesDuplicateCmd(kind, "copy "), + newTemplatesDuplicationCmd(kind, "copy-status "), + } +} + +// What differs by kind when duplicating: how a pinned container is checked, and +// what the finished copy points at. Basecamp resolves the container itself from +// the destination project, so the unpinned path is the same for every kind. +type templateCopyKind struct { + noun string + group string + containerFlag string + containerUsage string + resolveContainer func(cmd *cobra.Command, app *appctx.App, projectID, container string) (int64, error) + describeCompletedResult func(templateCopy *basecamp.TemplateLibraryCopy, contextArgs string) (string, []output.Breadcrumb, bool) +} + +func todolistTemplateKind() templateCopyKind { + return templateCopyKind{ + noun: "to-do list", + group: "todolists", + containerFlag: "todoset", + containerUsage: "To-dos tool ID (auto-detected from project)", + resolveContainer: resolveTemplateCopyTodoset, + describeCompletedResult: describeTodolistDuplication, + } +} + +func describeTodolistDuplication(templateCopy *basecamp.TemplateLibraryCopy, contextArgs string) (string, []output.Breadcrumb, bool) { + list := templateCopy.DestinationTodolist + if list == nil { + return "", nil, false + } + + breadcrumbs := []output.Breadcrumb{ + { + Action: "show", + Cmd: fmt.Sprintf("basecamp todolists show %d --in %d%s", list.ID, list.Bucket.ID, contextArgs), + Description: "View the duplicated to-do list", + }, + } + + return fmt.Sprintf("Template duplication complete: %s (to-do list #%d)", list.Name, list.ID), breadcrumbs, true +} + +func resolveTemplateCopyTodoset(cmd *cobra.Command, app *appctx.App, projectID, todoset string) (int64, error) { + if err := validateTemplateCopyTodoset(cmd, app, todoset, projectID); err != nil { + return 0, err + } + + todosetID, err := strconv.ParseInt(todoset, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid todoset ID") + } + + return todosetID, nil +} + func newTemplatesListCmd() *cobra.Command { var status string @@ -121,26 +233,26 @@ func runTemplatesList(cmd *cobra.Command, status string) error { output.WithBreadcrumbs( output.Breadcrumb{ Action: "show", - Cmd: "basecamp templates show ", + Cmd: "basecamp templates projects show ", Description: "View template details", }, output.Breadcrumb{ Action: "create", - Cmd: "basecamp templates create \"Name\"", + Cmd: "basecamp templates projects create \"Name\"", Description: "Create new template", }, output.Breadcrumb{ Action: "construct", - Cmd: "basecamp templates construct --name \"Project Name\"", + Cmd: "basecamp templates projects construct --name \"Project Name\"", Description: "Create project from template", }, ), ) } -func newTemplatesLibraryCmd() *cobra.Command { +func newTemplatesLibraryListCmd(use string) *cobra.Command { return &cobra.Command{ - Use: "library", + Use: use, Short: "List to-do list templates", Long: "List the account's active to-do list templates.", RunE: func(cmd *cobra.Command, args []string) error { @@ -174,9 +286,9 @@ func newTemplatesLibraryCmd() *cobra.Command { output.WithSummary(fmt.Sprintf("%d active to-do list templates", len(library.Todolists))), output.WithBreadcrumbs( output.Breadcrumb{ - Action: "copy", - Cmd: "basecamp templates copy --in " + contextArgs, - Description: "Copy a template into a project", + Action: "duplicate", + Cmd: "basecamp templates todolists duplicate --in " + contextArgs, + Description: "Duplicate a template into a project", }, ), ) @@ -184,26 +296,27 @@ func newTemplatesLibraryCmd() *cobra.Command { } } -func newTemplatesCopyCmd() *cobra.Command { +func newTemplatesDuplicateCmd(kind templateCopyKind, use string, aliases ...string) *cobra.Command { var project string - var todoset string + var container 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.`, + Use: use, + Aliases: aliases, + Short: "Duplicate a " + kind.noun + " template into a project", + Long: fmt.Sprintf(`Start duplicating a %s template into a project. + +The duplication runs asynchronously. Use 'templates %s duplication' with the +returned ID to check its progress. Referenced people receive project access +only when --confirm-adding-people is explicitly provided.`, kind.noun, kind.group), 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 { + if err := requireNumericID(container, kind.containerFlag+" ID"); err != nil { return err } @@ -222,45 +335,45 @@ when --confirm-adding-people is explicitly provided.`, if err != nil { return err } - if todoset != "" { - if err := validateTemplateCopyTodoset(cmd, app, todoset, resolvedProjectID); err != nil { + + req := &basecamp.CreateTemplateLibraryCopyRequest{ + TemplateRecordingID: templateID, + AddingPeopleConfirmed: confirmAddingPeople, + } + if container == "" { + projectID, err := strconv.ParseInt(resolvedProjectID, 10, 64) + if err != nil { + return output.ErrUsage("Invalid project ID") + } + req.DestinationProjectID = projectID + } else { + req.DestinationParentID, err = kind.resolveContainer(cmd, app, resolvedProjectID, container) + 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, - }) + templateCopy, err := app.Account().Templates().CreateLibraryCopy(cmd.Context(), req) if err != nil { - return templateCopyError(err, templateID, resolvedProjectID, resolvedTodosetID, contextArgs) + return templateCopyError(err, kind, templateID, resolvedProjectID, container, contextArgs) } return app.OK(templateCopy, - output.WithSummary(fmt.Sprintf("Started template copy #%d (%s)", templateCopy.ID, templateCopy.Status)), + output.WithSummary(fmt.Sprintf("Started template duplication #%d (%s)", templateCopy.ID, templateCopy.Status)), output.WithBreadcrumbs( output.Breadcrumb{ Action: "status", - Cmd: fmt.Sprintf("basecamp templates copy-status %d%s", templateCopy.ID, contextArgs), - Description: "Check copy status", + Cmd: fmt.Sprintf("basecamp templates %s duplication %d%s", kind.group, templateCopy.ID, contextArgs), + Description: "Check duplication 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().StringVarP(&project, "project", "p", "", "Destination project ID or name") + cmd.Flags().StringVar(&project, "in", "", "Destination project ID or name (alias for --project)") + cmd.Flags().StringVar(&container, kind.containerFlag, "", kind.containerUsage) cmd.Flags().BoolVar(&confirmAddingPeople, "confirm-adding-people", false, "Grant referenced people access to the destination project") return cmd @@ -300,7 +413,7 @@ func templateCommandContextArgs(profile string, persistentAccount bool, accountI return args + replyAccountArg(persistentAccount, accountID) } -func templateCopyError(err error, templateID int64, projectID, todosetID, contextArgs string) error { +func templateCopyError(err error, kind templateCopyKind, templateID int64, projectID, container, contextArgs string) error { var confirmationErr *basecamp.PeopleConfirmationRequiredError if !errors.As(err, &confirmationErr) { return convertSDKError(err) @@ -311,7 +424,12 @@ func templateCopyError(err error, templateID int64, projectID, todosetID, contex people = append(people, fmt.Sprintf("%s (#%d)", person.Name, person.ID)) } - rerun := fmt.Sprintf("basecamp templates copy %d --in %s --todoset %s%s --confirm-adding-people", templateID, projectID, todosetID, contextArgs) + pinned := "" + if container != "" { + pinned = fmt.Sprintf(" --%s %s", kind.containerFlag, container) + } + rerun := fmt.Sprintf("basecamp templates %s duplicate %d --in %s%s%s --confirm-adding-people", + kind.group, templateID, projectID, pinned, contextArgs) converted := output.AsError(err) return &output.Error{ @@ -324,16 +442,17 @@ func templateCopyError(err error, templateID int64, projectID, todosetID, contex } } -func newTemplatesCopyStatusCmd() *cobra.Command { +func newTemplatesDuplicationCmd(kind templateCopyKind, use string, aliases ...string) *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), + Use: use, + Aliases: aliases, + Short: "Check a " + kind.noun + " template duplication", + Long: "Check whether a " + kind.noun + " template duplication 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") + return output.ErrUsage("Invalid duplication ID") } app := appctx.FromContext(cmd.Context()) @@ -352,7 +471,7 @@ func newTemplatesCopyStatusCmd() *cobra.Command { return convertSDKError(err) } - summary, breadcrumbs := templateCopyStatusOutput(templateCopy, contextArgs) + summary, breadcrumbs := templateCopyStatusOutput(templateCopy, kind, contextArgs) return app.OK(templateCopy, output.WithSummary(summary), output.WithBreadcrumbs(breadcrumbs...), @@ -361,39 +480,31 @@ func newTemplatesCopyStatusCmd() *cobra.Command { } } -func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy, contextArgs string) (string, []output.Breadcrumb) { +func templateCopyStatusOutput(templateCopy *basecamp.TemplateLibraryCopy, kind templateCopyKind, contextArgs string) (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%s", list.ID, list.Bucket.ID, contextArgs), - Description: "View copied to-do list", - }, - } - return fmt.Sprintf("Template copy complete: %s (to-do list #%d)", list.Name, list.ID), breadcrumbs + if summary, breadcrumbs, ok := kind.describeCompletedResult(templateCopy, contextArgs); ok { + return summary, breadcrumbs } - return fmt.Sprintf("Template copy #%d completed", templateCopy.ID), nil + return fmt.Sprintf("Template duplication #%d completed", templateCopy.ID), nil case "failed": - return fmt.Sprintf("Template copy #%d failed", templateCopy.ID), []output.Breadcrumb{ + return fmt.Sprintf("Template duplication #%d failed", templateCopy.ID), []output.Breadcrumb{ { - Action: "library", - Cmd: "basecamp templates library" + contextArgs, + Action: "list", + Cmd: fmt.Sprintf("basecamp templates %s list%s", kind.group, contextArgs), Description: "List available templates", }, } case "pending", "processing": - return fmt.Sprintf("Template copy #%d is %s", templateCopy.ID, templateCopy.Status), []output.Breadcrumb{ + return fmt.Sprintf("Template duplication #%d is %s", templateCopy.ID, templateCopy.Status), []output.Breadcrumb{ { Action: "poll", - Cmd: fmt.Sprintf("basecamp templates copy-status %d%s", templateCopy.ID, contextArgs), + Cmd: fmt.Sprintf("basecamp templates %s duplication %d%s", kind.group, templateCopy.ID, contextArgs), Description: "Check again", }, } default: - return fmt.Sprintf("Template copy #%d status: %s", templateCopy.ID, templateCopy.Status), nil + return fmt.Sprintf("Template duplication #%d status: %s", templateCopy.ID, templateCopy.Status), nil } } @@ -425,17 +536,17 @@ func newTemplatesShowCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "construct", - Cmd: fmt.Sprintf("basecamp templates construct %d --name \"Project Name\"", templateID), + Cmd: fmt.Sprintf("basecamp templates projects construct %d --name \"Project Name\"", templateID), Description: "Create project from template", }, output.Breadcrumb{ Action: "update", - Cmd: fmt.Sprintf("basecamp templates update %d --name \"New Name\"", templateID), + Cmd: fmt.Sprintf("basecamp templates projects update %d --name \"New Name\"", templateID), Description: "Update template", }, output.Breadcrumb{ Action: "list", - Cmd: "basecamp templates", + Cmd: "basecamp templates projects list", Description: "List all templates", }, ), @@ -490,12 +601,12 @@ func newTemplatesCreateCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "show", - Cmd: fmt.Sprintf("basecamp templates show %d", template.ID), + Cmd: fmt.Sprintf("basecamp templates projects show %d", template.ID), Description: "View template", }, output.Breadcrumb{ Action: "construct", - Cmd: fmt.Sprintf("basecamp templates construct %d --name \"Project Name\"", template.ID), + Cmd: fmt.Sprintf("basecamp templates projects construct %d --name \"Project Name\"", template.ID), Description: "Create project from template", }, ), @@ -568,7 +679,7 @@ func newTemplatesUpdateCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "show", - Cmd: fmt.Sprintf("basecamp templates show %d", templateID), + Cmd: fmt.Sprintf("basecamp templates projects show %d", templateID), Description: "View template", }, ), @@ -613,12 +724,12 @@ func newTemplatesDeleteCmd() *cobra.Command { output.WithBreadcrumbs( output.Breadcrumb{ Action: "list", - Cmd: "basecamp templates", + Cmd: "basecamp templates projects list", Description: "List templates", }, output.Breadcrumb{ Action: "trashed", - Cmd: "basecamp templates --status trashed", + Cmd: "basecamp templates projects list --status trashed", Description: "View trashed templates", }, ), @@ -685,7 +796,7 @@ the given date; without it they anchor to the week the project is constructed.`, output.WithBreadcrumbs( output.Breadcrumb{ Action: "status", - Cmd: fmt.Sprintf("basecamp templates construction %d %d", templateID, construction.ID), + Cmd: fmt.Sprintf("basecamp templates projects construction %d %d", templateID, construction.ID), Description: "Check construction status", }, ), @@ -752,7 +863,7 @@ the response includes the newly created project.`, breadcrumbs = []output.Breadcrumb{ { Action: "poll", - Cmd: fmt.Sprintf("basecamp templates construction %d %d", templateID, constructionID), + Cmd: fmt.Sprintf("basecamp templates projects construction %d %d", templateID, constructionID), Description: "Check again", }, } diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index 397e11e52..d195ac205 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -85,7 +85,7 @@ func TestTemplatesLibraryUsesSDKLibraryEndpoint(t *testing.T) { 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 --profile 'work profile' --account 99999", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "basecamp templates todolists duplicate --in --profile 'work profile' --account 99999", envelope.Breadcrumbs[0].Cmd) } func TestTemplatesLibraryHumanOutputShowsCopyableID(t *testing.T) { @@ -105,10 +105,9 @@ func TestTemplatesLibraryHumanOutputShowsCopyableID(t *testing.T) { assert.Contains(t, buf.String(), "3") } -func TestTemplatesCopyResolvesProjectAndTodosTool(t *testing.T) { +func TestTemplatesCopyNamesTheProjectAndLetsBasecampPickTheContainer(t *testing.T) { app, transport := setupRecordingTestApp(t, projectsRoute(), - templateCopyProjectRoute(), stubRoute{ method: http.MethodPost, path: "/99999/template_library/copies.json", @@ -124,17 +123,16 @@ func TestTemplatesCopyResolvesProjectAndTodosTool(t *testing.T) { require.NoError(t, err) requests := transport.recorded() - require.Len(t, requests, 3) + require.Len(t, requests, 2, "no container lookup: Basecamp resolves it from the project") 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) + assert.Equal(t, http.MethodPost, requests[1].Method) + assert.Equal(t, "/99999/template_library/copies.json", requests[1].Path) + assert.JSONEq(t, `{"template_recording_id":3,"destination_project_id":123}`, requests[1].Body) envelope := decodeTemplateEnvelope(t, buf) - assert.Equal(t, "Started template copy #5 (pending)", envelope.Summary) + assert.Equal(t, "Started template duplication #5 (pending)", envelope.Summary) require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, "basecamp templates copy-status 5 --profile 'work profile' --account 99999", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "basecamp templates todolists duplication 5 --profile 'work profile' --account 99999", envelope.Breadcrumbs[0].Cmd) } func TestTemplatesCopySendsExplicitPeopleConfirmation(t *testing.T) { @@ -223,7 +221,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 profile; echo unsafe' --account 99999 --confirm-adding-people") + assert.Contains(t, outputErr.Hint, "basecamp templates todolists duplicate 3 --in 123 --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") @@ -256,7 +254,7 @@ func TestTemplatesConstructSendsStartDateUnderProjectEnvelope(t *testing.T) { envelope := decodeTemplateEnvelope(t, buf) assert.Equal(t, "Started project construction #598194962 (pending)", envelope.Summary) require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, "basecamp templates construction 2085958507 598194962", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "basecamp templates projects construction 2085958507 598194962", envelope.Breadcrumbs[0].Cmd) } func TestTemplatesConstructOmitsStartDateWhenUnset(t *testing.T) { @@ -338,10 +336,10 @@ func TestTemplatesCopyStatusStates(t *testing.T) { 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"}, + {name: "pending", body: templateCopyPendingJSON, summary: "Template duplication #5 is pending", breadcrumb: "basecamp templates todolists duplication 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 duplication #5 is processing", breadcrumb: "basecamp templates todolists duplication 5"}, + {name: "completed", body: completed, summary: "Template duplication 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 duplication #5 failed", breadcrumb: "basecamp templates todolists list"}, } for _, test := range tests { diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 20a688290..3a06c9987 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -971,30 +971,39 @@ basecamp recordings visibility --hidden # Hide from clients ### Templates +Templates are grouped by the kind of template each verb operates on. + ```bash -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 project template -basecamp templates construct --name "New Project" # Create project (async) -basecamp templates construct --name "New Project" --start-date 2026-09-01 # Anchor template dates to that week -basecamp templates construction # Check project status +basecamp templates projects list --json # List project templates +basecamp templates projects show --json # Project template details +basecamp templates projects create "Template Name" # Create empty project template +basecamp templates projects update --name "New Name" +basecamp templates projects delete # Trash project template +basecamp templates projects construct --name "New Project" # Create project (async) +basecamp templates projects construct --name "New Project" --start-date 2026-09-01 # Anchor template dates to that week +basecamp templates projects 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 +basecamp templates todolists list --json # List active to-do list templates +basecamp templates todolists duplicate --in # Duplicate into To-dos (async) +basecamp templates todolists duplication # Check duplication status ``` +**Older spellings still work.** The flat commands that shipped before the grouping +(`templates list`, `templates show`, `templates create`, `templates update`, +`templates delete`, `templates construct`, `templates construction`, +`templates library`, `templates copy`, `templates copy-status`) remain as aliases. +`copy` and `copy-status` also work inside `templates todolists`. Prefer the grouped, +canonical spellings above when writing new commands. + **Asynchronous results:** `construct` returns a construction ID; poll `construction` until `status="completed"` to get the project. A template's dates are relative to its first week, and template weeks start on Sunday: `--start-date` (YYYY-MM-DD or natural, e.g. `next monday`) anchors them to the Sunday on or before that date; without it they -anchor to the week of construction. `copy` returns a copy ID; poll -`copy-status` through `pending` and `processing` until it is `completed` or `failed`. +anchor to the week of construction. `duplicate` returns a duplication ID; poll +`duplication` 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 +A duplication 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 4bad6aa0e9ab718c24b6961295172fbd37c4ac9b Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 11 Sep 2026 18:05:14 -0700 Subject: [PATCH 2/8] Keep the to-do list listing working when the old library address goes The address this listing read predates the library holding more than one kind of template. It survives only as a redirect, and the SDK now marks it deprecated, so a newly grouped command would have been built on a call already scheduled to disappear. --- internal/commands/templates.go | 2 +- internal/commands/templates_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 1f731ac99..2002a1682 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -267,7 +267,7 @@ func newTemplatesLibraryListCmd(use string) *cobra.Command { app.Config.AccountID, ) - library, err := app.Account().Templates().GetLibrary(cmd.Context()) + library, err := app.Account().Templates().GetLibraryTodolists(cmd.Context()) if err != nil { return convertSDKError(err) } diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index d195ac205..2e47c88c0 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -65,10 +65,10 @@ func decodeTemplateEnvelope(t *testing.T, buf *bytes.Buffer) struct { return envelope } -func TestTemplatesLibraryUsesSDKLibraryEndpoint(t *testing.T) { +func TestTemplatesLibraryUsesCanonicalTodolistsEndpoint(t *testing.T) { app, transport := setupRecordingTestApp(t, stubRoute{ method: http.MethodGet, - path: "/99999/template_library.json", + path: "/99999/template_library/todolists.json", status: http.StatusOK, body: templateLibraryJSON, }) @@ -81,7 +81,7 @@ func TestTemplatesLibraryUsesSDKLibraryEndpoint(t *testing.T) { request := transport.last(t) assert.Equal(t, http.MethodGet, request.Method) - assert.Equal(t, "/99999/template_library.json", request.Path) + assert.Equal(t, "/99999/template_library/todolists.json", request.Path) envelope := decodeTemplateEnvelope(t, buf) assert.Equal(t, "1 active to-do list templates", envelope.Summary) require.Len(t, envelope.Breadcrumbs, 1) @@ -91,7 +91,7 @@ func TestTemplatesLibraryUsesSDKLibraryEndpoint(t *testing.T) { func TestTemplatesLibraryHumanOutputShowsCopyableID(t *testing.T) { app, _ := setupRecordingTestApp(t, stubRoute{ method: http.MethodGet, - path: "/99999/template_library.json", + path: "/99999/template_library/todolists.json", status: http.StatusOK, body: templateLibraryJSON, }) From 3b8e6c4eb7cb662b98edabc27767c63738b6debd Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 11 Sep 2026 18:05:21 -0700 Subject: [PATCH 3/8] Let people reuse a card table the same way they reuse a to-do list The template library has held card table templates since the API gained them, and the CLI could not see them. Someone who had built a board worth reusing could reach it from Basecamp but not from a script. A card table duplicate lands on the destination project rather than in one of its tools, so this command takes no container flag. A project has exactly one dock. This does not build against the released SDK. The operations it needs are not published yet, so the dependency is deliberately left where it was. Bump it before merging. --- .surface | 169 ++++++++++++++++++++++++++++ e2e/smoke/smoke_account.bats | 7 ++ e2e/smoke/smoke_lifecycle.bats | 20 ++++ e2e/templates.bats | 41 +++++++ internal/commands/commands.go | 2 +- internal/commands/templates.go | 159 +++++++++++++++++++++++++- internal/commands/templates_test.go | 54 +++++++++ skills/basecamp/SKILL.md | 12 ++ 8 files changed, 458 insertions(+), 6 deletions(-) diff --git a/.surface b/.surface index 7ae5aac18..b5985f3f9 100644 --- a/.surface +++ b/.surface @@ -336,6 +336,11 @@ ARG basecamp subscriptions remove 01 [person_ids] ARG basecamp subscriptions show 00 ARG basecamp subscriptions subscribe 00 ARG basecamp subscriptions unsubscribe 00 +ARG basecamp templates card-tables copy 00 +ARG basecamp templates card-tables copy-status 00 +ARG basecamp templates card-tables create 00 +ARG basecamp templates card-tables duplicate 00 +ARG basecamp templates card-tables duplication 00 ARG basecamp templates construct 00 ARG basecamp templates construction 00 ARG basecamp templates construction 01 @@ -1014,6 +1019,13 @@ CMD basecamp subscriptions show CMD basecamp subscriptions subscribe CMD basecamp subscriptions unsubscribe CMD basecamp templates +CMD basecamp templates card-tables +CMD basecamp templates card-tables copy +CMD basecamp templates card-tables copy-status +CMD basecamp templates card-tables create +CMD basecamp templates card-tables duplicate +CMD basecamp templates card-tables duplication +CMD basecamp templates card-tables list CMD basecamp templates construct CMD basecamp templates construction CMD basecamp templates copy @@ -13492,6 +13504,156 @@ FLAG basecamp templates --stats type=bool FLAG basecamp templates --styled type=bool FLAG basecamp templates --todolist type=string FLAG basecamp templates --verbose type=count +FLAG basecamp templates card-tables --account type=string +FLAG basecamp templates card-tables --agent type=bool +FLAG basecamp templates card-tables --cache-dir type=string +FLAG basecamp templates card-tables --count type=bool +FLAG basecamp templates card-tables --help type=bool +FLAG basecamp templates card-tables --hints type=bool +FLAG basecamp templates card-tables --ids-only type=bool +FLAG basecamp templates card-tables --in type=string +FLAG basecamp templates card-tables --jq type=string +FLAG basecamp templates card-tables --json type=bool +FLAG basecamp templates card-tables --markdown type=bool +FLAG basecamp templates card-tables --md type=bool +FLAG basecamp templates card-tables --no-hints type=bool +FLAG basecamp templates card-tables --no-stats type=bool +FLAG basecamp templates card-tables --profile type=string +FLAG basecamp templates card-tables --project type=string +FLAG basecamp templates card-tables --quiet type=bool +FLAG basecamp templates card-tables --stats type=bool +FLAG basecamp templates card-tables --styled type=bool +FLAG basecamp templates card-tables --todolist type=string +FLAG basecamp templates card-tables --verbose type=count +FLAG basecamp templates card-tables copy --account type=string +FLAG basecamp templates card-tables copy --agent type=bool +FLAG basecamp templates card-tables copy --cache-dir type=string +FLAG basecamp templates card-tables copy --confirm-adding-people type=bool +FLAG basecamp templates card-tables copy --count type=bool +FLAG basecamp templates card-tables copy --help type=bool +FLAG basecamp templates card-tables copy --hints type=bool +FLAG basecamp templates card-tables copy --ids-only type=bool +FLAG basecamp templates card-tables copy --in type=string +FLAG basecamp templates card-tables copy --jq type=string +FLAG basecamp templates card-tables copy --json type=bool +FLAG basecamp templates card-tables copy --markdown type=bool +FLAG basecamp templates card-tables copy --md type=bool +FLAG basecamp templates card-tables copy --no-hints type=bool +FLAG basecamp templates card-tables copy --no-stats type=bool +FLAG basecamp templates card-tables copy --profile type=string +FLAG basecamp templates card-tables copy --project type=string +FLAG basecamp templates card-tables copy --quiet type=bool +FLAG basecamp templates card-tables copy --stats type=bool +FLAG basecamp templates card-tables copy --styled type=bool +FLAG basecamp templates card-tables copy --todolist type=string +FLAG basecamp templates card-tables copy --verbose type=count +FLAG basecamp templates card-tables copy-status --account type=string +FLAG basecamp templates card-tables copy-status --agent type=bool +FLAG basecamp templates card-tables copy-status --cache-dir type=string +FLAG basecamp templates card-tables copy-status --count type=bool +FLAG basecamp templates card-tables copy-status --help type=bool +FLAG basecamp templates card-tables copy-status --hints type=bool +FLAG basecamp templates card-tables copy-status --ids-only type=bool +FLAG basecamp templates card-tables copy-status --in type=string +FLAG basecamp templates card-tables copy-status --jq type=string +FLAG basecamp templates card-tables copy-status --json type=bool +FLAG basecamp templates card-tables copy-status --markdown type=bool +FLAG basecamp templates card-tables copy-status --md type=bool +FLAG basecamp templates card-tables copy-status --no-hints type=bool +FLAG basecamp templates card-tables copy-status --no-stats type=bool +FLAG basecamp templates card-tables copy-status --profile type=string +FLAG basecamp templates card-tables copy-status --project type=string +FLAG basecamp templates card-tables copy-status --quiet type=bool +FLAG basecamp templates card-tables copy-status --stats type=bool +FLAG basecamp templates card-tables copy-status --styled type=bool +FLAG basecamp templates card-tables copy-status --todolist type=string +FLAG basecamp templates card-tables copy-status --verbose type=count +FLAG basecamp templates card-tables create --account type=string +FLAG basecamp templates card-tables create --agent type=bool +FLAG basecamp templates card-tables create --cache-dir type=string +FLAG basecamp templates card-tables create --count type=bool +FLAG basecamp templates card-tables create --help type=bool +FLAG basecamp templates card-tables create --hints type=bool +FLAG basecamp templates card-tables create --ids-only type=bool +FLAG basecamp templates card-tables create --in type=string +FLAG basecamp templates card-tables create --jq type=string +FLAG basecamp templates card-tables create --json type=bool +FLAG basecamp templates card-tables create --markdown type=bool +FLAG basecamp templates card-tables create --md type=bool +FLAG basecamp templates card-tables create --name type=string +FLAG basecamp templates card-tables create --no-hints type=bool +FLAG basecamp templates card-tables create --no-stats type=bool +FLAG basecamp templates card-tables create --profile type=string +FLAG basecamp templates card-tables create --project type=string +FLAG basecamp templates card-tables create --quiet type=bool +FLAG basecamp templates card-tables create --stats type=bool +FLAG basecamp templates card-tables create --styled type=bool +FLAG basecamp templates card-tables create --todolist type=string +FLAG basecamp templates card-tables create --verbose type=count +FLAG basecamp templates card-tables duplicate --account type=string +FLAG basecamp templates card-tables duplicate --agent type=bool +FLAG basecamp templates card-tables duplicate --cache-dir type=string +FLAG basecamp templates card-tables duplicate --confirm-adding-people type=bool +FLAG basecamp templates card-tables duplicate --count type=bool +FLAG basecamp templates card-tables duplicate --help type=bool +FLAG basecamp templates card-tables duplicate --hints type=bool +FLAG basecamp templates card-tables duplicate --ids-only type=bool +FLAG basecamp templates card-tables duplicate --in type=string +FLAG basecamp templates card-tables duplicate --jq type=string +FLAG basecamp templates card-tables duplicate --json type=bool +FLAG basecamp templates card-tables duplicate --markdown type=bool +FLAG basecamp templates card-tables duplicate --md type=bool +FLAG basecamp templates card-tables duplicate --no-hints type=bool +FLAG basecamp templates card-tables duplicate --no-stats type=bool +FLAG basecamp templates card-tables duplicate --profile type=string +FLAG basecamp templates card-tables duplicate --project type=string +FLAG basecamp templates card-tables duplicate --quiet type=bool +FLAG basecamp templates card-tables duplicate --stats type=bool +FLAG basecamp templates card-tables duplicate --styled type=bool +FLAG basecamp templates card-tables duplicate --todolist type=string +FLAG basecamp templates card-tables duplicate --verbose type=count +FLAG basecamp templates card-tables duplication --account type=string +FLAG basecamp templates card-tables duplication --agent type=bool +FLAG basecamp templates card-tables duplication --cache-dir type=string +FLAG basecamp templates card-tables duplication --count type=bool +FLAG basecamp templates card-tables duplication --help type=bool +FLAG basecamp templates card-tables duplication --hints type=bool +FLAG basecamp templates card-tables duplication --ids-only type=bool +FLAG basecamp templates card-tables duplication --in type=string +FLAG basecamp templates card-tables duplication --jq type=string +FLAG basecamp templates card-tables duplication --json type=bool +FLAG basecamp templates card-tables duplication --markdown type=bool +FLAG basecamp templates card-tables duplication --md type=bool +FLAG basecamp templates card-tables duplication --no-hints type=bool +FLAG basecamp templates card-tables duplication --no-stats type=bool +FLAG basecamp templates card-tables duplication --profile type=string +FLAG basecamp templates card-tables duplication --project type=string +FLAG basecamp templates card-tables duplication --quiet type=bool +FLAG basecamp templates card-tables duplication --stats type=bool +FLAG basecamp templates card-tables duplication --styled type=bool +FLAG basecamp templates card-tables duplication --todolist type=string +FLAG basecamp templates card-tables duplication --verbose type=count +FLAG basecamp templates card-tables list --account type=string +FLAG basecamp templates card-tables list --agent type=bool +FLAG basecamp templates card-tables list --cache-dir type=string +FLAG basecamp templates card-tables list --count type=bool +FLAG basecamp templates card-tables list --help type=bool +FLAG basecamp templates card-tables list --hints type=bool +FLAG basecamp templates card-tables list --ids-only type=bool +FLAG basecamp templates card-tables list --in type=string +FLAG basecamp templates card-tables list --jq type=string +FLAG basecamp templates card-tables list --json type=bool +FLAG basecamp templates card-tables list --markdown type=bool +FLAG basecamp templates card-tables list --md type=bool +FLAG basecamp templates card-tables list --no-hints type=bool +FLAG basecamp templates card-tables list --no-stats type=bool +FLAG basecamp templates card-tables list --profile type=string +FLAG basecamp templates card-tables list --project type=string +FLAG basecamp templates card-tables list --quiet type=bool +FLAG basecamp templates card-tables list --stats type=bool +FLAG basecamp templates card-tables list --styled type=bool +FLAG basecamp templates card-tables list --todolist type=string +FLAG basecamp templates card-tables list --verbose type=count FLAG basecamp templates construct --account type=string FLAG basecamp templates construct --agent type=bool FLAG basecamp templates construct --cache-dir type=string @@ -19049,6 +19211,13 @@ SUB basecamp subscriptions show SUB basecamp subscriptions subscribe SUB basecamp subscriptions unsubscribe SUB basecamp templates +SUB basecamp templates card-tables +SUB basecamp templates card-tables copy +SUB basecamp templates card-tables copy-status +SUB basecamp templates card-tables create +SUB basecamp templates card-tables duplicate +SUB basecamp templates card-tables duplication +SUB basecamp templates card-tables list SUB basecamp templates construct SUB basecamp templates construction SUB basecamp templates copy diff --git a/e2e/smoke/smoke_account.bats b/e2e/smoke/smoke_account.bats index f0f0efc5d..79f2bbba8 100644 --- a/e2e/smoke/smoke_account.bats +++ b/e2e/smoke/smoke_account.bats @@ -128,3 +128,10 @@ setup_file() { assert_json_value '.ok' 'true' assert_json_not_null '.data.id' } + +@test "templates card-tables list returns card table templates" { + run_smoke basecamp templates card-tables list --json + [[ "$status" -ne 0 ]] && mark_unverifiable "Card table template library not available in this account" + assert_success + assert_json_value '.ok' 'true' +} diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index a874da465..dded3a5ea 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -157,6 +157,26 @@ load smoke_helper mark_out_of_scope "Alias of templates todolists duplication (OOS)" } +@test "templates card-tables create is out of scope" { + mark_out_of_scope "Account-wide template mutation" +} + +@test "templates card-tables duplicate is out of scope" { + mark_out_of_scope "Duplicates a board into a project and may grant project access" +} + +@test "templates card-tables duplication is out of scope" { + mark_out_of_scope "Depends on templates card-tables duplicate (OOS)" +} + +@test "templates card-tables copy is out of scope" { + mark_out_of_scope "Alias of templates card-tables duplicate (OOS)" +} + +@test "templates card-tables copy-status is out of scope" { + mark_out_of_scope "Alias of templates card-tables duplication (OOS)" +} + @test "messagetypes create is out of scope" { mark_out_of_scope "Account-wide message type mutation" } diff --git a/e2e/templates.bats b/e2e/templates.bats index c8709cb3b..4fff5f89d 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -298,3 +298,44 @@ load test_helper assert_failure assert_output_contains "ID required" } + +@test "templates card-tables without subcommand shows help" { + run basecamp templates card-tables + assert_success + assert_output_contains "duplicate" + assert_output_contains "duplication" +} + +@test "templates card-tables create without name shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates card-tables create + assert_failure + assert_json_value '.error' ' required' +} + +@test "templates card-tables duplicate without template id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates card-tables duplicate + assert_failure + assert_output_contains "ID required" +} + +@test "templates card-tables duplicate has no --todoset flag" { + run basecamp templates card-tables duplicate --help + assert_success + assert_output_contains "--in" + assert_output_not_contains "--todoset" +} + +@test "templates card-tables duplication without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates card-tables duplication + assert_failure + assert_output_contains "ID required" +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 4e072c240..ddf928de1 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", "clients"}}, - {Name: "templates", Category: "organization", Description: "Manage project and to-do list templates", Actions: []string{"projects", "todolists"}}, + {Name: "templates", Category: "organization", Description: "Manage project, to-do list, and card table templates", Actions: []string{"projects", "todolists", "card-tables"}}, {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 2002a1682..9b5c15122 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -20,20 +20,22 @@ import ( func NewTemplatesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "templates", - Short: "Manage project and to-do list templates", + Short: "Manage project, to-do list, and card table templates", Long: `Manage the account's template library, grouped by kind of template. projects Project templates: construct a whole project from a blueprint. todolists To-do list templates: duplicate a reusable list into a project. + card-tables Card table templates: duplicate a reusable board into a project. The pre-grouping spellings (templates list, templates library, templates copy, and the rest) still work and resolve to the same commands.`, - Annotations: map[string]string{"agent_notes": "Project construction and to-do list template duplication are asynchronous. Poll templates projects construction or templates todolists duplication until status=completed. construct --start-date anchors the template's relative dates to the Sunday of that week; without it they anchor to the week of construction. Duplicating grants referenced people project access only with --confirm-adding-people."}, + Annotations: map[string]string{"agent_notes": "Project construction and to-do list or card table template duplication are asynchronous. Poll templates projects construction or templates duplication until status=completed. construct --start-date anchors the template's relative dates to the Sunday of that week; without it they anchor to the week of construction. Duplicating grants referenced people project access only with --confirm-adding-people."}, } cmd.AddCommand( newTemplatesProjectsCmd(), newTemplatesTodolistsCmd(), + newTemplatesCardTablesCmd(), ) cmd.AddCommand(templatesBackCompatCmds()...) @@ -103,6 +105,124 @@ func templatesBackCompatCmds() []*cobra.Command { } } +func newTemplatesCardTablesCmd() *cobra.Command { + kind := cardTableTemplateKind() + + cmd := &cobra.Command{ + Use: "card-tables", + Short: "Manage card table templates", + Long: `Manage the account's card table template library. + +A card table template is a reusable board with its columns. Duplicating one +adds it to an existing project.`, + } + + cmd.AddCommand( + newTemplatesCardTablesListCmd(), + newTemplatesCardTablesCreateCmd(), + newTemplatesDuplicateCmd(kind, "duplicate ", "copy"), + newTemplatesDuplicationCmd(kind, "duplication ", "copy-status"), + ) + + return cmd +} + +func newTemplatesCardTablesListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List card table templates", + Long: "List the account's active card table templates.", + RunE: func(cmd *cobra.Command, args []string) error { + 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, + ) + + library, err := app.Account().Templates().GetLibraryCardTables(cmd.Context()) + if err != nil { + return convertSDKError(err) + } + + display := make([]struct { + ID int64 `json:"id"` + Title string `json:"title"` + }, len(library.CardTables)) + for i, cardTable := range library.CardTables { + display[i].ID = cardTable.ID + display[i].Title = cardTable.Title + } + + return app.OK(library, + output.WithDisplayData(display), + output.WithSummary(fmt.Sprintf("%d active card table templates", len(library.CardTables))), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "duplicate", + Cmd: "basecamp templates card-tables duplicate --in " + contextArgs, + Description: "Duplicate a template into a project", + }, + ), + ) + }, + } +} + +func newTemplatesCardTablesCreateCmd() *cobra.Command { + var name string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create an empty card table template", + Long: "Create an empty card table template in the account's library.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && name == "" { + name = args[0] + } + if name == "" { + return missingArg(cmd, "") + } + + 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, + ) + + cardTable, err := app.Account().Templates().CreateLibraryCardTable(cmd.Context(), name) + if err != nil { + return convertSDKError(err) + } + + return app.OK(cardTable, + output.WithSummary(fmt.Sprintf("Created card table template #%d: %s", cardTable.ID, cardTable.Title)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "duplicate", + Cmd: fmt.Sprintf("basecamp templates card-tables duplicate %d --in %s", cardTable.ID, contextArgs), + Description: "Duplicate the template into a project", + }, + ), + ) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Template name") + + return cmd +} + // What differs by kind when duplicating: how a pinned container is checked, and // what the finished copy points at. Basecamp resolves the container itself from // the destination project, so the unpinned path is the same for every kind. @@ -126,6 +246,31 @@ func todolistTemplateKind() templateCopyKind { } } +func cardTableTemplateKind() templateCopyKind { + return templateCopyKind{ + noun: "card table", + group: "card-tables", + describeCompletedResult: describeCardTableDuplication, + } +} + +func describeCardTableDuplication(templateCopy *basecamp.TemplateLibraryCopy, contextArgs string) (string, []output.Breadcrumb, bool) { + table := templateCopy.DestinationCardTable + if table == nil { + return "", nil, false + } + + breadcrumbs := []output.Breadcrumb{ + { + Action: "cards", + Cmd: fmt.Sprintf("basecamp cards list --card-table %d%s", table.ID, contextArgs), + Description: "List cards on the duplicated board", + }, + } + + return fmt.Sprintf("Template duplication complete: %s (card table #%d)", table.Title, table.ID), breadcrumbs, true +} + func describeTodolistDuplication(templateCopy *basecamp.TemplateLibraryCopy, contextArgs string) (string, []output.Breadcrumb, bool) { list := templateCopy.DestinationTodolist if list == nil { @@ -316,8 +461,10 @@ only when --confirm-adding-people is explicitly provided.`, kind.noun, kind.grou if err != nil { return output.ErrUsage("Invalid template ID") } - if err := requireNumericID(container, kind.containerFlag+" ID"); err != nil { - return err + if kind.containerFlag != "" { + if err := requireNumericID(container, kind.containerFlag+" ID"); err != nil { + return err + } } app := appctx.FromContext(cmd.Context()) @@ -373,7 +520,9 @@ only when --confirm-adding-people is explicitly provided.`, kind.noun, kind.grou cmd.Flags().StringVarP(&project, "project", "p", "", "Destination project ID or name") cmd.Flags().StringVar(&project, "in", "", "Destination project ID or name (alias for --project)") - cmd.Flags().StringVar(&container, kind.containerFlag, "", kind.containerUsage) + if kind.containerFlag != "" { + cmd.Flags().StringVar(&container, kind.containerFlag, "", kind.containerUsage) + } cmd.Flags().BoolVar(&confirmAddingPeople, "confirm-adding-people", false, "Grant referenced people access to the destination project") return cmd diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index 2e47c88c0..421801e14 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -367,3 +367,57 @@ func TestTemplatesCopyStatusStates(t *testing.T) { }) } } + +func TestTemplatesCardTablesListUsesCardTablesEndpoint(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodGet, + path: "/99999/template_library/card_tables.json", + status: http.StatusOK, + body: `{"bucket":{"id":1,"name":"Templates","type":"TemplateLibrary"},"kanban_boardset":{"id":8,"title":"Boards","type":"Kanban::Boardset","url":"https://example.test/boardset.json","app_url":"https://example.test/boardset"},"card_tables":[{"id":12,"title":"Launch board","type":"Kanban::Board"}]}`, + }) + buf := captureTemplateOutput(app) + app.Config.ActiveProfile = "work profile" + app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} + + err := executeRecordingCommand(NewTemplatesCmd(), app, "card-tables", "list") + require.NoError(t, err) + + assert.Equal(t, "/99999/template_library/card_tables.json", transport.last(t).Path) + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "1 active card table templates", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp templates card-tables duplicate --in --profile 'work profile' --account 99999", envelope.Breadcrumbs[0].Cmd) +} + +func TestTemplatesCardTablesDuplicateNamesTheProject(t *testing.T) { + app, transport := setupRecordingTestApp(t, + projectsRoute(), + stubRoute{ + method: http.MethodPost, + path: "/99999/template_library/copies.json", + status: http.StatusCreated, + body: templateCopyPendingJSON, + }, + ) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "card-tables", "duplicate", "3", "--in", "Test Project") + require.NoError(t, err) + + requests := transport.recorded() + post := requests[len(requests)-1] + assert.Equal(t, "/99999/template_library/copies.json", post.Path) + assert.JSONEq(t, `{"template_recording_id":3,"destination_project_id":123}`, post.Body) + + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Started template duplication #5 (pending)", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp templates card-tables duplication 5", envelope.Breadcrumbs[0].Cmd) +} + +func TestTemplatesCardTablesDuplicateHasNoTodosetFlag(t *testing.T) { + cmd, _, err := NewTemplatesCmd().Find([]string{"card-tables", "duplicate"}) + require.NoError(t, err) + assert.Nil(t, cmd.Flags().Lookup("todoset"), "a project has one dock, so there is nothing to pin") + assert.NotNil(t, cmd.Flags().Lookup("in")) +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 3a06c9987..5a93596ee 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -986,8 +986,20 @@ basecamp templates projects construction # Chec basecamp templates todolists list --json # List active to-do list templates basecamp templates todolists duplicate --in # Duplicate into To-dos (async) basecamp templates todolists duplication # Check duplication status + +basecamp templates card-tables list --json # List active card table templates +basecamp templates card-tables create "Board Name" # Create empty card table template +basecamp templates card-tables duplicate --in # Duplicate into project (async) +basecamp templates card-tables duplication # Check duplication status ``` +`duplicate` names the destination project and Basecamp picks the container from +the template's kind: a to-do list lands in the project's To-dos tool, a card +table on its dock. `--todoset` overrides that for a to-do list when a project +has more than one; `card-tables duplicate` has no container flag because a +project has exactly one dock. To-do list +templates land in the project's To-dos tool, which `--todoset` can pin. + **Older spellings still work.** The flat commands that shipped before the grouping (`templates list`, `templates show`, `templates create`, `templates update`, `templates delete`, `templates construct`, `templates construction`, From 015e5ab55123ef9604e19593ed7defad8e7150d7 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 11 Sep 2026 19:19:25 -0700 Subject: [PATCH 4/8] Let people make an empty to-do list template, as they already could a board The card table half of the template library could be seeded from the CLI and the to-do list half could not, so someone scripting the library had to open Basecamp for one kind and not the other. The asymmetry was never intended. It existed because the SDK wrapped one endpoint before the other. --- .surface | 27 ++++++++++++ e2e/smoke/smoke_lifecycle.bats | 4 ++ e2e/templates.bats | 9 ++++ internal/commands/templates.go | 64 +++++++++++++++++++++++++++++ internal/commands/templates_test.go | 16 ++++++++ 5 files changed, 120 insertions(+) diff --git a/.surface b/.surface index b5985f3f9..3e9340bcc 100644 --- a/.surface +++ b/.surface @@ -358,6 +358,7 @@ ARG basecamp templates projects update 00 ARG basecamp templates show 00 ARG basecamp templates todolists copy 00 ARG basecamp templates todolists copy-status 00 +ARG basecamp templates todolists create 00 ARG basecamp templates todolists duplicate 00 ARG basecamp templates todolists duplication 00 ARG basecamp templates update 00 @@ -1046,6 +1047,7 @@ CMD basecamp templates show CMD basecamp templates todolists CMD basecamp templates todolists copy CMD basecamp templates todolists copy-status +CMD basecamp templates todolists create CMD basecamp templates todolists duplicate CMD basecamp templates todolists duplication CMD basecamp templates todolists list @@ -14097,6 +14099,30 @@ FLAG basecamp templates todolists copy-status --stats type=bool FLAG basecamp templates todolists copy-status --styled type=bool FLAG basecamp templates todolists copy-status --todolist type=string FLAG basecamp templates todolists copy-status --verbose type=count +FLAG basecamp templates todolists create --account type=string +FLAG basecamp templates todolists create --agent type=bool +FLAG basecamp templates todolists create --cache-dir type=string +FLAG basecamp templates todolists create --count type=bool +FLAG basecamp templates todolists create --desc type=string +FLAG basecamp templates todolists create --description type=string +FLAG basecamp templates todolists create --help type=bool +FLAG basecamp templates todolists create --hints type=bool +FLAG basecamp templates todolists create --ids-only type=bool +FLAG basecamp templates todolists create --in type=string +FLAG basecamp templates todolists create --jq type=string +FLAG basecamp templates todolists create --json type=bool +FLAG basecamp templates todolists create --markdown type=bool +FLAG basecamp templates todolists create --md type=bool +FLAG basecamp templates todolists create --name type=string +FLAG basecamp templates todolists create --no-hints type=bool +FLAG basecamp templates todolists create --no-stats type=bool +FLAG basecamp templates todolists create --profile type=string +FLAG basecamp templates todolists create --project type=string +FLAG basecamp templates todolists create --quiet type=bool +FLAG basecamp templates todolists create --stats type=bool +FLAG basecamp templates todolists create --styled type=bool +FLAG basecamp templates todolists create --todolist type=string +FLAG basecamp templates todolists create --verbose type=count FLAG basecamp templates todolists duplicate --account type=string FLAG basecamp templates todolists duplicate --agent type=bool FLAG basecamp templates todolists duplicate --cache-dir type=string @@ -19238,6 +19264,7 @@ SUB basecamp templates show SUB basecamp templates todolists SUB basecamp templates todolists copy SUB basecamp templates todolists copy-status +SUB basecamp templates todolists create SUB basecamp templates todolists duplicate SUB basecamp templates todolists duplication SUB basecamp templates todolists list diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index dded3a5ea..66e4f929a 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -177,6 +177,10 @@ load smoke_helper mark_out_of_scope "Alias of templates card-tables duplication (OOS)" } +@test "templates todolists create is out of scope" { + mark_out_of_scope "Account-wide template mutation" +} + @test "messagetypes create is out of scope" { mark_out_of_scope "Account-wide message type mutation" } diff --git a/e2e/templates.bats b/e2e/templates.bats index 4fff5f89d..2b8708cc7 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -339,3 +339,12 @@ load test_helper assert_failure assert_output_contains "ID required" } + +@test "templates todolists create without name shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates todolists create + assert_failure + assert_json_value '.error' ' required' +} diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 9b5c15122..0c60f3662 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -79,6 +79,7 @@ existing project's To-dos tool.`, cmd.AddCommand( newTemplatesLibraryListCmd("list"), + newTemplatesTodolistsCreateCmd(), newTemplatesDuplicateCmd(kind, "duplicate ", "copy"), newTemplatesDuplicationCmd(kind, "duplication ", "copy-status"), ) @@ -173,6 +174,69 @@ func newTemplatesCardTablesListCmd() *cobra.Command { } } +func newTemplatesTodolistsCreateCmd() *cobra.Command { + var name string + var description string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create an empty to-do list template", + Long: "Create an empty to-do list template in the account's library.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && name == "" { + name = args[0] + } + if name == "" { + return missingArg(cmd, "") + } + + description, err := resolveContentValue(cmd, description, -1, "--description") + if err != nil { + return err + } + + 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, + ) + + todolist, err := app.Account().Templates().CreateLibraryTodolist(cmd.Context(), &basecamp.CreateTemplateLibraryTodolistRequest{ + Name: name, + Description: description, + }) + if err != nil { + return convertSDKError(err) + } + + return app.OK(todolist, + output.WithSummary(fmt.Sprintf("Created to-do list template #%d: %s", todolist.ID, todolist.Name)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "duplicate", + Cmd: fmt.Sprintf("basecamp templates todolists duplicate %d --in %s", todolist.ID, contextArgs), + Description: "Duplicate the template into a project", + }, + ), + ) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Template name") + cmd.Flags().StringVar(&description, "description", "", "Template description; use - to read from stdin") + cmd.Flags().StringVar(&description, "desc", "", "Template description (alias)") + + allowDash(cmd, "flag:description", "flag:desc") + + return cmd +} + func newTemplatesCardTablesCreateCmd() *cobra.Command { var name string diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index 421801e14..89e2ec510 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -421,3 +421,19 @@ func TestTemplatesCardTablesDuplicateHasNoTodosetFlag(t *testing.T) { assert.Nil(t, cmd.Flags().Lookup("todoset"), "a project has one dock, so there is nothing to pin") assert.NotNil(t, cmd.Flags().Lookup("in")) } + +func TestTemplatesTodolistsCreateSendsNameAndDescription(t *testing.T) { + app, transport := setupRecordingTestApp(t, stubRoute{ + method: http.MethodPost, + path: "/99999/template_library/todolists.json", + status: http.StatusCreated, + body: `{"id":71,"name":"New hire setup","title":"New hire setup"}`, + }) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "create", "New hire setup", "--description", "Day one") + require.NoError(t, err) + + assert.JSONEq(t, `{"name":"New hire setup","description":"Day one"}`, transport.last(t).Body) + assert.Equal(t, "Created to-do list template #71: New hire setup", decodeTemplateEnvelope(t, buf).Summary) +} From 5c2797995196560f0e1caa1729c41ff33aed3c7d Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 11 Sep 2026 19:39:40 -0700 Subject: [PATCH 5/8] Let people turn a to-do list they already built into a reusable template Building a template meant starting from an empty one and retyping work that already existed somewhere. Basecamp itself offers this from the list's own menu, and the route is nested under the recording, so the verb belongs on the to-do list rather than in the template library. The save runs asynchronously and hands back an ID to poll, matching how constructing a project and duplicating a template already behave. Polling inside the command would leave someone who interrupted it holding a finished template with no way to ask about it. Templatify and templatification are made-up words, and the only honest ones available. Construct pairs with construction and duplicate with duplication, each naming the async record it polls, and saving work as a template has no plain English verb and noun to take those slots. The help text carries the definition. --- .surface | 104 +++++++++++++++ e2e/smoke/smoke_lifecycle.bats | 8 ++ e2e/templates.bats | 16 +++ internal/commands/commands.go | 2 +- internal/commands/templates.go | 192 ++++++++++++++++++++++++++++ internal/commands/templates_test.go | 43 +++++++ internal/commands/todolists.go | 2 + skills/basecamp/SKILL.md | 12 ++ 8 files changed, 378 insertions(+), 1 deletion(-) diff --git a/.surface b/.surface index 3e9340bcc..0a2fdf38c 100644 --- a/.surface +++ b/.surface @@ -388,6 +388,9 @@ ARG basecamp todolist position 00 ... ARG basecamp todolist reorder 00 ... ARG basecamp todolist restore 00 ARG basecamp todolist show 00 +ARG basecamp todolist templatification 00 +ARG basecamp todolist templatification 01 +ARG basecamp todolist templatify 00 ARG basecamp todolist trash 00 ARG basecamp todolist update 00 ARG basecamp todolistgroup create 00 @@ -413,6 +416,9 @@ ARG basecamp todolists position 00 ... ARG basecamp todolists reorder 00 ... ARG basecamp todolists restore 00 ARG basecamp todolists show 00 +ARG basecamp todolists templatification 00 +ARG basecamp todolists templatification 01 +ARG basecamp todolists templatify 00 ARG basecamp todolists trash 00 ARG basecamp todolists update 00 ARG basecamp todos archive 00 @@ -1083,6 +1089,8 @@ CMD basecamp todolist position CMD basecamp todolist reorder CMD basecamp todolist restore CMD basecamp todolist show +CMD basecamp todolist templatification +CMD basecamp todolist templatify CMD basecamp todolist trash CMD basecamp todolist update CMD basecamp todolistgroup @@ -1110,6 +1118,8 @@ CMD basecamp todolists position CMD basecamp todolists reorder CMD basecamp todolists restore CMD basecamp todolists show +CMD basecamp todolists templatification +CMD basecamp todolists templatify CMD basecamp todolists trash CMD basecamp todolists update CMD basecamp todos @@ -14935,6 +14945,51 @@ FLAG basecamp todolist show --stats type=bool FLAG basecamp todolist show --styled type=bool FLAG basecamp todolist show --todolist type=string FLAG basecamp todolist show --verbose type=count +FLAG basecamp todolist templatification --account type=string +FLAG basecamp todolist templatification --agent type=bool +FLAG basecamp todolist templatification --cache-dir type=string +FLAG basecamp todolist templatification --count type=bool +FLAG basecamp todolist templatification --help type=bool +FLAG basecamp todolist templatification --hints type=bool +FLAG basecamp todolist templatification --ids-only type=bool +FLAG basecamp todolist templatification --in type=string +FLAG basecamp todolist templatification --jq type=string +FLAG basecamp todolist templatification --json type=bool +FLAG basecamp todolist templatification --markdown type=bool +FLAG basecamp todolist templatification --md type=bool +FLAG basecamp todolist templatification --no-hints type=bool +FLAG basecamp todolist templatification --no-stats type=bool +FLAG basecamp todolist templatification --profile type=string +FLAG basecamp todolist templatification --project type=string +FLAG basecamp todolist templatification --quiet type=bool +FLAG basecamp todolist templatification --stats type=bool +FLAG basecamp todolist templatification --styled type=bool +FLAG basecamp todolist templatification --todolist type=string +FLAG basecamp todolist templatification --verbose type=count +FLAG basecamp todolist templatify --account type=string +FLAG basecamp todolist templatify --agent type=bool +FLAG basecamp todolist templatify --cache-dir type=string +FLAG basecamp todolist templatify --copy-assignments type=bool +FLAG basecamp todolist templatify --copy-comments type=bool +FLAG basecamp todolist templatify --count type=bool +FLAG basecamp todolist templatify --help type=bool +FLAG basecamp todolist templatify --hints type=bool +FLAG basecamp todolist templatify --ids-only type=bool +FLAG basecamp todolist templatify --in type=string +FLAG basecamp todolist templatify --jq type=string +FLAG basecamp todolist templatify --json type=bool +FLAG basecamp todolist templatify --markdown type=bool +FLAG basecamp todolist templatify --md type=bool +FLAG basecamp todolist templatify --name type=string +FLAG basecamp todolist templatify --no-hints type=bool +FLAG basecamp todolist templatify --no-stats type=bool +FLAG basecamp todolist templatify --profile type=string +FLAG basecamp todolist templatify --project type=string +FLAG basecamp todolist templatify --quiet type=bool +FLAG basecamp todolist templatify --stats type=bool +FLAG basecamp todolist templatify --styled type=bool +FLAG basecamp todolist templatify --todolist type=string +FLAG basecamp todolist templatify --verbose type=count FLAG basecamp todolist trash --account type=string FLAG basecamp todolist trash --agent type=bool FLAG basecamp todolist trash --cache-dir type=string @@ -15547,6 +15602,51 @@ FLAG basecamp todolists show --stats type=bool FLAG basecamp todolists show --styled type=bool FLAG basecamp todolists show --todolist type=string FLAG basecamp todolists show --verbose type=count +FLAG basecamp todolists templatification --account type=string +FLAG basecamp todolists templatification --agent type=bool +FLAG basecamp todolists templatification --cache-dir type=string +FLAG basecamp todolists templatification --count type=bool +FLAG basecamp todolists templatification --help type=bool +FLAG basecamp todolists templatification --hints type=bool +FLAG basecamp todolists templatification --ids-only type=bool +FLAG basecamp todolists templatification --in type=string +FLAG basecamp todolists templatification --jq type=string +FLAG basecamp todolists templatification --json type=bool +FLAG basecamp todolists templatification --markdown type=bool +FLAG basecamp todolists templatification --md type=bool +FLAG basecamp todolists templatification --no-hints type=bool +FLAG basecamp todolists templatification --no-stats type=bool +FLAG basecamp todolists templatification --profile type=string +FLAG basecamp todolists templatification --project type=string +FLAG basecamp todolists templatification --quiet type=bool +FLAG basecamp todolists templatification --stats type=bool +FLAG basecamp todolists templatification --styled type=bool +FLAG basecamp todolists templatification --todolist type=string +FLAG basecamp todolists templatification --verbose type=count +FLAG basecamp todolists templatify --account type=string +FLAG basecamp todolists templatify --agent type=bool +FLAG basecamp todolists templatify --cache-dir type=string +FLAG basecamp todolists templatify --copy-assignments type=bool +FLAG basecamp todolists templatify --copy-comments type=bool +FLAG basecamp todolists templatify --count type=bool +FLAG basecamp todolists templatify --help type=bool +FLAG basecamp todolists templatify --hints type=bool +FLAG basecamp todolists templatify --ids-only type=bool +FLAG basecamp todolists templatify --in type=string +FLAG basecamp todolists templatify --jq type=string +FLAG basecamp todolists templatify --json type=bool +FLAG basecamp todolists templatify --markdown type=bool +FLAG basecamp todolists templatify --md type=bool +FLAG basecamp todolists templatify --name type=string +FLAG basecamp todolists templatify --no-hints type=bool +FLAG basecamp todolists templatify --no-stats type=bool +FLAG basecamp todolists templatify --profile type=string +FLAG basecamp todolists templatify --project type=string +FLAG basecamp todolists templatify --quiet type=bool +FLAG basecamp todolists templatify --stats type=bool +FLAG basecamp todolists templatify --styled type=bool +FLAG basecamp todolists templatify --todolist type=string +FLAG basecamp todolists templatify --verbose type=count FLAG basecamp todolists trash --account type=string FLAG basecamp todolists trash --agent type=bool FLAG basecamp todolists trash --cache-dir type=string @@ -19300,6 +19400,8 @@ SUB basecamp todolist position SUB basecamp todolist reorder SUB basecamp todolist restore SUB basecamp todolist show +SUB basecamp todolist templatification +SUB basecamp todolist templatify SUB basecamp todolist trash SUB basecamp todolist update SUB basecamp todolistgroup @@ -19327,6 +19429,8 @@ SUB basecamp todolists position SUB basecamp todolists reorder SUB basecamp todolists restore SUB basecamp todolists show +SUB basecamp todolists templatification +SUB basecamp todolists templatify SUB basecamp todolists trash SUB basecamp todolists update SUB basecamp todos diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index 66e4f929a..f8411c397 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -181,6 +181,14 @@ load smoke_helper mark_out_of_scope "Account-wide template mutation" } +@test "todolists templatify is out of scope" { + mark_out_of_scope "Writes a new template into the account library" +} + +@test "todolists templatification is out of scope" { + mark_out_of_scope "Depends on todolists templatify (OOS)" +} + @test "messagetypes create is out of scope" { mark_out_of_scope "Account-wide message type mutation" } diff --git a/e2e/templates.bats b/e2e/templates.bats index 2b8708cc7..557b94d60 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -348,3 +348,19 @@ load test_helper assert_failure assert_json_value '.error' ' required' } + +@test "todolists templatify without id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp todolists templatify + assert_failure + assert_output_contains "ID required" +} + +@test "todolists templatify has no --move-cards-to-triage flag" { + run basecamp todolists templatify --help + assert_success + assert_output_contains "--copy-comments" + assert_output_not_contains "--move-cards-to-triage" +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index ddf928de1..6a1248d4f 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -36,7 +36,7 @@ func CommandCategories() []CommandCategory { Commands: []CommandInfo{ {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete"}}, {Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "trash", "archive", "restore"}}, - {Name: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "position", "trash", "archive", "restore"}}, + {Name: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "position", "templatify", "templatification", "trash", "archive", "restore"}}, {Name: "todosets", Category: "core", Description: "Manage to-do set containers", Actions: []string{"list", "show"}}, {Name: "hillcharts", Category: "core", Description: "Manage hill charts", Actions: []string{"show", "track", "untrack"}}, {Name: "gauges", Category: "core", Description: "Manage gauges", Actions: []string{"list", "needles", "needle", "create", "update", "delete", "enable", "disable"}}, diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 0c60f3662..863596fd7 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -1089,3 +1089,195 @@ the response includes the newly created project.`, }, } } + +// What differs by kind when saving work as a template: what a finished save +// points at, and whether the board-only triage flag is offered. bc3 accepts +// move_cards_to_triage on any recording but never reads it for a to-do list, +// so only the card table command registers it. +type templatificationKind struct { + noun string + group string + offersTriage bool + describeCompletedResult func(save *basecamp.Templatification, contextArgs string) (string, []output.Breadcrumb, bool) +} + +func todolistTemplatificationKind() templatificationKind { + return templatificationKind{ + noun: "to-do list", + group: "todolists", + describeCompletedResult: func(save *basecamp.Templatification, contextArgs string) (string, []output.Breadcrumb, bool) { + list := save.DestinationTodolist + if list == nil { + return "", nil, false + } + return fmt.Sprintf("Saved as template: %s (to-do list template #%d)", list.Name, list.ID), + []output.Breadcrumb{ + { + Action: "list", + Cmd: "basecamp templates todolists list" + contextArgs, + Description: "List to-do list templates", + }, + }, true + }, + } +} + +func newTemplatifyCmd(kind templatificationKind, project *string) *cobra.Command { + var name string + var copyComments bool + var copyAssignments bool + var moveCardsToTriage bool + + cmd := &cobra.Command{ + Use: "templatify ", + Short: "Save a " + kind.noun + " as a reusable template", + Long: fmt.Sprintf(`Save an existing %s into the account's template library. + +The save runs asynchronously. Use '%s templatification' with the returned ID +to check its progress. Without --name the template takes the source's title.`, kind.noun, kind.group), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + recordingID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid " + kind.noun + " ID") + } + + 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, + ) + + bucketID, err := resolveBucketID(cmd, app, *project) + if err != nil { + return err + } + + // Unset stays off the wire so the server default applies, which also + // makes --copy-comments=false mean false rather than nothing. + req := &basecamp.CreateTemplatificationRequest{TemplateName: name} + if cmd.Flags().Changed("copy-comments") { + req.CopyComments = ©Comments + } + if cmd.Flags().Changed("copy-assignments") { + req.CopyAssignments = ©Assignments + } + if cmd.Flags().Changed("move-cards-to-triage") { + req.MoveCardsToTriage = &moveCardsToTriage + } + + save, err := app.Account().Templates().CreateTemplatification(cmd.Context(), bucketID, recordingID, req) + if err != nil { + return convertSDKError(err) + } + + return app.OK(save, + output.WithSummary(fmt.Sprintf("Started saving %s #%d as a template (%s)", kind.noun, recordingID, save.Status)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "status", + Cmd: fmt.Sprintf("basecamp %s templatification %d %d --in %d%s", kind.group, recordingID, save.ID, bucketID, contextArgs), + Description: "Check templatification status", + }, + ), + ) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Template name (defaults to the source's title)") + cmd.Flags().BoolVar(©Comments, "copy-comments", false, "Carry the comments across") + cmd.Flags().BoolVar(©Assignments, "copy-assignments", false, "Carry assignees and the people involved across") + if kind.offersTriage { + cmd.Flags().BoolVar(&moveCardsToTriage, "move-cards-to-triage", false, "Gather the cards into Triage instead of leaving them where they sit") + } + + return cmd +} + +func newTemplatificationCmd(kind templatificationKind, project *string) *cobra.Command { + return &cobra.Command{ + Use: "templatification ", + Short: "Check a " + kind.noun + " templatification", + Long: "Check a templatification: the record of saving a " + kind.noun + " as a\n" + + "template. Reports pending, processing, completed, or failed.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + recordingID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid " + kind.noun + " ID") + } + templatificationID, err := strconv.ParseInt(args[1], 10, 64) + if err != nil { + return output.ErrUsage("Invalid templatification ID") + } + + 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, + ) + + bucketID, err := resolveBucketID(cmd, app, *project) + if err != nil { + return err + } + + save, err := app.Account().Templates().GetTemplatification(cmd.Context(), bucketID, recordingID, templatificationID) + if err != nil { + return convertSDKError(err) + } + + summary, breadcrumbs := templatificationStatusOutput(save, kind, recordingID, bucketID, contextArgs) + return app.OK(save, + output.WithSummary(summary), + output.WithBreadcrumbs(breadcrumbs...), + ) + }, + } +} + +func templatificationStatusOutput(save *basecamp.Templatification, kind templatificationKind, recordingID, bucketID int64, contextArgs string) (string, []output.Breadcrumb) { + switch save.Status { + case "completed": + if summary, breadcrumbs, ok := kind.describeCompletedResult(save, contextArgs); ok { + return summary, breadcrumbs + } + return fmt.Sprintf("Templatification #%d completed", save.ID), nil + case "failed": + return fmt.Sprintf("Templatification #%d failed", save.ID), nil + case "pending", "processing": + return fmt.Sprintf("Templatification #%d is %s", save.ID, save.Status), []output.Breadcrumb{ + { + Action: "poll", + Cmd: fmt.Sprintf("basecamp %s templatification %d %d --in %d%s", kind.group, recordingID, save.ID, bucketID, contextArgs), + Description: "Check again", + }, + } + default: + return fmt.Sprintf("Templatification #%d status: %s", save.ID, save.Status), nil + } +} + +func resolveBucketID(cmd *cobra.Command, app *appctx.App, project string) (int64, error) { + resolved, err := resolveProjectID(cmd, app, project) + if err != nil { + return 0, err + } + + bucketID, err := strconv.ParseInt(resolved, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid project ID") + } + + return bucketID, nil +} diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index 89e2ec510..8fa8f4477 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -437,3 +437,46 @@ func TestTemplatesTodolistsCreateSendsNameAndDescription(t *testing.T) { assert.JSONEq(t, `{"name":"New hire setup","description":"Day one"}`, transport.last(t).Body) assert.Equal(t, "Created to-do list template #71: New hire setup", decodeTemplateEnvelope(t, buf).Summary) } + +func TestTemplatifySendsEmptyBodyWithoutFlags(t *testing.T) { + app, transport := setupRecordingTestApp(t, + projectsRoute(), + stubRoute{ + method: http.MethodPost, + path: "/99999/buckets/123/recordings/55/templatifications.json", + status: http.StatusCreated, + body: `{"id":9,"status":"pending","source_recording_id":55,"url":"https://example.test/t/9.json"}`, + }, + ) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTodolistsCmd(), app, "templatify", "55", "--in", "123") + require.NoError(t, err) + + post := transport.recorded()[len(transport.recorded())-1] + assert.JSONEq(t, `{}`, post.Body, "every attribute defaults server-side, so no flags means no body") + + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Started saving to-do list #55 as a template (pending)", envelope.Summary) +} + +func TestTemplatificationReportsTheTemplateItMade(t *testing.T) { + app, _ := setupRecordingTestApp(t, + projectsRoute(), + stubRoute{ + method: http.MethodGet, + path: "/99999/buckets/123/recordings/55/templatifications/9", + status: http.StatusOK, + body: `{"id":9,"status":"completed","source_recording_id":55,"url":"https://example.test/t/9.json","destination_todolist":{"id":70,"name":"Client onboarding","title":"Client onboarding","bucket":{"id":1,"name":"Templates","type":"TemplateLibrary"}}}`, + }, + ) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTodolistsCmd(), app, "templatification", "55", "9", "--in", "123") + require.NoError(t, err) + + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Saved as template: Client onboarding (to-do list template #70)", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp templates todolists list", envelope.Breadcrumbs[0].Cmd) +} diff --git a/internal/commands/todolists.go b/internal/commands/todolists.go index dbd20f635..edd2c7909 100644 --- a/internal/commands/todolists.go +++ b/internal/commands/todolists.go @@ -40,6 +40,8 @@ to disambiguate when needed.`, newTodolistsCreateCmd(&project, &todosetID), newTodolistsUpdateCmd(&project), newTodolistsPositionCmd(&project), + newTemplatifyCmd(todolistTemplatificationKind(), &project), + newTemplatificationCmd(todolistTemplatificationKind(), &project), newRecordableTrashCmd("todolist"), newRecordableArchiveCmd("todolist"), newRecordableRestoreCmd("todolist"), diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 5a93596ee..62138a51d 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -984,6 +984,7 @@ basecamp templates projects construct --name "New Project" --start-date 202 basecamp templates projects construction # Check project status basecamp templates todolists list --json # List active to-do list templates +basecamp templates todolists create "List Name" # Create empty to-do list template basecamp templates todolists duplicate --in # Duplicate into To-dos (async) basecamp templates todolists duplication # Check duplication status @@ -993,6 +994,17 @@ basecamp templates card-tables duplicate --in # Duplica basecamp templates card-tables duplication # Check duplication status ``` +Save existing work into the library from the source group, mirroring the +product, where "Save as a template..." is an action on the item itself: + +```bash +basecamp todolists templatify --in # Save a list (async) +basecamp todolists templatification --in # Check save status +``` + +A templatification is the record of a save in progress, named to match +`construction` and `duplication`. Every attribute of a save defaults server-side, so `templatify ` with +no flags is a complete request and the template takes the source's title. `duplicate` names the destination project and Basecamp picks the container from the template's kind: a to-do list lands in the project's To-dos tool, a card table on its dock. `--todoset` overrides that for a to-do list when a project From 8881affdef41f8c654e08536d895da4b3e33d32c Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 11 Sep 2026 19:40:00 -0700 Subject: [PATCH 6/8] Let people turn a board they already built into a reusable template Saving existing work as a template reached to-do lists but not card tables, even though the same endpoint serves both. Card tables had no home in the CLI for actions on the table itself: the cards group manages what is inside a board, and its card-table flag only picks which board to look in. Gathering the cards into Triage is offered only here. bc3 accepts that attribute on any recording but never reads it for a to-do list, so a per-group command is what keeps the invalid combination unreachable. --- .surface | 76 ++++++++++++++++++++++++ e2e/smoke/smoke_lifecycle.bats | 8 +++ e2e/templates.bats | 39 ++++++++++--- internal/cli/root.go | 1 + internal/commands/cardtables.go | 30 ++++++++++ internal/commands/commands.go | 1 + internal/commands/commands_test.go | 1 + internal/commands/templates.go | 22 +++++++ internal/commands/templates_test.go | 91 +++++++++++++++++++++++++---- skills/basecamp/SKILL.md | 5 ++ 10 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 internal/commands/cardtables.go diff --git a/.surface b/.surface index 0a2fdf38c..d2aea4fb5 100644 --- a/.surface +++ b/.surface @@ -45,6 +45,9 @@ ARG basecamp campfire show 00 ARG basecamp campfire update 00 ARG basecamp campfire update 01 [content] ARG basecamp campfire upload 00 +ARG basecamp card-tables templatification 00 +ARG basecamp card-tables templatification 01 +ARG basecamp card-tables templatify 00 ARG basecamp cards archive 00 ARG basecamp cards column color 00 ARG basecamp cards column create 00 @@ -585,6 +588,9 @@ CMD basecamp campfire post CMD basecamp campfire show CMD basecamp campfire update CMD basecamp campfire upload +CMD basecamp card-tables +CMD basecamp card-tables templatification +CMD basecamp card-tables templatify CMD basecamp cards CMD basecamp cards archive CMD basecamp cards column @@ -2925,6 +2931,73 @@ FLAG basecamp campfire upload --stats type=bool FLAG basecamp campfire upload --styled type=bool FLAG basecamp campfire upload --todolist type=string FLAG basecamp campfire upload --verbose type=count +FLAG basecamp card-tables --account type=string +FLAG basecamp card-tables --agent type=bool +FLAG basecamp card-tables --cache-dir type=string +FLAG basecamp card-tables --count type=bool +FLAG basecamp card-tables --help type=bool +FLAG basecamp card-tables --hints type=bool +FLAG basecamp card-tables --ids-only type=bool +FLAG basecamp card-tables --in type=string +FLAG basecamp card-tables --jq type=string +FLAG basecamp card-tables --json type=bool +FLAG basecamp card-tables --markdown type=bool +FLAG basecamp card-tables --md type=bool +FLAG basecamp card-tables --no-hints type=bool +FLAG basecamp card-tables --no-stats type=bool +FLAG basecamp card-tables --profile type=string +FLAG basecamp card-tables --project type=string +FLAG basecamp card-tables --quiet type=bool +FLAG basecamp card-tables --stats type=bool +FLAG basecamp card-tables --styled type=bool +FLAG basecamp card-tables --todolist type=string +FLAG basecamp card-tables --verbose type=count +FLAG basecamp card-tables templatification --account type=string +FLAG basecamp card-tables templatification --agent type=bool +FLAG basecamp card-tables templatification --cache-dir type=string +FLAG basecamp card-tables templatification --count type=bool +FLAG basecamp card-tables templatification --help type=bool +FLAG basecamp card-tables templatification --hints type=bool +FLAG basecamp card-tables templatification --ids-only type=bool +FLAG basecamp card-tables templatification --in type=string +FLAG basecamp card-tables templatification --jq type=string +FLAG basecamp card-tables templatification --json type=bool +FLAG basecamp card-tables templatification --markdown type=bool +FLAG basecamp card-tables templatification --md type=bool +FLAG basecamp card-tables templatification --no-hints type=bool +FLAG basecamp card-tables templatification --no-stats type=bool +FLAG basecamp card-tables templatification --profile type=string +FLAG basecamp card-tables templatification --project type=string +FLAG basecamp card-tables templatification --quiet type=bool +FLAG basecamp card-tables templatification --stats type=bool +FLAG basecamp card-tables templatification --styled type=bool +FLAG basecamp card-tables templatification --todolist type=string +FLAG basecamp card-tables templatification --verbose type=count +FLAG basecamp card-tables templatify --account type=string +FLAG basecamp card-tables templatify --agent type=bool +FLAG basecamp card-tables templatify --cache-dir type=string +FLAG basecamp card-tables templatify --copy-assignments type=bool +FLAG basecamp card-tables templatify --copy-comments type=bool +FLAG basecamp card-tables templatify --count type=bool +FLAG basecamp card-tables templatify --help type=bool +FLAG basecamp card-tables templatify --hints type=bool +FLAG basecamp card-tables templatify --ids-only type=bool +FLAG basecamp card-tables templatify --in type=string +FLAG basecamp card-tables templatify --jq type=string +FLAG basecamp card-tables templatify --json type=bool +FLAG basecamp card-tables templatify --markdown type=bool +FLAG basecamp card-tables templatify --md type=bool +FLAG basecamp card-tables templatify --move-cards-to-triage type=bool +FLAG basecamp card-tables templatify --name type=string +FLAG basecamp card-tables templatify --no-hints type=bool +FLAG basecamp card-tables templatify --no-stats type=bool +FLAG basecamp card-tables templatify --profile type=string +FLAG basecamp card-tables templatify --project type=string +FLAG basecamp card-tables templatify --quiet type=bool +FLAG basecamp card-tables templatify --stats type=bool +FLAG basecamp card-tables templatify --styled type=bool +FLAG basecamp card-tables templatify --todolist type=string +FLAG basecamp card-tables templatify --verbose type=count FLAG basecamp cards --account type=string FLAG basecamp cards --agent type=bool FLAG basecamp cards --cache-dir type=string @@ -18896,6 +18969,9 @@ SUB basecamp campfire post SUB basecamp campfire show SUB basecamp campfire update SUB basecamp campfire upload +SUB basecamp card-tables +SUB basecamp card-tables templatification +SUB basecamp card-tables templatify SUB basecamp cards SUB basecamp cards archive SUB basecamp cards column diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index f8411c397..e36cc42be 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -189,6 +189,14 @@ load smoke_helper mark_out_of_scope "Depends on todolists templatify (OOS)" } +@test "card-tables templatify is out of scope" { + mark_out_of_scope "Writes a new template into the account library" +} + +@test "card-tables templatification is out of scope" { + mark_out_of_scope "Depends on card-tables templatify (OOS)" +} + @test "messagetypes create is out of scope" { mark_out_of_scope "Account-wide message type mutation" } diff --git a/e2e/templates.bats b/e2e/templates.bats index 557b94d60..d344daab9 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -340,15 +340,6 @@ load test_helper assert_output_contains "ID required" } -@test "templates todolists create without name shows error" { - create_credentials - create_global_config '{"account_id": 99999}' - - run basecamp templates todolists create - assert_failure - assert_json_value '.error' '<name> required' -} - @test "todolists templatify without id shows error" { create_credentials create_global_config '{"account_id": 99999}' @@ -364,3 +355,33 @@ load test_helper assert_output_contains "--copy-comments" assert_output_not_contains "--move-cards-to-triage" } + +@test "card-tables without subcommand shows help" { + run basecamp card-tables + assert_success + assert_output_contains "templatify" + assert_output_contains "templatification" +} + +@test "card-tables templatify offers --move-cards-to-triage" { + run basecamp card-tables templatify --help + assert_success + assert_output_contains "--move-cards-to-triage" +} + +@test "card-tables templatification requires two ids" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp card-tables templatification 123 + assert_failure +} + +@test "templates todolists create without name shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp templates todolists create + assert_failure + assert_json_value '.error' '<name> required' +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d1b03d187..aeb73180b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -321,6 +321,7 @@ func Execute() { cmd.AddCommand(commands.NewUnassignCmd()) cmd.AddCommand(commands.NewMessagesCmd()) cmd.AddCommand(commands.NewCardsCmd()) + cmd.AddCommand(commands.NewCardTablesCmd()) cmd.AddCommand(commands.NewURLCmd()) cmd.AddCommand(commands.NewSearchCmd()) cmd.AddCommand(commands.NewRecordingsCmd()) diff --git a/internal/commands/cardtables.go b/internal/commands/cardtables.go new file mode 100644 index 000000000..c60ee441c --- /dev/null +++ b/internal/commands/cardtables.go @@ -0,0 +1,30 @@ +package commands + +import ( + "github.com/spf13/cobra" +) + +// NewCardTablesCmd creates the card-tables command for managing card tables themselves. +func NewCardTablesCmd() *cobra.Command { + var project string + + cmd := &cobra.Command{ + Use: "card-tables", + Short: "Manage card tables", + Long: `Manage card tables in a project. + +A card table is the board that contains columns and cards. Use this group for +actions on the table itself, such as saving it as a reusable template. Use +'basecamp cards' to manage cards, columns, steps, and wormholes inside a table.`, + } + + cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name") + cmd.PersistentFlags().StringVar(&project, "in", "", "Project ID (alias for --project)") + + cmd.AddCommand( + newTemplatifyCmd(cardTableTemplatificationKind(), &project), + newTemplatificationCmd(cardTableTemplatificationKind(), &project), + ) + + return cmd +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 6a1248d4f..41be3b8e0 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -43,6 +43,7 @@ func CommandCategories() []CommandCategory { {Name: "todolistgroups", Category: "core", Description: "Manage to-do list groups", Actions: []string{"list", "show", "create", "update", "position"}}, {Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "trash", "archive", "restore"}}, {Name: "chat", Category: "core", Description: "Chat in real-time", Actions: []string{"list", "messages", "post", "upload", "line", "update", "delete"}}, + {Name: "card-tables", Category: "core", Description: "Manage card tables", Actions: []string{"templatify", "templatification"}}, {Name: "cards", Category: "core", Description: "Manage Kanban cards", Actions: []string{"list", "show", "create", "update", "move", "done", "columns", "wormholes", "steps", "trash", "archive", "restore"}}, {Name: "files", Category: "core", Description: "Manage files, documents, and folders", Actions: []string{"list", "show", "versions", "replace", "download", "update", "trash", "archive", "restore"}}, {Name: "checkins", Category: "core", Description: "View automatic check-ins", Actions: []string{"questions", "question", "answers", "answer", "reminders"}}, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index d295c3353..1f4feec22 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -69,6 +69,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewUnassignCmd()) root.AddCommand(commands.NewMessagesCmd()) root.AddCommand(commands.NewCardsCmd()) + root.AddCommand(commands.NewCardTablesCmd()) root.AddCommand(commands.NewURLCmd()) root.AddCommand(commands.NewSearchCmd()) root.AddCommand(commands.NewRecordingsCmd()) diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 863596fd7..d4ffb5fdf 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -1122,6 +1122,28 @@ func todolistTemplatificationKind() templatificationKind { } } +func cardTableTemplatificationKind() templatificationKind { + return templatificationKind{ + noun: "card table", + group: "card-tables", + offersTriage: true, + describeCompletedResult: func(save *basecamp.Templatification, contextArgs string) (string, []output.Breadcrumb, bool) { + table := save.DestinationCardTable + if table == nil { + return "", nil, false + } + return fmt.Sprintf("Saved as template: %s (card table template #%d)", table.Title, table.ID), + []output.Breadcrumb{ + { + Action: "list", + Cmd: "basecamp templates card-tables list" + contextArgs, + Description: "List card table templates", + }, + }, true + }, + } +} + func newTemplatifyCmd(kind templatificationKind, project *string) *cobra.Command { var name string var copyComments bool diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index 8fa8f4477..c8a7516b0 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -422,6 +422,59 @@ func TestTemplatesCardTablesDuplicateHasNoTodosetFlag(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("in")) } +func TestTemplatifySendsEmptyBodyWithoutFlags(t *testing.T) { + app, transport := setupRecordingTestApp(t, + projectsRoute(), + stubRoute{ + method: http.MethodPost, + path: "/99999/buckets/123/recordings/55/templatifications.json", + status: http.StatusCreated, + body: `{"id":9,"status":"pending","source_recording_id":55,"url":"https://example.test/t/9.json"}`, + }, + ) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTodolistsCmd(), app, "templatify", "55", "--in", "123") + require.NoError(t, err) + + post := transport.recorded()[len(transport.recorded())-1] + assert.JSONEq(t, `{}`, post.Body, "every attribute defaults server-side, so no flags means no body") + + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Started saving to-do list #55 as a template (pending)", envelope.Summary) +} + +func TestTemplatifyOffersTriageOnlyForCardTables(t *testing.T) { + todolist, _, err := NewTodolistsCmd().Find([]string{"templatify"}) + require.NoError(t, err) + assert.Nil(t, todolist.Flags().Lookup("move-cards-to-triage"), "bc3 never reads it for a to-do list") + + cardTable, _, err := NewCardTablesCmd().Find([]string{"templatify"}) + require.NoError(t, err) + assert.NotNil(t, cardTable.Flags().Lookup("move-cards-to-triage")) +} + +func TestTemplatificationReportsTheTemplateItMade(t *testing.T) { + app, _ := setupRecordingTestApp(t, + projectsRoute(), + stubRoute{ + method: http.MethodGet, + path: "/99999/buckets/123/recordings/55/templatifications/9", + status: http.StatusOK, + body: `{"id":9,"status":"completed","source_recording_id":55,"url":"https://example.test/t/9.json","destination_todolist":{"id":70,"name":"Client onboarding","title":"Client onboarding","bucket":{"id":1,"name":"Templates","type":"TemplateLibrary"}}}`, + }, + ) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTodolistsCmd(), app, "templatification", "55", "9", "--in", "123") + require.NoError(t, err) + + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Saved as template: Client onboarding (to-do list template #70)", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 1) + assert.Equal(t, "basecamp templates todolists list", envelope.Breadcrumbs[0].Cmd) +} + func TestTemplatesTodolistsCreateSendsNameAndDescription(t *testing.T) { app, transport := setupRecordingTestApp(t, stubRoute{ method: http.MethodPost, @@ -438,7 +491,7 @@ func TestTemplatesTodolistsCreateSendsNameAndDescription(t *testing.T) { assert.Equal(t, "Created to-do list template #71: New hire setup", decodeTemplateEnvelope(t, buf).Summary) } -func TestTemplatifySendsEmptyBodyWithoutFlags(t *testing.T) { +func TestCardTablesTemplatifySendsMoveCardsToTriage(t *testing.T) { app, transport := setupRecordingTestApp(t, projectsRoute(), stubRoute{ @@ -448,35 +501,51 @@ func TestTemplatifySendsEmptyBodyWithoutFlags(t *testing.T) { body: `{"id":9,"status":"pending","source_recording_id":55,"url":"https://example.test/t/9.json"}`, }, ) - buf := captureTemplateOutput(app) - err := executeRecordingCommand(NewTodolistsCmd(), app, "templatify", "55", "--in", "123") + err := executeRecordingCommand(NewCardTablesCmd(), app, + "templatify", "55", "--in", "123", "--move-cards-to-triage") require.NoError(t, err) post := transport.recorded()[len(transport.recorded())-1] - assert.JSONEq(t, `{}`, post.Body, "every attribute defaults server-side, so no flags means no body") + assert.JSONEq(t, `{"move_cards_to_triage":true}`, post.Body) +} - envelope := decodeTemplateEnvelope(t, buf) - assert.Equal(t, "Started saving to-do list #55 as a template (pending)", envelope.Summary) +func TestCardTablesTemplatifyOmitsFlagsLeftUnset(t *testing.T) { + app, transport := setupRecordingTestApp(t, + projectsRoute(), + stubRoute{ + method: http.MethodPost, + path: "/99999/buckets/123/recordings/55/templatifications.json", + status: http.StatusCreated, + body: `{"id":9,"status":"pending","source_recording_id":55,"url":"https://example.test/t/9.json"}`, + }, + ) + + err := executeRecordingCommand(NewCardTablesCmd(), app, "templatify", "55", "--in", "123") + require.NoError(t, err) + + post := transport.recorded()[len(transport.recorded())-1] + assert.JSONEq(t, `{}`, post.Body, "an unset flag stays off the wire so the server default applies") } -func TestTemplatificationReportsTheTemplateItMade(t *testing.T) { +func TestCardTablesTemplatificationReportsTheBoardItMade(t *testing.T) { app, _ := setupRecordingTestApp(t, projectsRoute(), stubRoute{ method: http.MethodGet, path: "/99999/buckets/123/recordings/55/templatifications/9", status: http.StatusOK, - body: `{"id":9,"status":"completed","source_recording_id":55,"url":"https://example.test/t/9.json","destination_todolist":{"id":70,"name":"Client onboarding","title":"Client onboarding","bucket":{"id":1,"name":"Templates","type":"TemplateLibrary"}}}`, + body: `{"id":9,"status":"completed","source_recording_id":55,"url":"https://example.test/t/9.json","destination_card_table":{"id":70,"title":"Launch board","type":"Kanban::Board"}}`, }, ) buf := captureTemplateOutput(app) - err := executeRecordingCommand(NewTodolistsCmd(), app, "templatification", "55", "9", "--in", "123") + err := executeRecordingCommand(NewCardTablesCmd(), app, "templatification", "55", "9", "--in", "123") require.NoError(t, err) envelope := decodeTemplateEnvelope(t, buf) - assert.Equal(t, "Saved as template: Client onboarding (to-do list template #70)", envelope.Summary) + assert.Equal(t, "Saved as template: Launch board (card table template #70)", envelope.Summary, + "a nil destination would fall through to the generic completion line") require.Len(t, envelope.Breadcrumbs, 1) - assert.Equal(t, "basecamp templates todolists list", envelope.Breadcrumbs[0].Cmd) + assert.Equal(t, "basecamp templates card-tables list", envelope.Breadcrumbs[0].Cmd) } diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 62138a51d..b2ebbbe8b 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -1000,11 +1000,16 @@ product, where "Save as a template..." is an action on the item itself: ```bash basecamp todolists templatify <id> --in <project> # Save a list (async) basecamp todolists templatification <id> <tid> --in <project> # Check save status +basecamp card-tables templatify <id> --in <project> --move-cards-to-triage +basecamp card-tables templatification <id> <tid> --in <project> ``` A templatification is the record of a save in progress, named to match `construction` and `duplication`. Every attribute of a save defaults server-side, so `templatify <id>` with no flags is a complete request and the template takes the source's title. +`--move-cards-to-triage` gathers the cards into Triage instead of leaving them +where they sit, and is card tables only. + `duplicate` names the destination project and Basecamp picks the container from the template's kind: a to-do list lands in the project's To-dos tool, a card table on its dock. `--todoset` overrides that for a to-do list when a project From 10571ce87b153cfda0b0bcf3378ae3565db5639b Mon Sep 17 00:00:00 2001 From: Joseph Hale <joseph@37signals.com> Date: Mon, 14 Sep 2026 15:49:38 -0700 Subject: [PATCH 7/8] Install bats so bin/ci runs the e2e suite for everyone The e2e suite needs bats, and nothing in the repo installed it. CI cloned a pinned tag, but a local checkout was left to find bats on its own, so bin/ci failed at the e2e step on a machine that had no bats or had a mise shim shadowing one. Pinning it alongside the other tools makes bin/setup enough to get a green local run. --- .mise.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.mise.toml b/.mise.toml index f1ac9ee05..92e2c37ac 100644 --- a/.mise.toml +++ b/.mise.toml @@ -4,6 +4,9 @@ go = "1.26" goreleaser = "2.15.4" # Keep in sync with .github/workflows/test.yml rhysd/actionlint version pin. actionlint = "1.7.12" +# Runs the e2e suite. Keep in sync with the bats-core tag installed in +# .github/workflows/test.yml and .github/workflows/release.yml. +bats = "1.11.0" # Not invoked directly; actionlint shells out to it to lint workflow `run:` # scripts (CI uses the runner's preinstalled shellcheck). shellcheck = "0.11.0" From 6383b7e6e7997279699f5b7326d808c016e0d5f6 Mon Sep 17 00:00:00 2001 From: Joseph Hale <joseph@37signals.com> Date: Mon, 14 Sep 2026 15:57:32 -0700 Subject: [PATCH 8/8] Let people take a template out of the library Basecamp offers to archive or delete a to-do list or card table template, and the CLI could do neither. The verbs that looked like they would serve project templates only, and return 404 for anything else in the library. The flat spellings go at the same time. A verb sitting directly under templates cannot say which kind of template it acts on, which is how a library template ended up at the project-template route to begin with. Every verb now lives under the kind it acts on, so there is one way to spell each one. --- .surface | 396 +++++++------------- .surface-breaking | 252 +++++++++++++ STYLE.md | 9 + e2e/smoke/smoke_account.bats | 14 +- e2e/smoke/smoke_lifecycle.bats | 52 ++- e2e/templates.bats | 90 ++--- internal/commands/stdin_integration_test.go | 8 +- internal/commands/templates.go | 134 +++++-- internal/commands/templates_test.go | 86 ++++- skills/basecamp/SKILL.md | 20 +- 10 files changed, 669 insertions(+), 392 deletions(-) diff --git a/.surface b/.surface index d2aea4fb5..1f91205b2 100644 --- a/.surface +++ b/.surface @@ -339,18 +339,14 @@ ARG basecamp subscriptions remove 01 [person_ids] ARG basecamp subscriptions show 00 <id|url> ARG basecamp subscriptions subscribe 00 <id|url> ARG basecamp subscriptions unsubscribe 00 <id|url> +ARG basecamp templates card-tables archive 00 <template_id> ARG basecamp templates card-tables copy 00 <template_id> ARG basecamp templates card-tables copy-status 00 <duplication_id> ARG basecamp templates card-tables create 00 <name> ARG basecamp templates card-tables duplicate 00 <template_id> ARG basecamp templates card-tables duplication 00 <duplication_id> -ARG basecamp templates construct 00 <template_id> -ARG basecamp templates construction 00 <template_id> -ARG basecamp templates construction 01 <construction_id> -ARG basecamp templates copy 00 <template_id> -ARG basecamp templates copy-status 00 <copy_id> -ARG basecamp templates create 00 <name> -ARG basecamp templates delete 00 <id> +ARG basecamp templates card-tables restore 00 <template_id> +ARG basecamp templates card-tables trash 00 <template_id> ARG basecamp templates projects construct 00 <template_id> ARG basecamp templates projects construction 00 <template_id> ARG basecamp templates projects construction 01 <construction_id> @@ -358,13 +354,14 @@ ARG basecamp templates projects create 00 <name> ARG basecamp templates projects delete 00 <id> ARG basecamp templates projects show 00 <id> ARG basecamp templates projects update 00 <id> -ARG basecamp templates show 00 <id> +ARG basecamp templates todolists archive 00 <template_id> ARG basecamp templates todolists copy 00 <template_id> ARG basecamp templates todolists copy-status 00 <duplication_id> ARG basecamp templates todolists create 00 <name> ARG basecamp templates todolists duplicate 00 <template_id> ARG basecamp templates todolists duplication 00 <duplication_id> -ARG basecamp templates update 00 <id> +ARG basecamp templates todolists restore 00 <template_id> +ARG basecamp templates todolists trash 00 <template_id> ARG basecamp timeline 00 [me] ARG basecamp timesheet item 00 <id> ARG basecamp timesheet recording 00 <id> @@ -1033,20 +1030,15 @@ CMD basecamp subscriptions subscribe CMD basecamp subscriptions unsubscribe CMD basecamp templates CMD basecamp templates card-tables +CMD basecamp templates card-tables archive CMD basecamp templates card-tables copy CMD basecamp templates card-tables copy-status CMD basecamp templates card-tables create CMD basecamp templates card-tables duplicate CMD basecamp templates card-tables duplication CMD basecamp templates card-tables list -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 card-tables restore +CMD basecamp templates card-tables trash CMD basecamp templates projects CMD basecamp templates projects construct CMD basecamp templates projects construction @@ -1055,15 +1047,16 @@ CMD basecamp templates projects delete CMD basecamp templates projects list CMD basecamp templates projects show CMD basecamp templates projects update -CMD basecamp templates show CMD basecamp templates todolists +CMD basecamp templates todolists archive CMD basecamp templates todolists copy CMD basecamp templates todolists copy-status CMD basecamp templates todolists create CMD basecamp templates todolists duplicate CMD basecamp templates todolists duplication CMD basecamp templates todolists list -CMD basecamp templates update +CMD basecamp templates todolists restore +CMD basecamp templates todolists trash CMD basecamp timeline CMD basecamp timesheet CMD basecamp timesheet item @@ -13610,6 +13603,27 @@ FLAG basecamp templates card-tables --stats type=bool FLAG basecamp templates card-tables --styled type=bool FLAG basecamp templates card-tables --todolist type=string FLAG basecamp templates card-tables --verbose type=count +FLAG basecamp templates card-tables archive --account type=string +FLAG basecamp templates card-tables archive --agent type=bool +FLAG basecamp templates card-tables archive --cache-dir type=string +FLAG basecamp templates card-tables archive --count type=bool +FLAG basecamp templates card-tables archive --help type=bool +FLAG basecamp templates card-tables archive --hints type=bool +FLAG basecamp templates card-tables archive --ids-only type=bool +FLAG basecamp templates card-tables archive --in type=string +FLAG basecamp templates card-tables archive --jq type=string +FLAG basecamp templates card-tables archive --json type=bool +FLAG basecamp templates card-tables archive --markdown type=bool +FLAG basecamp templates card-tables archive --md type=bool +FLAG basecamp templates card-tables archive --no-hints type=bool +FLAG basecamp templates card-tables archive --no-stats type=bool +FLAG basecamp templates card-tables archive --profile type=string +FLAG basecamp templates card-tables archive --project type=string +FLAG basecamp templates card-tables archive --quiet type=bool +FLAG basecamp templates card-tables archive --stats type=bool +FLAG basecamp templates card-tables archive --styled type=bool +FLAG basecamp templates card-tables archive --todolist type=string +FLAG basecamp templates card-tables archive --verbose type=count FLAG basecamp templates card-tables copy --account type=string FLAG basecamp templates card-tables copy --agent type=bool FLAG basecamp templates card-tables copy --cache-dir type=string @@ -13739,184 +13753,48 @@ FLAG basecamp templates card-tables list --stats type=bool FLAG basecamp templates card-tables list --styled type=bool FLAG basecamp templates card-tables list --todolist type=string FLAG basecamp templates card-tables list --verbose type=count -FLAG basecamp templates construct --account type=string -FLAG basecamp templates construct --agent type=bool -FLAG basecamp templates construct --cache-dir type=string -FLAG basecamp templates construct --count type=bool -FLAG basecamp templates construct --desc type=string -FLAG basecamp templates construct --description type=string -FLAG basecamp templates construct --help type=bool -FLAG basecamp templates construct --hints type=bool -FLAG basecamp templates construct --ids-only type=bool -FLAG basecamp templates construct --in type=string -FLAG basecamp templates construct --jq type=string -FLAG basecamp templates construct --json type=bool -FLAG basecamp templates construct --markdown type=bool -FLAG basecamp templates construct --md type=bool -FLAG basecamp templates construct --name type=string -FLAG basecamp templates construct --no-hints type=bool -FLAG basecamp templates construct --no-stats type=bool -FLAG basecamp templates construct --profile type=string -FLAG basecamp templates construct --project type=string -FLAG basecamp templates construct --quiet type=bool -FLAG basecamp templates construct --start-date type=string -FLAG basecamp templates construct --stats type=bool -FLAG basecamp templates construct --styled type=bool -FLAG basecamp templates construct --todolist type=string -FLAG basecamp templates construct --verbose type=count -FLAG basecamp templates construction --account type=string -FLAG basecamp templates construction --agent type=bool -FLAG basecamp templates construction --cache-dir type=string -FLAG basecamp templates construction --count type=bool -FLAG basecamp templates construction --help type=bool -FLAG basecamp templates construction --hints type=bool -FLAG basecamp templates construction --ids-only type=bool -FLAG basecamp templates construction --in type=string -FLAG basecamp templates construction --jq type=string -FLAG basecamp templates construction --json type=bool -FLAG basecamp templates construction --markdown type=bool -FLAG basecamp templates construction --md type=bool -FLAG basecamp templates construction --no-hints type=bool -FLAG basecamp templates construction --no-stats type=bool -FLAG basecamp templates construction --profile type=string -FLAG basecamp templates construction --project type=string -FLAG basecamp templates construction --quiet type=bool -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 -FLAG basecamp templates create --count type=bool -FLAG basecamp templates create --desc type=string -FLAG basecamp templates create --description type=string -FLAG basecamp templates create --help type=bool -FLAG basecamp templates create --hints type=bool -FLAG basecamp templates create --ids-only type=bool -FLAG basecamp templates create --in type=string -FLAG basecamp templates create --jq type=string -FLAG basecamp templates create --json type=bool -FLAG basecamp templates create --markdown type=bool -FLAG basecamp templates create --md type=bool -FLAG basecamp templates create --name type=string -FLAG basecamp templates create --no-hints type=bool -FLAG basecamp templates create --no-stats type=bool -FLAG basecamp templates create --profile type=string -FLAG basecamp templates create --project type=string -FLAG basecamp templates create --quiet type=bool -FLAG basecamp templates create --stats type=bool -FLAG basecamp templates create --styled type=bool -FLAG basecamp templates create --todolist type=string -FLAG basecamp templates create --verbose type=count -FLAG basecamp templates delete --account type=string -FLAG basecamp templates delete --agent type=bool -FLAG basecamp templates delete --cache-dir type=string -FLAG basecamp templates delete --count type=bool -FLAG basecamp templates delete --help type=bool -FLAG basecamp templates delete --hints type=bool -FLAG basecamp templates delete --ids-only type=bool -FLAG basecamp templates delete --in type=string -FLAG basecamp templates delete --jq type=string -FLAG basecamp templates delete --json type=bool -FLAG basecamp templates delete --markdown type=bool -FLAG basecamp templates delete --md type=bool -FLAG basecamp templates delete --no-hints type=bool -FLAG basecamp templates delete --no-stats type=bool -FLAG basecamp templates delete --profile type=string -FLAG basecamp templates delete --project type=string -FLAG basecamp templates delete --quiet type=bool -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 -FLAG basecamp templates list --count type=bool -FLAG basecamp templates list --help type=bool -FLAG basecamp templates list --hints type=bool -FLAG basecamp templates list --ids-only type=bool -FLAG basecamp templates list --in type=string -FLAG basecamp templates list --jq type=string -FLAG basecamp templates list --json type=bool -FLAG basecamp templates list --markdown type=bool -FLAG basecamp templates list --md type=bool -FLAG basecamp templates list --no-hints type=bool -FLAG basecamp templates list --no-stats type=bool -FLAG basecamp templates list --profile type=string -FLAG basecamp templates list --project type=string -FLAG basecamp templates list --quiet type=bool -FLAG basecamp templates list --stats type=bool -FLAG basecamp templates list --status type=string -FLAG basecamp templates list --styled type=bool -FLAG basecamp templates list --todolist type=string -FLAG basecamp templates list --verbose type=count +FLAG basecamp templates card-tables restore --account type=string +FLAG basecamp templates card-tables restore --agent type=bool +FLAG basecamp templates card-tables restore --cache-dir type=string +FLAG basecamp templates card-tables restore --count type=bool +FLAG basecamp templates card-tables restore --help type=bool +FLAG basecamp templates card-tables restore --hints type=bool +FLAG basecamp templates card-tables restore --ids-only type=bool +FLAG basecamp templates card-tables restore --in type=string +FLAG basecamp templates card-tables restore --jq type=string +FLAG basecamp templates card-tables restore --json type=bool +FLAG basecamp templates card-tables restore --markdown type=bool +FLAG basecamp templates card-tables restore --md type=bool +FLAG basecamp templates card-tables restore --no-hints type=bool +FLAG basecamp templates card-tables restore --no-stats type=bool +FLAG basecamp templates card-tables restore --profile type=string +FLAG basecamp templates card-tables restore --project type=string +FLAG basecamp templates card-tables restore --quiet type=bool +FLAG basecamp templates card-tables restore --stats type=bool +FLAG basecamp templates card-tables restore --styled type=bool +FLAG basecamp templates card-tables restore --todolist type=string +FLAG basecamp templates card-tables restore --verbose type=count +FLAG basecamp templates card-tables trash --account type=string +FLAG basecamp templates card-tables trash --agent type=bool +FLAG basecamp templates card-tables trash --cache-dir type=string +FLAG basecamp templates card-tables trash --count type=bool +FLAG basecamp templates card-tables trash --help type=bool +FLAG basecamp templates card-tables trash --hints type=bool +FLAG basecamp templates card-tables trash --ids-only type=bool +FLAG basecamp templates card-tables trash --in type=string +FLAG basecamp templates card-tables trash --jq type=string +FLAG basecamp templates card-tables trash --json type=bool +FLAG basecamp templates card-tables trash --markdown type=bool +FLAG basecamp templates card-tables trash --md type=bool +FLAG basecamp templates card-tables trash --no-hints type=bool +FLAG basecamp templates card-tables trash --no-stats type=bool +FLAG basecamp templates card-tables trash --profile type=string +FLAG basecamp templates card-tables trash --project type=string +FLAG basecamp templates card-tables trash --quiet type=bool +FLAG basecamp templates card-tables trash --stats type=bool +FLAG basecamp templates card-tables trash --styled type=bool +FLAG basecamp templates card-tables trash --todolist type=string +FLAG basecamp templates card-tables trash --verbose type=count FLAG basecamp templates projects --account type=string FLAG basecamp templates projects --agent type=bool FLAG basecamp templates projects --cache-dir type=string @@ -14096,27 +13974,6 @@ FLAG basecamp templates projects update --stats type=bool FLAG basecamp templates projects update --styled type=bool FLAG basecamp templates projects update --todolist type=string FLAG basecamp templates projects update --verbose type=count -FLAG basecamp templates show --account type=string -FLAG basecamp templates show --agent type=bool -FLAG basecamp templates show --cache-dir type=string -FLAG basecamp templates show --count type=bool -FLAG basecamp templates show --help type=bool -FLAG basecamp templates show --hints type=bool -FLAG basecamp templates show --ids-only type=bool -FLAG basecamp templates show --in type=string -FLAG basecamp templates show --jq type=string -FLAG basecamp templates show --json type=bool -FLAG basecamp templates show --markdown type=bool -FLAG basecamp templates show --md type=bool -FLAG basecamp templates show --no-hints type=bool -FLAG basecamp templates show --no-stats type=bool -FLAG basecamp templates show --profile type=string -FLAG basecamp templates show --project type=string -FLAG basecamp templates show --quiet type=bool -FLAG basecamp templates show --stats type=bool -FLAG basecamp templates show --styled type=bool -FLAG basecamp templates show --todolist type=string -FLAG basecamp templates show --verbose type=count FLAG basecamp templates todolists --account type=string FLAG basecamp templates todolists --agent type=bool FLAG basecamp templates todolists --cache-dir type=string @@ -14138,6 +13995,27 @@ FLAG basecamp templates todolists --stats type=bool FLAG basecamp templates todolists --styled type=bool FLAG basecamp templates todolists --todolist type=string FLAG basecamp templates todolists --verbose type=count +FLAG basecamp templates todolists archive --account type=string +FLAG basecamp templates todolists archive --agent type=bool +FLAG basecamp templates todolists archive --cache-dir type=string +FLAG basecamp templates todolists archive --count type=bool +FLAG basecamp templates todolists archive --help type=bool +FLAG basecamp templates todolists archive --hints type=bool +FLAG basecamp templates todolists archive --ids-only type=bool +FLAG basecamp templates todolists archive --in type=string +FLAG basecamp templates todolists archive --jq type=string +FLAG basecamp templates todolists archive --json type=bool +FLAG basecamp templates todolists archive --markdown type=bool +FLAG basecamp templates todolists archive --md type=bool +FLAG basecamp templates todolists archive --no-hints type=bool +FLAG basecamp templates todolists archive --no-stats type=bool +FLAG basecamp templates todolists archive --profile type=string +FLAG basecamp templates todolists archive --project type=string +FLAG basecamp templates todolists archive --quiet type=bool +FLAG basecamp templates todolists archive --stats type=bool +FLAG basecamp templates todolists archive --styled type=bool +FLAG basecamp templates todolists archive --todolist type=string +FLAG basecamp templates todolists archive --verbose type=count FLAG basecamp templates todolists copy --account type=string FLAG basecamp templates todolists copy --agent type=bool FLAG basecamp templates todolists copy --cache-dir type=string @@ -14271,30 +14149,48 @@ FLAG basecamp templates todolists list --stats type=bool FLAG basecamp templates todolists list --styled type=bool FLAG basecamp templates todolists list --todolist type=string FLAG basecamp templates todolists list --verbose type=count -FLAG basecamp templates update --account type=string -FLAG basecamp templates update --agent type=bool -FLAG basecamp templates update --cache-dir type=string -FLAG basecamp templates update --count type=bool -FLAG basecamp templates update --desc type=string -FLAG basecamp templates update --description type=string -FLAG basecamp templates update --help type=bool -FLAG basecamp templates update --hints type=bool -FLAG basecamp templates update --ids-only type=bool -FLAG basecamp templates update --in type=string -FLAG basecamp templates update --jq type=string -FLAG basecamp templates update --json type=bool -FLAG basecamp templates update --markdown type=bool -FLAG basecamp templates update --md type=bool -FLAG basecamp templates update --name type=string -FLAG basecamp templates update --no-hints type=bool -FLAG basecamp templates update --no-stats type=bool -FLAG basecamp templates update --profile type=string -FLAG basecamp templates update --project type=string -FLAG basecamp templates update --quiet type=bool -FLAG basecamp templates update --stats type=bool -FLAG basecamp templates update --styled type=bool -FLAG basecamp templates update --todolist type=string -FLAG basecamp templates update --verbose type=count +FLAG basecamp templates todolists restore --account type=string +FLAG basecamp templates todolists restore --agent type=bool +FLAG basecamp templates todolists restore --cache-dir type=string +FLAG basecamp templates todolists restore --count type=bool +FLAG basecamp templates todolists restore --help type=bool +FLAG basecamp templates todolists restore --hints type=bool +FLAG basecamp templates todolists restore --ids-only type=bool +FLAG basecamp templates todolists restore --in type=string +FLAG basecamp templates todolists restore --jq type=string +FLAG basecamp templates todolists restore --json type=bool +FLAG basecamp templates todolists restore --markdown type=bool +FLAG basecamp templates todolists restore --md type=bool +FLAG basecamp templates todolists restore --no-hints type=bool +FLAG basecamp templates todolists restore --no-stats type=bool +FLAG basecamp templates todolists restore --profile type=string +FLAG basecamp templates todolists restore --project type=string +FLAG basecamp templates todolists restore --quiet type=bool +FLAG basecamp templates todolists restore --stats type=bool +FLAG basecamp templates todolists restore --styled type=bool +FLAG basecamp templates todolists restore --todolist type=string +FLAG basecamp templates todolists restore --verbose type=count +FLAG basecamp templates todolists trash --account type=string +FLAG basecamp templates todolists trash --agent type=bool +FLAG basecamp templates todolists trash --cache-dir type=string +FLAG basecamp templates todolists trash --count type=bool +FLAG basecamp templates todolists trash --help type=bool +FLAG basecamp templates todolists trash --hints type=bool +FLAG basecamp templates todolists trash --ids-only type=bool +FLAG basecamp templates todolists trash --in type=string +FLAG basecamp templates todolists trash --jq type=string +FLAG basecamp templates todolists trash --json type=bool +FLAG basecamp templates todolists trash --markdown type=bool +FLAG basecamp templates todolists trash --md type=bool +FLAG basecamp templates todolists trash --no-hints type=bool +FLAG basecamp templates todolists trash --no-stats type=bool +FLAG basecamp templates todolists trash --profile type=string +FLAG basecamp templates todolists trash --project type=string +FLAG basecamp templates todolists trash --quiet type=bool +FLAG basecamp templates todolists trash --stats type=bool +FLAG basecamp templates todolists trash --styled type=bool +FLAG basecamp templates todolists trash --todolist type=string +FLAG basecamp templates todolists trash --verbose type=count FLAG basecamp timeline --account type=string FLAG basecamp timeline --agent type=bool FLAG basecamp timeline --all type=bool @@ -19414,20 +19310,15 @@ SUB basecamp subscriptions subscribe SUB basecamp subscriptions unsubscribe SUB basecamp templates SUB basecamp templates card-tables +SUB basecamp templates card-tables archive SUB basecamp templates card-tables copy SUB basecamp templates card-tables copy-status SUB basecamp templates card-tables create SUB basecamp templates card-tables duplicate SUB basecamp templates card-tables duplication SUB basecamp templates card-tables list -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 card-tables restore +SUB basecamp templates card-tables trash SUB basecamp templates projects SUB basecamp templates projects construct SUB basecamp templates projects construction @@ -19436,15 +19327,16 @@ SUB basecamp templates projects delete SUB basecamp templates projects list SUB basecamp templates projects show SUB basecamp templates projects update -SUB basecamp templates show SUB basecamp templates todolists +SUB basecamp templates todolists archive SUB basecamp templates todolists copy SUB basecamp templates todolists copy-status SUB basecamp templates todolists create SUB basecamp templates todolists duplicate SUB basecamp templates todolists duplication SUB basecamp templates todolists list -SUB basecamp templates update +SUB basecamp templates todolists restore +SUB basecamp templates todolists trash SUB basecamp timeline SUB basecamp timesheet SUB basecamp timesheet item diff --git a/.surface-breaking b/.surface-breaking index 840004f60..4fe732e4a 100644 --- a/.surface-breaking +++ b/.surface-breaking @@ -17,6 +17,15 @@ ARG basecamp message 00 <title> ARG basecamp message 01 [body] ARG basecamp react 00 <content> ARG basecamp reopen 00 <id|url>... +ARG basecamp templates construct 00 <template_id> +ARG basecamp templates construction 00 <template_id> +ARG basecamp templates construction 01 <construction_id> +ARG basecamp templates copy 00 <template_id> +ARG basecamp templates copy-status 00 <copy_id> +ARG basecamp templates create 00 <name> +ARG basecamp templates delete 00 <id> +ARG basecamp templates show 00 <id> +ARG basecamp templates update 00 <id> ARG basecamp todo 00 <content> ARG basecamp unassign 00 <id> ARG basecamp unassign 00 <todo_id> @@ -70,6 +79,16 @@ CMD basecamp mcp CMD basecamp message CMD basecamp react CMD basecamp reopen +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 CMD basecamp todo CMD basecamp upload archive CMD basecamp upload doc @@ -676,12 +695,235 @@ FLAG basecamp reopen --todolist type=string FLAG basecamp reopen --verbose type=count FLAG basecamp reopen --version type=bool FLAG basecamp templates --status type=string +FLAG basecamp templates construct --account type=string +FLAG basecamp templates construct --agent type=bool +FLAG basecamp templates construct --cache-dir type=string +FLAG basecamp templates construct --count type=bool +FLAG basecamp templates construct --desc type=string +FLAG basecamp templates construct --description type=string +FLAG basecamp templates construct --help type=bool +FLAG basecamp templates construct --hints type=bool +FLAG basecamp templates construct --ids-only type=bool +FLAG basecamp templates construct --in type=string +FLAG basecamp templates construct --jq type=string +FLAG basecamp templates construct --json type=bool +FLAG basecamp templates construct --markdown type=bool +FLAG basecamp templates construct --md type=bool +FLAG basecamp templates construct --name type=string +FLAG basecamp templates construct --no-hints type=bool +FLAG basecamp templates construct --no-stats type=bool +FLAG basecamp templates construct --profile type=string +FLAG basecamp templates construct --project type=string +FLAG basecamp templates construct --quiet type=bool +FLAG basecamp templates construct --start-date type=string +FLAG basecamp templates construct --stats type=bool FLAG basecamp templates construct --status type=string +FLAG basecamp templates construct --styled type=bool +FLAG basecamp templates construct --todolist type=string +FLAG basecamp templates construct --verbose type=count +FLAG basecamp templates construction --account type=string +FLAG basecamp templates construction --agent type=bool +FLAG basecamp templates construction --cache-dir type=string +FLAG basecamp templates construction --count type=bool +FLAG basecamp templates construction --help type=bool +FLAG basecamp templates construction --hints type=bool +FLAG basecamp templates construction --ids-only type=bool +FLAG basecamp templates construction --in type=string +FLAG basecamp templates construction --jq type=string +FLAG basecamp templates construction --json type=bool +FLAG basecamp templates construction --markdown type=bool +FLAG basecamp templates construction --md type=bool +FLAG basecamp templates construction --no-hints type=bool +FLAG basecamp templates construction --no-stats type=bool +FLAG basecamp templates construction --profile type=string +FLAG basecamp templates construction --project type=string +FLAG basecamp templates construction --quiet type=bool +FLAG basecamp templates construction --stats type=bool FLAG basecamp templates construction --status type=string +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 +FLAG basecamp templates create --count type=bool +FLAG basecamp templates create --desc type=string +FLAG basecamp templates create --description type=string +FLAG basecamp templates create --help type=bool +FLAG basecamp templates create --hints type=bool +FLAG basecamp templates create --ids-only type=bool +FLAG basecamp templates create --in type=string +FLAG basecamp templates create --jq type=string +FLAG basecamp templates create --json type=bool +FLAG basecamp templates create --markdown type=bool +FLAG basecamp templates create --md type=bool +FLAG basecamp templates create --name type=string +FLAG basecamp templates create --no-hints type=bool +FLAG basecamp templates create --no-stats type=bool +FLAG basecamp templates create --profile type=string +FLAG basecamp templates create --project type=string +FLAG basecamp templates create --quiet type=bool +FLAG basecamp templates create --stats type=bool FLAG basecamp templates create --status type=string +FLAG basecamp templates create --styled type=bool +FLAG basecamp templates create --todolist type=string +FLAG basecamp templates create --verbose type=count +FLAG basecamp templates delete --account type=string +FLAG basecamp templates delete --agent type=bool +FLAG basecamp templates delete --cache-dir type=string +FLAG basecamp templates delete --count type=bool +FLAG basecamp templates delete --help type=bool +FLAG basecamp templates delete --hints type=bool +FLAG basecamp templates delete --ids-only type=bool +FLAG basecamp templates delete --in type=string +FLAG basecamp templates delete --jq type=string +FLAG basecamp templates delete --json type=bool +FLAG basecamp templates delete --markdown type=bool +FLAG basecamp templates delete --md type=bool +FLAG basecamp templates delete --no-hints type=bool +FLAG basecamp templates delete --no-stats type=bool +FLAG basecamp templates delete --profile type=string +FLAG basecamp templates delete --project type=string +FLAG basecamp templates delete --quiet type=bool +FLAG basecamp templates delete --stats type=bool FLAG basecamp templates delete --status type=string +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 +FLAG basecamp templates list --count type=bool +FLAG basecamp templates list --help type=bool +FLAG basecamp templates list --hints type=bool +FLAG basecamp templates list --ids-only type=bool +FLAG basecamp templates list --in type=string +FLAG basecamp templates list --jq type=string +FLAG basecamp templates list --json type=bool +FLAG basecamp templates list --markdown type=bool +FLAG basecamp templates list --md type=bool +FLAG basecamp templates list --no-hints type=bool +FLAG basecamp templates list --no-stats type=bool +FLAG basecamp templates list --profile type=string +FLAG basecamp templates list --project type=string +FLAG basecamp templates list --quiet type=bool +FLAG basecamp templates list --stats type=bool +FLAG basecamp templates list --status type=string +FLAG basecamp templates list --styled type=bool +FLAG basecamp templates list --todolist type=string +FLAG basecamp templates list --verbose type=count +FLAG basecamp templates show --account type=string +FLAG basecamp templates show --agent type=bool +FLAG basecamp templates show --cache-dir type=string +FLAG basecamp templates show --count type=bool +FLAG basecamp templates show --help type=bool +FLAG basecamp templates show --hints type=bool +FLAG basecamp templates show --ids-only type=bool +FLAG basecamp templates show --in type=string +FLAG basecamp templates show --jq type=string +FLAG basecamp templates show --json type=bool +FLAG basecamp templates show --markdown type=bool +FLAG basecamp templates show --md type=bool +FLAG basecamp templates show --no-hints type=bool +FLAG basecamp templates show --no-stats type=bool +FLAG basecamp templates show --profile type=string +FLAG basecamp templates show --project type=string +FLAG basecamp templates show --quiet type=bool +FLAG basecamp templates show --stats type=bool FLAG basecamp templates show --status type=string +FLAG basecamp templates show --styled type=bool +FLAG basecamp templates show --todolist type=string +FLAG basecamp templates show --verbose type=count +FLAG basecamp templates update --account type=string +FLAG basecamp templates update --agent type=bool +FLAG basecamp templates update --cache-dir type=string +FLAG basecamp templates update --count type=bool +FLAG basecamp templates update --desc type=string +FLAG basecamp templates update --description type=string +FLAG basecamp templates update --help type=bool +FLAG basecamp templates update --hints type=bool +FLAG basecamp templates update --ids-only type=bool +FLAG basecamp templates update --in type=string +FLAG basecamp templates update --jq type=string +FLAG basecamp templates update --json type=bool +FLAG basecamp templates update --markdown type=bool +FLAG basecamp templates update --md type=bool +FLAG basecamp templates update --name type=string +FLAG basecamp templates update --no-hints type=bool +FLAG basecamp templates update --no-stats type=bool +FLAG basecamp templates update --profile type=string +FLAG basecamp templates update --project type=string +FLAG basecamp templates update --quiet type=bool +FLAG basecamp templates update --stats type=bool FLAG basecamp templates update --status type=string +FLAG basecamp templates update --styled type=bool +FLAG basecamp templates update --todolist type=string +FLAG basecamp templates update --verbose type=count FLAG basecamp timeline --interval type=int FLAG basecamp timeline --watch type=bool FLAG basecamp tlgroup create --name type=string @@ -1009,6 +1251,16 @@ SUB basecamp mcp SUB basecamp message SUB basecamp react SUB basecamp reopen +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 SUB basecamp todo SUB basecamp upload archive SUB basecamp upload doc diff --git a/STYLE.md b/STYLE.md index 57933f538..f09166f37 100644 --- a/STYLE.md +++ b/STYLE.md @@ -40,6 +40,15 @@ the canonical `<group> <action>` form (`cards create`, `todos create`, `todos complete`, etc.). Introducing a new top-level verb that shadows an existing group noun is not allowed. +Where a group is split by the kind of thing it holds, every verb lives under the +kind it acts on. `templates` has `projects`, `todolists`, and `card-tables`; the +flat pre-grouping spellings (`templates list`, `templates show`, `templates +create`, `templates update`, `templates delete`, `templates construct`, +`templates construction`, `templates library`, `templates copy`, +`templates copy-status`) have been removed. Use `templates projects delete`, +never `templates delete` — a flat verb cannot say which kind it acts on, and the +three kinds hit different endpoints. + Shortcut commands without a sibling plural group — `search`, `url`, `recordings`, `timesheet`, `assignments`, `notifications`, `setup`, `completion` — may have both `RunE` and subcommands. `scripts/check-bare-groups.sh` enforces diff --git a/e2e/smoke/smoke_account.bats b/e2e/smoke/smoke_account.bats index 79f2bbba8..44abd54cf 100644 --- a/e2e/smoke/smoke_account.bats +++ b/e2e/smoke/smoke_account.bats @@ -15,14 +15,14 @@ setup_file() { assert_json_not_null '.data[0].id' } -@test "templates list returns templates" { - run_smoke basecamp templates list --json +@test "templates projects list returns templates" { + run_smoke basecamp templates projects list --json assert_success assert_json_value '.ok' 'true' } -@test "templates library returns to-do list templates" { - run_smoke basecamp templates library --json +@test "templates todolists list returns to-do list templates" { + run_smoke basecamp templates todolists list --json [[ "$status" -ne 0 ]] && mark_unverifiable "Template library not available in this account" assert_success assert_json_value '.ok' 'true' @@ -48,14 +48,14 @@ setup_file() { assert_json_not_null '.data.id' } -@test "templates show returns template detail" { +@test "templates projects show returns template detail" { local out - out=$(basecamp templates list --json 2>/dev/null) || mark_unverifiable "Cannot list templates" + out=$(basecamp templates projects list --json 2>/dev/null) || mark_unverifiable "Cannot list templates" local tmpl_id tmpl_id=$(echo "$out" | jq -r '.data[0].id // empty') [[ -n "$tmpl_id" ]] || mark_unverifiable "No templates found" - run_smoke basecamp templates show "$tmpl_id" --json + run_smoke basecamp templates projects show "$tmpl_id" --json assert_success assert_json_value '.ok' 'true' assert_json_not_null '.data.id' diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index e36cc42be..761439019 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -93,34 +93,6 @@ load smoke_helper # --- Account-wide / dangerous mutations --- -@test "templates construct is out of scope" { - mark_out_of_scope "Creates project from template — account-wide mutation" -} - -@test "templates construction is out of scope" { - 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" -} - -@test "templates update is out of scope" { - mark_out_of_scope "Account-wide template mutation" -} - -@test "templates delete is out of scope" { - mark_out_of_scope "Account-wide template mutation" -} - @test "templates projects construct is out of scope" { mark_out_of_scope "Creates project from template — account-wide mutation" } @@ -157,6 +129,18 @@ load smoke_helper mark_out_of_scope "Alias of templates todolists duplication (OOS)" } +@test "templates todolists archive is out of scope" { + mark_out_of_scope "Takes a template out of the account library" +} + +@test "templates todolists trash is out of scope" { + mark_out_of_scope "Takes a template out of the account library" +} + +@test "templates todolists restore is out of scope" { + mark_out_of_scope "Account-wide template mutation" +} + @test "templates card-tables create is out of scope" { mark_out_of_scope "Account-wide template mutation" } @@ -177,6 +161,18 @@ load smoke_helper mark_out_of_scope "Alias of templates card-tables duplication (OOS)" } +@test "templates card-tables archive is out of scope" { + mark_out_of_scope "Takes a template out of the account library" +} + +@test "templates card-tables trash is out of scope" { + mark_out_of_scope "Takes a template out of the account library" +} + +@test "templates card-tables restore is out of scope" { + mark_out_of_scope "Account-wide template mutation" +} + @test "templates todolists create is out of scope" { mark_out_of_scope "Account-wide template mutation" } diff --git a/e2e/templates.bats b/e2e/templates.bats index d344daab9..9ccb9bf8d 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -15,11 +15,11 @@ load test_helper # Show errors -@test "templates show without id shows error" { +@test "templates projects show without id shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates show + run basecamp templates projects show assert_failure assert_output_contains "ID required" } @@ -27,30 +27,30 @@ load test_helper # Create errors -@test "templates create without name shows error" { +@test "templates projects create without name shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates create + run basecamp templates projects create assert_failure assert_json_value '.error' '<name> required' assert_json_value '.code' 'usage' } -@test "templates create --name without value shows error" { +@test "templates projects create --name without value shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates create --name + run basecamp templates projects create --name assert_failure assert_output_contains "--name requires a value" } -@test "templates create --description without value shows error" { +@test "templates projects create --description without value shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates create "Test" --description + run basecamp templates projects create "Test" --description assert_failure assert_output_contains "--description requires a value" } @@ -58,20 +58,20 @@ load test_helper # Update errors -@test "templates update without id shows error" { +@test "templates projects update without id shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates update + run basecamp templates projects update assert_failure assert_output_contains "ID required" } -@test "templates update without fields shows error" { +@test "templates projects update without fields shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates update 123 + run basecamp templates projects update 123 assert_failure assert_output_contains "No update fields specified" } @@ -79,11 +79,11 @@ load test_helper # Delete errors -@test "templates delete without id shows error" { +@test "templates projects delete without id shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates delete + run basecamp templates projects delete assert_failure assert_output_contains "ID required" } @@ -91,29 +91,20 @@ load test_helper # Construct errors -@test "templates construct without template id shows error" { +@test "templates projects construct without template id shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates construct + run basecamp templates projects construct assert_failure assert_output_contains "ID required" } -@test "templates construct without project name shows error" { +@test "templates projects construct with malformed start date shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates construct 123 - assert_failure - assert_output_contains "name required" -} - -@test "templates construct with malformed start date shows error" { - create_credentials - create_global_config '{"account_id": 99999}' - - run basecamp templates construct 123 --name "Project" --start-date someday + run basecamp templates projects construct 123 --name "Project" --start-date someday assert_failure assert_output_contains "Invalid start date" } @@ -121,20 +112,20 @@ load test_helper # Construction status errors -@test "templates construction without template id shows error" { +@test "templates projects construction without template id shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates construction + run basecamp templates projects construction assert_failure assert_output_contains "ID required" } -@test "templates construction without construction id shows error" { +@test "templates projects construction without construction id shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates construction 123 + run basecamp templates projects construction 123 # Cobra returns "accepts 2 arg(s)" error assert_failure } @@ -142,26 +133,17 @@ 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" { +@test "templates todolists duplication without copy id shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates copy-status + run basecamp templates todolists duplication assert_failure assert_output_contains "ID required" } -@test "templates copy --confirm-adding-people is accepted" { - run basecamp templates copy --help +@test "templates todolists duplicate --confirm-adding-people is accepted" { + run basecamp templates todolists duplicate --help assert_success assert_output_contains "--confirm-adding-people" } @@ -169,24 +151,15 @@ load test_helper # Flag parsing -@test "templates list --status without value shows error" { +@test "templates projects list --status without value shows error" { create_credentials create_global_config '{"account_id": 99999}' - run basecamp templates list --status + run basecamp templates projects list --status assert_failure assert_output_contains "--status requires a value" } -@test "templates list --status with invalid value shows error" { - create_credentials - create_global_config '{"account_id": 99999}' - - run basecamp templates list --status bogus - assert_failure - assert_output_contains "unknown --status value" -} - # Help @@ -197,10 +170,9 @@ load test_helper run basecamp templates --help assert_success assert_output_contains "basecamp templates" - assert_output_contains "construct" - assert_output_contains "construction" - assert_output_contains "library" - assert_output_contains "copy-status" + assert_output_contains "projects" + assert_output_contains "todolists" + assert_output_contains "card-tables" } diff --git a/internal/commands/stdin_integration_test.go b/internal/commands/stdin_integration_test.go index 3477cf881..5599a610c 100644 --- a/internal/commands/stdin_integration_test.go +++ b/internal/commands/stdin_integration_test.go @@ -278,8 +278,8 @@ func TestStdinResolvesBeforeAccountAndProject(t *testing.T) { {"schedule create --description", NewScheduleCmd, []string{ "create", "Title", "--starts-at", "2026-01-01T10:00:00Z", "--ends-at", "2026-01-01T11:00:00Z", "--description", "-", }}, - {"templates update --description", NewTemplatesCmd, []string{"update", "1", "--description", "-"}}, - {"templates construct --description", NewTemplatesCmd, []string{"construct", "1", "--name", "P", "--description", "-"}}, + {"templates projects update --description", NewTemplatesCmd, []string{"projects", "update", "1", "--description", "-"}}, + {"templates projects construct --description", NewTemplatesCmd, []string{"projects", "construct", "1", "--name", "P", "--description", "-"}}, } { t.Run(tc.name, func(t *testing.T) { transport := &countingTransport{} @@ -340,8 +340,8 @@ func TestMalformedIDRejectedBeforeReadingStdin(t *testing.T) { {"cards update", NewCardsCmd, []string{"update", "nope", "--body", "-"}}, {"cards column update", NewCardsCmd, []string{"column", "update", "nope", "--description", "-"}}, {"gauges update", NewGaugesCmd, []string{"update", "nope", "--description", "-"}}, - {"templates update", NewTemplatesCmd, []string{"update", "nope", "--description", "-"}}, - {"templates construct", NewTemplatesCmd, []string{"construct", "nope", "--name", "P", "--description", "-"}}, + {"templates projects update", NewTemplatesCmd, []string{"projects", "update", "nope", "--description", "-"}}, + {"templates projects construct", NewTemplatesCmd, []string{"projects", "construct", "nope", "--name", "P", "--description", "-"}}, {"messages update", NewMessagesCmd, []string{"update", "nope", "--body", "-"}}, {"todos update", NewTodosCmd, []string{"update", "nope", "--description", "-"}}, {"todolists update", NewTodolistsCmd, []string{"update", "nope", "--description", "-"}}, diff --git a/internal/commands/templates.go b/internal/commands/templates.go index d4ffb5fdf..9a2233d4c 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -1,6 +1,7 @@ package commands import ( + "context" "errors" "fmt" "strconv" @@ -27,8 +28,8 @@ func NewTemplatesCmd() *cobra.Command { todolists To-do list templates: duplicate a reusable list into a project. card-tables Card table templates: duplicate a reusable board into a project. -The pre-grouping spellings (templates list, templates library, templates copy, -and the rest) still work and resolve to the same commands.`, +Every verb lives under the kind it acts on: templates projects delete, not +templates delete.`, Annotations: map[string]string{"agent_notes": "Project construction and to-do list or card table template duplication are asynchronous. Poll templates projects construction or templates <kind> duplication until status=completed. construct --start-date anchors the template's relative dates to the Sunday of that week; without it they anchor to the week of construction. Duplicating grants referenced people project access only with --confirm-adding-people."}, } @@ -37,7 +38,6 @@ and the rest) still work and resolve to the same commands.`, newTemplatesTodolistsCmd(), newTemplatesCardTablesCmd(), ) - cmd.AddCommand(templatesBackCompatCmds()...) return cmd } @@ -78,34 +78,16 @@ existing project's To-dos tool.`, } cmd.AddCommand( - newTemplatesLibraryListCmd("list"), + newTemplatesLibraryListCmd(), newTemplatesTodolistsCreateCmd(), newTemplatesDuplicateCmd(kind, "duplicate <template_id>", "copy"), newTemplatesDuplicationCmd(kind, "duplication <duplication_id>", "copy-status"), ) + cmd.AddCommand(newTemplatesLibraryStatusCmds("todolists", "to-do list")...) return cmd } -func templatesBackCompatCmds() []*cobra.Command { - kind := todolistTemplateKind() - - // Registered a second time rather than aliased: cobra aliases rename a - // command where it sits, and these moved a level down the tree. - return []*cobra.Command{ - newTemplatesListCmd(), - newTemplatesShowCmd(), - newTemplatesCreateCmd(), - newTemplatesUpdateCmd(), - newTemplatesDeleteCmd(), - newTemplatesConstructCmd(), - newTemplatesConstructionCmd(), - newTemplatesLibraryListCmd("library"), - newTemplatesDuplicateCmd(kind, "copy <template_id>"), - newTemplatesDuplicationCmd(kind, "copy-status <copy_id>"), - } -} - func newTemplatesCardTablesCmd() *cobra.Command { kind := cardTableTemplateKind() @@ -124,6 +106,7 @@ adds it to an existing project.`, newTemplatesDuplicateCmd(kind, "duplicate <template_id>", "copy"), newTemplatesDuplicationCmd(kind, "duplication <duplication_id>", "copy-status"), ) + cmd.AddCommand(newTemplatesLibraryStatusCmds("card-tables", "card table")...) return cmd } @@ -459,9 +442,9 @@ func runTemplatesList(cmd *cobra.Command, status string) error { ) } -func newTemplatesLibraryListCmd(use string) *cobra.Command { +func newTemplatesLibraryListCmd() *cobra.Command { return &cobra.Command{ - Use: use, + Use: "list", Short: "List to-do list templates", Long: "List the account's active to-do list templates.", RunE: func(cmd *cobra.Command, args []string) error { @@ -505,6 +488,107 @@ func newTemplatesLibraryListCmd(use string) *cobra.Command { } } +// templateStatusChange is one of the three status changes a library template +// can undergo, bundled so the run helper needs no dispatch of its own. +type templateStatusChange struct { + state string // status the template ends up in + past string // summary verb + apply func(*basecamp.RecordingsService, context.Context, int64) error +} + +func newTemplatesLibraryStatusCmds(group, noun string) []*cobra.Command { + archive := templateStatusChange{state: "archived", past: "Archived", apply: (*basecamp.RecordingsService).Archive} + trash := templateStatusChange{state: "trashed", past: "Trashed", apply: (*basecamp.RecordingsService).Trash} + restore := templateStatusChange{state: "active", past: "Restored", apply: (*basecamp.RecordingsService).Unarchive} + + return []*cobra.Command{ + { + Use: "archive <template_id>", + Short: fmt.Sprintf("Archive a %s template", noun), + Long: fmt.Sprintf(`Archive a %s template, taking it out of the library. + +Archiving keeps the template around: restore it later with +basecamp templates %s restore <template_id>.`, noun, group), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runTemplatesLibraryStatus(cmd, group, noun, args[0], archive) + }, + }, + { + Use: "trash <template_id>", + Short: fmt.Sprintf("Move a %s template to the trash", noun), + Long: fmt.Sprintf(`Move a %s template to the trash, taking it out of the library. + +Restore it with basecamp templates %s restore <template_id> while it is +still in the trash.`, noun, group), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runTemplatesLibraryStatus(cmd, group, noun, args[0], trash) + }, + }, + { + Use: "restore <template_id>", + Short: fmt.Sprintf("Restore a %s template", noun), + Long: fmt.Sprintf("Return an archived or trashed %s template to the library.", noun), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runTemplatesLibraryStatus(cmd, group, noun, args[0], restore) + }, + }, + } +} + +func runTemplatesLibraryStatus(cmd *cobra.Command, group, noun, templateIDArg string, change templateStatusChange) error { + 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, + ) + + templateID, err := strconv.ParseInt(templateIDArg, 10, 64) + if err != nil { + return output.ErrUsage("Invalid template ID") + } + + // A library template is a recording, so its status changes go through the + // recordings endpoint; the project-template routes 404 for one. + if err := change.apply(app.Account().Recordings(), cmd.Context(), templateID); err != nil { + return convertSDKError(err) + } + + return app.OK(map[string]any{"id": templateID, "status": change.state}, + output.WithSummary(fmt.Sprintf("%s %s template #%d", change.past, noun, templateID)), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "list", + Cmd: fmt.Sprintf("basecamp templates %s list%s", group, contextArgs), + Description: fmt.Sprintf("List %s templates", noun), + }, + templateStatusFollowUp(group, templateID, contextArgs, change), + ), + ) +} + +func templateStatusFollowUp(group string, templateID int64, contextArgs string, change templateStatusChange) output.Breadcrumb { + if change.state == "active" { + return output.Breadcrumb{ + Action: "duplicate", + Cmd: fmt.Sprintf("basecamp templates %s duplicate %d --in <project>%s", group, templateID, contextArgs), + Description: "Duplicate the template into a project", + } + } + return output.Breadcrumb{ + Action: "restore", + Cmd: fmt.Sprintf("basecamp templates %s restore %d%s", group, templateID, contextArgs), + Description: "Put the template back in the library", + } +} + func newTemplatesDuplicateCmd(kind templateCopyKind, use string, aliases ...string) *cobra.Command { var project string var container string diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index c8a7516b0..e4c74f7e9 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -76,7 +76,7 @@ func TestTemplatesLibraryUsesCanonicalTodolistsEndpoint(t *testing.T) { app.Config.ActiveProfile = "work profile" app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} - err := executeRecordingCommand(NewTemplatesCmd(), app, "library") + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "list") require.NoError(t, err) request := transport.last(t) @@ -98,7 +98,7 @@ func TestTemplatesLibraryHumanOutputShowsCopyableID(t *testing.T) { buf := &bytes.Buffer{} app.Output = output.New(output.Options{Format: output.FormatStyled, Writer: buf}) - err := executeRecordingCommand(NewTemplatesCmd(), app, "library") + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "list") require.NoError(t, err) assert.Contains(t, buf.String(), "Project kickoff") @@ -119,7 +119,7 @@ func TestTemplatesCopyNamesTheProjectAndLetsBasecampPickTheContainer(t *testing. 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") + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "duplicate", "3", "--in", "Test Project") require.NoError(t, err) requests := transport.recorded() @@ -149,7 +149,7 @@ func TestTemplatesCopySendsExplicitPeopleConfirmation(t *testing.T) { ) err := executeRecordingCommand(NewTemplatesCmd(), app, - "copy", "3", "--project", "123", "--todoset", "9", "--confirm-adding-people") + "todolists", "duplicate", "3", "--project", "123", "--todoset", "9", "--confirm-adding-people") require.NoError(t, err) requests := transport.recorded() @@ -167,7 +167,7 @@ func TestTemplatesCopyRejectsTodosetFromAnotherProject(t *testing.T) { app, transport := setupRecordingTestApp(t, projectsRoute(), templateCopyTodosetRoute(456)) err := executeRecordingCommand(NewTemplatesCmd(), app, - "copy", "3", "--project", "123", "--todoset", "9") + "todolists", "duplicate", "3", "--project", "123", "--todoset", "9") require.Error(t, err) var outputErr *output.Error @@ -189,7 +189,7 @@ func TestTemplatesCopyRejectsDisabledTodoset(t *testing.T) { ) err := executeRecordingCommand(NewTemplatesCmd(), app, - "copy", "3", "--project", "123", "--todoset", "9") + "todolists", "duplicate", "3", "--project", "123", "--todoset", "9") require.Error(t, err) var outputErr *output.Error @@ -213,7 +213,7 @@ func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { 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") + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "duplicate", "3", "--in", "123") require.Error(t, err) var outputErr *output.Error @@ -239,7 +239,7 @@ func TestTemplatesConstructSendsStartDateUnderProjectEnvelope(t *testing.T) { buf := captureTemplateOutput(app) err := executeRecordingCommand(NewTemplatesCmd(), app, - "construct", "2085958507", "--name", "Marketing Campaign", + "projects", "construct", "2085958507", "--name", "Marketing Campaign", "--description", "For Client: Xyz Corp Conference", "--start-date", "2026-09-01") require.NoError(t, err) @@ -268,7 +268,7 @@ func TestTemplatesConstructOmitsStartDateWhenUnset(t *testing.T) { ) captureTemplateOutput(app) - err := executeRecordingCommand(NewTemplatesCmd(), app, "construct", "2085958507", "--name", "Marketing Campaign") + err := executeRecordingCommand(NewTemplatesCmd(), app, "projects", "construct", "2085958507", "--name", "Marketing Campaign") require.NoError(t, err) requests := transport.recorded() @@ -288,7 +288,7 @@ func TestTemplatesConstructParsesNaturalStartDate(t *testing.T) { captureTemplateOutput(app) before := time.Now() - err := executeRecordingCommand(NewTemplatesCmd(), app, "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "tomorrow") + err := executeRecordingCommand(NewTemplatesCmd(), app, "projects", "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "tomorrow") after := time.Now() require.NoError(t, err) @@ -308,7 +308,7 @@ func TestTemplatesConstructRejectsBlankStartDate(t *testing.T) { app, transport := setupRecordingTestApp(t) captureTemplateOutput(app) - err := executeRecordingCommand(NewTemplatesCmd(), app, "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "") + err := executeRecordingCommand(NewTemplatesCmd(), app, "projects", "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "") outErr := requireUsageErr(t, err) assert.Contains(t, outErr.Message, "Invalid start date") assert.Empty(t, transport.recorded()) @@ -318,7 +318,7 @@ func TestTemplatesConstructRejectsMalformedStartDateBeforeAnyRequest(t *testing. app, transport := setupRecordingTestApp(t) captureTemplateOutput(app) - err := executeRecordingCommand(NewTemplatesCmd(), app, "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "someday") + err := executeRecordingCommand(NewTemplatesCmd(), app, "projects", "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "someday") outErr := requireUsageErr(t, err) assert.Contains(t, outErr.Message, "Invalid start date") assert.Empty(t, transport.recorded()) @@ -354,7 +354,7 @@ func TestTemplatesCopyStatusStates(t *testing.T) { app.Config.ActiveProfile = "work profile" app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} - err := executeRecordingCommand(NewTemplatesCmd(), app, "copy-status", "5") + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "duplication", "5") require.NoError(t, err) request := transport.last(t) @@ -549,3 +549,63 @@ func TestCardTablesTemplatificationReportsTheBoardItMade(t *testing.T) { require.Len(t, envelope.Breadcrumbs, 1) assert.Equal(t, "basecamp templates card-tables list", envelope.Breadcrumbs[0].Cmd) } + +func templateStatusRoute(status string) stubRoute { + return stubRoute{ + method: http.MethodPut, + path: fmt.Sprintf("/99999/recordings/3/status/%s.json", status), + status: http.StatusNoContent, + } +} + +func TestTemplatesCardTablesArchiveGoesThroughTheRecordingsEndpoint(t *testing.T) { + app, transport := setupRecordingTestApp(t, templateStatusRoute("archived")) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "card-tables", "archive", "3") + require.NoError(t, err) + + request := transport.last(t) + assert.Equal(t, http.MethodPut, request.Method) + assert.Equal(t, "/99999/recordings/3/status/archived.json", request.Path, + "the project-template routes serve project templates only and 404 for a library template") + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Archived card table template #3", envelope.Summary) + require.Len(t, envelope.Breadcrumbs, 2) + assert.Equal(t, "basecamp templates card-tables restore 3", envelope.Breadcrumbs[1].Cmd) +} + +func TestTemplatesTodolistsTrashGoesThroughTheRecordingsEndpoint(t *testing.T) { + app, transport := setupRecordingTestApp(t, templateStatusRoute("trashed")) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "trash", "3") + require.NoError(t, err) + + assert.Equal(t, "/99999/recordings/3/status/trashed.json", transport.last(t).Path) + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Trashed to-do list template #3", envelope.Summary) + assert.Equal(t, "basecamp templates todolists restore 3", envelope.Breadcrumbs[1].Cmd) +} + +func TestTemplatesRestorePointsBackAtDuplicating(t *testing.T) { + app, transport := setupRecordingTestApp(t, templateStatusRoute("active")) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "todolists", "restore", "3") + require.NoError(t, err) + + assert.Equal(t, "/99999/recordings/3/status/active.json", transport.last(t).Path) + envelope := decodeTemplateEnvelope(t, buf) + assert.Equal(t, "Restored to-do list template #3", envelope.Summary) + assert.Equal(t, "basecamp templates todolists duplicate 3 --in <project>", envelope.Breadcrumbs[1].Cmd) +} + +func TestTemplatesArchiveRejectsANonNumericID(t *testing.T) { + app, transport := setupRecordingTestApp(t) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "card-tables", "archive", "kickoff") + require.Error(t, err) + + assert.Empty(t, transport.recorded(), "a bad ID is caught before any request goes out") +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index b2ebbbe8b..6587c4c24 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -987,13 +987,24 @@ basecamp templates todolists list --json # List active to-do list templ basecamp templates todolists create "List Name" # Create empty to-do list template basecamp templates todolists duplicate <template_id> --in <project> # Duplicate into To-dos (async) basecamp templates todolists duplication <id> # Check duplication status +basecamp templates todolists archive <template_id> # Take out of the library, keep it +basecamp templates todolists trash <template_id> # Move to the trash +basecamp templates todolists restore <template_id> # Put it back in the library basecamp templates card-tables list --json # List active card table templates basecamp templates card-tables create "Board Name" # Create empty card table template basecamp templates card-tables duplicate <template_id> --in <project> # Duplicate into project (async) basecamp templates card-tables duplication <id> # Check duplication status +basecamp templates card-tables archive <template_id> +basecamp templates card-tables trash <template_id> +basecamp templates card-tables restore <template_id> ``` +`archive`, `trash`, and `restore` are the library kinds' equivalent of +`templates projects delete`, which serves project templates only and 404s for a +to-do list or card table template. `list` shows active templates, so note the ID +before archiving or trashing one. + Save existing work into the library from the source group, mirroring the product, where "Save as a template..." is an action on the item itself: @@ -1017,12 +1028,13 @@ has more than one; `card-tables duplicate` has no container flag because a project has exactly one dock. To-do list templates land in the project's To-dos tool, which `--todoset` can pin. -**Older spellings still work.** The flat commands that shipped before the grouping +**Every verb lives under its kind.** The flat pre-grouping spellings (`templates list`, `templates show`, `templates create`, `templates update`, `templates delete`, `templates construct`, `templates construction`, -`templates library`, `templates copy`, `templates copy-status`) remain as aliases. -`copy` and `copy-status` also work inside `templates todolists`. Prefer the grouped, -canonical spellings above when writing new commands. +`templates library`, `templates copy`, `templates copy-status`) have been +removed — use `templates projects delete`, not `templates delete`. `copy` and +`copy-status` still work inside `templates todolists` and +`templates card-tables` as aliases of `duplicate` and `duplication`. **Asynchronous results:** `construct` returns a construction ID; poll `construction` until `status="completed"` to get the project. A template's dates are relative to its