use std::ffi::{OsStr, OsString};
use std::fmt::Write as _;
use std::fs;
use std::path::{Component, Path, PathBuf};
use serde_json::Value;
use super::ccregistry::registry_lists_plugin;
use super::confedit::{remove_file_idem, write_file_idem, yaml_scalar};
use super::mcpjson::{self, ServerShape};
use super::report;
use super::{AgentBackend, BackendState};
use crate::components::MarkdownDoc;
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct OmpBackend;
impl AgentBackend for OmpBackend {
fn id(&self) -> &'static str {
"omp"
}
fn detect(&self) -> bool {
which::which("omp").is_ok() || omp_root().is_some_and(|r| r.is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: false,
commands: true,
agents: true,
skills: false,
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 = surface_base(scope)?;
let mcp = mcpjson::probe_surface(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, ServerShape::plain())?;
let commands = report::probe_files(
&expected_docs(&base.join("commands"), plugin.name, "commands/", &comp.commands, |doc| doc.raw.clone()),
|_, _| true,
)?;
let agents = if cc_registry_covers_agents(plugin) {
None
} else {
report::probe_files(
&expected_docs(&base.join("agents"), plugin.name, "agents/", &comp.agents, |doc| {
render_agent(plugin.name, doc).into_bytes()
}),
|_, _| true,
)?
};
Ok(report::compose([mcp, commands, agents].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 = surface_base(scope)?;
let mut changed = false;
changed |= mcpjson::reconcile(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, ServerShape::plain())? != Outcome::NoOp;
let cmd_root = base.join("commands");
for doc in &comp.commands {
changed |= write_file_idem(&cmd_root.join(doc_file(plugin.name, &doc.rel, "commands/")), &doc.raw)?;
}
if !cc_registry_covers_agents(plugin) {
let agent_root = base.join("agents");
for doc in &comp.agents {
changed |= write_file_idem(
&agent_root.join(doc_file(plugin.name, &doc.rel, "agents/")),
render_agent(plugin.name, 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 base = surface_base(scope)?;
let mut changed = false;
changed |= mcpjson::remove(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, ServerShape::plain())? != Outcome::NoOp;
let cmd_root = base.join("commands");
for doc in &comp.commands {
changed |= remove_file_idem(&cmd_root.join(doc_file(plugin.name, &doc.rel, "commands/")))?;
}
let agent_root = base.join("agents");
for doc in &comp.agents {
changed |= remove_file_idem(&agent_root.join(doc_file(plugin.name, &doc.rel, "agents/")))?;
}
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_dir_name() -> OsString {
std::env::var_os("PI_CONFIG_DIR").filter(|v| !v.is_empty()).unwrap_or_else(|| ".omp".into())
}
fn omp_root() -> Option<PathBuf> {
resolve_omp_root(config_dir_name().as_os_str(), dirs::home_dir().as_deref())
}
fn resolve_omp_root(name: &OsStr, home: Option<&Path>) -> Option<PathBuf> {
let relative: PathBuf = Path::new(name).components().filter(|c| !matches!(c, Component::RootDir | Component::Prefix(_))).collect();
home.map(|h| h.join(relative))
}
fn surface_base(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => omp_root()
.map(|r| r.join("agent"))
.ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/.omp/agent".into())),
Scope::Project { path } => Ok(path.join(".omp")),
}
}
fn doc_file(plugin: &str, rel: &str, prefix: &str) -> String {
format!("{plugin}-{}.md", flat_stem(rel, prefix))
}
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 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(doc_file(plugin, &doc.rel, prefix)), render(doc))).collect()
}
fn render_agent(plugin: &str, doc: &MarkdownDoc) -> String {
let mut out = String::from("---\n");
let _ = writeln!(out, "name: {}", yaml_scalar(&format!("{plugin}-{}", flat_stem(&doc.rel, "agents/"))));
if let Some(desc) = doc.frontmatter.get("description").and_then(Value::as_str) {
let _ = writeln!(out, "description: {}", yaml_scalar(desc));
}
out.push_str("---\n\n");
out.push_str(doc.body.trim());
out.push('\n');
out
}
const CLAUDE_PLUGINS_PROVIDER: &str = "claude-plugins";
fn cc_registry_covers_agents(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()) && omp_claude_plugins_provider_enabled()
}
fn omp_claude_plugins_provider_enabled() -> bool {
let Some(agent) = omp_root().map(|r| r.join("agent")) else {
return true;
};
!["config.yml", "config.yaml"].iter().any(|name| config_disables_claude_plugins(&agent.join(name)))
}
fn config_disables_claude_plugins(path: &Path) -> bool {
let Some(root) = fs::read(path).ok().and_then(|b| serde_norway::from_slice::<serde_norway::Value>(&b).ok()) else {
return false;
};
root.get("disabledProviders").is_some_and(|v| yaml_has_string_leaf(v, CLAUDE_PLUGINS_PROVIDER))
}
fn yaml_has_string_leaf(value: &serde_norway::Value, needle: &str) -> bool {
use serde_norway::Value as Yaml;
match value {
Yaml::String(s) => s == needle,
Yaml::Sequence(seq) => seq.iter().any(|v| yaml_has_string_leaf(v, needle)),
Yaml::Mapping(map) => map.iter().any(|(_, v)| yaml_has_string_leaf(v, needle)),
_ => false,
}
}
fn report_checks(backend: &OmpBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "omp detected", status: CheckStatus::Ok("`omp` on PATH or ~/.omp present".into()) }
} else {
DoctorCheck {
name: "omp detected",
status: CheckStatus::Fail {
problem: "omp not detected".into(),
fix: "install it with `curl -fsSL https://omp.sh/install | sh`".into(),
},
}
});
let base = match surface_base(&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(if cc_registry_covers_agents(plugin) {
DoctorCheck {
name: "translated agents present",
status: CheckStatus::Ok("covered by Claude Code's plugin registry; not translated".into()),
}
} else {
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| doc_file(plugin, &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/omp.rs"]
mod omp_tests;