use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::cchooks::hook_is_portable;
use super::confedit::{remove_file_idem, write_file_idem};
use super::mcpjson::{self, RemoteShape, ServerShape};
use super::report;
use super::{AgentBackend, BackendState};
use crate::components::{HookBinding, MarkdownDoc, McpServer};
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct VscodeCopilotBackend;
const MCP_KEY: &[&str] = &["servers"];
const SHAPE: ServerShape = ServerShape::typed().with_remote(RemoteShape::TypeUrlHeadersHttpOnly);
impl AgentBackend for VscodeCopilotBackend {
fn id(&self) -> &'static str {
"vscode-copilot"
}
fn detect(&self) -> bool {
which::which("code").is_ok()
|| which::which("code-insiders").is_ok()
|| dirs::home_dir().is_some_and(|h| h.join(".vscode").is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: true,
commands: false,
agents: true,
skills: false,
instructions: false,
statusline: false,
scopes: &["project"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let root = project_root(scope)?;
let comp = plugin.components(source)?.with_client(self.id());
let mcp = mcpjson::probe_surface(&mcp_path(root), MCP_KEY, &comp.mcp_servers, SHAPE)?;
let hooks = probe_hooks(&hooks_path(root, plugin.name), &comp.hooks)?;
let agents = report::probe_files(&expected_agents(&agents_dir(root), plugin.name, &comp.agents), |_, _| true)?;
Ok(report::compose([mcp, hooks, agents].into_iter().flatten()))
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
let root = project_root(scope)?;
let comp = plugin.components(&desired.source)?.with_client(self.id());
let mut changed = false;
changed |= mcpjson::reconcile(&mcp_path(root), MCP_KEY, &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= reconcile_hooks(&hooks_path(root, plugin.name), &comp.hooks)?;
let agent_root = agents_dir(root);
for doc in &comp.agents {
changed |= write_file_idem(&agent_root.join(agent_file(plugin.name, doc)), render_agent(plugin.name, doc).as_bytes())?;
}
Ok(if changed { Outcome::Installed } else { Outcome::NoOp })
}
fn remove(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<Outcome> {
let root = project_root(scope)?;
let comp = plugin.components(source)?.with_client(self.id());
let mut changed = false;
changed |= mcpjson::remove(&mcp_path(root), MCP_KEY, &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= remove_file_idem(&hooks_path(root, plugin.name))?;
let agent_root = agents_dir(root);
for doc in &comp.agents {
changed |= remove_file_idem(&agent_root.join(agent_file(plugin.name, doc)))?;
}
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 project_root(scope: &Scope) -> Result<&Path> {
match scope {
Scope::Project { path } => Ok(path),
Scope::User => Err(Error::Tree(
"vscode-copilot is project-scoped: its user-scope mcp.json path is known (<userDataDir>/User/mcp.json) but the active VS Code profile is not derivable from disk, so a user-scope write could target the wrong profile — install into a project scope".into(),
)),
}
}
fn mcp_path(root: &Path) -> PathBuf {
root.join(".vscode").join("mcp.json")
}
fn hooks_path(root: &Path, plugin: &str) -> PathBuf {
root.join(".github").join("hooks").join(format!("{plugin}.json"))
}
fn agents_dir(root: &Path) -> PathBuf {
root.join(".github").join("agents")
}
fn portable_names(servers: &[McpServer]) -> Vec<&str> {
servers.iter().filter(|s| s.is_portable()).map(|s| s.name.as_str()).collect()
}
fn map_event(cc_event: &str) -> Option<&'static str> {
match cc_event {
"SessionStart" => Some("SessionStart"),
"SessionEnd" => Some("SessionEnd"),
"UserPromptSubmit" => Some("UserPromptSubmit"),
"PreToolUse" => Some("PreToolUse"),
"PostToolUse" => Some("PostToolUse"),
"PreCompact" => Some("PreCompact"),
"SubagentStart" => Some("SubagentStart"),
"SubagentStop" => Some("SubagentStop"),
"Stop" => Some("Stop"),
_ => None,
}
}
fn render_hook_entry(hook: &HookBinding) -> Value {
let mut obj = Map::new();
obj.insert("type".into(), Value::from("command"));
obj.insert("command".into(), Value::from(hook.command.clone()));
Value::Object(obj)
}
fn render_hooks_file(hooks: &[HookBinding]) -> Option<Value> {
let mut by_event: BTreeMap<&'static str, Vec<Value>> = BTreeMap::new();
for hook in hooks.iter().filter(|h| hook_is_portable(h)) {
if let Some(event) = map_event(&hook.event) {
by_event.entry(event).or_default().push(render_hook_entry(hook));
}
}
if by_event.is_empty() {
return None;
}
let events: Map<String, Value> = by_event.into_iter().map(|(event, entries)| (event.to_string(), Value::Array(entries))).collect();
let mut root = Map::new();
root.insert("hooks".into(), Value::Object(events));
Some(Value::Object(root))
}
fn reconcile_hooks(path: &Path, hooks: &[HookBinding]) -> Result<bool> {
match render_hooks_file(hooks) {
Some(value) => {
let mut bytes =
serde_json::to_vec_pretty(&value).map_err(|source| Error::Json { what: "vscode-copilot hooks".into(), source })?;
bytes.push(b'\n');
write_file_idem(path, &bytes)
}
None => remove_file_idem(path),
}
}
fn probe_hooks(path: &Path, hooks: &[HookBinding]) -> Result<Option<BackendState>> {
match render_hooks_file(hooks) {
Some(value) => {
let mut bytes =
serde_json::to_vec_pretty(&value).map_err(|source| Error::Json { what: "vscode-copilot hooks".into(), source })?;
bytes.push(b'\n');
report::probe_files(&[(path.to_path_buf(), bytes)], |_, _| true)
}
None => Ok(None),
}
}
fn expected_agents(dir: &Path, plugin: &str, agents: &[MarkdownDoc]) -> Vec<(PathBuf, Vec<u8>)> {
agents.iter().map(|doc| (dir.join(agent_file(plugin, doc)), render_agent(plugin, doc).into_bytes())).collect()
}
fn agent_name(plugin: &str, doc: &MarkdownDoc) -> String {
format!("{plugin}-{}", flat_stem(&doc.rel, "agents/"))
}
fn agent_file(plugin: &str, doc: &MarkdownDoc) -> String {
format!("{}.agent.md", agent_name(plugin, doc))
}
fn flat_stem(rel: &str, prefix: &str) -> String {
let stripped = rel.strip_prefix(prefix).unwrap_or(rel);
let stem = stripped.strip_suffix(".md").unwrap_or(stripped);
stem.replace(['/', '\\'], "-")
}
fn render_agent(plugin: &str, doc: &MarkdownDoc) -> String {
let mut out = String::new();
out.push_str("---\n");
out.push_str("name: ");
out.push_str(&agent_name(plugin, doc));
out.push('\n');
if let Some(desc) = doc.frontmatter.get("description").and_then(Value::as_str) {
out.push_str("description: ");
out.push_str(&Value::String(desc.to_string()).to_string());
out.push('\n');
}
out.push_str("---\n\n");
out.push_str(doc.body.trim());
out.push('\n');
out
}
fn check_mcp_registered(servers: &[McpServer], root: Option<&Value>) -> DoctorCheck {
let name = "mcp server registered";
let portable = 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` in the project root".into(),
},
};
}
report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok(format!("{} registered", portable.join(", "))) }, &skipped)
}
fn report_checks(backend: &VscodeCopilotBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "vscode-copilot detected", status: CheckStatus::Ok("`code` on PATH or ~/.vscode present".into()) }
} else {
DoctorCheck {
name: "vscode-copilot detected",
status: CheckStatus::Warn("no `code` on PATH and no ~/.vscode; VS Code Copilot isn't set up here".into()),
}
});
let root = match std::env::current_dir() {
Ok(dir) => dir,
Err(e) => {
checks
.push(DoctorCheck { name: "mcp.json", status: CheckStatus::Warn(format!("could not resolve the current directory: {e}")) });
return checks;
}
};
let mcp = mcp_path(&root);
let parsed = match fs::read(&mcp) {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(v) => {
checks.push(DoctorCheck { name: "mcp.json", status: CheckStatus::Ok(format!("{} parses", mcp.display())) });
Some(v)
}
Err(e) => {
checks.push(DoctorCheck {
name: "mcp.json",
status: CheckStatus::Fail {
problem: format!("{} does not parse: {e}", mcp.display()),
fix: "fix the JSON syntax or remove the file".into(),
},
});
None
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
checks.push(DoctorCheck {
name: "mcp.json",
status: CheckStatus::Warn(format!("{} does not exist (run setup in the project root)", mcp.display())),
});
None
}
Err(e) => {
checks.push(DoctorCheck { name: "mcp.json", status: CheckStatus::Warn(format!("could not read {}: {e}", mcp.display())) });
None
}
};
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, parsed.as_ref()));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks.push(check_agents_present(&comp.agents, &agents_dir(&root), plugin.name));
checks
}
fn check_agents_present(agents: &[MarkdownDoc], dir: &Path, plugin: &str) -> DoctorCheck {
let name = "translated agents present";
if agents.is_empty() {
return DoctorCheck { name, status: CheckStatus::Ok("no agents to translate".into()) };
}
let missing: Vec<String> = agents.iter().map(|d| agent_file(plugin, d)).filter(|f| !dir.join(f).exists()).collect();
if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} agent file(s) present", agents.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("agent file(s) missing: {}", missing.join(", ")),
fix: "run the host's `setup` in the project root".into(),
},
}
}
}
#[cfg(test)]
#[path = "../../tests/unit/vscode_copilot.rs"]
mod vscode_copilot_tests;