1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6#[derive(Debug, Clone)]
7pub struct SkillMetadata {
8 pub name: String,
9 pub description: String,
10 pub dir_path: PathBuf,
11 pub platforms: Vec<String>,
12 pub deps: Vec<String>,
13 pub source: String,
14 pub version: Option<String>,
15 pub updated_at: Option<String>,
16 pub env_file: Option<String>,
17 pub license: Option<String>,
20 pub compatibility: Option<String>,
26 pub allowed_tools: Option<String>,
31}
32
33#[derive(Debug, Clone)]
34pub struct SkillAvailability {
35 pub meta: SkillMetadata,
36 pub available: bool,
37 pub reason: Option<String>,
38}
39
40#[derive(Debug, Deserialize, Default)]
41#[allow(dead_code)]
42struct SkillFrontmatter {
43 name: Option<String>,
44 #[serde(default)]
45 description: String,
46 #[serde(default)]
47 platforms: Vec<String>,
48 #[serde(default)]
49 deps: Vec<String>,
50 #[serde(default)]
51 compatibility: CompatibilityField,
52 #[serde(default)]
53 source: Option<String>,
54 #[serde(default)]
55 version: Option<String>,
56 #[serde(default)]
57 updated_at: Option<String>,
58 #[serde(default)]
59 env_file: Option<String>,
60 #[serde(default)]
61 metadata: SkillFrontmatterMetadata,
62 #[serde(default)]
64 license: Option<String>,
65 #[serde(default, rename = "allowed-tools")]
67 allowed_tools: Option<String>,
68}
69
70#[derive(Debug, Deserialize)]
75#[serde(untagged)]
76enum CompatibilityField {
77 Structured(SkillCompatibility),
78 Free(String),
79}
80
81impl Default for CompatibilityField {
82 fn default() -> Self {
83 CompatibilityField::Structured(SkillCompatibility::default())
84 }
85}
86
87#[derive(Debug, Deserialize, Default)]
88#[allow(dead_code)]
89struct SkillFrontmatterMetadata {
90 #[serde(default)]
91 pub openclaw: Option<OpenClaw>,
92 #[serde(default)]
93 pub clawdbot: Option<OpenClaw>, }
95
96#[derive(Debug, Deserialize, Default)]
97#[allow(dead_code)]
98struct OpenClaw {
99 #[serde(default)]
100 pub requires: Option<Requires>,
101 #[serde(default)]
102 pub os: Vec<String>,
103}
104
105#[derive(Debug, Deserialize, Default)]
106#[allow(dead_code)]
107struct Requires {
108 #[serde(default)]
109 pub bins: Vec<String>,
110 #[serde(default)]
111 pub env: Vec<String>,
112 #[serde(default, rename = "anyBins")]
113 pub any_bins: Vec<String>,
114}
115
116#[derive(Debug, Deserialize, Default)]
117struct SkillCompatibility {
118 #[serde(default)]
119 os: Vec<String>,
120 #[serde(default)]
121 deps: Vec<String>,
122}
123
124pub struct SkillManager {
125 skills_dir: PathBuf,
126 state_file: Option<PathBuf>,
127 clawhub_lock: Option<PathBuf>,
130 clawhub_block: bool,
133 clawhub_verdicts: Mutex<Option<HashMap<String, String>>>,
137}
138
139const MAX_SKILLS_CATALOG_ITEMS: usize = 40;
140const MAX_SKILL_DESCRIPTION_CHARS: usize = 120;
141const COMPACT_SKILLS_MODE_THRESHOLD: usize = 20;
142const SKILLS_STATE_FILENAME: &str = "skills_state.json";
143
144const MAX_INLINED_SKILL_BODY_CHARS: usize = 1500;
149
150impl SkillManager {
151 fn base(skills_dir: PathBuf, state_file: Option<PathBuf>) -> Self {
152 SkillManager {
153 skills_dir,
154 state_file,
155 clawhub_lock: None,
156 clawhub_block: true,
157 clawhub_verdicts: Mutex::new(None),
158 }
159 }
160
161 pub fn from_skills_dir(skills_dir: &str) -> Self {
162 Self::base(PathBuf::from(skills_dir), None)
163 }
164
165 pub fn from_skills_and_runtime(skills_dir: &str, runtime_dir: &str) -> Self {
166 Self::base(
167 PathBuf::from(skills_dir),
168 Some(PathBuf::from(runtime_dir).join(SKILLS_STATE_FILENAME)),
169 )
170 }
171
172 #[allow(dead_code)]
173 pub fn new(data_dir: &str) -> Self {
174 Self::base(PathBuf::from(data_dir).join("skills"), None)
175 }
176
177 pub fn with_clawhub_verification(mut self, lockfile: PathBuf, block: bool) -> Self {
182 self.clawhub_lock = Some(lockfile);
183 self.clawhub_block = block;
184 self
185 }
186
187 pub fn with_config_verification(self, config: &crate::config::Config) -> Self {
189 match config.clawhub_load_verification() {
190 Some((lockfile, block)) => self.with_clawhub_verification(lockfile, block),
191 None => self,
192 }
193 }
194
195 pub fn discover_skills(&self) -> Vec<SkillMetadata> {
197 self.discover_skills_internal(false)
198 }
199
200 pub fn discover_skills_with_status(&self, include_unavailable: bool) -> Vec<SkillAvailability> {
202 let mut statuses = self.discover_skill_statuses();
203 if !include_unavailable {
204 statuses.retain(|s| s.available);
205 }
206 statuses
207 }
208
209 pub fn reload(&self) -> Vec<SkillMetadata> {
211 match self.clawhub_verdicts.lock() {
212 Ok(mut cache) => *cache = None,
213 Err(poisoned) => *poisoned.into_inner() = None,
214 }
215 self.discover_skills()
216 }
217
218 fn clawhub_unavailable_reasons(&self) -> HashMap<String, String> {
224 let Some(lock_path) = &self.clawhub_lock else {
225 return HashMap::new();
226 };
227 let mut cache = match self.clawhub_verdicts.lock() {
228 Ok(cache) => cache,
229 Err(poisoned) => poisoned.into_inner(),
230 };
231 if let Some(map) = cache.as_ref() {
232 return map.clone();
233 }
234 let mut reasons = HashMap::new();
235 let lock = match crate::internal::clawhub::lockfile::read_lockfile(lock_path) {
236 Ok(lock) => lock,
237 Err(e) => {
238 tracing::error!(
242 "ClawHub lockfile {} is unreadable: {e}",
243 lock_path.display()
244 );
245 *cache = Some(reasons.clone());
246 return reasons;
247 }
248 };
249 use crate::internal::clawhub::install::TreeVerification;
250 for (slug, entry) in &lock.skills {
251 match crate::internal::clawhub::install::verify_tree(entry, &self.skills_dir) {
252 TreeVerification::Ok => {}
253 TreeVerification::Unpinned => {
254 tracing::warn!(
255 "ClawHub skill '{slug}' has no recorded tree hash (installed before \
256 pinning); reinstall to pin"
257 );
258 }
259 TreeVerification::Missing => {
260 tracing::warn!(
261 "ClawHub skill '{slug}' is in the lockfile but its installed directory \
262 is missing or unreadable"
263 );
264 }
265 TreeVerification::Modified { expected, actual } => {
266 if self.clawhub_block {
267 tracing::error!(
268 "ClawHub skill '{slug}' tree MODIFIED since install (expected \
269 {expected}, actual {actual}); the skill is unavailable until \
270 reinstalled"
271 );
272 reasons.insert(
273 slug.clone(),
274 "Installed files no longer match the ClawHub lockfile hash. Run \
275 `microclaw skill verify` for details and reinstall the skill to \
276 restore integrity."
277 .to_string(),
278 );
279 } else {
280 tracing::warn!(
281 "ClawHub skill '{slug}' tree MODIFIED since install (expected \
282 {expected}, actual {actual}); clawhub_verify_on_load is 'warn' so \
283 the skill remains available"
284 );
285 }
286 }
287 }
288 }
289 *cache = Some(reasons.clone());
290 reasons
291 }
292
293 fn discover_skills_internal(&self, include_unavailable: bool) -> Vec<SkillMetadata> {
294 self.discover_skills_with_status(include_unavailable)
295 .into_iter()
296 .map(|s| s.meta)
297 .collect()
298 }
299
300 fn discover_skill_statuses(&self) -> Vec<SkillAvailability> {
301 let mut statuses = Vec::new();
302 let state = self.read_state_file();
303 let clawhub_blocked = self.clawhub_unavailable_reasons();
304 let entries = match std::fs::read_dir(&self.skills_dir) {
305 Ok(e) => e,
306 Err(_) => return statuses,
307 };
308
309 for entry in entries.flatten() {
310 let path = entry.path();
311 if !path.is_dir() {
312 continue;
313 }
314 let skill_md = path.join("SKILL.md");
315 if !skill_md.exists() {
316 continue;
317 }
318 if let Ok(content) = std::fs::read_to_string(&skill_md) {
319 if let Some((meta, _body)) = parse_skill_md(&content, &path) {
320 warn_once_if_legacy_name(&meta.name);
321 if matches!(state.get(&meta.name), Some(false)) {
322 statuses.push(SkillAvailability {
323 meta,
324 available: false,
325 reason: Some("Skill is disabled for this runtime.".to_string()),
326 });
327 continue;
328 }
329 let dir_slug = path
332 .file_name()
333 .and_then(|s| s.to_str())
334 .unwrap_or_default();
335 if let Some(reason) = clawhub_blocked.get(dir_slug) {
336 statuses.push(SkillAvailability {
337 meta,
338 available: false,
339 reason: Some(reason.clone()),
340 });
341 continue;
342 }
343 match self.skill_is_available(&meta) {
344 Ok(()) => statuses.push(SkillAvailability {
345 meta,
346 available: true,
347 reason: None,
348 }),
349 Err(reason) => statuses.push(SkillAvailability {
350 meta,
351 available: false,
352 reason: Some(reason),
353 }),
354 };
355 }
356 }
357 }
358
359 statuses.sort_by(|a, b| a.meta.name.cmp(&b.meta.name));
360 statuses
361 }
362
363 pub fn has_skill(&self, name: &str) -> bool {
364 self.discover_skill_statuses()
365 .iter()
366 .any(|skill| skill.meta.name == name)
367 }
368
369 pub fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), String> {
370 if !self.has_skill(name) {
371 return Err(format!("Skill not found: {name}"));
372 }
373 let mut state = self.read_state_file();
374 if enabled {
375 state.remove(name);
376 } else {
377 state.insert(name.to_string(), false);
378 }
379 self.write_state_file(&state)
380 }
381
382 pub fn load_skill(&self, name: &str) -> Option<(SkillMetadata, String)> {
384 self.load_skill_checked(name).ok()
385 }
386
387 pub fn load_skill_checked(&self, name: &str) -> Result<(SkillMetadata, String), String> {
389 let all_skills = self.discover_skills_with_status(true);
390
391 for skill in all_skills {
392 if skill.meta.name != name {
393 continue;
394 }
395 if !skill.available {
396 let reason = skill
397 .reason
398 .unwrap_or_else(|| "unknown availability failure".to_string());
399 return Err(format!(
400 "Skill '{name}' is currently unavailable: {reason}\nRun `microclaw skill available --all` for full diagnostics."
401 ));
402 }
403 let skill_md = skill.meta.dir_path.join("SKILL.md");
404 if let Ok(content) = std::fs::read_to_string(&skill_md) {
405 if let Some((meta, body)) = parse_skill_md(&content, &skill.meta.dir_path) {
406 return Ok((meta, body));
407 }
408 }
409 return Err(format!("Skill '{name}' exists but could not be loaded."));
410 }
411
412 let available = self.discover_skills();
413 if available.is_empty() {
414 Err(format!(
415 "Skill '{name}' not found. No skills are currently available."
416 ))
417 } else {
418 let names: Vec<&str> = available.iter().map(|s| s.name.as_str()).collect();
419 Err(format!(
420 "Skill '{name}' not found. Available skills: {}",
421 names.join(", ")
422 ))
423 }
424 }
425
426 fn skill_is_available(&self, skill: &SkillMetadata) -> Result<(), String> {
427 if !platform_allowed(&skill.platforms) {
428 return Err(format!(
429 "Skill '{}' is not available on this platform (current: {}, supported: {}).",
430 skill.name,
431 current_platform(),
432 skill.platforms.join(", ")
433 ));
434 }
435
436 let missing = missing_deps(&skill.deps);
437 if !missing.is_empty() {
438 return Err(format!(
439 "Skill '{}' is missing required dependencies: {}",
440 skill.name,
441 missing.join(", ")
442 ));
443 }
444
445 Ok(())
446 }
447
448 pub fn build_skills_catalog(&self) -> String {
451 let mut skills = self.discover_skills();
452 if skills.is_empty() {
453 return String::new();
454 }
455
456 skills.sort_by_key(|s| s.name.to_ascii_lowercase());
458
459 let total_count = skills.len();
460 let omitted = total_count.saturating_sub(MAX_SKILLS_CATALOG_ITEMS);
461 let visible = skills
462 .into_iter()
463 .take(MAX_SKILLS_CATALOG_ITEMS)
464 .collect::<Vec<_>>();
465 let compact_mode = total_count > COMPACT_SKILLS_MODE_THRESHOLD || omitted > 0;
466
467 let mut catalog = String::from("<available_skills>\n");
468 for skill in &visible {
469 if compact_mode {
470 catalog.push_str(&format!("- {}\n", skill.name));
471 } else {
472 let desc = truncate_chars(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
473 catalog.push_str(&format!("- {}: {}\n", skill.name, desc));
474 }
475 }
476 if compact_mode {
477 catalog.push_str("- (compact mode: use activate_skill to load full instructions)\n");
478 }
479 if omitted > 0 {
480 catalog.push_str(&format!(
481 "- ... ({} additional skills omitted for prompt budget)\n",
482 omitted
483 ));
484 }
485 catalog.push_str("</available_skills>");
486 catalog
487 }
488
489 pub fn build_skills_catalog_for_query(&self, query: &str, top_k: usize) -> String {
502 if top_k == 0 || query.trim().is_empty() {
503 return self.build_skills_catalog();
504 }
505 let mut skills = self.discover_skills();
506 if skills.is_empty() {
507 return String::new();
508 }
509
510 let query_tokens = crate::memory_service::tokenize_for_relevance(query);
511 let mut scored: Vec<(usize, SkillMetadata)> = skills
515 .drain(..)
516 .map(|s| {
517 let blob = format!("{} {}", s.name, s.description);
518 let blob_tokens = crate::memory_service::tokenize_for_relevance(&blob);
519 let score = blob_tokens
520 .iter()
521 .filter(|t| query_tokens.contains(*t))
522 .count();
523 (score, s)
524 })
525 .collect();
526 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
528
529 let mut hot: Vec<SkillMetadata> = Vec::new();
531 let mut cold: Vec<SkillMetadata> = Vec::new();
532 for (score, meta) in scored {
533 if score > 0 && hot.len() < top_k {
534 hot.push(meta);
535 } else {
536 cold.push(meta);
537 }
538 }
539 if hot.is_empty() {
540 return self.build_skills_catalog();
542 }
543
544 let mut out = String::from("<available_skills>\n");
545 out.push_str("<!-- Hot matches: full body inlined for the most relevant skills. -->\n");
546 for meta in &hot {
547 out.push_str(&format!("## {}\n", meta.name));
548 out.push_str(&format!("Description: {}\n", meta.description));
549 if let Some(version) = &meta.version {
550 out.push_str(&format!("Version: {}\n", version));
551 }
552 let skill_md = meta.dir_path.join("SKILL.md");
553 if let Ok(content) = std::fs::read_to_string(&skill_md) {
554 let body = strip_frontmatter(&content);
555 let body = truncate_chars(body, MAX_INLINED_SKILL_BODY_CHARS);
556 out.push_str("Instructions:\n");
557 out.push_str(&body);
558 if !body.ends_with('\n') {
559 out.push('\n');
560 }
561 }
562 out.push('\n');
563 }
564 if !cold.is_empty() {
565 out.push_str(
566 "<!-- Other available skills (call activate_skill to load full body): -->\n",
567 );
568 cold.sort_by_key(|s| s.name.to_ascii_lowercase());
569 let total_cold = cold.len();
570 let omitted = total_cold.saturating_sub(MAX_SKILLS_CATALOG_ITEMS);
571 for skill in cold.into_iter().take(MAX_SKILLS_CATALOG_ITEMS) {
572 let desc = truncate_chars(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
573 out.push_str(&format!("- {}: {}\n", skill.name, desc));
574 }
575 if omitted > 0 {
576 out.push_str(&format!(
577 "- ... ({omitted} additional skills omitted for prompt budget)\n"
578 ));
579 }
580 }
581 out.push_str("</available_skills>");
582 out
583 }
584
585 pub fn build_skills_catalog_for_names_and_query(
592 &self,
593 names: &[String],
594 _query: &str,
595 _top_k: usize,
596 ) -> String {
597 let mut seen = std::collections::HashSet::new();
598 let mut catalog = String::from("<available_skills>\n");
599 for name in names {
600 let name = name.trim();
601 if name.is_empty() || !seen.insert(name.to_ascii_lowercase()) {
602 continue;
603 }
604 match self.load_skill_checked(name) {
605 Ok((metadata, body)) => {
606 catalog.push_str(&format!(
607 "<skill name=\"{}\" description=\"{}\">\n{}\n</skill>\n",
608 escape_xml_attribute(&metadata.name),
609 escape_xml_attribute(&metadata.description),
610 truncate_chars(&body, MAX_INLINED_SKILL_BODY_CHARS),
611 ));
612 }
613 Err(reason) => catalog.push_str(&format!(
614 "<skill-unavailable name=\"{}\">{}</skill-unavailable>\n",
615 escape_xml_attribute(name),
616 escape_xml_text(&reason),
617 )),
618 }
619 }
620 catalog.push_str("</available_skills>");
621 catalog
622 }
623
624 pub fn list_skills_formatted(&self) -> String {
626 let skills = self.discover_skills();
627 if skills.is_empty() {
628 return "No skills available on this platform/runtime.".into();
629 }
630 let mut output = format!("Available skills ({}):\n\n", skills.len());
631 for skill in &skills {
632 output.push_str(&format!(
633 "• {} — {} [{}]\n",
634 skill.name, skill.description, skill.source
635 ));
636 append_agentskills_metadata(&mut output, skill);
637 }
638 output
639 }
640
641 pub fn list_skills_formatted_all(&self) -> String {
643 let statuses = self.discover_skills_with_status(true);
644 if statuses.is_empty() {
645 return "No skills found in skills directory.".into();
646 }
647 let available: Vec<&SkillAvailability> = statuses.iter().filter(|s| s.available).collect();
648 let unavailable: Vec<&SkillAvailability> =
649 statuses.iter().filter(|s| !s.available).collect();
650 let mut output = String::new();
651 output.push_str(&format!("Available skills ({}):\n\n", available.len()));
652 for skill in available {
653 output.push_str(&format!(
654 "• {} — {} [{}]\n",
655 skill.meta.name, skill.meta.description, skill.meta.source
656 ));
657 append_agentskills_metadata(&mut output, &skill.meta);
658 }
659 output.push('\n');
660 output.push_str(&format!("Unavailable skills ({}):\n\n", unavailable.len()));
661 for skill in unavailable {
662 output.push_str(&format!(
663 "• {} — {}\n",
664 skill.meta.name,
665 skill
666 .reason
667 .as_deref()
668 .unwrap_or("unavailable for unknown reason")
669 ));
670 }
671 output
672 }
673
674 #[allow(dead_code)]
675 pub fn skills_dir(&self) -> &PathBuf {
676 &self.skills_dir
677 }
678
679 pub fn state_file_path(&self) -> Option<&Path> {
680 self.state_file.as_deref()
681 }
682
683 fn read_state_file(&self) -> HashMap<String, bool> {
684 let Some(path) = self.state_file.as_ref() else {
685 return HashMap::new();
686 };
687 if !path.exists() {
688 return HashMap::new();
689 }
690 match std::fs::read_to_string(path) {
691 Ok(raw) => serde_json::from_str::<HashMap<String, bool>>(&raw).unwrap_or_default(),
692 Err(_) => HashMap::new(),
693 }
694 }
695
696 fn write_state_file(&self, state: &HashMap<String, bool>) -> Result<(), String> {
697 let Some(path) = self.state_file.as_ref() else {
698 return Err("Skill state is not configured for this runtime.".to_string());
699 };
700 if let Some(parent) = path.parent() {
701 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
702 }
703 let body = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
704 std::fs::write(path, body).map_err(|e| e.to_string())
705 }
706}
707
708fn current_platform() -> &'static str {
709 if cfg!(target_os = "macos") {
710 "darwin"
711 } else if cfg!(target_os = "linux") {
712 "linux"
713 } else if cfg!(target_os = "windows") {
714 "windows"
715 } else {
716 "unknown"
717 }
718}
719
720fn normalize_platform(value: &str) -> String {
721 let v = value.trim().to_ascii_lowercase();
722 match v.as_str() {
723 "macos" | "osx" => "darwin".to_string(),
724 _ => v,
725 }
726}
727
728fn platform_allowed(platforms: &[String]) -> bool {
729 if platforms.is_empty() {
730 return true;
731 }
732
733 let current = current_platform();
734 platforms.iter().any(|p| {
735 let p = normalize_platform(p);
736 p == "all" || p == "*" || p == current
737 })
738}
739
740fn command_exists(command: &str) -> bool {
741 if command.trim().is_empty() {
742 return true;
743 }
744
745 let path_var = std::env::var_os("PATH").unwrap_or_default();
746 let paths = std::env::split_paths(&path_var);
747
748 #[cfg(target_os = "windows")]
749 let candidates: Vec<String> = {
750 let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
751 let ext_list: Vec<String> = exts
752 .split(';')
753 .map(|s| s.trim().to_ascii_lowercase())
754 .filter(|s| !s.is_empty())
755 .collect();
756 let lower = command.to_ascii_lowercase();
757 if ext_list.iter().any(|ext| lower.ends_with(ext)) {
758 vec![command.to_string()]
759 } else {
760 let mut c = vec![command.to_string()];
761 for ext in ext_list {
762 c.push(format!("{command}{ext}"));
763 }
764 c
765 }
766 };
767
768 #[cfg(not(target_os = "windows"))]
769 let candidates: Vec<String> = vec![command.to_string()];
770
771 for base in paths {
772 for candidate in &candidates {
773 let full = base.join(candidate);
774 if full.is_file() {
775 return true;
776 }
777 }
778 }
779
780 false
781}
782
783fn missing_deps(deps: &[String]) -> Vec<String> {
784 deps.iter()
785 .filter(|dep| !command_exists(dep))
786 .cloned()
787 .collect()
788}
789
790fn strip_frontmatter(content: &str) -> &str {
793 let trimmed = content.trim_start_matches('\u{feff}');
794 if !trimmed.starts_with("---\n") {
795 return trimmed;
796 }
797 match trimmed[4..].find("\n---\n") {
798 Some(end) => trimmed[4 + end + 5..].trim_start_matches('\n'),
799 None => trimmed,
800 }
801}
802
803fn truncate_chars(s: &str, max_chars: usize) -> String {
804 if s.chars().count() <= max_chars {
805 return s.to_string();
806 }
807 let truncated: String = s.chars().take(max_chars).collect();
808 format!("{truncated}...")
809}
810
811fn escape_xml_attribute(value: &str) -> String {
812 value
813 .replace('&', "&")
814 .replace('<', "<")
815 .replace('>', ">")
816 .replace('"', """)
817 .replace('\'', "'")
818}
819
820fn escape_xml_text(value: &str) -> String {
821 value
822 .replace('&', "&")
823 .replace('<', "<")
824 .replace('>', ">")
825}
826
827fn normalize_single_line_frontmatter(content: &str) -> Option<String> {
830 if !content.starts_with("--- ") {
831 return None;
832 }
833 let after_open = &content[4..]; let close_idx = after_open.find(" ---")?;
835 let yaml_part = after_open[..close_idx].trim();
836 if yaml_part.is_empty() {
837 return None;
838 }
839 let body = after_open[close_idx + 4..].trim_start();
840
841 let known_keys: &[&str] = &[
843 "name:",
844 "description:",
845 "license:",
846 "platforms:",
847 "deps:",
848 "compatibility:",
849 "source:",
850 "version:",
851 "updated_at:",
852 ];
853 let mut yaml = yaml_part.to_string();
854 for key in known_keys {
855 yaml = yaml.replacen(&format!(" {key}"), &format!("\n{key}"), 1);
856 }
857
858 Some(format!("---\n{yaml}\n---\n{body}"))
859}
860
861pub(crate) fn parse_skill_md(
864 content: &str,
865 dir_path: &std::path::Path,
866) -> Option<(SkillMetadata, String)> {
867 let trimmed = content.trim_start_matches('\u{feff}');
868
869 let normalized;
871 let input = if !trimmed.starts_with("---\n") && !trimmed.starts_with("---\r\n") {
872 normalized = normalize_single_line_frontmatter(trimmed)?;
873 &normalized
874 } else {
875 trimmed
876 };
877
878 let mut lines = input.lines();
879 let _ = lines.next()?; let mut yaml_block = String::new();
882 let mut consumed = 0usize;
883 for line in lines {
884 consumed += line.len() + 1;
885 if line.trim() == "---" || line.trim() == "..." {
886 break;
887 }
888 yaml_block.push_str(line);
889 yaml_block.push('\n');
890 }
891
892 if yaml_block.trim().is_empty() {
893 return None;
894 }
895
896 let fm: SkillFrontmatter = serde_yaml::from_str(&yaml_block).ok()?;
897 let name = fm.name?.trim().to_string();
898 if name.is_empty() {
899 return None;
900 }
901
902 let (compat_os, compat_deps, compatibility_str) = match fm.compatibility {
903 CompatibilityField::Structured(c) => (c.os, c.deps, None),
904 CompatibilityField::Free(s) => {
905 let trimmed = s.trim();
906 if trimmed.is_empty() {
907 (Vec::new(), Vec::new(), None)
908 } else {
909 (Vec::new(), Vec::new(), Some(trimmed.to_string()))
910 }
911 }
912 };
913
914 let mut platforms: Vec<String> = fm
915 .platforms
916 .into_iter()
917 .chain(compat_os)
918 .map(|p| normalize_platform(&p))
919 .filter(|p| !p.is_empty())
920 .collect();
921 platforms.sort();
922 platforms.dedup();
923
924 let mut deps: Vec<String> = fm
925 .deps
926 .into_iter()
927 .chain(compat_deps)
928 .map(|d| d.trim().to_string())
929 .filter(|d| !d.is_empty())
930 .collect();
931 deps.sort();
932 deps.dedup();
933
934 let header_len = if let Some(idx) = input.find("\n---\n") {
935 idx + 5
936 } else if let Some(idx) = input.find("\n...\n") {
937 idx + 5
938 } else {
939 4 + consumed
941 };
942
943 let body = input
944 .get(header_len..)
945 .unwrap_or_default()
946 .trim()
947 .to_string();
948
949 Some((
950 SkillMetadata {
951 name,
952 description: fm.description,
953 dir_path: dir_path.to_path_buf(),
954 platforms,
955 deps,
956 source: fm
957 .source
958 .map(|s| s.trim().to_string())
959 .filter(|s| !s.is_empty())
960 .unwrap_or_else(|| "local".to_string()),
961 version: fm
962 .version
963 .map(|s| s.trim().to_string())
964 .filter(|s| !s.is_empty()),
965 updated_at: fm
966 .updated_at
967 .map(|s| s.trim().to_string())
968 .filter(|s| !s.is_empty()),
969 env_file: fm
970 .env_file
971 .map(|s| s.trim().to_string())
972 .filter(|s| !s.is_empty()),
973 license: fm
974 .license
975 .map(|s| s.trim().to_string())
976 .filter(|s| !s.is_empty()),
977 compatibility: compatibility_str,
978 allowed_tools: fm
979 .allowed_tools
980 .map(|s| s.trim().to_string())
981 .filter(|s| !s.is_empty()),
982 },
983 body,
984 ))
985}
986
987fn warn_once_if_legacy_name(name: &str) {
992 static SEEN: OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = OnceLock::new();
993 if validate_agentskills_name(name).is_ok() {
994 return;
995 }
996 let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
997 let mut guard = match seen.lock() {
998 Ok(g) => g,
999 Err(_) => return,
1000 };
1001 if guard.insert(name.to_string()) {
1002 tracing::warn!(
1003 skill = %name,
1004 "skill name is not agentskills.io spec-compliant (lowercase a-z, 0-9, '-' only; no leading/trailing/consecutive '-'); rename before publishing"
1005 );
1006 }
1007}
1008
1009fn append_agentskills_metadata(output: &mut String, meta: &SkillMetadata) {
1014 if let Some(license) = meta.license.as_deref() {
1015 output.push_str(&format!(" license: {license}\n"));
1016 }
1017 if let Some(compat) = meta.compatibility.as_deref() {
1018 output.push_str(&format!(" compatibility: {compat}\n"));
1019 }
1020 if let Some(tools) = meta.allowed_tools.as_deref() {
1021 output.push_str(&format!(" allowed-tools: {tools}\n"));
1022 }
1023}
1024
1025pub fn validate_agentskills_name(name: &str) -> Result<(), String> {
1032 if name.is_empty() || name.len() > 64 {
1033 return Err(format!(
1034 "skill name must be 1-64 characters (got {})",
1035 name.len()
1036 ));
1037 }
1038 if name.starts_with('-') || name.ends_with('-') {
1039 return Err("skill name must not start or end with a hyphen".into());
1040 }
1041 if name.contains("--") {
1042 return Err("skill name must not contain consecutive hyphens".into());
1043 }
1044 for ch in name.chars() {
1045 if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') {
1046 return Err(format!(
1047 "skill name contains invalid character '{ch}'; only lowercase a-z, 0-9, and '-' are allowed"
1048 ));
1049 }
1050 }
1051 Ok(())
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 #[test]
1059 fn test_parse_skill_md_valid() {
1060 let content = r#"---
1061name: pdf
1062description: Convert documents to PDF
1063platforms: [linux, darwin]
1064deps: [pandoc]
1065---
1066Use this skill to convert documents.
1067"#;
1068 let dir = PathBuf::from("/tmp/skills/pdf");
1069 let result = parse_skill_md(content, &dir);
1070 assert!(result.is_some());
1071 let (meta, body) = result.unwrap();
1072 assert_eq!(meta.name, "pdf");
1073 assert_eq!(meta.description, "Convert documents to PDF");
1074 assert_eq!(meta.platforms, vec!["darwin", "linux"]);
1075 assert_eq!(meta.deps, vec!["pandoc"]);
1076 assert_eq!(meta.source, "local");
1077 assert!(body.contains("Use this skill"));
1078 }
1079
1080 #[test]
1081 fn test_parse_skill_md_agentskills_minimal_spec() {
1082 let content = r#"---
1085name: pdf-processing
1086description: Extract text and tables from PDF files. Use when working with PDFs.
1087---
1088Body.
1089"#;
1090 let dir = PathBuf::from("/tmp/skills/pdf-processing");
1091 let (meta, body) = parse_skill_md(content, &dir).expect("minimal spec form should parse");
1092 assert_eq!(meta.name, "pdf-processing");
1093 assert!(meta.description.starts_with("Extract text"));
1094 assert!(meta.license.is_none());
1095 assert!(meta.compatibility.is_none());
1096 assert!(meta.allowed_tools.is_none());
1097 assert_eq!(body, "Body.");
1098 }
1099
1100 #[test]
1101 fn test_parse_skill_md_agentskills_optional_fields() {
1102 let content = r#"---
1103name: pdf-processing
1104description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
1105license: Apache-2.0
1106compatibility: Requires git, docker, jq, and access to the internet
1107allowed-tools: Bash(git:*) Bash(jq:*) Read
1108metadata:
1109 author: example-org
1110 version: "1.0"
1111---
1112Body.
1113"#;
1114 let dir = PathBuf::from("/tmp/skills/pdf-processing");
1115 let (meta, _) = parse_skill_md(content, &dir).expect("spec optional fields should parse");
1116 assert_eq!(meta.license.as_deref(), Some("Apache-2.0"));
1117 assert_eq!(
1118 meta.compatibility.as_deref(),
1119 Some("Requires git, docker, jq, and access to the internet")
1120 );
1121 assert_eq!(
1122 meta.allowed_tools.as_deref(),
1123 Some("Bash(git:*) Bash(jq:*) Read")
1124 );
1125 }
1126
1127 #[test]
1128 fn test_validate_agentskills_name_accepts_valid_names() {
1129 assert!(validate_agentskills_name("pdf-processing").is_ok());
1130 assert!(validate_agentskills_name("data-analysis").is_ok());
1131 assert!(validate_agentskills_name("a").is_ok());
1132 assert!(validate_agentskills_name("a1").is_ok());
1133 }
1134
1135 #[test]
1136 fn test_validate_agentskills_name_rejects_invalid_names() {
1137 assert!(validate_agentskills_name("").is_err());
1138 assert!(validate_agentskills_name("PDF-Processing").is_err());
1139 assert!(validate_agentskills_name("-pdf").is_err());
1140 assert!(validate_agentskills_name("pdf-").is_err());
1141 assert!(validate_agentskills_name("pdf--processing").is_err());
1142 assert!(validate_agentskills_name("pdf_processing").is_err());
1143 assert!(validate_agentskills_name("pdf processing").is_err());
1144 assert!(validate_agentskills_name(&"a".repeat(65)).is_err());
1145 }
1146
1147 #[test]
1148 fn test_parse_skill_md_compatibility_os() {
1149 let content = r#"---
1150name: apple-notes
1151description: Apple Notes
1152compatibility:
1153 os:
1154 - darwin
1155 deps:
1156 - memo
1157---
1158Instructions.
1159"#;
1160 let dir = PathBuf::from("/tmp/skills/apple-notes");
1161 let (meta, _) = parse_skill_md(content, &dir).unwrap();
1162 assert_eq!(meta.platforms, vec!["darwin"]);
1163 assert_eq!(meta.deps, vec!["memo"]);
1164 }
1165
1166 #[test]
1167 fn test_parse_skill_md_no_frontmatter() {
1168 let content = "Just some markdown without frontmatter.";
1169 let dir = PathBuf::from("/tmp/skills/test");
1170 assert!(parse_skill_md(content, &dir).is_none());
1171 }
1172
1173 #[test]
1174 fn test_parse_skill_md_single_line_frontmatter() {
1175 let content = "--- name: frontend-design description: Create distinctive UIs license: Complete terms in LICENSE.txt --- This skill guides creation of distinctive interfaces.";
1176 let dir = PathBuf::from("/tmp/skills/frontend-design");
1177 let result = parse_skill_md(content, &dir);
1178 assert!(result.is_some(), "single-line frontmatter should parse");
1179 let (meta, body) = result.unwrap();
1180 assert_eq!(meta.name, "frontend-design");
1181 assert!(meta.description.starts_with("Create distinctive"));
1182 assert!(body.contains("This skill guides"));
1183 }
1184
1185 #[test]
1186 fn test_normalize_single_line_frontmatter() {
1187 let content = "--- name: test description: A test skill --- Body here";
1188 let result = normalize_single_line_frontmatter(content);
1189 assert!(result.is_some());
1190 let norm = result.unwrap();
1191 assert!(norm.starts_with("---\n"));
1192 assert!(norm.contains("\nname: test"));
1193 assert!(norm.contains("\ndescription: A test skill"));
1194 assert!(norm.contains("---\nBody here"));
1195 }
1196
1197 #[test]
1198 fn test_platform_allowed_empty_means_all() {
1199 assert!(platform_allowed(&[]));
1200 }
1201
1202 #[test]
1203 fn test_list_skills_formatted_surfaces_agentskills_metadata() {
1204 let dir =
1205 std::env::temp_dir().join(format!("microclaw_skills_meta_{}", uuid::Uuid::new_v4()));
1206 let pdf = dir.join("skills").join("pdf-processing");
1209 std::fs::create_dir_all(&pdf).unwrap();
1210 std::fs::write(
1211 pdf.join("SKILL.md"),
1212 "---\n\
1213 name: pdf-processing\n\
1214 description: Extract PDF text\n\
1215 license: Apache-2.0\n\
1216 compatibility: Requires git, docker\n\
1217 allowed-tools: Bash(git:*) Read\n\
1218 ---\nbody",
1219 )
1220 .unwrap();
1221
1222 let sm = SkillManager::new(dir.to_str().unwrap());
1223 let listed = sm.list_skills_formatted();
1224 assert!(listed.contains("license: Apache-2.0"), "listing: {listed}");
1225 assert!(listed.contains("compatibility: Requires git, docker"));
1226 assert!(listed.contains("allowed-tools: Bash(git:*) Read"));
1227
1228 let _ = std::fs::remove_dir_all(&dir);
1229 }
1230
1231 #[test]
1232 fn test_build_skills_catalog_empty() {
1233 let dir =
1234 std::env::temp_dir().join(format!("microclaw_skills_test_{}", uuid::Uuid::new_v4()));
1235 let sm = SkillManager::new(dir.to_str().unwrap());
1236 let catalog = sm.build_skills_catalog();
1237 assert!(catalog.is_empty());
1238 let _ = std::fs::remove_dir_all(&dir);
1239 }
1240
1241 #[test]
1242 fn selected_skill_catalog_includes_only_profile_assignments() {
1243 let dir = std::env::temp_dir().join(format!(
1244 "microclaw_selected_skills_{}",
1245 uuid::Uuid::new_v4()
1246 ));
1247 write_skill(
1248 &dir,
1249 "rust-review",
1250 "Review Rust",
1251 "Check ownership and errors.",
1252 );
1253 write_skill(&dir, "deploy", "Deploy services", "Push the image.");
1254 let manager = SkillManager::from_skills_dir(dir.to_str().unwrap());
1255
1256 let catalog = manager.build_skills_catalog_for_names_and_query(
1257 &["rust-review".into()],
1258 "deploy rust",
1259 3,
1260 );
1261
1262 assert!(catalog.contains("rust-review"), "catalog: {catalog}");
1263 assert!(
1264 catalog.contains("Check ownership and errors."),
1265 "catalog: {catalog}"
1266 );
1267 assert!(!catalog.contains("Push the image."), "catalog: {catalog}");
1268 let _ = std::fs::remove_dir_all(&dir);
1269 }
1270
1271 #[test]
1272 fn test_build_skills_catalog_sorted_and_truncated() {
1273 let dir = std::env::temp_dir().join(format!(
1274 "microclaw_skills_catalog_sorted_{}",
1275 uuid::Uuid::new_v4()
1276 ));
1277 let long_desc = "z".repeat(MAX_SKILL_DESCRIPTION_CHARS + 32);
1278 let zeta = dir.join("zeta");
1279 let alpha = dir.join("alpha");
1280 std::fs::create_dir_all(&zeta).unwrap();
1281 std::fs::create_dir_all(&alpha).unwrap();
1282 std::fs::write(
1283 zeta.join("SKILL.md"),
1284 format!("---\nname: zeta\ndescription: {long_desc}\n---\nok\n"),
1285 )
1286 .unwrap();
1287 std::fs::write(
1288 alpha.join("SKILL.md"),
1289 r#"---
1290name: alpha
1291description: alpha skill
1292---
1293ok
1294"#,
1295 )
1296 .unwrap();
1297
1298 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1299 let catalog = sm.build_skills_catalog();
1300 let alpha_pos = catalog.find("- alpha: alpha skill").unwrap();
1301 let zeta_pos = catalog.find("- zeta: ").unwrap();
1302 assert!(alpha_pos < zeta_pos);
1303 assert!(catalog.contains("..."));
1304 let _ = std::fs::remove_dir_all(&dir);
1305 }
1306
1307 fn write_skill(root: &std::path::Path, name: &str, description: &str, body: &str) {
1308 let dir = root.join(name);
1309 std::fs::create_dir_all(&dir).unwrap();
1310 let content = format!(
1311 "---\nname: {name}\ndescription: {description}\nsource: agent-created\n---\n{body}\n"
1312 );
1313 std::fs::write(dir.join("SKILL.md"), content).unwrap();
1314 }
1315
1316 fn write_lock_entry(lock_path: &std::path::Path, slug: &str, tree_hash: Option<String>) {
1317 let mut skills = std::collections::HashMap::new();
1318 skills.insert(
1319 slug.to_string(),
1320 crate::internal::clawhub::types::LockEntry {
1321 slug: slug.to_string(),
1322 installed_version: "1.0.0".into(),
1323 installed_at: "2026-08-07T00:00:00Z".into(),
1324 content_hash: "sha256:unused".into(),
1325 tree_hash,
1326 local_path: slug.to_string(),
1327 },
1328 );
1329 let lock = crate::internal::clawhub::types::LockFile { version: 1, skills };
1330 crate::internal::clawhub::lockfile::write_lockfile(lock_path, &lock).unwrap();
1331 }
1332
1333 #[test]
1334 fn clawhub_tampered_skill_blocks_at_load_after_reload() {
1335 let dir = std::env::temp_dir().join(format!(
1336 "microclaw_clawhub_verify_block_{}",
1337 uuid::Uuid::new_v4()
1338 ));
1339 std::fs::create_dir_all(&dir).unwrap();
1340 write_skill(&dir, "managed", "managed skill", "ok");
1341 write_skill(&dir, "local", "local skill", "ok");
1342 let tree =
1343 crate::internal::clawhub::install::compute_tree_hash(&dir.join("managed")).unwrap();
1344 let lock_path = dir.join("clawhub.lock.json");
1345 write_lock_entry(&lock_path, "managed", Some(tree));
1346
1347 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
1348 .with_clawhub_verification(lock_path.clone(), true);
1349 assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
1350
1351 std::fs::write(
1352 dir.join("managed").join("SKILL.md"),
1353 "---\nname: managed\ndescription: mutated\n---\npayload\n",
1354 )
1355 .unwrap();
1356 assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
1359
1360 sm.reload();
1361 let statuses = sm.discover_skills_with_status(true);
1362 let managed = statuses.iter().find(|s| s.meta.name == "managed").unwrap();
1363 assert!(!managed.available);
1364 assert!(managed
1365 .reason
1366 .as_deref()
1367 .unwrap_or_default()
1368 .contains("lockfile hash"));
1369 assert!(sm.load_skill_checked("managed").is_err());
1370 assert!(sm.discover_skills().iter().any(|s| s.name == "local"));
1372 let _ = std::fs::remove_dir_all(&dir);
1373 }
1374
1375 #[test]
1376 fn clawhub_warn_mode_keeps_tampered_skill_available() {
1377 let dir = std::env::temp_dir().join(format!(
1378 "microclaw_clawhub_verify_warn_{}",
1379 uuid::Uuid::new_v4()
1380 ));
1381 std::fs::create_dir_all(&dir).unwrap();
1382 write_skill(&dir, "managed", "managed skill", "ok");
1383 let tree =
1384 crate::internal::clawhub::install::compute_tree_hash(&dir.join("managed")).unwrap();
1385 let lock_path = dir.join("clawhub.lock.json");
1386 write_lock_entry(&lock_path, "managed", Some(tree));
1387
1388 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
1389 .with_clawhub_verification(lock_path, false);
1390 std::fs::write(
1391 dir.join("managed").join("SKILL.md"),
1392 "---\nname: managed\ndescription: mutated\n---\npayload\n",
1393 )
1394 .unwrap();
1395 assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
1396 let _ = std::fs::remove_dir_all(&dir);
1397 }
1398
1399 #[test]
1400 fn clawhub_unpinned_entry_stays_available() {
1401 let dir = std::env::temp_dir().join(format!(
1402 "microclaw_clawhub_verify_unpinned_{}",
1403 uuid::Uuid::new_v4()
1404 ));
1405 std::fs::create_dir_all(&dir).unwrap();
1406 write_skill(&dir, "managed", "managed skill", "ok");
1407 let lock_path = dir.join("clawhub.lock.json");
1408 write_lock_entry(&lock_path, "managed", None);
1409
1410 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
1411 .with_clawhub_verification(lock_path, true);
1412 assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
1413 let _ = std::fs::remove_dir_all(&dir);
1414 }
1415
1416 #[test]
1417 fn build_skills_catalog_for_query_falls_back_when_top_k_zero() {
1418 let dir = std::env::temp_dir().join(format!(
1419 "mc_skills_query_top_k_zero_{}",
1420 uuid::Uuid::new_v4()
1421 ));
1422 write_skill(&dir, "alpha", "alpha skill", "ok");
1423 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1424 let plain = sm.build_skills_catalog();
1425 let queried = sm.build_skills_catalog_for_query("alpha", 0);
1426 assert_eq!(plain, queried);
1427 let _ = std::fs::remove_dir_all(&dir);
1428 }
1429
1430 #[test]
1431 fn build_skills_catalog_for_query_inlines_relevant_skill_body() {
1432 let dir =
1433 std::env::temp_dir().join(format!("mc_skills_query_inline_{}", uuid::Uuid::new_v4()));
1434 write_skill(
1435 &dir,
1436 "deploy-helper",
1437 "kubernetes rolling deploy",
1438 "Step 1: kubectl apply\nStep 2: verify",
1439 );
1440 write_skill(&dir, "irrelevant", "make tea", "boil water");
1441 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1442 let out = sm.build_skills_catalog_for_query("how do i do a kubernetes deploy?", 3);
1443 assert!(out.contains("## deploy-helper"), "got: {out}");
1444 assert!(
1445 out.contains("Step 1: kubectl apply"),
1446 "body not inlined: {out}"
1447 );
1448 assert!(out.contains("- irrelevant: make tea"), "got: {out}");
1450 assert!(!out.contains("boil water"), "irrelevant body leaked: {out}");
1451 let _ = std::fs::remove_dir_all(&dir);
1452 }
1453
1454 #[test]
1455 fn build_skills_catalog_for_query_falls_back_when_no_match() {
1456 let dir =
1457 std::env::temp_dir().join(format!("mc_skills_query_nomatch_{}", uuid::Uuid::new_v4()));
1458 write_skill(&dir, "alpha", "alpha skill", "ok");
1459 write_skill(&dir, "beta", "beta skill", "ok");
1460 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1461 let out = sm.build_skills_catalog_for_query("xyz unrelated", 3);
1463 assert!(!out.contains("## alpha"));
1465 assert!(!out.contains("## beta"));
1466 assert!(out.contains("- alpha: alpha skill"));
1468 assert!(out.contains("- beta: beta skill"));
1469 let _ = std::fs::remove_dir_all(&dir);
1470 }
1471
1472 #[test]
1473 fn build_skills_catalog_for_query_caps_inlined_body() {
1474 let dir =
1475 std::env::temp_dir().join(format!("mc_skills_query_cap_{}", uuid::Uuid::new_v4()));
1476 let big_body = "x".repeat(MAX_INLINED_SKILL_BODY_CHARS + 500);
1477 write_skill(&dir, "matchme", "matchme description", &big_body);
1478 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1479 let out = sm.build_skills_catalog_for_query("matchme description", 3);
1480 assert!(out.contains("## matchme"));
1481 assert!(out.contains("..."), "expected truncation marker: {out}");
1483 let _ = std::fs::remove_dir_all(&dir);
1484 }
1485
1486 #[test]
1487 fn build_skills_catalog_for_query_respects_top_k_bucket() {
1488 let dir =
1489 std::env::temp_dir().join(format!("mc_skills_query_top_k_{}", uuid::Uuid::new_v4()));
1490 for n in 0..5 {
1493 let name = format!("deploy-{n}");
1494 write_skill(&dir, &name, "deploy stuff", &format!("body {n}"));
1495 }
1496 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1497 let out = sm.build_skills_catalog_for_query("deploy", 2);
1498 let inlined = out.matches("## deploy-").count();
1499 assert_eq!(inlined, 2, "expected 2 inlined hot matches; got: {out}");
1500 let cold = out.matches("- deploy-").count();
1502 assert_eq!(cold, 3, "expected 3 cold entries; got: {out}");
1503 let _ = std::fs::remove_dir_all(&dir);
1504 }
1505
1506 #[test]
1507 fn strip_frontmatter_removes_yaml_block() {
1508 let body = "---\nname: x\ndescription: y\n---\nthe body\n";
1509 assert_eq!(strip_frontmatter(body), "the body\n");
1510 }
1511
1512 #[test]
1513 fn strip_frontmatter_returns_input_when_no_frontmatter() {
1514 let body = "no frontmatter here\n";
1515 assert_eq!(strip_frontmatter(body), body);
1516 }
1517
1518 #[test]
1519 fn test_build_skills_catalog_applies_item_cap() {
1520 let dir = std::env::temp_dir().join(format!(
1521 "microclaw_skills_catalog_cap_{}",
1522 uuid::Uuid::new_v4()
1523 ));
1524 std::fs::create_dir_all(&dir).unwrap();
1525 for idx in 0..=MAX_SKILLS_CATALOG_ITEMS {
1526 let name = format!("skill-{idx:02}");
1527 let skill_dir = dir.join(&name);
1528 std::fs::create_dir_all(&skill_dir).unwrap();
1529 std::fs::write(
1530 skill_dir.join("SKILL.md"),
1531 format!("---\nname: {name}\ndescription: test skill {idx}\n---\nbody\n"),
1532 )
1533 .unwrap();
1534 }
1535 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1536 let catalog = sm.build_skills_catalog();
1537 assert!(catalog.contains("additional skills omitted for prompt budget"));
1538 let rendered_items = catalog
1539 .lines()
1540 .filter(|line| line.starts_with("- skill-"))
1541 .count();
1542 assert_eq!(rendered_items, MAX_SKILLS_CATALOG_ITEMS);
1543 let _ = std::fs::remove_dir_all(&dir);
1544 }
1545
1546 #[test]
1547 fn test_build_skills_catalog_enters_compact_mode_when_many_skills() {
1548 let dir = std::env::temp_dir().join(format!(
1549 "microclaw_skills_catalog_compact_mode_{}",
1550 uuid::Uuid::new_v4()
1551 ));
1552 std::fs::create_dir_all(&dir).unwrap();
1553 for idx in 0..=COMPACT_SKILLS_MODE_THRESHOLD {
1554 let name = format!("compact-skill-{idx:02}");
1555 let skill_dir = dir.join(&name);
1556 std::fs::create_dir_all(&skill_dir).unwrap();
1557 std::fs::write(
1558 skill_dir.join("SKILL.md"),
1559 format!(
1560 "---\nname: {name}\ndescription: this description should not appear in compact mode\n---\nbody\n"
1561 ),
1562 )
1563 .unwrap();
1564 }
1565
1566 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1567 let catalog = sm.build_skills_catalog();
1568 assert!(catalog.contains("compact mode: use activate_skill"));
1569 assert!(!catalog.contains(": this description should not appear"));
1570 let _ = std::fs::remove_dir_all(&dir);
1571 }
1572
1573 #[test]
1574 fn test_list_skills_formatted_all_includes_unavailable_reasons() {
1575 let dir = std::env::temp_dir().join(format!(
1576 "microclaw_skills_all_test_{}",
1577 uuid::Uuid::new_v4()
1578 ));
1579 let available = dir.join("available");
1580 std::fs::create_dir_all(&available).unwrap();
1581 std::fs::write(
1582 available.join("SKILL.md"),
1583 r#"---
1584name: available
1585description: Available skill
1586---
1587ok
1588"#,
1589 )
1590 .unwrap();
1591
1592 let unavailable = dir.join("unavailable");
1593 std::fs::create_dir_all(&unavailable).unwrap();
1594 std::fs::write(
1595 unavailable.join("SKILL.md"),
1596 r#"---
1597name: unavailable
1598description: Missing dependency
1599deps: [definitely_missing_dep_123456]
1600---
1601nope
1602"#,
1603 )
1604 .unwrap();
1605
1606 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1607 let text = sm.list_skills_formatted_all();
1608 assert!(text.contains("Available skills (1)"));
1609 assert!(text.contains("available"));
1610 assert!(text.contains("Unavailable skills (1)"));
1611 assert!(text.contains("definitely_missing_dep_123456"));
1612 let _ = std::fs::remove_dir_all(&dir);
1613 }
1614
1615 #[test]
1616 fn test_load_skill_checked_unavailable_has_diagnostic_hint() {
1617 let dir = std::env::temp_dir().join(format!(
1618 "microclaw_skills_unavailable_test_{}",
1619 uuid::Uuid::new_v4()
1620 ));
1621 let unavailable = dir.join("bad");
1622 std::fs::create_dir_all(&unavailable).unwrap();
1623 std::fs::write(
1624 unavailable.join("SKILL.md"),
1625 r#"---
1626name: bad
1627description: Missing dependency
1628deps: [definitely_missing_dep_654321]
1629---
1630nope
1631"#,
1632 )
1633 .unwrap();
1634 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1635 let err = sm.load_skill_checked("bad").unwrap_err();
1636 assert!(err.contains("currently unavailable"));
1637 assert!(err.contains("available --all"));
1638 let _ = std::fs::remove_dir_all(&dir);
1639 }
1640
1641 #[test]
1642 fn test_parse_skill_md_with_env_file() {
1643 let content = r#"---
1644name: outline
1645description: Manage Outline wiki
1646env_file: .env
1647---
1648Use this skill to interact with Outline.
1649"#;
1650 let dir = PathBuf::from("/tmp/skills/outline");
1651 let result = parse_skill_md(content, &dir);
1652 assert!(result.is_some());
1653 let (meta, _body) = result.unwrap();
1654 assert_eq!(meta.env_file.as_deref(), Some(".env"));
1655 }
1656
1657 #[test]
1658 fn test_disable_skill_is_runtime_scoped() {
1659 let base_dir = std::env::temp_dir().join(format!(
1660 "microclaw_skills_runtime_scoped_{}",
1661 uuid::Uuid::new_v4()
1662 ));
1663 let runtime_a = base_dir.join("runtime-a");
1664 let runtime_b = base_dir.join("runtime-b");
1665 let skills_dir = base_dir.join("skills");
1666 let skill_dir = skills_dir.join("pdf");
1667 std::fs::create_dir_all(&skill_dir).unwrap();
1668 std::fs::write(
1669 skill_dir.join("SKILL.md"),
1670 r#"---
1671name: pdf
1672description: Convert to PDF
1673---
1674Use this skill.
1675"#,
1676 )
1677 .unwrap();
1678
1679 let manager_a = SkillManager::from_skills_and_runtime(
1680 skills_dir.to_str().unwrap(),
1681 runtime_a.to_str().unwrap(),
1682 );
1683 let manager_b = SkillManager::from_skills_and_runtime(
1684 skills_dir.to_str().unwrap(),
1685 runtime_b.to_str().unwrap(),
1686 );
1687
1688 manager_a.set_enabled("pdf", false).unwrap();
1689
1690 let status_a = manager_a.discover_skills_with_status(true);
1691 let status_b = manager_b.discover_skills_with_status(true);
1692
1693 assert!(!status_a[0].available);
1694 assert!(status_a[0]
1695 .reason
1696 .as_deref()
1697 .unwrap_or_default()
1698 .contains("disabled"));
1699 assert!(status_b[0].available);
1700 assert!(skill_dir.join("SKILL.md").exists());
1701 let _ = std::fs::remove_dir_all(&base_dir);
1702 }
1703
1704 #[test]
1705 fn test_set_enabled_missing_skill_returns_error() {
1706 let base_dir = std::env::temp_dir().join(format!(
1707 "microclaw_skills_enable_missing_{}",
1708 uuid::Uuid::new_v4()
1709 ));
1710 let runtime = base_dir.join("runtime");
1711 let skills_dir = base_dir.join("skills");
1712 std::fs::create_dir_all(&skills_dir).unwrap();
1713 let manager = SkillManager::from_skills_and_runtime(
1714 skills_dir.to_str().unwrap(),
1715 runtime.to_str().unwrap(),
1716 );
1717 let err = manager.set_enabled("missing", false).unwrap_err();
1718 assert!(err.contains("Skill not found"));
1719 let _ = std::fs::remove_dir_all(&base_dir);
1720 }
1721}