use std::path::{Path, PathBuf};
use serde_json::Value;
use super::mcpjson::{self, RemoteShape, ServerShape};
use super::report;
use super::skillsdir;
use super::{AgentBackend, BackendState};
use crate::components::McpServer;
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct ZedBackend;
impl AgentBackend for ZedBackend {
fn id(&self) -> &'static str {
"zed"
}
fn detect(&self) -> bool {
which::which("zed").is_ok() || user_config_dir().is_some_and(|d| d.is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: false,
commands: false,
agents: false,
skills: true,
instructions: false,
statusline: false,
scopes: &["user", "project"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
let mcp = probe_mcp(&settings_path(scope)?, &comp.mcp_servers)?;
let skills = skillsdir::probe(&skillsdir::agents_skills_root(scope)?, plugin, &comp.skills)?;
Ok(report::compose([Some(mcp), skills].into_iter().flatten()))
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
let comp = plugin.components(&desired.source)?.with_client(self.id());
let mut changed = reconcile_mcp(&settings_path(scope)?, &comp.mcp_servers)? != Outcome::NoOp;
changed |= skillsdir::reconcile(&skillsdir::agents_skills_root(scope)?, plugin, &comp.skills)?;
Ok(if changed { Outcome::Installed } else { Outcome::NoOp })
}
fn remove(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<Outcome> {
let comp = plugin.components(source)?.with_client(self.id());
let mut changed = remove_mcp(&settings_path(scope)?, &comp.mcp_servers)? != Outcome::NoOp;
changed |= skillsdir::remove(&skillsdir::agents_skills_root(scope)?, plugin, &comp.skills)?;
Ok(if changed { Outcome::Removed } else { Outcome::NoOp })
}
fn report(&self, plugin: &Plugin, source: &Source) -> DoctorReport {
DoctorReport::from_checks(report_checks(self, plugin, source))
}
}
fn user_config_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
dirs::config_dir().map(|c| c.join("Zed"))
}
#[cfg(target_os = "macos")]
{
dirs::home_dir().map(|h| h.join(".config").join("zed"))
}
#[cfg(not(any(windows, target_os = "macos")))]
{
xdg_or_home_config(std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), dirs::home_dir())
}
}
#[cfg(not(any(windows, target_os = "macos")))]
fn xdg_or_home_config(xdg: Option<PathBuf>, home: Option<PathBuf>) -> Option<PathBuf> {
xdg.filter(|p| p.is_absolute()).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("zed"))
}
fn settings_path(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => user_config_dir().map(|d| d.join("settings.json")).ok_or_else(|| {
Error::Tree("no config directory (XDG_CONFIG_HOME and HOME both unset); cannot locate zed's settings.json".into())
}),
Scope::Project { path } => Ok(path.join(".zed").join("settings.json")),
}
}
const CONTEXT_SERVERS: &str = "context_servers";
const MCP_KEY: &[&str] = &[CONTEXT_SERVERS];
const SHAPE: ServerShape = ServerShape::plain().with_remote(RemoteShape::UrlHeadersHttpOnly);
fn writable_names(servers: &[McpServer]) -> Vec<&str> {
servers.iter().filter(|s| s.is_portable() && mcpjson::render_server(s, SHAPE).is_some()).map(|s| s.name.as_str()).collect()
}
fn reconcile_mcp(settings: &Path, servers: &[McpServer]) -> Result<Outcome> {
mcpjson::reconcile(settings, MCP_KEY, servers, SHAPE)
}
fn probe_mcp(settings: &Path, servers: &[McpServer]) -> Result<BackendState> {
mcpjson::probe(settings, MCP_KEY, servers, SHAPE)
}
fn remove_mcp(settings: &Path, servers: &[McpServer]) -> Result<Outcome> {
mcpjson::remove(settings, MCP_KEY, servers, SHAPE)
}
fn report_checks(backend: &ZedBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "zed detected", status: CheckStatus::Ok("`zed` on PATH or a zed config dir present".into()) }
} else {
DoctorCheck {
name: "zed detected",
status: CheckStatus::Fail {
problem: "zed not detected".into(),
fix: "install it with `curl -f https://zed.dev/install.sh | sh`".into(),
},
}
});
let settings = match settings_path(&Scope::User) {
Ok(settings) => settings,
Err(e) => {
checks.push(DoctorCheck { name: "settings file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let root = report::read_json_config(&mut checks, "settings file", &settings);
let Some(comp) = report::components(&mut checks, plugin, source).map(|c| c.with_client(backend.id())) else {
return checks;
};
checks.push(check_mcp_registered(&comp.mcp_servers, root.as_ref()));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks
}
fn check_mcp_registered(servers: &[McpServer], root: Option<&Value>) -> DoctorCheck {
let name = "mcp server registered";
let expected = writable_names(servers);
let skipped = report::skipped_mcp(servers, &expected);
if expected.is_empty() {
return report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok(report::NO_MCP.into()) }, &skipped);
}
let obj = root.and_then(|r| r.get(CONTEXT_SERVERS)).and_then(Value::as_object);
let missing: Vec<&str> = expected.iter().copied().filter(|n| obj.is_none_or(|o| !o.contains_key(*n))).collect();
if !missing.is_empty() {
return DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("mcp server(s) not in settings.json: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
};
}
report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok(format!("{} registered", expected.join(", "))) }, &skipped)
}
#[cfg(test)]
#[path = "../../tests/unit/zed.rs"]
mod zed_tests;