use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::confedit::{json_edit, json_obj_at, json_prune_at, json_prune_obj, json_remove, remove_file_idem, write_file_idem, yaml_quote};
use super::report;
use super::{AgentBackend, BackendState};
use crate::components::{MarkdownDoc, McpKind, McpServer};
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct OpencodeBackend;
impl AgentBackend for OpencodeBackend {
fn id(&self) -> &'static str {
"opencode"
}
fn detect(&self) -> bool {
which::which("opencode").is_ok() || dirs::config_dir().is_some_and(|c| c.join("opencode").is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: false,
commands: true,
agents: true,
skills: false,
instructions: true,
scopes: &["user", "project"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
let mcp =
if comp.mcp_servers.iter().any(|s| s.is_portable()) { Some(probe_mcp(&config_file(scope)?, &comp.mcp_servers)?) } else { None };
let base = surface_base(scope)?;
let commands =
report::probe_files(&expected_docs(&base, "commands", plugin.name, &comp.commands, |doc| doc.raw.clone()), |_, _| true)?;
let agents = report::probe_files(
&expected_docs(&base, "agents", plugin.name, &comp.agents, |doc| render_agent_md(doc).into_bytes()),
|_, _| true,
)?;
let (instr_file, instr_reg) = match &plugin.instructions {
Some(text) => (
report::probe_files(&[(instructions_file(scope, plugin.name)?, render_instructions(text))], |_, _| true)?,
report::probe_json_entries(
&config_file(scope)?,
&[(vec!["instructions".to_string()], Value::from(instructions_registration(scope, plugin.name)?))],
)?,
),
None => (None, None),
};
Ok(report::compose([mcp, commands, agents, instr_file, instr_reg].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 config = config_file(scope)?;
let base = surface_base(scope)?;
let mut changed = false;
changed |= reconcile_mcp(&config, &comp.mcp_servers, desired.reenable)?;
for doc in &comp.commands {
changed |= write_file_idem(&doc_path(&base, "commands", plugin.name, doc), &doc.raw)?;
}
for doc in &comp.agents {
changed |= write_file_idem(&doc_path(&base, "agents", plugin.name, doc), render_agent_md(doc).as_bytes())?;
}
if let Some(text) = &plugin.instructions {
changed |= write_file_idem(&instructions_file(scope, plugin.name)?, &render_instructions(text))?;
changed |= reconcile_instructions_entry(&config, &instructions_registration(scope, plugin.name)?)?;
}
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 config = config_file(scope)?;
let base = surface_base(scope)?;
let mut changed = false;
changed |= remove_mcp(&config, &portable_names(&comp.mcp_servers))?;
for doc in &comp.commands {
changed |= remove_file_idem(&doc_path(&base, "commands", plugin.name, doc))?;
}
for doc in &comp.agents {
changed |= remove_file_idem(&doc_path(&base, "agents", plugin.name, doc))?;
}
if plugin.instructions.is_some() {
changed |= remove_file_idem(&instructions_file(scope, plugin.name)?)?;
changed |= remove_instructions_entry(&config, &instructions_registration(scope, plugin.name)?)?;
}
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 opencode_config_dir() -> Result<PathBuf> {
dirs::config_dir()
.map(|c| c.join("opencode"))
.ok_or_else(|| Error::Tree("no config directory (HOME and XDG_CONFIG_HOME unset); cannot locate ~/.config/opencode".into()))
}
fn config_file(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => Ok(opencode_config_dir()?.join("opencode.json")),
Scope::Project { path } => Ok(path.join("opencode.json")),
}
}
fn surface_base(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => opencode_config_dir(),
Scope::Project { path } => Ok(path.join(".opencode")),
}
}
fn doc_path(base: &Path, subdir: &str, plugin: &str, doc: &MarkdownDoc) -> PathBuf {
let stem =
doc.rel.strip_prefix(subdir).unwrap_or(&doc.rel).trim_start_matches('/').strip_suffix(".md").unwrap_or(&doc.rel).replace('/', "-");
base.join(subdir).join(format!("{plugin}-{stem}.md"))
}
fn expected_docs(
base: &Path, subdir: &str, plugin: &str, docs: &[MarkdownDoc], render: impl Fn(&MarkdownDoc) -> Vec<u8>,
) -> Vec<(PathBuf, Vec<u8>)> {
docs.iter().map(|doc| (doc_path(base, subdir, plugin, doc), render(doc))).collect()
}
fn portable_names(servers: &[McpServer]) -> Vec<&str> {
servers.iter().filter(|s| s.is_portable()).map(|s| s.name.as_str()).collect()
}
fn render_mcp_server(server: &McpServer, enabled: bool) -> Value {
match &server.kind {
McpKind::Stdio => {
let mut command = Vec::with_capacity(1 + server.args.len());
command.push(Value::from(server.command.clone()));
command.extend(server.args.iter().map(|a| Value::from(a.clone())));
let env: Map<String, Value> = server.env.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect();
let mut obj = Map::new();
obj.insert("type".into(), Value::from("local"));
obj.insert("command".into(), Value::Array(command));
obj.insert("enabled".into(), Value::Bool(enabled));
obj.insert("environment".into(), Value::Object(env));
Value::Object(obj)
}
McpKind::Http { url } | McpKind::Sse { url } => {
let mut obj = Map::new();
obj.insert("type".into(), Value::from("remote"));
obj.insert("url".into(), Value::from(url.clone()));
obj.insert("enabled".into(), Value::Bool(enabled));
Value::Object(obj)
}
}
}
fn reconcile_mcp(config: &Path, servers: &[McpServer], reenable: bool) -> Result<bool> {
let portable: Vec<&McpServer> = servers.iter().filter(|s| s.is_portable()).collect();
if portable.is_empty() {
return Ok(false);
}
json_edit(config, |root| {
let obj = json_obj_at(root, &["mcp"]);
for server in &portable {
let currently_disabled = obj.get(&server.name).and_then(|v| v.get("enabled")).and_then(Value::as_bool) == Some(false);
let enabled = reenable || !currently_disabled;
obj.insert(server.name.clone(), render_mcp_server(server, enabled));
}
Ok(())
})
}
fn remove_mcp(config: &Path, names: &[&str]) -> Result<bool> {
if !config.exists() || names.is_empty() {
return Ok(false);
}
json_remove(config, |root| {
json_prune_obj(root, &["mcp"], |obj| {
for name in names {
obj.remove(*name);
}
Ok(())
})
.map(|_| ())
})
}
fn probe_mcp(config: &Path, servers: &[McpServer]) -> Result<BackendState> {
let bytes = match fs::read(config) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BackendState::Absent),
Err(source) => return Err(Error::Io { context: format!("reading {}", config.display()), source }),
};
let root: Value =
serde_json::from_slice(&bytes).map_err(|e| Error::Config { path: config.display().to_string(), detail: e.to_string() })?;
let portable: Vec<&McpServer> = servers.iter().filter(|s| s.is_portable()).collect();
if portable.is_empty() {
return Ok(BackendState::Healthy);
}
let obj = root.get("mcp").and_then(Value::as_object);
let mut present = 0usize;
let mut enabled = 0usize;
let mut disabled = 0usize;
for server in &portable {
if let Some(existing) = obj.and_then(|o| o.get(&server.name)) {
present += 1;
if *existing == render_mcp_server(server, true) {
enabled += 1;
} else if *existing == render_mcp_server(server, false) {
disabled += 1;
}
}
}
Ok(if present == 0 {
BackendState::Absent
} else if disabled == portable.len() {
BackendState::Disabled
} else if enabled == portable.len() {
BackendState::Healthy
} else {
BackendState::NeedsRepair
})
}
fn instructions_file(scope: &Scope, plugin: &str) -> Result<PathBuf> {
Ok(surface_base(scope)?.join(format!("{plugin}-instructions.md")))
}
fn instructions_registration(scope: &Scope, plugin: &str) -> Result<String> {
match scope {
Scope::User => Ok(instructions_file(scope, plugin)?.to_string_lossy().into_owned()),
Scope::Project { .. } => Ok(format!(".opencode/{plugin}-instructions.md")),
}
}
fn render_instructions(text: &str) -> Vec<u8> {
let mut out = text.to_string();
if !out.ends_with('\n') {
out.push('\n');
}
out.into_bytes()
}
fn reconcile_instructions_entry(config: &Path, entry: &str) -> Result<bool> {
json_edit(config, |root| {
let obj = json_obj_at(root, &[]);
let list = obj.entry("instructions".to_string()).or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(arr) = list
&& !arr.iter().any(|e| e.as_str() == Some(entry))
{
arr.push(Value::from(entry));
}
Ok(())
})
}
fn remove_instructions_entry(config: &Path, entry: &str) -> Result<bool> {
if !config.exists() {
return Ok(false);
}
json_remove(config, |root| {
json_prune_at(root, &["instructions"], |list| {
if let Some(arr) = list.as_array_mut() {
arr.retain(|e| e.as_str() != Some(entry));
}
Ok(())
})
.map(|_| ())
})
}
fn render_agent_md(doc: &MarkdownDoc) -> String {
let mut out = String::from("---\n");
if let Some(desc) = doc.frontmatter.get("description").and_then(Value::as_str) {
let _ = writeln!(out, "description: {}", yaml_quote(desc));
}
out.push_str("mode: subagent\n---\n\n");
out.push_str(doc.body.trim_start_matches(['\n', '\r']));
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn report_checks(backend: &OpencodeBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "opencode detected", status: CheckStatus::Ok("`opencode` on PATH or ~/.config/opencode present".into()) }
} else {
DoctorCheck {
name: "opencode detected",
status: CheckStatus::Fail {
problem: "opencode CLI not detected".into(),
fix: "install it with `npm install -g opencode-ai`".into(),
},
}
});
let config = match config_file(&Scope::User) {
Ok(config) => config,
Err(e) => {
checks.push(DoctorCheck { name: "config file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
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 opencode.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
let base = match surface_base(&Scope::User) {
Ok(base) => base,
Err(e) => {
checks.push(DoctorCheck { name: "translated files present", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
checks.push(check_docs_present("translated commands present", &comp.commands, &base, "commands", plugin.name));
checks.push(check_docs_present("translated agents present", &comp.agents, &base, "agents", plugin.name));
if plugin.instructions.is_some() {
checks.push(match instructions_file(&Scope::User, plugin.name) {
Ok(f) if f.exists() => {
DoctorCheck { name: "instructions file present", status: CheckStatus::Ok(format!("{} present", f.display())) }
}
Ok(f) => DoctorCheck {
name: "instructions file present",
status: CheckStatus::Fail { problem: format!("{} missing", f.display()), fix: "run the host's `setup`".into() },
},
Err(e) => DoctorCheck { name: "instructions file present", status: CheckStatus::Warn(e.to_string()) },
});
let name = "instructions registered";
checks.push(match instructions_registration(&Scope::User, plugin.name) {
Ok(reg) => {
let registered = root
.as_ref()
.and_then(|r| r.get("instructions"))
.and_then(Value::as_array)
.is_some_and(|a| a.iter().any(|e| e.as_str() == Some(reg.as_str())));
if registered {
DoctorCheck { name, status: CheckStatus::Ok("registered in opencode.json `instructions[]`".into()) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: "guidance file not registered in opencode.json `instructions[]`".into(),
fix: "run the host's `setup`".into(),
},
}
}
}
Err(e) => DoctorCheck { name, status: CheckStatus::Warn(e.to_string()) },
});
}
checks
}
fn check_docs_present(name: &'static str, docs: &[MarkdownDoc], base: &Path, subdir: &str, plugin: &str) -> DoctorCheck {
if docs.is_empty() {
return DoctorCheck { name, status: CheckStatus::Ok("nothing to translate".into()) };
}
let missing: Vec<String> =
docs.iter().map(|doc| doc_path(base, subdir, plugin, doc)).filter(|p| !p.exists()).map(|p| p.display().to_string()).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() },
}
}
}