From b27df01ff660d2b2d4cf339e7323ef0c7ca7b3df Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Tue, 29 Sep 2026 12:20:25 +0300 Subject: [PATCH 1/2] Check README's commands against the binary The README listed four API groups (rasterarrays, static-images, static-tiles, tilequery) the binary no longer has, and never mentioned doctor, usage or the real static group. Nothing read the README, so nobody noticed. --- tests/docs_contract.rs | 140 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/tests/docs_contract.rs b/tests/docs_contract.rs index 467ac51..a8471ff 100644 --- a/tests/docs_contract.rs +++ b/tests/docs_contract.rs @@ -17,6 +17,12 @@ //! when the change is made, is whether the page has fallen behind our own //! binary. That's all this file claims to check. //! +//! README.md gets a lighter version of the same check: every `mapbox …` +//! invocation it spells must name a real command, and every top-level +//! command must appear in it. That one exists because the README's list of +//! API groups went on naming four groups the binary no longer had, and +//! nothing here read the README. +//! //! Three things it deliberately doesn't do, written down so the next //! reader doesn't have to re-derive the scope: //! @@ -378,3 +384,137 @@ fn every_flag_a_command_takes_is_named_in_its_own_section() { unmentioned.join("\n") ); } + +/// README.md, read the same way as the page. +fn readme() -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("README.md"); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +/// Every `mapbox …` invocation in README.md, with its line number. +/// +/// Read from shell-language fenced blocks and from inline code spans. An +/// unlabeled fence is skipped: it holds printed output, like the update +/// notice's "A newer mapbox is available", which is prose, not a command. +fn readme_invocations(readme: &str) -> Vec<(usize, String)> { + const SHELLS: [&str; 3] = ["sh", "console", "powershell"]; + let mut found = Vec::new(); + let mut fence: Option = None; + + for (index, line) in readme.lines().enumerate() { + let number = index + 1; + if let Some(lang) = line.trim_start().strip_prefix("```") { + fence = match fence { + Some(_) => None, + None => Some(SHELLS.contains(&lang.trim())), + }; + continue; + } + match fence { + Some(true) => { + // `source <(mapbox completion bash)` and `$ mapbox …` both + // put something before the name, so match it mid-line. + for (at, _) in line.match_indices("mapbox ") { + let before = line[..at].chars().next_back(); + if before.is_none_or(|c| c == ' ' || c == '(') { + found.push((number, line[at..].to_string())); + } + } + } + Some(false) => {} + None => { + for (i, span) in line.split('`').enumerate() { + if i % 2 == 1 && span.starts_with("mapbox ") { + found.push((number, span.to_string())); + } + } + } + } + } + found +} + +/// A word that can be part of a command path, as opposed to a flag, a +/// placeholder, a pipe or a value like `data.geojson.ld`. +fn is_path_word(word: &str) -> bool { + !word.starts_with('-') + && word + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +#[test] +fn every_command_the_readme_spells_exists() { + let schema = schema(); + let full: BTreeSet<&str> = commands(&schema).iter().map(name).collect(); + // Every group a command sits under, like `mapbox styles draft`, so an + // invocation may stop at a group without naming an operation. + let mut prefixes = BTreeSet::new(); + for command in &full { + let mut path = String::from("mapbox"); + for word in command.split_whitespace().skip(1) { + path = format!("{path} {word}"); + prefixes.insert(path.clone()); + } + } + + let mut unknown = Vec::new(); + for (line, invocation) in readme_invocations(&readme()) { + let mut words = invocation.split_whitespace().skip(1).peekable(); + // `mapbox --profile NAME styles list`: a leading global flag takes a + // value this check can't tell apart from a command, so the rest of + // the line isn't checked. + if words.peek().is_some_and(|word| word.starts_with('-')) { + continue; + } + let mut path = String::from("mapbox"); + for word in words.take_while(|word| is_path_word(word)) { + let longer = format!("{path} {word}"); + if prefixes.contains(&longer) { + path = longer; + } else if full.contains(path.as_str()) { + // A positional argument, like `completion bash`. + break; + } else { + unknown.push(format!("README.md:{line}: {longer}")); + break; + } + } + } + + assert!( + unknown.is_empty(), + "README.md names commands the CLI doesn't have:\n{}\n\ + `mapbox --schema` lists what it does have.{}", + unknown.join("\n"), + spec_revision_note() + ); +} + +#[test] +fn every_top_level_command_appears_in_the_readme() { + let schema = schema(); + let top_level: BTreeSet = commands(&schema) + .iter() + .filter_map(|command| { + let group = name(command).split_whitespace().nth(1)?; + Some(format!("mapbox {group}")) + }) + .collect(); + + let mentioned: BTreeSet = readme_invocations(&readme()) + .into_iter() + .filter_map(|(_, invocation)| { + let group = invocation.split_whitespace().nth(1)?; + Some(format!("mapbox {group}")) + }) + .collect(); + + let missing: Vec<&String> = top_level.difference(&mentioned).collect(); + assert!( + missing.is_empty(), + "README.md never shows these top-level commands: {missing:?}\n\ + Give each at least one example line, under Commands or wherever it \ + fits, linking to docs/commands.md for the detail." + ); +} From 4201078aa465f96254c0ad35a437015da1833997 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Tue, 29 Sep 2026 12:20:25 +0300 Subject: [PATCH 2/2] Fix README's command list and cut detail docs/commands.md already has --- CONTRIBUTING.md | 4 ++ README.md | 175 ++++++++++++++++++++---------------------------- 2 files changed, 76 insertions(+), 103 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49d7b78..8374056 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,10 @@ and not a style note. and a fake server, so the suite runs offline and on a machine that has never logged in. +`scripts/test-install.sh` and `scripts/test-install.ps1` exercise the +installers end to end without touching the network, and +`scripts/test-completion.sh` does the same for the completion scripts. + Four rules the compiler holds rather than a reviewer, declared in `Cargo.toml` with the reasoning beside each: no `unsafe`, no `println!` (stdout belongs to `output::emit`, the single place `--output` is honored), diff --git a/README.md b/README.md index 8251524..44cb286 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ mapbox styles list - [Named profiles](#named-profiles) - [Commands](#commands) - [API commands](#api-commands) + - [Diagnostics and settings](#diagnostics-and-settings) - [Shell completion](#shell-completion) - [Tileset CLI](#tileset-cli) - [For AI agents](#for-ai-agents) @@ -84,11 +85,9 @@ 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 ``` -`MAPBOX_INSTALL_DIR` chooses where the binary lands. - -`scripts/install.sh` and `scripts/install.ps1` here are those installers' -sources; `scripts/test-install.sh` and `scripts/test-install.ps1` exercise -them end to end without touching the network. +`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). ### Download the archive yourself @@ -135,15 +134,9 @@ if ((Get-FileHash -Algorithm SHA256 $artifact.file).Hash -ine $artifact.sha256) Expand-Archive $artifact.file -DestinationPath . ``` -`Invoke-WebRequest` and `Get-FileHash` rather than `curl` and `sha256sum`: -Windows PowerShell 5.1 — the edition that ships with Windows, `powershell.exe` -— aliases `curl` to `Invoke-WebRequest`, whose flags are nothing like real -curl's, so the commands above would silently run the wrong tool there even -though `curl.exe` itself has shipped in `System32` since Windows 10 1803. -PowerShell 7 (`pwsh`) dropped that alias, so `curl` there is the real thing — -but 5.1 is still what a plain "Windows PowerShell" shortcut opens on a -default install. `sha256sum` has the simpler problem: it isn't shipped at -all outside WSL or Git Bash. +This uses `Invoke-WebRequest` and `Get-FileHash` because Windows PowerShell +5.1, the edition that ships with Windows, aliases `curl` to +`Invoke-WebRequest`, and `sha256sum` isn't shipped outside WSL or Git Bash. Use `latest` in place of the version for whatever is current. Each archive holds one file, the `mapbox` executable. @@ -183,12 +176,13 @@ knows it is gone. ```sh mapbox auth login # opens a browser (OAuth/PKCE) -mapbox auth logout # removes stored credentials -mapbox auth refresh # force-refreshes the access token mapbox auth whoami # reports which token the next command will use -mapbox auth profiles # lists every stored profile, not just one +mapbox auth logout # removes stored credentials ``` +`auth refresh` and `auth profiles` complete the set; see +[docs/commands.md](./docs/commands.md#auth). + Credentials live in `~/.mapbox` as plain JSON with locked-down file permissions. There is no OS keychain integration. Override them with `--token`/`--username` or `MAPBOX_ACCESS_TOKEN`/`MAPBOX_USERNAME`, and set @@ -209,31 +203,19 @@ mapbox auth profiles # which profiles are actually stored ### API commands -Each API is a top-level subcommand, one sub-subcommand per operation: +Each Mapbox API is a command group, with one subcommand per operation: ```sh -mapbox accounts * -mapbox fonts * -mapbox geocoder * -mapbox rasterarrays * -mapbox search * -mapbox sprites * -mapbox static-images * -mapbox static-tiles * -mapbox styles * -mapbox tilequery * -mapbox tilesets * +mapbox accounts +mapbox fonts +mapbox geocoder +mapbox search +mapbox sprites +mapbox static +mapbox styles +mapbox tilesets ``` -A command group is not the same thing as a spec file: which one an operation -belongs to is decided per operation. So `sprites` and `tilesets` are each -assembled from operations declared by the Styles, Raster Tiles and Vector -Tiles specs. `mapbox tilesets` is also unrelated to `mapbox tilesets-cli`, -which proxies to the separate Python tool. - -An operation can nest one level deeper where a group reads better, as in -`mapbox styles draft get`, `draft update` and `draft delete`. - For example: ```sh @@ -241,20 +223,37 @@ mapbox styles get mapbox styles create --data '{"name": "My Style", "version": 8, ...}' ``` -[docs/commands.md](./docs/commands.md) lists every command. +Groups are assigned per operation, not per spec file, so `sprites` and +`tilesets` each collect operations from several specs. A few nest one level +deeper where that reads better, as in `mapbox styles draft get`. +`mapbox tilesets` is unrelated to [`mapbox tilesets-cli`](#tileset-cli). + +[docs/commands.md](./docs/commands.md) lists every command with its +parameters and sample output. Every request sends `User-Agent: mapbox-cli/` and nothing else about you or your machine. `MAPBOX_CLI_NO_TELEMETRY=1` keeps even future markers out of that header. -### Shell completion +### Diagnostics and settings ```sh -mapbox completion bash | zsh | fish | powershell +mapbox doctor # the token, proxies and settings the next command would use +mapbox usage # account usage per product, by day +mapbox config set update-check off # a setting that persists across shells +mapbox history list # recent runs, newest first ``` -Prints a completion script on stdout. Nothing is written to disk, so put it -where your shell looks: +`mapbox doctor` is the first thing to run when a command behaves +unexpectedly. It makes no request unless you pass `--verify`. See +[Doctor](./docs/commands.md#doctor), [Usage](./docs/commands.md#usage) and +[Config](./docs/commands.md#config) for details, and +[Command history](#command-history) below. + +### Shell completion + +Homebrew installs completions for you. Otherwise, `mapbox completion` +prints a script on stdout for you to put where your shell looks: ```sh mapbox completion bash > ~/.local/share/bash-completion/completions/mapbox @@ -267,11 +266,9 @@ source <(mapbox completion bash) # this shell only mapbox completion powershell >> $PROFILE ``` -It completes commands, subcommands and flag names, generated from this -binary's own command tree. So it matches the build that printed it, and -nothing about it is maintained by hand. Values (style ids, usernames) are not -completed: that would mean an API request mid-keystroke. `--output` does not -apply, because the script is the result. +It completes commands, subcommands and flags from this binary's own command +tree, so it always matches the build that printed it. Values such as style +IDs are not completed, since that would mean an API request mid-keystroke. ### Tileset CLI @@ -280,18 +277,17 @@ mapbox tilesets-cli list mapbox tilesets-cli upload-source data.geojson.ld ``` -Forwards everything to the separately-installed [Tilesets +Forwards everything to the separately installed [Tilesets CLI](https://github.com/mapbox/tilesets-cli): ```sh pipx install mapbox-tilesets # Python 3.10+ ``` -`--output` and `--yes` don't apply here. `tilesets` has its own flags, so use -`--force`/`-f` for its prompts. It needs a token too: `mapbox auth login` -covers it, or pass `--token`/`MAPBOX_ACCESS_TOKEN` the same way as every -other command. `mapbox auth whoami` shows which one is in play if a -tileset command answers for the wrong account. +It uses the same token as every other command. `--output` and `--yes` don't +apply here; `tilesets` has its own flags, so use `--force`/`-f` for its +prompts. If a tileset command answers for the wrong account, +`mapbox auth whoami` shows which token is in play. ## For AI agents @@ -302,41 +298,23 @@ using Mapbox, and `generate-skills` writes a skill describing this CLI. ```sh mapbox agent-skills list # what's published, and what's installed here -mapbox agent-skills install # all 20, into whichever agents you have +mapbox agent-skills install # every skill, for whichever agents you have mapbox agent-skills update # re-install what's here, report what changed mapbox agent-skills uninstall ``` Installs the [Mapbox Agent Skills](https://github.com/mapbox/mapbox-agent-skills): hand-written guidance for coding agents on cartography, token security, style -quality, geospatial operations and the mobile and web SDKs. No token needed, -and no Node. It's one tarball, extracted in the binary. - -Fifteen agents are supported: Claude Code, Codex, Cursor, Cline, Gemini CLI, -GitHub Copilot, Zed, OpenCode, Amp, Windsurf, Roo Code, Continue, Kiro CLI, -Qwen Code and Goose. By default, skills are installed for whichever of them -are found on the machine. - -| Flag | What it does | -| --- | --- | -| `--agent ` | Install for one agent. Repeatable. | -| `--global` | Write to the agent's home directory instead of this project. | -| `--dir ` | Write to a directory you name, for a Dockerfile or a CI job. | +quality, geospatial operations and the mobile and web SDKs. No token or Node +needed. -Most of these agents read the same `.agents/skills` directory, so asking for -several usually means a single write. - -`--ref ` installs a particular version and a SHA pins it; -`--dry-run` lists the files first. A skill directory that already exists stops -the install until `--force`, since it may hold your edits. - -`update` compares what's installed with what's published, byte for byte, and -rewrites only what differs, including restoring a file you edited. It never -installs a skill that wasn't already there. `uninstall ` removes the -directory, asking first at a terminal; it makes no network request at all. -There's no lock file: one tarball arrives before any record could be -consulted, so comparing bytes answers exactly and leaves no state to keep in -step with another tool's. +By default it installs into this project for each supported agent it finds +on the machine, including Claude Code, Codex, Cursor and GitHub Copilot. +`--agent` picks one, `--global` writes to the agent's home directory, and +`--dir` writes to a path you name, for a Dockerfile or a CI job. `update` +rewrites only files that differ from what's published, including files you +edited. The full list of agents and flags is in +[docs/commands.md](./docs/commands.md#agent-skills). ### Generate skills @@ -344,22 +322,16 @@ step with another tool's. mapbox generate-skills ``` -Writes the whole command surface as an [Agent -Skill](https://code.claude.com/docs/en/skills): `.claude/skills` for -Claude Code, `.agents/skills` for Codex. `--agent`, `--global`, `--dir`, -and `--service` narrow it; `--dry-run` lists files without writing them. - -Without `--global` it writes into the current project, once per agent it -finds, and it prints every directory it used. To take them out again: +Writes this CLI's whole command surface as an [Agent +Skill](https://code.claude.com/docs/en/skills), into the current project for +each agent it finds: `.claude/skills` for Claude Code, `.agents/skills` for +Codex. `--agent`, `--global`, `--dir` and `--service` narrow it, and +`--dry-run` lists the files first. To remove every copy it wrote: ```sh mapbox agent-skills uninstall mapbox-cli ``` -That removes every copy this command wrote, which is more than deleting the -directories by hand usually catches — a default run writes for each agent on -the machine, not just the one you had in mind. - ## Global options These apply to every command, not just the API ones. @@ -419,10 +391,8 @@ Two things are easy to lose an afternoon to: | `text` | Pretty-printed, readable | | `json` | Everything on stdout is JSON: one compact document per command | -`json` promises the shape, not the count. Every command today returns one -document. A command that streams would emit one per line (JSON Lines), but -that is a property of the command rather than of the flag, so there is no -`-o jsonl`. Nothing streams yet. +Every command prints one JSON document today. A command that streams would +print one per line (JSON Lines), which is why there is no `-o jsonl`. Errors always go to stderr and never appear in stdout. Under `json` they're one flat object: `code`, `message`, plus `fix`, `next_actions` and `docs` @@ -484,17 +454,16 @@ kept narrow: between runs. A build that names no release channel never checks at all, and `cargo build` produces one. -`mapbox config set update-check off` turns it off for good, in every shell — -see [Config](docs/commands.md#config) — rather than just the session an -environment variable happens to be set in. +`mapbox config set update-check off` turns it off in every shell, not just +the one an environment variable is set in. See +[Config](./docs/commands.md#config). ### Command history Each run appends one line to `~/.mapbox/history/.jsonl` (or under `$MAPBOX_CONFIG_DIR`), kept for 30 days and at most 10 MB, oldest dropped -first: which command ran (its command path, -like `search forward`), how it ended, how long it took and the request ids -support can look up. Argument values are never recorded — not what you +first: which command ran (its command path, like `search forward`), how it +ended, how long it took and the request ids support can look up. Argument values are never recorded — not what you searched for, not a file path, not a token. The files are readable only by you and never leave your machine.