1use regex::Regex;
8use serde_json::Value;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone)]
14pub struct SkillInfo {
15 pub name: String,
16 pub path: PathBuf,
17 pub source: SkillSource,
18}
19
20#[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#[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#[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
55pub struct SkillsLoader {
57 workspace_skills: PathBuf,
58 builtin_skills: PathBuf,
59}
60
61impl SkillsLoader {
62 fn default_builtin_skills_dir() -> PathBuf {
63 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
65 .join("..")
66 .join("skills")
67 }
68
69 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 pub fn list_skills(&self, filter_unavailable: bool) -> Vec<SkillInfo> {
94 let mut skills = Vec::new();
95
96 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 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 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 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 pub fn load_skill(&self, name: &str) -> Option<String> {
160 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 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 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 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 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 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 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 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 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 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 fn check_requirements(&self, meta: &SkillRuntimeMetadata) -> bool {
312 for bin in &meta.requires_bins {
314 if which::which(bin).is_err() {
315 return false;
316 }
317 }
318
319 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 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 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 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 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 fn escape_xml(s: &str) -> String {
429 s.replace('&', "&")
430 .replace('<', "<")
431 .replace('>', ">")
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>"), "<test>");
534 assert_eq!(SkillsLoader::escape_xml("a & b"), "a & 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}