From 4fcb496b6e2c786df89c7db709ea043bebcdb8d8 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 22 Oct 2026 20:48:05 -0400 Subject: [PATCH 1/6] Add projects wallpaper set, mode and remove basecamp projects wallpaper set [--mode fill|tile] basecamp projects wallpaper mode basecamp projects wallpaper remove The project comes from --project/--in, then the configured default, as for other project-scoped commands; the positional slot is the file or the mode. Before anything is sent, set refuses a file that is not PNG, JPEG, WebP or AVIF or is over 10 MB, and both set and mode refuse a mode other than fill or tile. The formats and the cap are named once and feed the help and the refusals. The server sniffs the bytes and holds the web's rules (editors only, agents refused), and its 422 or 403 comes back as the error. mode goes through PUT /projects/:id.json with wallpaper_mode, sending the current name the SDK requires and nothing else. Each command answers with the project re-read, so the output shows wallpaper {url, mode}, or no wallpaper after remove; a failed re-read falls back with a notice, since the change has already happened. --- .surface | 190 ++++++++++ e2e/projects.bats | 93 +++++ e2e/smoke/smoke_projects_write.bats | 39 +++ internal/commands/commands.go | 2 +- internal/commands/projects.go | 274 +++++++++++++++ internal/commands/projects_wallpaper_test.go | 347 +++++++++++++++++++ 6 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 e2e/projects.bats create mode 100644 internal/commands/projects_wallpaper_test.go diff --git a/.surface b/.surface index 935e43236..1b6b8238d 100644 --- a/.surface +++ b/.surface @@ -334,11 +334,15 @@ ARG basecamp project delete 00 ARG basecamp project show 00 ARG basecamp project trash 00 ARG basecamp project update 00 +ARG basecamp project wallpaper mode 00 +ARG basecamp project wallpaper set 00 ARG basecamp projects create 00 ARG basecamp projects delete 00 ARG basecamp projects show 00 ARG basecamp projects trash 00 ARG basecamp projects update 00 +ARG basecamp projects wallpaper mode 00 +ARG basecamp projects wallpaper set 00 ARG basecamp recordings 00 [type] ARG basecamp recordings active 00 ARG basecamp recordings archive 00 @@ -1028,6 +1032,10 @@ CMD basecamp project list CMD basecamp project show CMD basecamp project trash CMD basecamp project update +CMD basecamp project wallpaper +CMD basecamp project wallpaper mode +CMD basecamp project wallpaper remove +CMD basecamp project wallpaper set CMD basecamp projects CMD basecamp projects create CMD basecamp projects delete @@ -1035,6 +1043,10 @@ CMD basecamp projects list CMD basecamp projects show CMD basecamp projects trash CMD basecamp projects update +CMD basecamp projects wallpaper +CMD basecamp projects wallpaper mode +CMD basecamp projects wallpaper remove +CMD basecamp projects wallpaper set CMD basecamp recordings CMD basecamp recordings active CMD basecamp recordings archive @@ -13236,6 +13248,91 @@ FLAG basecamp project update --stats type=bool FLAG basecamp project update --styled type=bool FLAG basecamp project update --todolist type=string FLAG basecamp project update --verbose type=count +FLAG basecamp project wallpaper --account type=string +FLAG basecamp project wallpaper --agent type=bool +FLAG basecamp project wallpaper --cache-dir type=string +FLAG basecamp project wallpaper --count type=bool +FLAG basecamp project wallpaper --help type=bool +FLAG basecamp project wallpaper --hints type=bool +FLAG basecamp project wallpaper --ids-only type=bool +FLAG basecamp project wallpaper --in type=string +FLAG basecamp project wallpaper --jq type=string +FLAG basecamp project wallpaper --json type=bool +FLAG basecamp project wallpaper --markdown type=bool +FLAG basecamp project wallpaper --md type=bool +FLAG basecamp project wallpaper --no-hints type=bool +FLAG basecamp project wallpaper --no-stats type=bool +FLAG basecamp project wallpaper --profile type=string +FLAG basecamp project wallpaper --project type=string +FLAG basecamp project wallpaper --quiet type=bool +FLAG basecamp project wallpaper --stats type=bool +FLAG basecamp project wallpaper --styled type=bool +FLAG basecamp project wallpaper --todolist type=string +FLAG basecamp project wallpaper --verbose type=count +FLAG basecamp project wallpaper mode --account type=string +FLAG basecamp project wallpaper mode --agent type=bool +FLAG basecamp project wallpaper mode --cache-dir type=string +FLAG basecamp project wallpaper mode --count type=bool +FLAG basecamp project wallpaper mode --help type=bool +FLAG basecamp project wallpaper mode --hints type=bool +FLAG basecamp project wallpaper mode --ids-only type=bool +FLAG basecamp project wallpaper mode --in type=string +FLAG basecamp project wallpaper mode --jq type=string +FLAG basecamp project wallpaper mode --json type=bool +FLAG basecamp project wallpaper mode --markdown type=bool +FLAG basecamp project wallpaper mode --md type=bool +FLAG basecamp project wallpaper mode --no-hints type=bool +FLAG basecamp project wallpaper mode --no-stats type=bool +FLAG basecamp project wallpaper mode --profile type=string +FLAG basecamp project wallpaper mode --project type=string +FLAG basecamp project wallpaper mode --quiet type=bool +FLAG basecamp project wallpaper mode --stats type=bool +FLAG basecamp project wallpaper mode --styled type=bool +FLAG basecamp project wallpaper mode --todolist type=string +FLAG basecamp project wallpaper mode --verbose type=count +FLAG basecamp project wallpaper remove --account type=string +FLAG basecamp project wallpaper remove --agent type=bool +FLAG basecamp project wallpaper remove --cache-dir type=string +FLAG basecamp project wallpaper remove --count type=bool +FLAG basecamp project wallpaper remove --help type=bool +FLAG basecamp project wallpaper remove --hints type=bool +FLAG basecamp project wallpaper remove --ids-only type=bool +FLAG basecamp project wallpaper remove --in type=string +FLAG basecamp project wallpaper remove --jq type=string +FLAG basecamp project wallpaper remove --json type=bool +FLAG basecamp project wallpaper remove --markdown type=bool +FLAG basecamp project wallpaper remove --md type=bool +FLAG basecamp project wallpaper remove --no-hints type=bool +FLAG basecamp project wallpaper remove --no-stats type=bool +FLAG basecamp project wallpaper remove --profile type=string +FLAG basecamp project wallpaper remove --project type=string +FLAG basecamp project wallpaper remove --quiet type=bool +FLAG basecamp project wallpaper remove --stats type=bool +FLAG basecamp project wallpaper remove --styled type=bool +FLAG basecamp project wallpaper remove --todolist type=string +FLAG basecamp project wallpaper remove --verbose type=count +FLAG basecamp project wallpaper set --account type=string +FLAG basecamp project wallpaper set --agent type=bool +FLAG basecamp project wallpaper set --cache-dir type=string +FLAG basecamp project wallpaper set --count type=bool +FLAG basecamp project wallpaper set --help type=bool +FLAG basecamp project wallpaper set --hints type=bool +FLAG basecamp project wallpaper set --ids-only type=bool +FLAG basecamp project wallpaper set --in type=string +FLAG basecamp project wallpaper set --jq type=string +FLAG basecamp project wallpaper set --json type=bool +FLAG basecamp project wallpaper set --markdown type=bool +FLAG basecamp project wallpaper set --md type=bool +FLAG basecamp project wallpaper set --mode type=string +FLAG basecamp project wallpaper set --no-hints type=bool +FLAG basecamp project wallpaper set --no-stats type=bool +FLAG basecamp project wallpaper set --profile type=string +FLAG basecamp project wallpaper set --project type=string +FLAG basecamp project wallpaper set --quiet type=bool +FLAG basecamp project wallpaper set --stats type=bool +FLAG basecamp project wallpaper set --styled type=bool +FLAG basecamp project wallpaper set --todolist type=string +FLAG basecamp project wallpaper set --verbose type=count FLAG basecamp projects --account type=string FLAG basecamp projects --agent type=bool FLAG basecamp projects --cache-dir type=string @@ -13393,6 +13490,91 @@ FLAG basecamp projects update --stats type=bool FLAG basecamp projects update --styled type=bool FLAG basecamp projects update --todolist type=string FLAG basecamp projects update --verbose type=count +FLAG basecamp projects wallpaper --account type=string +FLAG basecamp projects wallpaper --agent type=bool +FLAG basecamp projects wallpaper --cache-dir type=string +FLAG basecamp projects wallpaper --count type=bool +FLAG basecamp projects wallpaper --help type=bool +FLAG basecamp projects wallpaper --hints type=bool +FLAG basecamp projects wallpaper --ids-only type=bool +FLAG basecamp projects wallpaper --in type=string +FLAG basecamp projects wallpaper --jq type=string +FLAG basecamp projects wallpaper --json type=bool +FLAG basecamp projects wallpaper --markdown type=bool +FLAG basecamp projects wallpaper --md type=bool +FLAG basecamp projects wallpaper --no-hints type=bool +FLAG basecamp projects wallpaper --no-stats type=bool +FLAG basecamp projects wallpaper --profile type=string +FLAG basecamp projects wallpaper --project type=string +FLAG basecamp projects wallpaper --quiet type=bool +FLAG basecamp projects wallpaper --stats type=bool +FLAG basecamp projects wallpaper --styled type=bool +FLAG basecamp projects wallpaper --todolist type=string +FLAG basecamp projects wallpaper --verbose type=count +FLAG basecamp projects wallpaper mode --account type=string +FLAG basecamp projects wallpaper mode --agent type=bool +FLAG basecamp projects wallpaper mode --cache-dir type=string +FLAG basecamp projects wallpaper mode --count type=bool +FLAG basecamp projects wallpaper mode --help type=bool +FLAG basecamp projects wallpaper mode --hints type=bool +FLAG basecamp projects wallpaper mode --ids-only type=bool +FLAG basecamp projects wallpaper mode --in type=string +FLAG basecamp projects wallpaper mode --jq type=string +FLAG basecamp projects wallpaper mode --json type=bool +FLAG basecamp projects wallpaper mode --markdown type=bool +FLAG basecamp projects wallpaper mode --md type=bool +FLAG basecamp projects wallpaper mode --no-hints type=bool +FLAG basecamp projects wallpaper mode --no-stats type=bool +FLAG basecamp projects wallpaper mode --profile type=string +FLAG basecamp projects wallpaper mode --project type=string +FLAG basecamp projects wallpaper mode --quiet type=bool +FLAG basecamp projects wallpaper mode --stats type=bool +FLAG basecamp projects wallpaper mode --styled type=bool +FLAG basecamp projects wallpaper mode --todolist type=string +FLAG basecamp projects wallpaper mode --verbose type=count +FLAG basecamp projects wallpaper remove --account type=string +FLAG basecamp projects wallpaper remove --agent type=bool +FLAG basecamp projects wallpaper remove --cache-dir type=string +FLAG basecamp projects wallpaper remove --count type=bool +FLAG basecamp projects wallpaper remove --help type=bool +FLAG basecamp projects wallpaper remove --hints type=bool +FLAG basecamp projects wallpaper remove --ids-only type=bool +FLAG basecamp projects wallpaper remove --in type=string +FLAG basecamp projects wallpaper remove --jq type=string +FLAG basecamp projects wallpaper remove --json type=bool +FLAG basecamp projects wallpaper remove --markdown type=bool +FLAG basecamp projects wallpaper remove --md type=bool +FLAG basecamp projects wallpaper remove --no-hints type=bool +FLAG basecamp projects wallpaper remove --no-stats type=bool +FLAG basecamp projects wallpaper remove --profile type=string +FLAG basecamp projects wallpaper remove --project type=string +FLAG basecamp projects wallpaper remove --quiet type=bool +FLAG basecamp projects wallpaper remove --stats type=bool +FLAG basecamp projects wallpaper remove --styled type=bool +FLAG basecamp projects wallpaper remove --todolist type=string +FLAG basecamp projects wallpaper remove --verbose type=count +FLAG basecamp projects wallpaper set --account type=string +FLAG basecamp projects wallpaper set --agent type=bool +FLAG basecamp projects wallpaper set --cache-dir type=string +FLAG basecamp projects wallpaper set --count type=bool +FLAG basecamp projects wallpaper set --help type=bool +FLAG basecamp projects wallpaper set --hints type=bool +FLAG basecamp projects wallpaper set --ids-only type=bool +FLAG basecamp projects wallpaper set --in type=string +FLAG basecamp projects wallpaper set --jq type=string +FLAG basecamp projects wallpaper set --json type=bool +FLAG basecamp projects wallpaper set --markdown type=bool +FLAG basecamp projects wallpaper set --md type=bool +FLAG basecamp projects wallpaper set --mode type=string +FLAG basecamp projects wallpaper set --no-hints type=bool +FLAG basecamp projects wallpaper set --no-stats type=bool +FLAG basecamp projects wallpaper set --profile type=string +FLAG basecamp projects wallpaper set --project type=string +FLAG basecamp projects wallpaper set --quiet type=bool +FLAG basecamp projects wallpaper set --stats type=bool +FLAG basecamp projects wallpaper set --styled type=bool +FLAG basecamp projects wallpaper set --todolist type=string +FLAG basecamp projects wallpaper set --verbose type=count FLAG basecamp recordings --account type=string FLAG basecamp recordings --agent type=bool FLAG basecamp recordings --all type=bool @@ -19873,6 +20055,10 @@ SUB basecamp project list SUB basecamp project show SUB basecamp project trash SUB basecamp project update +SUB basecamp project wallpaper +SUB basecamp project wallpaper mode +SUB basecamp project wallpaper remove +SUB basecamp project wallpaper set SUB basecamp projects SUB basecamp projects create SUB basecamp projects delete @@ -19880,6 +20066,10 @@ SUB basecamp projects list SUB basecamp projects show SUB basecamp projects trash SUB basecamp projects update +SUB basecamp projects wallpaper +SUB basecamp projects wallpaper mode +SUB basecamp projects wallpaper remove +SUB basecamp projects wallpaper set SUB basecamp recordings SUB basecamp recordings active SUB basecamp recordings archive diff --git a/e2e/projects.bats b/e2e/projects.bats new file mode 100644 index 000000000..91c3b28d8 --- /dev/null +++ b/e2e/projects.bats @@ -0,0 +1,93 @@ +#!/usr/bin/env bats +# projects.bats - Test projects command input handling + +load test_helper + + +# Help + +@test "projects wallpaper without subcommand shows help" { + run basecamp projects wallpaper + assert_success + assert_output_contains "COMMANDS" + assert_output_contains "set" + assert_output_contains "mode" + assert_output_contains "remove" +} + +@test "projects wallpaper set help names the formats and the size cap" { + run basecamp projects wallpaper set --help + assert_success + assert_output_contains "PNG, JPEG, WebP, or AVIF files up to 10 MB" +} + + +# Input errors that need no network + +@test "projects wallpaper set without a file shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper set + assert_failure + assert_json_value '.error' ' required' + assert_json_value '.code' 'usage' +} + +@test "projects wallpaper set refuses a GIF" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + printf 'GIF89a' > anim.gif + + run basecamp projects wallpaper set anim.gif + assert_failure + assert_json_value '.error' 'anim.gif: unsupported image type image/gif (use PNG, JPEG, WebP, or AVIF)' + assert_json_value '.code' 'usage' +} + +@test "projects wallpaper set refuses a file over 10 MB" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + truncate -s 10485761 big.png + + run basecamp projects wallpaper set big.png + assert_failure + assert_json_value '.error' 'big.png exceeds maximum wallpaper size of 10 MB' +} + +@test "projects wallpaper set refuses an unknown --mode" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + printf '\x89PNG\r\n\x1a\n' > skyline.png + + run basecamp projects wallpaper set skyline.png --mode stretch + assert_failure + assert_json_value '.error' 'unknown wallpaper mode "stretch"' + assert_json_value '.hint' 'Use fill or tile' +} + +@test "projects wallpaper mode without a mode shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper mode + assert_failure + assert_json_value '.error' ' required' +} + +@test "projects wallpaper mode refuses an unknown mode" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper mode stretch + assert_failure + assert_json_value '.error' 'unknown wallpaper mode "stretch"' +} + +@test "projects wallpaper remove takes no arguments" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper remove skyline.png + assert_failure +} diff --git a/e2e/smoke/smoke_projects_write.bats b/e2e/smoke/smoke_projects_write.bats index aa7fea558..7aecce48b 100644 --- a/e2e/smoke/smoke_projects_write.bats +++ b/e2e/smoke/smoke_projects_write.bats @@ -30,6 +30,45 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "projects wallpaper set uploads a tiled wallpaper" { + local id_file="$BATS_FILE_TMPDIR/created_project_id" + [[ -f "$id_file" ]] || mark_unverifiable "No project created in prior test" + local proj_id + proj_id=$(<"$id_file") + local png="$BATS_FILE_TMPDIR/wallpaper.png" + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' | + base64 -d > "$png" + + run_smoke basecamp projects wallpaper set "$png" --mode tile --project "$proj_id" --json + if [[ "$status" -ne 0 ]] && [[ "$output" == *"not found"* || "$output" == *"404"* ]]; then + mark_unverifiable "This Basecamp does not serve project wallpaper to API clients yet" + fi + assert_success + assert_json_value '.data.wallpaper.mode' 'tile' + assert_json_not_null '.data.wallpaper.url' + touch "$BATS_FILE_TMPDIR/wallpaper_set" +} + +@test "projects wallpaper mode changes the layout" { + [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" + local proj_id + proj_id=$(<"$BATS_FILE_TMPDIR/created_project_id") + + run_smoke basecamp projects wallpaper mode fill --project "$proj_id" --json + assert_success + assert_json_value '.data.wallpaper.mode' 'fill' +} + +@test "projects wallpaper remove clears the wallpaper" { + [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" + local proj_id + proj_id=$(<"$BATS_FILE_TMPDIR/created_project_id") + + run_smoke basecamp projects wallpaper remove --project "$proj_id" --json + assert_success + assert_json_value '.data.wallpaper' 'null' +} + @test "projects delete deletes a project" { local id_file="$BATS_FILE_TMPDIR/created_project_id" [[ -f "$id_file" ]] || mark_unverifiable "No project created in prior test" diff --git a/internal/commands/commands.go b/internal/commands/commands.go index ca6c799cc..7936ac2d7 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -34,7 +34,7 @@ func CommandCategories() []CommandCategory { { Name: "Core Commands", Commands: []CommandInfo{ - {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete"}}, + {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete", "wallpaper"}}, {Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "trash", "archive", "restore"}}, {Name: "subtasks", Category: "core", Description: "Manage subtasks on to-dos and cards", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "move", "delete"}}, {Name: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "position", "trash", "archive", "restore"}}, diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 4cf270ddc..0a4772980 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -3,6 +3,8 @@ package commands import ( "errors" "fmt" + "os" + "path/filepath" "slices" "sort" "strconv" @@ -15,6 +17,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/completion" "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/richtext" ) // NewProjectsCmd creates the projects command group. @@ -37,6 +40,7 @@ func NewProjectsCmd() *cobra.Command { newProjectsCreateCmd(), newProjectsUpdateCmd(), newProjectsDeleteCmd(), + newProjectsWallpaperCmd(), ) return cmd @@ -427,6 +431,276 @@ func newProjectsDeleteCmd() *cobra.Command { } } +// wallpaperFormats are the image types a project wallpaper may be: the set the +// web's picker offers, which the server holds the API to as well. +var wallpaperFormats = []struct{ mime, name string }{ + {"image/png", "PNG"}, + {"image/jpeg", "JPEG"}, + {"image/webp", "WebP"}, + {"image/avif", "AVIF"}, +} + +const maxWallpaperMB = 10 + +var wallpaperModes = []string{"fill", "tile"} + +func wallpaperAgentNotes() map[string]string { + return map[string]string{"agent_notes": "Agent profiles are refused (403): changing a wallpaper needs a person who can edit the project\n.data.wallpaper on the returned project is {url, mode}, absent when there is none"} +} + +func newProjectsWallpaperCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "wallpaper", + Short: "Manage a project's wallpaper", + Annotations: wallpaperAgentNotes(), + Long: `Set, lay out, or remove a project's wallpaper. + +The project comes from --project (or --in), then the configured default. +Only people who can edit the project can change its wallpaper.`, + Example: ` $ basecamp projects wallpaper set skyline.jpg --mode fill --project 12345 + $ basecamp projects wallpaper mode tile --project 12345 + $ basecamp projects wallpaper remove --project 12345`, + } + + cmd.AddCommand( + newProjectsWallpaperSetCmd(), + newProjectsWallpaperModeCmd(), + newProjectsWallpaperRemoveCmd(), + ) + + return cmd +} + +func newProjectsWallpaperSetCmd() *cobra.Command { + var mode string + + cmd := &cobra.Command{ + Use: "set ", + Annotations: wallpaperAgentNotes(), + Short: "Upload or replace a project's wallpaper", + Long: fmt.Sprintf(`Upload or replace the project's wallpaper. + +Accepts %s files up to %d MB. +--mode lays the image out (%s); without it the project keeps +its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpaperModes, " or ")), + Example: " $ basecamp projects wallpaper set skyline.jpg --mode fill --project 12345", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "") + } + if mode != "" { + if err := validateWallpaperMode(mode); err != nil { + return err + } + } + path, contentType, err := wallpaperFile(args[0]) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + projectID, err := wallpaperProjectID(cmd, app) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("%s: %w", args[0], err) + } + defer f.Close() + + filename := filepath.Base(path) + opts := &basecamp.UpdateProjectWallpaperOptions{Mode: mode} + if err := app.Account().Projects().UpdateWallpaper(cmd.Context(), projectID, f, filename, contentType, opts); err != nil { + return convertSDKError(err) + } + + summary := "Wallpaper set: " + filename + if mode != "" { + summary += " (" + mode + ")" + } + return respondWithWallpaperProject(cmd, app, projectID, map[string]any{"id": projectID}, summary, + output.Breadcrumb{ + Action: "mode", + Cmd: fmt.Sprintf("basecamp projects wallpaper mode --project %d", projectID), + Description: "Change how the wallpaper is laid out", + }, + output.Breadcrumb{ + Action: "remove", + Cmd: fmt.Sprintf("basecamp projects wallpaper remove --project %d", projectID), + Description: "Remove the wallpaper", + }, + ) + }, + } + + cmd.Flags().StringVar(&mode, "mode", "", "Lay the image out: "+strings.Join(wallpaperModes, " or ")+" (default: keep the current mode)") + _ = cmd.RegisterFlagCompletionFunc("mode", cobra.FixedCompletions(wallpaperModes, cobra.ShellCompDirectiveNoFileComp)) + + return cmd +} + +func newProjectsWallpaperModeCmd() *cobra.Command { + return &cobra.Command{ + Use: "mode ", + Annotations: wallpaperAgentNotes(), + Short: "Change how a project's wallpaper is laid out", + Long: `Change how the project's wallpaper is laid out. fill scales one copy to +cover the page; tile repeats the image at its own size. + +A project with no wallpaper keeps the mode for the next one uploaded.`, + Example: " $ basecamp projects wallpaper mode tile --project 12345", + Args: cobra.MaximumNArgs(1), + ValidArgs: wallpaperModes, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "") + } + mode := args[0] + if err := validateWallpaperMode(mode); err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + projectID, err := wallpaperProjectID(cmd, app) + if err != nil { + return err + } + + // The SDK's update requires a name, so send the current one back. + current, err := app.Account().Projects().Get(cmd.Context(), projectID) + if err != nil { + return convertSDKError(err) + } + req := &basecamp.UpdateProjectRequest{Name: current.Name, WallpaperMode: mode} + project, err := app.Account().Projects().Update(cmd.Context(), projectID, req) + if err != nil { + return convertSDKError(err) + } + + return respondWithWallpaperProject(cmd, app, projectID, project, "Wallpaper mode set to "+mode, + output.Breadcrumb{ + Action: "set", + Cmd: fmt.Sprintf("basecamp projects wallpaper set --project %d", projectID), + Description: "Upload a wallpaper", + }, + ) + }, + } +} + +func newProjectsWallpaperRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove", + Annotations: wallpaperAgentNotes(), + Short: "Remove a project's wallpaper", + Long: "Remove the project's wallpaper. Removing it from a project that has none succeeds.", + Example: " $ basecamp projects wallpaper remove --project 12345", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + projectID, err := wallpaperProjectID(cmd, app) + if err != nil { + return err + } + + if err := app.Account().Projects().RemoveWallpaper(cmd.Context(), projectID); err != nil { + return convertSDKError(err) + } + + return respondWithWallpaperProject(cmd, app, projectID, map[string]any{"id": projectID}, "Wallpaper removed", + output.Breadcrumb{ + Action: "set", + Cmd: fmt.Sprintf("basecamp projects wallpaper set --project %d", projectID), + Description: "Upload a new wallpaper", + }, + ) + }, + } +} + +// wallpaperFile refuses, before anything is sent, a file the server would +// refuse for its size or type. The server sniffs the bytes, so a mislabeled +// file still comes back as its 422. +func wallpaperFile(arg string) (path, contentType string, err error) { + path = richtext.NormalizeDragPath(arg) + if err := richtext.ValidateFile(path); err != nil { + return "", "", fmt.Errorf("%s: %w", arg, err) + } + info, err := os.Stat(path) + if err != nil { + return "", "", fmt.Errorf("%s: %w", arg, err) + } + name := filepath.Base(path) + if info.Size() > maxWallpaperMB<<20 { + return "", "", output.ErrUsage(fmt.Sprintf("%s exceeds maximum wallpaper size of %d MB", name, maxWallpaperMB)) + } + contentType = richtext.DetectMIME(path) + for _, f := range wallpaperFormats { + if f.mime == contentType { + return path, contentType, nil + } + } + return "", "", output.ErrUsage(fmt.Sprintf("%s: unsupported image type %s (use %s)", name, contentType, wallpaperFormatNames())) +} + +// wallpaperFormatNames reads "PNG, JPEG, WebP, or AVIF". +func wallpaperFormatNames() string { + names := make([]string, len(wallpaperFormats)) + for i, f := range wallpaperFormats { + names[i] = f.name + } + return strings.Join(names[:len(names)-1], ", ") + ", or " + names[len(names)-1] +} + +func validateWallpaperMode(mode string) error { + if slices.Contains(wallpaperModes, mode) { + return nil + } + return output.ErrUsageHint(fmt.Sprintf("unknown wallpaper mode %q", mode), "Use "+strings.Join(wallpaperModes, " or ")) +} + +func wallpaperProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { + if err := ensureAccount(cmd, app); err != nil { + return 0, err + } + resolved, err := resolveProjectID(cmd, app, "") + if err != nil { + return 0, err + } + projectID, err := strconv.ParseInt(resolved, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid project ID") + } + return projectID, nil +} + +// respondWithWallpaperProject answers with the project as it now stands, so +// the output carries its wallpaper. The change has already happened, so a +// failed read falls back to what the caller has, with a diagnostic, rather +// than failing the command. +func respondWithWallpaperProject(cmd *cobra.Command, app *appctx.App, projectID int64, fallback any, summary string, crumbs ...output.Breadcrumb) error { + opts := []output.ResponseOption{ + output.WithEntity("project"), + output.WithSummary(summary), + output.WithBreadcrumbs(append([]output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp projects show %d", projectID), + Description: "View the project", + }}, crumbs...)...), + } + data := fallback + project, err := app.Account().Projects().Get(cmd.Context(), projectID) + if err != nil { + opts = append(opts, output.WithDiagnostic(fmt.Sprintf("%s, but fetching the latest project state failed: %v", summary, err))) + } else { + data = project + } + return app.OK(data, opts...) +} + // convertSDKError converts SDK errors to output errors for consistent CLI error handling. func convertSDKError(err error) error { if err == nil { diff --git a/internal/commands/projects_wallpaper_test.go b/internal/commands/projects_wallpaper_test.go new file mode 100644 index 000000000..ea754994e --- /dev/null +++ b/internal/commands/projects_wallpaper_test.go @@ -0,0 +1,347 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +const wallpaperURL = "https://3.basecamp.com/99999/projects/123/wallpaper?v=1" + +// wallpaperServer plays bc3's side of the wallpaper endpoints for project 123: +// it records each request and keeps the wallpaper the requests leave behind, +// so a re-read of the project shows what the change did. +type wallpaperServer struct { + t *testing.T + mu sync.Mutex + requests []wallpaperRequest + attached bool + mode string + refuse string +} + +type wallpaperRequest struct { + method, path, mode string + upload *wallpaperUpload + update map[string]any +} + +type wallpaperUpload struct { + field, filename, contentType string + data []byte +} + +func newWallpaperServer(t *testing.T) (*wallpaperServer, *appctx.App, *bytes.Buffer) { + t.Helper() + s := &wallpaperServer{t: t, mode: "fill"} + server := httptest.NewServer(http.HandlerFunc(s.serve)) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + return s, app, buf +} + +func (s *wallpaperServer) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + req := wallpaperRequest{method: r.Method, path: r.URL.Path, mode: r.URL.Query().Get("mode")} + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodPut && r.URL.Path == "/99999/projects/123/wallpaper.json": + req.upload = s.readUpload(r) + s.requests = append(s.requests, req) + if s.refuse != "" { + w.WriteHeader(http.StatusUnprocessableEntity) + fmt.Fprintf(w, `{"error":%q}`, s.refuse) + return + } + s.attached = true + if req.mode != "" { + s.mode = req.mode + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodDelete && r.URL.Path == "/99999/projects/123/wallpaper.json": + s.requests = append(s.requests, req) + s.attached = false + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPut && r.URL.Path == "/99999/projects/123": + require.NoError(s.t, json.NewDecoder(r.Body).Decode(&req.update)) + s.requests = append(s.requests, req) + if mode, ok := req.update["wallpaper_mode"].(string); ok { + s.mode = mode + } + fmt.Fprint(w, s.projectJSON()) + case r.Method == http.MethodGet && r.URL.Path == "/99999/projects/123": + s.requests = append(s.requests, req) + fmt.Fprint(w, s.projectJSON()) + default: + s.requests = append(s.requests, req) + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":"not found"}`) + } +} + +func (s *wallpaperServer) readUpload(r *http.Request) *wallpaperUpload { + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(s.t, err) + part, err := multipart.NewReader(r.Body, params["boundary"]).NextPart() + require.NoError(s.t, err) + data, err := io.ReadAll(part) + require.NoError(s.t, err) + return &wallpaperUpload{ + field: part.FormName(), + filename: part.FileName(), + contentType: part.Header.Get("Content-Type"), + data: data, + } +} + +func (s *wallpaperServer) projectJSON() string { + if !s.attached { + return `{"id":123,"name":"Launch","description":"Keep me"}` + } + return fmt.Sprintf(`{"id":123,"name":"Launch","description":"Keep me","wallpaper":{"url":%q,"mode":%q}}`, wallpaperURL, s.mode) +} + +func (s *wallpaperServer) calls() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.requests)) + for i, r := range s.requests { + out[i] = r.method + " " + r.path + } + return out +} + +func (s *wallpaperServer) request(method, path string) wallpaperRequest { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range s.requests { + if r.method == method && r.path == path { + return r + } + } + s.t.Fatalf("no %s %s among %v", method, path, s.requests) + return wallpaperRequest{} +} + +type wallpaperEnvelope struct { + OK bool `json:"ok"` + Summary string `json:"summary"` + Notice string `json:"notice"` + Breadcrumbs []output.Breadcrumb `json:"breadcrumbs"` + Data map[string]any `json:"data"` +} + +func decodeWallpaperEnvelope(t *testing.T, buf *bytes.Buffer) wallpaperEnvelope { + t.Helper() + var env wallpaperEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &env), "output: %s", buf.String()) + return env +} + +func writeWallpaperFile(t *testing.T, name string, size int64) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte("\x89PNG\r\n\x1a\n"), 0o600)) + if size > 0 { + require.NoError(t, os.Truncate(path, size)) + } + return path +} + +func TestProjectsWallpaperSetUploadsTheFileAndReturnsTheProject(t *testing.T) { + s, app, buf := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, "skyline.png", 0) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path, "--mode", "tile")) + + assert.Equal(t, []string{ + "PUT /99999/projects/123/wallpaper.json", + "GET /99999/projects/123", + }, s.calls()) + put := s.request(http.MethodPut, "/99999/projects/123/wallpaper.json") + assert.Equal(t, "tile", put.mode) + require.NotNil(t, put.upload) + assert.Equal(t, "wallpaper", put.upload.field) + assert.Equal(t, "skyline.png", put.upload.filename) + assert.Equal(t, "image/png", put.upload.contentType) + assert.Equal(t, []byte("\x89PNG\r\n\x1a\n"), put.upload.data) + + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, "Wallpaper set: skyline.png (tile)", env.Summary) + assert.Equal(t, map[string]any{"url": wallpaperURL, "mode": "tile"}, env.Data["wallpaper"]) + assert.Equal(t, "basecamp projects show 123", env.Breadcrumbs[0].Cmd) +} + +func TestProjectsWallpaperSetWithoutModeKeepsTheCurrentMode(t *testing.T) { + s, app, buf := newWallpaperServer(t) + app.Config.ProjectID = "123" + path := writeWallpaperFile(t, "skyline.png", 0) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path)) + + put := s.request(http.MethodPut, "/99999/projects/123/wallpaper.json") + assert.Empty(t, put.mode, "no mode is sent, so the server keeps its own") + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Wallpaper set: skyline.png", env.Summary) + assert.Equal(t, map[string]any{"url": wallpaperURL, "mode": "fill"}, env.Data["wallpaper"]) +} + +func TestProjectsWallpaperSetAcceptsExactlyTheSizeCap(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, "big.jpg", maxWallpaperMB<<20) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path)) + + assert.Equal(t, "image/jpeg", s.request(http.MethodPut, "/99999/projects/123/wallpaper.json").upload.contentType) +} + +func TestProjectsWallpaperSetRefusesBeforeAnyRequest(t *testing.T) { + cases := []struct { + name, file string + size int64 + args []string + message string + }{ + {"over the size cap", "big.png", maxWallpaperMB<<20 + 1, nil, "big.png exceeds maximum wallpaper size of 10 MB"}, + {"a GIF", "anim.gif", 0, nil, "anim.gif: unsupported image type image/gif (use PNG, JPEG, WebP, or AVIF)"}, + {"a HEIC", "photo.heic", 0, nil, "photo.heic: unsupported image type image/heic (use PNG, JPEG, WebP, or AVIF)"}, + {"an unknown mode", "skyline.png", 0, []string{"--mode", "stretch"}, `unknown wallpaper mode "stretch"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, tc.file, tc.size) + + err := executeCommand(NewProjectsCmd(), app, append([]string{"wallpaper", "set", path}, tc.args...)...) + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, output.CodeUsage, outErr.Code) + assert.Equal(t, tc.message, outErr.Message) + assert.Empty(t, s.calls()) + }) + } +} + +func TestProjectsWallpaperSetRefusesAMissingFileBeforeAnyRequest(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + + err := executeCommand(NewProjectsCmd(), app, "wallpaper", "set", filepath.Join(t.TempDir(), "nope.png")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "nope.png") + assert.Empty(t, s.calls()) +} + +func TestProjectsWallpaperSetCarriesTheServersRefusal(t *testing.T) { + s, app, _ := newWallpaperServer(t) + s.refuse = "Wallpaper must be a PNG, JPEG, WebP, or AVIF image" + app.Flags.Project = "123" + path := writeWallpaperFile(t, "disguised.png", 0) + + err := executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path) + + require.Error(t, err) + assert.Contains(t, err.Error(), s.refuse) + assert.Equal(t, []string{"PUT /99999/projects/123/wallpaper.json"}, s.calls(), "a refused upload is not followed by a read") +} + +func TestProjectsWallpaperModeSendsTheCurrentNameWithTheMode(t *testing.T) { + s, app, buf := newWallpaperServer(t) + s.attached = true + app.Flags.Project = "123" + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "mode", "tile")) + + assert.Equal(t, []string{ + "GET /99999/projects/123", + "PUT /99999/projects/123", + "GET /99999/projects/123", + }, s.calls()) + assert.Equal(t, map[string]any{"name": "Launch", "wallpaper_mode": "tile"}, + s.request(http.MethodPut, "/99999/projects/123").update, + "only the name rides along, so the description is left alone") + + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Wallpaper mode set to tile", env.Summary) + assert.Equal(t, map[string]any{"url": wallpaperURL, "mode": "tile"}, env.Data["wallpaper"]) +} + +func TestProjectsWallpaperModeRefusesAnUnknownModeBeforeAnyRequest(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + + err := executeCommand(NewProjectsCmd(), app, "wallpaper", "mode", "Tile") + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, output.CodeUsage, outErr.Code) + assert.Equal(t, `unknown wallpaper mode "Tile"`, outErr.Message) + assert.Equal(t, "Use fill or tile", outErr.Hint) + assert.Empty(t, s.calls()) +} + +func TestProjectsWallpaperRemoveDeletesAndReturnsTheProjectWithoutWallpaper(t *testing.T) { + s, app, buf := newWallpaperServer(t) + s.attached = true + app.Flags.Project = "123" + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "remove")) + + assert.Equal(t, []string{ + "DELETE /99999/projects/123/wallpaper.json", + "GET /99999/projects/123", + }, s.calls()) + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Wallpaper removed", env.Summary) + assert.Equal(t, float64(123), env.Data["id"]) + assert.NotContains(t, env.Data, "wallpaper") +} + +func TestProjectsWallpaperReportsAFailedReadAfterTheChange(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"boom"}`) + })) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "remove")) + + assert.Equal(t, []string{"DELETE /99999/projects/123/wallpaper.json", "GET /99999/projects/123"}, calls) + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, float64(123), env.Data["id"]) + assert.Contains(t, env.Notice, "Wallpaper removed, but fetching the latest project state failed") +} From aced8045987c4bcbea553531776eeb360bbcda76 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 22 Oct 2026 20:48:06 -0400 Subject: [PATCH 2/6] Document project wallpaper in the skill and API coverage The skill says which commands change a wallpaper and what an agent cannot learn from leaf help in time: an agent profile is always refused, so retrying or reaching for basecamp api is wrong. An eval case holds that the agent uses wallpaper set rather than projects update. API-COVERAGE gains a BC5 projects (wallpaper) row for the two new endpoints, and the totals move from 209 to 211. --- API-COVERAGE.md | 9 ++++++--- skill-evals/cases/project-wallpaper.yml | 9 +++++++++ skills/basecamp/SKILL.md | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 skill-evals/cases/project-wallpaper.yml diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 5a8bc5452..f92321e97 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,14 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 55 | 211 | +| ✅ Implemented | 55 | 213 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **59** | **223** | +| **Total tracked** | **59** | **225** | -**211 of 211 tracked in-scope endpoints.** Spotlighting a recording on its +**213 of 213 tracked in-scope endpoints.** Uploading and removing a project's +wallpaper (`PUT`/`DELETE /projects/:id/wallpaper.json`, added by bc3#14062 and +modelled by basecamp-sdk#1037) land as `projects wallpaper`. Spotlighting a recording on its project or template home page (`POST`/`DELETE /recordings/:id/spotlight.json`, SDK v0.27.0) lands as `spotlight` and `unspotlight`. The two embed endpoints bc3 documents in `embeds.md` — `POST /embeds.json` and `GET /embeds/:sgid.json` — land as @@ -201,6 +203,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A |---------|-----------|-------------|--------|-------|----------|-------| | **Core** | | projects | 9 | `projects` | ✅ | BC4 | - | list, show, create, update, delete | +| projects (wallpaper) | 2 | `projects wallpaper` | ✅ | BC5 | - | set (`PUT /projects/:id/wallpaper.json`, multipart `wallpaper` with an optional `mode`), remove (`DELETE` on the same route; 204 with or without a wallpaper). `mode` sends `wallpaper_mode` through the existing `PUT /projects/:id.json`, with the current name the SDK requires. PNG, JPEG, WebP or AVIF up to 10 MB, checked before the upload; the server sniffs the bytes and holds the web's rules (editors only, agents refused with 403). Project JSON carries `wallpaper {url, mode}` when one is attached, so `projects show` shows it. Project-scoped via `--in`/`--project` | | todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`, plus `subtasks_count`/`subtasks_completed_count`/`subtasks_url`; edit via `subtasks`). `todos create --loose` creates on the to-do set, outside any list | | subtasks | 8 | `subtasks` | ✅ | BC5 | - | list (`GET /recordings/:id/subtasks.json`, paginated), show, create (`POST` on the same list route), update (partial; `--no-due`/`--no-assignees` clear), complete/uncomplete (`POST`/`DELETE /subtasks/:id/completion.json`), move (`PUT /subtasks/:id/position.json`, 1-based), delete. Account-scoped flat routes (bc3#12659), so no `--in`. Parents are to-dos and cards only. Same `Kanban::Step` records as `card_table_steps` | | todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position | diff --git a/skill-evals/cases/project-wallpaper.yml b/skill-evals/cases/project-wallpaper.yml new file mode 100644 index 000000000..b1535c3f3 --- /dev/null +++ b/skill-evals/cases/project-wallpaper.yml @@ -0,0 +1,9 @@ +task: Use ./skyline.png as the wallpaper for project 55555, repeated as a tile +tags: [domain, update] +accept: + - 'projects? wallpaper set .*skyline\.png' + - '--mode tile' + - '(--in|--project|-p) 55555' +reject: + - 'projects? update' + - 'basecamp api' diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 479bce387..c4cce659f 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -10,6 +10,7 @@ triggers: - basecamp todos - basecamp subtasks - basecamp project + - project wallpaper - basecamp cards - basecamp chat - basecamp campfire @@ -231,6 +232,27 @@ basecamp spotlight --agent basecamp unspotlight --agent ``` +## Project wallpaper + +The wallpaper is the image behind a project's pages. The `projects wallpaper` +group sets, lays out, and removes it, for the project in `--in`/`--project` or +the configured default: + +```bash +basecamp projects wallpaper set skyline.jpg --mode tile --in "" --json +basecamp projects wallpaper mode fill --in "" --json +basecamp projects wallpaper remove --in "" --json +``` + +- Basecamp applies the web's rules: only someone who can edit the project may + change it, and an agent profile is always refused (403). Say so rather than + retrying or reaching for `basecamp api`. +- The CLI refuses a file of the wrong type or size before uploading; leaf help + names the limits. `--mode` is `fill` or `tile`, and leaving it out keeps the + current mode. +- Each command returns the project. `.data.wallpaper` is `{url, mode}`, and is + absent when the project has none; `projects show` carries the same field. + ## Content and stdin Command help declares content arguments and stdin support. Titles are generally From b4cedccbbbff108f0983a3b12fdd1372dc0133c5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 22 Oct 2026 20:48:06 -0400 Subject: [PATCH 3/6] Name the wallpaper mode on a project's detail view Styled and Markdown project output listed no wallpaper, so a person running projects show or a wallpaper command saw nothing of it. Show the mode under Info when one is attached. The URL is a signed-in redirect, no use to read in a terminal, so it stays in JSON only. --- internal/presenter/format.go | 13 +++++++++++++ internal/presenter/presenter_test.go | 15 +++++++++++++++ internal/presenter/schemas/project.yaml | 6 +++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/presenter/format.go b/internal/presenter/format.go index 80b58c9d6..c99668dc3 100644 --- a/internal/presenter/format.go +++ b/internal/presenter/format.go @@ -30,11 +30,24 @@ func FormatField(spec FieldSpec, key string, val any, locale Locale) string { return formatDock(val) case "steps": return formatSteps(val) + case "wallpaper": + return formatWallpaper(val) default: return formatText(val) } } +// formatWallpaper names how a project's wallpaper is laid out. Its URL is a +// signed-in redirect, no use to read in a terminal. +func formatWallpaper(val any) string { + if m, ok := val.(map[string]any); ok { + if mode, ok := m["mode"].(string); ok { + return richtext.SanitizeSingleLine(mode) + } + } + return "" +} + // formatSteps renders a CardStep array as a multi-line checklist. // Each step prefixes its title with [x] for completed, [ ] for active. func formatSteps(val any) string { diff --git a/internal/presenter/presenter_test.go b/internal/presenter/presenter_test.go index 4a02c68b2..03b87bd06 100644 --- a/internal/presenter/presenter_test.go +++ b/internal/presenter/presenter_test.go @@ -690,6 +690,21 @@ func TestPresentEmptySliceMarkdownTable(t *testing.T) { } } +func TestProjectDetailNamesTheWallpaperMode(t *testing.T) { + project := map[string]any{"id": float64(123), "name": "Launch", "status": "active"} + + var without strings.Builder + require.True(t, PresentWithTheme(&without, project, "project", ModeMarkdown, tui.NoColorTheme(), enUS)) + assert.NotContains(t, without.String(), "Wallpaper") + + project["wallpaper"] = map[string]any{"url": "https://3.basecamp.com/1/projects/123/wallpaper", "mode": "tile"} + var with strings.Builder + require.True(t, PresentWithTheme(&with, project, "project", ModeMarkdown, tui.NoColorTheme(), enUS)) + assert.Contains(t, with.String(), "Wallpaper") + assert.Contains(t, with.String(), "tile") + assert.NotContains(t, with.String(), "/wallpaper") +} + // ============================================================================= // Collapse Tests // ============================================================================= diff --git a/internal/presenter/schemas/project.yaml b/internal/presenter/schemas/project.yaml index cd0c62501..900bb1185 100644 --- a/internal/presenter/schemas/project.yaml +++ b/internal/presenter/schemas/project.yaml @@ -29,6 +29,10 @@ fields: role: detail format: text + wallpaper: + role: detail + format: wallpaper + dock: role: body format: dock @@ -52,7 +56,7 @@ views: sections: - fields: [name, description] - heading: Info - fields: [status, bookmarked] + fields: [status, bookmarked, wallpaper] - heading: Dock fields: [dock] - heading: Metadata From 303b5aeb1ca63c9d4ecdb791ae036c1cef34382d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 22:51:52 -0400 Subject: [PATCH 4/6] Use released wallpaper SDK and match deployed image rules --- API-COVERAGE.md | 8 +- e2e/projects.bats | 10 +- e2e/smoke/smoke_projects_write.bats | 3 - go.mod | 2 +- go.sum | 4 +- internal/commands/projects.go | 18 +-- internal/commands/projects_wallpaper_test.go | 29 +++- internal/mcpserver/catalog_test.go | 11 +- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/behavior-model.json | 12 ++ internal/mcpserver/model/openapi.json | 127 ++++++++++++++++++ .../mcpserver/testdata/catalog_snapshot.txt | 1 + internal/richtext/mime.go | 1 + internal/richtext/mime_test.go | 2 + internal/version/sdk-provenance.json | 6 +- nix/package.nix | 2 +- scripts/sync-mcp-model.sh | 1 + skills/basecamp/SKILL.md | 13 +- 18 files changed, 211 insertions(+), 43 deletions(-) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index f92321e97..bb0d99b41 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -12,8 +12,10 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | **Total tracked** | **59** | **225** | **213 of 213 tracked in-scope endpoints.** Uploading and removing a project's -wallpaper (`PUT`/`DELETE /projects/:id/wallpaper.json`, added by bc3#14062 and -modelled by basecamp-sdk#1037) land as `projects wallpaper`. Spotlighting a recording on its +wallpaper (`PUT`/`DELETE /projects/:id/wallpaper.json`, added by +[bc3#14062](https://github.com/basecamp/bc3/pull/14062) and shipped in +[SDK v0.28.0](https://github.com/basecamp/basecamp-sdk/releases/tag/v0.28.0)) +land as `projects wallpaper`. Spotlighting a recording on its project or template home page (`POST`/`DELETE /recordings/:id/spotlight.json`, SDK v0.27.0) lands as `spotlight` and `unspotlight`. The two embed endpoints bc3 documents in `embeds.md` — `POST /embeds.json` and `GET /embeds/:sgid.json` — land as @@ -203,7 +205,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A |---------|-----------|-------------|--------|-------|----------|-------| | **Core** | | projects | 9 | `projects` | ✅ | BC4 | - | list, show, create, update, delete | -| projects (wallpaper) | 2 | `projects wallpaper` | ✅ | BC5 | - | set (`PUT /projects/:id/wallpaper.json`, multipart `wallpaper` with an optional `mode`), remove (`DELETE` on the same route; 204 with or without a wallpaper). `mode` sends `wallpaper_mode` through the existing `PUT /projects/:id.json`, with the current name the SDK requires. PNG, JPEG, WebP or AVIF up to 10 MB, checked before the upload; the server sniffs the bytes and holds the web's rules (editors only, agents refused with 403). Project JSON carries `wallpaper {url, mode}` when one is attached, so `projects show` shows it. Project-scoped via `--in`/`--project` | +| projects (wallpaper) | 2 | `projects wallpaper` | ✅ | BC5 | - | set (`PUT /projects/:id/wallpaper.json`, multipart `wallpaper` with an optional `mode`), remove (`DELETE` on the same route; 204 with or without a wallpaper). `mode` sends `wallpaper_mode` through the existing `PUT /projects/:id.json`, with the current name the SDK requires. PNG, JPEG, GIF, WebP, AVIF, HEIF or HEIC up to 10 MB, checked before the upload using a local MIME hint; the server applies its shared image validations. People and personal assistants follow project-edit permissions; shared agents are refused with 403. Project JSON carries `wallpaper {url, mode}` when one is attached, so `projects show` shows it. Project-scoped via `--in`/`--project` | | todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`, plus `subtasks_count`/`subtasks_completed_count`/`subtasks_url`; edit via `subtasks`). `todos create --loose` creates on the to-do set, outside any list | | subtasks | 8 | `subtasks` | ✅ | BC5 | - | list (`GET /recordings/:id/subtasks.json`, paginated), show, create (`POST` on the same list route), update (partial; `--no-due`/`--no-assignees` clear), complete/uncomplete (`POST`/`DELETE /subtasks/:id/completion.json`), move (`PUT /subtasks/:id/position.json`, 1-based), delete. Account-scoped flat routes (bc3#12659), so no `--in`. Parents are to-dos and cards only. Same `Kanban::Step` records as `card_table_steps` | | todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position | diff --git a/e2e/projects.bats b/e2e/projects.bats index 91c3b28d8..fe6550f65 100644 --- a/e2e/projects.bats +++ b/e2e/projects.bats @@ -18,7 +18,7 @@ load test_helper @test "projects wallpaper set help names the formats and the size cap" { run basecamp projects wallpaper set --help assert_success - assert_output_contains "PNG, JPEG, WebP, or AVIF files up to 10 MB" + assert_output_contains "PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC files up to 10 MB" } @@ -34,14 +34,14 @@ load test_helper assert_json_value '.code' 'usage' } -@test "projects wallpaper set refuses a GIF" { +@test "projects wallpaper set refuses an SVG" { create_credentials create_global_config '{"account_id": 99999, "project_id": 123}' - printf 'GIF89a' > anim.gif + printf '' > image.svg - run basecamp projects wallpaper set anim.gif + run basecamp projects wallpaper set image.svg assert_failure - assert_json_value '.error' 'anim.gif: unsupported image type image/gif (use PNG, JPEG, WebP, or AVIF)' + assert_json_value '.error' 'image.svg: unsupported image type image/svg+xml (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)' assert_json_value '.code' 'usage' } diff --git a/e2e/smoke/smoke_projects_write.bats b/e2e/smoke/smoke_projects_write.bats index 7aecce48b..c9a9fe784 100644 --- a/e2e/smoke/smoke_projects_write.bats +++ b/e2e/smoke/smoke_projects_write.bats @@ -40,9 +40,6 @@ setup_file() { base64 -d > "$png" run_smoke basecamp projects wallpaper set "$png" --mode tile --project "$proj_id" --json - if [[ "$status" -ne 0 ]] && [[ "$output" == *"not found"* || "$output" == *"404"* ]]; then - mark_unverifiable "This Basecamp does not serve project wallpaper to API clients yet" - fi assert_success assert_json_value '.data.wallpaper.mode' 'tile' assert_json_not_null '.data.wallpaper.url' diff --git a/go.mod b/go.mod index 6ad7e9f21..9cdd2fbc6 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9 + github.com/basecamp/basecamp-sdk/go v0.28.0 github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 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 bf81fac46..236448fc9 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9 h1:ZcW7c/cnQTgFkNHMcnkxPqHIko2d0v2MrOgyykBrOj0= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.28.0 h1:OwMD/mg/2QvVGtvSRkMraCOjrYVQk3Dgz/IJPPA6P90= +github.com/basecamp/basecamp-sdk/go v0.28.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 h1:3NfGs+zx23Tp6cf+97+UewrJHlzZMLzmvEXOmOJyQAk= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 0a4772980..32fbc43ac 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -431,13 +431,15 @@ func newProjectsDeleteCmd() *cobra.Command { } } -// wallpaperFormats are the image types a project wallpaper may be: the set the -// web's picker offers, which the server holds the API to as well. +// wallpaperFormats match the server's shared image validation, not the narrower web picker. var wallpaperFormats = []struct{ mime, name string }{ {"image/png", "PNG"}, {"image/jpeg", "JPEG"}, + {"image/gif", "GIF"}, {"image/webp", "WebP"}, {"image/avif", "AVIF"}, + {"image/heif", "HEIF"}, + {"image/heic", "HEIC"}, } const maxWallpaperMB = 10 @@ -445,7 +447,7 @@ const maxWallpaperMB = 10 var wallpaperModes = []string{"fill", "tile"} func wallpaperAgentNotes() map[string]string { - return map[string]string{"agent_notes": "Agent profiles are refused (403): changing a wallpaper needs a person who can edit the project\n.data.wallpaper on the returned project is {url, mode}, absent when there is none"} + return map[string]string{"agent_notes": "People and personal assistants follow project-edit permissions; shared agents are refused (403)\n.data.wallpaper is {url, mode}, absent when there is none; url requires authentication and redirects to the image"} } func newProjectsWallpaperCmd() *cobra.Command { @@ -456,7 +458,8 @@ func newProjectsWallpaperCmd() *cobra.Command { Long: `Set, lay out, or remove a project's wallpaper. The project comes from --project (or --in), then the configured default. -Only people who can edit the project can change its wallpaper.`, +People and personal assistants with project-edit permissions can change its wallpaper. +Shared agents are refused.`, Example: ` $ basecamp projects wallpaper set skyline.jpg --mode fill --project 12345 $ basecamp projects wallpaper mode tile --project 12345 $ basecamp projects wallpaper remove --project 12345`, @@ -621,9 +624,8 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { } } -// wallpaperFile refuses, before anything is sent, a file the server would -// refuse for its size or type. The server sniffs the bytes, so a mislabeled -// file still comes back as its 422. +// wallpaperFile checks the size and MIME hint before uploading. The server's +// shared image validations remain authoritative. func wallpaperFile(arg string) (path, contentType string, err error) { path = richtext.NormalizeDragPath(arg) if err := richtext.ValidateFile(path); err != nil { @@ -646,7 +648,7 @@ func wallpaperFile(arg string) (path, contentType string, err error) { return "", "", output.ErrUsage(fmt.Sprintf("%s: unsupported image type %s (use %s)", name, contentType, wallpaperFormatNames())) } -// wallpaperFormatNames reads "PNG, JPEG, WebP, or AVIF". +// wallpaperFormatNames lists the accepted formats for help and validation errors. func wallpaperFormatNames() string { names := make([]string, len(wallpaperFormats)) for i, f := range wallpaperFormats { diff --git a/internal/commands/projects_wallpaper_test.go b/internal/commands/projects_wallpaper_test.go index ea754994e..6a7558625 100644 --- a/internal/commands/projects_wallpaper_test.go +++ b/internal/commands/projects_wallpaper_test.go @@ -21,7 +21,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/output" ) -const wallpaperURL = "https://3.basecamp.com/99999/projects/123/wallpaper?v=1" +const wallpaperURL = "https://3.basecampapi.com/99999/projects/123/wallpaper?v=1" // wallpaperServer plays bc3's side of the wallpaper endpoints for project 123: // it records each request and keeps the wallpaper the requests leave behind, @@ -216,6 +216,27 @@ func TestProjectsWallpaperSetAcceptsExactlyTheSizeCap(t *testing.T) { assert.Equal(t, "image/jpeg", s.request(http.MethodPut, "/99999/projects/123/wallpaper.json").upload.contentType) } +func TestProjectsWallpaperSetAcceptsServerImageFormats(t *testing.T) { + for _, tc := range []struct{ filename, contentType string }{ + {"image.png", "image/png"}, + {"image.jpg", "image/jpeg"}, + {"image.gif", "image/gif"}, + {"image.webp", "image/webp"}, + {"image.avif", "image/avif"}, + {"image.heif", "image/heif"}, + {"image.HEIC", "image/heic"}, + } { + t.Run(tc.filename, func(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, tc.filename, 0) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path)) + assert.Equal(t, tc.contentType, s.request(http.MethodPut, "/99999/projects/123/wallpaper.json").upload.contentType) + }) + } +} + func TestProjectsWallpaperSetRefusesBeforeAnyRequest(t *testing.T) { cases := []struct { name, file string @@ -224,8 +245,8 @@ func TestProjectsWallpaperSetRefusesBeforeAnyRequest(t *testing.T) { message string }{ {"over the size cap", "big.png", maxWallpaperMB<<20 + 1, nil, "big.png exceeds maximum wallpaper size of 10 MB"}, - {"a GIF", "anim.gif", 0, nil, "anim.gif: unsupported image type image/gif (use PNG, JPEG, WebP, or AVIF)"}, - {"a HEIC", "photo.heic", 0, nil, "photo.heic: unsupported image type image/heic (use PNG, JPEG, WebP, or AVIF)"}, + {"an SVG", "image.svg", 0, nil, "image.svg: unsupported image type image/svg+xml (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)"}, + {"plain text", "notes.txt", 0, nil, "notes.txt: unsupported image type text/plain (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)"}, {"an unknown mode", "skyline.png", 0, []string{"--mode", "stretch"}, `unknown wallpaper mode "stretch"`}, } for _, tc := range cases { @@ -258,7 +279,7 @@ func TestProjectsWallpaperSetRefusesAMissingFileBeforeAnyRequest(t *testing.T) { func TestProjectsWallpaperSetCarriesTheServersRefusal(t *testing.T) { s, app, _ := newWallpaperServer(t) - s.refuse = "Wallpaper must be a PNG, JPEG, WebP, or AVIF image" + s.refuse = "Wallpaper is not an allowed image type" app.Flags.Project = "123" path := writeWallpaperFile(t, "disguised.png", 0) diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index e92884a0e..d785902aa 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -55,10 +55,11 @@ func TestCatalogClaimsEveryTag(t *testing.T) { func TestCatalogExcludesUnservedOperations(t *testing.T) { cat := loadForTest(t) excluded := map[string]bool{ - "CreateAttachment": true, - "CreateCampfireUpload": true, - "UpdateAccountLogo": true, - "CreateStreamTicket": true, + "CreateAttachment": true, + "CreateCampfireUpload": true, + "UpdateAccountLogo": true, + "UpdateProjectWallpaper": true, + "CreateStreamTicket": true, } model, composite := 0, 0 for _, d := range cat.Domains { @@ -71,7 +72,7 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 281, model, "served model operation count") + assert.Equal(t, 282, model, "served model operation count") assert.Equal(t, 1, composite, "served composite operation count") } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 42ac0b999..d0588379b 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "63115e7978c989a271220cee8b025135315db882", - "ref": "v0.27.1-3-g63115e79", + "commit": "1850988c288405541bc785e4a9f9e06fce7ff458", + "ref": "v0.28.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index a8bc4d3b9..9d8057658 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -2789,6 +2789,18 @@ ] } }, + "RemoveProjectWallpaper": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "ReorderUpNext": { "retry": { "max": 3, diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index d95723c84..8cf097de9 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -16481,6 +16481,104 @@ } } }, + "/{accountId}/projects/{projectId}/wallpaper.json": { + "delete": { + "description": "Remove the project's wallpaper (returns 204 No Content).\nPeople and personal assistants follow project-edit permissions; shared agents are refused.", + "operationId": "RemoveProjectWallpaper", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "projectId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "RemoveProjectWallpaper 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Projects" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/question_answers/{answerId}": { "get": { "description": "Get a single answer by id", @@ -36532,6 +36630,9 @@ "client_company": { "$ref": "#/components/schemas/ClientCompany" }, + "wallpaper": { + "$ref": "#/components/schemas/ProjectWallpaper" + }, "clientside": { "$ref": "#/components/schemas/ClientSide", "deprecated": true, @@ -36634,6 +36735,24 @@ "error" ] }, + "ProjectWallpaper": { + "type": "object", + "description": "The project's wallpaper, included only in single-project responses when attached.\nProject lists and home responses omit it.", + "properties": { + "url": { + "type": "string", + "description": "An authenticated API-host URL that redirects to the wallpaper image in storage, not a public image link.\nUse the SDK's authenticated download helper instead of placing this URL directly in an image element." + }, + "mode": { + "type": "string", + "description": "fill|tile" + } + }, + "required": [ + "mode", + "url" + ] + }, "Question": { "type": "object", "properties": { @@ -40304,6 +40423,10 @@ }, "schedule_attributes": { "$ref": "#/components/schemas/ScheduleAttributes" + }, + "wallpaper_mode": { + "type": "string", + "description": "How the project's wallpaper is laid out. Changes only the layout of a\nwallpaper already attached; UpdateProjectWallpaper uploads one." } }, "required": [ @@ -40313,6 +40436,10 @@ "UpdateProjectResponseContent": { "$ref": "#/components/schemas/Project" }, + "UpdateProjectWallpaperInputPayload": { + "type": "string", + "contentEncoding": "byte" + }, "UpdateQuestionNotificationSettingsRequestContent": { "type": "object", "properties": { diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index f2a6e0635..e71d07449 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -11,6 +11,7 @@ ACTIONS (RO = read-only): - list_projects (RO, paginated): List projects (active by default; optionally archived/trashed) - list_recent_projects (RO): List the projects the current user has most recently visited, most recent visit first - record_project_visit: Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content) +- remove_project_wallpaper: Remove the project's wallpaper (returns 204 No Content) - trash_project: Trash a project (returns 204 No Content) - unarchive_project: Restore a project to active status from trash as well as from the archive (returns 204 No Content) - update_project: Update an existing project diff --git a/internal/richtext/mime.go b/internal/richtext/mime.go index 22dad5cfb..a1ea5385a 100644 --- a/internal/richtext/mime.go +++ b/internal/richtext/mime.go @@ -31,6 +31,7 @@ var mimeByExt = map[string]string{ ".webp": "image/webp", ".avif": "image/avif", ".heic": "image/heic", + ".heif": "image/heif", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".mp4": "video/mp4", diff --git a/internal/richtext/mime_test.go b/internal/richtext/mime_test.go index e7c380910..458a7352f 100644 --- a/internal/richtext/mime_test.go +++ b/internal/richtext/mime_test.go @@ -16,6 +16,8 @@ func TestDetectMIME(t *testing.T) { {"data.json", "application/json"}, {"README.md", "text/markdown"}, {"photo.jpeg", "image/jpeg"}, + {"photo.HEIC", "image/heic"}, + {"photo.heif", "image/heif"}, {"archive.zip", "application/zip"}, {"style.css", "application/octet-stream"}, // not in our map, file doesn't exist {"doc.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index f0239d2a0..b7129ec5e 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.27.1-0.20261022230746-63115e7978c9", - "revision": "63115e7978c9", - "updated_at": "2026-10-22T23:07:46Z" + "version": "v0.28.0", + "revision": "1850988c2884", + "updated_at": "2026-10-23T23:22:11Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 650dca96b..25120130f 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-TqjNLGbA3/mruCH8TA4HaGHYLnsXLK66fUuXp4ye9eQ="; + vendorHash = "sha256-JNG5aUCyIcuhsmdCd+6DocMeUff5geyEbPVqy+GlQio="; subPackages = [ "cmd/basecamp" ]; diff --git a/scripts/sync-mcp-model.sh b/scripts/sync-mcp-model.sh index 5912137bc..894991fbc 100755 --- a/scripts/sync-mcp-model.sh +++ b/scripts/sync-mcp-model.sh @@ -61,6 +61,7 @@ EXCLUDED_OPERATIONS = { "CreateAttachment", "CreateCampfireUpload", "UpdateAccountLogo", + "UpdateProjectWallpaper", } # Excluded by policy rather than by body shape, so these are not held to the diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index c4cce659f..3495c9ad7 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -244,14 +244,15 @@ basecamp projects wallpaper mode fill --in "" --json basecamp projects wallpaper remove --in "" --json ``` -- Basecamp applies the web's rules: only someone who can edit the project may - change it, and an agent profile is always refused (403). Say so rather than - retrying or reaching for `basecamp api`. -- The CLI refuses a file of the wrong type or size before uploading; leaf help - names the limits. `--mode` is `fill` or `tile`, and leaving it out keeps the - current mode. +- People and personal assistants follow project-edit permissions. Shared + agents are refused (403); do not retry or route around that refusal. +- The CLI checks the file's size and MIME hint before uploading; leaf help + names the limits. Server image validations still apply. `--mode` is `fill` + or `tile`, and leaving it out keeps the current mode. - Each command returns the project. `.data.wallpaper` is `{url, mode}`, and is absent when the project has none; `projects show` carries the same field. + The URL requires authentication and redirects to the image; it is not a + public image link. ## Content and stdin From ceb6871e6224e37caa9ced89e089aa9b7a2ecf39 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 23:16:29 -0400 Subject: [PATCH 5/6] Preserve wallpaper follow-up profile and account scope --- internal/commands/projects.go | 26 ++++++++---- internal/commands/projects_wallpaper_test.go | 43 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 32fbc43ac..015066dd9 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -503,6 +503,7 @@ its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpap } app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) projectID, err := wallpaperProjectID(cmd, app) if err != nil { return err @@ -524,7 +525,7 @@ its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpap if mode != "" { summary += " (" + mode + ")" } - return respondWithWallpaperProject(cmd, app, projectID, map[string]any{"id": projectID}, summary, + return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, summary, output.Breadcrumb{ Action: "mode", Cmd: fmt.Sprintf("basecamp projects wallpaper mode --project %d", projectID), @@ -567,6 +568,7 @@ A project with no wallpaper keeps the mode for the next one uploaded.`, } app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) projectID, err := wallpaperProjectID(cmd, app) if err != nil { return err @@ -583,7 +585,7 @@ A project with no wallpaper keeps the mode for the next one uploaded.`, return convertSDKError(err) } - return respondWithWallpaperProject(cmd, app, projectID, project, "Wallpaper mode set to "+mode, + return respondWithWallpaperProject(cmd, app, projectID, scope, project, "Wallpaper mode set to "+mode, output.Breadcrumb{ Action: "set", Cmd: fmt.Sprintf("basecamp projects wallpaper set --project %d", projectID), @@ -604,6 +606,7 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) projectID, err := wallpaperProjectID(cmd, app) if err != nil { return err @@ -613,7 +616,7 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { return convertSDKError(err) } - return respondWithWallpaperProject(cmd, app, projectID, map[string]any{"id": projectID}, "Wallpaper removed", + return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Wallpaper removed", output.Breadcrumb{ Action: "set", Cmd: fmt.Sprintf("basecamp projects wallpaper set --project %d", projectID), @@ -683,15 +686,20 @@ func wallpaperProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { // the output carries its wallpaper. The change has already happened, so a // failed read falls back to what the caller has, with a diagnostic, rather // than failing the command. -func respondWithWallpaperProject(cmd *cobra.Command, app *appctx.App, projectID int64, fallback any, summary string, crumbs ...output.Breadcrumb) error { +func respondWithWallpaperProject(cmd *cobra.Command, app *appctx.App, projectID int64, scope followUpScope, fallback any, summary string, crumbs ...output.Breadcrumb) error { + crumbs = append([]output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp projects show %d", projectID), + Description: "View the project", + }}, crumbs...) + scopeArgs := scope.args(app.Config.AccountID) + for i := range crumbs { + crumbs[i].Cmd += scopeArgs + } opts := []output.ResponseOption{ output.WithEntity("project"), output.WithSummary(summary), - output.WithBreadcrumbs(append([]output.Breadcrumb{{ - Action: "show", - Cmd: fmt.Sprintf("basecamp projects show %d", projectID), - Description: "View the project", - }}, crumbs...)...), + output.WithBreadcrumbs(crumbs...), } data := fallback project, err := app.Account().Projects().Get(cmd.Context(), projectID) diff --git a/internal/commands/projects_wallpaper_test.go b/internal/commands/projects_wallpaper_test.go index 6a7558625..761f3a71d 100644 --- a/internal/commands/projects_wallpaper_test.go +++ b/internal/commands/projects_wallpaper_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/require" "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/config" "github.com/basecamp/basecamp-cli/internal/output" ) @@ -192,6 +193,48 @@ func TestProjectsWallpaperSetUploadsTheFileAndReturnsTheProject(t *testing.T) { assert.Equal(t, "basecamp projects show 123", env.Breadcrumbs[0].Cmd) } +func TestProjectsWallpaperFollowUpsPreserveScope(t *testing.T) { + for _, scope := range []struct { + name, profile, accountSource, suffix string + }{ + {"configured account", "", string(config.SourceGlobal), ""}, + {"active profile", "staging", "profile", " --profile staging"}, + {"explicit account override", "staging", string(config.SourceFlag), " --profile staging --account 99999"}, + {"selected account", "", string(config.SourcePrompt), " --account 99999"}, + } { + for _, action := range []string{"set", "mode", "remove"} { + t.Run(scope.name+"/"+action, func(t *testing.T) { + _, app, buf := newWallpaperServer(t) + app.Flags.Project = "123" + app.Config.ActiveProfile = scope.profile + app.Config.Sources = map[string]string{"account_id": scope.accountSource} + args := []string{"wallpaper", action} + want := map[string]string{"show": "basecamp projects show 123" + scope.suffix} + switch action { + case "set": + args = append(args, writeWallpaperFile(t, "image.png", 0)) + want["mode"] = "basecamp projects wallpaper mode --project 123" + scope.suffix + want["remove"] = "basecamp projects wallpaper remove --project 123" + scope.suffix + case "mode": + args = append(args, "tile") + fallthrough + case "remove": + want["set"] = "basecamp projects wallpaper set --project 123" + scope.suffix + } + + require.NoError(t, executeCommand(NewProjectsCmd(), app, args...)) + env := decodeWallpaperEnvelope(t, buf) + require.Len(t, env.Breadcrumbs, len(want)) + got := make(map[string]string, len(env.Breadcrumbs)) + for _, breadcrumb := range env.Breadcrumbs { + got[breadcrumb.Action] = breadcrumb.Cmd + } + assert.Equal(t, want, got) + }) + } + } +} + func TestProjectsWallpaperSetWithoutModeKeepsTheCurrentMode(t *testing.T) { s, app, buf := newWallpaperServer(t) app.Config.ProjectID = "123" From ea21e610d59f16aad8c9271d920f2db43446baf6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 23:31:39 -0400 Subject: [PATCH 6/6] Use a portable oversized wallpaper test fixture --- e2e/projects.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/projects.bats b/e2e/projects.bats index fe6550f65..973aeba72 100644 --- a/e2e/projects.bats +++ b/e2e/projects.bats @@ -48,7 +48,7 @@ load test_helper @test "projects wallpaper set refuses a file over 10 MB" { create_credentials create_global_config '{"account_id": 99999, "project_id": 123}' - truncate -s 10485761 big.png + dd if=/dev/zero of=big.png bs=1 count=1 seek=10485760 2>/dev/null run basecamp projects wallpaper set big.png assert_failure