claude-agent 0.2.25

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

use crate::common::IndexRegistry;
use crate::mcp::McpServerConfig;
use crate::skills::SkillIndex;
use crate::subagents::SubagentIndex;

use super::PluginError;
use super::discovery::PluginDiscovery;
use super::loader::{PluginHookEntry, PluginLoader, PluginResources};
use super::manifest::PluginDescriptor;

pub struct PluginManager {
    plugins: Vec<PluginDescriptor>,
    resources: PluginResources,
}

impl PluginManager {
    pub async fn load_from_dirs(dirs: &[PathBuf]) -> Result<Self, PluginError> {
        let plugins = PluginDiscovery::discover(dirs)?;

        Self::validate_plugins(&plugins)?;

        let mut resources = PluginResources::default();

        for plugin in &plugins {
            let plugin_resources = PluginLoader::load(plugin).await?;
            Self::merge(&mut resources, plugin_resources);
        }

        Ok(Self { plugins, resources })
    }

    pub fn register_skills(&self, registry: &mut IndexRegistry<SkillIndex>) {
        for skill in &self.resources.skills {
            registry.register(skill.clone());
        }
    }

    pub fn register_subagents(&self, registry: &mut IndexRegistry<SubagentIndex>) {
        for subagent in &self.resources.subagents {
            registry.register(subagent.clone());
        }
    }

    pub fn hooks(&self) -> &[PluginHookEntry] {
        &self.resources.hooks
    }

    pub fn mcp_servers(&self) -> &HashMap<String, McpServerConfig> {
        &self.resources.mcp_servers
    }

    pub fn plugins(&self) -> &[PluginDescriptor] {
        &self.plugins
    }

    pub fn plugin_count(&self) -> usize {
        self.plugins.len()
    }

    pub fn has_plugin(&self, name: &str) -> bool {
        self.plugins.iter().any(|p| p.name() == name)
    }

    fn validate_plugins(plugins: &[PluginDescriptor]) -> Result<(), PluginError> {
        let mut seen: HashMap<String, &PathBuf> = HashMap::new();
        for plugin in plugins {
            let name = plugin.name();

            if name.contains(super::namespace::NAMESPACE_SEP) {
                return Err(PluginError::InvalidName {
                    name: name.to_string(),
                    reason: format!(
                        "must not contain namespace separator '{}'",
                        super::namespace::NAMESPACE_SEP
                    ),
                });
            }

            if let Some(first_path) = seen.get(name) {
                return Err(PluginError::DuplicateName {
                    name: name.to_string(),
                    first: (*first_path).clone(),
                    second: plugin.root_dir.clone(),
                });
            }
            seen.insert(name.to_string(), &plugin.root_dir);
        }
        Ok(())
    }

