pleiades-agent-plugins 2.0.0

Pleiades plugin manifests, lifecycle management, and shell hooks
Documentation
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

pub const MANIFEST_FILE_NAME: &str = "plugin.json";
pub const MANIFEST_RELATIVE_PATH: &str = ".pleiades-plugin/plugin.json";

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct PluginHooks {
    #[serde(rename = "PreToolUse", default)]
    pub pre_tool_use: Vec<String>,
    #[serde(rename = "PostToolUse", default)]
    pub post_tool_use: Vec<String>,
    #[serde(rename = "PostToolUseFailure", default)]
    pub post_tool_use_failure: Vec<String>,
}

impl PluginHooks {
    pub fn is_empty(&self) -> bool {
        self.pre_tool_use.is_empty()
            && self.post_tool_use.is_empty()
            && self.post_tool_use_failure.is_empty()
    }

    pub fn merged_with(&self, other: &Self) -> Self {
        let mut merged = self.clone();
        merged
            .pre_tool_use
            .extend(other.pre_tool_use.iter().cloned());
        merged
            .post_tool_use
            .extend(other.post_tool_use.iter().cloned());
        merged
            .post_tool_use_failure
            .extend(other.post_tool_use_failure.iter().cloned());
        merged
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginLifecycle {
    #[serde(rename = "Init", default)]
    pub init: Vec<String>,
    #[serde(rename = "Shutdown", default)]
    pub shutdown: Vec<String>,
}

impl PluginLifecycle {
    pub fn is_empty(&self) -> bool {
        self.init.is_empty() && self.shutdown.is_empty()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginPermission {
    Read,
    Write,
    Execute,
}

impl PluginPermission {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Read => "read",
            Self::Write => "write",
            Self::Execute => "execute",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginToolManifest {
    pub name: String,
    pub description: String,
    #[serde(rename = "inputSchema")]
    pub input_schema: serde_json::Value,
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(rename = "requiredPermission", default = "default_tool_permission")]
    pub required_permission: PluginToolPermission,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginToolPermission {
    ReadOnly,
    WorkspaceWrite,
    DangerFullAccess,
}

impl PluginToolPermission {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::ReadOnly => "read-only",
            Self::WorkspaceWrite => "workspace-write",
            Self::DangerFullAccess => "danger-full-access",
        }
    }
}

fn default_tool_permission() -> PluginToolPermission {
    PluginToolPermission::DangerFullAccess
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginCommandManifest {
    pub name: String,
    pub description: String,
    pub command: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
    pub name: String,
    pub version: String,
    pub description: String,
    #[serde(default)]
    pub permissions: Vec<String>,
    #[serde(rename = "defaultEnabled", default)]
    pub default_enabled: bool,
    #[serde(default)]
    pub hooks: PluginHooks,
    #[serde(default)]
    pub lifecycle: PluginLifecycle,
    #[serde(default)]
    pub tools: Vec<PluginToolManifest>,
    #[serde(default)]
    pub commands: Vec<PluginCommandManifest>,
}

impl PluginManifest {
    pub fn validate(&self, root: &Path) -> Result<(), PluginError> {
        let mut errors = Vec::new();

        if self.name.trim().is_empty() {
            errors.push(PluginErrorKind::EmptyField { field: "name" });
        }
        if self.version.trim().is_empty() {
            errors.push(PluginErrorKind::EmptyField { field: "version" });
        }
        if self.description.trim().is_empty() {
            errors.push(PluginErrorKind::EmptyField {
                field: "description",
            });
        }

        let mut seen_perms = BTreeSet::new();
        for perm in &self.permissions {
            if !seen_perms.insert(perm.clone()) {
                errors.push(PluginErrorKind::DuplicatePermission {
                    permission: perm.clone(),
                });
            }
            match perm.as_str() {
                "read" | "write" | "execute" => {}
                other => errors.push(PluginErrorKind::InvalidPermission {
                    permission: other.to_string(),
                }),
            }
        }

        Self::validate_paths(root, &self.hooks.pre_tool_use, "hook", &mut errors);
        Self::validate_paths(root, &self.hooks.post_tool_use, "hook", &mut errors);
        Self::validate_paths(root, &self.hooks.post_tool_use_failure, "hook", &mut errors);
        Self::validate_paths(root, &self.lifecycle.init, "lifecycle", &mut errors);
        Self::validate_paths(root, &self.lifecycle.shutdown, "lifecycle", &mut errors);

        if !errors.is_empty() {
            return Err(PluginError::Validation(errors));
        }
        Ok(())
    }

    fn validate_paths(
        root: &Path,
        paths: &[String],
        kind: &str,
        errors: &mut Vec<PluginErrorKind>,
    ) {
        for path in paths {
            let full_path = if Path::new(path).is_absolute() {
                PathBuf::from(path)
            } else {
                root.join(path)
            };
            if !full_path.exists() {
                errors.push(PluginErrorKind::MissingPath {
                    kind: kind.to_string(),
                    path: full_path,
                });
            }
        }
    }

    pub fn load_from_directory(root: &Path) -> Result<Self, PluginError> {
        let manifest_path = find_manifest_path(root)?;
        let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
            PluginError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                format!(
                    "plugin manifest not found at {}: {e}",
                    manifest_path.display()
                ),
            ))
        })?;

        let manifest: Self = serde_json::from_str(&content)?;
        manifest.validate(root)?;
        Ok(manifest)
    }
}

fn find_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
    let direct = root.join(MANIFEST_FILE_NAME);
    if direct.exists() {
        return Ok(direct);
    }
    let packaged = root.join(MANIFEST_RELATIVE_PATH);
    if packaged.exists() {
        return Ok(packaged);
    }
    Err(PluginError::Io(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        format!(
            "plugin manifest not found at {} or {}",
            direct.display(),
            packaged.display()
        ),
    )))
}

#[derive(Debug)]
pub enum PluginError {
    Io(std::io::Error),
    Json(serde_json::Error),
    Validation(Vec<PluginErrorKind>),
    NotFound(String),
    CommandFailed(String),
}

impl std::fmt::Display for PluginError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(e) => write!(f, "{e}"),
            Self::Json(e) => write!(f, "{e}"),
            Self::Validation(errors) => {
                for (i, e) in errors.iter().enumerate() {
                    if i > 0 {
                        write!(f, "; ")?;
                    }
                    write!(f, "{e}")?;
                }
                Ok(())
            }
            Self::NotFound(msg) | Self::CommandFailed(msg) => write!(f, "{msg}"),
        }
    }
}

impl std::error::Error for PluginError {}

impl From<std::io::Error> for PluginError {
    fn from(e: std::io::Error) -> Self {
        Self::Io(e)
    }
}

impl From<serde_json::Error> for PluginError {
    fn from(e: serde_json::Error) -> Self {
        Self::Json(e)
    }
}

#[derive(Debug, Clone)]
pub enum PluginErrorKind {
    EmptyField { field: &'static str },
    InvalidPermission { permission: String },
    DuplicatePermission { permission: String },
    MissingPath { kind: String, path: PathBuf },
}

impl std::fmt::Display for PluginErrorKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::EmptyField { field } => write!(f, "plugin manifest {field} cannot be empty"),
            Self::InvalidPermission { permission } => {
                write!(
                    f,
                    "invalid permission `{permission}`, must be read/write/execute"
                )
            }
            Self::DuplicatePermission { permission } => {
                write!(f, "duplicate permission `{permission}`")
            }
            Self::MissingPath { kind, path } => {
                write!(f, "{kind} path `{}` does not exist", path.display())
            }
        }
    }
}