claude-agent 0.2.25

Rust SDK for building AI agents with Anthropic's Claude - Direct API, no CLI dependency
Documentation
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use super::PluginError;

pub(super) const PLUGIN_CONFIG_DIR: &str = ".claude-plugin";
const PLUGIN_MANIFEST_FILE: &str = "plugin.json";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginAuthor {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
    pub name: String,
    pub description: String,
    pub version: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub author: Option<PluginAuthor>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub homepage: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repository: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keywords: Vec<String>,
}

impl PluginManifest {
    pub fn load(root_dir: &Path) -> Result<Self, PluginError> {
        let manifest_path = root_dir.join(PLUGIN_CONFIG_DIR).join(PLUGIN_MANIFEST_FILE);
        if !manifest_path.exists() {
            return Err(PluginError::ManifestNotFound {
                path: manifest_path,
            });
        }
        let content = std::fs::read_to_string(&manifest_path)?;
        serde_json::from_str(&content).map_err(|e| PluginError::InvalidManifest {
            path: manifest_path,
            reason: e.to_string(),
        })
    }
}

#[derive(Debug, Clone)]
pub struct PluginDescriptor {
    pub(crate) manifest: PluginManifest,
    pub(crate) root_dir: PathBuf,
}

impl PluginDescriptor {
    pub(crate) fn new(manifest: PluginManifest, root_dir: PathBuf) -> Self {
        Self { manifest, root_dir }
    }

    pub fn name(&self) -> &str {
        &self.manifest.name
    }

    pub fn version(&self) -> &str {
        &self.manifest.version
    }

    pub fn description(&self) -> &str {
        &self.manifest.description
    }

    pub fn root_dir(&self) -> &Path {
        &self.root_dir
    }

    pub fn skills_dir(&self) -> PathBuf {
        self.root_dir.join("skills")
    }

    pub fn commands_dir(&self) -> PathBuf {
        self.root_dir.join("commands")
    }

    pub fn agents_dir(&self) -> PathBuf {
        self.root_dir.join("agents")
    }

    pub fn hooks_config_path(&self) -> PathBuf {
        self.root_dir.join("hooks").join("hooks.json")
    }

    pub fn mcp_config_path(&self) -> PathBuf {
        self.root_dir.join(".mcp.json")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_manifest_load() {
        let dir = tempdir().unwrap();
        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join(PLUGIN_MANIFEST_FILE),
            r#"{"name":"test-plugin","description":"A test plugin","version":"1.0.0"}"#,
        )
        .unwrap();

        let manifest = PluginManifest::load(dir.path()).unwrap();
        assert_eq!(manifest.name, "test-plugin");
        assert_eq!(manifest.description, "A test plugin");
        assert_eq!(manifest.version, "1.0.0");
        assert!(manifest.author.is_none());
    }

    #[test]
    fn test_manifest_load_with_author() {
        let dir = tempdir().unwrap();
        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join(PLUGIN_MANIFEST_FILE),
            r#"{
                "name": "authored",
                "description": "Has author",
                "version": "0.1.0",
                "author": {"name": "Alice", "email": "alice@example.com"}
            }"#,
        )
        .unwrap();

        let manifest = PluginManifest::load(dir.path()).unwrap();
        let author = manifest.author.unwrap();
        assert_eq!(author.name, "Alice");
        assert_eq!(author.email.as_deref(), Some("alice@example.com"));
    }

    #[test]
    fn test_manifest_not_found() {
        let dir = tempdir().unwrap();
        let err = PluginManifest::load(dir.path()).unwrap_err();
        assert!(matches!(err, PluginError::ManifestNotFound { .. }));
    }

    #[test]
    fn test_manifest_invalid_json() {
        let dir = tempdir().unwrap();
        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(config_dir.join(PLUGIN_MANIFEST_FILE), "not json").unwrap();

        let err = PluginManifest::load(dir.path()).unwrap_err();
        assert!(matches!(err, PluginError::InvalidManifest { .. }));
    }

    #[test]
    fn test_manifest_missing_required_fields() {
        let dir = tempdir().unwrap();
        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join(PLUGIN_MANIFEST_FILE),
            r#"{"name":"incomplete"}"#,
        )
        .unwrap();

        let err = PluginManifest::load(dir.path()).unwrap_err();
        assert!(matches!(err, PluginError::InvalidManifest { .. }));
    }

    #[test]
    fn test_descriptor_paths() {
        let descriptor = PluginDescriptor::new(
            PluginManifest {
                name: "my-plugin".into(),
                description: "desc".into(),
                version: "1.0.0".into(),
                author: None,
                homepage: None,
                repository: None,
                license: None,
                keywords: Vec::new(),
            },
            PathBuf::from("/plugins/my-plugin"),
        );

        assert_eq!(descriptor.name(), "my-plugin");
        assert_eq!(
            descriptor.skills_dir(),
            PathBuf::from("/plugins/my-plugin/skills")
        );
        assert_eq!(
            descriptor.commands_dir(),
            PathBuf::from("/plugins/my-plugin/commands")
        );
        assert_eq!(
            descriptor.agents_dir(),
            PathBuf::from("/plugins/my-plugin/agents")
        );
        assert_eq!(
            descriptor.hooks_config_path(),
            PathBuf::from("/plugins/my-plugin/hooks/hooks.json")
        );
        assert_eq!(
            descriptor.mcp_config_path(),
            PathBuf::from("/plugins/my-plugin/.mcp.json")
        );
    }

    #[test]
    fn test_manifest_serde_roundtrip() {
        let manifest = PluginManifest {
            name: "roundtrip".into(),
            description: "Test roundtrip".into(),
            version: "2.0.0".into(),
            author: Some(PluginAuthor {
                name: "Bob".into(),
                email: None,
                url: Some("https://example.com".into()),
            }),
            homepage: None,
            repository: Some("https://github.com/bob/roundtrip".into()),
            license: Some("MIT".into()),
            keywords: vec!["test".into(), "roundtrip".into()],
        };

        let json = serde_json::to_string(&manifest).unwrap();
        let parsed: PluginManifest = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.name, "roundtrip");
        assert_eq!(parsed.version, "2.0.0");
        assert!(parsed.author.unwrap().url.is_some());
    }
}