Skip to main content

dot_agent_core/
plugin_registrar.rs

1//! Plugin Registration for Claude Code
2//!
3//! Automatically registers profiles with plugin features (hooks, MCP, LSP)
4//! to Claude Code's settings.json.
5//!
6//! # Claude Code Settings Structure
7//!
8//! ```json
9//! {
10//!   "plugins": {
11//!     "installed": ["path/to/plugin1", "path/to/plugin2"]
12//!   }
13//! }
14//! ```
15
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20
21use crate::error::{DotAgentError, Result};
22use crate::profile_metadata::{PluginScope, ProfileMetadata};
23
24/// Plugin-related files/directories that trigger registration
25const PLUGIN_HOOKS_DIR: &str = "hooks";
26const PLUGIN_MCP_FILE: &str = ".mcp.json";
27const PLUGIN_LSP_FILE: &str = ".lsp.json";
28
29/// Claude Code settings file names
30const SETTINGS_FILE: &str = "settings.json";
31const SETTINGS_LOCAL_FILE: &str = "settings.local.json";
32
33/// Plugin registration result
34#[derive(Debug, Default)]
35pub struct PluginRegistrationResult {
36    /// Whether plugin was registered
37    pub registered: bool,
38    /// Path where plugin was registered
39    pub settings_path: Option<PathBuf>,
40    /// Plugin features found
41    pub features: Vec<String>,
42    /// Error message if failed
43    pub error: Option<String>,
44}
45
46/// Plugin registrar for Claude Code
47pub struct PluginRegistrar {
48    /// Home directory for user scope
49    home_dir: PathBuf,
50}
51
52impl PluginRegistrar {
53    /// Create new registrar
54    pub fn new() -> Result<Self> {
55        let home_dir = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
56        Ok(Self { home_dir })
57    }
58
59    /// Check if a profile has plugin features
60    pub fn has_plugin_features(profile_path: &Path) -> bool {
61        ProfileMetadata::has_plugin_features(profile_path)
62    }
63
64    /// Get list of plugin features in a profile
65    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            // Check for hooks.json or any hook files
71            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    /// Register a profile as a plugin
92    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        // Check for plugin features
102        let features = Self::get_plugin_features(profile_path);
103        if features.is_empty() {
104            return Ok(result);
105        }
106        result.features = features;
107
108        // Determine settings file path based on scope
109        let settings_path = self.get_settings_path(scope, target_dir)?;
110        result.settings_path = Some(settings_path.clone());
111
112        // Load or create settings
113        let mut settings = self.load_settings(&settings_path)?;
114
115        // Add plugin to installed list
116        let plugin_path = profile_path.to_string_lossy().to_string();
117        self.add_plugin_to_settings(&mut settings, &plugin_path)?;
118
119        // Save settings
120        self.save_settings(&settings_path, &settings)?;
121
122        result.registered = true;
123        Ok(result)
124    }
125
126    /// Unregister a profile plugin
127    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    /// Get settings file path based on scope
152    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    /// Load settings file or create default
176    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    /// Save settings file
191    fn save_settings(&self, path: &Path, settings: &Value) -> Result<()> {
192        // Ensure directory exists
193        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    /// Add plugin to settings
208    fn add_plugin_to_settings(&self, settings: &mut Value, plugin_path: &str) -> Result<()> {
209        // Ensure plugins.installed exists
210        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        // Get or create installed array
223        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        // Check if already installed
235        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    /// Remove plugin from settings
244    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    /// List all registered plugins for a scope
272    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}