use std::path::PathBuf;
use serde_json::Value;
use super::mcpjson::{self, RemoteShape, ServerShape};
use super::report;
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 JetbrainsCopilotBackend;
const MCP_KEY: &[&str] = &["servers"];
const SHAPE: ServerShape = ServerShape::typed().with_remote(RemoteShape::TypeUrl);
impl AgentBackend for JetbrainsCopilotBackend {
fn id(&self) -> &'static str {
"jetbrains-copilot"
}
fn detect(&self) -> bool {
config_base().is_some_and(|b| b.join("github-copilot").join("intellij").is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: false,
commands: false,
agents: false,
skills: false,
instructions: false,
statusline: false,
scopes: &["user"],
}
}
fn probe(&self, plugin: &Plugin, _scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
mcpjson::probe(&mcp_path()?, MCP_KEY, &comp.mcp_servers, SHAPE)
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, _scope: &Scope) -> Result<Outcome> {
let comp = plugin.components(&desired.source)?.with_client(self.id());
mcpjson::reconcile(&mcp_path()?, MCP_KEY, &comp.mcp_servers, SHAPE)
}
fn remove(&self, plugin: &Plugin, _scope: &Scope, source: &Source) -> Result<Outcome> {
let comp = plugin.components(source)?.with_client(self.id());
mcpjson::remove(&mcp_path()?, MCP_KEY, &comp.mcp_servers, SHAPE)
}
fn report(&self, plugin: &Plugin, source: &Source) -> DoctorReport {
DoctorReport::from_checks(report_checks(self, plugin, source))
}
}
#[cfg(not(windows))]
fn config_base() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".config"))
}
#[cfg(windows)]
fn config_base() -> Option<PathBuf> {
dirs::data_local_dir()
}
fn config_dir_from(xdg_config_home: Option<PathBuf>, fallback_base: Option<PathBuf>) -> Option<PathBuf> {
xdg_config_home
.filter(|p| p.is_absolute())
.map(|xdg| xdg.join("github-copilot"))
.or_else(|| fallback_base.map(|b| b.join("github-copilot").join("intellij")))
}
fn config_dir() -> Option<PathBuf> {
config_dir_from(env_nonempty("XDG_CONFIG_HOME").map(PathBuf::from), config_base())
}
fn env_nonempty(var: &str) -> Option<std::ffi::OsString> {
std::env::var_os(var).filter(|v| !v.is_empty())
}
fn mcp_path() -> Result<PathBuf> {
let dir = config_dir()
.ok_or_else(|| Error::Tree("no config directory (XDG_CONFIG_HOME/HOME/LOCALAPPDATA unset); cannot locate github-copilot".into()))?;
Ok(dir.join("mcp.json"))
}
fn portable_names(servers: &[McpServer]) -> Vec<&str> {
servers.iter().filter(|s| s.is_portable()).map(|s| s.name.as_str()).collect()
}
fn check_mcp_registered(servers: &[McpServer], root: Option<&Value>) -> DoctorCheck {
let name = "mcp server registered";
let portable: Vec<&str> = portable_names(servers);
let skipped = report::skipped_mcp(servers, &portable);
if portable.is_empty() {
return report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok(report::NO_MCP.into()) }, &skipped);
}
let obj = root.and_then(|r| r.get("servers")).and_then(Value::as_object);
let missing: Vec<&str> = portable.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 mcp.json: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
};
}
report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok(format!("{} registered", portable.join(", "))) }, &skipped)
}
fn report_checks(backend: &JetbrainsCopilotBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "jetbrains-copilot detected", status: CheckStatus::Ok("`github-copilot/intellij` config dir present".into()) }
} else {
DoctorCheck {
name: "jetbrains-copilot detected",
status: CheckStatus::Warn("no `github-copilot/intellij` config dir; the JetBrains Copilot plugin isn't set up here".into()),
}
});
let path = match mcp_path() {
Ok(path) => path,
Err(e) => {
checks.push(DoctorCheck { name: "mcp.json", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let root = report::read_json_config(&mut checks, "mcp.json", &path);
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
}
#[cfg(test)]
#[path = "../../tests/unit/jetbrains_copilot.rs"]
mod jetbrains_copilot_tests;