diff --git a/agent-support/zed/rules b/agent-support/zed/rules new file mode 100644 index 000000000..322fa769e --- /dev/null +++ b/agent-support/zed/rules @@ -0,0 +1,29 @@ +You have access to a `git_ai_checkpoint` MCP tool from git-ai that tracks AI code authorship. + +IMPORTANT: You MUST call this tool to track which code you write vs which code the human writes. + +## Rules + +1. **Before editing any file**, call `git_ai_checkpoint` with: + - `event`: `"PreToolUse"` + - `file_paths`: array of file paths you are about to edit + +2. **After editing any file**, call `git_ai_checkpoint` with: + - `event`: `"PostToolUse"` + - `file_paths`: array of file paths you just edited + +3. Call the checkpoint tool for EVERY file edit operation (edit_file, create file, etc.) + +4. Do NOT skip checkpoint calls even if you are making multiple edits in sequence - call before and after each edit operation. + +## Example + +Before editing `src/main.rs`: +``` +git_ai_checkpoint(event: "PreToolUse", file_paths: ["src/main.rs"]) +``` + +After editing `src/main.rs`: +``` +git_ai_checkpoint(event: "PostToolUse", file_paths: ["src/main.rs"]) +``` diff --git a/src/commands/checkpoint_agent/mod.rs b/src/commands/checkpoint_agent/mod.rs index f6ae812b4..d91f08a9b 100644 --- a/src/commands/checkpoint_agent/mod.rs +++ b/src/commands/checkpoint_agent/mod.rs @@ -2,3 +2,4 @@ pub mod agent_presets; pub mod agent_v1_preset; pub mod amp_preset; pub mod opencode_preset; +pub mod zed_preset; diff --git a/src/commands/checkpoint_agent/zed_preset.rs b/src/commands/checkpoint_agent/zed_preset.rs new file mode 100644 index 000000000..d7c599a86 --- /dev/null +++ b/src/commands/checkpoint_agent/zed_preset.rs @@ -0,0 +1,85 @@ +use crate::{ + authorship::working_log::{AgentId, CheckpointKind}, + commands::checkpoint_agent::agent_presets::{ + AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, + }, + error::GitAiError, +}; +use serde::Deserialize; + +pub struct ZedPreset; + +/// Hook input from git-ai MCP server or direct CLI invocation +#[derive(Debug, Deserialize)] +struct ZedHookInput { + hook_event_name: String, + #[serde(default)] + session_id: Option, + #[serde(default)] + cwd: Option, + #[serde(default)] + edited_filepaths: Option>, + #[serde(default)] + tool_input: Option, +} + +#[derive(Debug, Deserialize)] +struct ZedToolInput { + #[serde(default)] + file_paths: Option>, +} + +impl AgentCheckpointPreset for ZedPreset { + fn run(&self, flags: AgentCheckpointFlags) -> Result { + let hook_input_json = flags.hook_input.ok_or_else(|| { + GitAiError::PresetError("hook_input is required for Zed preset".to_string()) + })?; + + let hook_input: ZedHookInput = serde_json::from_str(&hook_input_json) + .map_err(|e| GitAiError::PresetError(format!("Invalid JSON in hook_input: {}", e)))?; + + let is_pre_tool_use = hook_input.hook_event_name == "PreToolUse"; + + // Extract file paths from edited_filepaths or tool_input + let file_paths = hook_input + .edited_filepaths + .or_else(|| hook_input.tool_input.and_then(|ti| ti.file_paths)) + .filter(|paths| !paths.is_empty()); + + // Use session_id from MCP server (stable per server instance) or generate fallback + let session_id = hook_input + .session_id + .unwrap_or_else(|| "zed-unknown".to_string()); + + let agent_id = AgentId { + tool: "zed".to_string(), + id: session_id, + model: "unknown".to_string(), + }; + + if is_pre_tool_use { + return Ok(AgentRunResult { + agent_id, + agent_metadata: None, + checkpoint_kind: CheckpointKind::Human, + transcript: None, + repo_working_dir: hook_input.cwd, + edited_filepaths: None, + will_edit_filepaths: file_paths, + dirty_files: None, + }); + } + + // PostToolUse - AI checkpoint + Ok(AgentRunResult { + agent_id, + agent_metadata: None, + checkpoint_kind: CheckpointKind::AiAgent, + transcript: None, + repo_working_dir: hook_input.cwd, + edited_filepaths: file_paths, + will_edit_filepaths: None, + dirty_files: None, + }) + } +} diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index f0b07e3e7..31703c697 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -13,6 +13,7 @@ use crate::commands::checkpoint_agent::agent_presets::{ use crate::commands::checkpoint_agent::agent_v1_preset::AgentV1Preset; use crate::commands::checkpoint_agent::amp_preset::AmpPreset; use crate::commands::checkpoint_agent::opencode_preset::OpenCodePreset; +use crate::commands::checkpoint_agent::zed_preset::ZedPreset; use crate::config; use crate::git::find_repository; use crate::git::find_repository_in_path; @@ -174,6 +175,9 @@ pub fn handle_git_ai(args: &[String]) { "effective-ignore-patterns" => { handle_effective_ignore_patterns_internal(&args[1..]); } + "mcp-server" => { + commands::mcp_server::run_mcp_server(); + } "blame-analysis" => { handle_blame_analysis_internal(&args[1..]); } @@ -202,7 +206,7 @@ fn print_help() { eprintln!("Commands:"); eprintln!(" checkpoint Checkpoint working changes and attribute author"); eprintln!( - " Presets: claude, codex, continue-cli, cursor, gemini, github-copilot, amp, windsurf, opencode, ai_tab, mock_ai" + " Presets: claude, codex, continue-cli, cursor, gemini, github-copilot, amp, windsurf, opencode, zed, ai_tab, mock_ai" ); eprintln!( " --hook-input JSON payload required by presets, or 'stdin' to read from stdin" @@ -292,6 +296,7 @@ fn print_help() { eprintln!(" --launch Launch agent CLI with restored context"); eprintln!(" --clipboard Copy context to system clipboard"); eprintln!(" --json Output context as structured JSON"); + eprintln!(" mcp-server Run as MCP server (for Zed and other MCP-compatible editors)"); eprintln!(" login Authenticate with Git AI"); eprintln!(" logout Clear stored credentials"); eprintln!(" whoami Show auth state and login identity"); @@ -546,6 +551,22 @@ fn handle_checkpoint(args: &[String]) { } } } + "zed" => { + match ZedPreset.run(AgentCheckpointFlags { + hook_input: hook_input.clone(), + }) { + Ok(agent_run) => { + if agent_run.repo_working_dir.is_some() { + repository_working_dir = agent_run.repo_working_dir.clone().unwrap(); + } + agent_run_result = Some(agent_run); + } + Err(e) => { + eprintln!("Zed preset error: {}", e); + std::process::exit(0); + } + } + } "mock_ai" => { let mock_agent_id = format!( "ai-thread-{}", diff --git a/src/commands/mcp_server.rs b/src/commands/mcp_server.rs new file mode 100644 index 000000000..b58bf7f89 --- /dev/null +++ b/src/commands/mcp_server.rs @@ -0,0 +1,404 @@ +use serde::{Deserialize, Serialize}; +use std::io::{self, BufRead, Read, Write}; +use std::process::{Command, Stdio}; +use std::thread; + +/// Minimal MCP server over stdio for Zed integration. +/// +/// Implements just enough of the MCP protocol (JSON-RPC 2.0 over stdio) to expose +/// a `git_ai_checkpoint` tool that Zed's agent can call before/after file edits. +/// +/// Message framing uses Content-Length headers (same as LSP): +/// Content-Length: \r\n +/// \r\n +/// + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + #[allow(dead_code)] + jsonrpc: String, + id: Option, + method: String, + #[serde(default)] + params: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: String, + id: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcError { + code: i64, + message: String, +} + +/// Tool input for git_ai_checkpoint +#[derive(Debug, Deserialize)] +struct CheckpointToolInput { + /// "PreToolUse" or "PostToolUse" + event: String, + /// File paths being edited + #[serde(default)] + file_paths: Vec, +} + +/// Read one MCP message from stdin using Content-Length framing. +fn read_message(reader: &mut impl BufRead) -> io::Result> { + // Read headers until empty line + let mut content_length: Option = None; + loop { + let mut header_line = String::new(); + let bytes_read = reader.read_line(&mut header_line)?; + if bytes_read == 0 { + return Ok(None); // EOF + } + + let trimmed = header_line.trim(); + if trimmed.is_empty() { + break; // End of headers + } + + if let Some(value) = trimmed.strip_prefix("Content-Length:") + && let Ok(len) = value.trim().parse::() + { + content_length = Some(len); + } + } + + let length = match content_length { + Some(l) => l, + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Missing Content-Length header", + )); + } + }; + + let mut body = vec![0u8; length]; + reader.read_exact(&mut body)?; + String::from_utf8(body).map(Some).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid UTF-8 in message body: {}", e), + ) + }) +} + +/// Write one MCP message to stdout with Content-Length framing. +fn write_message(writer: &mut impl Write, json: &str) -> io::Result<()> { + write!(writer, "Content-Length: {}\r\n\r\n{}", json.len(), json)?; + writer.flush() +} + +pub fn run_mcp_server() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = stdin.lock(); + + // Generate a stable session ID for this MCP server instance + let session_id = format!( + "zed-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + + loop { + let body = match read_message(&mut reader) { + Ok(Some(b)) => b, + Ok(None) => break, // EOF + Err(e) => { + eprintln!("[git-ai mcp] Failed to read message: {}", e); + continue; + } + }; + + let request: JsonRpcRequest = match serde_json::from_str(&body) { + Ok(r) => r, + Err(e) => { + eprintln!("[git-ai mcp] Failed to parse JSON-RPC: {}", e); + continue; + } + }; + + let response = handle_request(&request, &session_id); + + if let Some(resp) = response { + match serde_json::to_string(&resp) { + Ok(json) => { + let mut out = stdout.lock(); + if let Err(e) = write_message(&mut out, &json) { + eprintln!("[git-ai mcp] Failed to write response: {}", e); + } + } + Err(e) => { + eprintln!("[git-ai mcp] Failed to serialize response: {}", e); + } + } + } + } +} + +fn handle_request(request: &JsonRpcRequest, session_id: &str) -> Option { + let id = request.id.clone()?; + + let response = match request.method.as_str() { + "initialize" => handle_initialize(id), + "tools/list" => handle_tools_list(id), + "tools/call" => handle_tools_call(id, request.params.as_ref(), session_id), + "notifications/initialized" | "notifications/cancelled" => return None, + "ping" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(serde_json::json!({})), + error: None, + }, + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + }), + }, + }; + + Some(response) +} + +fn handle_initialize(id: serde_json::Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "git-ai", + "version": env!("CARGO_PKG_VERSION") + } + })), + error: None, + } +} + +fn handle_tools_list(id: serde_json::Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(serde_json::json!({ + "tools": [ + { + "name": "git_ai_checkpoint", + "description": "Track AI code authorship. Call with event='PreToolUse' BEFORE editing files and event='PostToolUse' AFTER editing files. This lets git-ai attribute code changes to AI vs human authors.", + "inputSchema": { + "type": "object", + "properties": { + "event": { + "type": "string", + "enum": ["PreToolUse", "PostToolUse"], + "description": "PreToolUse = about to edit files (human checkpoint), PostToolUse = just edited files (AI checkpoint)" + }, + "file_paths": { + "type": "array", + "items": { "type": "string" }, + "description": "File paths being edited (recommended but optional)" + } + }, + "required": ["event"] + } + } + ] + })), + error: None, + } +} + +fn handle_tools_call( + id: serde_json::Value, + params: Option<&serde_json::Value>, + session_id: &str, +) -> JsonRpcResponse { + let params = match params { + Some(p) => p, + None => { + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32602, + message: "Missing params".to_string(), + }), + }; + } + }; + + let tool_name = params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + if tool_name != "git_ai_checkpoint" { + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Unknown tool: {}", tool_name) + }], + "isError": true + })), + error: None, + }; + } + + let arguments = params + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + + let input: CheckpointToolInput = match serde_json::from_value(arguments) { + Ok(i) => i, + Err(e) => { + return JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Invalid arguments: {}", e) + }], + "isError": true + })), + error: None, + }; + } + }; + + let result = run_checkpoint(&input, session_id); + + let (text, is_error) = match result { + Ok(msg) => (msg, false), + Err(e) => (format!("Checkpoint failed: {}", e), true), + }; + + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(serde_json::json!({ + "content": [{ + "type": "text", + "text": text + }], + "isError": is_error + })), + error: None, + } +} + +fn run_checkpoint(input: &CheckpointToolInput, session_id: &str) -> Result { + // Validate event field + if input.event != "PreToolUse" && input.event != "PostToolUse" { + return Err(format!( + "Invalid event '{}': must be 'PreToolUse' or 'PostToolUse'", + input.event + )); + } + + let cwd = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))? + .to_string_lossy() + .to_string(); + + let hook_input = serde_json::json!({ + "hook_event_name": input.event, + "session_id": session_id, + "cwd": cwd, + "tool_input": { + "file_paths": input.file_paths, + }, + "edited_filepaths": input.file_paths, + }); + + let hook_input_str = + serde_json::to_string(&hook_input).map_err(|e| format!("JSON error: {}", e))?; + + // Shell out to `git-ai checkpoint zed --hook-input stdin` + // This mirrors how OpenCode/Amp plugins work + let binary_path = + std::env::current_exe().map_err(|e| format!("Failed to get current exe path: {}", e))?; + + let mut child = Command::new(&binary_path) + .args(["checkpoint", "zed", "--hook-input", "stdin"]) + .current_dir(&cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn checkpoint: {}", e))?; + + // Follow pipe deadlock prevention: start stdout/stderr readers BEFORE writing stdin + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let stdout_handle = thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut out) = stdout { + let _ = out.read_to_string(&mut buf); + } + buf + }); + + let stderr_handle = thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut err) = stderr { + let _ = err.read_to_string(&mut buf); + } + buf + }); + + // Write stdin asynchronously after readers are started + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(hook_input_str.as_bytes()); + // stdin dropped here, signaling EOF + } + + let status = child + .wait() + .map_err(|e| format!("Failed to wait for checkpoint: {}", e))?; + + let stderr_output = stderr_handle.join().unwrap_or_default(); + + // stdout_handle must be joined to avoid leak + let _ = stdout_handle.join(); + + if !status.success() { + return Err(format!("Checkpoint failed: {}", stderr_output.trim())); + } + + let event_type = if input.event == "PreToolUse" { + "human" + } else { + "AI" + }; + + Ok(format!( + "Created {} checkpoint for {} file(s)", + event_type, + input.file_paths.len() + )) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 87f6b3e4b..2915975a5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -17,6 +17,7 @@ pub mod hooks; pub mod install_hooks; pub mod login; pub mod logout; +pub mod mcp_server; pub mod personal_dashboard; pub mod prompt_picker; pub mod prompts_db; diff --git a/src/mdm/agents/mod.rs b/src/mdm/agents/mod.rs index 57f637471..4bf9c54d9 100644 --- a/src/mdm/agents/mod.rs +++ b/src/mdm/agents/mod.rs @@ -9,6 +9,7 @@ mod jetbrains; mod opencode; mod vscode; mod windsurf; +mod zed; pub use amp::AmpInstaller; pub use claude_code::ClaudeCodeInstaller; @@ -21,6 +22,7 @@ pub use jetbrains::JetBrainsInstaller; pub use opencode::OpenCodeInstaller; pub use vscode::VSCodeInstaller; pub use windsurf::WindsurfInstaller; +pub use zed::ZedInstaller; use super::hook_installer::HookInstaller; @@ -38,5 +40,6 @@ pub fn get_all_installers() -> Vec> { Box::new(DroidInstaller), Box::new(JetBrainsInstaller), Box::new(WindsurfInstaller), + Box::new(ZedInstaller), ] } diff --git a/src/mdm/agents/zed.rs b/src/mdm/agents/zed.rs new file mode 100644 index 000000000..aa750db0d --- /dev/null +++ b/src/mdm/agents/zed.rs @@ -0,0 +1,223 @@ +use crate::error::GitAiError; +use crate::mdm::hook_installer::{HookCheckResult, HookInstaller, HookInstallerParams}; +use crate::mdm::utils::{binary_exists, home_dir, write_atomic}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct ZedInstaller; + +impl ZedInstaller { + /// Path to Zed's config directory, respecting XDG_CONFIG_HOME + fn config_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("zed") + } else { + home_dir().join(".config").join("zed") + } + } + + /// Path to Zed's global settings + fn settings_path() -> PathBuf { + Self::config_dir().join("settings.json") + } + + /// Generate the context_servers JSON snippet for Zed settings + fn generate_context_server_config(binary_path: &Path) -> String { + let path_str = binary_path.display().to_string(); + format!( + r#" "git-ai": {{ + "command": {{ + "path": "{}", + "args": ["mcp-server"] + }} + }}"#, + path_str + ) + } + + /// Check if Zed settings already contain git-ai context server config + fn has_context_server_config(settings_content: &str) -> bool { + settings_content.contains("\"git-ai\"") && settings_content.contains("mcp-server") + } +} + +impl HookInstaller for ZedInstaller { + fn name(&self) -> &str { + "Zed" + } + + fn id(&self) -> &str { + "zed" + } + + fn check_hooks(&self, params: &HookInstallerParams) -> Result { + let settings_path = Self::settings_path(); + let has_binary = binary_exists("zed"); + let has_config_dir = Self::config_dir().exists(); + + // Check if Zed is installed (binary or config directory) + if !has_binary && !has_config_dir { + return Ok(HookCheckResult { + tool_installed: false, + hooks_installed: false, + hooks_up_to_date: false, + }); + } + + // Check if context server is configured in settings + let has_config = if settings_path.exists() { + let content = fs::read_to_string(&settings_path).unwrap_or_default(); + Self::has_context_server_config(&content) + } else { + false + }; + + if !has_config { + return Ok(HookCheckResult { + tool_installed: true, + hooks_installed: false, + hooks_up_to_date: false, + }); + } + + // Check if the config references the correct binary path + let settings_content = fs::read_to_string(&settings_path).unwrap_or_default(); + let binary_path_str = params.binary_path.display().to_string(); + let is_up_to_date = settings_content.contains(&binary_path_str); + + Ok(HookCheckResult { + tool_installed: true, + hooks_installed: true, + hooks_up_to_date: is_up_to_date, + }) + } + + fn install_hooks( + &self, + params: &HookInstallerParams, + dry_run: bool, + ) -> Result, GitAiError> { + let settings_path = Self::settings_path(); + + // Read existing settings or create default + let existing_content = if settings_path.exists() { + fs::read_to_string(&settings_path)? + } else { + String::from("{}") + }; + + // Check if already configured and up to date + let binary_path_str = params.binary_path.display().to_string(); + if Self::has_context_server_config(&existing_content) + && existing_content.contains(&binary_path_str) + { + return Ok(None); + } + + let server_config = Self::generate_context_server_config(¶ms.binary_path); + + // Generate instruction text for the user since we can't safely modify JSONC + let instruction = format!( + "Add the following to your Zed settings (~/.config/zed/settings.json) under \"context_servers\":\n\n{}\n\nAlso copy the rules file to your project:\n mkdir -p .zed && cp {} .zed/rules", + server_config, + concat!(env!("CARGO_MANIFEST_DIR"), "/agent-support/zed/rules") + ); + + if !dry_run { + // If settings file doesn't exist or doesn't have context_servers, create/update it + if !settings_path.exists() { + if let Some(dir) = settings_path.parent() { + fs::create_dir_all(dir)?; + } + let new_content = + format!("{{\n \"context_servers\": {{\n{}\n }}\n}}", server_config); + write_atomic(&settings_path, new_content.as_bytes())?; + } else if !Self::has_context_server_config(&existing_content) { + // Settings exist but no git-ai config - print instructions + // We don't modify existing JSONC files as they may have comments + eprintln!("\n{}\n", instruction); + } else { + // Has old config, needs update - print instructions + eprintln!( + "\nUpdate your Zed settings with the new binary path:\n{}\n", + server_config + ); + } + } + + Ok(Some(instruction)) + } + + fn uninstall_hooks( + &self, + _params: &HookInstallerParams, + dry_run: bool, + ) -> Result, GitAiError> { + let settings_path = Self::settings_path(); + + if !settings_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&settings_path)?; + if !Self::has_context_server_config(&content) { + return Ok(None); + } + + let instruction = + "Remove the \"git-ai\" entry from \"context_servers\" in your Zed settings." + .to_string(); + + if !dry_run { + eprintln!("\n{}\n", instruction); + } + + Ok(Some(instruction)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zed_rules_file_exists() { + let rules_path = concat!(env!("CARGO_MANIFEST_DIR"), "/agent-support/zed/rules"); + let content = std::fs::read_to_string(rules_path).expect("rules file should exist"); + assert!(content.contains("git_ai_checkpoint")); + assert!(content.contains("PreToolUse")); + assert!(content.contains("PostToolUse")); + assert!(content.contains("file_paths")); + } + + #[test] + fn test_zed_context_server_config() { + let binary_path = PathBuf::from("/usr/local/bin/git-ai"); + let config = ZedInstaller::generate_context_server_config(&binary_path); + assert!(config.contains("\"git-ai\"")); + assert!(config.contains("mcp-server")); + assert!(config.contains("/usr/local/bin/git-ai")); + } + + #[test] + fn test_zed_has_context_server_config() { + let settings_with_config = r#"{ + "context_servers": { + "git-ai": { + "command": { + "path": "/usr/local/bin/git-ai", + "args": ["mcp-server"] + } + } + } + }"#; + assert!(ZedInstaller::has_context_server_config( + settings_with_config + )); + + let settings_without_config = r#"{ "theme": "One Dark" }"#; + assert!(!ZedInstaller::has_context_server_config( + settings_without_config + )); + } +}