From 9502f1f28988b3d1a51ccbcbf00818fbcc3ed213 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 24 Mar 2026 13:07:56 -0700 Subject: [PATCH 01/10] Add --list flag to todos position for cross-list moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support moving a todo to a different todolist within the same project via the reposition endpoint's parent_id field. Detects and rejects cross-project moves with guidance. Blocked on SDK exposing parentID on Reposition — will compile after rebasing onto main with the new SDK release. Closes #337 --- internal/commands/todos.go | 48 +++++++++++++++++++++++++++++++------- skills/basecamp/SKILL.md | 1 + 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index 0d4947b5..f946d3e7 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1640,17 +1640,25 @@ func reopenTodos(cmd *cobra.Command, todoIDs []string) error { } func newTodosPositionCmd() *cobra.Command { - var position int + var ( + position int + list string + ) cmd := &cobra.Command{ Use: "position ", Aliases: []string{"move", "reorder"}, - Short: "Change todo position", - Long: `Reorder a todo within its todolist. Position is 1-based (1 = top). + Short: "Change todo position or move between lists", + Long: `Reorder a todo within its todolist, or move it to a different list in the +same project. Position is 1-based (1 = top). You can pass either a todo ID or a Basecamp URL: basecamp todos position 789 --to 1 - basecamp todos position https://3.basecamp.com/123/buckets/456/todos/789 --to 1`, + basecamp todos position https://3.basecamp.com/123/buckets/456/todos/789 --to 1 + +Move to a different todolist in the same project: + basecamp todos position 789 --to 1 --list 321 + basecamp todos position --to 1 --list `, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return missingArg(cmd, "") @@ -1669,21 +1677,44 @@ You can pass either a todo ID or a Basecamp URL: return output.ErrUsage("--to is required (1 = top)") } - // Extract ID from URL if provided - todoIDStr := extractID(args[0]) + // Extract todo ID and project from URL if provided + todoIDStr, todoProjectID := extractWithProject(args[0]) todoID, err := strconv.ParseInt(todoIDStr, 10, 64) if err != nil { return output.ErrUsage("Invalid todo ID") } - err = app.Account().Todos().Reposition(cmd.Context(), todoID, position, nil) + // Resolve destination todolist when --list is provided + var parentID *int64 + if list != "" { + listIDStr, listProjectID := extractWithProject(list) + + // Cross-project moves are not supported by the reposition endpoint + if todoProjectID != "" && listProjectID != "" && todoProjectID != listProjectID { + return output.ErrUsage("Cannot move a todo to a list in a different project. " + + "Cross-project moves are not yet supported.") + } + + listID, parseErr := strconv.ParseInt(listIDStr, 10, 64) + if parseErr != nil { + return output.ErrUsage("Invalid todolist ID") + } + parentID = &listID + } + + err = app.Account().Todos().Reposition(cmd.Context(), todoID, position, parentID) if err != nil { return convertSDKError(err) } + summary := fmt.Sprintf("Moved todo #%d to position %d", todoID, position) + if parentID != nil { + summary = fmt.Sprintf("Moved todo #%d to list #%d at position %d", todoID, *parentID, position) + } + return app.OK(map[string]any{"repositioned": true, "position": position}, - output.WithSummary(fmt.Sprintf("Moved todo #%d to position %d", todoID, position)), + output.WithSummary(summary), output.WithBreadcrumbs( output.Breadcrumb{ Action: "show", @@ -1697,6 +1728,7 @@ You can pass either a todo ID or a Basecamp URL: cmd.Flags().IntVar(&position, "to", 0, "Target position, 1-based (1 = top)") cmd.Flags().IntVar(&position, "position", 0, "Target position (alias for --to)") + cmd.Flags().StringVarP(&list, "list", "l", "", "Destination todolist ID or URL (move to a different list)") return cmd } diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index ccee186c..685db079 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -373,6 +373,7 @@ basecamp unassign [id...] --card --from --in # Remove ca basecamp assign [id...] --step --to --in # Assign card step basecamp unassign [id...] --step --from --in # Remove step assignee basecamp todos position --to 1 # Move to top +basecamp todos position --to 1 --list # Move to different list basecamp todos sweep --overdue --complete --comment "Done" --in ``` From f8075852a7325df4dcc840d33bab5f77211fa056 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 24 Mar 2026 13:18:45 -0700 Subject: [PATCH 02/10] Support todolist name resolution and improve cross-project validation - Resolve --list via resolveTodolistInTodoset so names like "Sprint 1" work, not just numeric IDs and URLs - Use project context from todo URL, --in flag, and config for both name resolution and cross-project validation - Use ErrUsageHint for the cross-project error to show actionable guidance - Update help text and SKILL.md placeholder to reflect name support --- internal/commands/todos.go | 36 ++++++++++++++++++++++++++++++++---- skills/basecamp/SKILL.md | 2 +- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index f946d3e7..2d0be10f 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1657,6 +1657,7 @@ You can pass either a todo ID or a Basecamp URL: basecamp todos position https://3.basecamp.com/123/buckets/456/todos/789 --to 1 Move to a different todolist in the same project: + basecamp todos position 789 --to 1 --list "Sprint 1" --in myproject basecamp todos position 789 --to 1 --list 321 basecamp todos position --to 1 --list `, RunE: func(cmd *cobra.Command, args []string) error { @@ -1690,10 +1691,37 @@ Move to a different todolist in the same project: if list != "" { listIDStr, listProjectID := extractWithProject(list) + // Build project context: todo URL > --in flag > config + project := todoProjectID + if project == "" { + project = app.Flags.Project + } + if project == "" { + project = app.Config.ProjectID + } + // Cross-project moves are not supported by the reposition endpoint - if todoProjectID != "" && listProjectID != "" && todoProjectID != listProjectID { - return output.ErrUsage("Cannot move a todo to a list in a different project. " + - "Cross-project moves are not yet supported.") + if project != "" && listProjectID != "" && project != listProjectID { + return output.ErrUsageHint( + "Cannot move a todo to a list in a different project.", + "Pass a todolist from the same project; cross-project moves are not supported.", + ) + } + + // Resolve todolist name to ID when not already numeric + if !isNumeric(listIDStr) { + if project == "" { + return output.ErrUsage("--in is required to resolve todolist names") + } + resolvedProject, _, resolveErr := app.Names.ResolveProject(cmd.Context(), project) + if resolveErr != nil { + return resolveErr + } + resolved, resolveErr := resolveTodolistInTodoset(cmd, app, listIDStr, resolvedProject, "") + if resolveErr != nil { + return resolveErr + } + listIDStr = resolved } listID, parseErr := strconv.ParseInt(listIDStr, 10, 64) @@ -1728,7 +1756,7 @@ Move to a different todolist in the same project: cmd.Flags().IntVar(&position, "to", 0, "Target position, 1-based (1 = top)") cmd.Flags().IntVar(&position, "position", 0, "Target position (alias for --to)") - cmd.Flags().StringVarP(&list, "list", "l", "", "Destination todolist ID or URL (move to a different list)") + cmd.Flags().StringVarP(&list, "list", "l", "", "Destination todolist ID, name, or URL (move to a different list)") return cmd } diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 685db079..e5705ff1 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -373,7 +373,7 @@ basecamp unassign [id...] --card --from --in # Remove ca basecamp assign [id...] --step --to --in # Assign card step basecamp unassign [id...] --step --from --in # Remove step assignee basecamp todos position --to 1 # Move to top -basecamp todos position --to 1 --list # Move to different list +basecamp todos position --to 1 --list # Move to different list basecamp todos sweep --overdue --complete --comment "Done" --in ``` From 676206bcb0ef4a351083e599340c67a174a447a7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 24 Mar 2026 13:23:58 -0700 Subject: [PATCH 03/10] Resolve project name before cross-project comparison Project context from --in may be a name like "myproject" while the list URL yields a numeric project ID. Resolve the name to an ID before comparing, so --in myproject --list doesn't falsely reject. Also consolidates project resolution: the resolved ID is reused for both cross-project validation and todolist name resolution. --- internal/commands/todos.go | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index 2d0be10f..c899908c 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1700,8 +1700,21 @@ Move to a different todolist in the same project: project = app.Config.ProjectID } - // Cross-project moves are not supported by the reposition endpoint - if project != "" && listProjectID != "" && project != listProjectID { + // Resolve project name to numeric ID before comparing + // against the numeric project ID from a list URL. + resolvedProject := project + if project != "" && !isNumeric(project) { + rp, _, resolveErr := app.Names.ResolveProject(cmd.Context(), project) + if resolveErr != nil { + return resolveErr + } + resolvedProject = rp + } + + // Cross-project moves are not supported by the reposition endpoint. + // This catches the URL-vs-URL and URL-vs-config cases. Bare numeric + // list IDs without a project context rely on server rejection. + if resolvedProject != "" && listProjectID != "" && resolvedProject != listProjectID { return output.ErrUsageHint( "Cannot move a todo to a list in a different project.", "Pass a todolist from the same project; cross-project moves are not supported.", @@ -1710,13 +1723,9 @@ Move to a different todolist in the same project: // Resolve todolist name to ID when not already numeric if !isNumeric(listIDStr) { - if project == "" { + if resolvedProject == "" { return output.ErrUsage("--in is required to resolve todolist names") } - resolvedProject, _, resolveErr := app.Names.ResolveProject(cmd.Context(), project) - if resolveErr != nil { - return resolveErr - } resolved, resolveErr := resolveTodolistInTodoset(cmd, app, listIDStr, resolvedProject, "") if resolveErr != nil { return resolveErr From 0b43c6379a262b1d6b39f723098f4d3829896710 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 24 Mar 2026 13:31:17 -0700 Subject: [PATCH 04/10] Update surface snapshot with --list flag on todos position --- .surface | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.surface b/.surface index 70225d1e..56f27f83 100644 --- a/.surface +++ b/.surface @@ -13284,6 +13284,7 @@ FLAG basecamp todos move --ids-only type=bool FLAG basecamp todos move --in type=string FLAG basecamp todos move --jq type=string FLAG basecamp todos move --json type=bool +FLAG basecamp todos move --list type=string FLAG basecamp todos move --markdown type=bool FLAG basecamp todos move --md type=bool FLAG basecamp todos move --no-hints type=bool @@ -13307,6 +13308,7 @@ FLAG basecamp todos position --ids-only type=bool FLAG basecamp todos position --in type=string FLAG basecamp todos position --jq type=string FLAG basecamp todos position --json type=bool +FLAG basecamp todos position --list type=string FLAG basecamp todos position --markdown type=bool FLAG basecamp todos position --md type=bool FLAG basecamp todos position --no-hints type=bool @@ -13351,6 +13353,7 @@ FLAG basecamp todos reorder --ids-only type=bool FLAG basecamp todos reorder --in type=string FLAG basecamp todos reorder --jq type=string FLAG basecamp todos reorder --json type=bool +FLAG basecamp todos reorder --list type=string FLAG basecamp todos reorder --markdown type=bool FLAG basecamp todos reorder --md type=bool FLAG basecamp todos reorder --no-hints type=bool From 60cbf375de34e5e8884b1038e213bf0fc765ee84 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 24 Mar 2026 13:48:22 -0700 Subject: [PATCH 05/10] Add tests for --list flag on todos position Cover the new cross-list move path: - Cross-project URL rejection - Same-project URL passes validation - List name without project context requires --in - Cross-project via config project ID --- internal/commands/todos_test.go | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/internal/commands/todos_test.go b/internal/commands/todos_test.go index 469c0b21..69e04f16 100644 --- a/internal/commands/todos_test.go +++ b/internal/commands/todos_test.go @@ -181,6 +181,73 @@ func TestTodosPositionRequiresPosition(t *testing.T) { assert.Equal(t, "--to is required (1 = top)", err.Error()) } +// TestTodosPositionRejectsCrossProjectListURL tests that --list with a URL from a +// different project than the todo URL is rejected with a clear error. +func TestTodosPositionRejectsCrossProjectListURL(t *testing.T) { + app, _ := setupTodosTestApp(t) + + cmd := NewTodosCmd() + + err := executeTodosCommand(cmd, app, "position", + "https://3.basecamp.com/99999/buckets/100/todos/789", + "--to", "1", + "--list", "https://3.basecamp.com/99999/buckets/200/todolists/321", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cannot move a todo to a list in a different project") +} + +// TestTodosPositionAcceptsSameProjectListURL tests that --list with a URL from the +// same project as the todo URL passes the cross-project check (fails at network, +// not at validation). +func TestTodosPositionAcceptsSameProjectListURL(t *testing.T) { + app, _ := setupTodosTestApp(t) + + cmd := NewTodosCmd() + + err := executeTodosCommand(cmd, app, "position", + "https://3.basecamp.com/99999/buckets/100/todos/789", + "--to", "1", + "--list", "https://3.basecamp.com/99999/buckets/100/todolists/321", + ) + // Should pass validation and fail at the SDK call (network disabled), + // not at the cross-project check. + require.Error(t, err) + assert.NotContains(t, err.Error(), "different project") +} + +// TestTodosPositionListNameRequiresProject tests that --list with a name (not numeric) +// requires a project context via --in or config. +func TestTodosPositionListNameRequiresProject(t *testing.T) { + app, _ := setupTodosTestApp(t) + + cmd := NewTodosCmd() + + err := executeTodosCommand(cmd, app, "position", "789", + "--to", "1", + "--list", "Sprint 1", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "--in is required to resolve todolist names") +} + +// TestTodosPositionCrossProjectViaConfigFlag tests that --in project context is used +// for cross-project validation when the todo is passed as a bare ID. +func TestTodosPositionCrossProjectViaConfigFlag(t *testing.T) { + app, _ := setupTodosTestApp(t) + app.Config.ProjectID = "100" + + cmd := NewTodosCmd() + + // Todo is bare ID (project from config = "100"), list URL has project 200 + err := executeTodosCommand(cmd, app, "position", "789", + "--to", "1", + "--list", "https://3.basecamp.com/99999/buckets/200/todolists/321", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cannot move a todo to a list in a different project") +} + // TestTodoShortcutRequiresContent tests that todo shortcut requires content. func TestTodoShortcutShowsHelpWithoutContent(t *testing.T) { app, _ := setupTodosTestApp(t) From 90d6145698319f5bc5f6a911dbeea9efd5d4ad28 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 24 Mar 2026 13:52:57 -0700 Subject: [PATCH 06/10] Narrow cross-project guard to URL-sourced projects, include todolist_id in response - Only fire the cross-project check when the todo's project comes from its URL, not from config/flags. Config project is a default context that may not match where a bare-ID todo actually lives. - Include todolist_id in JSON response when --list is used so automation can confirm the cross-list move. --- internal/commands/todos.go | 14 ++++++++++---- internal/commands/todos_test.go | 12 +++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index c899908c..5a806d7a 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1712,9 +1712,10 @@ Move to a different todolist in the same project: } // Cross-project moves are not supported by the reposition endpoint. - // This catches the URL-vs-URL and URL-vs-config cases. Bare numeric - // list IDs without a project context rely on server rejection. - if resolvedProject != "" && listProjectID != "" && resolvedProject != listProjectID { + // Only enforce when the todo's project comes from its URL (high + // confidence). Config/flag project is a default context — it may + // not match where a bare-ID todo actually lives. + if todoProjectID != "" && listProjectID != "" && resolvedProject != listProjectID { return output.ErrUsageHint( "Cannot move a todo to a list in a different project.", "Pass a todolist from the same project; cross-project moves are not supported.", @@ -1750,7 +1751,12 @@ Move to a different todolist in the same project: summary = fmt.Sprintf("Moved todo #%d to list #%d at position %d", todoID, *parentID, position) } - return app.OK(map[string]any{"repositioned": true, "position": position}, + response := map[string]any{"repositioned": true, "position": position} + if parentID != nil { + response["todolist_id"] = *parentID + } + + return app.OK(response, output.WithSummary(summary), output.WithBreadcrumbs( output.Breadcrumb{ diff --git a/internal/commands/todos_test.go b/internal/commands/todos_test.go index 69e04f16..fb6599eb 100644 --- a/internal/commands/todos_test.go +++ b/internal/commands/todos_test.go @@ -231,21 +231,23 @@ func TestTodosPositionListNameRequiresProject(t *testing.T) { assert.Contains(t, err.Error(), "--in is required to resolve todolist names") } -// TestTodosPositionCrossProjectViaConfigFlag tests that --in project context is used -// for cross-project validation when the todo is passed as a bare ID. -func TestTodosPositionCrossProjectViaConfigFlag(t *testing.T) { +// TestTodosPositionBareIDSkipsCrossProjectGuard tests that the cross-project +// guard does not fire when the todo is a bare ID. Config project is a default +// context that may not match where the todo actually lives. +func TestTodosPositionBareIDSkipsCrossProjectGuard(t *testing.T) { app, _ := setupTodosTestApp(t) app.Config.ProjectID = "100" cmd := NewTodosCmd() - // Todo is bare ID (project from config = "100"), list URL has project 200 + // Todo is bare ID (config project = "100"), list URL has project 200. + // Should NOT reject — bare ID means we don't know the todo's project. err := executeTodosCommand(cmd, app, "position", "789", "--to", "1", "--list", "https://3.basecamp.com/99999/buckets/200/todolists/321", ) require.Error(t, err) - assert.Contains(t, err.Error(), "Cannot move a todo to a list in a different project") + assert.NotContains(t, err.Error(), "different project") } // TestTodoShortcutRequiresContent tests that todo shortcut requires content. From 0ea5f242d6ca4d572cb721e6d0d17657763c38dd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 25 Mar 2026 00:26:14 -0700 Subject: [PATCH 07/10] Defer project resolution to when it's actually needed ResolveProject was called unconditionally when --list was provided and the project context was non-numeric, even when the list was already a numeric ID and no resolution was needed. This made otherwise valid moves fail if the configured project name was stale. Now only resolves when cross-project URL validation or todolist name resolution actually requires the numeric project ID. --- internal/commands/todos.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index 5a806d7a..c82024b9 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1700,10 +1700,11 @@ Move to a different todolist in the same project: project = app.Config.ProjectID } - // Resolve project name to numeric ID before comparing - // against the numeric project ID from a list URL. + // Resolve project name to numeric ID only when needed: + // cross-project URL validation or todolist name resolution. resolvedProject := project - if project != "" && !isNumeric(project) { + needsResolve := (todoProjectID != "" && listProjectID != "") || !isNumeric(listIDStr) + if needsResolve && project != "" && !isNumeric(project) { rp, _, resolveErr := app.Names.ResolveProject(cmd.Context(), project) if resolveErr != nil { return resolveErr From 635e6ac1ba2c5c10e0ef09448bd40f723dd5181d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 25 Mar 2026 00:28:44 -0700 Subject: [PATCH 08/10] Include --page in notifications list breadcrumb when not on first page The read breadcrumb from 'notifications list --page 2' said 'basecamp notifications read ' without --page, leading users into a "not found" error because read defaults to page 0. Now carries the page context forward. --- internal/commands/notifications.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/commands/notifications.go b/internal/commands/notifications.go index d3dbfa2e..6810a978 100644 --- a/internal/commands/notifications.go +++ b/internal/commands/notifications.go @@ -77,10 +77,14 @@ func runNotificationsList(cmd *cobra.Command, page int32) error { if page == 0 { nextPage = 2 } + readCmd := "basecamp notifications read " + if page > 0 { + readCmd = fmt.Sprintf("basecamp notifications read --page %d", page) + } breadcrumbs := []output.Breadcrumb{ { Action: "read", - Cmd: "basecamp notifications read ", + Cmd: readCmd, Description: "Mark as read", }, { From fa990390d89cb161b157d79243d6a7d1f3f28854 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 25 Mar 2026 00:44:33 -0700 Subject: [PATCH 09/10] Guard against empty list ID from malformed todolist URLs extractWithProject can return an empty recording ID for URLs that parse but have no recording segment (e.g. project URLs). Fail fast with a targeted error instead of falling through to a confusing "Invalid todolist ID". --- internal/commands/todos.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index c82024b9..fbacdc77 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -1691,6 +1691,11 @@ Move to a different todolist in the same project: if list != "" { listIDStr, listProjectID := extractWithProject(list) + if listIDStr == "" { + return output.ErrUsage("Could not extract a todolist ID from that URL. " + + "Expected a todolist URL (.../todolists/), or pass a todolist ID or name.") + } + // Build project context: todo URL > --in flag > config project := todoProjectID if project == "" { From cefabdb9bf6b4ec22f648d1331acdf1c8c96495d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 25 Mar 2026 00:59:27 -0700 Subject: [PATCH 10/10] Validate --list URL type to reject non-todolist URLs A todo URL or project URL passed as --list would silently extract the wrong ID and use it as a todolist ID. Now validates via urlarg.Parse that the URL is actually a todolists URL (correct type, not a collection, has a recording ID). --- internal/commands/todos.go | 12 +++++++++--- internal/commands/todos_test.go | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/internal/commands/todos.go b/internal/commands/todos.go index fbacdc77..2d6fc790 100644 --- a/internal/commands/todos.go +++ b/internal/commands/todos.go @@ -18,6 +18,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/dateparse" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/richtext" + "github.com/basecamp/basecamp-cli/internal/urlarg" ) // todosListFlags holds the flags for the todos list command. @@ -1691,9 +1692,14 @@ Move to a different todolist in the same project: if list != "" { listIDStr, listProjectID := extractWithProject(list) - if listIDStr == "" { - return output.ErrUsage("Could not extract a todolist ID from that URL. " + - "Expected a todolist URL (.../todolists/), or pass a todolist ID or name.") + // When --list is a URL, validate it's a todolist URL — not a + // todo, project, or collection URL that would silently extract + // the wrong ID. + if parsed := urlarg.Parse(list); parsed != nil { + if parsed.RecordingID == "" || parsed.Type != "todolists" || parsed.IsCollection { + return output.ErrUsage("Expected a todolist URL (.../todolists/), " + + "or pass a todolist ID or name.") + } } // Build project context: todo URL > --in flag > config diff --git a/internal/commands/todos_test.go b/internal/commands/todos_test.go index fb6599eb..4873b625 100644 --- a/internal/commands/todos_test.go +++ b/internal/commands/todos_test.go @@ -250,6 +250,23 @@ func TestTodosPositionBareIDSkipsCrossProjectGuard(t *testing.T) { assert.NotContains(t, err.Error(), "different project") } +// TestTodosPositionRejectsNonTodolistURL tests that --list rejects URLs that +// aren't todolist URLs (e.g. todo URLs, project URLs). +func TestTodosPositionRejectsNonTodolistURL(t *testing.T) { + app, _ := setupTodosTestApp(t) + + cmd := NewTodosCmd() + + // A todo URL, not a todolist URL — should not silently use the todo ID + err := executeTodosCommand(cmd, app, "position", + "https://3.basecamp.com/99999/buckets/100/todos/789", + "--to", "1", + "--list", "https://3.basecamp.com/99999/buckets/100/todos/555", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "todolist URL") +} + // TestTodoShortcutRequiresContent tests that todo shortcut requires content. func TestTodoShortcutShowsHelpWithoutContent(t *testing.T) { app, _ := setupTodosTestApp(t)