use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::cchooks::hook_is_portable;
use super::ccregistry::registry_lists_plugin;
use super::confedit::{json_edit, json_obj_at, json_prune_obj, json_remove, remove_file_idem, write_file_idem};
use super::mcpjson::{self, ServerShape};
use super::report;
use super::skillsdir;
use super::{AgentBackend, BackendState};
use crate::components::{HookBinding, MarkdownDoc};
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct CursorBackend;
impl AgentBackend for CursorBackend {
fn id(&self) -> &'static str {
"cursor"
}
fn detect(&self) -> bool {
which::which("cursor-agent").is_ok()
|| which::which("cursor").is_ok()
|| dirs::home_dir().is_some_and(|h| h.join(".cursor").is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: true,
commands: true,
agents: true,
skills: true,
instructions: false,
scopes: &["user", "project"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
if cc_registry_covers(plugin) {
return Ok(BackendState::Healthy);
}
let comp = plugin.components(source)?.with_client(self.id());
let base = cursor_dir(scope)?;
let mcp = mcpjson::probe_surface(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, ServerShape::typed())?;
let hooks = report::probe_json_entries(&base.join("hooks.json"), &hook_entries(&comp.hooks))?;
let commands = report::probe_files(
&expected_docs(&base.join("commands"), plugin.name, "commands/", &comp.commands, |doc| command_body(doc).into_bytes()),
|_, _| true,
)?;
let agents = report::probe_files(
&expected_docs(&base.join("agents"), plugin.name, "agents/", &comp.agents, |doc| render_agent(plugin.name, doc).into_bytes()),
|_, _| true,
)?;
let skills = skillsdir::probe(&base.join("skills"), plugin, &comp.skills)?;
Ok(report::compose([mcp, hooks, commands, agents, skills].into_iter().flatten()))
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
if cc_registry_covers(plugin) {
return Ok(Outcome::NoOp);
}
let comp = plugin.components(&desired.source)?.with_client(self.id());
let base = cursor_dir(scope)?;
let mut changed = false;
changed |= mcpjson::reconcile(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, ServerShape::typed())? != Outcome::NoOp;
changed |= reconcile_hooks(&base.join("hooks.json"), &comp.hooks)?;
let cmd_root = base.join("commands");
for doc in &comp.commands {
changed |= write_file_idem(&cmd_root.join(command_file(plugin.name, doc)), command_body(doc).as_bytes())?;
}
let agent_root = base.join("agents");
for doc in &comp.agents {
changed |= write_file_idem(&agent_root.join(agent_file(plugin.name, doc)), render_agent(plugin.name, doc).as_bytes())?;
}
changed |= skillsdir::reconcile(&base.join("skills"), 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 base = cursor_dir(scope)?;
let mut changed = false;
changed |= mcpjson::remove(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, ServerShape::typed())? != Outcome::NoOp;
changed |= remove_hooks(&base.join("hooks.json"), &comp.hooks)?;
let cmd_root = base.join("commands");
for doc in &comp.commands {
changed |= remove_file_idem(&cmd_root.join(command_file(plugin.name, doc)))?;
}
let agent_root = base.join("agents");
for doc in &comp.agents {
changed |= remove_file_idem(&agent_root.join(agent_file(plugin.name, doc)))?;
}
changed |= skillsdir::remove(&base.join("skills"), 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 cursor_dir(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => dirs::home_dir()
.map(|h| h.join(".cursor"))
.ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/.cursor".into())),
Scope::Project { path } => Ok(path.join(".cursor")),
}
}
fn cc_registry_covers(plugin: &Plugin) -> bool {
let Some(cc) = dirs::home_dir().map(|h| h.join(".claude")) else {
return false;
};
registry_lists_plugin(&cc.join("plugins").join("installed_plugins.json"), &plugin.id())
}
fn map_event(cc_event: &str) -> Option<&'static str> {
match cc_event {
"SessionStart" => Some("sessionStart"),
"SessionEnd" => Some("sessionEnd"),
"UserPromptSubmit" => Some("beforeSubmitPrompt"),
"PreToolUse" => Some("preToolUse"),
"PostToolUse" => Some("postToolUse"),
"PreCompact" => Some("preCompact"),
"Stop" => Some("stop"),
"SubagentStart" => Some("subagentStart"),
"SubagentStop" => Some("subagentStop"),
_ => None,
}
}
fn hook_entries(hooks: &[HookBinding]) -> Vec<(Vec<String>, Value)> {
hooks
.iter()
.filter(|h| hook_is_portable(h))
.filter_map(|h| map_event(&h.event).map(|event| (vec!["hooks".to_string(), event.to_string()], render_hook_entry(h))))
.collect()
}
fn expected_docs(
dir: &Path, plugin: &str, prefix: &str, docs: &[MarkdownDoc], render: impl Fn(&MarkdownDoc) -> Vec<u8>,
) -> Vec<(PathBuf, Vec<u8>)> {
docs.iter().map(|doc| (dir.join(format!("{plugin}-{}.md", flat_stem(&doc.rel, prefix))), render(doc))).collect()
}
fn render_hook_entry(hook: &HookBinding) -> Value {
let mut obj = Map::new();
obj.insert("command".into(), Value::from(hook.command.clone()));
if let Some(matcher) = &hook.matcher {
obj.insert("matcher".into(), Value::from(matcher.clone()));
}
Value::Object(obj)
}
fn reconcile_hooks(path: &Path, hooks: &[HookBinding]) -> Result<bool> {
let writable: Vec<(&'static str, &HookBinding)> =
hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event).map(|event| (event, h))).collect();
if writable.is_empty() {
return Ok(false);
}
json_edit(path, |root| {
if let Value::Object(map) = root {
map.entry("version".to_string()).or_insert_with(|| Value::from(1));
}
let events = json_obj_at(root, &["hooks"]);
for (event, hook) in &writable {
let entry = render_hook_entry(hook);
let list = events.entry((*event).to_string()).or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(arr) = list
&& !arr.iter().any(|e| e == &entry)
{
arr.push(entry);
}
}
Ok(())
})
}
fn remove_hooks(path: &Path, hooks: &[HookBinding]) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let ours: BTreeSet<&str> = hooks.iter().filter(|h| hook_is_portable(h)).map(|h| h.command.as_str()).collect();
json_remove(path, |root| {
json_prune_obj(root, &["hooks"], |events| {
events.retain(|_, list| {
let Some(arr) = list.as_array_mut() else { return true };
if arr.is_empty() {
return true;
}
arr.retain(|e| e.get("command").and_then(Value::as_str).is_none_or(|c| !ours.contains(c)));
!arr.is_empty()
});
Ok(())
})
.map(|_| ())
})
}
fn command_file(plugin: &str, doc: &MarkdownDoc) -> String {
format!("{plugin}-{}.md", flat_stem(&doc.rel, "commands/"))
}
fn agent_file(plugin: &str, doc: &MarkdownDoc) -> String {
format!("{plugin}-{}.md", flat_stem(&doc.rel, "agents/"))
}
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 command_body(doc: &MarkdownDoc) -> String {
let mut body = doc.body.trim().to_string();
body.push('\n');
body
}
fn render_agent(plugin: &str, doc: &MarkdownDoc) -> String {
let name = doc.frontmatter.get("name").and_then(Value::as_str).unwrap_or(doc.name.as_str());
let mut out = String::new();
out.push_str("---\n");
out.push_str("name: ");
out.push_str(plugin);
out.push('-');
out.push_str(name);
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("model: inherit\n");
out.push_str("---\n\n");
out.push_str(doc.body.trim());
out.push('\n');
out
}
fn report_checks(backend: &CursorBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "cursor detected", status: CheckStatus::Ok("~/.cursor present or a cursor CLI on PATH".into()) }
} else {
DoctorCheck {
name: "cursor detected",
status: CheckStatus::Fail {
problem: "cursor not detected".into(),
fix: "install Cursor (the editor creates ~/.cursor on first run)".into(),
},
}
});
if cc_registry_covers(plugin) {
checks.push(DoctorCheck {
name: "translation",
status: CheckStatus::Ok("covered by Claude Code's own `loadClaude` plugin registry; not translated".into()),
});
return checks;
}
let base = match cursor_dir(&Scope::User) {
Ok(base) => base,
Err(e) => {
checks.push(DoctorCheck { name: "mcp.json", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let mcp = base.join("mcp.json");
let root = report::read_json_config(&mut checks, "mcp.json", &mcp);
let Some(comp) = report::components(&mut checks, plugin, source).map(|c| c.with_client(backend.id())) else {
return checks;
};
checks.push(report::check_mcp_registered(
&comp.mcp_servers,
root.as_ref(),
&["mcpServers"],
"not in mcp.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks.push(check_docs_present("translated commands present", &comp.commands, &base.join("commands"), plugin.name, "commands/"));
checks.push(check_docs_present("translated agents present", &comp.agents, &base.join("agents"), plugin.name, "agents/"));
checks
}
fn check_docs_present(name: &'static str, docs: &[MarkdownDoc], dir: &Path, plugin: &str, prefix: &str) -> DoctorCheck {
if docs.is_empty() {
return DoctorCheck { name, status: CheckStatus::Ok("nothing to translate".into()) };
}
let missing: Vec<String> =
docs.iter().map(|d| format!("{plugin}-{}.md", flat_stem(&d.rel, prefix))).filter(|f| !dir.join(f).exists()).collect();
if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} file(s) present", docs.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail { problem: format!("file(s) missing: {}", missing.join(", ")), fix: "run the host's `setup`".into() },
}
}
}
#[cfg(test)]
#[path = "../../tests/unit/cursor.rs"]
mod cursor_tests;