diff --git a/CHANGELOG.md b/CHANGELOG.md index d0bbaa6..dbdbd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,26 @@ that may never merge. They are not releases and are not listed here. match on is new. Documented in docs/commands.md's "Where they go" section. +- `install.sh` now adds its install directory to `PATH` by appending one + line to your shell profile (`~/.zshrc`, `~/.bash_profile` or `~/.bashrc`, + fish's `config.fish`, otherwise `~/.profile`), the way `install.ps1` + already edits the user `PATH` on Windows. It never writes the line twice. + Set `MAPBOX_NO_MODIFY_PATH=1` to keep the old behavior of only printing + the line. Both installers' output is also shorter and ends with what to + run next. + +- `install.sh`/`install.ps1`: after the skill question, the install now + asks whether to add the Mapbox MCP servers to the coding agents it finds, + the same as `mapbox mcp install --global`. It is asked only at a terminal + and only when something is not registered yet; the default is no. + `MAPBOX_CLI_NO_AGENT_SETUP=1` skips it along with the skill question. + +- `install.sh` now writes a log of each run to + `~/.local/state/mapbox-cli/install.log` (`$XDG_STATE_HOME` when set): + every step, and the full output of each `mapbox` and Tilesets command it + ran after installing the binary. The previous run's is kept as + `install.log.1`. + - New command: `mapbox styles download > style.zip` saves a style as a ZIP with its sprite icons and custom fonts. `mapbox auth login` now also asks for the `styles:download` scope it needs, so log in again diff --git a/README.md b/README.md index 8cddff3..71ba4e7 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ curl -fsSL https://cli.mapbox.com/install.sh | MAPBOX_CLI_VERSION=0.3.0 sh $env:MAPBOX_CLI_VERSION = '0.3.0'; irm https://cli.mapbox.com/install.ps1 | iex ``` +Both scripts put the install directory on your `PATH`: `install.sh` adds a +line to your shell profile, `install.ps1` edits the user `PATH`. Set +`MAPBOX_NO_MODIFY_PATH=1` to skip that and only print what to add. + `MAPBOX_INSTALL_DIR` chooses where the binary lands. The scripts' sources are [`scripts/install.sh`](./scripts/install.sh) and [`scripts/install.ps1`](./scripts/install.ps1). @@ -95,8 +99,28 @@ and the rest of the fifteen `agent-skills` supports), it asks, once, whether to set up this CLI's own [skill](#generate-skills) and the Mapbox Agent Skills library for it, naming the agent before it writes anything. Answer no, or run the script somewhere with no terminal to ask on (CI, a container), and -it does neither, printing the two commands to run by hand instead. Set -`MAPBOX_CLI_NO_AGENT_SETUP=1` to skip the question entirely. +it does neither, printing the two commands to run by hand instead. + +It then asks, separately, whether to add the [Mapbox MCP servers](#mcp-servers) +to each coding agent it finds that can take one (Claude Code, Codex, VS Code, +Cursor), the same thing `mapbox mcp install --global` does. It asks only when +there is something left to add, with the same rules: no terminal means no. +Set `MAPBOX_CLI_NO_AGENT_SETUP=1` to skip both questions. + +On macOS and Linux, `install.sh` also offers to install the +[Tilesets CLI](https://github.com/mapbox/tilesets-cli), which only +`mapbox tilesets-cli` needs. `MAPBOX_INSTALL_TILESETS=yes` or `no` answers +that question in advance; with no terminal, the answer is no. To install +without being asked anything: + +```sh +curl -fsSL https://cli.mapbox.com/install.sh | MAPBOX_CLI_NO_AGENT_SETUP=1 MAPBOX_INSTALL_TILESETS=no sh +``` + +`install.sh` records each run in `~/.local/state/mapbox-cli/install.log` +(under `$XDG_STATE_HOME` when that is set): every step, and the full output of +each `mapbox` and Tilesets command it ran after installing the binary. The +previous run's log is kept as `install.log.1`. ### Download the archive yourself diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c7fa74d..3974070 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -145,10 +145,68 @@ $ModifyPath = $false } - $Repo = 'https://github.com/mapbox/cli' + $Repo = 'https://github.com/mapbox/mapbox-cli' function Write-Err([string]$Text) { [Console]::Error.WriteLine($Text) } + # --- presentation ------------------------------------------------------ + # + # Color through Write-Host's own -ForegroundColor rather than ANSI codes: + # the console renders it under 5.1 and conhost alike, and a redirected run + # drops it by itself. NO_COLOR is honored the way the CLI honors it. + $UseColor = -not $env:NO_COLOR + + # The check mark only where the font is known to carry it: Windows + # Terminal's Cascadia does, conhost's default Consolas does not. Built + # from its code point because this file has to stay ASCII. + $MarkOk = '*' + if ($env:WT_SESSION) { $MarkOk = [string][char]0x2713 } + + function Write-Part([string]$Text, [string]$Color = '') { + if ($UseColor -and $Color) { Write-Host $Text -ForegroundColor $Color -NoNewline } + else { Write-Host $Text -NoNewline } + } + # Where a step line starts. A question's answers are its children, so a + # step that asked one sets this two deeper while it reports, then puts it + # back. A hashtable so the functions below see the change. + $Layout = @{ Indent = ' ' } + function Write-Step([string]$Mark, [string]$Color, [string]$Text, [string]$Detail = '') { + Write-Part $Layout.Indent + Write-Part $Mark $Color + Write-Part " $Text" + if ($Detail) { Write-Part " $Detail" 'DarkGray' } + Write-Host '' + } + function Write-Ok([string]$Text, [string]$Detail = '') { Write-Step $MarkOk 'Green' $Text $Detail } + function Write-Skip([string]$Text) { Write-Step '-' 'DarkGray' $Text } + function Write-Warn([string]$Text) { Write-Step '!' 'Yellow' $Text } + # A line of detail under the step line before it, aligned with its text. + function Write-Detail([string]$Text) { Write-Host "$($Layout.Indent) $Text" } + + # A question reads in three layers, as in install.sh: the question in the + # console's own text color, what answering does in gray under it, and the + # command it runs in cyan, so the eye lands on the question and the + # command. Not White: on a light console theme it all but disappears. + function Write-AskTitle([string]$Text) { + Write-Part ' ' + Write-Part '?' 'Cyan' + Write-Part " $Text" + Write-Host '' + } + # -Parts alternates plain note text and a command: note, command, note... + function Write-AskNote([string[]]$Parts) { + Write-Part ' ' + for ($i = 0; $i -lt $Parts.Count; $i++) { + if ($i % 2) { Write-Part $Parts[$i] 'Cyan' } else { Write-Part $Parts[$i] 'DarkGray' } + } + Write-Host '' + } + function Read-AskAnswer([string]$Question) { + Write-Part " $Question " + Write-Part '[y/N] ' 'DarkGray' + return Read-Host + } + # Every failure path ends here. The message is written out in full and then # thrown, so the summary line appears twice: once as we wrote it, and once # more in whatever the host makes of a terminating error. That is the price @@ -377,9 +435,7 @@ This channel is private. Set the credential and run it again: if (-not $target) { Fail "$manifestUrl lists no artifact for $($candidates -join ' or ')." } - if ($arch -eq 'ARM64' -and $target -eq 'x86_64-pc-windows-msvc') { - Write-Host 'This channel has no arm64 build; installing the x64 one, which Windows 11 on Arm runs under emulation.' - } + $emulated = $arch -eq 'ARM64' -and $target -eq 'x86_64-pc-windows-msvc' $entry = $artifacts.$target $file = '' @@ -414,7 +470,19 @@ This channel is private. Set the credential and run it again: $archive = Join-Path $workDir $localName $artifactUrl = "$BaseUrl/$Version/$file" - Write-Host "Downloading $artifactUrl" + $platformName = 'Windows (x64)' + if ($target -like 'aarch64-*') { $platformName = 'Windows (arm64)' } + $shownVersion = $resolvedVersion + if (-not $shownVersion) { $shownVersion = $Version } + Write-Host '' + Write-Part "Installing Mapbox CLI $shownVersion" + Write-Part " for $platformName" 'DarkGray' + Write-Host '' + Write-Host '' + if ($emulated) { + Write-Warn 'This channel has no arm64 build; installing the x64 one, which Windows 11 on Arm runs under emulation.' + } + try { Invoke-WebRequest -Uri $artifactUrl -OutFile $archive -Headers $headers -UserAgent $UserAgent -UseBasicParsing -TimeoutSec 600 } catch { @@ -435,6 +503,12 @@ $Repo/issues "@ } + $sizeBytes = (Get-Item -LiteralPath $archive).Length + $invariant = [Globalization.CultureInfo]::InvariantCulture + if ($sizeBytes -ge 1MB) { $size = [string]::Format($invariant, '{0:0.0} MB', $sizeBytes / 1MB) } + else { $size = [string]::Format($invariant, '{0} KB', [math]::Ceiling($sizeBytes / 1KB)) } + Write-Ok "Downloaded $($file.Split('/')[-1])" "($size, SHA-256 verified)" + $unpacked = Join-Path $workDir 'unpacked' try { Expand-Archive -LiteralPath $archive -DestinationPath $unpacked -Force @@ -541,7 +615,7 @@ watching the folder - and run this again. $installedVersion = Get-BinaryVersion $destination if (-not $installedVersion) { $detail = "Build from source: $Repo" - if ($arch -eq 'ARM64' -and $target -eq 'x86_64-pc-windows-msvc') { + if ($emulated) { $detail = @" This is the x64 build running on arm64, which needs the x64 emulation that Windows 11 on Arm has and Windows 10 on Arm does not. @@ -550,27 +624,13 @@ Windows 11 on Arm has and Windows 10 on Arm does not. Fail "installed $destination, but it does not run here. The artifact may be built for a platform other than $target." $detail } - Write-Host '' - Write-Host "Installed $installedVersion" - Write-Host " path $destination" - Write-Host " channel $Version ($resolvedVersion)" if ($previousVersion -and $previousVersion -ne $installedVersion) { - Write-Host " replaced $previousVersion" + Write-Ok "Installed $installedVersion to $destination" "(replaced $previousVersion)" + } else { + Write-Ok "Installed $installedVersion to $destination" } if ($asideNote) { - Write-Host " note the copy it replaced is still running; $asideNote is deleted next time" - } - - # Said once, here, rather than on every future command: someone piping - # this into `iex` is not going to read the man page before their first - # run. Skipped when telemetry is already off, since there's nothing to - # opt out of. - if ($TelemetryAllowed) { - Write-Host '' - Write-Host 'Mapbox CLI collects telemetry by default. To disable it, set' - Write-Host 'MAPBOX_CLI_NO_TELEMETRY=1 before running CLI commands.' - Write-Host '' - Write-Host 'Learn more: https://github.com/mapbox/mapbox-cli#privacy' + Write-Warn "The copy it replaced is still running; $asideNote is deleted next time." } # --- PATH ---------------------------------------------------------- @@ -601,15 +661,12 @@ Windows 11 on Arm has and Windows 10 on Arm does not. try { $added = Add-ToUserPath $InstallDir } catch { - Write-Host '' - Write-Host "Could not add $InstallDir to your PATH: $($_.Exception.Message)" - Write-Host 'Add it in Settings > "Edit environment variables for your account".' + Write-Warn "Could not add $InstallDir to your PATH: $($_.Exception.Message)" + Write-Host ' Add it in Settings > "Edit environment variables for your account".' } if ($added) { Send-EnvironmentChange - Write-Host '' - Write-Host "Added $InstallDir to your PATH." - Write-Host 'Terminals that are already open still have the old one - restart them.' + Write-Ok "Added $InstallDir to your PATH" '(other open terminals need a restart)' } if (-not $alreadyOnPath) { # Whether it was just added or was already in the registry, the @@ -619,28 +676,25 @@ Windows 11 on Arm has and Windows 10 on Arm does not. $env:Path = $env:Path.TrimEnd(';') + ";$InstallDir" } } elseif (-not $alreadyOnPath) { + Write-Warn "$InstallDir is not on your PATH, and MAPBOX_NO_MODIFY_PATH is set." + Write-Host ' Add it in Settings > "Edit environment variables for your account",' + Write-Host ' or for this session only:' Write-Host '' - Write-Host "$InstallDir is not on your PATH, and MAPBOX_NO_MODIFY_PATH is set, so this did" - Write-Host 'not add it. Add it in Settings > "Edit environment variables for your account",' - Write-Host 'or for this session only:' + Write-Host " `$env:Path += `";$InstallDir`"" Write-Host '' - Write-Host " `$env:Path += `";$InstallDir`"" } if ($shadowing) { - Write-Host '' - Write-Host "Note: mapbox on your PATH still resolves to $shadowing, which came from" - Write-Host 'somewhere else. This script did not touch it. To use the copy just installed,' - Write-Host "remove that one, or put $InstallDir ahead of it on PATH - entries from your" - Write-Host 'user PATH come after the machine-wide ones.' + Write-Warn "mapbox on your PATH still resolves to $shadowing" + Write-Host ' That one came from somewhere else, and this script did not touch it. To use' + Write-Host " the copy just installed, remove it, or put $InstallDir ahead of it on PATH -" + Write-Host ' entries from your user PATH come after the machine-wide ones.' } elseif ($behind) { - Write-Host '' - Write-Host "Note: there is another mapbox at $behind. $InstallDir comes first on your" - Write-Host 'PATH, so a new terminal runs the copy just installed - but a terminal that was' - Write-Host 'already open resolves against the PATH it started with, and can go on' - Write-Host 'reporting the old version. Restart it, and Get-Command mapbox says which file' - Write-Host "it would run. Removing $behind stops this happening again; this script did" - Write-Host 'not touch it.' + Write-Warn "There is another mapbox at $behind" + Write-Host " $InstallDir comes first on your PATH, so a new terminal runs the copy just" + Write-Host ' installed. A terminal that was already open can still resolve the old one:' + Write-Host ' Restart it, and Get-Command mapbox says which file it would run. This script' + Write-Host ' did not touch the other copy.' } # --- the Tilesets CLI ---------------------------------------------- @@ -651,19 +705,14 @@ Windows 11 on Arm has and Windows 10 on Arm does not. # `launch_failed` in src/tilesets_cli.rs says when it is reached on # Windows; keep the two in step. - Write-Host '' if ($env:MAPBOX_TILESETS_CLI) { - Write-Host "Tilesets CLI: $env:MAPBOX_TILESETS_CLI (MAPBOX_TILESETS_CLI)" + Write-Ok "Found Tilesets CLI: $env:MAPBOX_TILESETS_CLI (MAPBOX_TILESETS_CLI)" } else { - Write-Host 'mapbox is installed and ready to use - the rest of this is optional.' + Write-Skip 'Tilesets CLI skipped: only the mapbox tilesets-cli command needs it, and it does not work natively here.' + Write-Host ' Run that command from WSL, or point the CLI at a tilesets of your own,' + Write-Host ' such as a wrapper that shells into WSL:' Write-Host '' - Write-Host 'mapbox tilesets-cli is the one command that does not work natively here: it' - Write-Host 'forwards to the Python package mapbox-tilesets, which is macOS and Linux only.' - Write-Host 'Run those commands from WSL. Every other command works as it does anywhere' - Write-Host 'else. If you have a tilesets of your own - a wrapper that shells into WSL,' - Write-Host 'say - point the CLI at it:' - Write-Host '' - Write-Host " `$env:MAPBOX_TILESETS_CLI = 'C:\path\to\tilesets.cmd'" + Write-Host " `$env:MAPBOX_TILESETS_CLI = 'C:\path\to\tilesets.cmd'" } # --- Coding agent skill ---------------------------------------------- @@ -717,12 +766,14 @@ Windows 11 on Arm has and Windows 10 on Arm does not. $checkOut = (& $destination generate-skills --global --dry-run -o json 2>$null) -join '' if ($LASTEXITCODE -eq 0) { $agentNames = Get-AgentName $checkOut - Write-Host '' - Write-Host "A coding agent was detected on this machine: $agentNames." + Write-Ok "Coding agents found: $agentNames." $doAgentSetup = $false if (-not [Console]::IsInputRedirected) { - Write-Host '' - $answer = Read-Host 'Set up the mapbox CLI skill and the Mapbox Agent Skills library for it? [y/N]' + Write-AskTitle 'Set up the mapbox CLI skill and the Mapbox Agent Skills library for them?' + Write-AskNote @('This runs ', 'mapbox generate-skills --global', ' and ', 'mapbox agent-skills install --global', ',') + Write-AskNote @('now or any time later.') + $answer = Read-AskAnswer 'Set them up now?' + $Layout.Indent = ' ' if ($answer -match '^(?i:y|yes)$') { $doAgentSetup = $true } } if ($doAgentSetup) { @@ -732,7 +783,7 @@ Windows 11 on Arm has and Windows 10 on Arm does not. # keeps out of this script's way. & $destination generate-skills --global *> $null if ($LASTEXITCODE -eq 0) { - Write-Host "Wrote the mapbox CLI skill for: $agentNames." + Write-Ok "Wrote the mapbox CLI skill for: $agentNames." } # `install` errors with already_installed on a @@ -747,7 +798,7 @@ Windows 11 on Arm has and Windows 10 on Arm does not. try { & $destination agent-skills install --global -o json 1>$null 2>$agentStderr if ($LASTEXITCODE -eq 0) { - Write-Host "Installed the Mapbox Agent Skills library for: $agentNames." + Write-Ok "Installed the Mapbox Agent Skills library for: $agentNames." } else { $agentOut = Get-Content -LiteralPath $agentStderr -Raw -ErrorAction SilentlyContinue if ($agentOut -and $agentOut.Contains('"code":"already_installed"')) { @@ -757,10 +808,10 @@ Windows 11 on Arm has and Windows 10 on Arm does not. if ($LASTEXITCODE -eq 0) { if ($updateOut.Contains('"updated":[]')) { & $destination agent-skills update --global *> $null - Write-Host "Updated the Mapbox Agent Skills library for: $agentNames." + Write-Ok "Updated the Mapbox Agent Skills library for: $agentNames." } else { - Write-Host 'Some Mapbox Agent Skills have local changes and were left alone.' - Write-Host "Run 'mapbox agent-skills update --global' to review and replace them." + Write-Warn 'Some Mapbox Agent Skills have local changes and were left alone.' + Write-Detail "Run 'mapbox agent-skills update --global' to review and replace them." } } } finally { @@ -772,15 +823,136 @@ Windows 11 on Arm has and Windows 10 on Arm does not. Remove-Item -LiteralPath $agentStderr -Force -ErrorAction SilentlyContinue } } else { - Write-Host 'Not set up. Run these any time:' - Write-Host ' mapbox generate-skills --global' - Write-Host ' mapbox agent-skills install --global' + Write-Skip 'Not set up. Run these any time:' + Write-Detail 'mapbox generate-skills --global' + Write-Detail 'mapbox agent-skills install --global' } + $Layout.Indent = ' ' } } finally { $ErrorActionPreference = $previousErrorAction } } + + # --- Mapbox MCP servers ---------------------------------------------- + # + # A separate question from the skill above: this registers a hosted + # server in the coding agent's own config, next to servers the user + # added, rather than writing a directory this CLI owns. `mapbox mcp + # install` never replaces an entry that is already there (src/mcp.rs), + # so on a reinstall it is only offered when something is left to + # register. Same opt-out and same no-console rule as the skill. + if ($AgentSetupAllowed) { + $previousErrorAction = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + # Fails with mcp_client_not_found when no supported client is + # installed, which is most machines: nothing to offer, so + # nothing is said. + $mcpCheck = (& $destination mcp install --global --dry-run -o json 2>$null) -join '' + if ($LASTEXITCODE -eq 0) { + $clientLabels = @{ + 'claude-code' = 'Claude Code'; 'codex' = 'Codex' + 'vscode' = 'VS Code'; 'cursor' = 'Cursor' + } + $mcpClients = @(($mcpCheck | ConvertFrom-Json).results | + Where-Object { $_.status -eq 'would_install' } | + ForEach-Object { $_.client } | Select-Object -Unique | + ForEach-Object { if ($clientLabels.ContainsKey($_)) { $clientLabels[$_] } else { $_ } }) -join ', ' + if ($mcpClients) { + $doMcpSetup = $false + if (-not [Console]::IsInputRedirected) { + Write-AskTitle "Add the Mapbox MCP servers to ${mcpClients}?" + Write-AskNote @('This runs ', 'mapbox mcp install --global', ', now or any time later.') + $answer = Read-AskAnswer 'Add them now?' + $Layout.Indent = ' ' + if ($answer -match '^(?i:y|yes)$') { $doMcpSetup = $true } + } + if ($doMcpSetup) { + # Each client's own CLI does the registering, and + # Codex's can stop to print a sign-in URL, so + # stderr - where mapbox forwards what they print - + # is left on the console, live. No spinner: a + # second writer to the console is not worth it + # here without a terminal to test it on. + Write-Part $Layout.Indent + Write-Part '...' 'Cyan' + Write-Host ' Adding the Mapbox MCP servers' + $mcpOut = (& $destination mcp install --global -o json) -join '' + $mcpOk = $LASTEXITCODE -eq 0 + # One line per server and outcome, clients joined, + # so a server added just now reads differently from + # one that was already there. + $serverLabels = @{ 'mapbox' = 'Mapbox MCP'; 'mapbox-devkit' = 'Mapbox DevKit MCP' } + $rows = @() + try { $rows = @(($mcpOut | ConvertFrom-Json).results) } catch { $rows = @() } + $groups = [ordered]@{} + foreach ($row in $rows) { + $what = if ($serverLabels.ContainsKey($row.server)) { $serverLabels[$row.server] } else { $row.server } + $who = if ($clientLabels.ContainsKey($row.client)) { $clientLabels[$row.client] } else { $row.client } + $key = "$($row.status)|$what" + if (-not $groups.Contains($key)) { $groups[$key] = @() } + $groups[$key] += $who + } + foreach ($key in $groups.Keys) { + $status, $what = $key -split '\|', 2 + $who = $groups[$key] -join ', ' + switch ($status) { + 'installed' { Write-Ok "Added $what to $who" } + 'already_installed' { Write-Skip "$what was already in $who" } + 'installed_login_incomplete' { Write-Warn "Added $what to $who, but sign-in did not finish" } + 'client_not_found' { Write-Warn "Skipped $what for ${who}: CLI not on PATH" } + 'config_unreadable' { Write-Warn "Skipped $what for ${who}: config could not be read" } + default { Write-Warn "Could not add $what to $who" } + } + } + if (-not $mcpOk) { + if ($rows.Count -eq 0) { Write-Warn 'Could not add the Mapbox MCP servers.' } + Write-Detail "Run 'mapbox mcp install --global' to see why and try again." + } + } else { + Write-Skip "Mapbox MCP servers not added to $mcpClients. Run this any time:" + Write-Detail 'mapbox mcp install --global' + } + $Layout.Indent = ' ' + } + } + } finally { + $ErrorActionPreference = $previousErrorAction + } + } + + # --- summary ------------------------------------------------------- + # + # The last thing on screen, because it is what the reader acts on. The + # PATH of the session that ran this was updated above, so under + # `irm | iex` mapbox already works in it. + + Write-Host '' + Write-Part "$installedVersion is ready." 'Green' + Write-Host '' + Write-Host '' + Write-Part 'Get started' + Write-Host '' + foreach ($pair in @( + @('mapbox auth login', 'Sign in to your Mapbox account'), + @('mapbox --help', 'See every command'))) { + Write-Part (' {0,-20}' -f $pair[0]) 'Cyan' + Write-Host " $($pair[1])" + } + Write-Host '' + Write-Host 'Docs: https://cli.mapbox.com' + + # Said once, here, rather than on every future command: someone piping + # this into `iex` is not going to read the man page before their first + # run. Skipped when telemetry is already off, since there's nothing to + # opt out of. + if ($TelemetryAllowed) { + Write-Host '' + Write-Host 'Mapbox CLI collects telemetry by default. To disable it, set' + Write-Host 'MAPBOX_CLI_NO_TELEMETRY=1. Learn more: https://github.com/mapbox/mapbox-cli#privacy' + } + Write-Host '' } finally { if ($staged -and (Test-Path -LiteralPath $staged)) { Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue diff --git a/scripts/install.sh b/scripts/install.sh index 6a78949..a458d9b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -41,12 +41,22 @@ INSTALL_DIR="${MAPBOX_INSTALL_DIR:-$HOME/.local/bin}" # variable rather than three literals, the way install.ps1 keeps its $Repo: # this script is served on its own, so a repository rename has to be a single # edit here and a single edit there. -REPO='https://github.com/mapbox/cli' +REPO='https://github.com/mapbox/mapbox-cli' # yes or no to answer the Tilesets CLI prompt ahead of time. Unset means ask # when there is a terminal, and no when there isn't. INSTALL_TILESETS="${MAPBOX_INSTALL_TILESETS:-}" +# The install dir goes on PATH through the shell's profile unless this is set, +# the same switch, read the same way, as install.ps1's registry edit: a fresh +# Mac has no ~/.local/bin on PATH, so without it nearly every first install +# ends with a `command not found`. +MODIFY_PATH=yes +case "${MAPBOX_NO_MODIFY_PATH:-}" in + '' | 0 | no | false) ;; + *) MODIFY_PATH=no ;; +esac + # Only set if you need a non-production channel. Export # MAPBOX_CLI_AUTH=user:password to authenticate to it — the same credential # the outer `curl -u` used to fetch this script, which is why it has to be @@ -139,11 +149,246 @@ fetch() { fi } +# --- Presentation ---------------------------------------------------------- +# +# Color under the rules src/output/style.rs applies to the CLI itself: a +# terminal, no NO_COLOR (empty counts as unset), and not TERM=dumb. The palette +# is the terminal's own, so it reads on light and dark backgrounds alike. +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ]; then + BOLD="$(printf '\033[1m')" + DIM="$(printf '\033[2m')" + GREEN="$(printf '\033[32m')" + YELLOW="$(printf '\033[33m')" + RED="$(printf '\033[31m')" + ACCENT="$(printf '\033[94m')" + RESET="$(printf '\033[0m')" +else + BOLD='' DIM='' GREEN='' YELLOW='' RED='' ACCENT='' RESET='' +fi + +# A check mark in a C locale prints as mojibake, so fall back to ASCII unless +# the locale says UTF-8. +case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in + *UTF-8* | *utf-8* | *UTF8* | *utf8*) + MARK_OK='✓' MARK_SKIP='–' MARK_WARN='!' MARK_ASK='?' MARK_WAIT='…' + SPIN_FRAMES='⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏' + ;; + *) + MARK_OK='*' MARK_SKIP='-' MARK_WARN='!' MARK_ASK='?' MARK_WAIT='.' + SPIN_FRAMES='- \\ | /' + ;; +esac + +# The install log (see "Install log" below), set once it can be written. +INSTALL_LOG='' +ESC="$(printf '\033')" +# Appends to the install log, without color codes. Never fails the install. +log() { + [ -n "$INSTALL_LOG" ] || return 0 + printf '%s\n' "$*" | sed "s/${ESC}\\[[0-9;]*m//g" >>"$INSTALL_LOG" 2>/dev/null || true +} + +# Where a step line starts. A question's answers are its children, so a step +# that asked one sets this two deeper while it reports, then puts it back. +STEP_INDENT=' ' + +step_ok() { printf '%s%s%s%s %s\n' "$STEP_INDENT" "$GREEN" "$MARK_OK" "$RESET" "$*"; log "ok: $*"; } +step_skip() { printf '%s%s%s%s %s\n' "$STEP_INDENT" "$DIM" "$MARK_SKIP" "$RESET" "$*"; log "skip: $*"; } +step_warn() { printf '%s%s%s%s %s\n' "$STEP_INDENT" "$YELLOW" "$MARK_WARN" "$RESET" "$*"; log "warn: $*"; } +# A line of detail under the step line before it, aligned with its text. +step_detail() { printf '%s %s\n' "$STEP_INDENT" "$*"; log " $*"; } + +# Runs a command for its exit status only; what it prints goes to the log. +log_run() { # command... + log "\$ $*" + if [ -n "$INSTALL_LOG" ]; then + if "$@" >"$INSTALL_LOG" 2>&1; then lr_status=0; else lr_status=$?; fi + else + if "$@" /dev/null 2>&1; then lr_status=0; else lr_status=$?; fi + fi + log "(exit ${lr_status})" + return "$lr_status" +} + +# Runs a command for its stdout, which it prints for the caller to capture; +# stdout and stderr both go to the log as well. +log_capture() { # command... + log "\$ $*" + lc_err="${WORK_DIR}/log-capture.err" + if lc_out="$("$@" "$lc_err")"; then lc_status=0; else lc_status=$?; fi + [ -z "$lc_out" ] || log "$lc_out" + if [ -s "$lc_err" ]; then log "$(cat "$lc_err")"; fi + log "(exit ${lc_status})" + printf '%s' "$lc_out" + return "$lc_status" +} + +# A question reads in three layers: the question itself in bold, what +# answering it does dimmed under it, and the command it runs in the accent +# color, so the eye lands on the question and the command and can skip the +# rest. +ask_title() { printf ' %s%s%s%s %s%s%s\n' "$ACCENT" "$BOLD" "$MARK_ASK" "$RESET" "$BOLD" "$*" "$RESET"; } +ask_note() { printf ' %s%s%s\n' "$DIM" "$*" "$RESET"; } +# Inside an ask_note: the command in the accent color, then back to dim. +ask_cmd() { printf '%s%s%s%s' "$RESET" "$ACCENT" "$1" "$RESET$DIM"; } + +# Runs a command that can take a while and talks while it does (pipx, pip, a +# coding agent's own `mcp add`). At a terminal, a spinner and the label sit on +# one line with the command's latest output dimmed under it, both erased when +# it ends; elsewhere the label is printed once. Everything goes to +# either way, for a caller to show in full when it is the diagnosis. +# , when not empty, takes the command's stdout instead, for a +# caller that parses it. +# The child's own colors and control characters would throw off both the +# width and the redraw, so only its text is shown. +run_live_text() { + sed "s/${ESC}\\[[0-9;?]*[A-Za-z]//g" | tr '\t' ' ' | tr -d '\000-\010\013-\037\177' +} + +# A line with a URL in it is usually one to open — a sign-in page — and the +# spinner would cut it to the screen and erase it at the end. So each new one +# is printed in full above the spinner, with line wrap back on for it, and +# stays there. +run_live_urls() { + run_total="$(wc -l <"$run_log" 2>/dev/null | tr -d ' ')" + [ -n "$run_total" ] && [ "$run_total" -gt "$run_seen" ] || return 0 + sed -n "$((run_seen + 1)),${run_total}p" "$run_log" | grep -E 'https?://' | run_live_text | + while IFS= read -r run_url; do + printf '\r\033[2K\n\033[2K\033[1A\r\033[?7h%s %s\n\033[?7l' "$STEP_INDENT" "$run_url" + done + run_seen="$run_total" +} + +# One frame of run_live: the spinner and label, then the log's latest line. +run_live_draw() { + run_live_urls + run_line="$(tail -n 1 "$run_log" 2>/dev/null | run_live_text | cut -c "1-${run_width}")" + printf '\r\033[2K%s%s%s%s %s\n\033[2K%s %s%s%s\033[1A\r' \ + "$STEP_INDENT" "$ACCENT" "$run_frame" "$RESET" "$run_label" "$STEP_INDENT" "$DIM" "$run_line" "$RESET" +} + +run_live() { # label log stdout-file command... + run_label="$1" run_log="$2" run_stdout="$3" + shift 3 + : >"$run_log" + [ -n "$run_stdout" ] || run_stdout="$run_log" + # A dumb terminal would print the cursor movement as text, every tick. + if [ ! -t 1 ] || [ "${TERM:-}" = dumb ]; then + printf '%s%s %s\n' "$STEP_INDENT" "$MARK_WAIT" "$run_label" + if [ "$run_stdout" = "$run_log" ]; then + if "$@" >"$run_log" 2>&1; then run_status=0; else run_status=$?; fi + else + if "$@" "$run_stdout" 2>>"$run_log"; then run_status=0; else run_status=$?; fi + fi + run_live_log "$@" + return "$run_status" + fi + + # A line that wraps can no longer be redrawn in place — the spinner moves + # up one line to start each frame, so a wrapped one pushes every frame + # after it a line further down. So the log line is cut to what fits after + # its indent, and line wrap is off while the spinner runs, which also + # covers a character drawn two columns wide (pipx's emoji) that a cut by + # character count lets through. + run_width="$(stty size /dev/null | cut -d' ' -f2)" + # A pty with no size set, such as script(1)'s, reports 0: unknown, not narrow. + case "$run_width" in + '' | 0 | *[!0-9]*) run_width=80 ;; + esac + run_width=$((run_width - ${#STEP_INDENT} - 3)) + [ "$run_width" -ge 20 ] || run_width=20 + # A sleep without fractions would make 0.1 an error and the loop a busy + # one, so fall back to whole seconds. + run_delay=0.1 + sleep "$run_delay" 2>/dev/null || run_delay=1 + + # One writer for both lines: the spinner redraws them from the log until + # the flag file goes, which cleanup also removes on an interrupt. + run_flag="${WORK_DIR}/run-live.running" + : >"$run_flag" + # The spinner parks the cursor at the start of its line, where a visible + # one sits on top of the indent, so it is hidden too. + printf '\033[?25l\033[?7l' + TERM_MODES_CHANGED=yes + ( + run_frame="${SPIN_FRAMES%% *}" + run_seen=0 + while [ -f "$run_flag" ]; do + # Word splitting is the point: one frame per word. + # shellcheck disable=SC2086 + for run_frame in $SPIN_FRAMES; do + [ -f "$run_flag" ] || break + run_live_draw + sleep "$run_delay" + done + done + # Once more after the command ends, so output from one that finished + # between two ticks is still drawn rather than skipped. + run_live_draw + ) & + run_spinner=$! + + if [ "$run_stdout" = "$run_log" ]; then + if "$@" >"$run_log" 2>&1; then run_status=0; else run_status=$?; fi + else + if "$@" "$run_stdout" 2>>"$run_log"; then run_status=0; else run_status=$?; fi + fi + + rm -f "$run_flag" + wait "$run_spinner" 2>/dev/null || true + # Erase the label line and the log line under it. + printf '\r\033[2K\n\033[2K\033[1A\r\033[?7h\033[?25h' + TERM_MODES_CHANGED=no + run_live_log "$@" + return "$run_status" +} + +# What run_live's command printed, and how it ended, into the install log. +run_live_log() { # command... + log "\$ $*" + if [ -s "$run_log" ]; then log "$(cat "$run_log")"; fi + if [ "$run_stdout" != "$run_log" ] && [ -s "$run_stdout" ]; then log "$(cat "$run_stdout")"; fi + log "(exit ${run_status})" +} + +# For display only: a path under $HOME reads shorter as ~/…, and nothing +# shown this way is ever opened. +tildify() { + # shellcheck disable=SC2088 + case "$1" in + "$HOME") printf '~' ;; + "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; + *) printf '%s' "$1" ;; + esac +} + die() { - echo "mapbox-cli: $*" >&2 + echo "${RED}mapbox-cli:${RESET} $*" >&2 + log "error: $*" exit 1 } +# --- Install log ------------------------------------------------------------- +# +# Each run is recorded, so an error seen only in passing — run_live erases a +# command's output once it ends — can be read again afterwards: every step +# line, and every command run after the binary is in place with its full +# output and exit status. The previous run's log is kept beside it as +# install.log.1. Local only: nothing reads or sends it. A directory that cannot +# be written leaves the install unlogged rather than failed. +INSTALL_LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/mapbox-cli" +if mkdir -p "$INSTALL_LOG_DIR" 2>/dev/null; then + if [ -f "${INSTALL_LOG_DIR}/install.log" ]; then + mv -f "${INSTALL_LOG_DIR}/install.log" "${INSTALL_LOG_DIR}/install.log.1" 2>/dev/null || true + fi + if { printf 'mapbox-cli install.sh, %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >"${INSTALL_LOG_DIR}/install.log"; } 2>/dev/null; then + INSTALL_LOG="${INSTALL_LOG_DIR}/install.log" + fi +fi +log "system: $(uname -srm 2>/dev/null)" +log "install dir: ${INSTALL_DIR}" +log "SHELL: ${SHELL:-}" + detect_target() { os="$(uname -s)" arch="$(uname -m)" @@ -349,6 +594,13 @@ STAGED='' # reads the body as both uncalled (SC2329) and unreachable (SC2317). # shellcheck disable=SC2317,SC2329 cleanup() { + # run_live hides the cursor and turns off line wrap while its spinner + # runs; an interrupt must not leave the user's terminal that way. + if [ "${TERM_MODES_CHANGED:-no}" = yes ]; then + # Stopped first, or it can draw again after the lines are erased. + kill "${run_spinner:-}" 2>/dev/null || true + printf '\r\033[2K\n\033[2K\033[1A\r\033[?7h\033[?25h' + fi if [ -n "$STAGED" ] && [ -e "$STAGED" ]; then rm -f "$STAGED" fi @@ -369,9 +621,30 @@ WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mapbox-cli.XXXXXX")" || artifact_url="${BASE_URL}/${VERSION}/${file}" tarball="${WORK_DIR}/${file##*/}" -echo "Downloading ${artifact_url}" -fetch -o "$tarball" "$artifact_url" || - die "could not download ${artifact_url}" +case "$TARGET" in + aarch64-apple-darwin) platform_name='macOS (Apple Silicon)' ;; + x86_64-apple-darwin) platform_name='macOS (Intel)' ;; + aarch64-*-linux-*) platform_name='Linux (arm64)' ;; + *) platform_name='Linux (x86_64)' ;; +esac +echo "" +echo "${BOLD}Installing Mapbox CLI ${resolved_version:-$VERSION}${RESET} ${DIM}for ${platform_name}${RESET}" +echo "" + +# A progress bar when someone is watching, and only then: in a log, curl's +# carriage-return redraws are noise. Once the download is done the bar has +# nothing left to say, so it is erased and the step line takes its place. +if [ -t 2 ]; then + if [ -n "$AUTH" ]; then + curl -fSL --progress-bar -A "$USER_AGENT" -u "$AUTH" -o "$tarball" "$artifact_url" + else + curl -fSL --progress-bar -A "$USER_AGENT" -o "$tarball" "$artifact_url" + fi || die "could not download ${artifact_url}" + printf '\033[1A\033[2K' >&2 +else + fetch -o "$tarball" "$artifact_url" || + die "could not download ${artifact_url}" +fi # Before unpacking, not after: an artifact that fails here never gets the # chance to write anything, anywhere. @@ -390,6 +663,12 @@ EOF exit 1 fi +size_bytes="$(wc -c <"$tarball" | tr -d ' ')" +size="$(awk -v b="$size_bytes" 'BEGIN { + if (b >= 1048576) printf "%.1f MB", b / 1048576; else printf "%d KB", (b + 1023) / 1024 +}')" +step_ok "Downloaded ${file##*/} ${DIM}(${size}, SHA-256 verified)${RESET}" + tar -xzf "$tarball" -C "$WORK_DIR" || die "could not unpack ${file}" @@ -434,6 +713,7 @@ mv -f "$STAGED" "${INSTALL_DIR}/mapbox" || die "could not install into ${INSTALL_DIR}" STAGED='' + # Report what the binary says about itself rather than what the manifest # claimed: an artifact built for another platform fails here and nowhere # earlier. @@ -441,27 +721,14 @@ installed_version="$("${INSTALL_DIR}/mapbox" --version 2>/dev/null > ${path_file} +# Whether a profile line other than a comment names as a whole path, +# not as a prefix of another (/x/bin in /x/bin2) or a word inside one. Catches +# a line the user wrote by hand, in either spelling of the directory. +profile_names_dir() { # profile_names_dir + awk -v d="$2" -v before=":=\"' ( " -v after=":\"' ) " ' + /^[ \t]*#/ { next } + { + off = 0 + rest = $0 + while ((i = index(rest, d)) > 0) { + at = off + i + b = at > 1 ? substr($0, at - 1, 1) : "" + a = substr($0, at + length(d), 1) + if ((b == "" || index(before, b)) && (a == "" || index(after, a))) { found = 1; exit } + off = at + rest = substr($0, at + 1) + } + } + END { exit !found }' "$1" 2>/dev/null +} -Then restart your shell, or run that line now to use mapbox in this one. -EOF +# yes when a profile now puts the install dir on PATH but this shell, which +# a child process cannot change, does not have it yet. +needs_new_shell=no +if [ "$install_dir_on_path" = no ]; then + if [ "$MODIFY_PATH" = no ]; then + step_warn "$(tildify "$INSTALL_DIR") is not on your PATH, and MAPBOX_NO_MODIFY_PATH is set. Add it:" + echo "" + echo " echo '${path_line}' >> $(tildify "$profile")" + echo "" + # A second run must not append a second copy. + elif [ -f "$profile" ] && + { grep -qF -- "$path_line" "$profile" 2>/dev/null || + profile_names_dir "$profile" "$INSTALL_DIR" || + profile_names_dir "$profile" "$path_dir"; }; then + step_ok "PATH is already set up in $(tildify "$profile")" + needs_new_shell=yes + elif mkdir -p "$(dirname "$profile")" 2>/dev/null && + printf '\n# Added by the Mapbox CLI installer\n%s\n' "$path_line" >>"$profile" 2>/dev/null; then + step_ok "Added $(tildify "$INSTALL_DIR") to PATH in $(tildify "$profile")" + needs_new_shell=yes + else + step_warn "Could not write to $(tildify "$profile"). Add $(tildify "$INSTALL_DIR") to your PATH yourself:" + echo "" + echo " ${path_line}" + echo "" + fi fi # `command -v` names the winner and stops, so a second mapbox further down @@ -531,28 +849,25 @@ for dir in $PATH; do done IFS="$saved_ifs" +# A profile line written just now prepends the install dir, so a new shell +# runs the copy just installed whatever this one resolves; the new-terminal +# hint below already covers this shell. resolved="$(command -v mapbox 2>/dev/null || true)" -if [ -n "$resolved" ] && [ "$resolved" != "${INSTALL_DIR}/mapbox" ]; then +if [ -n "$resolved" ] && [ "$resolved" != "${INSTALL_DIR}/mapbox" ] && [ "$needs_new_shell" = no ]; then + step_warn "mapbox on your PATH still resolves to ${resolved}" cat < : comma-joined agent labels } if agent_setup_allowed; then - if agent_check=$("${INSTALL_DIR}/mapbox" generate-skills --global --dry-run -o json 2>/dev/null /dev/null 2>&1 &1 &1 /dev/null /dev/null 2>&1 + printf '%s' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" +} +mcp_rows() { # mcp_rows + # "client" sorts first, so every row starts with it; splitting there + # keeps a row whole even when its "error" text has braces of its own. + printf '%s' "$1" | awk '{ gsub(/[{]"client":"/, "\n{\"client\":\""); print }' | while IFS= read -r mcp_row; do + mcp_s="$(mcp_field status "$mcp_row")" + mcp_v="$(mcp_field server "$mcp_row")" + mcp_c="$(mcp_field client "$mcp_row")" + if [ -n "$mcp_s" ] && [ -n "$mcp_v" ] && [ -n "$mcp_c" ]; then + printf '%s|%s|%s\n' "$mcp_s" "$mcp_v" "$mcp_c" + fi + done | + sed 's/|mapbox|/|Mapbox MCP|/;s/|mapbox-devkit|/|Mapbox DevKit MCP|/' | + sed 's/|claude-code$/|Claude Code/;s/|codex$/|Codex/;s/|vscode$/|VS Code/;s/|cursor$/|Cursor/' +} + +# "status|server|client, client, ..." — one line per server and outcome, in +# the order they first appear, so four results read as two lines. +mcp_grouped() { # mcp_grouped + mcp_rows "$1" | awk -F'|' '{ + k = $1 "|" $2 + if (!(k in who)) { order[++n] = k; who[k] = $3 } else { who[k] = who[k] ", " $3 } + } END { for (i = 1; i <= n; i++) print order[i] "|" who[order[i]] }' +} + +# The clients a dry run would register anything with, comma-joined. +mcp_clients_from() { # mcp_clients_from + mcp_rows "$1" | awk -F'|' '$1 == "would_install" && !seen[$3]++ { print $3 }' | + paste -sd, - | sed 's/,/, /g' +} + +if agent_setup_allowed; then + # Fails with mcp_client_not_found when no supported client is installed, + # which is most machines: nothing to offer, so nothing is said. + if mcp_check=$(log_capture "${INSTALL_DIR}/mapbox" mcp install --global --dry-run -o json); then + mcp_clients="$(mcp_clients_from "$mcp_check")" + if [ -n "$mcp_clients" ]; then + do_mcp_setup=no + if have_tty; then + ask_title "Add the Mapbox MCP servers to ${mcp_clients}?" + ask_note "This runs $(ask_cmd 'mapbox mcp install --global'), now or any time later." + answer="$(ask " Add them now? ${DIM}[y/N]${RESET} ")" || answer='' + is_yes "$answer" && do_mcp_setup=yes + STEP_INDENT=' ' + fi + if [ "$do_mcp_setup" = yes ]; then + # Each client's own CLI does the registering, and Codex's can + # stop to print a sign-in URL, so its output is shown live. + if run_live "Adding the Mapbox MCP servers" "${WORK_DIR}/mcp-install.log" "${WORK_DIR}/mcp-install.json" \ + "${INSTALL_DIR}/mapbox" mcp install --global -o json; then + mcp_ok=yes + else + mcp_ok=no + fi + mcp_grouped "$(cat "${WORK_DIR}/mcp-install.json" 2>/dev/null)" >"${WORK_DIR}/mcp-rows" + # One line per server and outcome, so a server added just now + # reads differently from one that was already there. + mcp_trouble=no + while IFS='|' read -r status what who; do + case "$status" in + installed | already_installed) ;; + *) mcp_trouble=yes ;; + esac + case "$status" in + installed) step_ok "Added ${what} to ${who}" ;; + already_installed) step_skip "${what} was already in ${who}" ;; + installed_login_incomplete) step_warn "Added ${what} to ${who}, but sign-in did not finish" ;; + client_not_found) step_warn "Skipped ${what} for ${who}: CLI not on PATH" ;; + config_unreadable) step_warn "Skipped ${what} for ${who}: config could not be read" ;; + *) step_warn "Could not add ${what} to ${who}" ;; + esac + done <"${WORK_DIR}/mcp-rows" + if [ "$mcp_ok" = no ]; then + # A failure no row above accounts for still gets a line of + # its own, rather than a list of successes. + [ "$mcp_trouble" = yes ] || step_warn "Not every Mapbox MCP server could be added." + step_detail "Run 'mapbox mcp install --global' to see why and try again." + fi + else + step_skip "Mapbox MCP servers not added to ${mcp_clients}. Run this any time:" + step_detail "${DIM}mapbox mcp install --global${RESET}" + fi + STEP_INDENT=' ' fi fi fi # --- The Tilesets CLI ------------------------------------------------------ # -# `mapbox tilesets-cli` execs a separately installed `tilesets`. Everything -# below says the same thing as `launch_failed` in src/tilesets_cli.rs, in -# another language; keep the two in step. +# `mapbox tilesets-cli` execs a separately installed `tilesets`. When an +# install was tried and failed, what is printed says the same thing as +# `launch_failed` in src/tilesets_cli.rs, in another language; keep the two in +# step. A plain "no" gets one line instead, since `mapbox tilesets-cli` prints +# the full version itself the first time it is run without one. tilesets_instructions() { cat </dev/null 2>&1 } +# pipx and pip talk at length (emoji included), and once they are done say +# nothing the step line after them does not, so run_live shows them only +# while they run. +TILESETS_LOG="${WORK_DIR}/tilesets-install.log" + # 0 installed, 1 an installer ran and failed, 2 no interpreter to install with, # 3 an interpreter that will not be installed into. # Children get /dev/null 2>&1; then - echo "Running: pipx install mapbox-tilesets" - pipx install mapbox-tilesets /dev/null 2>&1; then - echo "" - echo "Tilesets CLI: $(tilesets --version 2>/dev/null /dev/null 2>&1; then + step_ok "Found Tilesets CLI: $(tilesets --version 2>/dev/null /dev/null 2>&1; then + step_ok "Installed Tilesets CLI: $(tilesets --version 2>/dev/null /dev/null 2>&1; then - echo "" - echo "Tilesets CLI: $(tilesets --version 2>/dev/null &2 exit 1 ;; + mcp) + echo '{"code":"mcp_client_not_found"}' >&2 + exit 1 + ;; *) echo '$VersionText' ;; esac "@ @@ -192,7 +200,8 @@ esac # variables so the same binary covers every branch: MAPBOX_TEST_INSTALL_MODE # (ok | already_installed) and MAPBOX_TEST_UPDATE_MODE (clean | dirty, read # only once install reports already_installed) - the same two knobs -# test-install.sh's agent-channel fixture uses. +# test-install.sh's agent-channel fixture uses. MAPBOX_TEST_MCP_MODE (none | +# fresh) drives `mcp`; only its dry run is reachable from here, see below. function New-AgentFakeBinary([string]$Path, [string]$VersionText) { $ErrorActionPreference = 'Continue' if ($OnWindows) { @@ -242,6 +251,14 @@ class Program { return 0; } } + if (a0 == "mcp") { + if ((Environment.GetEnvironmentVariable("MAPBOX_TEST_MCP_MODE") ?? "none") == "none") { + Console.Error.WriteLine("{\"code\":\"mcp_client_not_found\"}"); + return 1; + } + Console.WriteLine("{\"results\":[{\"client\":\"claude-code\",\"server\":\"mapbox\",\"status\":\"would_install\"},{\"client\":\"claude-code\",\"server\":\"mapbox-devkit\",\"status\":\"would_install\"},{\"client\":\"vscode\",\"server\":\"mapbox\",\"status\":\"would_install\"}]}"); + return 0; + } Console.WriteLine("$VersionText"); return 0; } @@ -264,6 +281,13 @@ case "`${1:-}" in done echo '{"written":true}' ;; + mcp) + if [ "`${MAPBOX_TEST_MCP_MODE:-none}" = none ]; then + echo '{"code":"mcp_client_not_found"}' >&2 + exit 1 + fi + echo '{"results":[{"client":"claude-code","server":"mapbox","status":"would_install"},{"client":"claude-code","server":"mapbox-devkit","status":"would_install"},{"client":"vscode","server":"mapbox","status":"would_install"}]}' + ;; agent-skills) sub="`$2" case "`$sub" in @@ -532,6 +556,7 @@ function New-CaseEnv([string]$Name) { Clear-Env 'MAPBOX_CLI_NO_AGENT_SETUP' Clear-Env 'MAPBOX_TEST_INSTALL_MODE' Clear-Env 'MAPBOX_TEST_UPDATE_MODE' + Clear-Env 'MAPBOX_TEST_MCP_MODE' # What install.ps1 reads to decide where it is running. Set explicitly so # the same case means the same thing on Windows and on the machine this is # written on. @@ -608,7 +633,9 @@ try { Expect-File (Join-Path $script:BinDir 'mapbox.exe') 'mapbox.exe is in the install dir' Expect-Out 'Installed mapbox 9.9.9' 'reports the version it ran, not the one it was promised' Expect-Out (Join-Path $script:BinDir 'mapbox.exe') 'reports the path' - Expect-Out 'channel latest (9.9.9)' 'names the channel and the version it resolved' + Expect-Out 'Installing Mapbox CLI 9.9.9' 'names the version it resolved' + Expect-Out 'mapbox 9.9.9 is ready.' 'ends by saying it is ready' + Expect-Out 'mapbox auth login' 'names the first command to run' Expect-NoOut '.mapbox.install.' 'leaves no staging file behind' $staging = Get-ChildItem -LiteralPath $script:BinDir -Force | Where-Object { $_.Name -ne 'mapbox.exe' } Expect-Equal '' ([string]($staging | ForEach-Object { $_.Name })) 'nothing else is left in the install dir' @@ -740,7 +767,7 @@ try { Invoke-Installer Expect-Status 0 'exits 0' Expect-Out 'Installed mapbox 0.1.0-dev.abc1234' 'installs that exact version' - Expect-Out "channel $PinnedVersion" 'names the channel it resolved' + Expect-Out 'Installing Mapbox CLI 0.1.0-dev.abc1234' 'names the version it resolved' # The channel's directories carry a leading `v`. Every place a person # reads a version from (`mapbox --version`, CHANGELOG.md, Cargo.toml) @@ -756,7 +783,7 @@ try { Invoke-Installer Expect-Status 0 'exits 0' Expect-Out 'Installed mapbox 0.1.0-dev.abc1234' 'installs that exact version' - Expect-Out "channel $PinnedVersion" 'and resolved the v-prefixed directory' + Expect-Out 'Installing Mapbox CLI 0.1.0-dev.abc1234' 'and resolved the v-prefixed directory' # `latest` starts with a letter, so nothing is prepended. Getting this # wrong would break the default install rather than an edge case. @@ -765,7 +792,7 @@ try { $env:MAPBOX_CLI_VERSION = 'latest' Invoke-Installer Expect-Status 0 'exits 0' - Expect-Out 'channel latest' 'asked for latest, not vlatest' + Expect-Out 'Installing Mapbox CLI 9.9.9' 'asked for latest, not vlatest' Start-Case 'reinstalling reports the version it replaced' New-CaseEnv 'upgrade' @@ -1027,7 +1054,7 @@ try { $env:MAPBOX_CLI_BASE_URL = "$($open.BaseUrl)/agent-channel" Invoke-Installer Expect-Status 0 'exits 0' - Expect-Out 'A coding agent was detected on this machine: claude.' 'names the agent' + Expect-Out 'Coding agents found: claude.' 'names the agent' Expect-Out 'Not set up. Run these any time:' 'gives the manual commands instead of asking' Expect-Out 'mapbox generate-skills --global' 'the first manual command' Expect-Out 'mapbox agent-skills install --global' 'the second manual command' @@ -1039,6 +1066,27 @@ try { Invoke-Installer Expect-Status 0 'exits 0' Expect-NoOut 'coding agent' 'skips the question entirely' + + Start-Case 'MCP, no console: not asked, not added' + New-CaseEnv 'mcp-no-console' + $env:MAPBOX_CLI_BASE_URL = "$($open.BaseUrl)/agent-channel" + $env:MAPBOX_TEST_MCP_MODE = 'fresh' + Invoke-Installer + Expect-Status 0 'exits 0' + Expect-NoOut 'Add the Mapbox MCP servers' 'does not ask' + Expect-Out 'Mapbox MCP servers not added to Claude Code, VS Code.' 'names each client once' + Expect-Out 'mapbox mcp install --global' 'gives the manual command' + + Start-Case 'MAPBOX_CLI_NO_AGENT_SETUP skips the MCP question too' + New-CaseEnv 'mcp-opt-out' + $env:MAPBOX_CLI_BASE_URL = "$($open.BaseUrl)/agent-channel" + $env:MAPBOX_TEST_MCP_MODE = 'fresh' + $env:MAPBOX_CLI_NO_AGENT_SETUP = '1' + Invoke-Installer + Expect-Status 0 'exits 0' + # Not just 'MCP': -like ignores case, and this case's own directory, + # printed in the PATH note above, is named mcp-opt-out. + Expect-NoOut 'Mapbox MCP servers' 'says nothing about it' } finally { if ($open) { Stop-ChannelServer $open } if ($gated) { Stop-ChannelServer $gated } diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 4a05352..800d811 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -225,6 +225,10 @@ case "\${1:-}" in echo '{"code":"no_agent_detected"}' >&2 exit 1 ;; + mcp) + echo '{"code":"mcp_client_not_found"}' >&2 + exit 1 + ;; *) echo "fake mapbox: \$*" ;; esac EOF @@ -279,6 +283,9 @@ make_channel "${CHANNEL}/v0.1.0-dev.abc1234" v0.1.0-dev.abc1234 # environment variables: MAPBOX_TEST_INSTALL_MODE (ok | already_installed) # and MAPBOX_TEST_UPDATE_MODE (clean | dirty, read only once install # reports already_installed). Defaults match the plain first-install case. +# MAPBOX_TEST_MCP_MODE (none | fresh | registered | fail) drives `mcp`; +# none, no MCP client found, is the default so cases about the skill see +# only the skill's question. AGENT_CHANNEL="${ROOT}/agent-channel" mkdir -p "${AGENT_CHANNEL}/latest/build" cat >"${AGENT_CHANNEL}/latest/build/mapbox" <<'EOF' @@ -319,6 +326,38 @@ case "${1:-}" in ;; esac ;; + mcp) + mcp_mode="${MAPBOX_TEST_MCP_MODE:-none}" + if [ "$mcp_mode" = none ]; then + echo '{"code":"mcp_client_not_found"}' >&2 + exit 1 + fi + is_dry=no + for a in "$@"; do [ "$a" = "--dry-run" ] && is_dry=yes; done + if [ "$is_dry" = yes ]; then + if [ "$mcp_mode" = registered ]; then + status=already_installed + else + status=would_install + fi + echo '{"results":[{"client":"claude-code","server":"mapbox","status":"'"$status"'"},{"client":"claude-code","server":"mapbox-devkit","status":"'"$status"'"},{"client":"vscode","server":"mapbox","status":"'"$status"'"}]}' + elif [ "$mcp_mode" = fail ]; then + echo '{"results":[{"client":"claude-code","server":"mapbox","status":"installed"},{"client":"vscode","error":"--add-mcp rejected {\\"name\\":\\"mapbox\\"}","server":"mapbox","status":"failed"}]}' + exit 1 + else + # Like a real client's own output: colored, and longer than any + # terminal is wide. + printf '\033[2mFile modified: /%s/zz-end-of-long-line\033[0m\n' \ + "$(printf 'a%.0s' $(seq 1 200))" >&2 + printf 'Open https://auth.example.com/authorize?state=%s&end=zz-url-end\n' \ + "$(printf 'b%.0s' $(seq 1 120))" >&2 + echo 'waiting for sign-in' >&2 + # Mixed on purpose: added now, already there, and a row with an + # "error" key, which sorts between "client" and "server" as the + # real CLI's serde_json maps do. + echo '{"results":[{"client":"codex","error":"OAuth failed","server":"mapbox","status":"installed_login_incomplete"},{"client":"claude-code","server":"mapbox","status":"installed"},{"client":"claude-code","server":"mapbox-devkit","status":"installed"},{"client":"vscode","server":"mapbox","status":"already_installed"},{"client":"vscode","server":"mapbox-devkit","status":"installed"}]}' + fi + ;; *) echo "fake mapbox: $*" ;; esac EOF @@ -390,6 +429,13 @@ cat >"${SHIMS}/pipx-silent" <<'EOF' echo "fake pipx: $*" EOF +# A pipx that fails, the way a broken network or index does. +cat >"${SHIMS}/pipx-fail" <<'EOF' +#!/bin/sh +echo "fake pipx: Could not find a version that satisfies mapbox-tilesets" +exit 1 +EOF + cat >"${SHIMS}/tilesets" <<'EOF' #!/bin/sh echo "tilesets, version 1.11.0" @@ -610,11 +656,22 @@ new_case_env() { # case-name MAPBOX_INSTALL_DIR="$BIN_DIR" PATH="${CASE_SHIMS}:${SAFE_PATH}" unset MAPBOX_CLI_VERSION MAPBOX_CLI_AUTH MAPBOX_INSTALL_TILESETS MAPBOX_TILESETS_CLI - unset MAPBOX_CLI_INSTALL_SOURCE + unset MAPBOX_CLI_INSTALL_SOURCE MAPBOX_NO_MODIFY_PATH ZDOTDIR XDG_STATE_HOME + # A HOME of its own, because the installer now writes a PATH line into the + # shell's profile: no case may touch the real ~/.zshrc. /bin/sh picks + # ~/.profile, so a developer's own SHELL does not decide which file a case + # checks. + HOME="${CASE_DIR}/home" + mkdir -p "$HOME" + SHELL=/bin/sh + # Escape codes would split the substrings these cases look for; the one + # case about color unsets this. + NO_COLOR=1 + export HOME SHELL NO_COLOR # A developer with either of these set in their own shell would otherwise # turn every marker case into a failure that looks like the marker broke. unset DISABLE_TELEMETRY MAPBOX_CLI_NO_TELEMETRY - unset MAPBOX_CLI_NO_AGENT_SETUP MAPBOX_TEST_INSTALL_MODE MAPBOX_TEST_UPDATE_MODE + unset MAPBOX_CLI_NO_AGENT_SETUP MAPBOX_TEST_INSTALL_MODE MAPBOX_TEST_UPDATE_MODE MAPBOX_TEST_MCP_MODE export MAPBOX_CLI_BASE_URL MAPBOX_INSTALL_DIR PATH } @@ -633,6 +690,11 @@ expect_file "${BIN_DIR}/mapbox" 'the binary is in the install dir' expect_out 'Installed mapbox 9.9.9' 'reports the version it ran, not the one it was promised' expect_out "${BIN_DIR}/mapbox" 'reports the path' expect_says "$("${BIN_DIR}/mapbox" --version)" 'mapbox 9.9.9' 'the installed binary runs' +expect_out 'mapbox 9.9.9 is ready.' 'ends by saying it is ready' +expect_out 'mapbox auth login' 'names the first command to run' +expect_out 'MAPBOX_CLI_NO_TELEMETRY=1' 'discloses telemetry and the way to turn it off' +expect_order 'is ready.' 'mapbox auth login' 'the next step comes after the result' +expect_no_out "$(printf '\033')" 'no escape codes under NO_COLOR' ls -a "$BIN_DIR" >"$OUT" 2>&1 expect_no_out '.mapbox.install.' 'leaves no staging file behind' @@ -835,7 +897,7 @@ export MAPBOX_INSTALL_TILESETS=no run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' expect_out 'Installed mapbox 0.1.0-dev.abc1234' 'installs that exact version' -expect_out 'channel v0.1.0-dev.abc1234' 'names the channel it resolved' +expect_out 'Installing Mapbox CLI 0.1.0-dev.abc1234' 'names the version it resolved' # The channel's directories carry a leading `v`. Every place a person reads a # version from — `mapbox --version`, CHANGELOG.md, Cargo.toml — shows it @@ -848,7 +910,7 @@ export MAPBOX_INSTALL_TILESETS=no run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' expect_out 'Installed mapbox 0.1.0-dev.abc1234' 'installs that exact version' -expect_out 'channel v0.1.0-dev.abc1234' 'and resolved the v-prefixed directory' +expect_out 'Installing Mapbox CLI 0.1.0-dev.abc1234' 'and resolved the v-prefixed directory' # `latest` starts with a letter, so nothing is prepended to it. Pinning this # wrong would break the default install rather than an edge case. @@ -858,7 +920,7 @@ export MAPBOX_CLI_VERSION=latest export MAPBOX_INSTALL_TILESETS=no run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' -expect_out 'channel latest' 'asked for latest, not vlatest' +expect_out 'Installing Mapbox CLI 9.9.9' 'asked for latest, not vlatest' start 'an unsupported platform stops before downloading' new_case_env unsupported @@ -941,8 +1003,11 @@ export PATH="${OTHER}:${PATH}" export MAPBOX_INSTALL_TILESETS=no run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' -expect_out 'is not on your PATH' 'says the install dir is not on PATH' -expect_out "still resolves to ${OTHER}/mapbox" 'and names the other binary too' +expect_out "Added ${BIN_DIR} to PATH" 'puts the install dir on PATH' +expect_out 'This terminal started before that change' 'and says the new copy wins in a new terminal' +# The profile line prepends the install dir, so a new shell runs the copy just +# installed; telling the reader the other one still wins would be wrong there. +expect_no_out 'still resolves to' 'without claiming the other one still wins' start 'a mapbox further down PATH is named, with the way to clear the cache' new_case_env behind @@ -974,18 +1039,107 @@ expect_no_out 'is not on your PATH' 'says nothing about PATH' expect_no_out 'still resolves to' 'and nothing about shadowing' expect_no_out 'another mapbox at' 'and nothing about a second copy, because there is none' -start 'an install dir that is not on PATH is named, with the line to add' +start 'an install dir that is not on PATH is added through the profile' new_case_env off-path export SHELL=/bin/zsh export MAPBOX_INSTALL_TILESETS=no run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' +# BIN_DIR is outside HOME, so the line carries it verbatim. +expect_in_file "${HOME}/.zshrc" "export PATH=\"${BIN_DIR}:\$PATH\"" 'writes the line into ~/.zshrc' +# The literal ~ is the point: it is a path shown, not one opened. +# shellcheck disable=SC2088 +expect_out "Added ${BIN_DIR} to PATH in ~/.zshrc" 'says which file it changed' +expect_out 'This terminal started before that change' 'says this shell does not have it yet' +expect_out "export PATH=\"${BIN_DIR}:\$PATH\"" 'and gives the line for this one' +expect_no_out 'is not on your PATH' 'does not ask the reader to do it by hand' +run_piped && status=0 || status=$? +expect_status 0 "$status" 'a second run exits 0' +expect_out 'already set up' 'a second run sees the line it wrote' +lines="$(grep -cF "$BIN_DIR" "${HOME}/.zshrc")" +expect_says "$lines" 1 'and does not write it twice' + +start 'a dir under HOME keeps HOME literal in the profile' +new_case_env off-path-home +export MAPBOX_INSTALL_DIR="${HOME}/.local/bin" +export MAPBOX_INSTALL_TILESETS=no +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +# shellcheck disable=SC2016 +expect_in_file "${HOME}/.profile" 'export PATH="$HOME/.local/bin:$PATH"' \ + 'writes $HOME rather than this machine'"'"'s path' +# shellcheck disable=SC2088 +expect_out 'Added ~/.local/bin to PATH in ~/.profile' 'shows the dir under ~' + +start 'zsh honors ZDOTDIR, and fish gets fish_add_path' +new_case_env off-path-shells +export SHELL=/bin/zsh +export ZDOTDIR="${HOME}/zdot" +export MAPBOX_INSTALL_TILESETS=no +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_file "${ZDOTDIR}/.zshrc" 'writes ZDOTDIR/.zshrc' +expect_no_file "${HOME}/.zshrc" 'and not ~/.zshrc' +unset ZDOTDIR +export SHELL=/usr/local/bin/fish +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_in_file "${HOME}/.config/fish/config.fish" "fish_add_path \"${BIN_DIR}\"" \ + 'creates the fish config with fish_add_path' + +start 'MAPBOX_NO_MODIFY_PATH leaves the profile alone and prints the line' +new_case_env no-modify-path +export SHELL=/bin/zsh +export MAPBOX_NO_MODIFY_PATH=1 +export MAPBOX_INSTALL_TILESETS=no +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_no_file "${HOME}/.zshrc" 'writes no profile' expect_out "${BIN_DIR} is not on your PATH" 'names the directory' expect_out "export PATH=\"${BIN_DIR}:\$PATH\"" 'prints the exact line' -# The literal ~ is the point: it is advice to read, not a path to open. # shellcheck disable=SC2088 expect_out '~/.zshrc' 'names the file for the shell in use' -unset SHELL +expect_no_out 'This terminal started before that change' 'does not claim a new terminal would have it' +export MAPBOX_NO_MODIFY_PATH=0 +run_piped && status=0 || status=$? +expect_file "${HOME}/.zshrc" 'MAPBOX_NO_MODIFY_PATH=0 is not an opt-out' + +start 'a mention of the dir that does not put it on PATH is not mistaken for one' +new_case_env off-path-near-miss +export SHELL=/bin/zsh +export MAPBOX_INSTALL_TILESETS=no +# The $PATH is meant literally: it is profile text. +# shellcheck disable=SC2016 +printf '# %s is where mapbox goes\nexport PATH="%s2:$PATH"\nalias m=%s/mapbox\n' \ + "$BIN_DIR" "$BIN_DIR" "$BIN_DIR" >"${HOME}/.zshrc" +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_no_out 'already set up' 'a comment, a longer dir and a file under it are not the dir' +expect_in_file "${HOME}/.zshrc" "export PATH=\"${BIN_DIR}:\$PATH\"" 'so it adds the line' + +if [ "$(uname -s)" = Darwin ]; then + start 'bash on macOS writes to an existing .profile rather than shadowing it' + new_case_env off-path-bash-profile + export SHELL=/bin/bash + export MAPBOX_INSTALL_TILESETS=no + echo 'export EDITOR=vi' >"${HOME}/.profile" + run_piped && status=0 || status=$? + expect_status 0 "$status" 'exits 0' + expect_in_file "${HOME}/.profile" "export PATH=\"${BIN_DIR}:\$PATH\"" 'writes the line into ~/.profile' + expect_no_file "${HOME}/.bash_profile" 'and creates no .bash_profile, which login bash would read instead' +fi + +start 'a terminal gets color and NO_COLOR takes it away' +new_case_env color +export MAPBOX_INSTALL_TILESETS=no +unset NO_COLOR +export TERM=xterm +run_interactive '' && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_out "$(printf '\033[32m')" 'colors the step marks' +export NO_COLOR=1 +run_interactive '' && status=0 || status=$? +expect_no_out "$(printf '\033[32m')" 'and drops it under NO_COLOR' start 'an install dir that does not exist yet' new_case_env fresh-dir @@ -1018,10 +1172,9 @@ shim pipx run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0 rather than blocking (124 would be a hang)' expect_file "${BIN_DIR}/mapbox" 'mapbox is installed' -expect_out 'The Mapbox Tilesets CLI is not installed' 'explains what is missing' -expect_out 'pipx install mapbox-tilesets' 'prints the instructions instead of asking' -expect_out 'MAPBOX_TILESETS_CLI' 'mentions the override, as the CLI does' -expect_no_out 'Install it as well?' 'does not ask when nobody can answer' +expect_out 'Tilesets CLI is not installed' 'says what is missing' +expect_out 'pipx install mapbox-tilesets' 'gives the command instead of asking' +expect_no_out 'Install the Tilesets CLI too?' 'does not ask when nobody can answer' expect_no_out 'fake pipx' 'installs nothing unasked' start 'a terminal, answered no' @@ -1030,11 +1183,11 @@ shim pipx run_interactive n && status=0 || status=$? expect_status 0 "$status" 'a declined extra is not an install failure' expect_file "${BIN_DIR}/mapbox" 'mapbox is installed' -expect_out 'Install it as well?' 'asks' -expect_out 'mapbox is installed and ready to use' 'says mapbox is done before asking anything' -expect_out 'the rest of this is optional' 'calls the extra optional' -expect_order 'mapbox is installed and ready to use' 'Install it as well?' \ +expect_out 'Install the Tilesets CLI too?' 'asks' +expect_out 'Only the mapbox tilesets-cli command needs it' 'says which command needs it' +expect_order 'Installed mapbox 9.9.9' 'Install the Tilesets CLI too?' \ 'reports the finished install before the question, not after' +expect_order 'Install the Tilesets CLI too?' 'is ready.' 'and the summary after it' expect_says "$("${BIN_DIR}/mapbox" --version)" 'mapbox 9.9.9' 'and mapbox runs after answering no' expect_out 'pipx install mapbox-tilesets' 'falls back to printing the instructions' expect_no_out 'fake pipx' 'runs no installer' @@ -1044,7 +1197,7 @@ new_case_env tty-default shim pipx run_interactive '' && status=0 || status=$? expect_status 0 "$status" 'exits 0' -expect_out 'Install it as well?' 'asks' +expect_out 'Install the Tilesets CLI too?' 'asks' expect_no_out 'fake pipx' 'the default is no' start 'a terminal, answered yes' @@ -1053,8 +1206,9 @@ shim pipx run_interactive y && status=0 || status=$? expect_status 0 "$status" 'exits 0' expect_file "${BIN_DIR}/mapbox" 'mapbox is installed' -expect_out 'Install it as well?' 'asks' -expect_out 'fake pipx: install mapbox-tilesets' 'prefers pipx when it is there' +expect_out 'Install the Tilesets CLI too?' 'asks' +expect_out 'Installing the Tilesets CLI with pipx' 'prefers pipx when it is there' +expect_out 'fake pipx: install mapbox-tilesets' 'shows its progress while it runs' expect_out 'Tilesets CLI: tilesets, version 1.11.0' 'reads the version back' start 'MAPBOX_INSTALL_TILESETS=yes answers ahead of time' @@ -1063,8 +1217,9 @@ shim pipx export MAPBOX_INSTALL_TILESETS=yes run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' -expect_no_out 'Install it as well?' 'does not ask' -expect_out 'fake pipx: install mapbox-tilesets' 'installs with no terminal in sight' +expect_no_out 'Install the Tilesets CLI too?' 'does not ask' +expect_out 'Installing the Tilesets CLI with pipx' 'installs with no terminal in sight' +expect_no_out 'fake pipx' 'and keeps its output out of a log nobody watches live' start 'MAPBOX_INSTALL_TILESETS with a value that is neither yes nor no' new_case_env preanswered-junk @@ -1082,7 +1237,8 @@ export MAPBOX_INSTALL_TILESETS=yes run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' expect_out 'pipx is not installed' 'says why it is not using pipx' -expect_out 'fake python3 -m pip install --user mapbox-tilesets' 'falls back to pip --user' +expect_out 'with pip --user' 'falls back to pip --user' +expect_no_out 'fake python3' 'and keeps pip'"'"'s output out of the way' expect_out 'Tilesets CLI: tilesets, version 1.11.0' 'reads the version back' start 'no pipx and an OS-managed python3: refuse rather than run a doomed pip' @@ -1095,7 +1251,7 @@ expect_file "${BIN_DIR}/mapbox" 'mapbox is installed' expect_out 'managed by your OS (PEP 668)' 'names why pip is not an option' expect_out 'apt install pipx' 'points at the thing that does work there' expect_no_out 'fake python3 -m pip' 'does not run pip at all' -expect_no_out 'pipx is not installed; using pip instead' 'does not announce a fallback it will not take' +expect_no_out 'with pip --user' 'does not announce a fallback it will not take' start 'no pipx and no Python 3.10+: refuse rather than guess' new_case_env no-python @@ -1115,10 +1271,20 @@ shim pipx-silent pipx export MAPBOX_INSTALL_TILESETS=yes run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' -expect_out 'fake pipx: install mapbox-tilesets' 'ran the installer' +expect_out 'Installing the Tilesets CLI with pipx' 'ran the installer' expect_out 'is not on your PATH' 'says the binary is not reachable' expect_out 'MAPBOX_TILESETS_CLI' 'offers the override' +start 'a failed install shows what the installer said' +new_case_env tilesets-fail +shim pipx-fail pipx +export MAPBOX_INSTALL_TILESETS=yes +run_piped && status=0 || status=$? +expect_status 0 "$status" 'still exits 0 — mapbox itself installed' +expect_out 'did not succeed' 'says it failed' +expect_out 'Could not find a version' 'and shows the log it hid while it ran' +expect_out 'pipx install mapbox-tilesets' 'then the instructions' + start 'a tilesets already on PATH is reported and nothing is asked' new_case_env tilesets-present shim tilesets @@ -1127,7 +1293,7 @@ run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' expect_out 'Tilesets CLI: tilesets, version 1.11.0' 'reports its version' expect_no_out 'is not installed' 'says nothing else about it' -expect_no_out 'Install it as well?' 'does not ask' +expect_no_out 'Install the Tilesets CLI too?' 'does not ask' expect_no_out 'fake pipx' 'installs nothing' start 'MAPBOX_TILESETS_CLI is honored, set or broken' @@ -1139,7 +1305,7 @@ export MAPBOX_TILESETS_CLI="${CASE_DIR}/opt/ts" run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' expect_out "Tilesets CLI: ${CASE_DIR}/opt/ts (MAPBOX_TILESETS_CLI)" 'reports the override' -expect_no_out 'Install it as well?' 'does not offer to install over an override' +expect_no_out 'Install the Tilesets CLI too?' 'does not offer to install over an override' expect_no_out 'fake pipx' 'installs nothing' export MAPBOX_TILESETS_CLI="${CASE_DIR}/opt/gone" @@ -1162,9 +1328,11 @@ new_case_env agent-opt-out export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" export MAPBOX_INSTALL_TILESETS=no export MAPBOX_CLI_NO_AGENT_SETUP=1 +export MAPBOX_TEST_MCP_MODE=fresh run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0' expect_no_out 'coding agent' 'skips the question entirely' +expect_no_out 'MCP' 'skips the MCP question too' start 'no terminal: the question is skipped, not answered yes' new_case_env agent-no-tty @@ -1172,7 +1340,7 @@ export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" export MAPBOX_INSTALL_TILESETS=no run_piped && status=0 || status=$? expect_status 0 "$status" 'exits 0 rather than blocking (124 would be a hang)' -expect_out 'A coding agent was detected on this machine: claude.' 'names the agent' +expect_out 'Coding agents found: claude.' 'names the agent' expect_out 'Not set up. Run these any time:' 'gives the manual commands instead of asking' expect_out 'mapbox generate-skills --global' 'the first manual command' expect_out 'mapbox agent-skills install --global' 'the second manual command' @@ -1183,9 +1351,14 @@ export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" export MAPBOX_INSTALL_TILESETS=no run_interactive n && status=0 || status=$? expect_status 0 "$status" 'a declined setup is not an install failure' -expect_out 'A coding agent was detected on this machine: claude.' 'names the agent' +expect_out 'Coding agents found: claude.' 'names the agent' expect_out 'Set up the mapbox CLI skill' 'asks' +expect_out 'This runs mapbox generate-skills --global and mapbox agent-skills install --global,' 'names the commands it runs' +expect_out 'now or any time later.' 'says they can be run later instead' +expect_out 'Set them up now? [y/N]' 'asks about now' expect_out 'Not set up. Run these any time:' 'falls back to the manual commands' +expect_out ' - Not set up.' 'indented under the question it answers' +expect_out ' mapbox generate-skills --global' 'and its commands under that' start 'a terminal, answered yes: fresh install' new_case_env agent-tty-yes-fresh @@ -1222,6 +1395,107 @@ expect_out "Run 'mapbox agent-skills update --global' to review and replace them 'points at the command to review it by hand' expect_no_out 'Updated the Mapbox Agent Skills library' 'does not claim to have updated it' +# --- Mapbox MCP servers ----------------------------------------------------- +# +# The agent channel asks about the skill first, so a terminal case types two +# answers: the skill's, then this one's. + +start 'MCP, no terminal: not asked, not added' +new_case_env mcp-no-tty +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_MCP_MODE=fresh +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0 rather than blocking (124 would be a hang)' +expect_no_out 'Add the Mapbox MCP servers' 'does not ask' +expect_out 'Mapbox MCP servers not added to Claude Code, VS Code.' 'names each client once' +expect_says "$(grep -c '^ - Mapbox MCP servers not added' "$OUT")" 1 'top level when no question was asked' +expect_out 'mapbox mcp install --global' 'gives the manual command' + +start 'MCP, a terminal, answered yes' +new_case_env mcp-tty-yes +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_MCP_MODE=fresh +run_interactive "$(printf 'n\ny')" && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_out 'Add the Mapbox MCP servers to Claude Code, VS Code?' 'asks, separately from the skill' +expect_out 'This runs mapbox mcp install --global' 'names the command it runs' +expect_out 'This runs mapbox mcp install --global, now or any time later.' 'says it can be run later instead' +expect_out 'Add them now? [y/N]' 'asks about now' +expect_out 'Added Mapbox MCP to Claude Code' 'reports each server added now' +expect_out 'waiting for sign-in' 'shows the command output live' +expect_out 'zz-url-end' 'prints a URL in full, wider than the screen, so it can be opened' +expect_no_out 'zz-end-of-long-line' 'cut to the screen, so the spinner can redraw in place' +expect_out "$(printf '\033[?7l')" 'turns line wrap off while the spinner runs' +expect_out "$(printf '\033[?7h')" 'and back on after' +expect_out ' * Added Mapbox MCP to Claude Code' 'indented under the question it answers' +expect_out 'Added Mapbox DevKit MCP to Claude Code, VS Code' 'one line per server and outcome, clients joined' +expect_out 'Mapbox MCP was already in VS Code' 'tells an existing entry apart from a new one' +expect_no_out 'Added Mapbox MCP to VS Code' 'does not claim to have added an existing entry' +expect_out 'Added Mapbox MCP to Codex, but sign-in did not finish' 'reads a row that carries an error' +install_log="${HOME}/.local/state/mapbox-cli/install.log" +expect_file "$install_log" 'writes an install log' +expect_out "Install log: ~/.local/state/mapbox-cli/install.log" 'and says where it is' +expect_order 'Added Mapbox MCP to Claude Code' 'Install log:' 'after the steps it records' +expect_order 'Install log:' 'is ready.' 'and before the summary' +has() { if grep -qF -- "$1" "$install_log"; then echo yes; else echo no; fi; } +expect_says "$(has 'mcp install --global -o json')" yes 'the log names each command it ran' +expect_says "$(has 'OAuth failed')" yes 'and keeps its full output, error included' +expect_says "$(has 'zz-end-of-long-line')" yes 'and a line too long for the screen, in full' +expect_says "$(has 'ok: Added Mapbox MCP to Claude Code')" yes 'and every step line' +run_interactive "$(printf 'n\nn')" && status=0 || status=$? +expect_file "${install_log}.1" 'a second run keeps the previous log' +# In color this time, so the screen has escape codes for the log to drop. +unset NO_COLOR +run_interactive "$(printf 'n\nn')" && status=0 || status=$? +NO_COLOR=1 +export NO_COLOR +expect_out "$(printf '\033[')" 'the screen is in color' +expect_says "$(grep -c "$(printf '\033')" "$install_log")" 0 'the log has no color codes' +expect_order 'Set up the mapbox CLI skill' 'Add the Mapbox MCP servers' 'after the skill question' + +start 'MCP, a terminal, answered no' +new_case_env mcp-tty-no +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_MCP_MODE=fresh +run_interactive "$(printf 'n\nn')" && status=0 || status=$? +expect_status 0 "$status" 'a declined setup is not an install failure' +expect_out 'Mapbox MCP servers not added to Claude Code, VS Code.' 'says so' +expect_no_out 'Added the Mapbox MCP servers' 'adds nothing' + +start 'MCP, everything already registered: not asked again' +new_case_env mcp-registered +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_MCP_MODE=registered +run_interactive n && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_no_out 'MCP' 'says nothing when there is nothing to add' + +start 'MCP, a failed registration is reported, not fatal' +new_case_env mcp-fail +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_MCP_MODE=fail +run_interactive "$(printf 'n\ny')" && status=0 || status=$? +expect_status 0 "$status" 'mapbox itself is still installed' +expect_out 'Could not add Mapbox MCP to VS Code' 'reports which one failed' +expect_out 'Added Mapbox MCP to Claude Code' 'and still reports the one that worked' +expect_out "Run 'mapbox mcp install --global' to see why and try again." 'points at the command' +expect_out 'Could not add Mapbox MCP to VS Code' 'a failure whose error text has braces is still named' + +start 'MCP, TERM=dumb: no spinner escape codes' +new_case_env mcp-dumb +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_MCP_MODE=fresh +TERM=dumb run_interactive "$(printf 'n\ny')" && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_out 'Added Mapbox MCP to Claude Code' 'still adds them' +expect_no_out "$(printf '\033[?25l')" 'without hiding the cursor or redrawing in place' + # --- result ---------------------------------------------------------------- printf '\n'