Skip to main content

hermes_agent_cli_core/
plugins.rs

1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7/// Plugin configuration stored in plugins.json
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Plugin {
10    pub name: String,
11    #[serde(default)]
12    pub version: String,
13    #[serde(default)]
14    pub source: String,
15    #[serde(default = "default_enabled")]
16    pub enabled: bool,
17    #[serde(default)]
18    pub description: String,
19    #[serde(default)]
20    pub author: String,
21    #[serde(default = "default_installed_at")]
22    pub installed_at: String,
23    #[serde(default)]
24    pub updated_at: String,
25}
26
27fn default_enabled() -> bool {
28    true
29}
30
31fn default_installed_at() -> String {
32    chrono::Utc::now().to_rfc3339()
33}
34
35/// Storage for plugin configurations
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct PluginStore {
38    #[serde(default)]
39    pub plugins: Vec<Plugin>,
40}
41
42impl PluginStore {
43    /// Load plugin store from HERMES_HOME/plugins.json
44    pub fn load() -> Result<Self> {
45        let path = Self::plugins_path();
46        if !path.exists() {
47            return Ok(PluginStore::default());
48        }
49        let content = fs::read_to_string(&path)
50            .with_context(|| format!("failed to read plugins store from {:?}", path))?;
51        let store: PluginStore = serde_json::from_str(&content)
52            .with_context(|| format!("failed to parse plugins store from {:?}", path))?;
53        Ok(store)
54    }
55
56    /// Save plugin store to HERMES_HOME/plugins.json
57    pub fn save(&self) -> Result<()> {
58        let path = Self::plugins_path();
59        if let Some(parent) = path.parent() {
60            fs::create_dir_all(parent)
61                .with_context(|| format!("failed to create plugins directory {:?}", parent))?;
62        }
63        let content =
64            serde_json::to_string_pretty(self).context("failed to serialize plugins store")?;
65        fs::write(&path, content)
66            .with_context(|| format!("failed to write plugins store to {:?}", path))?;
67        Ok(())
68    }
69
70    /// Get plugins path
71    pub fn plugins_path() -> PathBuf {
72        if let Ok(home) = std::env::var("HERMES_HOME") {
73            return PathBuf::from(home).join("plugins.json");
74        }
75        if let Ok(profile) = std::env::var("HERMES_PROFILE") {
76            if let Some(proj_dirs) =
77                ProjectDirs::from("ai", "hermes", &format!("hermes-{}", profile))
78            {
79                return proj_dirs.config_dir().join("plugins.json");
80            }
81        }
82        if let Some(proj_dirs) = ProjectDirs::from("ai", "hermes", "hermes-cli") {
83            return proj_dirs.config_dir().join("plugins.json");
84        }
85        if let Ok(home) = std::env::var("USERPROFILE") {
86            return PathBuf::from(home).join(".hermes").join("plugins.json");
87        }
88        PathBuf::from(".hermes").join("plugins.json")
89    }
90
91    /// Add a plugin
92    pub fn add_plugin(&mut self, plugin: Plugin) -> Result<()> {
93        // Check if name already exists
94        if self.plugins.iter().any(|p| p.name == plugin.name) {
95            anyhow::bail!(
96                "Plugin '{}' already installed. Remove it first or use update.",
97                plugin.name
98            );
99        }
100
101        self.plugins.push(plugin);
102        Ok(())
103    }
104
105    /// Remove a plugin by name
106    pub fn remove_plugin(&mut self, name: &str) -> Result<()> {
107        let len = self.plugins.len();
108        self.plugins.retain(|p| p.name != name);
109        if self.plugins.len() == len {
110            anyhow::bail!("Plugin '{}' not found", name);
111        }
112        Ok(())
113    }
114
115    /// Get a plugin by name
116    pub fn get_plugin(&self, name: &str) -> Option<&Plugin> {
117        self.plugins.iter().find(|p| p.name == name)
118    }
119
120    /// Get a mutable plugin by name
121    pub fn get_plugin_mut(&mut self, name: &str) -> Option<&mut Plugin> {
122        self.plugins.iter_mut().find(|p| p.name == name)
123    }
124
125    /// List all plugins
126    pub fn list_plugins(&self) -> &[Plugin] {
127        &self.plugins
128    }
129
130    /// Update plugin version
131    pub fn update_plugin(&mut self, name: &str, version: &str) -> Result<()> {
132        let plugin = self.get_plugin_mut(name);
133        match plugin {
134            Some(p) => {
135                p.version = version.to_string();
136                p.updated_at = chrono::Utc::now().to_rfc3339();
137                Ok(())
138            }
139            None => anyhow::bail!("Plugin '{}' not found", name),
140        }
141    }
142
143    /// Enable a plugin
144    pub fn enable_plugin(&mut self, name: &str) -> Result<()> {
145        let plugin = self.get_plugin_mut(name);
146        match plugin {
147            Some(p) => {
148                p.enabled = true;
149                Ok(())
150            }
151            None => anyhow::bail!("Plugin '{}' not found", name),
152        }
153    }
154
155    /// Disable a plugin
156    pub fn disable_plugin(&mut self, name: &str) -> Result<()> {
157        let plugin = self.get_plugin_mut(name);
158        match plugin {
159            Some(p) => {
160                p.enabled = false;
161                Ok(())
162            }
163            None => anyhow::bail!("Plugin '{}' not found", name),
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_plugin_store_default() {
174        let store = PluginStore::default();
175        assert!(store.plugins.is_empty());
176    }
177
178    #[test]
179    fn test_plugin_store_add() {
180        let mut store = PluginStore::default();
181        let plugin = Plugin {
182            name: "test-plugin".to_string(),
183            version: "1.0.0".to_string(),
184            source: "https://example.com/test-plugin".to_string(),
185            enabled: true,
186            description: "Test plugin".to_string(),
187            author: "Test Author".to_string(),
188            installed_at: "2026-01-01T00:00:00Z".to_string(),
189            updated_at: "2026-01-01T00:00:00Z".to_string(),
190        };
191        store.add_plugin(plugin).unwrap();
192        assert_eq!(store.plugins.len(), 1);
193        assert_eq!(store.plugins[0].name, "test-plugin");
194    }
195
196    #[test]
197    fn test_plugin_store_add_duplicate() {
198        let mut store = PluginStore::default();
199        let plugin = Plugin {
200            name: "test-plugin".to_string(),
201            version: "1.0.0".to_string(),
202            source: "https://example.com/test-plugin".to_string(),
203            enabled: true,
204            description: "Test plugin".to_string(),
205            author: "Test Author".to_string(),
206            installed_at: "2026-01-01T00:00:00Z".to_string(),
207            updated_at: "2026-01-01T00:00:00Z".to_string(),
208        };
209        store.add_plugin(plugin.clone()).unwrap();
210        let result = store.add_plugin(plugin);
211        assert!(result.is_err());
212    }
213
214    #[test]
215    fn test_plugin_store_remove() {
216        let mut store = PluginStore::default();
217        let plugin = Plugin {
218            name: "test-plugin".to_string(),
219            version: "1.0.0".to_string(),
220            source: "https://example.com/test-plugin".to_string(),
221            enabled: true,
222            description: "Test plugin".to_string(),
223            author: "Test Author".to_string(),
224            installed_at: "2026-01-01T00:00:00Z".to_string(),
225            updated_at: "2026-01-01T00:00:00Z".to_string(),
226        };
227        store.add_plugin(plugin).unwrap();
228        store.remove_plugin("test-plugin").unwrap();
229        assert!(store.plugins.is_empty());
230    }
231
232    #[test]
233    fn test_plugin_store_enable_disable() {
234        let mut store = PluginStore::default();
235        let plugin = Plugin {
236            name: "test-plugin".to_string(),
237            version: "1.0.0".to_string(),
238            source: "https://example.com/test-plugin".to_string(),
239            enabled: true,
240            description: "Test plugin".to_string(),
241            author: "Test Author".to_string(),
242            installed_at: "2026-01-01T00:00:00Z".to_string(),
243            updated_at: "2026-01-01T00:00:00Z".to_string(),
244        };
245        store.add_plugin(plugin).unwrap();
246        store.disable_plugin("test-plugin").unwrap();
247        assert!(!store.plugins[0].enabled);
248        store.enable_plugin("test-plugin").unwrap();
249        assert!(store.plugins[0].enabled);
250    }
251}