use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::mcpjson::{self, RemoteShape, ServerShape};
use super::report;
use super::skillsdir;
use super::{AgentBackend, BackendState};
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
const MCP_KEY: &[&str] = &["mcp", "servers"];
const SHAPE: ServerShape = ServerShape::plain().with_remote(RemoteShape::UrlHeadersTransport);
pub(crate) struct OpenclawBackend;
impl AgentBackend for OpenclawBackend {
fn id(&self) -> &'static str {
"openclaw"
}
fn detect(&self) -> bool {
which::which("openclaw").is_ok() || config_path_opt().is_some_and(|p| p.parent().is_some_and(Path::is_dir))
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: false,
commands: false,
agents: false,
skills: true,
instructions: false,
scopes: &["user"],
}
}
fn probe(&self, plugin: &Plugin, _scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
let mcp = mcpjson::probe(&config_path()?, MCP_KEY, &comp.mcp_servers, SHAPE)?;
let skills = skillsdir::probe(&skills_root()?, plugin, &comp.skills)?;
Ok(report::compose([Some(mcp), 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 mut changed = mcpjson::reconcile(&config_path()?, MCP_KEY, &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= skillsdir::reconcile(&skills_root()?, 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 mut changed = mcpjson::remove(&config_path()?, MCP_KEY, &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= skillsdir::remove(&skills_root()?, 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 config_path_opt() -> Option<PathBuf> {
config_path_from(
env_nonempty("OPENCLAW_CONFIG_PATH").map(PathBuf::from),
env_nonempty("OPENCLAW_STATE_DIR").map(PathBuf::from),
env_nonempty("OPENCLAW_HOME").map(PathBuf::from),
dirs::home_dir(),
)
}
fn config_path_from(
config_path: Option<PathBuf>, state_dir: Option<PathBuf>, home_override: Option<PathBuf>, home: Option<PathBuf>,
) -> Option<PathBuf> {
config_path
.or_else(|| state_dir.map(|d| d.join("openclaw.json")))
.or_else(|| home_override.map(home_config))
.or_else(|| home.map(home_config))
}
fn home_config(home: PathBuf) -> PathBuf {
home.join(".openclaw").join("openclaw.json")
}
fn config_path() -> Result<PathBuf> {
config_path_opt().ok_or_else(|| {
Error::Tree("no home dir (HOME/OPENCLAW_STATE_DIR/OPENCLAW_HOME/OPENCLAW_CONFIG_PATH unset); cannot locate ~/.openclaw".into())
})
}
fn skills_root() -> Result<PathBuf> {
let config = config_path()?;
let base = config.parent().ok_or_else(|| Error::Tree("openclaw config path has no parent dir".into()))?;
Ok(base.join("skills"))
}
fn env_nonempty(var: &str) -> Option<std::ffi::OsString> {
std::env::var_os(var).filter(|v| !v.is_empty())
}
fn report_checks(backend: &OpenclawBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "openclaw detected", status: CheckStatus::Ok("`openclaw` on PATH or ~/.openclaw present".into()) }
} else {
DoctorCheck {
name: "openclaw detected",
status: CheckStatus::Fail {
problem: "openclaw CLI not detected".into(),
fix: "install it with `curl -fsSL https://openclaw.ai/install.sh | bash`".into(),
},
}
});
let config = match config_path() {
Ok(config) => config,
Err(e) => {
checks.push(DoctorCheck { name: "config file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let root = match fs::read(&config) {
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(v) => {
checks.push(DoctorCheck { name: "config file", status: CheckStatus::Ok(format!("{} parses", config.display())) });
Some(v)
}
Err(e) => {
checks.push(DoctorCheck {
name: "config file",
status: CheckStatus::Fail {
problem: format!("{} does not parse as strict JSON: {e}", config.display()),
fix: "remove JSON5-only comments/trailing commas or the file".into(),
},
});
None
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
checks.push(DoctorCheck {
name: "config file",
status: CheckStatus::Warn(format!("{} does not exist yet (run setup)", config.display())),
});
None
}
Err(e) => {
checks
.push(DoctorCheck { name: "config file", status: CheckStatus::Warn(format!("could not read {}: {e}", config.display())) });
None
}
};
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", "servers"],
"not under `mcp.servers` in openclaw.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks
}
#[cfg(test)]
#[path = "../../tests/unit/openclaw.rs"]
mod openclaw_tests;