Skip to main content

mur_core/
skill.rs

1//! Skill system — load, install, list, and manage workflow skills.
2//!
3//! Skills are packaged workflow templates with metadata that can be
4//! installed from local directories or a community repository.
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// Skill manifest (skill.yaml) — metadata for a skill package.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SkillManifest {
15    /// Unique skill identifier (e.g., "deploy-docker").
16    pub id: String,
17    /// Human-readable name.
18    pub name: String,
19    /// Description of what the skill does.
20    pub description: String,
21    /// Skill version (semver).
22    pub version: String,
23    /// Author name or handle.
24    pub author: String,
25    /// Tags for discovery.
26    #[serde(default)]
27    pub tags: Vec<String>,
28    /// Workflow files included in this skill.
29    #[serde(default)]
30    pub workflows: Vec<String>,
31    /// Required environment variables.
32    #[serde(default)]
33    pub requires_env: Vec<String>,
34    /// Minimum Commander version required.
35    #[serde(default)]
36    pub min_commander_version: Option<String>,
37}
38
39/// An installed skill with metadata.
40#[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/// Where a skill was installed from.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum SkillSource {
53    /// Installed from a local directory.
54    Local { path: String },
55    /// Installed from the community repository.
56    Community { url: String },
57}
58
59/// Registry of installed skills.
60pub struct SkillRegistry {
61    skills_dir: PathBuf,
62}
63
64impl SkillRegistry {
65    /// Create a new skill registry.
66    pub fn new(skills_dir: &Path) -> Self {
67        Self {
68            skills_dir: skills_dir.to_path_buf(),
69        }
70    }
71
72    /// Get the default skills directory (~/.mur/skills/).
73    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    /// List all installed skills.
80    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(&registry_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    /// Install a skill from a local directory.
98    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        // Create skill directory
110        let skill_dir = self.skills_dir.join(&manifest.id);
111        std::fs::create_dir_all(&skill_dir)?;
112
113        // Copy workflow files
114        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        // Copy manifest
123        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        // Update registry
136        self.add_to_registry(&installed)?;
137
138        // Link workflows to ~/.mur/workflows/
139        self.link_workflows(&installed)?;
140
141        Ok(installed)
142    }
143
144    /// Install a skill by name from the community repository.
145    pub fn install_community(&self, name: &str) -> Result<InstalledSkill> {
146        // Community repository integration is a future feature.
147        // For now, check if there's a local skill directory.
148        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    /// Uninstall a skill by ID.
160    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        // Remove linked workflows
170        self.unlink_workflows(&skill)?;
171
172        // Remove skill directory
173        if skill.install_path.exists() {
174            std::fs::remove_dir_all(&skill.install_path)?;
175        }
176
177        // Update registry
178        self.save_registry(&skills)?;
179
180        Ok(())
181    }
182
183    /// Enable or disable a skill.
184    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    /// Get skill details by ID.
201    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    /// Link a skill's workflows to the workflows directory.
207    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    /// Unlink a skill's workflows from the workflows directory.
222    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    /// Add a skill to the registry file.
234    fn add_to_registry(&self, skill: &InstalledSkill) -> Result<()> {
235        let mut skills = self.list()?;
236        // Remove existing version if any
237        skills.retain(|s| s.manifest.id != skill.manifest.id);
238        skills.push(skill.clone());
239        self.save_registry(&skills)?;
240        Ok(())
241    }
242
243    /// Save the registry file.
244    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(&registry_path, json)?;
249        Ok(())
250    }
251}
252
253/// Search skills by tag or name.
254pub 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/// Skill info summary for display.
271#[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/// Scheduled job definition used by both skill system and scheduler.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ScheduleConfig {
301    /// Workflow ID to run.
302    pub workflow_id: String,
303    /// Cron expression (e.g., "0 9 * * 1-5").
304    pub cron: String,
305    /// Variables to pass.
306    #[serde(default)]
307    pub variables: HashMap<String, String>,
308    /// Whether to run in shadow mode.
309    #[serde(default)]
310    pub shadow: bool,
311    /// Whether the schedule is enabled.
312    #[serde(default = "default_true")]
313    pub enabled: bool,
314}
315
316fn default_true() -> bool {
317    true
318}
319
320/// Trigger configuration used by skill system and trigger engine.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct TriggerConfig {
323    /// Trigger name.
324    pub name: String,
325    /// Trigger type.
326    pub trigger_type: TriggerType,
327    /// Workflow to execute when triggered.
328    pub workflow_id: String,
329    /// Variables to pass.
330    #[serde(default)]
331    pub variables: HashMap<String, String>,
332    /// Whether the trigger is enabled.
333    #[serde(default = "default_true")]
334    pub enabled: bool,
335}
336
337/// Types of event triggers.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(tag = "type", rename_all = "snake_case")]
340pub enum TriggerType {
341    /// Watch a file or directory for changes.
342    FileChange { path: String, pattern: Option<String> },
343    /// Watch for git push events.
344    GitPush { repo_path: String, branch: Option<String> },
345    /// Watch log files for error patterns.
346    ErrorPattern { file: String, pattern: String },
347    /// HTTP webhook trigger.
348    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        // Create a skill source
391        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}