Skip to main content

enact_plugins/
lib.rs

1//! Plugin discovery and loading — .enact-plugin/ manifest, namespaced commands and skills.
2//!
3//! Plugins live in ~/.enact/plugins/ and .enact/plugins/. Each plugin has:
4//! - .enact-plugin/plugin.json (name, version, author, description)
5//! - commands/ (Markdown slash commands, namespaced as plugin-name:command-name)
6//! - skills/ (SKILL.md files, namespaced as plugin-name:skill-name)
7//! - hooks/hooks.yaml (optional)
8
9use enact_config::enact_home;
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13/// Plugin manifest (.enact-plugin/plugin.json).
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PluginManifest {
16    pub name: String,
17    #[serde(default = "default_version")]
18    pub version: String,
19    #[serde(default)]
20    pub author: Option<String>,
21    #[serde(default)]
22    pub description: Option<String>,
23}
24
25fn default_version() -> String {
26    "0.1.0".to_string()
27}
28
29/// A loaded plugin (manifest + root path for resolving commands/skills).
30#[derive(Debug, Clone)]
31pub struct Plugin {
32    pub manifest: PluginManifest,
33    pub root: PathBuf,
34}
35
36impl Plugin {
37    /// Namespaced command name: plugin-name:command-name
38    pub fn command_name(&self, base: &str) -> String {
39        format!("{}:{}", self.manifest.name, base)
40    }
41
42    /// Namespaced skill name: plugin-name:skill-name
43    pub fn skill_name(&self, base: &str) -> String {
44        format!("{}:{}", self.manifest.name, base)
45    }
46
47    /// Path to plugin's commands directory
48    pub fn commands_dir(&self) -> PathBuf {
49        self.root.join("commands")
50    }
51
52    /// Path to plugin's skills directory
53    pub fn skills_dir(&self) -> PathBuf {
54        self.root.join("skills")
55    }
56
57    /// Path to plugin's hooks directory
58    pub fn hooks_dir(&self) -> PathBuf {
59        self.root.join("hooks")
60    }
61}
62
63/// Load all plugins from enact_home/plugins/ and optional project .enact/plugins/.
64/// Project plugins are listed after global ones; same name = first wins (global preferred).
65pub fn load_plugins(project_dir: Option<&Path>) -> Vec<Plugin> {
66    let home = enact_home();
67    let mut plugins = load_plugins_from_dir(&home.join("plugins"));
68    if let Some(proj) = project_dir {
69        let project_plugins = load_plugins_from_dir(&proj.join(".enact").join("plugins"));
70        for p in project_plugins {
71            if !plugins.iter().any(|e| e.manifest.name == p.manifest.name) {
72                plugins.push(p);
73            }
74        }
75    }
76    plugins
77}
78
79fn load_plugins_from_dir(dir: &Path) -> Vec<Plugin> {
80    let mut out = Vec::new();
81    if !dir.is_dir() {
82        return out;
83    }
84    let entries = match std::fs::read_dir(dir) {
85        Ok(e) => e,
86        Err(_) => return out,
87    };
88    for entry in entries.flatten() {
89        let path = entry.path();
90        if path.is_dir() {
91            let manifest_path = path.join(".enact-plugin").join("plugin.json");
92            if manifest_path.exists() {
93                if let Ok(manifest) = load_manifest(&manifest_path) {
94                    out.push(Plugin {
95                        manifest,
96                        root: path,
97                    });
98                }
99            }
100        }
101    }
102    out
103}
104
105fn load_manifest(path: &Path) -> anyhow::Result<PluginManifest> {
106    let content = std::fs::read_to_string(path)?;
107    let manifest: PluginManifest = serde_json::from_str(&content)?;
108    Ok(manifest)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn plugin_command_name() {
117        let p = Plugin {
118            manifest: PluginManifest {
119                name: "my-plugin".to_string(),
120                version: "1.0".to_string(),
121                author: None,
122                description: None,
123            },
124            root: PathBuf::from("/tmp/my-plugin"),
125        };
126        assert_eq!(p.command_name("hello"), "my-plugin:hello");
127    }
128}