hermes_agent_cli_core/
plugins.rs1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct PluginStore {
38 #[serde(default)]
39 pub plugins: Vec<Plugin>,
40}
41
42impl PluginStore {
43 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 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 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 pub fn add_plugin(&mut self, plugin: Plugin) -> Result<()> {
93 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 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 pub fn get_plugin(&self, name: &str) -> Option<&Plugin> {
117 self.plugins.iter().find(|p| p.name == name)
118 }
119
120 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 pub fn list_plugins(&self) -> &[Plugin] {
127 &self.plugins
128 }
129
130 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 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 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}