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 = serde_json::to_string_pretty(settings).map_err(|e| {
198            DotAgentError::ConfigParse {
199                path: path.to_path_buf(),
200                message: e.to_string(),
201            }
202        })?;
203
204        fs::write(path, content)?;
205        Ok(())
206    }
207
208    /// Add plugin to settings
209    fn add_plugin_to_settings(&self, settings: &mut Value, plugin_path: &str) -> Result<()> {
210        // Ensure plugins.installed exists
211        if settings.get("plugins").is_none() {
212            settings["plugins"] = serde_json::json!({});
213        }
214
215        let plugins = settings["plugins"].as_object_mut().ok_or_else(|| {
216            DotAgentError::ConfigParse {
217                path: PathBuf::from("<settings>"),
218                message: "plugins must be an object".to_string(),
219            }
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.as_array_mut().ok_or_else(|| {
228            DotAgentError::ConfigParse {
229                path: PathBuf::from("<settings>"),
230                message: "plugins.installed must be an array".to_string(),
231            }
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(&self, scope: PluginScope, target_dir: Option<&Path>) -> Result<Vec<String>> {
273        let settings_path = self.get_settings_path(scope, target_dir)?;
274
275        if !settings_path.exists() {
276            return Ok(Vec::new());
277        }
278
279        let settings = self.load_settings(&settings_path)?;
280
281        let plugins = settings
282            .get("plugins")
283            .and_then(|p| p.get("installed"))
284            .and_then(|i| i.as_array())
285            .map(|arr| {
286                arr.iter()
287                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
288                    .collect()
289            })
290            .unwrap_or_default();
291
292        Ok(plugins)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use tempfile::TempDir;
300
301    #[test]
302    fn get_plugin_features_empty() {
303        let tmp = TempDir::new().unwrap();
304        let features = PluginRegistrar::get_plugin_features(tmp.path());
305        assert!(features.is_empty());
306    }
307
308    #[test]
309    fn get_plugin_features_hooks() {
310        let tmp = TempDir::new().unwrap();
311        let hooks_dir = tmp.path().join("hooks");
312        fs::create_dir(&hooks_dir).unwrap();
313        fs::write(hooks_dir.join("hooks.json"), "{}").unwrap();
314
315        let features = PluginRegistrar::get_plugin_features(tmp.path());
316        assert!(features.contains(&"hooks".to_string()));
317    }
318
319    #[test]
320    fn get_plugin_features_mcp() {
321        let tmp = TempDir::new().unwrap();
322        fs::write(tmp.path().join(".mcp.json"), "{}").unwrap();
323
324        let features = PluginRegistrar::get_plugin_features(tmp.path());
325        assert!(features.contains(&"mcp".to_string()));
326    }
327
328    #[test]
329    fn add_plugin_to_empty_settings() {
330        let registrar = PluginRegistrar {
331            home_dir: PathBuf::from("/tmp"),
332        };
333
334        let mut settings = serde_json::json!({});
335        registrar
336            .add_plugin_to_settings(&mut settings, "/path/to/plugin")
337            .unwrap();
338
339        let installed = settings["plugins"]["installed"].as_array().unwrap();
340        assert_eq!(installed.len(), 1);
341        assert_eq!(installed[0].as_str().unwrap(), "/path/to/plugin");
342    }
343
344    #[test]
345    fn add_plugin_idempotent() {
346        let registrar = PluginRegistrar {
347            home_dir: PathBuf::from("/tmp"),
348        };
349
350        let mut settings = serde_json::json!({});
351        registrar
352            .add_plugin_to_settings(&mut settings, "/path/to/plugin")
353            .unwrap();
354        registrar
355            .add_plugin_to_settings(&mut settings, "/path/to/plugin")
356            .unwrap();
357
358        let installed = settings["plugins"]["installed"].as_array().unwrap();
359        assert_eq!(installed.len(), 1);
360    }
361
362    #[test]
363    fn remove_plugin_from_settings() {
364        let registrar = PluginRegistrar {
365            home_dir: PathBuf::from("/tmp"),
366        };
367
368        let mut settings = serde_json::json!({
369            "plugins": {
370                "installed": ["/path/to/plugin1", "/path/to/plugin2"]
371            }
372        });
373
374        let removed = registrar
375            .remove_plugin_from_settings(&mut settings, "/path/to/plugin1")
376            .unwrap();
377        assert!(removed);
378
379        let installed = settings["plugins"]["installed"].as_array().unwrap();
380        assert_eq!(installed.len(), 1);
381        assert_eq!(installed[0].as_str().unwrap(), "/path/to/plugin2");
382    }
383}