diff --git a/CHANGELOG.md b/CHANGELOG.md index c707676..f5d0564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,26 @@ that may never merge. They are not releases and are not listed here. ## Unreleased +- `install.sh`/`install.ps1`: when the install finds a coding agent on the + machine, it now asks, once, whether to write this CLI's own skill and + install the Mapbox Agent Skills library for it, naming the agent and what + will be written before doing either. Answered no, or asked somewhere with + no terminal to answer on (a CI job, a container), it does neither and + prints the two commands to run by hand instead. On a reinstall, it never + overwrites a skill file you edited: it checks first, and only replaces + what is unchanged from what was published, reporting local edits rather + than discarding them. `MAPBOX_CLI_NO_AGENT_SETUP=1` skips the question + entirely, same spelling convention as `MAPBOX_CLI_NO_TELEMETRY`. + +- `mapbox generate-skills`/`agent-skills install`/`agent-skills update`, + when no coding agent is detected and none was named with `--agent`, + `--global` or `--dir`: the failure now carries a stable + `no_agent_detected` error code (previously an unstructured message with + the generic `error` code, indistinguishable from an unrelated internal + failure). Same message, same non-zero exit — only the `code` a caller can + match on is new. Documented in docs/commands.md's "Where they go" + section. + - Diagnostic logs, off by default: `mapbox config set log on` (or `MAPBOX_LOG=1`) keeps, for each run in history, the command line, each request and the error message, tokens redacted, on your machine only. diff --git a/README.md b/README.md index 5867392..fb889c2 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ them end to end without touching the network. Run `mapbox --help` once it's on your `PATH`. +If the script finds a coding agent on the machine ([Claude Code](#agent-skills) +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. + ### Download the archive yourself Nothing about the install script is required. If piping one into a shell is diff --git a/docs/commands.md b/docs/commands.md index b320671..cf8448e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2623,6 +2623,21 @@ not in the table is what `--dir` is for. An agent counts as installed when its home directory exists — the presence of somewhere to read a skill from, not a `PATH` lookup. +**With no `--agent`, no `--global`, and no `--dir`, and no agent detected:** +`no_agent_detected` — both `generate-skills` and `agent-skills +install`/`update`/`uninstall` refuse rather than silently writing nowhere. +`agent-skills list` is the exception: it reports what's published either +way, since it never writes anything. + +``` +Error: Nowhere to write skills: no --agent was named, and no agent's home directory was found. +Fix: Pass --agent to write for one anyway (claude-code, codex, amp, …), --global to write under its home directory, or --dir to write somewhere specific. +``` + +An explicit `--agent ` for an agent whose home directory genuinely +isn't there is a different case — that's a mistake worth a specific +message of its own, not this code, since something *was* asked for. + --- ### `mapbox agent-skills list` diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e5c7663..c7fa74d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -665,6 +665,122 @@ Windows 11 on Arm has and Windows 10 on Arm does not. Write-Host '' Write-Host " `$env:MAPBOX_TILESETS_CLI = 'C:\path\to\tilesets.cmd'" } + + # --- Coding agent skill ---------------------------------------------- + # + # Most machines running this script have no coding agent installed at + # all, and generate-skills answers "nowhere to write" as + # no_agent_detected rather than writing anything when that is the + # case (src/skill_dest.rs) - checked first, silently, so nothing is + # offered when there is nothing to offer. -Global, not the + # project-scoped default: this script runs in whatever directory the + # shell happened to be in, which has no relation to a project. + # + # Past that point this asks: writing into a directory this CLI does + # not own and downloading a whole separate library is a bigger ask + # than a one-line telemetry notice, and an install nobody consented + # to is not a feature. [Console]::IsInputRedirected is false for a + # real interactive session even under `irm ... | iex` - that pipes an + # object through PowerShell's own pipeline into Invoke-Expression, + # which runs in this same process rather than redirecting a child + # process's stdin the way `curl ... | sh` does - so a person typing + # that at a real prompt still gets asked; only a session with no + # console at all (a scheduled task, an image build) does not. + function Get-AgentName([string]$Json) { + ([regex]::Matches($Json, '"source":"([^"]*)"') | ForEach-Object { + $_.Groups[1].Value -replace ', all projects$', '' + }) -join ', ' + } + + $AgentSetupAllowed = $true + $disableAgentSetup = $env:MAPBOX_CLI_NO_AGENT_SETUP + if ($null -ne $disableAgentSetup) { + $disableAgentSetup = $disableAgentSetup.Trim().ToLowerInvariant() + $AgentSetupAllowed = ( + $disableAgentSetup.Length -eq 0 -or + @('0', 'f', 'false', 'n', 'no', 'off') -contains $disableAgentSetup + ) + } + + if ($AgentSetupAllowed) { + # A child writing to stderr is a terminating NativeCommandError in + # Windows PowerShell under $ErrorActionPreference = 'Stop' (set + # once, at the top of this script) - the same reason + # Invoke-Installer in test-install.ps1 has to switch to + # 'Continue' around a call that does the same. Every command + # below writes its failures to stderr, on purpose, so this has to + # hold for all of them; restored in the finally below regardless + # of which path is reached. + $previousErrorAction = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $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." + $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]' + if ($answer -match '^(?i:y|yes)$') { $doAgentSetup = $true } + } + if ($doAgentSetup) { + # Offline and safe to re-run: it replaces its own + # generated directory wholesale and refuses only if + # something else already lives there, which -Global + # 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." + } + + # `install` errors with already_installed on a + # reinstall, since the skill directory is already + # there from a previous run of this same script - + # `update` is the one safe to repeat, but only once + # checked first: some of what changed since the last + # run might be a local edit, not just an upstream + # refresh, and that is never overwritten without + # being named. + $agentStderr = [System.IO.Path]::GetTempFileName() + 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." + } else { + $agentOut = Get-Content -LiteralPath $agentStderr -Raw -ErrorAction SilentlyContinue + if ($agentOut -and $agentOut.Contains('"code":"already_installed"')) { + $updateStderr = [System.IO.Path]::GetTempFileName() + try { + $updateOut = (& $destination agent-skills update --global --dry-run -o json 2>$updateStderr) -join '' + if ($LASTEXITCODE -eq 0) { + if ($updateOut.Contains('"updated":[]')) { + & $destination agent-skills update --global *> $null + Write-Host "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." + } + } + } finally { + Remove-Item -LiteralPath $updateStderr -Force -ErrorAction SilentlyContinue + } + } + } + } finally { + 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' + } + } + } finally { + $ErrorActionPreference = $previousErrorAction + } + } } 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 dbf91d8..6a78949 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -556,32 +556,11 @@ stops this happening again; this script did not touch it. EOF 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. - -tilesets_instructions() { - cat </dev/null @@ -610,6 +589,127 @@ is_no() { esac } +# --- Coding agent skill ------------------------------------------------------ +# +# Most machines running this script have no coding agent installed at all, +# and generate-skills answers "nowhere to write" as `no_agent_detected` +# rather than writing anything when that is the case (src/skill_dest.rs) — +# checked first, silently, so nothing is offered when there is nothing to +# offer. --global, not the project-scoped default: this script runs in +# whatever directory the shell happened to be in, which has no relation to +# a project. +# +# Ahead of the Tilesets CLI below, on purpose: several of its branches exit +# 0 partway through (already on PATH, MAPBOX_TILESETS_CLI set, declined), and +# this must run regardless of any of that. +# +# Past that point this asks, the same way the Tilesets CLI below does: +# writing into a directory this CLI does not own and downloading a whole +# separate library is a bigger ask than a one-line telemetry notice, and an +# install nobody consented to is not a feature. An interactive terminal +# asks and installs only on yes; no terminal says nothing was installed and +# how to do it by hand instead. +agent_setup_allowed() { + # Same boolean spellings as MAPBOX_CLI_NO_TELEMETRY above: unset, empty + # or one of clap's false spellings is allowed; anything else opts out. + # ASCII-only on purpose, the same reason telemetry_allowed is above. + # shellcheck disable=SC2018,SC2019 + case "$(printf '%s' "${MAPBOX_CLI_NO_AGENT_SETUP-}" | + tr 'A-Z' 'a-z' | + sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" in + '' | 0 | f | false | n | no | off) return 0 ;; + *) return 1 ;; + esac +} + +# The "source" field of each destination in a generate-skills/agent-skills +# --global JSON report is ", all projects" — this is the part +# worth putting in front of a person before asking them anything, not the +# full file listing the real report carries. +agent_names_from() { # agent_names_from : comma-joined agent labels + printf '%s' "$1" | grep -o '"source":"[^"]*"' | + sed 's/"source":"//;s/, all projects"$//' | + paste -sd, - | sed 's/,/, /g' +} + +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 /dev/null /dev/null 2>&1 0 ? args[0] : ""; + if (a0 == "generate-skills" || a0 == "agent-skills") { + System.Console.Error.WriteLine("{\"code\":\"no_agent_detected\"}"); + return 1; + } + System.Console.WriteLine("$VersionText"); + return 0; + } +} "@ & $csc /nologo "/out:$Path" $source | Out-Null if ($LASTEXITCODE -ne 0) { throw "csc.exe could not build the stand-in binary" } Remove-Item -LiteralPath $source -Force } else { - Set-Content -LiteralPath $Path -Value "#!/bin/sh`necho '$VersionText'" + Set-Content -LiteralPath $Path -Value @" +#!/bin/sh +case "`${1:-}" in + generate-skills | agent-skills) + echo '{"code":"no_agent_detected"}' >&2 + exit 1 + ;; + *) echo '$VersionText' ;; +esac +"@ + & /bin/chmod '+x' $Path + } +} + +# A fake binary that reports one detected coding agent, "claude", for the +# cases below that exercise install.ps1's coding-agent-skill step - the +# stand-in above always answers no_agent_detected instead, which every other +# case in this file relies on. Driven at runtime by two environment +# 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. +function New-AgentFakeBinary([string]$Path, [string]$VersionText) { + $ErrorActionPreference = 'Continue' + if ($OnWindows) { + $csc = Join-Path $env:SystemRoot 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' + if (-not (Test-Path -LiteralPath $csc)) { + $csc = Join-Path $env:SystemRoot 'Microsoft.NET\Framework\v4.0.30319\csc.exe' + } + if (-not (Test-Path -LiteralPath $csc)) { throw 'no csc.exe to build the stand-in binary with' } + $source = "$Path.cs" + Set-Content -LiteralPath $source -Encoding ASCII -Value @" +using System; +using System.Linq; +class Program { + static int Main(string[] args) { + string a0 = args.Length > 0 ? args[0] : ""; + if (a0 == "--version") { Console.WriteLine("$VersionText"); return 0; } + if (a0 == "generate-skills") { + if (args.Contains("--dry-run")) { + Console.WriteLine("{\"written\":true,\"skills\":[{\"source\":\"claude, all projects\"}]}"); + } else { + Console.WriteLine("{\"written\":true}"); + } + return 0; + } + if (a0 == "agent-skills") { + string sub = args.Length > 1 ? args[1] : ""; + string installMode = Environment.GetEnvironmentVariable("MAPBOX_TEST_INSTALL_MODE") ?? "ok"; + string updateMode = Environment.GetEnvironmentVariable("MAPBOX_TEST_UPDATE_MODE") ?? "clean"; + if (sub == "install") { + if (installMode == "already_installed") { + Console.Error.WriteLine("{\"code\":\"already_installed\"}"); + return 1; + } + Console.WriteLine("{\"installed\":true}"); + return 0; + } + if (sub == "update") { + if (args.Contains("--dry-run")) { + if (updateMode == "dirty") { + Console.WriteLine("{\"updated\":[\"some-skill\"],\"unchanged\":[]}"); + } else { + Console.WriteLine("{\"updated\":[],\"unchanged\":[\"some-skill\"]}"); + } + } else { + Console.WriteLine("{\"updated\":[\"some-skill\"]}"); + } + return 0; + } + } + Console.WriteLine("$VersionText"); + return 0; + } +} +"@ + & $csc /nologo "/out:$Path" $source | Out-Null + if ($LASTEXITCODE -ne 0) { throw "csc.exe could not build the agent-aware stand-in binary" } + Remove-Item -LiteralPath $source -Force + } else { + Set-Content -LiteralPath $Path -Value @" +#!/bin/sh +case "`${1:-}" in + --version) echo '$VersionText' ;; + generate-skills) + for a in "`$@"; do + if [ "`$a" = "--dry-run" ]; then + echo '{"written":true,"skills":[{"source":"claude, all projects"}]}' + exit 0 + fi + done + echo '{"written":true}' + ;; + agent-skills) + sub="`$2" + case "`$sub" in + install) + if [ "`${MAPBOX_TEST_INSTALL_MODE:-ok}" = already_installed ]; then + echo '{"code":"already_installed"}' >&2 + exit 1 + fi + echo '{"installed":true}' + ;; + update) + is_dry=no + for a in "`$@"; do [ "`$a" = "--dry-run" ] && is_dry=yes; done + if [ "`$is_dry" = yes ]; then + if [ "`${MAPBOX_TEST_UPDATE_MODE:-clean}" = dirty ]; then + echo '{"updated":["some-skill"],"unchanged":[]}' + else + echo '{"updated":[],"unchanged":["some-skill"]}' + fi + else + echo '{"updated":["some-skill"]}' + fi + ;; + esac + ;; + *) echo '$VersionText' ;; +esac +"@ & /bin/chmod '+x' $Path } } @@ -200,7 +336,8 @@ function New-ChannelVersion { [string]$ArtifactName = $Target, [switch]$BadSha, [switch]$Broken, - [switch]$Empty + [switch]$Empty, + [switch]$Agent ) $dir = Join-Path $Root $Dir @@ -225,6 +362,8 @@ function New-ChannelVersion { Set-Content -LiteralPath $payload -Value "#!/bin/sh`nexit 1" & /bin/chmod '+x' $payload } + } elseif ($Agent) { + New-AgentFakeBinary $payload $VersionText } else { New-FakeBinary $payload $VersionText } @@ -390,6 +529,9 @@ function New-CaseEnv([string]$Name) { # turn every marker case into a failure that looks like the marker broke. Clear-Env 'DISABLE_TELEMETRY' Clear-Env 'MAPBOX_CLI_NO_TELEMETRY' + Clear-Env 'MAPBOX_CLI_NO_AGENT_SETUP' + Clear-Env 'MAPBOX_TEST_INSTALL_MODE' + Clear-Env 'MAPBOX_TEST_UPDATE_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. @@ -429,6 +571,7 @@ $armRoot = Join-Path $Root 'arm-channel' $emptyRoot = Join-Path $Root 'empty-channel' $badShaRoot = Join-Path $Root 'bad-sha-channel' +$agentRoot = Join-Path $Root 'agent-channel' # Beside the channels rather than in one, and nothing ever requests it: this is # where the open server writes the path and User-Agent of every request it is @@ -443,6 +586,7 @@ New-ChannelVersion -Root $foreignRoot -Dir 'latest' -ArtifactName 'aarch64-apple New-ChannelVersion -Root $armRoot -Dir 'latest' -ArtifactName $Arm64Target -VersionText 'mapbox 9.9.9-arm64' New-ChannelVersion -Root $emptyRoot -Dir 'latest' -Empty New-ChannelVersion -Root $badShaRoot -Dir 'latest' -BadSha +New-ChannelVersion -Root $agentRoot -Dir 'latest' -Agent $open = $null $gated = $null @@ -855,6 +999,46 @@ try { Expect-Status 0 'exits 0' Expect-Out 'Tilesets CLI: C:\tools\tilesets.cmd' 'reports what it is pointed at' Expect-NoOut 'does not work natively here' 'and does not explain it again' + + # --- the coding agent skill --------------------------------------------- + # + # Invoke-Installer runs with -NonInteractive, which is what lets it merge + # a child's stderr without Stop turning that into a terminating error - + # but it also makes Read-Host refuse outright ("PowerShell is in + # NonInteractive mode"), regardless of what is actually attached to + # stdin. So only the no-console branch of install.ps1's prompt is + # reachable through this harness; that happens to be exactly what + # Invoke-Installer already simulates for every other case here. The + # answered-yes/answered-no branches were verified by hand instead, + # against a real pseudoterminal (`[Console]::IsInputRedirected` reads + # False there), across all of: declined, fresh install, reinstall with + # nothing changed, and reinstall with a local edit preserved rather than + # overwritten - there is no cross-platform way to allocate a pty from + # pure PowerShell to automate that part here. + + Start-Case 'no coding agent detected: silent, nothing offered' + New-CaseEnv 'no-agent' + Invoke-Installer + Expect-Status 0 'exits 0' + Expect-NoOut 'coding agent' 'says nothing about a step that has nothing to do' + + Start-Case 'no console: the question is skipped, not answered yes' + New-CaseEnv 'agent-no-console' + $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 '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' + + Start-Case 'MAPBOX_CLI_NO_AGENT_SETUP opts out even though an agent was detected' + New-CaseEnv 'agent-opt-out' + $env:MAPBOX_CLI_BASE_URL = "$($open.BaseUrl)/agent-channel" + $env:MAPBOX_CLI_NO_AGENT_SETUP = '1' + Invoke-Installer + Expect-Status 0 'exits 0' + Expect-NoOut 'coding agent' 'skips the question entirely' } finally { if ($open) { Stop-ChannelServer $open } if ($gated) { Stop-ChannelServer $gated } diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 30ad9b1..4a05352 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -211,10 +211,20 @@ make_channel() { # dir version [bad-sha|broken] # for another platform. printf '#!/bin/sh\nexit 1\n' >"${channel_dir}/build/mapbox" else + # generate-skills/agent-skills default to "no agent detected" — + # exit 1, the real CLI's own no_agent_detected shape — so every + # scenario built on this fixture reaches the coding-agent-skill + # section of install.sh and finds nothing to do, same as a plain + # machine with no ~/.claude, ~/.codex, etc. Scenarios that need a + # detected agent use make_agent_channel below instead. cat >"${channel_dir}/build/mapbox" <&2 + exit 1 + ;; *) echo "fake mapbox: \$*" ;; esac EOF @@ -263,6 +273,76 @@ CHANNEL="${ROOT}/channel" make_channel "${CHANNEL}/latest" v9.9.9 make_channel "${CHANNEL}/v0.1.0-dev.abc1234" v0.1.0-dev.abc1234 +# A channel whose fake binary reports one detected coding agent and answers +# generate-skills/agent-skills accordingly, driven at runtime (not baked in, +# since the same tarball is reused across every case below) by two +# 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. +AGENT_CHANNEL="${ROOT}/agent-channel" +mkdir -p "${AGENT_CHANNEL}/latest/build" +cat >"${AGENT_CHANNEL}/latest/build/mapbox" <<'EOF' +#!/bin/sh +case "${1:-}" in + --version) echo "mapbox 9.9.9" ;; + generate-skills) + for a in "$@"; do + if [ "$a" = "--dry-run" ]; then + echo '{"written":true,"skills":[{"source":"claude, all projects"}]}' + exit 0 + fi + done + echo '{"written":true}' + ;; + agent-skills) + sub="$2" + case "$sub" in + install) + if [ "${MAPBOX_TEST_INSTALL_MODE:-ok}" = already_installed ]; then + echo '{"code":"already_installed"}' >&2 + exit 1 + fi + echo '{"installed":true}' + ;; + update) + is_dry=no + for a in "$@"; do [ "$a" = "--dry-run" ] && is_dry=yes; done + if [ "$is_dry" = yes ]; then + if [ "${MAPBOX_TEST_UPDATE_MODE:-clean}" = dirty ]; then + echo '{"updated":["some-skill"],"unchanged":[]}' + else + echo '{"updated":[],"unchanged":["some-skill"]}' + fi + else + echo '{"updated":["some-skill"]}' + fi + ;; + esac + ;; + *) echo "fake mapbox: $*" ;; +esac +EOF +chmod 755 "${AGENT_CHANNEL}/latest/build/mapbox" +tar czf "${AGENT_CHANNEL}/latest/mapbox-v9.9.9-${TARGET}.tar.gz" \ + -C "${AGENT_CHANNEL}/latest/build" mapbox +rm -rf "${AGENT_CHANNEL}/latest/build" +agent_real_sha="$(sha256_of "${AGENT_CHANNEL}/latest/mapbox-v9.9.9-${TARGET}.tar.gz")" +cat >"${AGENT_CHANNEL}/latest/manifest.json" <"${AGENT_CHANNEL}/latest/SHA256SUMS" + # A channel whose manifest advertises a checksum the tarball does not have. BAD_SHA="${ROOT}/bad-sha-channel" make_channel "${BAD_SHA}/latest" v9.9.9 \ @@ -534,6 +614,7 @@ new_case_env() { # case-name # 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 export MAPBOX_CLI_BASE_URL MAPBOX_INSTALL_DIR PATH } @@ -1067,6 +1148,80 @@ expect_status 0 "$status" 'a broken override is not an install failure' expect_out 'where there is no' 'says the override points nowhere' expect_no_out 'fake pipx' 'does not install over a deliberate override' +# --- the coding agent skill ------------------------------------------------- + +start 'no coding agent detected: silent, nothing offered' +new_case_env no-agent +export MAPBOX_INSTALL_TILESETS=no +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_no_out 'coding agent' 'says nothing about a step that has nothing to do' + +start 'MAPBOX_CLI_NO_AGENT_SETUP opts out even though an agent was detected' +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 +run_piped && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_no_out 'coding agent' 'skips the question entirely' + +start 'no terminal: the question is skipped, not answered yes' +new_case_env agent-no-tty +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 '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' + +start 'a terminal, answered no' +new_case_env agent-tty-no +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 'Set up the mapbox CLI skill' 'asks' +expect_out 'Not set up. Run these any time:' 'falls back to the manual commands' + +start 'a terminal, answered yes: fresh install' +new_case_env agent-tty-yes-fresh +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_INSTALL_MODE=ok +run_interactive y && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_out 'Wrote the mapbox CLI skill for: claude.' 'reports the skill written' +expect_out 'Installed the Mapbox Agent Skills library for: claude.' 'reports the library installed' + +start 'a terminal, answered yes: reinstall with nothing locally changed' +new_case_env agent-tty-yes-reinstall-clean +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_INSTALL_MODE=already_installed +export MAPBOX_TEST_UPDATE_MODE=clean +run_interactive y && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_out 'Updated the Mapbox Agent Skills library for: claude.' 'updates in place' +expect_no_out 'have local changes and were left alone' 'nothing was skipped' + +start 'a terminal, answered yes: reinstall with a local edit is never overwritten' +new_case_env agent-tty-yes-reinstall-dirty +export MAPBOX_CLI_BASE_URL="file://${AGENT_CHANNEL}" +export MAPBOX_INSTALL_TILESETS=no +export MAPBOX_TEST_INSTALL_MODE=already_installed +export MAPBOX_TEST_UPDATE_MODE=dirty +run_interactive y && status=0 || status=$? +expect_status 0 "$status" 'exits 0' +expect_out 'Some Mapbox Agent Skills have local changes and were left alone.' \ + 'reports the local edit rather than discarding it' +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' + # --- result ---------------------------------------------------------------- printf '\n' diff --git a/src/skill_dest.rs b/src/skill_dest.rs index d51dbc3..4da2f4b 100644 --- a/src/skill_dest.rs +++ b/src/skill_dest.rs @@ -51,6 +51,9 @@ use anyhow::{anyhow, Result}; use clap::builder::PossibleValuesParser; use clap::{Arg, ArgAction, ArgMatches}; +use crate::output::CliError; +use crate::remedy::Remedy; + /// `--agent`, `--global` and `--dir`: the arg ids, and their long spellings. pub const AGENT_ARG: &str = "agent"; pub const GLOBAL_ARG: &str = "global"; @@ -610,13 +613,17 @@ pub fn resolve( if require && out.is_empty() { let known: Vec<&str> = Agent::all().map(Agent::flag).collect(); - return Err(anyhow!( + return Err(CliError::new( + "no_agent_detected", "Nowhere to write skills: no --agent was named, and no agent's home \ - directory was found. Pass --agent to write for one anyway ({}), \ - --global to write under its home directory, or --dir to write \ - somewhere specific.", + directory was found.", + ) + .with_remedy(Remedy::default().with_fix(&format!( + "Pass --agent to write for one anyway ({}), --global to write under \ + its home directory, or --dir to write somewhere specific.", known.join(", ") - )); + ))) + .into()); } Ok(out) @@ -1187,15 +1194,19 @@ mod tests { fn nothing_to_write_to_is_an_error_that_names_the_flags() { let nowhere = AgentHomes::default(); let err = resolve(None, &[], false, &nowhere, true) - .expect_err("no agent, no --dir, and nothing detected"); - let message = err.to_string(); + .expect_err("no agent, no --dir, and nothing detected") + .downcast::() + .expect("a CliError, not some other failure"); + assert_eq!(err.code, "no_agent_detected"); + + let fix = err.fix.as_deref().unwrap_or_default(); for flag in ["--agent", "--dir"] { - assert!(message.contains(flag), "{message} does not mention {flag}"); + assert!(fix.contains(flag), "{fix} does not mention {flag}"); } for agent in Agent::all() { assert!( - message.contains(agent.flag()), - "{message} does not mention {}", + fix.contains(agent.flag()), + "{fix} does not mention {}", agent.flag() ); } diff --git a/tests/generate_skills.rs b/tests/generate_skills.rs index 1d9f12d..99ac164 100644 --- a/tests/generate_skills.rs +++ b/tests/generate_skills.rs @@ -359,6 +359,14 @@ fn nowhere_to_write_is_an_error_naming_the_flags() { assert!(!out.status.success(), "{}", stdout(&out)); let message = stderr(&out); + // A stable, machine-readable code — not just a message a script would + // have to pattern-match — so a caller (an installer, say) can tell + // "nothing detected" apart from an unrelated failure. See + // docs/commands.md's own section on this. + assert!( + message.contains("\"code\":\"no_agent_detected\""), + "{message} does not carry the no_agent_detected code" + ); for flag in ["--agent", "--global", "--dir"] { assert!(message.contains(flag), "{message} does not mention {flag}"); }