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
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,7 @@ FLAG basecamp card move --markdown type=bool
FLAG basecamp card move --md type=bool
FLAG basecamp card move --no-hints type=bool
FLAG basecamp card move --no-stats type=bool
FLAG basecamp card move --on-hold type=bool
FLAG basecamp card move --pos type=int
FLAG basecamp card move --position type=int
FLAG basecamp card move --profile type=string
Expand Down Expand Up @@ -1299,6 +1300,7 @@ FLAG basecamp cards move --markdown type=bool
FLAG basecamp cards move --md type=bool
FLAG basecamp cards move --no-hints type=bool
FLAG basecamp cards move --no-stats type=bool
FLAG basecamp cards move --on-hold type=bool
FLAG basecamp cards move --pos type=int
FLAG basecamp cards move --position type=int
FLAG basecamp cards move --profile type=string
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.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.2
github.com/basecamp/basecamp-sdk/go v0.6.0
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318172136-4784bb2fda18
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This pins the SDK to a pseudo-version (untagged commit) which should not be merged. Once basecamp/basecamp-sdk#188 is tagged, run make bump-sdk to update to the proper release version — per AGENTS.md, go.mod should not be edited directly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go.mod, line 9:

<comment>This pins the SDK to a pseudo-version (untagged commit) which should not be merged. Once basecamp/basecamp-sdk#188 is tagged, run `make bump-sdk` to update to the proper release version — per AGENTS.md, go.mod should not be edited directly.</comment>

