diff --git a/README.md b/README.md index d8c00a3..d9589f7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ mapbox styles list - [Shell completion](#shell-completion) - [Tileset CLI](#tileset-cli) - [For AI agents](#for-ai-agents) + - [Quick start: Mapbox agent setup](#quick-start-mapbox-agent-setup) - [Agent skills](#agent-skills) - [Generate skills](#generate-skills) - [Global options](#global-options) @@ -291,8 +292,22 @@ prompts. If a tileset command answers for the wrong account, ## For AI agents -Two commands, for two different jobs: `agent-skills` installs guidance on -using Mapbox, and `generate-skills` writes a skill describing this CLI. +### Quick start: Mapbox agent setup + +Paste this into your coding agent to install this CLI, the Mapbox Agent +Skills and the Mapbox MCP servers in one go: + +```text +Set up Mapbox for me by following https://cli.mapbox.com/agent-setup/prompt.md +``` + +The agent runs the steps itself and asks you only to sign in to Mapbox in +the browser. The instructions it follows are in +[site/agent-setup/prompt.md](./site/agent-setup/prompt.md). + +To set things up by hand instead, there are two commands for two different +jobs: `agent-skills` installs guidance on using Mapbox, and +`generate-skills` writes a skill describing this CLI. ### Agent skills diff --git a/site/agent-setup/prompt.md b/site/agent-setup/prompt.md new file mode 100644 index 0000000..0160bc7 --- /dev/null +++ b/site/agent-setup/prompt.md @@ -0,0 +1,186 @@ +These are official instructions from Mapbox for setting up an AI coding agent to build with Mapbox. They install the Mapbox CLI and the Mapbox Agent Skills, and connect the Mapbox MCP servers. + +Run the commands yourself rather than asking the user to run them. The exceptions are the sign-in steps: those open a browser, and only the user can complete them. + +- Install the Mapbox CLI +- Install Mapbox Skills and MCP servers + +Never ask the user to paste a Mapbox access token into the conversation, and never write one into an MCP configuration file. The CLI and every server below sign in with OAuth in the browser. + +--- + +## Install the Mapbox CLI + +The Mapbox CLI, `mapbox`, calls Mapbox APIs from the shell: styles, tilesets, tokens, geocoding, static images and more. It prints JSON when its output is piped, so it suits agents. + +If Homebrew is available: + +```sh +brew install mapbox/tap/mapbox +``` + +Otherwise, on macOS or Linux, download the install script and run it. Do not pipe it into `sh`: many agent sandboxes deny that outright. + +```sh +curl -fsSL https://cli.mapbox.com/install.sh -o mapbox-install.sh +sh mapbox-install.sh +rm mapbox-install.sh +``` + +On Windows, in PowerShell: + +```powershell +Invoke-WebRequest https://cli.mapbox.com/install.ps1 -OutFile mapbox-install.ps1 +powershell -ExecutionPolicy Bypass -File mapbox-install.ps1 +Remove-Item mapbox-install.ps1 +``` + +The script installs into `~/.local/bin` on macOS and Linux and prints the path it used. If `mapbox` is not found in your current shell afterwards, call it by that path. If the install is denied or fails, do not skip it: show the user the commands above and ask them to run them in their own terminal, then continue. + +Write a skill that describes its commands, so you know them in every project: + +```sh +mapbox generate-skills --global +``` + +Then run `mapbox auth login`. It opens a browser where the user authorizes access to their Mapbox account. To explore the commands, run `mapbox --schema`, which describes all of them in one JSON document. + +--- + +## Install Mapbox Skills and MCP servers + +Use the section for your agent. + +### Claude Code + +These two commands install the skills and all three MCP servers in one step. Do not also run `npx skills add` or `claude mcp add`; the plugin covers both. + +```sh +claude plugin marketplace add mapbox/mapbox-agent-skills +claude plugin install mapbox@mapbox-agent-skills +``` + +Then tell the user to run `/reload-plugins`, and `/mcp` to sign in to `mapbox` and `mapbox-devkit`. + +--- + +## Install for other agents + +First, install the skills: + +```sh +npx -y skills add mapbox/mapbox-agent-skills --skill '*' --yes --global +``` + +If `npx` is unavailable or not allowed, use the Mapbox CLI instead: + +```sh +mapbox agent-skills install --global +``` + +Then register the MCP servers in your agent's configuration: + +| Server | URL | Sign-in | +| --------------- | ----------------------------------- | ------- | +| `mapbox` | `https://mcp.mapbox.com/mcp` | OAuth | +| `mapbox-devkit` | `https://mcp-devkit.mapbox.com/mcp` | OAuth | +| `mapbox-docs` | `https://mcp-docs.mapbox.com/mcp` | None | + +`mapbox` holds the geospatial tools: search, directions, isochrones, static maps. `mapbox-devkit` manages the user's styles, tokens and data, and can change them. `mapbox-docs` searches Mapbox documentation and is public. + +### Codex + +```sh +codex mcp add mapbox --url https://mcp.mapbox.com/mcp +codex mcp add mapbox-devkit --url https://mcp-devkit.mapbox.com/mcp +codex mcp add mapbox-docs --url https://mcp-docs.mapbox.com/mcp +codex mcp login mapbox +codex mcp login mapbox-devkit +``` + +### Cursor — `~/.cursor/mcp.json` + +Add under `"mcpServers"`: + +```json +"mapbox": { "url": "https://mcp.mapbox.com/mcp" }, +"mapbox-devkit": { "url": "https://mcp-devkit.mapbox.com/mcp" }, +"mapbox-docs": { "url": "https://mcp-docs.mapbox.com/mcp" } +``` + +Then tell the user to click "Needs authentication" next to `mapbox` and `mapbox-devkit` in Cursor's MCP settings. + +### GitHub Copilot in VS Code — `.vscode/mcp.json` + +Add under `"servers"` (note: `servers`, not `mcpServers`): + +```json +"mapbox": { "type": "http", "url": "https://mcp.mapbox.com/mcp" }, +"mapbox-devkit": { "type": "http", "url": "https://mcp-devkit.mapbox.com/mcp" }, +"mapbox-docs": { "type": "http", "url": "https://mcp-docs.mapbox.com/mcp" } +``` + +VS Code asks the user to sign in the first time a Mapbox tool is used. + +### OpenCode — `~/.config/opencode/opencode.jsonc` + +Add under `"mcp"`: + +```json +"mapbox": { "type": "remote", "url": "https://mcp.mapbox.com/mcp", "enabled": true, "oauth": {} }, +"mapbox-devkit": { "type": "remote", "url": "https://mcp-devkit.mapbox.com/mcp", "enabled": true, "oauth": {} }, +"mapbox-docs": { "type": "remote", "url": "https://mcp-docs.mapbox.com/mcp", "enabled": true } +``` + +Then run: + +```sh +opencode mcp auth mapbox +opencode mcp auth mapbox-devkit +``` + +### Windsurf — `~/.codeium/windsurf/mcp_config.json` + +Add under `"mcpServers"` (note: `serverUrl`, not `url`): + +```json +"mapbox": { "serverUrl": "https://mcp.mapbox.com/mcp" }, +"mapbox-devkit": { "serverUrl": "https://mcp-devkit.mapbox.com/mcp" }, +"mapbox-docs": { "serverUrl": "https://mcp-docs.mapbox.com/mcp" } +``` + +Windsurf asks the user to sign in the first time a Mapbox tool is used. + +### Any other agent + +Add the three servers from the table above as remote (Streamable HTTP) servers in your agent's MCP configuration. Both servers that need sign-in support OAuth with dynamic client registration. + +--- + +Once done, tell the user: + +``` +┌─ Mapbox Agent Setup Complete ────────────────────────┐ +│ ✓ CLI │ +│ ✓ Skills │ +│ ✓ MCPs │ +│ │ +│ Restart your agent to load the MCP servers │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## Resources + +- Mapbox Agent Skills: `https://github.com/mapbox/mapbox-agent-skills` +- Mapbox MCP server: `https://github.com/mapbox/mcp-server` +- Mapbox MCP DevKit server: `https://github.com/mapbox/mcp-devkit-server` +- Mapbox MCP docs server: `https://github.com/mapbox/mcp-docs-server` +- Mapbox CLI: `https://github.com/mapbox/mapbox-cli` +- Claude Code MCP: `https://code.claude.com/docs/en/mcp` +- Cursor MCP: `https://cursor.com/docs/mcp` +- VS Code MCP: `https://code.visualstudio.com/docs/copilot/customization/mcp-servers` +- OpenCode MCP: `https://opencode.ai/docs/mcp-servers/` + +These instructions are published at `https://cli.mapbox.com/agent-setup/prompt.md`, so you can re-check that they come from Mapbox at any time. diff --git a/tests/docs_contract.rs b/tests/docs_contract.rs index a8471ff..e3a302e 100644 --- a/tests/docs_contract.rs +++ b/tests/docs_contract.rs @@ -21,7 +21,8 @@ //! 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. +//! nothing here read the README. The agent-setup prompt under `site/` gets +//! the same check plus its flags, because agents run it verbatim. //! //! Three things it deliberately doesn't do, written down so the next //! reader doesn't have to re-derive the scope: @@ -391,17 +392,17 @@ fn readme() -> String { 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. +/// Every `mapbox …` invocation in a Markdown file, 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)> { +fn invocations(text: &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() { + for (index, line) in text.lines().enumerate() { let number = index + 1; if let Some(lang) = line.trim_start().strip_prefix("```") { fence = match fence { @@ -443,23 +444,34 @@ fn is_path_word(word: &str) -> bool { .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 invocation in `text` that names a command the CLI doesn't have, +/// as `file:line: mapbox …`. +/// +/// Also checks the flags of an invocation that resolves to a full command +/// when `check_flags` is set. The README leaves them out because a flag +/// there is illustration; in the agent-setup prompt it is an instruction an +/// agent runs verbatim, so a renamed flag is a broken setup. +fn unknown_invocations(file: &str, text: &str, schema: &Value, check_flags: bool) -> Vec { + let full: BTreeMap<&str, &Value> = commands(schema).iter().map(|c| (name(c), c)).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 { + for command in full.keys() { let mut path = String::from("mapbox"); for word in command.split_whitespace().skip(1) { path = format!("{path} {word}"); prefixes.insert(path.clone()); } } + let globals: BTreeSet<&str> = schema["global_options"] + .as_array() + .expect("global_options") + .iter() + .filter_map(|option| option["flag"].as_str()) + .collect(); let mut unknown = Vec::new(); - for (line, invocation) in readme_invocations(&readme()) { + for (line, invocation) in invocations(text) { 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 @@ -468,20 +480,44 @@ fn every_command_the_readme_spells_exists() { continue; } let mut path = String::from("mapbox"); + let mut known = true; 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()) { + } else if full.contains_key(path.as_str()) { // A positional argument, like `completion bash`. break; } else { - unknown.push(format!("README.md:{line}: {longer}")); + unknown.push(format!("{file}:{line}: {longer}")); + known = false; break; } } + let Some(command) = full.get(path.as_str()).filter(|_| known && check_flags) else { + continue; + }; + let takes: BTreeSet<&str> = command["arguments"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|argument| argument["flag"].as_str()) + .collect(); + let mut flags = BTreeSet::new(); + flags_in(&invocation, &mut flags); + for flag in flags { + if !takes.contains(flag.as_str()) && !globals.contains(flag.as_str()) { + unknown.push(format!("{file}:{line}: {path} {flag}")); + } + } } + unknown +} +#[test] +fn every_command_the_readme_spells_exists() { + let unknown = unknown_invocations("README.md", &readme(), &schema(), false); assert!( unknown.is_empty(), "README.md names commands the CLI doesn't have:\n{}\n\ @@ -491,6 +527,32 @@ fn every_command_the_readme_spells_exists() { ); } +/// The agent-setup prompt published at cli.mapbox.com/agent-setup/prompt.md. +/// Agents run its commands without a person reading them first, so a +/// command or flag it names that this binary doesn't have is a setup that +/// fails for everyone who follows it. The rest of the page — the skills, +/// the MCP servers, other agents' config formats — belongs to other +/// projects, and nothing here can check it. +#[test] +fn every_command_the_agent_setup_prompt_spells_exists() { + const PROMPT: &str = "site/agent-setup/prompt.md"; + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(PROMPT); + let text = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + assert!( + !invocations(&text).is_empty(), + "{PROMPT} spells no `mapbox …` invocation, so this test checks nothing" + ); + + let unknown = unknown_invocations(PROMPT, &text, &schema(), true); + assert!( + unknown.is_empty(), + "{PROMPT} names commands or flags the CLI doesn't have:\n{}\n\ + `mapbox --schema` lists what it does have.", + unknown.join("\n") + ); +} + #[test] fn every_top_level_command_appears_in_the_readme() { let schema = schema(); @@ -502,7 +564,7 @@ fn every_top_level_command_appears_in_the_readme() { }) .collect(); - let mentioned: BTreeSet = readme_invocations(&readme()) + let mentioned: BTreeSet = invocations(&readme()) .into_iter() .filter_map(|(_, invocation)| { let group = invocation.split_whitespace().nth(1)?; diff --git a/tests/source_guards.rs b/tests/source_guards.rs index eb6d4e6..cc38221 100644 --- a/tests/source_guards.rs +++ b/tests/source_guards.rs @@ -447,7 +447,7 @@ fn prose_files() -> Vec<(String, String)> { )); } - for dir in ["src", "tests", "docs", "scripts"] { + for dir in ["src", "tests", "docs", "scripts", "site"] { for (name, path) in files_under(&root.join(dir), &["rs", "md", "sh", "ps1"]) { out.push(( format!("{dir}/{name}"),