From cae9808d280b905af9658c5e2e73b26e5df4de58 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Tue, 1 Sep 2026 01:27:26 +0800 Subject: [PATCH 1/4] feat(cli): complete wave 3 bootstrap and project discovery --- README.md | 20 +- crates/codegraph-cli/src/installer/mod.rs | 14 +- .../src/installer/targets/codex.rs | 203 ++++++--- crates/codegraph-cli/src/main.rs | 59 ++- crates/codegraph-cli/tests/install_init.rs | 183 ++++++++ crates/codegraph-cli/tests/installer.rs | 26 +- .../codegraph-cli/tests/rmcp_serve_direct.rs | 279 +++++++++++- crates/codegraph-cli/tests/skill_install.rs | 5 +- crates/codegraph-mcp/src/lib.rs | 1 + crates/codegraph-mcp/src/rmcp_handler.rs | 187 +++++++- crates/codegraph-mcp/src/roots.rs | 425 ++++++++++++++++++ crates/codegraph-mcp/src/server.rs | 206 +++++++-- crates/codegraph-mcp/tests/rmcp_roots.rs | 19 +- docs/cli.md | 49 +- docs/mcp.md | 60 ++- docs/readme/README.zh-CN.md | 20 +- skills/codegraph/SKILL.md | 19 + 17 files changed, 1602 insertions(+), 173 deletions(-) create mode 100644 crates/codegraph-cli/tests/install_init.rs diff --git a/README.md b/README.md index bda31f67..d5ebf190 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ irm https://raw.githubusercontent.com/sunerpy/codegraph-rust/main/scripts/instal codegraph init /path/to/project # create .codegraph/ and run the first index codegraph search "" -p /path/to/project # full-text symbol search (`query` is a legacy alias) codegraph serve --mcp --path /path/to/project # MCP server (--path optional, defaults to cwd) +codegraph install --yes --init # wire detected agents + initialize cwd ``` --- @@ -114,6 +115,7 @@ VS Code, Copilot CLI, JetBrains): ```bash codegraph install --yes # detects installed agents and wires them up +codegraph install --yes --init # one-shot unattended install + cwd index ``` **MCP tools you can call** (prefer these over grep/read for indexed source): @@ -197,9 +199,12 @@ automatically): ``` **Default (no `-p`):** one config works for all your projects — each just needs -`codegraph index` first. The server resolves the project from the client's working -directory or `rootUri`/`workspaceFolders`/`roots`. When it can't resolve one, -`projectPath` is marked required per tool call. +an index first. The stdio server finds an indexed ancestor; from an unindexed +workspace root (`.git` or a workspace manifest), it also adopts exactly one +indexed child project. Multiple candidates are never guessed and are listed in +stderr/tool guidance; pass `projectPath` per call or pin `--path`. Client +`rootUri`/`workspaceFolders`/`roots` remain another adoption path. When no +default resolves, `projectPath` is marked required per tool call. **Optional `-p `:** pin to one fixed project (e.g. `"args": ["serve", "--mcp", "-p", "/abs/path/to/project"]`). @@ -211,10 +216,16 @@ Gemini CLI, Antigravity IDE, Kiro, Trae, Qoder, Zed, Zuno, VS Code ```bash codegraph install --yes # auto-detect installed agents +codegraph install --yes --init # wire agents, then initialize cwd codegraph install --target=claude,cursor --yes # explicit list codegraph install --target=auto --local # project-local config +codegraph install --target=codex --local --yes # .codex/config.toml + AGENTS.md + local skill ``` +Codex project-local configuration is applied only after the repository is +trusted in Codex. `install` never indexes implicitly; only explicit `--init` +does so, and `--print-config` always returns without writing or indexing. + Full MCP tool reference: [`docs/mcp.md`](docs/mcp.md). --- @@ -279,7 +290,8 @@ stays live depends on whether the IDE expands `${workspaceFolder}`: - **Kiro / Qoder** — global entry without `--path`; tools work read-only off the existing index. Run `codegraph init --target=kiro` (or `--target=qoder`) inside each project for live watch. - **Zed** — global `settings.json` entry without `--path`. Run `codegraph init --target=zed` inside each project to write a `.zed/settings.json` with an absolute `--path` — the only way to give Zed a per-project path. The installer also writes `//`-commented HTTP and SSH alternatives for remote-development use. - **Zuno** — global config is `$XDG_CONFIG_HOME/zuno/zuno.json[c]` (normally `~/.config/zuno/zuno.json[c]`); project config is `.zuno/zuno.json[c]`. The installer writes `mcp.codegraph` and migrates the older `mcp.codegraph-mcp-server` key without disturbing siblings or JSONC comments. -- **Other agents** (Claude Code, Codex CLI, opencode, Hermes, Gemini CLI, Antigravity) — their native MCP entry shape; live watch where the daemon can reach the project. +- **Codex CLI** — global install keeps `~/.codex/config.toml` + `~/.codex/AGENTS.md`; local install writes `.codex/config.toml`, project-root `AGENTS.md`, and `.agents/skills/codegraph`. Trust the project in Codex to activate its local layer. +- **Other agents** (Claude Code, opencode, Hermes, Gemini CLI, Antigravity) — their native MCP entry shape; live watch where the daemon can reach the project. > **Zed Remote (SSH).** Zed runs MCP `context_servers` on the local client, not on > the remote host. If codegraph tools return empty in a remote SSH session, use the diff --git a/crates/codegraph-cli/src/installer/mod.rs b/crates/codegraph-cli/src/installer/mod.rs index e17bf28b..e9840f13 100644 --- a/crates/codegraph-cli/src/installer/mod.rs +++ b/crates/codegraph-cli/src/installer/mod.rs @@ -337,8 +337,8 @@ fn resolve_skill_targets( /// `codegraph skill install`. Writes the embedded skill into each resolved /// target's skill directory, gating on `supports_skills` (NOT -/// `supports_location` — codex/antigravity support local skills even though -/// their MCP config is global-only). +/// `supports_location` — a target may expose skill locations independently +/// from its MCP config locations). pub fn run_skill_install(args: SkillArgs) -> Result<()> { let _ = args.yes; let ctx = context_from_env()?; @@ -913,10 +913,13 @@ mod tests { } #[test] - fn run_install_with_ctx_skips_unsupported_location() { - let (ctx, base) = temp_ctx("run-skip"); + fn run_install_with_ctx_installs_codex_locally() { + let (ctx, base) = temp_ctx("run-codex-local"); + let cwd = ctx.cwd.clone(); run_install_with_ctx(ctx.clone(), install_args("codex", "local")).unwrap(); assert!(!ctx.home.join(".codex").exists()); + assert!(cwd.join(".codex/config.toml").exists()); + assert!(cwd.join("AGENTS.md").exists()); let _ = fs::remove_dir_all(base); } @@ -978,8 +981,7 @@ mod tests { let reports = uninstall_targets(&ctx, &[gemini, codex], Location::Local); assert!(matches!(reports[0].status, UninstallStatus::Removed)); assert!(!reports[0].removed_paths.is_empty()); - assert!(matches!(reports[1].status, UninstallStatus::Unsupported)); - assert!(!reports[1].notes.is_empty()); + assert!(matches!(reports[1].status, UninstallStatus::NotConfigured)); let again = uninstall_targets(&ctx, &[gemini], Location::Local); assert!(matches!(again[0].status, UninstallStatus::NotConfigured)); diff --git a/crates/codegraph-cli/src/installer/targets/codex.rs b/crates/codegraph-cli/src/installer/targets/codex.rs index 4b280e53..01c4130a 100644 --- a/crates/codegraph-cli/src/installer/targets/codex.rs +++ b/crates/codegraph-cli/src/installer/targets/codex.rs @@ -1,8 +1,9 @@ //! OpenAI Codex CLI target. Ports `upstream installer/targets/codex.ts`. //! -//! Writes the MCP entry to `~/.codex/config.toml` as the dotted-key table -//! `[mcp_servers.codegraph]`, and the instructions block to `~/.codex/AGENTS.md`. -//! Codex is global-only (no project-local config concept). +//! Writes the MCP entry as the dotted-key table `[mcp_servers.codegraph]` and +//! installs the managed instructions block: +//! - global: `~/.codex/config.toml` + `~/.codex/AGENTS.md` +//! - local: `/.codex/config.toml` + `/AGENTS.md` use std::fs; use std::path::PathBuf; @@ -21,14 +22,27 @@ pub struct CodexTarget; const TOML_HEADER: &str = "mcp_servers.codegraph"; -fn config_dir(ctx: &InstallContext) -> PathBuf { - ctx.home.join(".codex") +fn config_dir(ctx: &InstallContext, loc: Location) -> PathBuf { + match loc { + Location::Global => ctx.home.join(".codex"), + Location::Local => ctx.cwd.join(".codex"), + } +} +fn toml_config_path(ctx: &InstallContext, loc: Location) -> PathBuf { + config_dir(ctx, loc).join("config.toml") } -fn toml_config_path(ctx: &InstallContext) -> PathBuf { - config_dir(ctx).join("config.toml") +fn instructions_path(ctx: &InstallContext, loc: Location) -> PathBuf { + match loc { + Location::Global => config_dir(ctx, loc).join("AGENTS.md"), + Location::Local => ctx.cwd.join("AGENTS.md"), + } } -fn instructions_path(ctx: &InstallContext) -> PathBuf { - config_dir(ctx).join("AGENTS.md") + +fn trust_note(ctx: &InstallContext) -> String { + format!( + "Codex applies {} only after this project is marked trusted. Trust the project in Codex to activate its local MCP configuration.", + toml_config_path(ctx, Location::Local).display() + ) } // Ports buildCodegraphBlock (codex.ts:136). The MCP server config command/args @@ -57,52 +71,40 @@ impl AgentTarget for CodexTarget { fn display_name(&self) -> &'static str { "Codex CLI" } - fn supports_location(&self, loc: Location) -> bool { - loc == Location::Global + fn supports_location(&self, _loc: Location) -> bool { + true } fn detect(&self, ctx: &InstallContext, loc: Location) -> DetectionResult { - if loc != Location::Global { - return DetectionResult::default(); - } - let toml_path = toml_config_path(ctx); + let toml_path = toml_config_path(ctx, loc); let already_configured = fs::read_to_string(&toml_path) .map(|content| contains_toml_table(&content, TOML_HEADER)) .unwrap_or(false); DetectionResult { - installed: config_dir(ctx).exists(), + installed: config_dir(ctx, loc).exists() || toml_path.exists(), already_configured, } } // Ports codexTarget.install (codex.ts:76). fn install(&self, ctx: &InstallContext, loc: Location, _opts: InstallOptions) -> WriteResult { - if loc != Location::Global { - return WriteResult { - files: Vec::new(), - notes: vec![ - "Codex CLI has no project-local config — re-run with --location=global to install." - .to_string(), - ], - }; - } let files = vec![ - write_mcp_entry(ctx), - upsert_instructions_entry(&instructions_path(ctx)), + write_mcp_entry(ctx, loc), + upsert_instructions_entry(&instructions_path(ctx, loc)), ]; WriteResult { files, - notes: Vec::new(), + notes: (loc == Location::Local) + .then(|| trust_note(ctx)) + .into_iter() + .collect(), } } // Ports codexTarget.uninstall (codex.ts:95). fn uninstall(&self, ctx: &InstallContext, loc: Location) -> WriteResult { - if loc != Location::Global { - return WriteResult::default(); - } let mut files = Vec::new(); - let toml_path = toml_config_path(ctx); + let toml_path = toml_config_path(ctx, loc); if let Ok(content) = fs::read_to_string(&toml_path) { let line_ending = if content.contains("\r\n") { "\r\n" @@ -135,7 +137,7 @@ impl AgentTarget for CodexTarget { action: FileAction::NotFound, }); } - files.push(remove_instructions_entry(ctx)); + files.push(remove_instructions_entry(ctx, loc)); WriteResult { files, notes: Vec::new(), @@ -144,26 +146,19 @@ impl AgentTarget for CodexTarget { // Ports codexTarget.printConfig (codex.ts:122). fn print_config(&self, ctx: &InstallContext, loc: Location) -> String { - if loc != Location::Global { - return "# Codex CLI has no project-local config — use --location=global.\n" - .to_string(); - } format!( "# Add to {}\n\n{}\n", - toml_config_path(ctx).display(), + toml_config_path(ctx, loc).display(), build_codegraph_block() ) } fn managed_instructions_path(&self, ctx: &InstallContext, loc: Location) -> Option { - (loc == Location::Global).then(|| instructions_path(ctx)) + Some(instructions_path(ctx, loc)) } - // Skill support is INTENTIONALLY decoupled from `supports_location`: Codex - // MCP config is global-only, yet Codex DOES scan project-local skills, so - // skills are gated on `supports_skills` (true for BOTH locations), never on - // `supports_location`. Codex + Antigravity LOCAL both target `.agents/skills` - // — co-installing them is idempotent (same content, same hash). + // Codex + Antigravity LOCAL both target `.agents/skills`; co-installing them + // is idempotent (same content, same hash). fn supports_skills(&self, _loc: Location) -> bool { true } @@ -177,8 +172,8 @@ impl AgentTarget for CodexTarget { } // Ports writeMcpEntry (codex.ts:144). -fn write_mcp_entry(ctx: &InstallContext) -> FileWrite { - let file = toml_config_path(ctx); +fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { + let file = toml_config_path(ctx, loc); if let Some(dir) = file.parent() { let _ = fs::create_dir_all(dir); } @@ -204,8 +199,8 @@ fn write_mcp_entry(ctx: &InstallContext) -> FileWrite { } // Ports removeInstructionsEntry (codex.ts:169). -fn remove_instructions_entry(ctx: &InstallContext) -> FileWrite { - let file = instructions_path(ctx); +fn remove_instructions_entry(ctx: &InstallContext, loc: Location) -> FileWrite { + let file = instructions_path(ctx, loc); let action = shared::remove_marked_section(&file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); FileWrite { path: file, action } @@ -246,7 +241,7 @@ mod tests { let t = CodexTarget; assert!(t.supports_skills(Location::Local)); assert!(t.supports_skills(Location::Global)); - assert!(!t.supports_location(Location::Local)); + assert!(t.supports_location(Location::Local)); assert!(t.supports_location(Location::Global)); } @@ -294,7 +289,7 @@ mod tests { fn install_creates_toml_table_and_instructions_then_uninstall() { let fx = TempCodex::new("lifecycle"); let target = CodexTarget; - let toml = toml_config_path(&fx.ctx); + let toml = toml_config_path(&fx.ctx, Location::Global); let before = target.detect(&fx.ctx, Location::Global); assert!(!before.installed); @@ -305,7 +300,7 @@ mod tests { assert!(content.contains("[mcp_servers.codegraph]")); assert!(content.contains("command = \"codegraph\"")); assert!(content.contains("args = [\"serve\", \"--mcp\"]")); - assert!(instructions_path(&fx.ctx).exists()); + assert!(instructions_path(&fx.ctx, Location::Global).exists()); let after = target.detect(&fx.ctx, Location::Global); assert!(after.installed); @@ -319,10 +314,10 @@ mod tests { fn install_is_idempotent() { let fx = TempCodex::new("idempotent"); let target = CodexTarget; - let toml = toml_config_path(&fx.ctx); + let toml = toml_config_path(&fx.ctx, Location::Global); target.install(&fx.ctx, Location::Global, opts()); let first = fs::read_to_string(&toml).unwrap(); - let again = write_mcp_entry(&fx.ctx); + let again = write_mcp_entry(&fx.ctx, Location::Global); assert_eq!(again.action, FileAction::Unchanged); assert_eq!(fs::read_to_string(&toml).unwrap(), first); } @@ -331,7 +326,7 @@ mod tests { fn detect_uses_toml_lexer_for_real_and_decoy_headers() { let fx = TempCodex::new("detect-toml-header"); let target = CodexTarget; - let toml = toml_config_path(&fx.ctx); + let toml = toml_config_path(&fx.ctx, Location::Global); fs::create_dir_all(toml.parent().unwrap()).unwrap(); fs::write( &toml, @@ -355,21 +350,95 @@ mod tests { } #[test] - fn local_location_is_rejected() { - let fx = TempCodex::new("local-reject"); + fn local_install_uses_project_layout_and_reports_trust_requirement() { + let fx = TempCodex::new("local-layout"); let target = CodexTarget; let install = target.install(&fx.ctx, Location::Local, opts()); - assert!(install.files.is_empty()); - assert!(install.notes[0].contains("--location=global")); - - let uninstall = target.uninstall(&fx.ctx, Location::Local); - assert!(uninstall.files.is_empty()); + assert_eq!(install.files.len(), 2); + assert!(install.notes.join(" ").contains("trusted")); + let toml = toml_config_path(&fx.ctx, Location::Local); + assert_eq!(toml, fx.ctx.cwd.join(".codex/config.toml")); + assert!( + fs::read_to_string(&toml) + .unwrap() + .contains("[mcp_servers.codegraph]") + ); + assert_eq!( + instructions_path(&fx.ctx, Location::Local), + fx.ctx.cwd.join("AGENTS.md") + ); + assert!(instructions_path(&fx.ctx, Location::Local).exists()); + assert!( + !toml_config_path(&fx.ctx, Location::Global).exists(), + "local install must not touch the global config" + ); + assert!(target.detect(&fx.ctx, Location::Local).already_configured); + assert!( + target + .print_config(&fx.ctx, Location::Local) + .contains(".codex/config.toml") + ); + } - let detect = target.detect(&fx.ctx, Location::Local); - assert!(!detect.installed); + #[test] + fn local_uninstall_leaves_global_install_intact() { + let fx = TempCodex::new("local-uninstall"); + let target = CodexTarget; + target.install(&fx.ctx, Location::Global, opts()); + target.install(&fx.ctx, Location::Local, opts()); + + let removed = target.uninstall(&fx.ctx, Location::Local); + assert!( + removed + .files + .iter() + .any(|file| file.action == FileAction::Removed) + ); + assert!(!target.detect(&fx.ctx, Location::Local).already_configured); + assert!(target.detect(&fx.ctx, Location::Global).already_configured); + assert!( + fs::read_to_string(toml_config_path(&fx.ctx, Location::Global)) + .unwrap() + .contains("[mcp_servers.codegraph]") + ); + } - let printed = target.print_config(&fx.ctx, Location::Local); - assert!(printed.contains("--location=global")); + #[test] + fn local_round_trip_preserves_user_toml_and_agents_text() { + let fx = TempCodex::new("local-preserve"); + let target = CodexTarget; + let toml = toml_config_path(&fx.ctx, Location::Local); + let agents = instructions_path(&fx.ctx, Location::Local); + fs::create_dir_all(toml.parent().unwrap()).unwrap(); + fs::create_dir_all(&fx.ctx.cwd).unwrap(); + fs::write( + &toml, + "# user comment\n[features]\nexperimental = true\n\n[mcp_servers.other]\ncommand = \"other\"\n", + ) + .unwrap(); + fs::write(&agents, "# User rules\n\nKeep this text.\n").unwrap(); + + target.install(&fx.ctx, Location::Local, opts()); + let installed_toml = fs::read_to_string(&toml).unwrap(); + let installed_agents = fs::read_to_string(&agents).unwrap(); + assert!(installed_toml.contains("# user comment")); + assert!(installed_toml.contains("[features]")); + assert!(installed_toml.contains("[mcp_servers.other]")); + assert!(installed_toml.contains("[mcp_servers.codegraph]")); + assert!(installed_agents.contains("# User rules")); + assert!(installed_agents.contains("Keep this text.")); + assert!(installed_agents.contains(CODEGRAPH_SECTION_START)); + + target.uninstall(&fx.ctx, Location::Local); + let uninstalled_toml = fs::read_to_string(&toml).unwrap(); + let uninstalled_agents = fs::read_to_string(&agents).unwrap(); + assert!(uninstalled_toml.contains("# user comment")); + assert!(uninstalled_toml.contains("[features]")); + assert!(uninstalled_toml.contains("[mcp_servers.other]")); + assert!(!uninstalled_toml.contains("[mcp_servers.codegraph]")); + assert!(uninstalled_agents.contains("# User rules")); + assert!(uninstalled_agents.contains("Keep this text.")); + assert!(!uninstalled_agents.contains(CODEGRAPH_SECTION_START)); } #[test] @@ -384,7 +453,7 @@ mod tests { fn uninstall_preserves_sibling_table() { let fx = TempCodex::new("uninstall-sibling"); let target = CodexTarget; - let toml = toml_config_path(&fx.ctx); + let toml = toml_config_path(&fx.ctx, Location::Global); target.install(&fx.ctx, Location::Global, opts()); let content = fs::read_to_string(&toml).unwrap(); fs::write( @@ -403,7 +472,7 @@ mod tests { fn uninstall_preserves_crlf_line_endings() { let fx = TempCodex::new("uninstall-crlf"); let target = CodexTarget; - let toml = toml_config_path(&fx.ctx); + let toml = toml_config_path(&fx.ctx, Location::Global); fs::create_dir_all(toml.parent().unwrap()).unwrap(); fs::write( &toml, diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index e4e6701c..baf94936 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -210,6 +210,10 @@ enum Command { /// get the project's absolute `--path`. #[arg(short, long, default_value = "none")] target: String, + /// Accepted for non-interactive bootstrap compatibility. `init` has no + /// confirmation prompt today, so this is intentionally behavior-neutral. + #[arg(short = 'y', long)] + yes: bool, }, // Upstream flags/output: upstream bin/codegraph.ts:482-485, 489-527. Uninit { @@ -468,6 +472,9 @@ enum Command { local: bool, #[arg(short, long)] yes: bool, + /// After a successful agent install, initialize the current project. + #[arg(short = 'i', long = "init")] + init: bool, #[arg(long = "no-permissions")] no_permissions: bool, #[arg(long = "prompt-hook")] @@ -633,7 +640,7 @@ enum McpAction { fn run(cli: Cli) -> Result<()> { match cli.command { - Command::Init { path, target } => cmd_init(path, &target), + Command::Init { path, target, yes } => cmd_init(path, &target, yes), Command::Uninit { path, force } => cmd_uninit(path, force), Command::Index { path, @@ -744,17 +751,25 @@ fn run(cli: Cli) -> Result<()> { global, local, yes, + init, no_permissions, prompt_hook, print_config, - } => installer::run_install(installer::InstallArgs { - target, - location: location_flag(location, global, local), - yes, - permissions: if no_permissions { Some(false) } else { None }, - front_load_hook: prompt_hook, - print_config, - }), + } => { + let print_only = print_config.is_some(); + installer::run_install(installer::InstallArgs { + target, + location: location_flag(location, global, local), + yes, + permissions: if no_permissions { Some(false) } else { None }, + front_load_hook: prompt_hook, + print_config, + })?; + if init && !print_only { + cmd_init(None, "none", yes)?; + } + Ok(()) + } Command::Uninstall { target, location, @@ -1291,7 +1306,7 @@ fn cmd_prompt_hook(path: Option, query: Option) -> Result<()> { Ok(()) } -fn cmd_init(path: Option, target: &str) -> Result<()> { +fn cmd_init(path: Option, target: &str, _yes: bool) -> Result<()> { let project = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); if explicit_init_observes_readable_current(&project)? { println!("Already initialized in {}", project.display()); @@ -2169,14 +2184,26 @@ fn cmd_serve( if http { return cmd_serve_http(path, &http_addr, detach); } - // Default the MCP project to cwd so `serve --mcp` (no --path, as the - // installer injects) finds the index of the agent's project root. let explicit_path = path.is_some(); - let project = Some(resolve_project_path_optional(&absolute_path( - path.unwrap_or_else(|| PathBuf::from(".")), - ))); + let search_from = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); + // Preserve the existing lifecycle-root fallback for interrupted/outdated + // indexes, but prefer the shared stdio resolver when it finds a usable + // ancestor or exactly one indexed child workspace project. + let lifecycle_root = resolve_project_path_optional(&search_from); + let resolution = codegraph_mcp::resolve_server_root(&search_from, true); + let project_root = resolution.root.clone().unwrap_or(lifecycle_root); + if resolution.via_subproject_scan { + let relative = project_root + .strip_prefix(&resolution.search_from) + .unwrap_or(&project_root) + .display(); + eprintln!( + "[CodeGraph MCP] No index at or above {}; adopted the single indexed sub-project {relative} as the default project.", + resolution.search_from.display() + ); + } + let project = Some(project_root.clone()); if mcp { - let project_root = project.clone().unwrap_or_else(|| PathBuf::from(".")); // Stop-the-bleed home guard: an IDE (e.g. Kiro) launches `serve --mcp` // with CWD=$HOME, which would otherwise spawn a daemon and run a // home-wide catch-up sync that pegs a CPU indexing the entire home diff --git a/crates/codegraph-cli/tests/install_init.rs b/crates/codegraph-cli/tests/install_init.rs new file mode 100644 index 00000000..67edff54 --- /dev/null +++ b/crates/codegraph-cli/tests/install_init.rs @@ -0,0 +1,183 @@ +//! End-to-end coverage for the explicit one-shot bootstrap flags: +//! `codegraph init --yes` and `codegraph install --init`. + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct Fixture { + root: PathBuf, + home: PathBuf, + project: PathBuf, +} + +impl Fixture { + fn new(label: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "codegraph-install-init-{label}-{}-{}", + std::process::id(), + now_nanos() + )); + let home = root.join("home"); + let project = root.join("project"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&project).unwrap(); + fs::write( + project.join("main.rs"), + "pub fn greet() -> &'static str { \"hello\" }\n", + ) + .unwrap(); + Self { + root, + home, + project, + } + } + + fn command(&self, cwd: &std::path::Path, args: &[&str]) -> Output { + Command::new(bin()) + .args(args) + .current_dir(cwd) + .env("HOME", &self.home) + .env("XDG_CONFIG_HOME", self.root.join("xdg")) + .env("HERMES_HOME", self.root.join("hermes")) + .env_remove("APPDATA") + .output() + .expect("run codegraph") + } + + fn run_ok(&self, args: &[&str]) -> Output { + let output = self.command(&self.project, args); + assert!( + output.status.success(), + "command {args:?} failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn now_nanos() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() +} + +#[test] +fn install_yes_target_none_init_builds_the_current_project() { + let fixture = Fixture::new("one-shot"); + + let output = fixture.run_ok(&["install", "--yes", "--target=none", "--init"]); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(stdout.contains("No agent targets selected")); + assert!(stdout.contains("Initialized in")); + assert!(fixture.project.join(".codegraph/codegraph.db").is_file()); +} + +#[test] +fn install_yes_init_still_bootstraps_when_no_agent_was_detected() { + let fixture = Fixture::new("auto-fallback"); + + let output = fixture.run_ok(&["install", "--yes", "--init"]); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("Claude Code"), + "auto detection has no installed agent in the isolated HOME, so the existing Claude fallback should run: {stdout}" + ); + assert!(fixture.project.join(".codegraph/codegraph.db").is_file()); +} + +#[test] +fn install_init_is_idempotent_for_an_already_initialized_project() { + let fixture = Fixture::new("already-initialized"); + fixture.run_ok(&["init", "--yes"]); + + let output = fixture.run_ok(&["install", "--yes", "--target=none", "--init"]); + + assert!( + String::from_utf8_lossy(&output.stdout).contains("Already initialized"), + "the shared init flow should preserve the existing-index fast path" + ); + assert!(fixture.project.join(".codegraph/codegraph.db").is_file()); +} + +#[test] +fn install_without_init_never_creates_an_index() { + let fixture = Fixture::new("no-implicit-index"); + + fixture.run_ok(&["install", "--yes", "--target=none"]); + + assert!(!fixture.project.join(".codegraph").exists()); +} + +#[test] +fn print_config_returns_before_explicit_init() { + let fixture = Fixture::new("print-config"); + + let output = fixture.run_ok(&["install", "--print-config=codex", "--local", "--init"]); + + assert!(String::from_utf8_lossy(&output.stdout).contains("[mcp_servers.codegraph]")); + assert!(!fixture.project.join(".codegraph").exists()); + assert!(!fixture.project.join(".codex/config.toml").exists()); +} + +#[test] +fn install_init_runs_only_after_a_successful_installer() { + let fixture = Fixture::new("failed-installer"); + + let output = fixture.command( + &fixture.project, + &["install", "--yes", "--target=unknown", "--init"], + ); + + assert!(!output.status.success()); + assert!(!fixture.project.join(".codegraph").exists()); +} + +#[test] +fn init_yes_is_behavior_neutral_and_non_interactive() { + let fixture = Fixture::new("init-yes"); + + let output = fixture.run_ok(&["init", "--yes"]); + + assert!(String::from_utf8_lossy(&output.stdout).contains("Initialized in")); + assert!(fixture.project.join(".codegraph/codegraph.db").is_file()); +} + +#[test] +fn install_init_keeps_the_init_unsafe_root_guard() { + let fixture = Fixture::new("unsafe-home"); + + let output = fixture.command( + &fixture.home, + &["install", "--yes", "--target=none", "--init"], + ); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("refusing to index")); + assert!(!fixture.home.join(".codegraph").exists()); +} + +#[test] +fn help_lists_both_bootstrap_flags() { + let fixture = Fixture::new("help"); + let init = fixture.run_ok(&["init", "--help"]); + let install = fixture.run_ok(&["install", "--help"]); + + assert!(String::from_utf8_lossy(&init.stdout).contains("-y, --yes")); + assert!(String::from_utf8_lossy(&install.stdout).contains("-i, --init")); +} diff --git a/crates/codegraph-cli/tests/installer.rs b/crates/codegraph-cli/tests/installer.rs index 9c1de89e..fef6375a 100644 --- a/crates/codegraph-cli/tests/installer.rs +++ b/crates/codegraph-cli/tests/installer.rs @@ -345,11 +345,31 @@ fn print_config_does_not_write() { } #[test] -fn codex_local_is_skipped_global_only() { - let fx = Fixture::new("codex-skip"); +fn codex_local_install_and_uninstall_are_project_scoped() { + let fx = Fixture::new("codex-local"); let out = fx.run(&["install", "--target=codex", "--local", "--yes"]); - assert!(out.contains("skipped")); + assert!(out.contains("trusted")); + let local_toml = fx.project.join(".codex/config.toml"); + let local_agents = fx.project.join("AGENTS.md"); + let local_skill = fx.project.join(".agents/skills/codegraph/SKILL.md"); + assert!( + fs::read_to_string(&local_toml) + .unwrap() + .contains("[mcp_servers.codegraph]") + ); + assert!( + fs::read_to_string(&local_agents) + .unwrap() + .contains("codegraph explore") + ); + assert!(local_skill.exists()); assert!(!fx.home.join(".codex/config.toml").exists()); + + fx.run(&["install", "--target=codex", "--global", "--yes"]); + fx.run(&["uninstall", "--target=codex", "--local", "--yes"]); + assert!(!local_toml.exists()); + assert!(!local_skill.exists()); + assert!(fx.home.join(".codex/config.toml").exists()); } fn user_prompt_groups(settings: &Value) -> Vec { diff --git a/crates/codegraph-cli/tests/rmcp_serve_direct.rs b/crates/codegraph-cli/tests/rmcp_serve_direct.rs index 282c1c4a..f9c0b81e 100644 --- a/crates/codegraph-cli/tests/rmcp_serve_direct.rs +++ b/crates/codegraph-cli/tests/rmcp_serve_direct.rs @@ -13,7 +13,7 @@ //! stdin EOF removes it. #![cfg(unix)] -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -333,6 +333,20 @@ fn search( id: i64, arguments: Value, ) -> String { + let resp = search_response(stdin, stdout, deadline, id, arguments); + resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string() +} + +fn search_response( + stdin: &mut impl Write, + stdout: &mut impl BufRead, + deadline: Instant, + id: i64, + arguments: Value, +) -> Value { let call = json!({ "jsonrpc": "2.0", "id": id, @@ -341,11 +355,7 @@ fn search( }); writeln!(stdin, "{call}").unwrap(); stdin.flush().unwrap(); - let resp = read_json_line_with_id(stdout, id, deadline).expect("tools/call response"); - resp["result"]["content"][0]["text"] - .as_str() - .unwrap_or("") - .to_string() + read_json_line_with_id(stdout, id, deadline).expect("tools/call response") } /// A `--path`-pinned server WILL open a DIFFERENT project's index when a client @@ -492,6 +502,263 @@ fn serve_mcp_without_an_explicit_path_registers_without_a_project() { ); } +#[test] +fn bare_serve_adopts_the_only_indexed_workspace_child() { + // GIVEN an unindexed workspace root with exactly one indexed child. + let workspace = TestDir::new("single-subproject"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + let indexed = indexed_project_with( + &workspace, + "service-a", + Some(( + "src/unique.ts", + "export function uniquechildmarker(): number {\n return 11;\n}\n", + )), + ); + let registry_dir = workspace.path().join("mcp-registry"); + + let mut process = Command::new(bin()) + .args(["serve", "--mcp"]) + .current_dir(workspace.path()) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_MCP_REGISTRY_DIR", ®istry_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn bare serve --mcp from workspace root"); + + let mut stdin = process.stdin.take().expect("child stdin"); + let mut stdout = BufReader::new(process.stdout.take().expect("child stdout")); + let deadline = Instant::now() + Duration::from_secs(30); + handshake( + &mut stdin, + &mut stdout, + deadline, + "rmcp-single-subproject-test", + ); + + // WHEN the client omits projectPath, THEN the unique child is the default. + let hit = search( + &mut stdin, + &mut stdout, + deadline, + 2, + json!({ "query": "uniquechildmarker" }), + ); + assert!( + hit.contains("unique.ts") && hit.contains("uniquechildmarker"), + "the only indexed child must be adopted as the default project: {hit}" + ); + + drop(stdin); + assert!( + wait_with_timeout(&mut process, Duration::from_secs(10)), + "serve --mcp must exit after stdin EOF" + ); + let mut diagnostics = String::new(); + process + .stderr + .take() + .expect("child stderr") + .read_to_string(&mut diagnostics) + .unwrap(); + assert!( + diagnostics.contains("adopted the single indexed sub-project service-a"), + "startup must explain the deterministic adoption: {diagnostics}" + ); + assert!(indexed.join(".codegraph/codegraph.db").is_file()); +} + +#[test] +fn bare_serve_reports_ambiguous_children_and_explicit_project_path_wins() { + // GIVEN an unindexed workspace root with two indexed children. + let workspace = TestDir::new("ambiguous-subprojects"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + let service_a = indexed_project_with( + &workspace, + "service-a", + Some(( + "src/alpha.ts", + "export function alphachildmarker(): number {\n return 1;\n}\n", + )), + ); + let service_b = indexed_project_with( + &workspace, + "service-b", + Some(( + "src/beta.ts", + "export function betachildmarker(): number {\n return 2;\n}\n", + )), + ); + let registry_dir = workspace.path().join("mcp-registry"); + + let mut process = Command::new(bin()) + .args(["serve", "--mcp"]) + .current_dir(workspace.path()) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_MCP_REGISTRY_DIR", ®istry_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn ambiguous bare serve --mcp"); + + let mut stdin = process.stdin.take().expect("child stdin"); + let mut stdout = BufReader::new(process.stdout.take().expect("child stdout")); + let deadline = Instant::now() + Duration::from_secs(30); + handshake( + &mut stdin, + &mut stdout, + deadline, + "rmcp-ambiguous-subproject-test", + ); + + // No projectPath means no guessing; the existing tool-result error shape + // lists candidates in deterministic order. + let ambiguous_response = search_response( + &mut stdin, + &mut stdout, + deadline, + 2, + json!({ "query": "add" }), + ); + assert!( + ambiguous_response["result"]["isError"] != json!(true), + "ambiguous defaults must use the existing success-shaped guidance result: {ambiguous_response}" + ); + let ambiguous = ambiguous_response["result"]["content"][0]["text"] + .as_str() + .unwrap_or(""); + assert!( + ambiguous.contains("service-a, service-b") && ambiguous.contains("projectPath"), + "ambiguous children must be reported rather than guessed: {ambiguous}" + ); + + // An explicit absolute projectPath bypasses the default-root ambiguity. + let explicit = search( + &mut stdin, + &mut stdout, + deadline, + 3, + json!({ + "query": "betachildmarker", + "projectPath": service_b.to_str().unwrap() + }), + ); + assert!( + explicit.contains("beta.ts") && explicit.contains("betachildmarker"), + "explicit projectPath must win over ambiguous defaults: {explicit}" + ); + assert!(service_a.join(".codegraph/codegraph.db").is_file()); + + drop(stdin); + assert!( + wait_with_timeout(&mut process, Duration::from_secs(10)), + "serve --mcp must exit after stdin EOF" + ); + let mut diagnostics = String::new(); + process + .stderr + .take() + .expect("child stderr") + .read_to_string(&mut diagnostics) + .unwrap(); + assert!( + diagnostics.contains("Indexed sub-projects found: service-a, service-b"), + "stderr must explain the ambiguity and sorted choices: {diagnostics}" + ); +} + +#[test] +fn bare_serve_does_not_scan_children_without_a_workspace_gate() { + // GIVEN an ordinary unindexed directory with one indexed child but no + // workspace manifest and no .git entry. + let directory = TestDir::new("ungated-subproject"); + let indexed = indexed_project_with( + &directory, + "hidden-child", + Some(( + "src/hidden.ts", + "export function ungatedchildmarker(): number {\n return 3;\n}\n", + )), + ); + let registry_dir = directory.path().join("mcp-registry"); + + let mut process = Command::new(bin()) + .args(["serve", "--mcp"]) + .current_dir(directory.path()) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_MCP_REGISTRY_DIR", ®istry_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn ungated bare serve --mcp"); + + let mut stdin = process.stdin.take().expect("child stdin"); + let mut stdout = BufReader::new(process.stdout.take().expect("child stdout")); + let deadline = Instant::now() + Duration::from_secs(30); + handshake( + &mut stdin, + &mut stdout, + deadline, + "rmcp-ungated-subproject-test", + ); + + let no_default = search_response( + &mut stdin, + &mut stdout, + deadline, + 2, + json!({ "query": "ungatedchildmarker" }), + ); + assert_ne!(no_default["result"]["isError"], json!(true), "{no_default}"); + let guidance = no_default["result"]["content"][0]["text"] + .as_str() + .unwrap_or(""); + assert!( + guidance.contains("No indexed project resolved") && !guidance.contains("hidden-child"), + "an ungated directory must not down-scan its child: {guidance}" + ); + + let explicit = search( + &mut stdin, + &mut stdout, + deadline, + 3, + json!({ + "query": "ungatedchildmarker", + "projectPath": indexed.to_str().unwrap() + }), + ); + assert!( + explicit.contains("hidden.ts") && explicit.contains("ungatedchildmarker"), + "the same child remains reachable when explicitly selected: {explicit}" + ); + + drop(stdin); + assert!( + wait_with_timeout(&mut process, Duration::from_secs(10)), + "serve --mcp must exit after stdin EOF" + ); + let mut diagnostics = String::new(); + process + .stderr + .take() + .expect("child stderr") + .read_to_string(&mut diagnostics) + .unwrap(); + assert!( + diagnostics.contains("no default project, live sync disabled") + && !diagnostics.contains("Indexed sub-projects found"), + "stderr must report the no-default state without claiming an ungated scan: {diagnostics}" + ); +} + /// Poll for process exit within `timeout`, returning whether it exited (killing /// it on timeout so the test process never leaks a child). fn wait_with_timeout(child: &mut std::process::Child, timeout: Duration) -> bool { diff --git a/crates/codegraph-cli/tests/skill_install.rs b/crates/codegraph-cli/tests/skill_install.rs index 520932db..a1f458cd 100644 --- a/crates/codegraph-cli/tests/skill_install.rs +++ b/crates/codegraph-cli/tests/skill_install.rs @@ -161,9 +161,8 @@ fn skill_install_all_global_writes_every_supporting_agent() { #[test] fn skill_install_codex_local_writes_into_project() { - // codex's MCP config is global-only (supports_location(Local)==false), but it - // DOES support local skills (gated on supports_skills). So a local skill - // install must WRITE under the project dir, NOT be skipped like local MCP. + // Codex supports both project-local MCP config and project-local skills. + // The skill belongs under the shared `.agents/skills` project directory. let fx = Fixture::new("codex-local"); let skill_md = fx.project.join(".agents/skills/codegraph/SKILL.md"); diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index 8a03f4df..236d70e8 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -24,4 +24,5 @@ pub mod server; pub use engine::CodeGraphEngine; pub use rmcp_handler::{serve_http, serve_stdio_rmcp}; +pub use roots::{ServerRootResolution, resolve_server_root}; pub use server::{McpServer, RunUntilAdoption, initialize_result}; diff --git a/crates/codegraph-mcp/src/rmcp_handler.rs b/crates/codegraph-mcp/src/rmcp_handler.rs index 708b0385..d7151bd5 100644 --- a/crates/codegraph-mcp/src/rmcp_handler.rs +++ b/crates/codegraph-mcp/src/rmcp_handler.rs @@ -36,7 +36,10 @@ use serde_json::{Value, json}; use crate::engine::CodeGraphEngine; use crate::instructions::server_instructions; use crate::protocol::ToolResult; -use crate::roots::{WorkspaceRoots, db_exists_for, db_path_for}; +use crate::roots::{ + ServerRootDiscovery, WorkspaceRoots, db_exists_for, db_path_for, format_subproject_candidates, + not_indexed_message, +}; use crate::schemas; const SERVER_NAME: &str = "codegraph"; @@ -102,6 +105,7 @@ fn tool_timeout() -> Option { /// handler, so it lives behind a `Mutex` for interior mutability. In pinned / /// `no_roots` mode it never changes after construction. type DefaultProject = Arc>>; +type RootDiscovery = Arc>; /// rmcp handler state: the default project / cwd used to resolve a per-call /// `projectPath`. `no_roots` mirrors the @@ -111,6 +115,7 @@ pub struct CodeGraphHandler { default_project: DefaultProject, cwd: Option, no_roots: bool, + root_discovery: Option, } impl CodeGraphHandler { @@ -119,6 +124,7 @@ impl CodeGraphHandler { default_project: Arc::new(Mutex::new(default_project)), cwd: std::env::current_dir().ok(), no_roots: true, + root_discovery: None, } } @@ -137,11 +143,7 @@ impl CodeGraphHandler { /// is the bare-`serve --mcp` / Zed-local case where `default_project` is a /// cwd-derived, possibly unindexed dir. pub fn serve_with_roots(default_project: Option, cwd: Option) -> Self { - Self { - default_project: Arc::new(Mutex::new(default_project)), - cwd, - no_roots: false, - } + Self::stdio(default_project, cwd, false) } /// Test-only constructor with an explicit cwd (mirrors @@ -149,10 +151,47 @@ impl CodeGraphHandler { /// exercised deterministically. #[doc(hidden)] pub fn new_with_cwd(default_project: Option, cwd: Option) -> Self { + Self::stdio(default_project, cwd, true) + } + + fn stdio(default_project: Option, cwd: Option, no_roots: bool) -> Self { + let search_from = default_project.clone().or_else(|| cwd.clone()); + let mut discovery = ServerRootDiscovery::new(search_from); + let mut resolved_default = default_project; + if let Some(resolution) = discovery.initial_resolution() { + if let Some(root) = resolution.root { + if resolution.via_subproject_scan { + let relative = root + .strip_prefix(&resolution.search_from) + .unwrap_or(&root) + .display(); + eprintln!( + "[CodeGraph MCP] No index at or above {}; adopted the single indexed sub-project {relative} as the default project.", + resolution.search_from.display() + ); + } + resolved_default = Some(root); + } else { + eprintln!( + "[CodeGraph MCP] No index at or above {}: no default project, live sync disabled.", + resolution.search_from.display() + ); + if !resolution.candidates.is_empty() { + eprintln!( + "[CodeGraph MCP] Indexed sub-projects found: {}. Pass `projectPath` per call, or launch with --path.", + format_subproject_candidates( + Some(&resolution.search_from), + &resolution.candidates + ) + ); + } + } + } Self { - default_project: Arc::new(Mutex::new(default_project)), + default_project: Arc::new(Mutex::new(resolved_default)), cwd, - no_roots: true, + no_roots, + root_discovery: Some(Arc::new(Mutex::new(discovery))), } } @@ -171,6 +210,54 @@ impl CodeGraphHandler { .is_some_and(|p| db_exists_for(p)) } + fn retry_default_project(&self) { + if self.has_default_codegraph() { + return; + } + let Some(discovery) = &self.root_discovery else { + return; + }; + let resolution = discovery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retry_resolution(); + let Some(resolution) = resolution else { + return; + }; + let Some(root) = resolution.root else { + return; + }; + let mut default = self + .default_project + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if default.as_ref().is_some_and(|path| db_exists_for(path)) { + return; + } + let relative = root + .strip_prefix(&resolution.search_from) + .unwrap_or(&root) + .display(); + eprintln!( + "[CodeGraph MCP] Adopted newly indexed project {relative} from {} as the default project.", + resolution.search_from.display() + ); + *default = Some(root); + } + + fn discovery_snapshot(&self) -> (Option, Vec) { + let Some(discovery) = &self.root_discovery else { + return (None, Vec::new()); + }; + let discovery = discovery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + ( + discovery.search_from().map(Path::to_path_buf), + discovery.known_candidates().to_vec(), + ) + } + /// Resolve a caller's `projectPath` through the shared /// [`crate::roots::resolve_project_arg`], so both front-ends agree on /// candidate order AND on the invalid-config vs absent-index distinction: an @@ -202,6 +289,12 @@ impl CodeGraphHandler { Some(roots_result), ); if let Some(adopted) = &adopted { + if let Some(discovery) = &self.root_discovery { + discovery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear_candidates(); + } let was = old_default .as_deref() .map_or_else(|| "none".to_string(), |p| p.display().to_string()); @@ -347,6 +440,7 @@ impl ServerHandler for CodeGraphHandler { _request: Option, _context: RequestContext, ) -> Result { + self.retry_default_project(); let defs = if self.has_default_codegraph() { schemas::visible_tool_definitions() } else { @@ -380,6 +474,9 @@ impl ServerHandler for CodeGraphHandler { .unwrap_or_else(|| json!({})); let raw_project = args.get("projectPath").and_then(Value::as_str); + if raw_project.is_none() { + self.retry_default_project(); + } let project_path = match self.resolve_project_arg(raw_project) { crate::roots::ProjectArg::Resolved(p) => p, crate::roots::ProjectArg::InvalidConfig(detail) => { @@ -389,13 +486,14 @@ impl ServerHandler for CodeGraphHandler { .into()); } crate::roots::ProjectArg::NotIndexed => { - let message = match raw_project { - Some(raw) => format!( - "No indexed project found for projectPath {raw:?}. Pass an absolute path to an indexed project, or run `codegraph init` there." - ), - None => "No indexed project resolved. Pass a `projectPath` argument, run `codegraph init` in the project, or start the server with `--path `.".to_string(), + let (search_from, candidates) = self.discovery_snapshot(); + let message = not_indexed_message(raw_project, search_from.as_deref(), &candidates); + let result = if raw_project.is_some() { + ToolResult::error(message) + } else { + ToolResult::text(message) }; - return Ok(tool_result_to_call_result(&ToolResult::error(message)).into()); + return Ok(tool_result_to_call_result(&result).into()); } }; @@ -835,6 +933,15 @@ mod handler_tests { dir } + fn placeholder_indexed_child(workspace: &TempDir, name: &str) -> PathBuf { + let child = workspace.path.join(name); + std::fs::create_dir_all(&child).unwrap(); + let db = db_path_for(&child).expect("child project resolves"); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + std::fs::write(&db, b"not a real sqlite db").unwrap(); + child + } + #[test] fn host_guard_from_env_open_when_unset_or_blank() { let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); @@ -1005,6 +1112,10 @@ mod handler_tests { let project = placeholder_indexed("http-ctor"); let handler = CodeGraphHandler::http(project.path.clone()); assert!(handler.no_roots); + assert!( + handler.root_discovery.is_none(), + "HTTP sessions never run stdio launch-root discovery" + ); assert_eq!( handler.default_project_snapshot().as_deref(), Some(project.path.as_path()) @@ -1017,6 +1128,54 @@ mod handler_tests { assert!(!handler.no_roots, "serve_with_roots enables adoption"); } + #[test] + fn rmcp_stdio_adopts_single_indexed_subproject() { + let workspace = unique_dir("stdio-single-child"); + std::fs::create_dir(workspace.path.join(".git")).unwrap(); + let child = placeholder_indexed_child(&workspace, "service-a"); + + let handler = CodeGraphHandler::serve_with_roots( + Some(workspace.path.clone()), + Some(workspace.path.clone()), + ); + + assert_eq!( + handler.default_project_snapshot().as_deref(), + Some(child.as_path()) + ); + assert!(handler.has_default_codegraph()); + } + + #[test] + fn rmcp_stdio_preserves_ambiguous_candidates_and_explicit_path_wins() { + let workspace = unique_dir("stdio-ambiguous"); + std::fs::create_dir(workspace.path.join(".git")).unwrap(); + let a = placeholder_indexed_child(&workspace, "service-a"); + let b = placeholder_indexed_child(&workspace, "service-b"); + + let handler = CodeGraphHandler::serve_with_roots( + Some(workspace.path.clone()), + Some(workspace.path.clone()), + ); + let (base, candidates) = handler.discovery_snapshot(); + + assert_eq!( + handler.default_project_snapshot().as_deref(), + Some(workspace.path.as_path()) + ); + assert_eq!( + format_subproject_candidates(base.as_deref(), &candidates), + "service-a, service-b" + ); + assert_eq!( + handler + .resolve_project_arg(Some(a.to_str().unwrap())) + .resolved(), + Some(a.as_path()) + ); + assert!(b.exists()); + } + #[test] fn has_default_codegraph_false_without_index() { let dir = unique_dir("no-index"); diff --git a/crates/codegraph-mcp/src/roots.rs b/crates/codegraph-mcp/src/roots.rs index 97020fde..32d2c634 100644 --- a/crates/codegraph-mcp/src/roots.rs +++ b/crates/codegraph-mcp/src/roots.rs @@ -1,11 +1,65 @@ //! Workspace-root discovery for clients that launch the MCP server globally. +use std::fs; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; pub const ROOTS_LIST_REQUEST_ID: &str = "codegraph-roots-list-1"; +const SUBPROJECT_SCAN_MAX_DEPTH: usize = 4; +const SUBPROJECT_SCAN_MAX_CANDIDATES: usize = 64; +const SUBPROJECT_RESCAN_TTL: Duration = Duration::from_secs(5); + +const SUBPROJECT_SCAN_SKIP: &[&str] = &[ + "node_modules", + ".git", + ".svn", + ".hg", + "dist", + "build", + "out", + "target", + "vendor", + "bin", + "obj", + ".next", + ".nuxt", + ".svelte-kit", + ".cache", + "coverage", + ".venv", + "venv", + "__pycache__", + ".turbo", + ".idea", + ".vscode", + "tmp", + "temp", +]; + +const WORKSPACE_ROOT_MANIFESTS: &[&str] = &[ + "package.json", + "pnpm-workspace.yaml", + "lerna.json", + "nx.json", + "turbo.json", + "go.work", + "go.mod", + "Cargo.toml", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "pyproject.toml", + "composer.json", + "Gemfile", + "rush.json", + "WORKSPACE", + "WORKSPACE.bazel", +]; + /// Pure formatter for the per-tool `projectPath` resolution debug line /// (unit-tested without touching process state). pub fn format_tool_debug_line( @@ -90,6 +144,245 @@ pub fn probe_root(project_path: &Path) -> RootStatus { )) } +/// One stdio server-root resolution. The common path is the upward walk. When +/// that misses and the launch directory is a plausible workspace root, the +/// bounded downward scan may adopt exactly one indexed child project. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerRootResolution { + pub search_from: PathBuf, + pub root: Option, + pub via_subproject_scan: bool, + pub candidates: Vec, +} + +/// Resolve the default project for a stdio MCP server. +/// +/// The downward scan is deliberately caller-controlled so long-running +/// no-default sessions can run the cheap upward walk on every request while +/// throttling the bounded workspace scan to once per five seconds. +#[must_use] +pub fn resolve_server_root( + search_from: impl AsRef, + scan_subprojects: bool, +) -> ServerRootResolution { + let search_from = absolute_search_path(search_from.as_ref()); + if let Some(root) = find_nearest_indexed_root(&search_from) { + return ServerRootResolution { + search_from, + root: Some(root), + via_subproject_scan: false, + candidates: Vec::new(), + }; + } + if !scan_subprojects || !eligible_for_subproject_scan(&search_from) { + return ServerRootResolution { + search_from, + root: None, + via_subproject_scan: false, + candidates: Vec::new(), + }; + } + + let candidates = find_indexed_subproject_roots( + &search_from, + SUBPROJECT_SCAN_MAX_DEPTH, + SUBPROJECT_SCAN_MAX_CANDIDATES, + ); + let root = (candidates.len() == 1).then(|| candidates[0].clone()); + ServerRootResolution { + search_from, + root, + via_subproject_scan: candidates.len() == 1, + candidates, + } +} + +/// Stateful wrapper shared by both stdio front-ends. Initial construction scans +/// immediately; retries always perform the cheap upward walk and run the +/// downward scan no more than once per five seconds. +#[derive(Debug)] +pub(crate) struct ServerRootDiscovery { + search_from: Option, + known_candidates: Vec, + last_subproject_scan: Option, +} + +impl ServerRootDiscovery { + pub(crate) fn new(search_from: Option) -> Self { + Self { + search_from, + known_candidates: Vec::new(), + last_subproject_scan: None, + } + } + + pub(crate) fn initial_resolution(&mut self) -> Option { + self.resolve_at(Instant::now(), true) + } + + pub(crate) fn retry_resolution(&mut self) -> Option { + self.resolve_at(Instant::now(), false) + } + + fn resolve_at( + &mut self, + now: Instant, + force_subproject_scan: bool, + ) -> Option { + let search_from = self.search_from.as_ref()?; + let scan_due = force_subproject_scan + || self.last_subproject_scan.is_none_or(|last| { + now.checked_duration_since(last) + .is_some_and(|elapsed| elapsed >= SUBPROJECT_RESCAN_TTL) + }); + let resolution = resolve_server_root(search_from, scan_due); + if scan_due { + self.last_subproject_scan = Some(now); + } + if resolution.root.is_some() { + self.known_candidates.clear(); + } else if scan_due { + self.known_candidates.clone_from(&resolution.candidates); + } + Some(resolution) + } + + pub(crate) fn search_from(&self) -> Option<&Path> { + self.search_from.as_deref() + } + + pub(crate) fn known_candidates(&self) -> &[PathBuf] { + &self.known_candidates + } + + pub(crate) fn clear_candidates(&mut self) { + self.known_candidates.clear(); + } +} + +fn absolute_search_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } +} + +fn find_nearest_indexed_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if db_exists_for(¤t) { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + +fn eligible_for_subproject_scan(base: &Path) -> bool { + if base.parent().is_none() { + return false; + } + if let Some(home) = home_dir() + && canonicalize_lenient(base) == canonicalize_lenient(&home) + { + return false; + } + WORKSPACE_ROOT_MANIFESTS + .iter() + .any(|manifest| base.join(manifest).exists()) + || base.join(".git").exists() +} + +fn find_indexed_subproject_roots( + root: &Path, + max_depth: usize, + max_candidates: usize, +) -> Vec { + fn walk( + dir: &Path, + depth: usize, + max_depth: usize, + max_candidates: usize, + out: &mut Vec, + ) { + if out.len() >= max_candidates || depth > max_depth { + return; + } + let Ok(read_dir) = fs::read_dir(dir) else { + return; + }; + let mut entries = read_dir.filter_map(Result::ok).collect::>(); + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + if out.len() >= max_candidates { + return; + } + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with('.') || SUBPROJECT_SCAN_SKIP.contains(&name.as_ref()) { + continue; + } + let child = entry.path(); + if db_exists_for(&child) { + out.push(child); + continue; + } + walk(&child, depth + 1, max_depth, max_candidates, out); + } + } + + let mut out = Vec::new(); + walk(root, 1, max_depth, max_candidates, &mut out); + out.sort(); + out.dedup(); + out +} + +pub(crate) fn format_subproject_candidates(base: Option<&Path>, roots: &[PathBuf]) -> String { + let mut display = roots + .iter() + .map(|root| { + base.and_then(|base| root.strip_prefix(base).ok()) + .filter(|relative| !relative.as_os_str().is_empty()) + .unwrap_or(root) + .display() + .to_string() + }) + .collect::>(); + display.sort(); + display.dedup(); + display.join(", ") +} + +pub(crate) fn not_indexed_message( + raw_project: Option<&str>, + search_from: Option<&Path>, + candidates: &[PathBuf], +) -> String { + if let Some(raw) = raw_project { + return format!( + "No indexed project found for projectPath {raw:?}. Pass an absolute path to an indexed project, or run `codegraph init` there." + ); + } + if candidates.is_empty() { + return "No indexed project resolved. Pass a `projectPath` argument, run `codegraph init` in the project, or start the server with `--path `.".to_string(); + } + let listed = format_subproject_candidates(search_from, candidates); + format!( + "No indexed project resolved. Indexed sub-projects were found below the server root: {listed}. Pass one as `projectPath`, or launch the server with `--path `." + ) +} + /// Pure classifier for an [`codegraph_core::IndexPaths::resolve`] outcome, split /// out so the invalid/absent decision is unit-tested WITHOUT mutating the /// process-global `CODEGRAPH_DIR`. @@ -1003,4 +1296,136 @@ mod tests { assert!(msg.contains("CODEGRAPH_DIR"), "{msg}"); assert!(msg.contains("unsafe"), "{msg}"); } + + fn mark_indexed(path: &Path) { + std::fs::create_dir_all(path).unwrap(); + let db = db_path_for(path).expect("indexed fixture path resolves"); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + std::fs::write(db, b"placeholder").unwrap(); + } + + #[test] + fn server_root_adopts_exactly_one_indexed_child_of_git_workspace() { + let workspace = unindexed_dir("subscan-one"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + let child = workspace.path().join("service-a"); + mark_indexed(&child); + + let resolved = resolve_server_root(workspace.path(), true); + + assert_eq!(resolved.root.as_deref(), Some(child.as_path())); + assert!(resolved.via_subproject_scan); + assert_eq!(resolved.candidates, vec![child]); + } + + #[test] + fn server_root_lists_multiple_children_in_deterministic_order_without_guessing() { + let workspace = unindexed_dir("subscan-many"); + std::fs::write(workspace.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let b = workspace.path().join("service-b"); + let a = workspace.path().join("service-a"); + mark_indexed(&b); + mark_indexed(&a); + + let resolved = resolve_server_root(workspace.path(), true); + + assert_eq!(resolved.root, None); + assert_eq!(resolved.candidates, vec![a, b]); + assert_eq!( + format_subproject_candidates(Some(workspace.path()), resolved.candidates.as_slice()), + "service-a, service-b" + ); + } + + #[test] + fn server_root_does_not_scan_a_non_workspace_or_filesystem_root() { + let workspace = unindexed_dir("subscan-gate"); + let child = workspace.path().join("service-a"); + mark_indexed(&child); + + let no_manifest = resolve_server_root(workspace.path(), true); + assert_eq!(no_manifest.root, None); + assert!(no_manifest.candidates.is_empty()); + + let filesystem_root = Path::new(if cfg!(windows) { r"C:\" } else { "/" }); + assert!(!eligible_for_subproject_scan(filesystem_root)); + if let Some(home) = home_dir() { + assert!( + !eligible_for_subproject_scan(&home), + "the home directory is never a downward-scan root" + ); + } + } + + #[test] + fn subproject_scan_honors_depth_skip_and_stop_descent_boundaries() { + let workspace = unindexed_dir("subscan-bounds"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + + let depth_four = workspace.path().join("a/b/c/d"); + mark_indexed(&depth_four); + mark_indexed(&workspace.path().join("x/y/z/u/v")); + mark_indexed(&workspace.path().join("vendor/hidden")); + mark_indexed(&workspace.path().join(".hidden/child")); + mark_indexed(&depth_four.join("nested-index")); + + let resolved = resolve_server_root(workspace.path(), true); + + assert_eq!(resolved.root.as_deref(), Some(depth_four.as_path())); + assert_eq!(resolved.candidates, vec![depth_four]); + } + + #[test] + fn subproject_scan_caps_candidates_at_sixty_four() { + let workspace = unindexed_dir("subscan-cap"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + for index in 0..70 { + mark_indexed(&workspace.path().join(format!("service-{index:02}"))); + } + + let candidates = + find_indexed_subproject_roots(workspace.path(), 4, SUBPROJECT_SCAN_MAX_CANDIDATES); + + assert_eq!(candidates.len(), SUBPROJECT_SCAN_MAX_CANDIDATES); + assert!(candidates[0].ends_with("service-00")); + assert!(candidates[63].ends_with("service-63")); + } + + #[test] + fn discovery_retries_upward_immediately_but_throttles_child_scan_for_five_seconds() { + let workspace = unindexed_dir("subscan-retry"); + std::fs::create_dir(workspace.path().join(".git")).unwrap(); + let started = Instant::now(); + let mut discovery = ServerRootDiscovery::new(Some(workspace.path().to_path_buf())); + + let first = discovery + .resolve_at(started, true) + .expect("search path configured"); + assert_eq!(first.root, None); + + let child = workspace.path().join("later"); + mark_indexed(&child); + let too_soon = discovery + .resolve_at(started + Duration::from_secs(4), false) + .expect("search path configured"); + assert_eq!(too_soon.root, None); + + let due = discovery + .resolve_at(started + Duration::from_secs(5), false) + .expect("search path configured"); + assert_eq!(due.root.as_deref(), Some(child.as_path())); + } + + #[test] + fn ambiguous_subprojects_are_named_in_the_existing_tool_error_shape() { + let base = Path::new("/workspace"); + let message = not_indexed_message( + None, + Some(base), + &[base.join("service-b"), base.join("service-a")], + ); + assert!(message.starts_with("No indexed project resolved.")); + assert!(message.contains("service-a, service-b")); + assert!(message.contains("projectPath")); + } } diff --git a/crates/codegraph-mcp/src/server.rs b/crates/codegraph-mcp/src/server.rs index 6fe297b2..d230f3b1 100644 --- a/crates/codegraph-mcp/src/server.rs +++ b/crates/codegraph-mcp/src/server.rs @@ -19,8 +19,8 @@ use crate::engine::CodeGraphEngine; use crate::instructions::server_instructions; use crate::protocol::{JsonRpcRequest, JsonRpcResponse, ToolResult, error_codes}; use crate::roots::{ - ROOTS_LIST_REQUEST_ID, WorkspaceRoots, db_exists_for, db_path_for, format_tool_debug_line, - roots_list_request, + ROOTS_LIST_REQUEST_ID, ServerRootDiscovery, WorkspaceRoots, db_exists_for, db_path_for, + format_subproject_candidates, format_tool_debug_line, not_indexed_message, roots_list_request, }; use crate::schemas; @@ -194,16 +194,60 @@ pub struct McpServer { cwd: Option, observed_identities: HashMap, workspace_roots: WorkspaceRoots, + root_discovery: ServerRootDiscovery, no_roots: bool, } impl McpServer { pub fn new(default_project: Option) -> Self { + Self::with_cwd(default_project, std::env::current_dir().ok(), false) + } + + fn with_cwd( + default_project: Option, + cwd: Option, + use_cwd_for_discovery: bool, + ) -> Self { + let search_from = default_project + .clone() + .or_else(|| use_cwd_for_discovery.then(|| cwd.clone()).flatten()); + let mut root_discovery = ServerRootDiscovery::new(search_from); + let mut resolved_default = default_project; + if let Some(resolution) = root_discovery.initial_resolution() { + if let Some(root) = resolution.root { + if resolution.via_subproject_scan { + let relative = root + .strip_prefix(&resolution.search_from) + .unwrap_or(&root) + .display(); + eprintln!( + "[CodeGraph MCP] No index at or above {}; adopted the single indexed sub-project {relative} as the default project.", + resolution.search_from.display() + ); + } + resolved_default = Some(root); + } else { + eprintln!( + "[CodeGraph MCP] No index at or above {}: no default project, live sync disabled.", + resolution.search_from.display() + ); + if !resolution.candidates.is_empty() { + eprintln!( + "[CodeGraph MCP] Indexed sub-projects found: {}. Pass `projectPath` per call, or launch with --path.", + format_subproject_candidates( + Some(&resolution.search_from), + &resolution.candidates + ) + ); + } + } + } Self { - default_project, - cwd: std::env::current_dir().ok(), + default_project: resolved_default, + cwd, observed_identities: HashMap::new(), workspace_roots: WorkspaceRoots::new(), + root_discovery, no_roots: false, } } @@ -220,6 +264,24 @@ impl McpServer { db_exists_for(project) } + fn retry_default_project(&mut self) -> Option { + if self.has_default_codegraph() { + return None; + } + let resolution = self.root_discovery.retry_resolution()?; + let root = resolution.root?; + let relative = root + .strip_prefix(&resolution.search_from) + .unwrap_or(&root) + .display(); + eprintln!( + "[CodeGraph MCP] Adopted newly indexed project {relative} from {} as the default project.", + resolution.search_from.display() + ); + self.default_project = Some(root.clone()); + Some(root) + } + /// Run the stdio loop until the broad-root daemon handoff adopts a project /// (or EOF). Reads `reader` line-by-line, writes one response line per /// request to `writer`. Notifications (no `id`) produce no output @@ -333,6 +395,7 @@ impl McpServer { cwd.as_deref(), req.params.as_ref(), ) { + self.root_discovery.clear_candidates(); self.debug_roots_adopted(&adopted, old_default.as_deref()); return Dispatch::ReplyWithAdoption { reply: initialize_result(), @@ -355,20 +418,26 @@ impl McpServer { } "initialized" => Dispatch::Notification, "notifications/initialized" => Dispatch::Notification, - "tools/list" if is_request => Dispatch::Reply(json!({ - // tools/list ALWAYS exposes the visible tool surface (#94 / - // colby #964, PR#966). When a default project is resolved, - // projectPath stays OPTIONAL (byte-identical to the golden). - // When none is resolved (roots-less client, unindexed cwd), - // the SAME tools are listed with projectPath marked required - // (#993, PR#1007) so the agent supplies it per call. This - // reverses the c450fd95 "empty list when unindexed" behavior. - "tools": if self.has_default_codegraph() { - schemas::visible_tool_definitions() - } else { - schemas::visible_tool_definitions_requiring_project_path() - } - })), + "tools/list" if is_request => { + let adopted = self.retry_default_project(); + let reply = json!({ + // tools/list ALWAYS exposes the visible tool surface (#94 / + // colby #964, PR#966). When a default project is resolved, + // projectPath stays OPTIONAL (byte-identical to the golden). + // When none is resolved (roots-less client, unindexed cwd), + // the SAME tools are listed with projectPath marked required + // (#993, PR#1007) so the agent supplies it per call. This + // reverses the c450fd95 "empty list when unindexed" behavior. + "tools": if self.has_default_codegraph() { + schemas::visible_tool_definitions() + } else { + schemas::visible_tool_definitions_requiring_project_path() + } + }); + adopted.map_or(Dispatch::Reply(reply.clone()), |adopted| { + Dispatch::ReplyWithAdoption { reply, adopted } + }) + } "tools/call" if is_request => self.handle_tools_call(req), "ping" if is_request => Dispatch::Reply(json!({})), "resources/list" if is_request => Dispatch::Reply(json!({ "resources": [] })), @@ -397,6 +466,7 @@ impl McpServer { cwd.as_deref(), response.get("result"), ) { + self.root_discovery.clear_candidates(); self.debug_roots_adopted(&adopted, old_default.as_deref()); return Some(adopted); } @@ -434,6 +504,10 @@ impl McpServer { .unwrap_or_else(|| json!({})); let raw_project = args.get("projectPath").and_then(Value::as_str); + let adopted = raw_project + .is_none() + .then(|| self.retry_default_project()) + .flatten(); let resolved = self.resolve_project_arg(raw_project); tracing::debug!( "{}", @@ -456,15 +530,18 @@ impl McpServer { ); } crate::roots::ProjectArg::NotIndexed => { - let message = match raw_project { - Some(raw) => format!( - "No indexed project found for projectPath {raw:?}. Pass an absolute path to an indexed project, or run `codegraph init` there." - ), - None => "No indexed project resolved. Pass a `projectPath` argument, run `codegraph init` in the project, or start the server with `--path `.".to_string(), + let message = not_indexed_message( + raw_project, + self.root_discovery.search_from(), + self.root_discovery.known_candidates(), + ); + let result = if raw_project.is_some() { + ToolResult::error(message) + } else { + ToolResult::text(message) }; return Dispatch::Reply( - serde_json::to_value(ToolResult::error(message)) - .expect("ToolResult serializes"), + serde_json::to_value(result).expect("ToolResult serializes"), ); } }; @@ -483,7 +560,10 @@ impl McpServer { }; let result = engine.execute(&tool_name, &args); - Dispatch::Reply(serde_json::to_value(result).expect("ToolResult serializes")) + let reply = serde_json::to_value(result).expect("ToolResult serializes"); + adopted.map_or(Dispatch::Reply(reply.clone()), |adopted| { + Dispatch::ReplyWithAdoption { reply, adopted } + }) } /// Resolve a caller's `projectPath` argument to an INDEXED project dir (one @@ -561,13 +641,7 @@ impl McpServer { /// mutating the process-global working directory. #[doc(hidden)] pub fn new_with_cwd(default_project: Option, cwd: Option) -> Self { - Self { - default_project, - cwd, - observed_identities: HashMap::new(), - workspace_roots: WorkspaceRoots::new(), - no_roots: false, - } + Self::with_cwd(default_project, cwd, true) } } @@ -655,6 +729,25 @@ mod tests { TempProject { path } } + fn workspace(tag: &str) -> TempProject { + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "cg-mcp-workspace-{tag}-{}-{seq}", + std::process::id() + )); + std::fs::create_dir_all(path.join(".git")).unwrap(); + TempProject { path } + } + + fn indexed_child(workspace: &TempProject, name: &str) -> PathBuf { + let child = workspace.path().join(name); + std::fs::create_dir_all(&child).unwrap(); + let db = db_path_for(&child).expect("child project resolves"); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + std::fs::write(db, b"placeholder").unwrap(); + child + } + /// Drive one `initialize` frame through the retained `run_until_adoption` /// loop and capture every response line. `initialize` never adopts (adoption /// only fires on a roots/list RESPONSE), so the loop runs to EOF. @@ -697,6 +790,46 @@ mod tests { .collect() } + #[test] + fn handwritten_stdio_adopts_single_indexed_subproject() { + let workspace = workspace("single-child"); + let child = indexed_child(&workspace, "service-a"); + + let server = McpServer::new_with_cwd( + Some(workspace.path().to_path_buf()), + Some(workspace.path().to_path_buf()), + ); + + assert_eq!(server.default_project(), Some(child.as_path())); + } + + #[test] + fn handwritten_stdio_keeps_ambiguous_children_explicit() { + let workspace = workspace("ambiguous"); + let a = indexed_child(&workspace, "service-a"); + let b = indexed_child(&workspace, "service-b"); + let server = McpServer::new_with_cwd( + Some(workspace.path().to_path_buf()), + Some(workspace.path().to_path_buf()), + ); + + assert_eq!(server.default_project(), Some(workspace.path())); + assert_eq!( + server + .resolve_project_arg(Some(a.to_str().unwrap())) + .resolved(), + Some(a.as_path()) + ); + assert_eq!( + format_subproject_candidates( + server.root_discovery.search_from(), + server.root_discovery.known_candidates() + ), + "service-a, service-b" + ); + assert!(b.exists()); + } + #[test] fn initialize_requests_roots_when_client_supports_roots_and_default_is_absent() { let mut server = McpServer::new(None); @@ -1057,7 +1190,7 @@ mod tests { } #[test] - fn tools_call_unresolved_project_returns_tool_error_result() { + fn tools_call_without_a_default_returns_success_text() { let mut server = McpServer::new(None); let out = one_line( &mut server, @@ -1072,7 +1205,10 @@ mod tests { out[0]["error"].is_null(), "must be a Reply, not a JSON-RPC error" ); - assert_eq!(out[0]["result"]["isError"], json!(true)); + assert!( + out[0]["result"]["isError"].is_null(), + "an absent default is guidance, not a failed explicit project selection" + ); let text = out[0]["result"]["content"][0]["text"].as_str().unwrap(); assert!( text.contains("No indexed project resolved"), diff --git a/crates/codegraph-mcp/tests/rmcp_roots.rs b/crates/codegraph-mcp/tests/rmcp_roots.rs index bb714eda..fb199e55 100644 --- a/crates/codegraph-mcp/tests/rmcp_roots.rs +++ b/crates/codegraph-mcp/tests/rmcp_roots.rs @@ -231,11 +231,16 @@ fn rmcp_does_not_adopt_unindexed_client_root() { .and_then(|c| c.as_text().map(|t| t.text.clone())) .unwrap_or_default(); - // No adoption ⇒ the unindexed default resolves nothing ⇒ the no-project - // error message (isError result), NOT a wrong project's results. + // No adoption ⇒ the unindexed default resolves nothing ⇒ the + // success-shaped no-project guidance, NOT a wrong project's results. + assert_ne!( + result.is_error, + Some(true), + "an absent default is guidance rather than an explicit-path error" + ); assert!( text.contains("No indexed project"), - "unindexed client root must NOT be adopted; expected no-project error, got: {text}" + "unindexed client root must NOT be adopted; expected no-project guidance, got: {text}" ); let _ = client.cancel().await; @@ -245,7 +250,8 @@ fn rmcp_does_not_adopt_unindexed_client_root() { /// GUARD: an HTTP/`no_roots` handler never requests roots. Even a roots-capable /// client that reports an INDEXED root leaves the unindexed HTTP pin unadopted, -/// so a tool call with NO projectPath errors (parity with the pinned no_roots path). +/// so a tool call with NO projectPath returns no-project guidance (parity with +/// the pinned no_roots path). #[test] fn rmcp_http_no_roots_never_adopts() { rt().block_on(async { @@ -284,6 +290,11 @@ fn rmcp_http_no_roots_never_adopts() { .and_then(|c| c.as_text().map(|t| t.text.clone())) .unwrap_or_default(); + assert_ne!( + result.is_error, + Some(true), + "an absent default is guidance, not an explicit-path failure" + ); assert!( text.contains("No indexed project"), "http no_roots mode must NOT adopt the client's indexed root: {text}" diff --git a/docs/cli.md b/docs/cli.md index 8c89a0c2..980a2f3c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,14 +16,14 @@ | Subcommand | Purpose | Key flags | | ----------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `install` | Write the codegraph MCP server into each AI agent's config | `-t/--target`, `-l/--location`, `--global`, `--local`, `-y/--yes`, `--no-permissions`, `--print-config `, `--prompt-hook` | +| `install` | Write the codegraph MCP server into each AI agent's config | `-t/--target`, `-l/--location`, `--global`, `--local`, `-y/--yes`, `-i/--init`, `--no-permissions`, `--print-config `, `--prompt-hook` | | `uninstall` | Remove codegraph from agent configs (inverse of `install`) | `-t/--target`, `-l/--location`, `--global`, `--local`, `-y/--yes` | | `skill` | Install / update / uninstall / check the embedded agent skill | `` (install, update, uninstall, status) | | `skill install` | Write the embedded SKILL.md into each agent's skill directory | `-t/--target`, `--global`, `--local`, `-y/--yes` | | `skill update` | Refresh the installed skill and marker-managed agent instructions | `-t/--target`, `--global`, `--local`, `--force`, `--diff`, `--dry-run` | | `skill uninstall` | Remove the skill from agent skill directories | `-t/--target`, `--global`, `--local`, `-y/--yes` | | `skill status` | Report install state per agent (up to date / locally modified / outdated / not installed) | `-t/--target`, `--global`, `--local` | -| `init` | Initialize `.codegraph/` and run the first full index | `[path]`, `-t/--target` (also write project-level MCP config; default `none`) | +| `init` | Initialize `.codegraph/` and run the first full index | `[path]`, `-t/--target` (also write project-level MCP config; default `none`), `-y/--yes` | | `uninit` | Delete the project's `.codegraph/` index | `[path]`, `-f/--force` | | `index` | (Re-)index in full | `[path]`, `-f/--force`, `-q/--quiet`, `-v/--verbose` | | `sync` | Incremental sync: re-index only changed files, drop deleted ones, re-resolve | `[path]`, `-q/--quiet` | @@ -119,8 +119,10 @@ install). ```bash codegraph install --yes # auto-detect installed agents, global +codegraph install --yes --init # install, then initialize cwd codegraph install --target=claude,cursor --yes # explicit list codegraph install --target=auto --local # detected agents, project-local +codegraph install --target=codex --local --yes # project .codex/config.toml + AGENTS.md + skill codegraph install --print-config cursor # print the snippet only, no write codegraph install --prompt-hook # also add the Claude UserPromptSubmit hook (opt-in) codegraph uninstall --target=claude --local # remove one agent's local config @@ -130,6 +132,24 @@ Behavior is idempotent (upsert by the `codegraph` key). `uninstall` removes only codegraph's own entry and leaves other MCP servers intact. Instruction files are delimited by ``/`` markers. +**One-shot bootstrap.** `install -i/--init` runs the normal `init` flow in the +current directory only after the installer succeeds. It still runs when no +target is selected (`--target=none`), but never runs for `--print-config`. +Without `--init`, install never creates or changes an index. `init -y/--yes` is +the non-interactive compatibility flag; the Rust init flow currently has no +confirmation prompt, so it is behavior-neutral. Therefore +`codegraph install --yes --init` is safe for unattended setup while retaining +all ordinary broad-root and existing-index guards. + +**Codex CLI.** Global install remains `~/.codex/config.toml` plus +`~/.codex/AGENTS.md`. Local install writes `/.codex/config.toml`, +the project-root `/AGENTS.md`, and +`/.agents/skills/codegraph`. Detection, `--print-config`, install, and +uninstall all honor the selected location. A local uninstall never edits the +global files. Codex loads the project layer only after that repository is +marked trusted; the installer prints this requirement instead of silently +claiming the local entry is active. + **Zuno.** `--target=zuno` supports both locations. Global files are `$XDG_CONFIG_HOME/zuno/zuno.json[c]` and `$XDG_CONFIG_HOME/zuno/AGENTS.md` (normally under `~/.config/zuno/`); @@ -176,6 +196,7 @@ the `[path]` argument differs from the current directory. It is idempotent. ```bash codegraph init # index only — no MCP config written (default none) +codegraph init --yes # same init flow, explicit non-interactive entry point codegraph init --target=kiro # index, then write this project's .kiro/settings/mcp.json with --path codegraph init . --target=kiro,cursor # index + wire both editors project-level codegraph init /path/to/proj -t auto # index that project + wire detected editors there @@ -818,6 +839,30 @@ exclusive lease succeeds. A live or contended owner is never folded underneath. ## Daemon, watch & environment variables +### How stdio chooses the default project + +Before choosing Direct/daemon mode, `serve --mcp` resolves one default root: + +1. walk upward from `--path` (or cwd) to the nearest usable index; +2. when that misses, scan downward only if the launch directory contains a + workspace manifest or `.git`; +3. adopt the child only when exactly one indexed project is found. + +The downward scan is deterministic and bounded: depth 4, at most 64 candidates, +sorted directory traversal, no descent below an indexed child, and no +`node_modules`, VCS metadata, build output, vendor, virtualenv, cache, or temp +directories. It never runs from `$HOME` or a filesystem root. An adopted child +enters the ordinary daemon/watcher/catch-up path, so the daemon socket and watch +scope are keyed to the child rather than the unindexed workspace container. + +Zero or multiple candidates remain unresolved. stderr reports the no-default +state and lists sorted candidates when present; tool calls without +`projectPath` return success-shaped guidance rather than guessing. An explicit +`projectPath` always bypasses the ambiguity. While no default exists, the +upward probe runs on each relevant request and the bounded child scan runs at +most once every five seconds, allowing an index created after startup to be +adopted. + ### How `serve --mcp` chooses a run mode The launcher selects a mode in this exact order: diff --git a/docs/mcp.md b/docs/mcp.md index c5d04bee..25444c6b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,12 +73,12 @@ it automatically: **Default (no `-p`):** `tools/list` always returns the full tool surface, even before a project is resolved. When the server resolves a default project — the -working directory is at or inside an indexed project (find-up), or the client -sends `rootUri`/`workspaceFolders`/`roots` — all tools work with `projectPath` -optional. When it cannot resolve one (a roots-less client launched from a fixed -directory that is not inside any project, e.g. a shared global config using the -home directory as cwd), tools are still listed but `projectPath` is marked -required in each tool's schema; the agent must then pass it per call. See +working directory is at or inside an indexed project (find-up), an unindexed +workspace root contains exactly one indexed child, or the client sends +`rootUri`/`workspaceFolders`/`roots` — all tools work with `projectPath` +optional. When it cannot resolve one, tools are still listed but `projectPath` +is marked required in each tool's schema; the agent must then pass it per call. +Multiple indexed children are listed and never guessed. See [Project resolution](#project-resolution) for the full three-case breakdown. **Optional `-p ` / `--path `:** pin the server to one fixed project regardless of cwd (e.g. @@ -90,6 +90,24 @@ Gemini CLI, Antigravity IDE, Kiro, Trae, Qoder, Zed, Zuno, VS Code --- +## Codex CLI — global and project-local config + +Codex accepts the same TOML entry at two locations: + +- global: `~/.codex/config.toml`, with managed instructions in + `~/.codex/AGENTS.md`; +- local: `/.codex/config.toml`, with managed instructions in the + project-root `AGENTS.md` and the Skill in `.agents/skills/codegraph`. + +Run `codegraph install --target=codex --local --yes` from the project root for +the local form. `--print-config=codex --local` prints the project snippet +without writing. Local uninstall removes only local CodeGraph markers/files and +does not touch the global entry. Codex disables project-layer configuration for +untrusted repositories, so mark the project trusted in Codex before expecting +the local MCP server to appear. + +--- + ## Zuno — `mcp` config Zuno uses an `mcp.` wrapper and a string-array command: @@ -423,6 +441,16 @@ do not support roots, pin the root via `--path ` in the client's MCP config args (e.g. a workspace-level `.kiro/settings/mcp.json`), or open the project folder as the working directory. +For a normal unindexed workspace container, stdio startup has one additional +safe path before entering no-default mode: if the launch directory has a +workspace manifest or `.git`, CodeGraph scans downward to depth 4, inspects at +most 64 indexed candidates, skips dot/heavy/build/vendor/venv/cache/temp +directories, and stops below an indexed child. Exactly one candidate is adopted +and gets the full daemon/watcher/catch-up lifecycle. Zero or multiple candidates +are never guessed. This scan is forbidden at `$HOME` and filesystem roots. +Streamable HTTP global/no-path mode remains request-scoped and does not adopt a +cwd child. + --- ## Project resolution @@ -438,20 +466,25 @@ whether a default project was resolved is which tool parameters are required: agent knows to supply it per call. You can also pin a single project with `-p`/`--path` instead. -The server resolves a default project by walking three sources in order: +The stdio server resolves a default project from these sources: 1. **`--path` flag** — explicit pin; always wins. 2. **find-up from cwd** — ascends from the working directory to the nearest `.codegraph/` index root. A cwd at or inside an indexed project resolves it here, and `projectPath` is optional. -3. **MCP `initialize` handshake** — if find-up yields nothing, the server reads +3. **bounded workspace child scan** — if find-up yields nothing and the launch + directory contains `.git` or a workspace manifest, scan at most four levels + and 64 candidates. Exactly one indexed child is adopted; multiple candidates + are sorted and reported, never selected. HOME and filesystem roots are not + scanned. +4. **MCP `initialize` handshake** — if startup resolution yields nothing, the server reads the `initialize` message sent by the client and adopts the workspace it advertises (`rootUri`, `rootPath`, or `workspaceFolders[0].uri`) — provided that path is already indexed. If the client does not include those fields but advertises `capabilities.roots`, the server sends a `roots/list` request and adopts the first indexed root from the response. -If all three sources yield nothing, the server serves the full tool list with +If all sources yield nothing, the server serves the full tool list with `projectPath` marked required. This is the case for roots-less clients that use a fixed launch directory not inside any project — for example, 通义灵码/Lingma configured with a single global MCP entry whose working directory is the home @@ -459,6 +492,15 @@ directory. In that scenario the tools are listed and the agent can still call them by passing an explicit `projectPath`; for single-project setups, pinning `-p /path/to/project` in the MCP config args is the simpler alternative. +With no default, a tool call that omits `projectPath` returns normal +success-shaped text explaining the remedies. If the child scan found several +indexes, that text and stderr list the sorted candidates. A caller-supplied +absolute `projectPath` is authoritative and bypasses the ambiguity; an explicit +path that does not resolve remains an `isError` tool result. The cheap upward +probe runs again on relevant requests, while the downward scan is throttled to +once per five seconds so a project initialized after server startup can be +adopted without walking the workspace on every call. + > **Note:** the home-directory / filesystem-root guard (see [Daemon & live watch] > above) also skips the normal watcher and catch-up sync for those paths. A real > project nested under `$HOME` (e.g. `~/projects/myapp`) is unaffected — it is diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 61bc1126..e881a97b 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -54,6 +54,7 @@ irm https://raw.githubusercontent.com/sunerpy/codegraph-rust/main/scripts/instal codegraph init /path/to/project # 创建 .codegraph/ 并执行首次索引 codegraph search "" -p /path/to/project # 全文符号检索(`query` 为兼容别名) codegraph serve --mcp --path /path/to/project # 为 AI 代理启动 MCP 服务器(--path 可选,默认 cwd) +codegraph install --yes --init # 接线已检测代理并初始化 cwd ``` --- @@ -104,6 +105,7 @@ Copilot CLI / JetBrains): ```bash codegraph install --yes # 检测已安装的代理并接线 +codegraph install --yes --init # 无人值守的一次性安装 + cwd 建索引 ``` **可调用的 MCP 工具**(对已索引源码优先用这些而非 grep/read): @@ -181,9 +183,12 @@ cargo install --git https://github.com/sunerpy/codegraph-rust codegraph-rs } ``` -**默认(不带 `-p`):** 一份配置即可覆盖所有项目——每个项目只需提前用 -`codegraph index` 建立索引。当服务器无法从 cwd 或 MCP 握手中解析项目时,每次 -工具调用需显式传入 `projectPath`。**可选 `-p ` / `--path `:** +**默认(不带 `-p`):** 一份配置即可覆盖所有项目。stdio 服务先向上查找索引; +若启动目录本身是 workspace 根(含 `.git` 或 workspace manifest),还会在确定性 +边界内自动采用唯一一个已索引子项目。多个候选绝不猜测,而会在 stderr/工具提示中 +按序列出;此时每次调用传 `projectPath`,或用 `--path` 固定项目。MCP 握手中的 +`rootUri`/`workspaceFolders`/`roots` 仍可用于采用项目。**可选 `-p ` / +`--path `:** 固定指向单个项目(例如 `"args": ["serve", "--mcp", "-p", "/abs/path/to/project"]`)。 @@ -193,10 +198,16 @@ GitHub Copilot CLI、JetBrains IDE。 ```bash codegraph install --yes # 自动检测已安装代理 +codegraph install --yes --init # 接线代理后初始化 cwd codegraph install --target=claude,cursor --yes # 显式指定 codegraph install --target=auto --local # 项目级配置 +codegraph install --target=codex --local --yes # .codex/config.toml + AGENTS.md + 本地 Skill ``` +Codex 的项目级配置只有在仓库被 Codex 标记为可信后才会启用。`install` 默认绝不 +创建索引;只有显式 `--init` 才会在安装成功后初始化,`--print-config` 始终提前返回, +既不写配置也不建索引。 + 完整 MCP 工具参考:[`../mcp.md`](../mcp.md)。 --- @@ -259,7 +270,8 @@ codegraph skill update --target=zuno --global - **Kiro / Qoder** — 全局条目写入裸 `serve --mcp`(无 `--path`),工具对现有索引只读访问。在各项目内运行 `codegraph init --target=kiro` 可获得实时监听。 - **Zed** — 全局 `~/.config/zed/settings.json`(Linux/macOS)或 `%APPDATA%\Zed\settings.json`(Windows)写入裸条目。在项目内运行 `codegraph init --target=zed` 写入带绝对 `--path` 的 `.zed/settings.json`——这是 Zed 获得项目级实时索引的唯一方式。 - **Zuno** — 全局配置位于 `$XDG_CONFIG_HOME/zuno/zuno.json[c]`(通常是 `~/.config/zuno/`),项目配置位于 `.zuno/zuno.json[c]`。安装器写入 `mcp.codegraph`,并自动迁移旧的 `mcp.codegraph-mcp-server` 键,同时保留 JSONC 注释和其他 MCP 项。 -- **Claude Code、Cursor、Codex CLI、opencode、Hermes、Gemini CLI、Antigravity** — 使用各自原生的 MCP 配置结构,守护进程可到达项目时获得实时监听。 +- **Codex CLI** — 全局安装保持 `~/.codex/config.toml` 与 `~/.codex/AGENTS.md`;项目级安装写入 `.codex/config.toml`、仓库根 `AGENTS.md` 和 `.agents/skills/codegraph`。需在 Codex 中信任该仓库才会启用本地层。 +- **Claude Code、Cursor、opencode、Hermes、Gemini CLI、Antigravity** — 使用各自原生的 MCP 配置结构,守护进程可到达项目时获得实时监听。 **在 Kiro、Qoder 或 Zed 中获得实时自动更新。** 在每个项目中运行一次: diff --git a/skills/codegraph/SKILL.md b/skills/codegraph/SKILL.md index 91884a63..55cf7fef 100644 --- a/skills/codegraph/SKILL.md +++ b/skills/codegraph/SKILL.md @@ -90,6 +90,16 @@ directory and incrementally syncs file changes after a debounce. When the watche is healthy, do not run a manual sync after every edit. Each project still needs one usable index, normally created with `codegraph init`. +A bare stdio launch first walks upward for an index. If that misses and the +launch directory is a workspace root (manifest or `.git`), CodeGraph performs a +bounded scan (depth 4, at most 64 candidates). It adopts exactly one indexed +child and starts that child's normal daemon/watcher/catch-up path. Zero or +multiple children are never guessed: the tool response and stderr name the +sorted candidates, and the caller must pass `projectPath` or restart with +`--path`. HOME and filesystem roots are never scanned downward. A no-default +server retries at most once every five seconds, so an index created later can be +adopted. + The watcher hot-reloads the selected index root's `config.toml` and `codegraph.json`, plus the project root `.gitignore`. A control-file change atomically replaces the live scope and triggers one full reconcile, so newly @@ -118,8 +128,17 @@ Copilot CLI, JetBrains): ```bash codegraph install --yes +codegraph install --yes --init # wire agents, then init cwd +codegraph install --target=codex --local --yes # project-local Codex setup ``` +`install --init` is explicit: without it, install never creates an index. +`--print-config` returns before initialization. `init -y/--yes` is accepted for +unattended bootstrap compatibility. A local Codex install writes +`.codex/config.toml`, project-root `AGENTS.md`, and +`.agents/skills/codegraph`; Codex applies that project layer only after the +repository is trusted. + --- ## Part B — Daily Code Research From a41a855ab69b944f2e74b5ce6ab9344258b88e21 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Tue, 1 Sep 2026 01:37:06 +0800 Subject: [PATCH 2/4] test(cli): make Codex path assertion portable --- crates/codegraph-cli/src/installer/targets/codex.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codegraph-cli/src/installer/targets/codex.rs b/crates/codegraph-cli/src/installer/targets/codex.rs index 01c4130a..abaaf27a 100644 --- a/crates/codegraph-cli/src/installer/targets/codex.rs +++ b/crates/codegraph-cli/src/installer/targets/codex.rs @@ -376,7 +376,7 @@ mod tests { assert!( target .print_config(&fx.ctx, Location::Local) - .contains(".codex/config.toml") + .contains(&toml.display().to_string()) ); } From c49a942a94c1cb0aca4f373ff32af71804658a2b Mon Sep 17 00:00:00 2001 From: sunerpy Date: Tue, 1 Sep 2026 01:49:53 +0800 Subject: [PATCH 3/4] fix(cli): generate installed completions on a larger stack --- crates/codegraph-cli/src/main.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index baf94936..fc209218 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -884,11 +884,21 @@ fn location_flag(location: Option, global: bool, local: bool) -> Option< None } -fn generate_completion_bytes(shell: Shell) -> Vec { - let mut cmd = Cli::command(); - let mut buf = Vec::new(); - generate(shell, &mut cmd, "codegraph", &mut buf); - buf +fn generate_completion_bytes(shell: Shell) -> Result> { + const COMPLETION_STACK_BYTES: usize = 8 * 1024 * 1024; + + std::thread::Builder::new() + .name("codegraph-completions".to_string()) + .stack_size(COMPLETION_STACK_BYTES) + .spawn(move || { + let mut cmd = Cli::command(); + let mut buf = Vec::new(); + generate(shell, &mut cmd, "codegraph", &mut buf); + buf + }) + .context("failed to start completion generator")? + .join() + .map_err(|_| anyhow!("completion generator thread panicked")) } fn env_path(key: &str) -> Option { @@ -967,7 +977,7 @@ fn append_dot_source_once(profile: &Path, script: &Path) -> Result { fn install_completions(shell: Shell) -> Result<()> { let target = completion_target(shell)?; - let bytes = generate_completion_bytes(shell); + let bytes = generate_completion_bytes(shell)?; write_completion_file(&target, &bytes)?; println!("Installed {shell} completions to {}", target.display()); @@ -6963,7 +6973,7 @@ mod pure_helper_tests { #[test] fn generate_completion_bytes_are_non_empty_for_bash() { - let bytes = generate_completion_bytes(clap_complete::Shell::Bash); + let bytes = generate_completion_bytes(clap_complete::Shell::Bash).unwrap(); assert!(!bytes.is_empty()); assert!(String::from_utf8_lossy(&bytes).contains("codegraph")); } @@ -7419,7 +7429,7 @@ mod formatter_and_env_tests { Shell::PowerShell, Shell::Elvish, ] { - let bytes = generate_completion_bytes(shell); + let bytes = generate_completion_bytes(shell).unwrap(); assert!(bytes.len() > 100); assert!(String::from_utf8_lossy(&bytes).contains("codegraph")); } From 3f723014955badc1fc00e195b9766e07e57c3a22 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Tue, 1 Sep 2026 02:01:20 +0800 Subject: [PATCH 4/4] test(cli): isolate Windows home in bootstrap coverage --- crates/codegraph-cli/tests/install_init.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/codegraph-cli/tests/install_init.rs b/crates/codegraph-cli/tests/install_init.rs index 67edff54..c089c674 100644 --- a/crates/codegraph-cli/tests/install_init.rs +++ b/crates/codegraph-cli/tests/install_init.rs @@ -43,6 +43,7 @@ impl Fixture { .args(args) .current_dir(cwd) .env("HOME", &self.home) + .env("USERPROFILE", &self.home) .env("XDG_CONFIG_HOME", self.root.join("xdg")) .env("HERMES_HOME", self.root.join("hermes")) .env_remove("APPDATA")