use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::cchooks::{hook_is_portable, remove_hook_groups, render_hook_group};
use super::confedit::{json_edit, json_obj_at, json_prune_obj, json_remove, write_file_idem, yaml_scalar};
use super::mcpjson::{self, RemoteShape, 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, IoContext, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct DevinBackend;
const SHAPE: ServerShape = ServerShape::plain().with_remote(RemoteShape::TransportKeyed);
impl AgentBackend for DevinBackend {
fn id(&self) -> &'static str {
"devin"
}
fn detect(&self) -> bool {
which::which("devin").is_ok() || user_config_base().is_some_and(|b| b.is_dir()) || project_marker_present()
}
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> {
let comp = plugin.components(source)?.with_client(self.id());
let base = config_base(scope)?;
let config = base.join("config.json");
let mcp = mcpjson::probe_surface(&config, &["mcpServers"], &comp.mcp_servers, SHAPE)?;
let hooks = report::probe_json_entries(&config, &hook_entries(&comp.hooks))?;
let commands =
report::probe_files(&expected_docs(&base, "skills", "SKILL.md", "commands/", plugin.name, &comp.commands), |_, _| true)?;
let agents = report::probe_files(&expected_docs(&base, "agents", "AGENT.md", "agents/", plugin.name, &comp.agents), |_, _| true)?;
let skills = skillsdir::probe(&skillsdir::agents_skills_root(scope)?, 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> {
let comp = plugin.components(&desired.source)?.with_client(self.id());
let base = config_base(scope)?;
let config = base.join("config.json");
let mut changed = false;
changed |= mcpjson::reconcile(&config, &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= reconcile_hooks(&config, &comp.hooks)?;
for doc in &comp.commands {
let path = base.join("skills").join(namespaced(plugin.name, &doc.rel, "commands/")).join("SKILL.md");
changed |= write_file_idem(&path, render_doc(plugin.name, &doc.rel, "commands/", doc).as_bytes())?;
}
for doc in &comp.agents {
let path = base.join("agents").join(namespaced(plugin.name, &doc.rel, "agents/")).join("AGENT.md");
changed |= write_file_idem(&path, render_doc(plugin.name, &doc.rel, "agents/", doc).as_bytes())?;
}
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 base = config_base(scope)?;
let config = base.join("config.json");
let mut changed = false;
changed |= mcpjson::remove(&config, &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= remove_hooks(&config, &comp.hooks)?;
for (subdir, prefix, docs) in [("skills", "commands/", &comp.commands), ("agents", "agents/", &comp.agents)] {
for doc in docs {
let dir = base.join(subdir).join(namespaced(plugin.name, &doc.rel, prefix));
if dir.exists() {
fs::remove_dir_all(&dir).io_ctx(|| format!("removing {}", dir.display()))?;
changed = true;
}
}
}
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_base() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.map(|c| c.join("devin"))
}
#[cfg(not(target_os = "macos"))]
{
dirs::config_dir().map(|c| c.join("devin"))
}
}
fn config_base(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => user_config_base()
.ok_or_else(|| Error::Tree("no config directory (XDG_CONFIG_HOME and HOME both unset); cannot locate ~/.config/devin".into())),
Scope::Project { path } => Ok(path.join(".devin")),
}
}
fn project_marker_present() -> bool {
std::env::current_dir().is_ok_and(|d| d.join(".devin").is_dir() || d.join(".cognition").is_dir())
}
fn namespaced(plugin: &str, rel: &str, prefix: &str) -> String {
let stripped = rel.strip_prefix(prefix).unwrap_or(rel);
let stem = stripped.strip_suffix(".md").unwrap_or(stripped);
format!("{plugin}-{}", stem.replace(['/', '\\'], "-"))
}
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"),
"Stop" => Some("Stop"),
_ => 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_group(h))))
.collect()
}
fn expected_docs(base: &Path, subdir: &str, file: &str, prefix: &str, plugin: &str, docs: &[MarkdownDoc]) -> Vec<(PathBuf, Vec<u8>)> {
docs.iter()
.map(|doc| {
let path = base.join(subdir).join(namespaced(plugin, &doc.rel, prefix)).join(file);
(path, render_doc(plugin, &doc.rel, prefix, doc).into_bytes())
})
.collect()
}
fn reconcile_hooks(config: &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(config, |root| {
let events = json_obj_at(root, &["hooks"]);
for (event, hook) in &writable {
let group = render_hook_group(hook);
let entry = events.entry((*event).to_string()).or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(list) = entry
&& !list.iter().any(|g| g == &group)
{
list.push(group);
}
}
Ok(())
})
}
fn remove_hooks(config: &Path, hooks: &[HookBinding]) -> Result<bool> {
if !config.exists() {
return Ok(false);
}
let ours: BTreeSet<&str> =
hooks.iter().filter(|h| hook_is_portable(h) && map_event(&h.event).is_some()).map(|h| h.command.as_str()).collect();
json_remove(config, |root| {
json_prune_obj(root, &["hooks"], |events| {
remove_hook_groups(events, &ours);
Ok(())
})
.map(|_| ())
})
}
fn render_doc(plugin: &str, rel: &str, prefix: &str, doc: &MarkdownDoc) -> String {
let mut out = String::from("---\n");
let _ = writeln!(out, "name: {}", yaml_scalar(&namespaced(plugin, rel, prefix)));
for (key, value) in &doc.frontmatter {
if key == "name" {
continue; }
let _ = writeln!(out, "{key}: {}", yaml_value(value));
}
out.push_str("---\n\n");
out.push_str(doc.body.trim());
out.push('\n');
out
}
fn yaml_value(value: &Value) -> String {
match value {
Value::String(s) => yaml_scalar(s),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
other => yaml_scalar(&other.to_string()),
}
}
fn report_checks(backend: &DevinBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "devin detected", status: CheckStatus::Ok("`devin` on PATH or a devin config dir present".into()) }
} else {
DoctorCheck {
name: "devin detected",
status: CheckStatus::Fail {
problem: "devin not detected".into(),
fix: "install it with `curl -fsSL https://cli.devin.ai/install.sh | bash`".into(),
},
}
});
let base = match config_base(&Scope::User) {
Ok(base) => base,
Err(e) => {
checks.push(DoctorCheck { name: "config file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let config = base.join("config.json");
let root = report::read_json_config(&mut checks, "config file", &config);
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 config.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks.push(check_docs_present("skills", "SKILL.md", "commands/", &comp.commands, plugin.name, &base));
checks.push(check_docs_present("agents", "AGENT.md", "agents/", &comp.agents, plugin.name, &base));
checks
}
fn check_docs_present(subdir: &str, file: &str, prefix: &str, docs: &[MarkdownDoc], plugin: &str, base: &Path) -> DoctorCheck {
let name: &'static str = if subdir == "skills" { "translated skills present" } else { "translated subagents present" };
if docs.is_empty() {
return DoctorCheck { name, status: CheckStatus::Ok(format!("no {subdir} to translate")) };
}
let missing: Vec<String> =
docs.iter().map(|d| namespaced(plugin, &d.rel, prefix)).filter(|dir| !base.join(subdir).join(dir).join(file).exists()).collect();
if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} {subdir} file(s) present", docs.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("{subdir} file(s) missing: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
}
}
}