From 8a7fafa1572186a84b2b807865d353b228eac074 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 18:10:57 +0200 Subject: [PATCH 1/3] feat(install-agent): add Cursor, Copilot, Gemini CLI and opencode Each flag writes the agent's project MCP file with a zerograph server and the stanza into the instructions file it always reads. The files are committed, so they hold no absolute path: Cursor and VS Code expand ${workspaceFolder}, the CLIs start the server at the root. A commented mcp.json is left alone and the entry to paste is printed. --- README.md | 6 +- docs/mcp.md | 23 ++++++ src/install_agent.rs | 167 ++++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 26 ++++++- 4 files changed, 217 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 792f21fa..b678359e 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,13 @@ npm install -g zerograph # or: pnpm add -D zerograph echo .zerograph/ >> .gitignore # the graph is built per checkout zerograph build # first run downloads the embedding model (~1.2 GB) zerograph ask cancellation policy # stays fresh with your edits, no update to run -zerograph install-agent --claude # optional: MCP server + git hooks (or --codex) +zerograph install-agent --claude # optional: MCP server + git hooks for your agent ``` Prebuilt for macOS arm64, Linux x64 and Windows x64. Defaults read markdown and code in 24 -languages; a `zerograph.toml` changes that ([Configuration](docs/configuration.md)). +languages; a `zerograph.toml` changes that ([Configuration](docs/configuration.md)). `install-agent` +takes `--claude`, `--codex`, `--cursor`, `--copilot`, `--gemini` or `--opencode`, several at once +([MCP](docs/mcp.md#other-agents)). - On Windows: [Windows notes](docs/install.md#windows-notes). - Upgrading from repograph: the old store, config and variables are read where they are, see diff --git a/docs/mcp.md b/docs/mcp.md index 92bb1e23..4939e1ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10,6 +10,29 @@ registered alone, and prints the command to run by hand where `claude` is not on protocol is newline-delimited JSON-RPC on stdout and nothing else; diagnostics go to stderr. A tool that fails returns an `isError` result and the server carries on. +### Other agents + +```bash +zerograph install-agent --cursor --copilot --gemini --opencode # any subset, with --claude or --codex too +``` + +Each flag writes the agent's project MCP file with a `zerograph` server in it and the stanza into +the instructions file that agent reads always. A file already there is merged: its other servers and +settings stay. + +| flag | MCP file | instructions | +| --- | --- | --- | +| `--cursor` | `.cursor/mcp.json` | `.cursor/rules/zerograph.mdc`, `alwaysApply: true` | +| `--copilot` | `.vscode/mcp.json` (VS Code) | `.github/copilot-instructions.md` | +| `--gemini` | `.gemini/settings.json` | `GEMINI.md` | +| `--opencode` | `opencode.json` | `AGENTS.md` | + +These files are committed, so they carry no absolute path. Cursor and VS Code expand +`${workspaceFolder}` into `--repo`; Gemini CLI and opencode start the server in the directory they +were started in, so start them at the repository root. An `mcp.json` with comments in it is not +rewritten: the entry to paste is printed instead. Windsurf reads MCP servers only from a +machine-wide file, so it is not covered; `zerograph mcp` is the command to give it. + | tool | does | | --- | --- | | `ask` | `question` (plus `seeds`, `bodies`, `rerank`, `rerank_local`, `depth`, `stale`) to the answer `ask` prints, with the low-confidence line when the retrievers disagree. `rerank` spends tokens on the agent's word, so one server process runs at most `mcp_reranks` of them (25 by default, set in the machine config) and answers the rest from the fused order, saying so | diff --git a/src/install_agent.rs b/src/install_agent.rs index b63d7657..fcd79929 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -1,5 +1,6 @@ //! `zerograph install-agent`: writes the agent-facing surface into a consuming repository, for -//! Claude Code or for Codex. The texts are embedded with `include_str!` rather than copied from a +//! Claude Code or for Codex, and an MCP entry and the stanza for Cursor, VS Code with Copilot, +//! Gemini CLI and opencode. The texts are embedded with `include_str!` rather than copied from a //! checkout, so a binary and the surface it installs cannot disagree about what the tool can do. //! //! Idempotent by construction: a second run rewrites the same bytes and reports nothing written. @@ -296,6 +297,112 @@ pub fn install(root: &Path, target: Target, command: &str) -> Result { Ok(report) } +/// An editor or CLI that reads a project MCP file and an instructions file, and none of the +/// hooks, skills or subagents above: for these the install is the server entry and the stanza. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Editor { Cursor, Copilot, Gemini, Opencode } + +impl Editor { + fn mcp_file(self) -> &'static str { + match self { + Editor::Cursor => ".cursor/mcp.json", + Editor::Copilot => ".vscode/mcp.json", + Editor::Gemini => ".gemini/settings.json", + Editor::Opencode => "opencode.json", + } + } + + fn servers_key(self) -> &'static str { + match self { + Editor::Cursor | Editor::Gemini => "mcpServers", + Editor::Copilot => "servers", + Editor::Opencode => "mcp", + } + } + + /// The server entry for `argv`. These files are committed and shared, so no absolute path + /// goes in them: Cursor and VS Code expand `${workspaceFolder}` themselves, and Gemini CLI + /// and opencode start the server in the directory they were started in, which is the + /// repository root `zerograph` already defaults to. + fn server(self, argv: &[String]) -> serde_json::Value { + let (program, rest) = argv.split_first().expect("argv is never empty"); + let workspace = ["--repo", "${workspaceFolder}", "mcp"]; + let args = |tail: &[&str]| rest.iter().cloned().chain(tail.iter().map(|s| s.to_string())).collect::>(); + match self { + Editor::Cursor => serde_json::json!({ "command": program, "args": args(&workspace) }), + Editor::Copilot => serde_json::json!({ "type": "stdio", "command": program, "args": args(&workspace) }), + Editor::Gemini => serde_json::json!({ "command": program, "args": args(&["mcp"]) }), + Editor::Opencode => serde_json::json!({ + "type": "local", + "command": std::iter::once(program.clone()).chain(args(&["mcp"])).collect::>(), + "enabled": true, + }), + } + } + + /// The always-on instructions file. Cursor ignores plain markdown under `.cursor/rules/`, so + /// its stanza is a rule file of our own; opencode reads the root `AGENTS.md`, as Codex does. + fn instructions(self) -> &'static str { + match self { + Editor::Cursor => ".cursor/rules/zerograph.mdc", + Editor::Copilot => ".github/copilot-instructions.md", + Editor::Gemini => "GEMINI.md", + Editor::Opencode => "AGENTS.md", + } + } +} + +const CURSOR_RULE_HEADER: &str = "---\ndescription: Query the zerograph index before reading files\nalwaysApply: true\n---\n\n"; + +/// The project MCP file with our server entry in it, every other key and server kept. A file +/// that is not plain JSON (VS Code allows comments in `mcp.json`) is an error the caller turns +/// into a note, so a hand-kept file is never rewritten without its comments. +pub fn merged_mcp_file(existing: &str, editor: Editor, argv: &[String]) -> Result { + let mut root: serde_json::Value = match existing.trim().is_empty() { + true => match editor { + Editor::Opencode => serde_json::json!({ "$schema": "https://opencode.ai/config.json" }), + _ => serde_json::json!({}), + }, + false => serde_json::from_str(existing).context("not plain JSON")?, + }; + let Some(object) = root.as_object_mut() else { anyhow::bail!("not a JSON object") }; + let key = editor.servers_key(); + let servers = object.entry(key).or_insert_with(|| serde_json::json!({})); + let Some(servers) = servers.as_object_mut() else { anyhow::bail!("`{key}` is not an object") }; + servers.insert(MCP_NAME.into(), editor.server(argv)); + Ok(serde_json::to_string_pretty(&root)? + "\n") +} + +/// Installs the MCP entry and the stanza for one editor. Neither failure stops the other: a +/// commented `mcp.json` still gets its instructions, and says what to paste. +pub fn install_editor(root: &Path, editor: Editor, command: &str) -> Result { + let mut report = Report::default(); + let argv = split_words(command); + if argv.is_empty() { anyhow::bail!("--command is empty"); } + + let mcp = root.join(editor.mcp_file()); + let existing = std::fs::read_to_string(&mcp).unwrap_or_default(); + match merged_mcp_file(&existing, editor, &argv) { + Ok(merged) => write_if_changed(&mcp, &merged, &mut report)?, + Err(e) => report.notes.push(format!( + "{} is left as it is ({e:#}); add this under `{}`:\n\"{MCP_NAME}\": {}", + mcp.display(), editor.servers_key(), serde_json::to_string_pretty(&editor.server(&argv))?)), + } + + let instructions = root.join(editor.instructions()); + let text = match editor { + Editor::Cursor => format!("{CURSOR_RULE_HEADER}{}", with_command(STANZA, command)), + _ => merged_instructions(&std::fs::read_to_string(&instructions).unwrap_or_default(), command), + }; + let unchanged = std::fs::read_to_string(&instructions).is_ok_and(|old| old == text); + match !unchanged && is_symlink(&instructions) { + true => report.notes.push(format!( + "{} is a symlink, so the stanza was not written through it", instructions.display())), + false => write_if_changed(&instructions, &text, &mut report)?, + } + Ok(report) +} + fn holds_stanza(path: &Path) -> bool { std::fs::read_to_string(path).is_ok_and(|t| t.contains(BEGIN) || t.contains(LEGACY_BEGIN)) } @@ -567,6 +674,64 @@ mod tests { assert_eq!(std::fs::read_to_string(&victim).unwrap(), "{}"); } + fn read_json(path: &Path) -> serde_json::Value { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() + } + + #[test] + fn each_editor_gets_its_server_entry_and_its_stanza_and_a_second_run_writes_nothing() { + let dir = root(); + for editor in [Editor::Cursor, Editor::Copilot, Editor::Gemini, Editor::Opencode] { + let first = install_editor(dir.path(), editor, "pnpm exec zerograph").unwrap(); + assert_eq!(first.written, 2, "{editor:?}: {:?}", first.paths); + assert_eq!(install_editor(dir.path(), editor, "pnpm exec zerograph").unwrap().written, 0, "{editor:?}"); + } + let cursor = read_json(&dir.path().join(".cursor/mcp.json")); + assert_eq!(cursor["mcpServers"]["zerograph"], serde_json::json!({ + "command": "pnpm", "args": ["exec", "zerograph", "--repo", "${workspaceFolder}", "mcp"] })); + let copilot = read_json(&dir.path().join(".vscode/mcp.json")); + assert_eq!(copilot["servers"]["zerograph"]["type"], "stdio"); + assert_eq!(copilot["servers"]["zerograph"]["args"][3], "${workspaceFolder}"); + let gemini = read_json(&dir.path().join(".gemini/settings.json")); + assert_eq!(gemini["mcpServers"]["zerograph"], serde_json::json!({ "command": "pnpm", "args": ["exec", "zerograph", "mcp"] })); + let opencode = read_json(&dir.path().join("opencode.json")); + assert_eq!(opencode["mcp"]["zerograph"], serde_json::json!({ + "type": "local", "command": ["pnpm", "exec", "zerograph", "mcp"], "enabled": true })); + + let rule = std::fs::read_to_string(dir.path().join(".cursor/rules/zerograph.mdc")).unwrap(); + assert!(rule.starts_with("---\n") && rule.contains("alwaysApply: true"), "{rule}"); + for file in [".cursor/rules/zerograph.mdc", ".github/copilot-instructions.md", "GEMINI.md", "AGENTS.md"] { + let text = std::fs::read_to_string(dir.path().join(file)).unwrap(); + assert!(text.contains(BEGIN) && text.contains("pnpm exec zerograph ask"), "{file}"); + } + } + + #[test] + fn a_projects_own_servers_and_settings_survive_the_merge() { + let dir = root(); + std::fs::create_dir_all(dir.path().join(".gemini")).unwrap(); + std::fs::write(dir.path().join(".gemini/settings.json"), + r#"{"theme":"dark","mcpServers":{"other":{"command":"x"},"zerograph":{"command":"stale"}}}"#).unwrap(); + install_editor(dir.path(), Editor::Gemini, "zerograph").unwrap(); + let merged = read_json(&dir.path().join(".gemini/settings.json")); + assert_eq!(merged["theme"], "dark"); + assert_eq!(merged["mcpServers"]["other"]["command"], "x"); + assert_eq!(merged["mcpServers"]["zerograph"]["command"], "zerograph"); + } + + #[test] + fn a_commented_mcp_file_is_left_alone_and_the_entry_is_printed_instead() { + let dir = root(); + let mcp = dir.path().join(".vscode/mcp.json"); + std::fs::create_dir_all(mcp.parent().unwrap()).unwrap(); + let mine = "{\n // ours\n \"servers\": {}\n}\n"; + std::fs::write(&mcp, mine).unwrap(); + let r = install_editor(dir.path(), Editor::Copilot, "zerograph").unwrap(); + assert_eq!(std::fs::read_to_string(&mcp).unwrap(), mine); + assert!(r.notes.iter().any(|n| n.contains("\"zerograph\"") && n.contains("${workspaceFolder}")), "{:?}", r.notes); + assert!(dir.path().join(".github/copilot-instructions.md").exists()); + } + #[cfg(unix)] fn exited(code: i32, stdout: &str) -> std::process::Output { use std::os::unix::process::ExitStatusExt; diff --git a/src/lib.rs b/src/lib.rs index 858b390b..234d2bf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -162,6 +162,14 @@ enum Cmd { #[arg(long)] claude: bool, /// Codex's `.codex/`. #[arg(long)] codex: bool, + /// Cursor: `.cursor/mcp.json` and an always-on rule. + #[arg(long)] cursor: bool, + /// VS Code with GitHub Copilot: `.vscode/mcp.json` and `.github/copilot-instructions.md`. + #[arg(long)] copilot: bool, + /// Gemini CLI: `.gemini/settings.json` and `GEMINI.md`. + #[arg(long)] gemini: bool, + /// opencode: `opencode.json` and `AGENTS.md`. + #[arg(long)] opencode: bool, /// How this repository invokes the binary, in every file the install writes. #[arg(long, default_value = "zerograph")] command: String, }, @@ -1105,9 +1113,15 @@ fn run(cli: Cli) -> anyhow::Result<()> { if graph.nodes.is_empty() { anyhow::bail!("graph is empty — run `zerograph build`"); } Ok(()) } - Cmd::InstallAgent { claude, codex, command } => { + Cmd::InstallAgent { claude, codex, cursor, copilot, gemini, opencode, command } => { + use install_agent::Editor; + let editors: Vec = [(cursor, Editor::Cursor), (copilot, Editor::Copilot), (gemini, Editor::Gemini), (opencode, Editor::Opencode)] + .into_iter().filter(|(on, _)| *on).map(|(_, e)| e).collect(); + if !claude && !codex && editors.is_empty() { + anyhow::bail!("name an agent: --claude, --codex, --cursor, --copilot, --gemini or --opencode (several at once is fine)"); + } let targets: Vec = match (claude, codex) { - (false, false) => anyhow::bail!("name a harness: --claude, --codex, or both"), + (false, false) => Vec::new(), // Codex first: its stanza goes into the root `AGENTS.md`, which a `CLAUDE.md` link // usually points at, so the Claude pass finds nothing left to write through the link. (c, x) => [(x, install_agent::Target::Codex), (c, install_agent::Target::Claude)] @@ -1140,6 +1154,14 @@ fn run(cli: Cli) -> anyhow::Result<()> { install_agent::codex_hooks_block(&abs.display().to_string())); } } + for editor in editors { + let r = install_agent::install_editor(&repo, editor, &command)?; + match r.written { + 0 => println!("{editor:?}: already installed, nothing written"), + n => println!("{editor:?}: wrote {n} files\n {}", r.paths.join("\n ")), + } + for note in &r.notes { println!("{editor:?}: {note}"); } + } // Once whatever the harnesses: git runs these, not the agent. let r = install_agent::install_git_hooks(&repo, &command)?; if r.written > 0 { println!("git: wrote {} hooks\n {}", r.written, r.paths.join("\n ")); } From 974b56844454e907638b85614ca6d4538e93ec96 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 18:10:57 +0200 Subject: [PATCH 2/3] chore(release): bump to 0.6.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/commands.md | 2 +- docs/releases/0.6.3.md | 11 +++++++++++ npm/zerograph/package.json | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 docs/releases/0.6.3.md diff --git a/Cargo.lock b/Cargo.lock index 935edef5..7f45d2bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3227,7 +3227,7 @@ dependencies = [ [[package]] name = "zerograph" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "ast-grep-language", diff --git a/Cargo.toml b/Cargo.toml index 2d0bddb3..7b9f9330 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerograph" -version = "0.6.2" +version = "0.6.3" edition = "2021" rust-version = "1.98" description = "A project knowledge graph built and queried at zero API tokens" diff --git a/docs/commands.md b/docs/commands.md index 65eefcf2..eaa89d9c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,7 @@ ## Status -0.6.2 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, +0.6.3 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, C#, Rust, Python, Dart, Swift, GraphQL, SQL, Bicep, HCL, Shell, Vue, Lua, Go, C, C++, PHP, Ruby, Scala, Elixir and OCaml; every command below is implemented rather than planned: diff --git a/docs/releases/0.6.3.md b/docs/releases/0.6.3.md new file mode 100644 index 00000000..93c6b0d8 --- /dev/null +++ b/docs/releases/0.6.3.md @@ -0,0 +1,11 @@ +# zerograph 0.6.3 + +The release notes for the GitHub release page. The why behind each change is in the docs each line +links to. + +## New + +- **`install-agent` for more agents.** `--cursor`, `--copilot` (VS Code), `--gemini` (Gemini CLI) + and `--opencode` write the agent's project MCP file with a `zerograph` server and the stanza into + the instructions file it always reads. Existing files are merged, other servers and settings + kept; any subset combines with `--claude` and `--codex` ([other agents](../mcp.md#other-agents)). diff --git a/npm/zerograph/package.json b/npm/zerograph/package.json index ac034177..515d3599 100644 --- a/npm/zerograph/package.json +++ b/npm/zerograph/package.json @@ -1,6 +1,6 @@ { "name": "zerograph", - "version": "0.6.2", + "version": "0.6.3", "description": "Knowledge graph of a repository's requirement docs and code in 24 languages, built and refreshed at zero model tokens, asked by id, keyword or paraphrase", "license": "MIT", "repository": { From 6a4d713417e9c5eaa38ad11a92a10f17d2a44b69 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 18:12:42 +0200 Subject: [PATCH 3/3] fix(install-agent): never overwrite an MCP file it cannot read or write A symlinked or unreadable MCP file is now a note with the entry to paste, so the stanza and the other agents still install. Editors run before the Claude pass, so a CLAUDE.md linked to AGENTS.md finds it already written. --- src/install_agent.rs | 28 ++++++++++++++++++++++++++-- src/lib.rs | 18 ++++++++++-------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/install_agent.rs b/src/install_agent.rs index fcd79929..fcb1e1be 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -381,8 +381,18 @@ pub fn install_editor(root: &Path, editor: Editor, command: &str) -> Result Ok(text), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(anyhow::Error::from(e).context("unreadable")), + }; + let merged = existing.and_then(|text| merged_mcp_file(&text, editor, &argv)); + let unchanged = merged.as_ref().is_ok_and(|m| std::fs::read_to_string(&mcp).is_ok_and(|old| &old == m)); + match merged { + Ok(_) if !unchanged && is_symlink(&mcp) => report.notes.push(format!( + "{} is a symlink, so the server entry was not written through it", mcp.display())), Ok(merged) => write_if_changed(&mcp, &merged, &mut report)?, Err(e) => report.notes.push(format!( "{} is left as it is ({e:#}); add this under `{}`:\n\"{MCP_NAME}\": {}", @@ -732,6 +742,20 @@ mod tests { assert!(dir.path().join(".github/copilot-instructions.md").exists()); } + #[cfg(unix)] + #[test] + fn a_linked_mcp_file_is_not_written_through_and_the_stanza_still_lands() { + let dir = root(); + let elsewhere = dir.path().join("shared.json"); + std::fs::write(&elsewhere, "{}").unwrap(); + std::fs::create_dir_all(dir.path().join(".cursor")).unwrap(); + std::os::unix::fs::symlink(&elsewhere, dir.path().join(".cursor/mcp.json")).unwrap(); + let r = install_editor(dir.path(), Editor::Cursor, "zerograph").unwrap(); + assert_eq!(std::fs::read_to_string(&elsewhere).unwrap(), "{}"); + assert!(r.notes.iter().any(|n| n.contains("symlink")), "{:?}", r.notes); + assert!(dir.path().join(".cursor/rules/zerograph.mdc").exists()); + } + #[cfg(unix)] fn exited(code: i32, stdout: &str) -> std::process::Output { use std::os::unix::process::ExitStatusExt; diff --git a/src/lib.rs b/src/lib.rs index 234d2bf5..7269cfd9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1127,6 +1127,16 @@ fn run(cli: Cli) -> anyhow::Result<()> { (c, x) => [(x, install_agent::Target::Codex), (c, install_agent::Target::Claude)] .into_iter().filter(|(on, _)| *on).map(|(_, t)| t).collect(), }; + // Editors first: opencode writes the root `AGENTS.md`, which a `CLAUDE.md` link usually + // points at, so the Claude pass below finds it written rather than a link to refuse. + for editor in editors { + let r = install_agent::install_editor(&repo, editor, &command)?; + match r.written { + 0 => println!("{editor:?}: already installed, nothing written"), + n => println!("{editor:?}: wrote {n} files\n {}", r.paths.join("\n ")), + } + for note in &r.notes { println!("{editor:?}: {note}"); } + } for target in targets { let r = install_agent::install(&repo, target, &command)?; match r.written { @@ -1154,14 +1164,6 @@ fn run(cli: Cli) -> anyhow::Result<()> { install_agent::codex_hooks_block(&abs.display().to_string())); } } - for editor in editors { - let r = install_agent::install_editor(&repo, editor, &command)?; - match r.written { - 0 => println!("{editor:?}: already installed, nothing written"), - n => println!("{editor:?}: wrote {n} files\n {}", r.paths.join("\n ")), - } - for note in &r.notes { println!("{editor:?}: {note}"); } - } // Once whatever the harnesses: git runs these, not the agent. let r = install_agent::install_git_hooks(&repo, &command)?; if r.written > 0 { println!("git: wrote {} hooks\n {}", r.written, r.paths.join("\n ")); }