    fn merge(target: &mut PluginResources, source: PluginResources) {
        target.skills.extend(source.skills);
        target.subagents.extend(source.subagents);
        target.hooks.extend(source.hooks);
        target.mcp_servers.extend(source.mcp_servers);
    }
}

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

    fn create_full_plugin(parent: &std::path::Path, name: &str) {
        let plugin_dir = parent.join(name);
        let config_dir = plugin_dir.join(".claude-plugin");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("plugin.json"),
            format!(
                r#"{{"name":"{}","description":"Test","version":"1.0.0"}}"#,
                name
            ),
        )
        .unwrap();

        let skills_dir = plugin_dir.join("skills");
        std::fs::create_dir_all(&skills_dir).unwrap();
        std::fs::write(
            skills_dir.join("test.skill.md"),
            format!(
                "---\nname: test-skill\ndescription: A test skill for {}\n---\nContent",
                name
            ),
        )
        .unwrap();
    }

    #[tokio::test]
    async fn test_load_from_dirs() {
        let dir = tempdir().unwrap();
        create_full_plugin(dir.path(), "plugin-a");
        create_full_plugin(dir.path(), "plugin-b");

        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
            .await
            .unwrap();

        assert_eq!(manager.plugin_count(), 2);
        assert!(manager.has_plugin("plugin-a"));
        assert!(manager.has_plugin("plugin-b"));
        assert!(!manager.has_plugin("plugin-c"));
        let mut registry = IndexRegistry::new();
        manager.register_skills(&mut registry);
        assert_eq!(registry.len(), 2);
    }

    #[tokio::test]
    async fn test_duplicate_detection() {
        let dir1 = tempdir().unwrap();
        let dir2 = tempdir().unwrap();

        let plugin_dir1 = dir1.path().join("same");
        let config1 = plugin_dir1.join(".claude-plugin");
        std::fs::create_dir_all(&config1).unwrap();
        std::fs::write(
            config1.join("plugin.json"),
            r#"{"name":"same","description":"A","version":"1.0.0"}"#,
        )
        .unwrap();

        let plugin_dir2 = dir2.path().join("same");
        let config2 = plugin_dir2.join(".claude-plugin");
        std::fs::create_dir_all(&config2).unwrap();
        std::fs::write(
            config2.join("plugin.json"),
            r#"{"name":"same","description":"B","version":"1.0.0"}"#,
        )
        .unwrap();

        let result =
            PluginManager::load_from_dirs(&[dir1.path().to_path_buf(), dir2.path().to_path_buf()])
                .await;

        assert!(
            matches!(result, Err(PluginError::DuplicateName { ref name, .. }) if name == "same")
        );
    }

    #[tokio::test]
    async fn test_register_skills() {
        let dir = tempdir().unwrap();
        create_full_plugin(dir.path(), "my-plugin");

        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
            .await
            .unwrap();

        let mut registry = IndexRegistry::new();
        manager.register_skills(&mut registry);

        assert_eq!(registry.len(), 1);
        assert!(registry.get("my-plugin:test-skill").is_some());
    }

    #[tokio::test]
    async fn test_register_subagents() {
        let dir = tempdir().unwrap();
        let plugin_dir = dir.path().join("agent-plugin");
        let config_dir = plugin_dir.join(".claude-plugin");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("plugin.json"),
            r#"{"name":"agent-plugin","description":"Has agents","version":"1.0.0"}"#,
        )
        .unwrap();

        let agents_dir = plugin_dir.join("agents");
        std::fs::create_dir_all(&agents_dir).unwrap();
        std::fs::write(
            agents_dir.join("reviewer.md"),
            "---\nname: reviewer\ndescription: Code reviewer\n---\nReview prompt",
        )
        .unwrap();

        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
            .await
            .unwrap();

        let mut registry = IndexRegistry::new();
        manager.register_subagents(&mut registry);

        assert_eq!(registry.len(), 1);
        assert!(registry.get("agent-plugin:reviewer").is_some());
    }

    #[tokio::test]
    async fn test_mcp_servers_aggregation() {
        let dir = tempdir().unwrap();
        let plugin_dir = dir.path().join("mcp-plugin");
        let config_dir = plugin_dir.join(".claude-plugin");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("plugin.json"),
            r#"{"name":"mcp-plugin","description":"Has MCP","version":"1.0.0"}"#,
        )
        .unwrap();
        std::fs::write(
            plugin_dir.join(".mcp.json"),
            r#"{"mcpServers":{"ctx":{"type":"stdio","command":"npx","args":["@ctx/mcp"]}}}"#,
        )
        .unwrap();

        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
            .await
            .unwrap();

        let servers = manager.mcp_servers();
        assert_eq!(servers.len(), 1);
        assert!(servers.contains_key("mcp-plugin:ctx"));
    }

    #[tokio::test]
    async fn test_empty_dirs() {
        let manager = PluginManager::load_from_dirs(&[]).await.unwrap();
        assert_eq!(manager.plugin_count(), 0);
    }

    #[tokio::test]
    async fn test_plugins_accessor() {
        let dir = tempdir().unwrap();
        create_full_plugin(dir.path(), "accessible");

        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
            .await
            .unwrap();

        assert_eq!(manager.plugins().len(), 1);
        assert_eq!(manager.plugins()[0].name(), "accessible");
    }
}