dot_agent_core/
plugin_manifest.rs1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct PluginManifest {
20 pub name: String,
22
23 #[serde(default)]
25 pub version: Option<String>,
26
27 #[serde(default)]
29 pub description: Option<String>,
30
31 #[serde(default)]
33 pub author: Option<Author>,
34
35 #[serde(default)]
37 pub homepage: Option<String>,
38
39 #[serde(default)]
41 pub repository: Option<String>,
42
43 #[serde(default)]
45 pub license: Option<String>,
46
47 #[serde(default)]
49 pub keywords: Vec<String>,
50
51 #[serde(default)]
54 pub commands: ComponentPaths,
55
56 #[serde(default)]
58 pub agents: ComponentPaths,
59
60 #[serde(default)]
62 pub skills: ComponentPaths,
63
64 #[serde(default)]
66 pub hooks: Option<serde_json::Value>,
67
68 #[serde(default)]
70 pub mcp_servers: Option<serde_json::Value>,
71
72 #[serde(default)]
74 pub lsp_servers: Option<serde_json::Value>,
75
76 #[serde(default)]
78 pub output_styles: ComponentPaths,
79}
80
81#[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#[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 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 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
119pub 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 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 pub fn exists(profile_path: &Path) -> bool {
146 profile_path.join(PLUGIN_DIR).join(PLUGIN_JSON).exists()
147 }
148
149 pub fn get_component_paths(&self) -> Vec<PathBuf> {
152 let mut paths = Vec::new();
153
154 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 paths.is_empty() {
170 for dir in DEFAULT_COMPONENT_DIRS {
171 paths.push(PathBuf::from(dir));
172 }
173 }
174
175 paths
176 }
177
178 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct FilterConfig {
190 #[serde(default)]
192 pub include: Vec<String>,
193
194 #[serde(default)]
196 pub exclude: Vec<String>,
197}
198
199impl FilterConfig {
200 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 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 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 pub fn is_empty(&self) -> bool {
254 self.include.is_empty() && self.exclude.is_empty()
255 }
256}
257
258fn 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}