use std::path::{Path, PathBuf};
use crate::doctor::DoctorReport;
use crate::error::Result;
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum Scope {
User,
Project {
path: PathBuf,
},
}
impl Scope {
pub(crate) fn as_cli(&self) -> &'static str {
match self {
Scope::User => "user",
Scope::Project { .. } => "project",
}
}
pub(crate) fn cwd(&self) -> Option<&Path> {
match self {
Scope::User => None,
Scope::Project { path } => Some(path),
}
}
pub(crate) fn key(&self) -> String {
match self {
Scope::User => "user".to_string(),
Scope::Project { path } => {
let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
format!("project:{}", resolved.display())
}
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Embedded,
GitHub {
repo: &'static str,
ref_: &'static str,
},
Path(PathBuf),
}
#[derive(Debug, Clone)]
pub struct Desired {
pub source: Source,
pub reenable: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
NoOp,
Installed,
Updated {
from: Option<String>,
to: String,
},
Repaired,
Adopted,
Removed,
Cleared,
}
impl std::fmt::Display for Outcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Outcome::NoOp => f.write_str("no changes needed"),
Outcome::Installed => f.write_str("installed"),
Outcome::Updated { from: Some(from), to } => write!(f, "updated ({from} -> {to})"),
Outcome::Updated { from: None, to } => write!(f, "updated (to {to})"),
Outcome::Repaired => f.write_str("repaired"),
Outcome::Adopted => f.write_str("adopted existing install"),
Outcome::Removed => f.write_str("removed"),
Outcome::Cleared => f.write_str("cleared stale marker"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct AgentReport {
pub results: Vec<AgentResult>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentResult {
pub agent: &'static str,
pub status: AgentStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AgentStatus {
Converged(Outcome),
Skipped(SkipReason),
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkipReason {
NotDetected,
ScopeUnsupported,
SourceUnsupported,
}
impl AgentReport {
pub(crate) fn new() -> Self {
Self { results: Vec::new() }
}
pub(crate) fn push(&mut self, agent: &'static str, status: AgentStatus) {
self.results.push(AgentResult { agent, status });
}
pub fn merged(&self) -> Outcome {
self.results
.iter()
.find_map(|result| match &result.status {
AgentStatus::Converged(outcome) if *outcome != Outcome::NoOp => Some(outcome.clone()),
_ => None,
})
.unwrap_or(Outcome::NoOp)
}
pub fn is_healthy(&self) -> bool {
!self.results.iter().any(|result| matches!(result.status, AgentStatus::Failed(_)))
}
pub(crate) fn into_merged(self) -> Result<Outcome> {
for result in &self.results {
if let AgentStatus::Failed(detail) = &result.status {
return Err(crate::error::Error::Backend { agent: result.agent.into(), detail: detail.clone() });
}
}
Ok(self.merged())
}
pub(crate) fn outcome_of(&self, agent: &str) -> Option<&Outcome> {
self.results.iter().find_map(|result| match &result.status {
AgentStatus::Converged(outcome) if result.agent == agent => Some(outcome),
_ => None,
})
}
}
impl std::fmt::Display for AgentReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for result in &self.results {
writeln!(f, "{}: {}", result.agent, result.status)?;
}
Ok(())
}
}
impl std::fmt::Display for AgentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentStatus::Converged(outcome) => outcome.fmt(f),
AgentStatus::Skipped(reason) => write!(f, "skipped ({reason})"),
AgentStatus::Failed(detail) => write!(f, "failed: {detail}"),
}
}
}
impl std::fmt::Display for SkipReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkipReason::NotDetected => f.write_str("not installed on this machine"),
SkipReason::ScopeUnsupported => f.write_str("no config surface at this scope"),
SkipReason::SourceUnsupported => f.write_str("cannot serve a github source; use an embedded or path source"),
}
}
}
#[derive(Debug, Clone)]
pub struct Capabilities {
pub plugins: bool,
pub mcp: bool,
pub hooks: bool,
pub commands: bool,
pub agents: bool,
pub skills: bool,
pub instructions: bool,
pub statusline: bool,
pub scopes: &'static [&'static str],
}
#[derive(Clone)]
pub struct Plugin {
pub name: &'static str,
pub marketplace: &'static str,
pub version: &'static str,
pub agents: &'static [&'static str],
pub instructions: Option<String>,
pub statusline: Option<crate::statusline::StatusLineDecl>,
pub(crate) blob: &'static [u8],
}
impl std::fmt::Debug for Plugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Plugin")
.field("name", &self.name)
.field("marketplace", &self.marketplace)
.field("version", &self.version)
.field("agents", &self.agents)
.field("instructions", &self.instructions)
.field("statusline", &self.statusline)
.finish_non_exhaustive()
}
}
impl Plugin {
pub fn id(&self) -> String {
format!("{}@{}", self.name, self.marketplace)
}
pub(crate) fn blob(&self) -> &'static [u8] {
self.blob
}
pub fn components(&self, source: &Source) -> Result<crate::components::PluginComponents> {
crate::components::PluginComponents::parse(&crate::materialize::entries_for(self, source)?)
}
}
pub trait PluginHost {
const NAME: &'static str;
const MARKETPLACE: &'static str;
const VERSION: &'static str;
const DEFAULT_SOURCE: Source;
const AGENTS: &'static [&'static str];
fn embedded_blob() -> &'static [u8];
fn instructions() -> Option<String> {
None
}
fn statusline() -> Option<crate::statusline::StatusLineDecl> {
None
}
fn descriptor() -> Plugin {
Plugin {
name: Self::NAME,
marketplace: Self::MARKETPLACE,
version: Self::VERSION,
agents: Self::AGENTS,
instructions: Self::instructions(),
statusline: Self::statusline(),
blob: Self::embedded_blob(),
}
}
fn install(scope: Scope, source: Source) -> Result<Outcome> {
Self::install_report(scope, source)?.into_merged()
}
fn install_report(scope: Scope, source: Source) -> Result<AgentReport> {
crate::install::install_report(&Self::descriptor(), scope, source, &[])
}
fn install_into(scope: Scope, source: Source, agents: &[&str]) -> Result<Outcome> {
Self::install_into_report(scope, source, agents)?.into_merged()
}
fn install_into_report(scope: Scope, source: Source, agents: &[&str]) -> Result<AgentReport> {
crate::install::install_report(&Self::descriptor(), scope, source, agents)
}
fn update(scope: Scope) -> Result<Outcome> {
Self::update_report(scope)?.into_merged()
}
fn update_report(scope: Scope) -> Result<AgentReport> {
crate::install::update_report(&Self::descriptor(), scope, Self::DEFAULT_SOURCE)
}
fn uninstall(scope: Scope) -> Result<Outcome> {
Self::uninstall_report(scope)?.into_merged()
}
fn uninstall_report(scope: Scope) -> Result<AgentReport> {
crate::install::uninstall_report(&Self::descriptor(), scope, Self::DEFAULT_SOURCE)
}
fn self_heal() -> Result<Outcome> {
Self::self_heal_report()?.into_merged()
}
fn self_heal_report() -> Result<AgentReport> {
crate::selfheal::self_heal_report(&Self::descriptor(), Self::DEFAULT_SOURCE)
}
fn restart_pending() -> Option<String> {
let plugin = Self::descriptor();
crate::restart::pending(&plugin).ok().flatten().map(|()| crate::restart::message(Self::NAME, Self::VERSION))
}
fn doctor() -> Result<DoctorReport> {
crate::doctor::doctor(&Self::descriptor(), &Self::DEFAULT_SOURCE)
}
}
pub(crate) fn data_root(plugin: &Plugin) -> Result<PathBuf> {
let base = dirs::data_dir().ok_or_else(|| crate::error::Error::Tree("no data directory (XDG_DATA_HOME and HOME both unset)".into()))?;
Ok(base.join(plugin.name))
}
#[cfg(test)]
#[path = "../tests/unit/host.rs"]
mod host_tests;