dot_agent_core/plugin/
registrar.rs1use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20
21use crate::error::{DotAgentError, Result};
22use crate::profile::{PluginScope, ProfileMetadata};
23
24const PLUGIN_HOOKS_DIR: &str = "hooks";
26const PLUGIN_MCP_FILE: &str = ".mcp.json";
27const PLUGIN_LSP_FILE: &str = ".lsp.json";
28
29const SETTINGS_FILE: &str = "settings.json";
31const SETTINGS_LOCAL_FILE: &str = "settings.local.json";
32
33#[derive(Debug, Default)]
35pub struct PluginRegistrationResult {
36 pub registered: bool,
38 pub settings_path: Option<PathBuf>,
40 pub features: Vec<String>,
42 pub error: Option<String>,
44}
45
46pub struct PluginRegistrar {
48 home_dir: PathBuf,
50}
51
52impl PluginRegistrar {
53 pub fn new() -> Result<Self> {
55 let home_dir = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
56 Ok(Self { home_dir })
57 }
58
59 pub fn has_plugin_features(profile_path: &Path) -> bool {
61 ProfileMetadata::has_plugin_features(profile_path)
62 }
63
64 pub fn get_plugin_features(profile_path: &Path) -> Vec<String> {
66 let mut features = Vec::new();
67
68 let hooks_dir = profile_path.join(PLUGIN_HOOKS_DIR);
69 if hooks_dir.exists() && hooks_dir.is_dir() {
70 let has_hooks = hooks_dir.join("hooks.json").exists()
72 || fs::read_dir(&hooks_dir)
73 .map(|mut d| d.next().is_some())
74 .unwrap_or(false);
75 if has_hooks {
76 features.push("hooks".to_string());
77 }
78 }
79
80 if profile_path.join(PLUGIN_MCP_FILE).exists() {
81 features.push("mcp".to_string());
82 }
83
84 if profile_path.join(PLUGIN_LSP_FILE).exists() {
85 features.push("lsp".to_string());
86 }
87
88 features
89 }
90
91 pub fn register_plugin(
93 &self,
94 profile_path: &Path,
95 _profile_name: &str,
96 scope: PluginScope,
97 target_dir: Option<&Path>,
98 ) -> Result<PluginRegistrationResult> {
99 let mut result = PluginRegistrationResult::default();
100
101 let features = Self::get_plugin_features(profile_path);
103 if features.is_empty() {
104 return Ok(result);
105 }
106 result.features = features;
107
108 let settings_path = self.get_settings_path(scope, target_dir)?;
110 result.settings_path = Some(settings_path.clone());
111
112 let mut settings = self.load_settings(&settings_path)?;
114
115 let plugin_path = profile_path.to_string_lossy().to_string();
117 self.add_plugin_to_settings(&mut settings, &plugin_path)?;
118
119 self.save_settings(&settings_path, &settings)?;
121
122 result.registered = true;
123 Ok(result)
124 }
125
126 pub fn unregister_plugin(
128 &self,
129 profile_path: &Path,
130 scope: PluginScope,
131 target_dir: Option<&Path>,
132 ) -> Result<bool> {
133 let settings_path = self.get_settings_path(scope, target_dir)?;
134
135 if !settings_path.exists() {
136 return Ok(false);
137 }
138
139 let mut settings = self.load_settings(&settings_path)?;
140 let plugin_path = profile_path.to_string_lossy().to_string();
141
142 let removed = self.remove_plugin_from_settings(&mut settings, &plugin_path)?;
143
144 if removed {
145 self.save_settings(&settings_path, &settings)?;
146 }
147
148 Ok(removed)
149 }
150
151 fn get_settings_path(&self, scope: PluginScope, target_dir: Option<&Path>) -> Result<PathBuf> {
153 match scope {
154 PluginScope::User => {
155 let claude_dir = self.home_dir.join(".claude");
156 Ok(claude_dir.join(SETTINGS_FILE))
157 }
158 PluginScope::Project => {
159 let base = target_dir
160 .map(|p| p.to_path_buf())
161 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
162 let claude_dir = base.join(".claude");
163 Ok(claude_dir.join(SETTINGS_FILE))
164 }
165 PluginScope::Local => {
166 let base = target_dir
167 .map(|p| p.to_path_buf())
168 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
169 let claude_dir = base.join(".claude");
170 Ok(claude_dir.join(SETTINGS_LOCAL_FILE))
171 }
172 }
173 }
174
175 fn load_settings(&self, path: &Path) -> Result<Value> {
177 if path.exists() {
178 let content = fs::read_to_string(path)?;
179 let settings: Value =
180 serde_json::from_str(&content).map_err(|e| DotAgentError::ConfigParse {
181 path: path.to_path_buf(),
182 message: e.to_string(),
183 })?;
184 Ok(settings)
185 } else {
186 Ok(serde_json::json!({}))
187 }
188 }
189
190 fn save_settings(&self, path: &Path, settings: &Value) -> Result<()> {
192 if let Some(parent) = path.parent() {
194 fs::create_dir_all(parent)?;
195 }
196
197 let content =
198 serde_json::to_string_pretty(settings).map_err(|e| DotAgentError::ConfigParse {
199 path: path.to_path_buf(),
200 message: e.to_string(),
201 })?;
202
203 fs::write(path, content)?;
204 Ok(())
205 }
206
207 fn add_plugin_to_settings(&self, settings: &mut Value, plugin_path: &str) -> Result<()> {
209 if settings.get("plugins").is_none() {
211 settings["plugins"] = serde_json::json!({});
212 }
213
214 let plugins =
215 settings["plugins"]
216 .as_object_mut()
217 .ok_or_else(|| DotAgentError::ConfigParse {
218 path: PathBuf::from("<settings>"),
219 message: "plugins must be an object".to_string(),
220 })?;
221
222 let installed = plugins
224 .entry("installed")
225 .or_insert_with(|| serde_json::json!([]));
226
227 let installed_arr = installed
228 .as_array_mut()
229 .ok_or_else(|| DotAgentError::ConfigParse {
230 path: PathBuf::from("<settings>"),
231 message: "plugins.installed must be an array".to_string(),
232 })?;
233
234 let path_value = serde_json::json!(plugin_path);
236 if !installed_arr.contains(&path_value) {
237 installed_arr.push(path_value);
238 }
239
240 Ok(())
241 }
242
243 fn remove_plugin_from_settings(&self, settings: &mut Value, plugin_path: &str) -> Result<bool> {
245 let plugins = match settings.get_mut("plugins") {
246 Some(p) => p,
247 None => return Ok(false),
248 };
249
250 let plugins_obj = match plugins.as_object_mut() {
251 Some(o) => o,
252 None => return Ok(false),
253 };
254
255 let installed = match plugins_obj.get_mut("installed") {
256 Some(i) => i,
257 None => return Ok(false),
258 };
259
260 let installed_arr = match installed.as_array_mut() {
261 Some(a) => a,
262 None => return Ok(false),
263 };
264
265 let original_len = installed_arr.len();
266 installed_arr.retain(|v| v.as_str() != Some(plugin_path));
267
268 Ok(installed_arr.len() < original_len)
269 }
270
271 pub fn list_plugins(
273 &self,
274 scope: PluginScope,
275 target_dir: Option<&Path>,
276 ) -> Result<Vec<String>> {
277 let settings_path = self.get_settings_path(scope, target_dir)?;
278
279 if !settings_path.exists() {
280 return Ok(Vec::new());
281 }
282
283 let settings = self.load_settings(&settings_path)?;
284
285 let plugins = settings
286 .get("plugins")
287 .and_then(|p| p.get("installed"))
288 .and_then(|i| i.as_array())
289 .map(|arr| {
290 arr.iter()
291 .filter_map(|v| v.as_str().map(|s| s.to_string()))
292 .collect()
293 })
294 .unwrap_or_default();
295
296 Ok(plugins)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use tempfile::TempDir;
304
305 #[test]
306 fn get_plugin_features_empty() {
307 let tmp = TempDir::new().unwrap();
308 let features = PluginRegistrar::get_plugin_features(tmp.path());
309 assert!(features.is_empty());
310 }
311
312 #[test]
313 fn get_plugin_features_hooks() {
314 let tmp = TempDir::new().unwrap();
315 let hooks_dir = tmp.path().join("hooks");
316 fs::create_dir(&hooks_dir).unwrap();
317 fs::write(hooks_dir.join("hooks.json"), "{}").unwrap();
318
319 let features = PluginRegistrar::get_plugin_features(tmp.path());
320 assert!(features.contains(&"hooks".to_string()));
321 }
322
323 #[test]
324 fn get_plugin_features_mcp() {
325 let tmp = TempDir::new().unwrap();
326 fs::write(tmp.path().join(".mcp.json"), "{}").unwrap();
327
328 let features = PluginRegistrar::get_plugin_features(tmp.path());
329 assert!(features.contains(&"mcp".to_string()));
330 }
331
332 #[test]
333 fn add_plugin_to_empty_settings() {
334 let registrar = PluginRegistrar {
335 home_dir: PathBuf::from("/tmp"),
336 };
337
338 let mut settings = serde_json::json!({});
339 registrar
340 .add_plugin_to_settings(&mut settings, "/path/to/plugin")
341 .unwrap();
342
343 let installed = settings["plugins"]["installed"].as_array().unwrap();
344 assert_eq!(installed.len(), 1);
345 assert_eq!(installed[0].as_str().unwrap(), "/path/to/plugin");
346 }
347
348 #[test]
349 fn add_plugin_idempotent() {
350 let registrar = PluginRegistrar {
351 home_dir: PathBuf::from("/tmp"),
352 };
353
354 let mut settings = serde_json::json!({});
355 registrar
356 .add_plugin_to_settings(&mut settings, "/path/to/plugin")
357 .unwrap();
358 registrar
359 .add_plugin_to_settings(&mut settings, "/path/to/plugin")
360 .unwrap();
361
362 let installed = settings["plugins"]["installed"].as_array().unwrap();
363 assert_eq!(installed.len(), 1);
364 }
365
366 #[test]
367 fn remove_plugin_from_settings() {
368 let registrar = PluginRegistrar {
369 home_dir: PathBuf::from("/tmp"),
370 };
371
372 let mut settings = serde_json::json!({
373 "plugins": {
374 "installed": ["/path/to/plugin1", "/path/to/plugin2"]
375 }
376 });
377
378 let removed = registrar
379 .remove_plugin_from_settings(&mut settings, "/path/to/plugin1")
380 .unwrap();
381 assert!(removed);
382
383 let installed = settings["plugins"]["installed"].as_array().unwrap();
384 assert_eq!(installed.len(), 1);
385 assert_eq!(installed[0].as_str().unwrap(), "/path/to/plugin2");
386 }
387}