1use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SkillManifest {
15 pub id: String,
17 pub name: String,
19 pub description: String,
21 pub version: String,
23 pub author: String,
25 #[serde(default)]
27 pub tags: Vec<String>,
28 #[serde(default)]
30 pub workflows: Vec<String>,
31 #[serde(default)]
33 pub requires_env: Vec<String>,
34 #[serde(default)]
36 pub min_commander_version: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct InstalledSkill {
42 pub manifest: SkillManifest,
43 pub install_path: PathBuf,
44 pub installed_at: DateTime<Utc>,
45 pub enabled: bool,
46 pub source: SkillSource,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum SkillSource {
53 Local { path: String },
55 Community { url: String },
57}
58
59pub struct SkillRegistry {
61 skills_dir: PathBuf,
62}
63
64impl SkillRegistry {
65 pub fn new(skills_dir: &Path) -> Self {
67 Self {
68 skills_dir: skills_dir.to_path_buf(),
69 }
70 }
71
72 pub fn default_dir() -> PathBuf {
74 directories::BaseDirs::new()
75 .map(|d| d.home_dir().join(".mur").join("skills"))
76 .unwrap_or_else(|| PathBuf::from(".mur/skills"))
77 }
78
79 pub fn list(&self) -> Result<Vec<InstalledSkill>> {
81 if !self.skills_dir.exists() {
82 return Ok(Vec::new());
83 }
84
85 let registry_path = self.skills_dir.join("registry.json");
86 if !registry_path.exists() {
87 return Ok(Vec::new());
88 }
89
90 let content = std::fs::read_to_string(®istry_path)
91 .context("Reading skill registry")?;
92 let skills: Vec<InstalledSkill> = serde_json::from_str(&content)
93 .context("Parsing skill registry")?;
94 Ok(skills)
95 }
96
97 pub fn install_local(&self, source_dir: &Path) -> Result<InstalledSkill> {
99 let manifest_path = source_dir.join("skill.yaml");
100 if !manifest_path.exists() {
101 anyhow::bail!("No skill.yaml found in {:?}", source_dir);
102 }
103
104 let manifest_content = std::fs::read_to_string(&manifest_path)
105 .context("Reading skill.yaml")?;
106 let manifest: SkillManifest = serde_yaml::from_str(&manifest_content)
107 .context("Parsing skill.yaml")?;
108
109 let skill_dir = self.skills_dir.join(&manifest.id);
111 std::fs::create_dir_all(&skill_dir)?;
112
113 for wf_file in &manifest.workflows {
115 let src = source_dir.join(wf_file);
116 if src.exists() {
117 let dst = skill_dir.join(wf_file);
118 std::fs::copy(&src, &dst)?;
119 }
120 }
121
122 std::fs::copy(&manifest_path, skill_dir.join("skill.yaml"))?;
124
125 let installed = InstalledSkill {
126 manifest,
127 install_path: skill_dir,
128 installed_at: Utc::now(),
129 enabled: true,
130 source: SkillSource::Local {
131 path: source_dir.to_string_lossy().to_string(),
132 },
133 };
134
135 self.add_to_registry(&installed)?;
137
138 self.link_workflows(&installed)?;
140
141 Ok(installed)
142 }
143
144 pub fn install_community(&self, name: &str) -> Result<InstalledSkill> {
146 let local_path = self.skills_dir.join("community").join(name);
149 if local_path.exists() {
150 return self.install_local(&local_path);
151 }
152 anyhow::bail!(
153 "Community skill '{}' not found. Community repository coming soon.\n\
154 Install from local: murc skills install /path/to/skill",
155 name
156 );
157 }
158
159 pub fn uninstall(&self, skill_id: &str) -> Result<()> {
161 let mut skills = self.list()?;
162 let idx = skills
163 .iter()
164 .position(|s| s.manifest.id == skill_id)
165 .context(format!("Skill '{}' not found", skill_id))?;
166
167 let skill = skills.remove(idx);
168
169 self.unlink_workflows(&skill)?;
171
172 if skill.install_path.exists() {
174 std::fs::remove_dir_all(&skill.install_path)?;
175 }
176
177 self.save_registry(&skills)?;
179
180 Ok(())
181 }
182
183 pub fn set_enabled(&self, skill_id: &str, enabled: bool) -> Result<()> {
185 let mut skills = self.list()?;
186 if let Some(skill) = skills.iter_mut().find(|s| s.manifest.id == skill_id) {
187 skill.enabled = enabled;
188 if enabled {
189 self.link_workflows(skill)?;
190 } else {
191 self.unlink_workflows(skill)?;
192 }
193 } else {
194 anyhow::bail!("Skill '{}' not found", skill_id);
195 }
196 self.save_registry(&skills)?;
197 Ok(())
198 }
199
200 pub fn get(&self, skill_id: &str) -> Result<Option<InstalledSkill>> {
202 let skills = self.list()?;
203 Ok(skills.into_iter().find(|s| s.manifest.id == skill_id))
204 }
205
206 fn link_workflows(&self, skill: &InstalledSkill) -> Result<()> {
208 let workflows_dir = crate::workflow::parser::workflows_dir();
209 std::fs::create_dir_all(&workflows_dir)?;
210
211 for wf_file in &skill.manifest.workflows {
212 let src = skill.install_path.join(wf_file);
213 let dst = workflows_dir.join(wf_file);
214 if src.exists() && !dst.exists() {
215 std::fs::copy(&src, &dst)?;
216 }
217 }
218 Ok(())
219 }
220
221 fn unlink_workflows(&self, skill: &InstalledSkill) -> Result<()> {
223 let workflows_dir = crate::workflow::parser::workflows_dir();
224 for wf_file in &skill.manifest.workflows {
225 let dst = workflows_dir.join(wf_file);
226 if dst.exists() {
227 let _ = std::fs::remove_file(&dst);
228 }
229 }
230 Ok(())
231 }
232
233 fn add_to_registry(&self, skill: &InstalledSkill) -> Result<()> {
235 let mut skills = self.list()?;
236 skills.retain(|s| s.manifest.id != skill.manifest.id);
238 skills.push(skill.clone());
239 self.save_registry(&skills)?;
240 Ok(())
241 }
242
243 fn save_registry(&self, skills: &[InstalledSkill]) -> Result<()> {
245 std::fs::create_dir_all(&self.skills_dir)?;
246 let registry_path = self.skills_dir.join("registry.json");
247 let json = serde_json::to_string_pretty(skills)?;
248 std::fs::write(®istry_path, json)?;
249 Ok(())
250 }
251}
252
253pub fn search_skills<'a>(
255 skills: &'a [InstalledSkill],
256 query: &str,
257) -> Vec<&'a InstalledSkill> {
258 let query_lower = query.to_lowercase();
259 skills
260 .iter()
261 .filter(|s| {
262 s.manifest.id.to_lowercase().contains(&query_lower)
263 || s.manifest.name.to_lowercase().contains(&query_lower)
264 || s.manifest.description.to_lowercase().contains(&query_lower)
265 || s.manifest.tags.iter().any(|t| t.to_lowercase().contains(&query_lower))
266 })
267 .collect()
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct SkillSummary {
273 pub id: String,
274 pub name: String,
275 pub description: String,
276 pub version: String,
277 pub author: String,
278 pub enabled: bool,
279 pub workflows: usize,
280 pub tags: Vec<String>,
281}
282
283impl From<&InstalledSkill> for SkillSummary {
284 fn from(skill: &InstalledSkill) -> Self {
285 Self {
286 id: skill.manifest.id.clone(),
287 name: skill.manifest.name.clone(),
288 description: skill.manifest.description.clone(),
289 version: skill.manifest.version.clone(),
290 author: skill.manifest.author.clone(),
291 enabled: skill.enabled,
292 workflows: skill.manifest.workflows.len(),
293 tags: skill.manifest.tags.clone(),
294 }
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ScheduleConfig {
301 pub workflow_id: String,
303 pub cron: String,
305 #[serde(default)]
307 pub variables: HashMap<String, String>,
308 #[serde(default)]
310 pub shadow: bool,
311 #[serde(default = "default_true")]
313 pub enabled: bool,
314}
315
316fn default_true() -> bool {
317 true
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct TriggerConfig {
323 pub name: String,
325 pub trigger_type: TriggerType,
327 pub workflow_id: String,
329 #[serde(default)]
331 pub variables: HashMap<String, String>,
332 #[serde(default = "default_true")]
334 pub enabled: bool,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(tag = "type", rename_all = "snake_case")]
340pub enum TriggerType {
341 FileChange { path: String, pattern: Option<String> },
343 GitPush { repo_path: String, branch: Option<String> },
345 ErrorPattern { file: String, pattern: String },
347 Webhook { secret: Option<String> },
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_skill_manifest_parse() {
357 let yaml = r#"
358id: deploy-docker
359name: Docker Deploy
360description: Deploy services using Docker Compose
361version: "1.0.0"
362author: mur-team
363tags: [docker, deploy, devops]
364workflows:
365 - docker-deploy.yaml
366 - docker-rollback.yaml
367requires_env:
368 - DOCKER_HOST
369"#;
370 let manifest: SkillManifest = serde_yaml::from_str(yaml).unwrap();
371 assert_eq!(manifest.id, "deploy-docker");
372 assert_eq!(manifest.workflows.len(), 2);
373 assert_eq!(manifest.tags.len(), 3);
374 assert_eq!(manifest.requires_env.len(), 1);
375 }
376
377 #[test]
378 fn test_skill_registry_empty() {
379 let dir = tempfile::TempDir::new().unwrap();
380 let registry = SkillRegistry::new(dir.path());
381 let skills = registry.list().unwrap();
382 assert!(skills.is_empty());
383 }
384
385 #[test]
386 fn test_skill_install_local() {
387 let skills_dir = tempfile::TempDir::new().unwrap();
388 let source_dir = tempfile::TempDir::new().unwrap();
389
390 let manifest = r#"
392id: test-skill
393name: Test Skill
394description: A test skill
395version: "0.1.0"
396author: test
397tags: [test]
398workflows:
399 - test-workflow.yaml
400"#;
401 std::fs::write(source_dir.path().join("skill.yaml"), manifest).unwrap();
402 std::fs::write(
403 source_dir.path().join("test-workflow.yaml"),
404 "id: test-wf\nname: Test\nsteps: []",
405 ).unwrap();
406
407 let registry = SkillRegistry::new(skills_dir.path());
408 let installed = registry.install_local(source_dir.path()).unwrap();
409
410 assert_eq!(installed.manifest.id, "test-skill");
411 assert!(installed.enabled);
412
413 let skills = registry.list().unwrap();
414 assert_eq!(skills.len(), 1);
415 }
416
417 #[test]
418 fn test_skill_uninstall() {
419 let skills_dir = tempfile::TempDir::new().unwrap();
420 let source_dir = tempfile::TempDir::new().unwrap();
421
422 let manifest = r#"
423id: removable-skill
424name: Removable
425description: Will be removed
426version: "1.0.0"
427author: test
428workflows: []
429"#;
430 std::fs::write(source_dir.path().join("skill.yaml"), manifest).unwrap();
431
432 let registry = SkillRegistry::new(skills_dir.path());
433 registry.install_local(source_dir.path()).unwrap();
434
435 assert_eq!(registry.list().unwrap().len(), 1);
436
437 registry.uninstall("removable-skill").unwrap();
438 assert_eq!(registry.list().unwrap().len(), 0);
439 }
440
441 #[test]
442 fn test_search_skills() {
443 let skill = InstalledSkill {
444 manifest: SkillManifest {
445 id: "deploy-docker".into(),
446 name: "Docker Deploy".into(),
447 description: "Deploy with Docker".into(),
448 version: "1.0.0".into(),
449 author: "test".into(),
450 tags: vec!["docker".into(), "deploy".into()],
451 workflows: vec![],
452 requires_env: vec![],
453 min_commander_version: None,
454 },
455 install_path: PathBuf::from("/tmp/test"),
456 installed_at: Utc::now(),
457 enabled: true,
458 source: SkillSource::Local { path: "/tmp".into() },
459 };
460
461 let skills = vec![skill];
462 assert_eq!(search_skills(&skills, "docker").len(), 1);
463 assert_eq!(search_skills(&skills, "kubernetes").len(), 0);
464 assert_eq!(search_skills(&skills, "deploy").len(), 1);
465 }
466
467 #[test]
468 fn test_trigger_type_serialization() {
469 let trigger = TriggerType::FileChange {
470 path: "/var/log".into(),
471 pattern: Some("*.log".into()),
472 };
473 let json = serde_json::to_string(&trigger).unwrap();
474 assert!(json.contains("file_change"));
475
476 let webhook = TriggerType::Webhook { secret: Some("s3cret".into()) };
477 let json = serde_json::to_string(&webhook).unwrap();
478 assert!(json.contains("webhook"));
479 }
480
481 #[test]
482 fn test_schedule_config() {
483 let config = ScheduleConfig {
484 workflow_id: "deploy".into(),
485 cron: "0 9 * * 1-5".into(),
486 variables: HashMap::new(),
487 shadow: false,
488 enabled: true,
489 };
490 let json = serde_json::to_string(&config).unwrap();
491 let parsed: ScheduleConfig = serde_json::from_str(&json).unwrap();
492 assert_eq!(parsed.workflow_id, "deploy");
493 assert!(parsed.enabled);
494 }
495
496 #[test]
497 fn test_skill_summary() {
498 let skill = InstalledSkill {
499 manifest: SkillManifest {
500 id: "test".into(),
501 name: "Test".into(),
502 description: "Desc".into(),
503 version: "1.0.0".into(),
504 author: "author".into(),
505 tags: vec!["tag1".into()],
506 workflows: vec!["w1.yaml".into(), "w2.yaml".into()],
507 requires_env: vec![],
508 min_commander_version: None,
509 },
510 install_path: PathBuf::from("/tmp"),
511 installed_at: Utc::now(),
512 enabled: true,
513 source: SkillSource::Local { path: "/tmp".into() },
514 };
515 let summary = SkillSummary::from(&skill);
516 assert_eq!(summary.workflows, 2);
517 assert!(summary.enabled);
518 }
519}