Skip to main content

dot_agent_core/plugin/
manifest.rs

1//! Plugin manifest parsing for .claude-plugin/plugin.json
2//!
3//! Follows Claude Code official plugin specification with include/exclude extensions.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use glob::Pattern;
9use serde::{Deserialize, Serialize};
10
11use crate::error::Result;
12
13const PLUGIN_DIR: &str = ".claude-plugin";
14const PLUGIN_JSON: &str = "plugin.json";
15
16/// Official Claude Code plugin manifest structure
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct PluginManifest {
20    /// Plugin name (required)
21    pub name: String,
22
23    /// Plugin version
24    #[serde(default)]
25    pub version: Option<String>,
26
27    /// Plugin description
28    #[serde(default)]
29    pub description: Option<String>,
30
31    /// Author information
32    #[serde(default)]
33    pub author: Option<Author>,
34
35    /// Homepage URL
36    #[serde(default)]
37    pub homepage: Option<String>,
38
39    /// Repository URL
40    #[serde(default)]
41    pub repository: Option<String>,
42
43    /// License
44    #[serde(default)]
45    pub license: Option<String>,
46
47    /// Keywords for discovery
48    #[serde(default)]
49    pub keywords: Vec<String>,
50
51    // Component paths (official spec)
52    /// Additional command paths
53    #[serde(default)]
54    pub commands: ComponentPaths,
55
56    /// Additional agent paths
57    #[serde(default)]
58    pub agents: ComponentPaths,
59
60    /// Additional skill paths
61    #[serde(default)]
62    pub skills: ComponentPaths,
63
64    /// Hook configuration path or inline
65    #[serde(default)]
66    pub hooks: Option<serde_json::Value>,
67
68    /// MCP server configuration
69    #[serde(default)]
70    pub mcp_servers: Option<serde_json::Value>,
71
72    /// LSP server configuration
73    #[serde(default)]
74    pub lsp_servers: Option<serde_json::Value>,
75
76    /// Output styles paths
77    #[serde(default)]
78    pub output_styles: ComponentPaths,
79}
80
81/// Author information
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct Author {
84    pub name: Option<String>,
85    pub email: Option<String>,
86    pub url: Option<String>,
87}
88
89/// Component paths can be a single string or array of strings
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91#[serde(untagged)]
92pub enum ComponentPaths {
93    #[default]
94    None,
95    Single(String),
96    Multiple(Vec<String>),
97}
98
99impl ComponentPaths {
100    /// Convert to Vec<String>
101    pub fn to_vec(&self) -> Vec<String> {
102        match self {
103            ComponentPaths::None => Vec::new(),
104            ComponentPaths::Single(s) => vec![s.clone()],
105            ComponentPaths::Multiple(v) => v.clone(),
106        }
107    }
108
109    /// Check if any paths are specified
110    pub fn is_empty(&self) -> bool {
111        match self {
112            ComponentPaths::None => true,
113            ComponentPaths::Single(_) => false,
114            ComponentPaths::Multiple(v) => v.is_empty(),
115        }
116    }
117}
118
119/// Default component directories (official spec)
120pub const DEFAULT_COMPONENT_DIRS: &[&str] = &[
121    "commands",
122    "agents",
123    "skills",
124    "hooks",
125    "rules",
126    "plugins",
127    "mcp-configs",
128    "examples",
129];
130
131impl PluginManifest {
132    /// Load plugin manifest from .claude-plugin/plugin.json
133    pub fn load(profile_path: &Path) -> Result<Option<Self>> {
134        let manifest_path = profile_path.join(PLUGIN_DIR).join(PLUGIN_JSON);
135        if !manifest_path.exists() {
136            return Ok(None);
137        }
138
139        let content = fs::read_to_string(&manifest_path)?;
140        let manifest: PluginManifest = serde_json::from_str(&content)?;
141        Ok(Some(manifest))
142    }
143
144    /// Check if plugin manifest exists
145    pub fn exists(profile_path: &Path) -> bool {
146        profile_path.join(PLUGIN_DIR).join(PLUGIN_JSON).exists()
147    }
148
149    /// Get all component paths to scan
150    /// Returns paths relative to profile root
151    pub fn get_component_paths(&self) -> Vec<PathBuf> {
152        let mut paths = Vec::new();
153
154        // Add explicitly specified paths
155        for p in self.commands.to_vec() {
156            paths.push(normalize_path(&p));
157        }
158        for p in self.agents.to_vec() {
159            paths.push(normalize_path(&p));
160        }
161        for p in self.skills.to_vec() {
162            paths.push(normalize_path(&p));
163        }
164        for p in self.output_styles.to_vec() {
165            paths.push(normalize_path(&p));
166        }
167
168        // If no explicit paths, use defaults
169        if paths.is_empty() {
170            for dir in DEFAULT_COMPONENT_DIRS {
171                paths.push(PathBuf::from(dir));
172            }
173        }
174
175        paths
176    }
177
178    /// Check if this manifest has explicit component paths
179    pub fn has_explicit_paths(&self) -> bool {
180        !self.commands.is_empty()
181            || !self.agents.is_empty()
182            || !self.skills.is_empty()
183            || !self.output_styles.is_empty()
184    }
185}
186
187/// Filter configuration for include/exclude patterns
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct FilterConfig {
190    /// Patterns to include (added to base)
191    #[serde(default)]
192    pub include: Vec<String>,
193
194    /// Patterns to exclude
195    #[serde(default)]
196    pub exclude: Vec<String>,
197}
198
199impl FilterConfig {
200    /// Load filter config from .dot-agent.toml
201    pub fn load(profile_path: &Path) -> Result<Option<Self>> {
202        let toml_path = profile_path.join(".dot-agent.toml");
203        if !toml_path.exists() {
204            return Ok(None);
205        }
206
207        let content = fs::read_to_string(&toml_path)?;
208        let value: toml::Value = toml::from_str(&content)?;
209
210        if let Some(filter) = value.get("filter") {
211            let config: FilterConfig = filter.clone().try_into()?;
212            return Ok(Some(config));
213        }
214
215        Ok(None)
216    }
217
218    /// Check if path matches any include pattern
219    pub fn matches_include(&self, path: &Path) -> bool {
220        if self.include.is_empty() {
221            return false;
222        }
223
224        let path_str = path.to_string_lossy();
225        for pattern in &self.include {
226            if let Ok(glob) = Pattern::new(pattern) {
227                if glob.matches(&path_str) {
228                    return true;
229                }
230            }
231        }
232        false
233    }
234
235    /// Check if path matches any exclude pattern
236    pub fn matches_exclude(&self, path: &Path) -> bool {
237        if self.exclude.is_empty() {
238            return false;
239        }
240
241        let path_str = path.to_string_lossy();
242        for pattern in &self.exclude {
243            if let Ok(glob) = Pattern::new(pattern) {
244                if glob.matches(&path_str) {
245                    return true;
246                }
247            }
248        }
249        false
250    }
251
252    /// Check if config is empty
253    pub fn is_empty(&self) -> bool {
254        self.include.is_empty() && self.exclude.is_empty()
255    }
256}
257
258/// Normalize path by removing leading "./"
259fn normalize_path(path: &str) -> PathBuf {
260    let p = path.trim_start_matches("./");
261    PathBuf::from(p)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn component_paths_single() {
270        let paths = ComponentPaths::Single("./commands/".to_string());
271        assert_eq!(paths.to_vec(), vec!["./commands/"]);
272        assert!(!paths.is_empty());
273    }
274
275    #[test]
276    fn component_paths_multiple() {
277        let paths = ComponentPaths::Multiple(vec!["./a/".to_string(), "./b/".to_string()]);
278        assert_eq!(paths.to_vec(), vec!["./a/", "./b/"]);
279    }
280
281    #[test]
282    fn component_paths_none() {
283        let paths = ComponentPaths::None;
284        assert!(paths.is_empty());
285        assert!(paths.to_vec().is_empty());
286    }
287
288    #[test]
289    fn filter_matches_exclude() {
290        let config = FilterConfig {
291            include: vec![],
292            exclude: vec!["**/brainstorm.md".to_string(), "tests/**".to_string()],
293        };
294
295        assert!(config.matches_exclude(Path::new("commands/brainstorm.md")));
296        assert!(config.matches_exclude(Path::new("tests/unit/test.rs")));
297        assert!(!config.matches_exclude(Path::new("commands/commit.md")));
298    }
299
300    #[test]
301    fn filter_matches_include() {
302        let config = FilterConfig {
303            include: vec!["examples/*.md".to_string()],
304            exclude: vec![],
305        };
306
307        assert!(config.matches_include(Path::new("examples/usage.md")));
308        assert!(!config.matches_include(Path::new("commands/test.md")));
309    }
310
311    #[test]
312    fn normalize_path_removes_prefix() {
313        assert_eq!(normalize_path("./commands/"), PathBuf::from("commands/"));
314        assert_eq!(normalize_path("agents/"), PathBuf::from("agents/"));
315    }
316}