Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions e2e/templates.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
22 changes: 19 additions & 3 deletions internal/commands/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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(
Expand Down Expand Up @@ -629,14 +630,19 @@ func newTemplatesDeleteCmd() *cobra.Command {
func newTemplatesConstructCmd() *cobra.Command {
var projectName string
var projectDesc string
var startDate string

cmd := &cobra.Command{
Use: "construct <template_id>",
Short: "Create project from template",
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 == "" {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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")
Expand Down
98 changes: 98 additions & 0 deletions internal/commands/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/mcpserver/model/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 4 additions & 0 deletions internal/mcpserver/model/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
6 changes: 3 additions & 3 deletions internal/version/sdk-provenance.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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" ];

Expand Down
6 changes: 5 additions & 1 deletion skills/basecamp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,7 @@ basecamp templates create "Template Name" # Create empty project templat
basecamp templates update <id> --name "New Name"
basecamp templates delete <id> # Trash project template
basecamp templates construct <id> --name "New Project" # Create project (async)
basecamp templates construct <id> --name "New Project" --start-date 2026-09-01 # Anchor template dates to that week
basecamp templates construction <template_id> <construction_id> # Check project status

basecamp templates library --json # List active to-do list templates
Expand All @@ -983,7 +984,10 @@ basecamp templates copy-status <copy_id> # 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
Expand Down
Loading