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_metadata::{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 = 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 fn add_plugin_to_settings(&self, settings: &mut Value, plugin_path: &str) -> Result<()> {
210 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 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 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(&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}