Skip to main content

agent_diva_agent/
skills.rs

1//! Skill loading and management
2//!
3//! Skills are markdown files (SKILL.md) that teach the agent how to use
4//! specific tools or perform certain tasks. They contain YAML frontmatter
5//! with metadata and markdown content with instructions.
6
7use regex::Regex;
8use serde_json::Value;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Skill information
13#[derive(Debug, Clone)]
14pub struct SkillInfo {
15    pub name: String,
16    pub path: PathBuf,
17    pub source: SkillSource,
18}
19
20/// Skill source location
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum SkillSource {
23    Workspace,
24    Builtin,
25}
26
27impl std::fmt::Display for SkillSource {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            SkillSource::Workspace => write!(f, "workspace"),
31            SkillSource::Builtin => write!(f, "builtin"),
32        }
33    }
34}
35
36/// Skill metadata from frontmatter
37#[derive(Debug, Clone, Default)]
38pub struct SkillMetadata {
39    pub name: Option<String>,
40    pub description: Option<String>,
41    pub homepage: Option<String>,
42    pub always: bool,
43    pub metadata: Option<String>,
44}
45
46/// Parsed agent-diva metadata from JSON in frontmatter
47#[derive(Debug, Clone, Default)]
48pub struct SkillRuntimeMetadata {
49    pub emoji: Option<String>,
50    pub always: bool,
51    pub requires_bins: Vec<String>,
52    pub requires_env: Vec<String>,
53}
54
55/// Skills loader for agent capabilities
56pub struct SkillsLoader {
57    workspace_skills: PathBuf,
58    builtin_skills: PathBuf,
59}
60
61impl SkillsLoader {
62    fn default_builtin_skills_dir() -> PathBuf {
63        // `agent-diva-agent` sits next to `skills/` in the workspace tree.
64        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
65            .join("..")
66            .join("skills")
67    }
68
69    /// Create a new skills loader
70    ///
71    /// # Arguments
72    ///
73    /// * `workspace` - Path to the workspace directory
74    /// * `builtin_skills_dir` - Optional path to built-in skills (defaults to bundled skills)
75    pub fn new<P: AsRef<Path>>(workspace: P, builtin_skills_dir: Option<PathBuf>) -> Self {
76        let workspace = workspace.as_ref();
77        let workspace_skills = workspace.join("skills");
78        Self {
79            workspace_skills,
80            builtin_skills: builtin_skills_dir.unwrap_or_else(Self::default_builtin_skills_dir),
81        }
82    }
83
84    /// List all available skills
85    ///
86    /// # Arguments
87    ///
88    /// * `filter_unavailable` - If true, filter out skills with unmet requirements
89    ///
90    /// # Returns
91    ///
92    /// List of skill information
93    pub fn list_skills(&self, filter_unavailable: bool) -> Vec<SkillInfo> {
94        let mut skills = Vec::new();
95
96        // Workspace skills (highest priority)
97        if self.workspace_skills.exists() {
98            if let Ok(entries) = fs::read_dir(&self.workspace_skills) {
99                for entry in entries.flatten() {
100                    if entry.path().is_dir() {
101                        let skill_file = entry.path().join("SKILL.md");
102                        if skill_file.exists() {
103                            if let Some(name) = entry.file_name().to_str() {
104                                skills.push(SkillInfo {
105                                    name: name.to_string(),
106                                    path: skill_file,
107                                    source: SkillSource::Workspace,
108                                });
109                            }
110                        }
111                    }
112                }
113            }
114        }
115
116        // Built-in skills
117        if self.builtin_skills.exists() {
118            if let Ok(entries) = fs::read_dir(&self.builtin_skills) {
119                for entry in entries.flatten() {
120                    if entry.path().is_dir() {
121                        let skill_file = entry.path().join("SKILL.md");
122                        if skill_file.exists() {
123                            if let Some(name) = entry.file_name().to_str() {
124                                // Skip if already in workspace skills
125                                if !skills.iter().any(|s| s.name == name) {
126                                    skills.push(SkillInfo {
127                                        name: name.to_string(),
128                                        path: skill_file,
129                                        source: SkillSource::Builtin,
130                                    });
131                                }
132                            }
133                        }
134                    }
135                }
136            }
137        }
138
139        // Filter by requirements
140        if filter_unavailable {
141            skills.retain(|s| {
142                let meta = self.get_skill_runtime_metadata(&s.name);
143                self.check_requirements(&meta)
144            });
145        }
146
147        skills
148    }
149
150    /// Load a skill by name
151    ///
152    /// # Arguments
153    ///
154    /// * `name` - Skill name (directory name)
155    ///
156    /// # Returns
157    ///
158    /// Skill content or None if not found
159    pub fn load_skill(&self, name: &str) -> Option<String> {
160        // Check workspace first
161        let workspace_skill = self.workspace_skills.join(name).join("SKILL.md");
162        if workspace_skill.exists() {
163            return fs::read_to_string(workspace_skill).ok();
164        }
165
166        // Check built-in
167        let builtin_skill = self.builtin_skills.join(name).join("SKILL.md");
168        if builtin_skill.exists() {
169            return fs::read_to_string(builtin_skill).ok();
170        }
171
172        None
173    }
174
175    /// Load specific skills for inclusion in agent context
176    ///
177    /// # Arguments
178    ///
179    /// * `skill_names` - List of skill names to load
180    ///
181    /// # Returns
182    ///
183    /// Formatted skills content
184    pub fn load_skills_for_context(&self, skill_names: &[String]) -> String {
185        let mut parts = Vec::new();
186
187        for name in skill_names {
188            if let Some(content) = self.load_skill(name) {
189                let content = Self::strip_frontmatter(&content);
190                parts.push(format!("### Skill: {}\n\n{}", name, content));
191            }
192        }
193
194        if parts.is_empty() {
195            String::new()
196        } else {
197            parts.join("\n\n---\n\n")
198        }
199    }
200
201    /// Build a summary of all skills (name, description, path, availability)
202    ///
203    /// This is used for progressive loading - the agent can read the full
204    /// skill content using read_file when needed.
205    ///
206    /// # Returns
207    ///
208    /// XML-formatted skills summary
209    pub fn build_skills_summary(&self) -> String {
210        let all_skills = self.list_skills(false);
211        if all_skills.is_empty() {
212            return String::new();
213        }
214
215        let mut lines = vec!["<skills>".to_string()];
216
217        for skill in all_skills {
218            let name = Self::escape_xml(&skill.name);
219            let path = skill.path.display().to_string();
220            let desc = Self::escape_xml(&self.get_skill_description(&skill.name));
221            let meta = self.get_skill_runtime_metadata(&skill.name);
222            let available = self.check_requirements(&meta);
223
224            lines.push(format!(
225                "  <skill available=\"{}\">",
226                if available { "true" } else { "false" }
227            ));
228            lines.push(format!("    <name>{}</name>", name));
229            lines.push(format!("    <description>{}</description>", desc));
230            lines.push(format!("    <location>{}</location>", path));
231
232            // Show missing requirements for unavailable skills
233            if !available {
234                let missing = self.get_missing_requirements(&meta);
235                if !missing.is_empty() {
236                    lines.push(format!(
237                        "    <requires>{}</requires>",
238                        Self::escape_xml(&missing)
239                    ));
240                }
241            }
242
243            lines.push("  </skill>".to_string());
244        }
245
246        lines.push("</skills>".to_string());
247        lines.join("\n")
248    }
249
250    /// Get skills marked as always=true that meet requirements
251    pub fn get_always_skills(&self) -> Vec<String> {
252        let mut result = Vec::new();
253
254        for skill in self.list_skills(true) {
255            let metadata = self.get_skill_metadata(&skill.name);
256            let runtime_meta = self.get_skill_runtime_metadata(&skill.name);
257
258            if metadata.always || runtime_meta.always {
259                result.push(skill.name);
260            }
261        }
262
263        result
264    }
265
266    /// Get metadata from a skill's frontmatter
267    ///
268    /// # Arguments
269    ///
270    /// * `name` - Skill name
271    ///
272    /// # Returns
273    ///
274    /// Metadata or default if not found
275    pub fn get_skill_metadata(&self, name: &str) -> SkillMetadata {
276        let content = match self.load_skill(name) {
277            Some(c) => c,
278            None => return SkillMetadata::default(),
279        };
280
281        if !content.starts_with("---") {
282            return SkillMetadata::default();
283        }
284
285        // Match YAML frontmatter
286        let re = Regex::new(r"(?s)^---\n(.*?)\n---").unwrap();
287        if let Some(caps) = re.captures(&content) {
288            let yaml_content = caps.get(1).unwrap().as_str();
289            return Self::parse_yaml_frontmatter(yaml_content);
290        }
291
292        SkillMetadata::default()
293    }
294
295    /// Get runtime metadata from a skill frontmatter JSON blob.
296    fn get_skill_runtime_metadata(&self, name: &str) -> SkillRuntimeMetadata {
297        let metadata = self.get_skill_metadata(name);
298        if let Some(ref meta_str) = metadata.metadata {
299            return Self::parse_runtime_metadata(meta_str);
300        }
301        SkillRuntimeMetadata::default()
302    }
303
304    /// Get the description of a skill
305    fn get_skill_description(&self, name: &str) -> String {
306        let meta = self.get_skill_metadata(name);
307        meta.description.unwrap_or_else(|| name.to_string())
308    }
309
310    /// Check if skill requirements are met (bins, env vars)
311    fn check_requirements(&self, meta: &SkillRuntimeMetadata) -> bool {
312        // Check required binaries
313        for bin in &meta.requires_bins {
314            if which::which(bin).is_err() {
315                return false;
316            }
317        }
318
319        // Check required environment variables
320        for env in &meta.requires_env {
321            if std::env::var(env).is_err() {
322                return false;
323            }
324        }
325
326        true
327    }
328
329    /// Get a description of missing requirements
330    fn get_missing_requirements(&self, meta: &SkillRuntimeMetadata) -> String {
331        let mut missing = Vec::new();
332
333        for bin in &meta.requires_bins {
334            if which::which(bin).is_err() {
335                missing.push(format!("CLI: {}", bin));
336            }
337        }
338
339        for env in &meta.requires_env {
340            if std::env::var(env).is_err() {
341                missing.push(format!("ENV: {}", env));
342            }
343        }
344
345        missing.join(", ")
346    }
347
348    /// Remove YAML frontmatter from markdown content
349    fn strip_frontmatter(content: &str) -> String {
350        if !content.starts_with("---") {
351            return content.to_string();
352        }
353
354        let re = Regex::new(r"(?s)^---\n.*?\n---\n").unwrap();
355        if let Some(m) = re.find(content) {
356            return content[m.end()..].trim().to_string();
357        }
358
359        content.to_string()
360    }
361
362    /// Parse YAML frontmatter (simple key-value parser)
363    fn parse_yaml_frontmatter(yaml: &str) -> SkillMetadata {
364        let mut metadata = SkillMetadata::default();
365
366        for line in yaml.lines() {
367            if let Some((key, value)) = line.split_once(':') {
368                let key = key.trim();
369                let value = value.trim().trim_matches('"').trim_matches('\'');
370
371                match key {
372                    "name" => metadata.name = Some(value.to_string()),
373                    "description" => metadata.description = Some(value.to_string()),
374                    "homepage" => metadata.homepage = Some(value.to_string()),
375                    "always" => metadata.always = value == "true",
376                    "metadata" => metadata.metadata = Some(value.to_string()),
377                    _ => {}
378                }
379            }
380        }
381
382        metadata
383    }
384
385    /// Parse runtime metadata JSON from frontmatter.
386    /// Supports `nanobot` and `openclaw` keys for compatibility.
387    fn parse_runtime_metadata(raw: &str) -> SkillRuntimeMetadata {
388        let value: Value = match serde_json::from_str(raw) {
389            Ok(v) => v,
390            Err(_) => return SkillRuntimeMetadata::default(),
391        };
392
393        let runtime = match value.get("nanobot").or_else(|| value.get("openclaw")) {
394            Some(n) => n,
395            None => return SkillRuntimeMetadata::default(),
396        };
397
398        let mut meta = SkillRuntimeMetadata::default();
399
400        if let Some(emoji) = runtime.get("emoji").and_then(|v| v.as_str()) {
401            meta.emoji = Some(emoji.to_string());
402        }
403
404        if let Some(always) = runtime.get("always").and_then(|v| v.as_bool()) {
405            meta.always = always;
406        }
407
408        if let Some(requires) = runtime.get("requires").and_then(|v| v.as_object()) {
409            if let Some(bins) = requires.get("bins").and_then(|v| v.as_array()) {
410                meta.requires_bins = bins
411                    .iter()
412                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
413                    .collect();
414            }
415
416            if let Some(env) = requires.get("env").and_then(|v| v.as_array()) {
417                meta.requires_env = env
418                    .iter()
419                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
420                    .collect();
421            }
422        }
423
424        meta
425    }
426
427    /// Escape XML special characters
428    fn escape_xml(s: &str) -> String {
429        s.replace('&', "&amp;")
430            .replace('<', "&lt;")
431            .replace('>', "&gt;")
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use std::fs;
439    use tempfile::TempDir;
440
441    fn create_test_skill(dir: &Path, name: &str, content: &str) {
442        let skill_dir = dir.join(name);
443        fs::create_dir_all(&skill_dir).unwrap();
444        fs::write(skill_dir.join("SKILL.md"), content).unwrap();
445    }
446
447    #[test]
448    fn test_list_skills() {
449        let workspace = TempDir::new().unwrap();
450        let builtin = TempDir::new().unwrap();
451        let skills_dir = workspace.path().join("skills");
452        fs::create_dir_all(&skills_dir).unwrap();
453
454        create_test_skill(
455            &skills_dir,
456            "test-skill",
457            "---\nname: test-skill\ndescription: A test skill\n---\n\n# Test\n",
458        );
459
460        let loader = SkillsLoader::new(workspace.path(), Some(builtin.path().to_path_buf()));
461        let skills = loader.list_skills(false);
462
463        assert_eq!(skills.len(), 1);
464        assert_eq!(skills[0].name, "test-skill");
465        assert_eq!(skills[0].source, SkillSource::Workspace);
466    }
467
468    #[test]
469    fn test_load_skill() {
470        let workspace = TempDir::new().unwrap();
471        let skills_dir = workspace.path().join("skills");
472        fs::create_dir_all(&skills_dir).unwrap();
473
474        let content = "---\nname: test\n---\n\n# Test Content\n";
475        create_test_skill(&skills_dir, "test", content);
476
477        let loader = SkillsLoader::new(workspace.path(), None);
478        let loaded = loader.load_skill("test");
479
480        assert!(loaded.is_some());
481        assert_eq!(loaded.unwrap(), content);
482    }
483
484    #[test]
485    fn test_strip_frontmatter() {
486        let content = "---\nname: test\n---\n\n# Content";
487        let stripped = SkillsLoader::strip_frontmatter(content);
488        assert_eq!(stripped, "# Content");
489    }
490
491    #[test]
492    fn test_parse_metadata() {
493        let yaml = "name: test\ndescription: A test\nalways: true";
494        let meta = SkillsLoader::parse_yaml_frontmatter(yaml);
495
496        assert_eq!(meta.name.unwrap(), "test");
497        assert_eq!(meta.description.unwrap(), "A test");
498        assert!(meta.always);
499    }
500
501    #[test]
502    fn test_parse_runtime_metadata_nanobot() {
503        let json =
504            r#"{"nanobot":{"emoji":"cloud","requires":{"bins":["curl"],"env":["API_KEY"]}}}"#;
505        let meta = SkillsLoader::parse_runtime_metadata(json);
506
507        assert_eq!(meta.emoji.unwrap(), "cloud");
508        assert_eq!(meta.requires_bins, vec!["curl"]);
509        assert_eq!(meta.requires_env, vec!["API_KEY"]);
510    }
511
512    #[test]
513    fn test_parse_runtime_metadata_openclaw() {
514        let json = r#"{"openclaw":{"always":true,"requires":{"bins":["git"]}}}"#;
515        let meta = SkillsLoader::parse_runtime_metadata(json);
516
517        assert!(meta.always);
518        assert_eq!(meta.requires_bins, vec!["git"]);
519    }
520
521    #[test]
522    fn test_parse_runtime_metadata_ignores_agent_diva_key() {
523        let json = r#"{"agent-diva":{"always":true}}"#;
524        let meta = SkillsLoader::parse_runtime_metadata(json);
525
526        assert!(!meta.always);
527        assert!(meta.requires_bins.is_empty());
528        assert!(meta.requires_env.is_empty());
529    }
530
531    #[test]
532    fn test_escape_xml() {
533        assert_eq!(SkillsLoader::escape_xml("<test>"), "&lt;test&gt;");
534        assert_eq!(SkillsLoader::escape_xml("a & b"), "a &amp; b");
535    }
536
537    #[test]
538    fn test_build_skills_summary() {
539        let workspace = TempDir::new().unwrap();
540        let skills_dir = workspace.path().join("skills");
541        fs::create_dir_all(&skills_dir).unwrap();
542
543        create_test_skill(
544            &skills_dir,
545            "weather",
546            "---\nname: weather\ndescription: Weather info\n---\n\n# Weather\n",
547        );
548
549        let loader = SkillsLoader::new(workspace.path(), None);
550        let summary = loader.build_skills_summary();
551
552        assert!(summary.contains("<skills>"));
553        assert!(summary.contains("<name>weather</name>"));
554        assert!(summary.contains("<description>Weather info</description>"));
555    }
556
557    #[test]
558    fn test_workspace_overrides_builtin() {
559        let workspace = TempDir::new().unwrap();
560        let builtin = TempDir::new().unwrap();
561        let workspace_skills = workspace.path().join("skills");
562        fs::create_dir_all(&workspace_skills).unwrap();
563
564        create_test_skill(
565            &workspace_skills,
566            "weather",
567            "---\nname: weather\ndescription: Workspace Weather\n---\n\n# Workspace\n",
568        );
569        create_test_skill(
570            builtin.path(),
571            "weather",
572            "---\nname: weather\ndescription: Builtin Weather\n---\n\n# Builtin\n",
573        );
574
575        let loader = SkillsLoader::new(workspace.path(), Some(builtin.path().to_path_buf()));
576        let summary = loader.build_skills_summary();
577
578        assert!(summary.contains("<description>Workspace Weather</description>"));
579        assert!(!summary.contains("Builtin Weather"));
580    }
581
582    #[test]
583    fn test_default_builtin_dir_loads_skills() {
584        let workspace = TempDir::new().unwrap();
585        let loader = SkillsLoader::new(workspace.path(), None);
586        let skills = loader.list_skills(false);
587
588        assert!(skills.iter().any(|s| s.source == SkillSource::Builtin));
589    }
590}