<file context>
@@ -6,7 +6,7 @@ require (
 	charm.land/bubbletea/v2 v2.0.2
 	charm.land/lipgloss/v2 v2.0.0
-	github.com/basecamp/basecamp-sdk/go v0.6.0
+	github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318172136-4784bb2fda18
 	github.com/basecamp/cli v0.1.1
 	github.com/charmbracelet/bubbles v1.0.0
</file context>
Fix with Cubic

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pseudo-version is intentional — SDK PR 188 merged to main but no tag has been cut yet. The bump was done via make bump-sdk REF=4784bb2fda18 which is the standard workflow per AGENTS.md. Once a tagged release is available, a follow-up make bump-sdk will pin to it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! I've saved this as a new learning to improve future reviews.

github.com/basecamp/cli v0.1.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,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.6.0 h1:LnyW0rFI5/1vmGpmFnogE+8ms/kueCwg5U/mSAuLJYw=
github.com/basecamp/basecamp-sdk/go v0.6.0/go.mod h1:g05DM58QkUm4/mvBAvRiugPw+F4trliuGkRGg8y+Th4=
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318172136-4784bb2fda18 h1:it4iTTmOKr4DFOdVGE7jVxNQ3lD9snwe8vYhVI8Soz4=
github.com/basecamp/basecamp-sdk/go v0.6.1-0.20260318172136-4784bb2fda18/go.mod h1:g05DM58QkUm4/mvBAvRiugPw+F4trliuGkRGg8y+Th4=
github.com/basecamp/cli v0.1.1 h1:FAF3M09xo1m7gJJXf38glCkT50ZUuvz+31f+c3R3zcc=
github.com/basecamp/cli v0.1.1/go.mod h1:NTHe+keCTGI2qM5sMXdkUN0QgU3zGbwnBxcmg8vD5QU=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
135 changes: 112 additions & 23 deletions internal/commands/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ You can pass either a card ID or a Basecamp URL:
func newCardsMoveCmd(project, cardTable *string) *cobra.Command {
var targetColumn string
var position int
var onHold bool

cmd := &cobra.Command{
Use: "move <id|url>",
Expand All @@ -638,42 +639,47 @@ func newCardsMoveCmd(project, cardTable *string) *cobra.Command {
You can pass either a card ID or a Basecamp URL:
basecamp cards move 789 --to "Done" --in my-project
basecamp cards move https://3.basecamp.com/123/buckets/456/card_tables/cards/789 --to "Done"
basecamp cards move 789 --to "Done" --position 1 --in my-project`,
Args: cobra.ExactArgs(1),
basecamp cards move 789 --to "Done" --position 1 --in my-project
basecamp cards move 789 --on-hold --in my-project
basecamp cards move 789 --to 456 --on-hold --in my-project`,
Args: cobra.ExactArgs(1),
Aliases: []string{"mv"},
Annotations: map[string]string{
"agent_notes": "When --on-hold is used without --to, the card moves to the on-hold section of its current column. " +
"When --on-hold is used with --to, the card moves to the on-hold section of the target column. " +
"--position cannot be combined with --on-hold.",
},
RunE: func(cmd *cobra.Command, args []string) error {
// Show help when invoked with no target column
if targetColumn == "" {
if targetColumn == "" && !onHold {
return missingArg(cmd, "--to")
}

// Detect --position/--pos presence and validate
positionSet := cmd.Flags().Changed("position") || cmd.Flags().Changed("pos")
if positionSet && position <= 0 {
return output.ErrUsage("--position must be a positive integer (1-indexed)")
}
if positionSet && onHold {
return output.ErrUsage("--position cannot be used with --on-hold")
}

app := appctx.FromContext(cmd.Context())

if err := ensureAccount(cmd, app); err != nil {
return err
}

// Extract ID and project from URL if provided
cardIDStr, urlProjectID := extractWithProject(args[0])

cardID, err := strconv.ParseInt(cardIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid card ID")
}

// Check if --to is a column name (not numeric) - requires --card-table
// Do this validation early, before any API calls
isNumericColumn := isNumericID(targetColumn)
if !isNumericColumn && *cardTable == "" {
isNumericColumn := targetColumn != "" && isNumericID(targetColumn)
if targetColumn != "" && !isNumericColumn && *cardTable == "" {
return output.ErrUsage("--card-table is required when --to is a column name")
}

// Resolve project - use URL > flag > config, with interactive fallback
projectID := *project
if projectID == "" && urlProjectID != "" {
projectID = urlProjectID
Expand All @@ -696,19 +702,19 @@ You can pass either a card ID or a Basecamp URL:
return err
}

// Determine column ID - numeric IDs bypass card table resolution
// --on-hold: move card to on-hold section of current or target column
if onHold {
return moveCardOnHold(cmd, app, cardID, cardIDStr, resolvedProjectID, targetColumn, *cardTable)
}

var columnID int64
var cardTableIDVal string // Will be empty if using numeric column ID directly
var cardTableIDVal string
if isNumericColumn {
// Numeric column ID - use directly without card table lookup
columnID, err = strconv.ParseInt(targetColumn, 10, 64)
if err != nil {
return output.ErrUsage("Invalid column ID")
}
} else {
// Column name - need card table to resolve (already validated above)

// Get card table ID from project dock
cardTableIDVal, err = getCardTableID(cmd, app, resolvedProjectID, *cardTable)
if err != nil {
return err
Expand All @@ -719,13 +725,11 @@ You can pass either a card ID or a Basecamp URL:
return output.ErrUsage("Invalid card table ID")
}

// Get card table with embedded columns (lists)
cardTableData, err := app.Account().CardTables().Get(cmd.Context(), cardTableIDInt)
if err != nil {
return convertSDKError(err)
}

// Find target column by name
columnID = resolveColumn(cardTableData.Lists, targetColumn)
if columnID == 0 {
return output.ErrUsageHint(
Expand All @@ -735,15 +739,13 @@ You can pass either a card ID or a Basecamp URL:
}
}

// Positioned moves need card table ID for the moves endpoint
if positionSet && position > 0 && cardTableIDVal == "" {
cardTableIDVal, err = getCardTableID(cmd, app, resolvedProjectID, *cardTable)
if err != nil {
return err
}
}

// Move card to column
if positionSet && position > 0 {
cardTableIDInt, parseErr := strconv.ParseInt(cardTableIDVal, 10, 64)
if parseErr != nil {
Expand All @@ -761,7 +763,6 @@ You can pass either a card ID or a Basecamp URL:
return convertSDKError(err)
}

// Build breadcrumbs - only include --card-table when known
breadcrumbs := []output.Breadcrumb{
{
Action: "view",
Expand Down Expand Up @@ -795,13 +796,101 @@ You can pass either a card ID or a Basecamp URL:
},
}

cmd.Flags().StringVarP(&targetColumn, "to", "t", "", "Target column ID or name (required)")
cmd.Flags().StringVarP(&targetColumn, "to", "t", "", "Target column ID or name (optional with --on-hold)")
cmd.Flags().IntVar(&position, "position", 0, "Position in column (1-indexed)")
cmd.Flags().IntVar(&position, "pos", 0, "Position in column (alias for --position)")
cmd.Flags().BoolVar(&onHold, "on-hold", false, "Move card to the on-hold section of its current (or target) column")

return cmd
}

func moveCardOnHold(cmd *cobra.Command, app *appctx.App, cardID int64, cardIDStr, projectID, targetColumn, cardTableFlag string) error {
var column *basecamp.CardColumn

if targetColumn != "" && isNumericID(targetColumn) {
columnID, err := strconv.ParseInt(targetColumn, 10, 64)
if err != nil {
return output.ErrUsage("Invalid column ID")
}
col, err := app.Account().CardColumns().Get(cmd.Context(), columnID)
if err != nil {
return convertSDKError(err)
}
column = col
} else if targetColumn == "" {
card, err := app.Account().Cards().Get(cmd.Context(), cardID)
if err != nil {
return convertSDKError(err)
}
if card.Parent == nil {
return output.ErrUsageHint(
"Card has no parent column",
fmt.Sprintf("Specify the target column: basecamp cards move %s --to <column-id> --on-hold", cardIDStr),
)
}
col, err := app.Account().CardColumns().Get(cmd.Context(), card.Parent.ID)
if err != nil {
return convertSDKError(err)
}
column = col
} else {
cardTableIDVal, err := getCardTableID(cmd, app, projectID, cardTableFlag)
if err != nil {
return err
}
cardTableIDInt, err := strconv.ParseInt(cardTableIDVal, 10, 64)
if err != nil {
return output.ErrUsage("Invalid card table ID")
}
cardTableData, err := app.Account().CardTables().Get(cmd.Context(), cardTableIDInt)
if err != nil {
return convertSDKError(err)
}
colID := resolveColumn(cardTableData.Lists, targetColumn)
if colID == 0 {
return output.ErrUsageHint(
fmt.Sprintf("Column '%s' not found", targetColumn),
"Use column ID or exact name",
)
}
for i := range cardTableData.Lists {
if cardTableData.Lists[i].ID == colID {
column = &cardTableData.Lists[i]
break
}
}
}

if column.OnHold == nil || column.OnHold.ID == 0 {
return output.ErrUsageHint(
fmt.Sprintf("Column '%s' does not have an on-hold section", column.Title),
fmt.Sprintf("Enable on-hold with: basecamp cards column on-hold %d", column.ID),
)
}

err := app.Account().Cards().Move(cmd.Context(), cardID, column.OnHold.ID)
if err != nil {
return convertSDKError(err)
}

result := map[string]any{
"id": cardIDStr,
"status": "moved",
"column": column.Title,
"on_hold": true,
}
summary := fmt.Sprintf("Moved card #%s to on-hold in '%s'", cardIDStr, column.Title)

return app.OK(result,
output.WithSummary(summary),
output.WithBreadcrumbs(output.Breadcrumb{
Action: "view",
Cmd: fmt.Sprintf("basecamp cards show %s --in %s", cardIDStr, projectID),
Description: "View card",
}),
)
}

func newCardsColumnsCmd(project, cardTable *string) *cobra.Command {
cmd := &cobra.Command{
Use: "columns",
Expand Down
Loading
Loading