use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::cchooks::hook_is_portable;
use super::confedit::{json_edit, json_obj_at, json_prune_obj, json_remove, write_file_idem};
use super::mcpjson::{self, ServerShape};
use super::report;
use super::skillsdir;
use super::{AgentBackend, BackendState};
use crate::components::{HookBinding, MarkdownDoc, McpServer};
#[cfg(test)]
use crate::components::McpKind;
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, IoContext, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct CrushBackend;
impl AgentBackend for CrushBackend {
fn id(&self) -> &'static str {
"crush"
}
fn detect(&self) -> bool {
which::which("crush").is_ok() || crush_config_dir_opt().is_some_and(|d| d.is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: true,
commands: true,
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 config = config_file(scope)?;
let mcp = mcpjson::probe_surface(&config, &["mcp"], &comp.mcp_servers, ServerShape::typed())?;
let hooks = report::probe_json_entries(&config, &hook_entries(&comp.hooks))?;
let skills = skillsdir::probe(&skills_root(scope)?, plugin, &comp.skills)?;
let cmd_root = commands_root(scope)?.join(plugin.name);
let commands = report::probe_files(&expected_commands(&cmd_root, &comp.commands), |_, _| true)?;
Ok(report::compose([mcp, hooks, skills, commands].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_config(&config_file(scope)?, &comp.mcp_servers, &comp.hooks)?;
changed |= skillsdir::reconcile(&skills_root(scope)?, plugin, &comp.skills)?;
let cmd_root = commands_root(scope)?.join(plugin.name);
for doc in &comp.commands {
changed |= write_file_idem(&cmd_root.join(command_rel(doc)), render_command(doc).as_bytes())?;
}
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_config(&config_file(scope)?, &portable_names(&comp.mcp_servers), &comp.hooks)?;
changed |= skillsdir::remove(&skills_root(scope)?, plugin, &comp.skills)?;
let cmd_root = commands_root(scope)?.join(plugin.name);
if cmd_root.exists() {
fs::remove_dir_all(&cmd_root).io_ctx(|| format!("removing {}", cmd_root.display()))?;
changed = true;
}
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 crush_config_dir_opt() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("CRUSH_GLOBAL_CONFIG").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(dir));
}
dirs::config_local_dir().map(|c| c.join("crush"))
}
fn crush_config_dir() -> Result<PathBuf> {
crush_config_dir_opt().ok_or_else(|| {
Error::Tree("no config directory (HOME/XDG_CONFIG_HOME/CRUSH_GLOBAL_CONFIG unset); cannot locate ~/.config/crush".into())
})
}
fn config_file(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => Ok(crush_config_dir()?.join("crush.json")),
Scope::Project { path } => Ok(path.join("crush.json")),
}
}
fn skills_root(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => Ok(crush_config_dir()?.join("skills")),
Scope::Project { path } => Ok(path.join(".crush").join("skills")),
}
}
fn commands_root(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => Ok(crush_config_dir()?.join("commands")),
Scope::Project { path } => Ok(path.join(".crush").join("commands")),
}
}
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> {
cc_event.eq_ignore_ascii_case("PreToolUse").then_some("PreToolUse")
}
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 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_config(config: &Path, servers: &[McpServer], hooks: &[HookBinding]) -> Result<bool> {
let portable: Vec<&McpServer> = servers.iter().filter(|s| s.is_portable()).collect();
let writable_hooks: Vec<(&'static str, &HookBinding)> =
hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event).map(|e| (e, h))).collect();
if portable.is_empty() && writable_hooks.is_empty() {
return Ok(false);
}
json_edit(config, |root| {
if !portable.is_empty() {
let mcp = json_obj_at(root, &["mcp"]);
for server in &portable {
if let Some(body) = mcpjson::render_server(server, ServerShape::typed()) {
mcp.insert(server.name.clone(), body);
}
}
}
if !writable_hooks.is_empty() {
let events = json_obj_at(root, &["hooks"]);
for (event, hook) in &writable_hooks {
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_config(config: &Path, server_names: &[&str], 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();
let managed: BTreeSet<&str> = hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event)).collect();
json_remove(config, |root| {
json_prune_obj(root, &["mcp"], |mcp| {
for name in server_names {
mcp.remove(*name);
}
Ok(())
})?;
json_prune_obj(root, &["hooks"], |events| {
let mut emptied: Vec<String> = Vec::new();
for event in &managed {
if let Some(arr) = events.get_mut(*event).and_then(Value::as_array_mut) {
let before = arr.len();
arr.retain(|e| e.get("command").and_then(Value::as_str).is_none_or(|c| !ours.contains(c)));
if arr.len() < before && arr.is_empty() {
emptied.push((*event).to_string());
}
}
}
for event in emptied {
events.remove(&event);
}
Ok(())
})?;
Ok(())
})
}
fn command_rel(doc: &MarkdownDoc) -> String {
doc.rel.strip_prefix("commands/").unwrap_or(&doc.rel).to_string()
}
fn render_command(doc: &MarkdownDoc) -> String {
let mut out = doc.body.trim().to_string();
out.push('\n');
out
}
fn expected_commands(cmd_root: &Path, commands: &[MarkdownDoc]) -> Vec<(PathBuf, Vec<u8>)> {
commands.iter().map(|doc| (cmd_root.join(command_rel(doc)), render_command(doc).into_bytes())).collect()
}
fn report_checks(backend: &CrushBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "crush detected", status: CheckStatus::Ok("`crush` on PATH or ~/.config/crush present".into()) }
} else {
DoctorCheck {
name: "crush detected",
status: CheckStatus::Fail {
problem: "crush CLI not detected".into(),
fix: "install it with `npm install -g @charmland/crush`".into(),
},
}
});
let base = match crush_config_dir() {
Ok(base) => base,
Err(e) => {
checks.push(DoctorCheck { name: "config file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let config = base.join("crush.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(),
&["mcp"],
"not under `mcp` in crush.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks.push(check_hooks_present(&comp.hooks, root.as_ref()));
checks.push(check_commands_present(&comp.commands, &base.join("commands").join(plugin.name)));
checks
}
fn check_hooks_present(hooks: &[HookBinding], root: Option<&Value>) -> DoctorCheck {
let name = "translated hooks present";
let skipped = report::skipped_hooks(hooks);
let ours: Vec<&str> =
hooks.iter().filter(|h| hook_is_portable(h) && map_event(&h.event).is_some()).map(|h| h.command.as_str()).collect();
if ours.is_empty() {
return report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok("no PreToolUse hooks to translate".into()) }, &skipped);
}
let commands: BTreeSet<&str> = root
.and_then(|r| r.get("hooks"))
.and_then(|h| h.get("PreToolUse"))
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(|e| e.get("command").and_then(Value::as_str)).collect())
.unwrap_or_default();
let missing: Vec<&str> = ours.iter().copied().filter(|c| !commands.contains(c)).collect();
let check = if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} PreToolUse hook(s) present", ours.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("PreToolUse hook(s) missing from crush.json: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
}
};
report::note_skipped(check, &skipped)
}
fn check_commands_present(commands: &[MarkdownDoc], cmd_root: &Path) -> DoctorCheck {
let name = "translated commands present";
if commands.is_empty() {
return DoctorCheck { name, status: CheckStatus::Ok("no commands to translate".into()) };
}
let missing: Vec<String> = commands.iter().map(command_rel).filter(|rel| !cmd_root.join(rel).exists()).collect();
if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} command file(s) present", commands.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("command file(s) missing: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
}
}
}
#[cfg(test)]
#[path = "../../tests/unit/crush.rs"]
mod crush_tests;