diff --git a/.surface b/.surface index e444710f..c7ff82be 100644 --- a/.surface +++ b/.surface @@ -13442,6 +13442,7 @@ 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 diff --git a/e2e/templates.bats b/e2e/templates.bats index 92ad572e..435618c6 100644 --- a/e2e/templates.bats +++ b/e2e/templates.bats @@ -109,6 +109,15 @@ load test_helper 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 + assert_failure + assert_output_contains "Invalid start date" +} + # Construction status errors diff --git a/go.mod b/go.mod index e0947fda..93c284fe 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.9 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.17.0 + github.com/basecamp/basecamp-sdk/go v0.18.0 github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index f200b7c8..965fdfd7 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/basecamp/basecamp-sdk/go v0.17.0 h1:u2kIDtr7bmHXLSkF61HXkLDymQdwoI+pzbT0F6OwHYM= -github.com/basecamp/basecamp-sdk/go v0.17.0/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= +github.com/basecamp/basecamp-sdk/go v0.18.0 h1:A6Mu1ugnvvExNdLN4w9th6Tz2fktSdfbqbbDhxYgPPE= +github.com/basecamp/basecamp-sdk/go v0.18.0/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8= github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 45644253..a8482b3d 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -12,6 +12,7 @@ import ( "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/dateparse" "github.com/basecamp/basecamp-cli/internal/output" ) @@ -24,7 +25,7 @@ func NewTemplatesCmd() *cobra.Command { Project templates create projects with predefined structure, tools, and content. Library templates copy a reusable to-do list into an existing project.`, - Annotations: map[string]string{"agent_notes": "Project construction and to-do list template copies are asynchronous. Poll construction or copy-status until status=completed. Copy grants referenced people project access only with --confirm-adding-people."}, + 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."}, } cmd.AddCommand( @@ -629,6 +630,7 @@ func newTemplatesDeleteCmd() *cobra.Command { func newTemplatesConstructCmd() *cobra.Command { var projectName string var projectDesc string + var startDate string cmd := &cobra.Command{ Use: "construct ", @@ -636,7 +638,11 @@ func newTemplatesConstructCmd() *cobra.Command { Long: `Create a new project from a template. This is an asynchronous operation. The command returns a construction ID -which can be polled via 'templates construction' until the status is "completed".`, +which can be polled via 'templates construction' until the status is "completed". + +A template's dates are relative to the start of its first week, and template +weeks start on a Sunday. --start-date anchors them to the Sunday on or before +the given date; without it they anchor to the week the project is constructed.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if projectName == "" { @@ -648,6 +654,14 @@ which can be polled via 'templates construction' until the status is "completed" return output.ErrUsage("Invalid template ID") } + var parsedStart string + if cmd.Flags().Changed("start-date") { + parsedStart = dateparse.Parse(startDate) + if _, err := time.Parse("2006-01-02", parsedStart); err != nil { + return output.ErrUsage(fmt.Sprintf("Invalid start date: %q", startDate)) + } + } + // Syntactic checks first, then "-", then account and network. projectDesc, err := resolveContentValue(cmd, projectDesc, -1, "--description") if err != nil { @@ -660,7 +674,8 @@ which can be polled via 'templates construction' until the status is "completed" return err } - construction, err := app.Account().Templates().CreateProject(cmd.Context(), templateID, projectName, projectDesc) + construction, err := app.Account().Templates().CreateProject(cmd.Context(), templateID, projectName, projectDesc, + &basecamp.CreateProjectOptions{StartDate: parsedStart}) if err != nil { return convertSDKError(err) } @@ -681,6 +696,7 @@ which can be polled via 'templates construction' until the status is "completed" cmd.Flags().StringVar(&projectName, "name", "", "Project name (required)") cmd.Flags().StringVar(&projectDesc, "description", "", "Project description; use - to read from stdin") cmd.Flags().StringVar(&projectDesc, "desc", "", "Project description (alias)") + cmd.Flags().StringVar(&startDate, "start-date", "", "Project start date (YYYY-MM-DD or natural, e.g. \"next monday\"); template dates anchor to the Sunday of that week") _ = cmd.MarkFlagRequired("name") allowDash(cmd, "flag:description", "flag:desc") diff --git a/internal/commands/templates_test.go b/internal/commands/templates_test.go index cc0b5123..397e11e5 100644 --- a/internal/commands/templates_test.go +++ b/internal/commands/templates_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -228,6 +229,103 @@ func TestTemplatesCopyExplainsPeopleConfirmation(t *testing.T) { assert.True(t, errors.As(err, &confirmationErr), "typed SDK error should remain available") } +func TestTemplatesConstructSendsStartDateUnderProjectEnvelope(t *testing.T) { + app, transport := setupRecordingTestApp(t, + stubRoute{ + method: http.MethodPost, + path: "/99999/templates/2085958507/project_constructions.json", + status: http.StatusCreated, + body: `{"id":598194962,"status":"pending","url":"https://example.test/constructions/598194962.json"}`, + }, + ) + buf := captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, + "construct", "2085958507", "--name", "Marketing Campaign", + "--description", "For Client: Xyz Corp Conference", "--start-date", "2026-09-01") + require.NoError(t, err) + + requests := transport.recorded() + require.Len(t, requests, 1) + assert.Equal(t, http.MethodPost, requests[0].Method) + assert.JSONEq(t, + `{"project":{"name":"Marketing Campaign","description":"For Client: Xyz Corp Conference","start_date":"2026-09-01"}}`, + requests[0].Body, + ) + + 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) +} + +func TestTemplatesConstructOmitsStartDateWhenUnset(t *testing.T) { + app, transport := setupRecordingTestApp(t, + stubRoute{ + method: http.MethodPost, + path: "/99999/templates/2085958507/project_constructions.json", + status: http.StatusCreated, + body: `{"id":598194962,"status":"pending","url":"https://example.test/constructions/598194962.json"}`, + }, + ) + captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "construct", "2085958507", "--name", "Marketing Campaign") + require.NoError(t, err) + + requests := transport.recorded() + require.Len(t, requests, 1) + assert.JSONEq(t, `{"project":{"name":"Marketing Campaign"}}`, requests[0].Body) +} + +func TestTemplatesConstructParsesNaturalStartDate(t *testing.T) { + app, transport := setupRecordingTestApp(t, + stubRoute{ + method: http.MethodPost, + path: "/99999/templates/2085958507/project_constructions.json", + status: http.StatusCreated, + body: `{"id":598194962,"status":"pending","url":"https://example.test/constructions/598194962.json"}`, + }, + ) + captureTemplateOutput(app) + + before := time.Now() + err := executeRecordingCommand(NewTemplatesCmd(), app, "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "tomorrow") + after := time.Now() + require.NoError(t, err) + + requests := transport.recorded() + require.Len(t, requests, 1) + var body struct { + Project struct { + StartDate string `json:"start_date"` + } `json:"project"` + } + require.NoError(t, json.Unmarshal([]byte(requests[0].Body), &body)) + tomorrow := func(now time.Time) string { return now.AddDate(0, 0, 1).Format("2006-01-02") } + assert.Contains(t, []string{tomorrow(before), tomorrow(after)}, body.Project.StartDate) +} + +func TestTemplatesConstructRejectsBlankStartDate(t *testing.T) { + app, transport := setupRecordingTestApp(t) + captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "construct", "2085958507", "--name", "Marketing Campaign", "--start-date", "") + outErr := requireUsageErr(t, err) + assert.Contains(t, outErr.Message, "Invalid start date") + assert.Empty(t, transport.recorded()) +} + +func TestTemplatesConstructRejectsMalformedStartDateBeforeAnyRequest(t *testing.T) { + app, transport := setupRecordingTestApp(t) + captureTemplateOutput(app) + + err := executeRecordingCommand(NewTemplatesCmd(), app, "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()) +} + func TestTemplatesCopyStatusStates(t *testing.T) { completed := `{ "id":5,"status":"completed","source_recording_id":3,"destination_parent_id":9,"url":"https://example.test/copies/5.json", diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 30764f81..c223d264 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "6e73a06f4262062b2c973b2d0787ab0029b08d0d", - "ref": "go/v0.17.0", + "commit": "400c76ca433d5ae08e1e7d6a561ab113949c70d8", + "ref": "go/v0.18.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "tags assigned to operations the export leaves untagged (PATCHED_TAGS); binary-upload operations dropped (EXCLUDED_OPERATIONS) — see the sync script" diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 6714b588..cf838833 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -33601,6 +33601,10 @@ }, "description": { "type": "string" + }, + "start_date": { + "type": "string", + "description": "Date the new project starts on. Template dates are relative to the start\nof the template's first week, and template weeks start on a Sunday, so\nthe project's dates are anchored to the Sunday on or before this date.\nOmitted, they are anchored to the week in which the construction is\nprocessed." } }, "required": [ diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index f5287d19..86b479d4 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.17.0", - "revision": "6e73a06f4262", - "updated_at": "2026-09-09T22:00:24Z" + "version": "v0.18.0", + "revision": "400c76ca433d", + "updated_at": "2026-09-10T05:31:19Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index de8833f1..26a0a2f4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-GCw7WkPmXDWDAhJrVqbXUfwLa9YzpWdTcSimwovjpcQ="; + vendorHash = "sha256-MdVsRrJ3SEEtFFU6X2gP5s414kEhulSkxLqoD9GxiIg="; subPackages = [ "cmd/basecamp" ]; diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 29886ae9..94a5fb71 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -975,6 +975,7 @@ basecamp templates create "Template Name" # Create empty project templat 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 library --json # List active to-do list templates @@ -983,7 +984,10 @@ basecamp templates copy-status # Check copy status ``` **Asynchronous results:** `construct` returns a construction ID; poll `construction` -until `status="completed"` to get the project. `copy` returns a copy ID; poll +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`. A copy can report the people who need access to the destination project. Show those