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 fn rank_skills_for_query(
495 &self,
496 query: &str,
497 top_k: usize,
498 ) -> (Vec<SkillMetadata>, Vec<SkillMetadata>) {
499 let mut skills = self.discover_skills();
500 if skills.is_empty() {
501 return (Vec::new(), Vec::new());
502 }
503
504 let query_tokens = crate::memory_service::tokenize_for_relevance(query);
505 let mut scored: Vec<(usize, SkillMetadata)> = skills
509 .drain(..)
510 .map(|s| {
511 let blob = format!("{} {}", s.name, s.description);
512 let blob_tokens = crate::memory_service::tokenize_for_relevance(&blob);
513 let score = blob_tokens
514 .iter()
515 .filter(|t| query_tokens.contains(*t))
516 .count();
517 (score, s)
518 })
519 .collect();
520 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
522
523 let mut hot: Vec<SkillMetadata> = Vec::new();
525 let mut cold: Vec<SkillMetadata> = Vec::new();
526 for (score, meta) in scored {
527 if score > 0 && hot.len() < top_k {
528 hot.push(meta);
529 } else {
530 cold.push(meta);
531 }
532 }
533 (hot, cold)
534 }
535
536 pub fn build_skills_hot_bodies_for_query(&self, query: &str, top_k: usize) -> String {
544 if top_k == 0 || query.trim().is_empty() {
545 return String::new();
546 }
547 let (hot, _cold) = self.rank_skills_for_query(query, top_k);
548 if hot.is_empty() {
549 return String::new();
550 }
551 render_hot_skill_bodies(&hot)
552 }
553
554 pub fn build_skills_catalog_for_query(&self, query: &str, top_k: usize) -> String {
567 if top_k == 0 || query.trim().is_empty() {
568 return self.build_skills_catalog();
569 }
570 let (hot, mut cold) = self.rank_skills_for_query(query, top_k);
571 if hot.is_empty() {
572 return self.build_skills_catalog();
574 }
575
576 let mut out = String::from("<available_skills>\n");
577 out.push_str(render_hot_skill_bodies(&hot).as_str());
578 if !cold.is_empty() {
579 out.push_str(
580 "<!-- Other available skills (call activate_skill to load full body): -->\n",
581 );
582 cold.sort_by_key(|s| s.name.to_ascii_lowercase());
583 let total_cold = cold.len();
584 let omitted = total_cold.saturating_sub(MAX_SKILLS_CATALOG_ITEMS);
585 for skill in cold.into_iter().take(MAX_SKILLS_CATALOG_ITEMS) {
586 let desc = truncate_chars(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
587 out.push_str(&format!("- {}: {}\n", skill.name, desc));
588 }
589 if omitted > 0 {
590 out.push_str(&format!(
591 "- ... ({omitted} additional skills omitted for prompt budget)\n"
592 ));
593 }
594 }
595 out.push_str("</available_skills>");
596 out
597 }
598
599 pub fn build_skills_catalog_for_names_and_query(
606 &self,
607 names: &[String],
608 _query: &str,
609 _top_k: usize,
610 ) -> String {
611 let mut seen = std::collections::HashSet::new();
612 let mut catalog = String::from("<available_skills>\n");
613 for name in names {
614 let name = name.trim();
615 if name.is_empty() || !seen.insert(name.to_ascii_lowercase()) {
616 continue;
617 }
618 match self.load_skill_checked(name) {
619 Ok((metadata, body)) => {
620 catalog.push_str(&format!(
621 "<skill name=\"{}\" description=\"{}\">\n{}\n</skill>\n",
622 escape_xml_attribute(&metadata.name),
623 escape_xml_attribute(&metadata.description),
624 truncate_chars(&body, MAX_INLINED_SKILL_BODY_CHARS),
625 ));
626 }
627 Err(reason) => catalog.push_str(&format!(
628 "<skill-unavailable name=\"{}\">{}</skill-unavailable>\n",
629 escape_xml_attribute(name),
630 escape_xml_text(&reason),
631 )),
632 }
633 }
634 catalog.push_str("</available_skills>");
635 catalog
636 }
637
638 pub fn list_skills_formatted(&self) -> String {
640 let skills = self.discover_skills();
641 if skills.is_empty() {
642 return "No skills available on this platform/runtime.".into();
643 }
644 let mut output = format!("Available skills ({}):\n\n", skills.len());
645 for skill in &skills {
646 output.push_str(&format!(
647 "• {} — {} [{}]\n",
648 skill.name, skill.description, skill.source
649 ));
650 append_agentskills_metadata(&mut output, skill);
651 }
652 output
653 }
654
655 pub fn list_skills_formatted_all(&self) -> String {
657 let statuses = self.discover_skills_with_status(true);
658 if statuses.is_empty() {
659 return "No skills found in skills directory.".into();
660 }
661 let available: Vec<&SkillAvailability> = statuses.iter().filter(|s| s.available).collect();
662 let unavailable: Vec<&SkillAvailability> =
663 statuses.iter().filter(|s| !s.available).collect();
664 let mut output = String::new();
665 output.push_str(&format!("Available skills ({}):\n\n", available.len()));
666 for skill in available {
667 output.push_str(&format!(
668 "• {} — {} [{}]\n",
669 skill.meta.name, skill.meta.description, skill.meta.source
670 ));
671 append_agentskills_metadata(&mut output, &skill.meta);
672 }
673 output.push('\n');
674 output.push_str(&format!("Unavailable skills ({}):\n\n", unavailable.len()));
675 for skill in unavailable {
676 output.push_str(&format!(
677 "• {} — {}\n",
678 skill.meta.name,
679 skill
680 .reason
681 .as_deref()
682 .unwrap_or("unavailable for unknown reason")
683 ));
684 }
685 output
686 }
687
688 #[allow(dead_code)]
689 pub fn skills_dir(&self) -> &PathBuf {
690 &self.skills_dir
691 }
692
693 pub fn state_file_path(&self) -> Option<&Path> {
694 self.state_file.as_deref()
695 }
696
697 fn read_state_file(&self) -> HashMap<String, bool> {
698 let Some(path) = self.state_file.as_ref() else {
699 return HashMap::new();
700 };
701 if !path.exists() {
702 return HashMap::new();
703 }
704 match std::fs::read_to_string(path) {
705 Ok(raw) => serde_json::from_str::<HashMap<String, bool>>(&raw).unwrap_or_default(),
706 Err(_) => HashMap::new(),
707 }
708 }
709
710 fn write_state_file(&self, state: &HashMap<String, bool>) -> Result<(), String> {
711 let Some(path) = self.state_file.as_ref() else {
712 return Err("Skill state is not configured for this runtime.".to_string());
713 };
714 if let Some(parent) = path.parent() {
715 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
716 }
717 let body = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
718 std::fs::write(path, body).map_err(|e| e.to_string())
719 }
720}
721
722fn current_platform() -> &'static str {
723 if cfg!(target_os = "macos") {
724 "darwin"
725 } else if cfg!(target_os = "linux") {
726 "linux"
727 } else if cfg!(target_os = "windows") {
728 "windows"
729 } else {
730 "unknown"
731 }
732}
733
734fn normalize_platform(value: &str) -> String {
735 let v = value.trim().to_ascii_lowercase();
736 match v.as_str() {
737 "macos" | "osx" => "darwin".to_string(),
738 _ => v,
739 }
740}
741
742fn platform_allowed(platforms: &[String]) -> bool {
743 if platforms.is_empty() {
744 return true;
745 }
746
747 let current = current_platform();
748 platforms.iter().any(|p| {
749 let p = normalize_platform(p);
750 p == "all" || p == "*" || p == current
751 })
752}
753
754fn command_exists(command: &str) -> bool {
755 if command.trim().is_empty() {
756 return true;
757 }
758
759 let path_var = std::env::var_os("PATH").unwrap_or_default();
760 let paths = std::env::split_paths(&path_var);
761
762 #[cfg(target_os = "windows")]
763 let candidates: Vec<String> = {
764 let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
765 let ext_list: Vec<String> = exts
766 .split(';')
767 .map(|s| s.trim().to_ascii_lowercase())
768 .filter(|s| !s.is_empty())
769 .collect();
770 let lower = command.to_ascii_lowercase();
771 if ext_list.iter().any(|ext| lower.ends_with(ext)) {
772 vec![command.to_string()]
773 } else {
774 let mut c = vec![command.to_string()];
775 for ext in ext_list {
776 c.push(format!("{command}{ext}"));
777 }
778 c
779 }
780 };
781
782 #[cfg(not(target_os = "windows"))]
783 let candidates: Vec<String> = vec![command.to_string()];
784
785 for base in paths {
786 for candidate in &candidates {
787 let full = base.join(candidate);
788 if full.is_file() {
789 return true;
790 }
791 }
792 }
793
794 false
795}
796
797fn missing_deps(deps: &[String]) -> Vec<String> {
798 deps.iter()
799 .filter(|dep| !command_exists(dep))
800 .cloned()
801 .collect()
802}
803
804fn strip_frontmatter(content: &str) -> &str {
807 let trimmed = content.trim_start_matches('\u{feff}');
808 if !trimmed.starts_with("---\n") {
809 return trimmed;
810 }
811 match trimmed[4..].find("\n---\n") {
812 Some(end) => trimmed[4 + end + 5..].trim_start_matches('\n'),
813 None => trimmed,
814 }
815}
816
817fn render_hot_skill_bodies(hot: &[SkillMetadata]) -> String {
819 let mut out =
820 String::from("<!-- Hot matches: full body inlined for the most relevant skills. -->\n");
821 for meta in hot {
822 out.push_str(&format!("## {}\n", meta.name));
823 out.push_str(&format!("Description: {}\n", meta.description));
824 if let Some(version) = &meta.version {
825 out.push_str(&format!("Version: {}\n", version));
826 }
827 let skill_md = meta.dir_path.join("SKILL.md");
828 if let Ok(content) = std::fs::read_to_string(&skill_md) {
829 let body = strip_frontmatter(&content);
830 let body = truncate_chars(body, MAX_INLINED_SKILL_BODY_CHARS);
831 out.push_str("Instructions:\n");
832 out.push_str(&body);
833 if !body.ends_with('\n') {
834 out.push('\n');
835 }
836 }
837 out.push('\n');
838 }
839 out
840}
841
842fn truncate_chars(s: &str, max_chars: usize) -> String {
843 if s.chars().count() <= max_chars {
844 return s.to_string();
845 }
846 let truncated: String = s.chars().take(max_chars).collect();
847 format!("{truncated}...")
848}
849
850fn escape_xml_attribute(value: &str) -> String {
851 value
852 .replace('&', "&")
853 .replace('<', "<")
854 .replace('>', ">")
855 .replace('"', """)
856 .replace('\'', "'")
857}
858
859fn escape_xml_text(value: &str) -> String {
860 value
861 .replace('&', "&")
862 .replace('<', "<")
863 .replace('>', ">")
864}
865
866fn normalize_single_line_frontmatter(content: &str) -> Option<String> {
869 if !content.starts_with("--- ") {
870 return None;
871 }
872 let after_open = &content[4..]; let close_idx = after_open.find(" ---")?;
874 let yaml_part = after_open[..close_idx].trim();
875 if yaml_part.is_empty() {
876 return None;
877 }
878 let body = after_open[close_idx + 4..].trim_start();
879
880 let known_keys: &[&str] = &[
882 "name:",
883 "description:",
884 "license:",
885 "platforms:",
886 "deps:",
887 "compatibility:",
888 "source:",
889 "version:",
890 "updated_at:",
891 ];
892 let mut yaml = yaml_part.to_string();
893 for key in known_keys {
894 yaml = yaml.replacen(&format!(" {key}"), &format!("\n{key}"), 1);
895 }
896
897 Some(format!("---\n{yaml}\n---\n{body}"))
898}
899
900pub(crate) fn parse_skill_md(
903 content: &str,
904 dir_path: &std::path::Path,
905) -> Option<(SkillMetadata, String)> {
906 let trimmed = content.trim_start_matches('\u{feff}');
907
908 let normalized;
910 let input = if !trimmed.starts_with("---\n") && !trimmed.starts_with("---\r\n") {
911 normalized = normalize_single_line_frontmatter(trimmed)?;
912 &normalized
913 } else {
914 trimmed
915 };
916
917 let mut lines = input.lines();
918 let _ = lines.next()?; let mut yaml_block = String::new();
921 let mut consumed = 0usize;
922 for line in lines {
923 consumed += line.len() + 1;
924 if line.trim() == "---" || line.trim() == "..." {
925 break;
926 }
927 yaml_block.push_str(line);
928 yaml_block.push('\n');
929 }
930
931 if yaml_block.trim().is_empty() {
932 return None;
933 }
934
935 let fm: SkillFrontmatter = serde_yaml::from_str(&yaml_block).ok()?;
936 let name = fm.name?.trim().to_string();
937 if name.is_empty() {
938 return None;
939 }
940
941 let (compat_os, compat_deps, compatibility_str) = match fm.compatibility {
942 CompatibilityField::Structured(c) => (c.os, c.deps, None),
943 CompatibilityField::Free(s) => {
944 let trimmed = s.trim();
945 if trimmed.is_empty() {
946 (Vec::new(), Vec::new(), None)
947 } else {
948 (Vec::new(), Vec::new(), Some(trimmed.to_string()))
949 }
950 }
951 };
952
953 let mut platforms: Vec<String> = fm
954 .platforms
955 .into_iter()
956 .chain(compat_os)
957 .map(|p| normalize_platform(&p))
958 .filter(|p| !p.is_empty())
959 .collect();
960 platforms.sort();
961 platforms.dedup();
962
963 let mut deps: Vec<String> = fm
964 .deps
965 .into_iter()
966 .chain(compat_deps)
967 .map(|d| d.trim().to_string())
968 .filter(|d| !d.is_empty())
969 .collect();
970 deps.sort();
971 deps.dedup();
972
973 let header_len = if let Some(idx) = input.find("\n---\n") {
974 idx + 5
975 } else if let Some(idx) = input.find("\n...\n") {
976 idx + 5
977 } else {
978 4 + consumed
980 };
981
982 let body = input
983 .get(header_len..)
984 .unwrap_or_default()
985 .trim()
986 .to_string();
987
988 Some((
989 SkillMetadata {
990 name,
991 description: fm.description,
992 dir_path: dir_path.to_path_buf(),
993 platforms,
994 deps,
995 source: fm
996 .source
997 .map(|s| s.trim().to_string())
998 .filter(|s| !s.is_empty())
999 .unwrap_or_else(|| "local".to_string()),
1000 version: fm
1001 .version
1002 .map(|s| s.trim().to_string())
1003 .filter(|s| !s.is_empty()),
1004 updated_at: fm
1005 .updated_at
1006 .map(|s| s.trim().to_string())
1007 .filter(|s| !s.is_empty()),
1008 env_file: fm
1009 .env_file
1010 .map(|s| s.trim().to_string())
1011 .filter(|s| !s.is_empty()),
1012 license: fm
1013 .license
1014 .map(|s| s.trim().to_string())
1015 .filter(|s| !s.is_empty()),
1016 compatibility: compatibility_str,
1017 allowed_tools: fm
1018 .allowed_tools
1019 .map(|s| s.trim().to_string())
1020 .filter(|s| !s.is_empty()),
1021 },
1022 body,
1023 ))
1024}
1025
1026fn warn_once_if_legacy_name(name: &str) {
1031 static SEEN: OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = OnceLock::new();
1032 if validate_agentskills_name(name).is_ok() {
1033 return;
1034 }
1035 let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
1036 let mut guard = match seen.lock() {
1037 Ok(g) => g,
1038 Err(_) => return,
1039 };
1040 if guard.insert(name.to_string()) {
1041 tracing::warn!(
1042 skill = %name,
1043 "skill name is not agentskills.io spec-compliant (lowercase a-z, 0-9, '-' only; no leading/trailing/consecutive '-'); rename before publishing"
1044 );
1045 }
1046}
1047
1048fn append_agentskills_metadata(output: &mut String, meta: &SkillMetadata) {
1053 if let Some(license) = meta.license.as_deref() {
1054 output.push_str(&format!(" license: {license}\n"));
1055 }
1056 if let Some(compat) = meta.compatibility.as_deref() {
1057 output.push_str(&format!(" compatibility: {compat}\n"));
1058 }
1059 if let Some(tools) = meta.allowed_tools.as_deref() {
1060 output.push_str(&format!(" allowed-tools: {tools}\n"));
1061 }
1062}
1063
1064pub fn validate_agentskills_name(name: &str) -> Result<(), String> {
1071 if name.is_empty() || name.len() > 64 {
1072 return Err(format!(
1073 "skill name must be 1-64 characters (got {})",
1074 name.len()
1075 ));
1076 }
1077 if name.starts_with('-') || name.ends_with('-') {
1078 return Err("skill name must not start or end with a hyphen".into());
1079 }
1080 if name.contains("--") {
1081 return Err("skill name must not contain consecutive hyphens".into());
1082 }
1083 for ch in name.chars() {
1084 if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') {
1085 return Err(format!(
1086 "skill name contains invalid character '{ch}'; only lowercase a-z, 0-9, and '-' are allowed"
1087 ));
1088 }
1089 }
1090 Ok(())
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 #[test]
1098 fn test_parse_skill_md_valid() {
1099 let content = r#"---
1100name: pdf
1101description: Convert documents to PDF
1102platforms: [linux, darwin]
1103deps: [pandoc]
1104---
1105Use this skill to convert documents.
1106"#;
1107 let dir = PathBuf::from("/tmp/skills/pdf");
1108 let result = parse_skill_md(content, &dir);
1109 assert!(result.is_some());
1110 let (meta, body) = result.unwrap();
1111 assert_eq!(meta.name, "pdf");
1112 assert_eq!(meta.description, "Convert documents to PDF");
1113 assert_eq!(meta.platforms, vec!["darwin", "linux"]);
1114 assert_eq!(meta.deps, vec!["pandoc"]);
1115 assert_eq!(meta.source, "local");
1116 assert!(body.contains("Use this skill"));
1117 }
1118
1119 #[test]
1120 fn test_parse_skill_md_agentskills_minimal_spec() {
1121 let content = r#"---
1124name: pdf-processing
1125description: Extract text and tables from PDF files. Use when working with PDFs.
1126---
1127Body.
1128"#;
1129 let dir = PathBuf::from("/tmp/skills/pdf-processing");
1130 let (meta, body) = parse_skill_md(content, &dir).expect("minimal spec form should parse");
1131 assert_eq!(meta.name, "pdf-processing");
1132 assert!(meta.description.starts_with("Extract text"));
1133 assert!(meta.license.is_none());
1134 assert!(meta.compatibility.is_none());
1135 assert!(meta.allowed_tools.is_none());
1136 assert_eq!(body, "Body.");
1137 }
1138
1139 #[test]
1140 fn test_parse_skill_md_agentskills_optional_fields() {
1141 let content = r#"---
1142name: pdf-processing
1143description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
1144license: Apache-2.0
1145compatibility: Requires git, docker, jq, and access to the internet
1146allowed-tools: Bash(git:*) Bash(jq:*) Read
1147metadata:
1148 author: example-org
1149 version: "1.0"
1150---
1151Body.
1152"#;
1153 let dir = PathBuf::from("/tmp/skills/pdf-processing");
1154 let (meta, _) = parse_skill_md(content, &dir).expect("spec optional fields should parse");
1155 assert_eq!(meta.license.as_deref(), Some("Apache-2.0"));
1156 assert_eq!(
1157 meta.compatibility.as_deref(),
1158 Some("Requires git, docker, jq, and access to the internet")
1159 );
1160 assert_eq!(
1161 meta.allowed_tools.as_deref(),
1162 Some("Bash(git:*) Bash(jq:*) Read")
1163 );
1164 }
1165
1166 #[test]
1167 fn test_validate_agentskills_name_accepts_valid_names() {
1168 assert!(validate_agentskills_name("pdf-processing").is_ok());
1169 assert!(validate_agentskills_name("data-analysis").is_ok());
1170 assert!(validate_agentskills_name("a").is_ok());
1171 assert!(validate_agentskills_name("a1").is_ok());
1172 }
1173
1174 #[test]
1175 fn test_validate_agentskills_name_rejects_invalid_names() {
1176 assert!(validate_agentskills_name("").is_err());
1177 assert!(validate_agentskills_name("PDF-Processing").is_err());
1178 assert!(validate_agentskills_name("-pdf").is_err());
1179 assert!(validate_agentskills_name("pdf-").is_err());
1180 assert!(validate_agentskills_name("pdf--processing").is_err());
1181 assert!(validate_agentskills_name("pdf_processing").is_err());
1182 assert!(validate_agentskills_name("pdf processing").is_err());
1183 assert!(validate_agentskills_name(&"a".repeat(65)).is_err());
1184 }
1185
1186 #[test]
1187 fn test_parse_skill_md_compatibility_os() {
1188 let content = r#"---
1189name: apple-notes
1190description: Apple Notes
1191compatibility:
1192 os:
1193 - darwin
1194 deps:
1195 - memo
1196---
1197Instructions.
1198"#;
1199 let dir = PathBuf::from("/tmp/skills/apple-notes");
1200 let (meta, _) = parse_skill_md(content, &dir).unwrap();
1201 assert_eq!(meta.platforms, vec!["darwin"]);
1202 assert_eq!(meta.deps, vec!["memo"]);
1203 }
1204
1205 #[test]
1206 fn test_parse_skill_md_no_frontmatter() {
1207 let content = "Just some markdown without frontmatter.";
1208 let dir = PathBuf::from("/tmp/skills/test");
1209 assert!(parse_skill_md(content, &dir).is_none());
1210 }
1211
1212 #[test]
1213 fn test_parse_skill_md_single_line_frontmatter() {
1214 let content = "--- name: frontend-design description: Create distinctive UIs license: Complete terms in LICENSE.txt --- This skill guides creation of distinctive interfaces.";
1215 let dir = PathBuf::from("/tmp/skills/frontend-design");
1216 let result = parse_skill_md(content, &dir);
1217 assert!(result.is_some(), "single-line frontmatter should parse");
1218 let (meta, body) = result.unwrap();
1219 assert_eq!(meta.name, "frontend-design");
1220 assert!(meta.description.starts_with("Create distinctive"));
1221 assert!(body.contains("This skill guides"));
1222 }
1223
1224 #[test]
1225 fn test_normalize_single_line_frontmatter() {
1226 let content = "--- name: test description: A test skill --- Body here";
1227 let result = normalize_single_line_frontmatter(content);
1228 assert!(result.is_some());
1229 let norm = result.unwrap();
1230 assert!(norm.starts_with("---\n"));
1231 assert!(norm.contains("\nname: test"));
1232 assert!(norm.contains("\ndescription: A test skill"));
1233 assert!(norm.contains("---\nBody here"));
1234 }
1235
1236 #[test]
1237 fn test_platform_allowed_empty_means_all() {
1238 assert!(platform_allowed(&[]));
1239 }
1240
1241 #[test]
1242 fn test_list_skills_formatted_surfaces_agentskills_metadata() {
1243 let dir =
1244 std::env::temp_dir().join(format!("microclaw_skills_meta_{}", uuid::Uuid::new_v4()));
1245 let pdf = dir.join("skills").join("pdf-processing");
1248 std::fs::create_dir_all(&pdf).unwrap();
1249 std::fs::write(
1250 pdf.join("SKILL.md"),
1251 "---\n\
1252 name: pdf-processing\n\
1253 description: Extract PDF text\n\
1254 license: Apache-2.0\n\
1255 compatibility: Requires git, docker\n\
1256 allowed-tools: Bash(git:*) Read\n\
1257 ---\nbody",
1258 )
1259 .unwrap();
1260
1261 let sm = SkillManager::new(dir.to_str().unwrap());
1262 let listed = sm.list_skills_formatted();
1263 assert!(listed.contains("license: Apache-2.0"), "listing: {listed}");
1264 assert!(listed.contains("compatibility: Requires git, docker"));
1265 assert!(listed.contains("allowed-tools: Bash(git:*) Read"));
1266
1267 let _ = std::fs::remove_dir_all(&dir);
1268 }
1269
1270 #[test]
1271 fn test_build_skills_catalog_empty() {
1272 let dir =
1273 std::env::temp_dir().join(format!("microclaw_skills_test_{}", uuid::Uuid::new_v4()));
1274 let sm = SkillManager::new(dir.to_str().unwrap());
1275 let catalog = sm.build_skills_catalog();
1276 assert!(catalog.is_empty());
1277 let _ = std::fs::remove_dir_all(&dir);
1278 }
1279
1280 #[test]
1281 fn selected_skill_catalog_includes_only_profile_assignments() {
1282 let dir = std::env::temp_dir().join(format!(
1283 "microclaw_selected_skills_{}",
1284 uuid::Uuid::new_v4()
1285 ));
1286 write_skill(
1287 &dir,
1288 "rust-review",
1289 "Review Rust",
1290 "Check ownership and errors.",
1291 );
1292 write_skill(&dir, "deploy", "Deploy services", "Push the image.");
1293 let manager = SkillManager::from_skills_dir(dir.to_str().unwrap());
1294
1295 let catalog = manager.build_skills_catalog_for_names_and_query(
1296 &["rust-review".into()],
1297 "deploy rust",
1298 3,
1299 );
1300
1301 assert!(catalog.contains("rust-review"), "catalog: {catalog}");
1302 assert!(
1303 catalog.contains("Check ownership and errors."),
1304 "catalog: {catalog}"
1305 );
1306 assert!(!catalog.contains("Push the image."), "catalog: {catalog}");
1307 let _ = std::fs::remove_dir_all(&dir);
1308 }
1309
1310 #[test]
1311 fn test_build_skills_catalog_sorted_and_truncated() {
1312 let dir = std::env::temp_dir().join(format!(
1313 "microclaw_skills_catalog_sorted_{}",
1314 uuid::Uuid::new_v4()
1315 ));
1316 let long_desc = "z".repeat(MAX_SKILL_DESCRIPTION_CHARS + 32);
1317 let zeta = dir.join("zeta");
1318 let alpha = dir.join("alpha");
1319 std::fs::create_dir_all(&zeta).unwrap();
1320 std::fs::create_dir_all(&alpha).unwrap();
1321 std::fs::write(
1322 zeta.join("SKILL.md"),
1323 format!("---\nname: zeta\ndescription: {long_desc}\n---\nok\n"),
1324 )
1325 .unwrap();
1326 std::fs::write(
1327 alpha.join("SKILL.md"),
1328 r#"---
1329name: alpha
1330description: alpha skill
1331---
1332ok
1333"#,
1334 )
1335 .unwrap();
1336
1337 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1338 let catalog = sm.build_skills_catalog();
1339 let alpha_pos = catalog.find("- alpha: alpha skill").unwrap();
1340 let zeta_pos = catalog.find("- zeta: ").unwrap();
1341 assert!(alpha_pos < zeta_pos);
1342 assert!(catalog.contains("..."));
1343 let _ = std::fs::remove_dir_all(&dir);
1344 }
1345
1346 fn write_skill(root: &std::path::Path, name: &str, description: &str, body: &str) {
1347 let dir = root.join(name);
1348 std::fs::create_dir_all(&dir).unwrap();
1349 let content = format!(
1350 "---\nname: {name}\ndescription: {description}\nsource: agent-created\n---\n{body}\n"
1351 );
1352 std::fs::write(dir.join("SKILL.md"), content).unwrap();
1353 }
1354
1355 fn write_lock_entry(lock_path: &std::path::Path, slug: &str, tree_hash: Option<String>) {
1356 let mut skills = std::collections::HashMap::new();
1357 skills.insert(
1358 slug.to_string(),
1359 crate::internal::clawhub::types::LockEntry {
1360 slug: slug.to_string(),
1361 installed_version: "1.0.0".into(),
1362 installed_at: "2026-08-07T00:00:00Z".into(),
1363 content_hash: "sha256:unused".into(),
1364 tree_hash,
1365 local_path: slug.to_string(),
1366 },
1367 );
1368 let lock = crate::internal::clawhub::types::LockFile { version: 1, skills };
1369 crate::internal::clawhub::lockfile::write_lockfile(lock_path, &lock).unwrap();
1370 }
1371
1372 #[test]
1373 fn clawhub_tampered_skill_blocks_at_load_after_reload() {
1374 let dir = std::env::temp_dir().join(format!(
1375 "microclaw_clawhub_verify_block_{}",
1376 uuid::Uuid::new_v4()
1377 ));
1378 std::fs::create_dir_all(&dir).unwrap();
1379 write_skill(&dir, "managed", "managed skill", "ok");
1380 write_skill(&dir, "local", "local skill", "ok");
1381 let tree =
1382 crate::internal::clawhub::install::compute_tree_hash(&dir.join("managed")).unwrap();
1383 let lock_path = dir.join("clawhub.lock.json");
1384 write_lock_entry(&lock_path, "managed", Some(tree));
1385
1386 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
1387 .with_clawhub_verification(lock_path.clone(), true);
1388 assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
1389
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"));
1398
1399 sm.reload();
1400 let statuses = sm.discover_skills_with_status(true);
1401 let managed = statuses.iter().find(|s| s.meta.name == "managed").unwrap();
1402 assert!(!managed.available);
1403 assert!(managed
1404 .reason
1405 .as_deref()
1406 .unwrap_or_default()
1407 .contains("lockfile hash"));
1408 assert!(sm.load_skill_checked("managed").is_err());
1409 assert!(sm.discover_skills().iter().any(|s| s.name == "local"));
1411 let _ = std::fs::remove_dir_all(&dir);
1412 }
1413
1414 #[test]
1415 fn clawhub_warn_mode_keeps_tampered_skill_available() {
1416 let dir = std::env::temp_dir().join(format!(
1417 "microclaw_clawhub_verify_warn_{}",
1418 uuid::Uuid::new_v4()
1419 ));
1420 std::fs::create_dir_all(&dir).unwrap();
1421 write_skill(&dir, "managed", "managed skill", "ok");
1422 let tree =
1423 crate::internal::clawhub::install::compute_tree_hash(&dir.join("managed")).unwrap();
1424 let lock_path = dir.join("clawhub.lock.json");
1425 write_lock_entry(&lock_path, "managed", Some(tree));
1426
1427 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
1428 .with_clawhub_verification(lock_path, false);
1429 std::fs::write(
1430 dir.join("managed").join("SKILL.md"),
1431 "---\nname: managed\ndescription: mutated\n---\npayload\n",
1432 )
1433 .unwrap();
1434 assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
1435 let _ = std::fs::remove_dir_all(&dir);
1436 }
1437
1438 #[test]
1439 fn clawhub_unpinned_entry_stays_available() {
1440 let dir = std::env::temp_dir().join(format!(
1441 "microclaw_clawhub_verify_unpinned_{}",
1442 uuid::Uuid::new_v4()
1443 ));
1444 std::fs::create_dir_all(&dir).unwrap();
1445 write_skill(&dir, "managed", "managed skill", "ok");
1446 let lock_path = dir.join("clawhub.lock.json");
1447 write_lock_entry(&lock_path, "managed", None);
1448
1449 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
1450 .with_clawhub_verification(lock_path, true);
1451 assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
1452 let _ = std::fs::remove_dir_all(&dir);
1453 }
1454
1455 #[test]
1456 fn build_skills_hot_bodies_inlines_only_matched_skills() {
1457 let dir = std::env::temp_dir().join(format!("mc_skills_hot_{}", uuid::Uuid::new_v4()));
1458 write_skill(
1459 &dir,
1460 "deploy-helper",
1461 "kubernetes deploy helper",
1462 "Step 1: kubectl apply",
1463 );
1464 write_skill(&dir, "irrelevant", "make tea", "boil water");
1465 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1466
1467 let out = sm.build_skills_hot_bodies_for_query("how do i do a kubernetes deploy?", 3);
1468 assert!(out.contains("## deploy-helper"), "got: {out}");
1469 assert!(
1470 out.contains("Step 1: kubectl apply"),
1471 "body not inlined: {out}"
1472 );
1473 assert!(
1475 !out.contains("Other available skills"),
1476 "cold list leaked: {out}"
1477 );
1478 assert!(
1479 !out.contains("- irrelevant: make tea"),
1480 "cold list leaked: {out}"
1481 );
1482 assert!(!out.contains("boil water"), "unmatched body leaked: {out}");
1483 let _ = std::fs::remove_dir_all(&dir);
1484 }
1485
1486 #[test]
1487 fn build_skills_hot_bodies_empty_when_nothing_matches() {
1488 let dir =
1489 std::env::temp_dir().join(format!("mc_skills_hot_nomatch_{}", uuid::Uuid::new_v4()));
1490 write_skill(&dir, "alpha", "alpha skill", "ok");
1491 write_skill(&dir, "beta", "beta skill", "ok");
1492 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1493 assert!(sm
1494 .build_skills_hot_bodies_for_query("xyz unrelated", 3)
1495 .is_empty());
1496 let _ = std::fs::remove_dir_all(&dir);
1497 }
1498
1499 #[test]
1500 fn build_skills_hot_bodies_empty_for_zero_top_k_and_blank_query() {
1501 let dir = std::env::temp_dir().join(format!("mc_skills_hot_edge_{}", uuid::Uuid::new_v4()));
1502 write_skill(&dir, "alpha", "alpha skill", "ok");
1503 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1504 assert!(sm
1506 .build_skills_hot_bodies_for_query("alpha skill", 0)
1507 .is_empty());
1508 assert!(sm.build_skills_hot_bodies_for_query("", 3).is_empty());
1510 assert!(sm.build_skills_hot_bodies_for_query(" ", 3).is_empty());
1511 let _ = std::fs::remove_dir_all(&dir);
1512 }
1513
1514 #[test]
1515 fn build_skills_catalog_is_stable_and_name_ordered() {
1516 let dir = std::env::temp_dir().join(format!("mc_skills_stable_{}", uuid::Uuid::new_v4()));
1519 write_skill(&dir, "zebra", "zebra skill", "z");
1520 write_skill(&dir, "alpha", "alpha skill", "a");
1521 write_skill(&dir, "mango", "mango skill", "m");
1522 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1523
1524 let first = sm.build_skills_catalog();
1525 let second = sm.build_skills_catalog();
1526 assert_eq!(first, second, "canonical catalog must be deterministic");
1527
1528 let alpha = first.find("- alpha:").expect("alpha listed");
1529 let mango = first.find("- mango:").expect("mango listed");
1530 let zebra = first.find("- zebra:").expect("zebra listed");
1531 assert!(alpha < mango && mango < zebra, "not name-ordered: {first}");
1532 let _ = std::fs::remove_dir_all(&dir);
1533 }
1534
1535 #[test]
1536 fn build_skills_catalog_for_query_falls_back_when_top_k_zero() {
1537 let dir = std::env::temp_dir().join(format!(
1538 "mc_skills_query_top_k_zero_{}",
1539 uuid::Uuid::new_v4()
1540 ));
1541 write_skill(&dir, "alpha", "alpha skill", "ok");
1542 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1543 let plain = sm.build_skills_catalog();
1544 let queried = sm.build_skills_catalog_for_query("alpha", 0);
1545 assert_eq!(plain, queried);
1546 let _ = std::fs::remove_dir_all(&dir);
1547 }
1548
1549 #[test]
1550 fn build_skills_catalog_for_query_inlines_relevant_skill_body() {
1551 let dir =
1552 std::env::temp_dir().join(format!("mc_skills_query_inline_{}", uuid::Uuid::new_v4()));
1553 write_skill(
1554 &dir,
1555 "deploy-helper",
1556 "kubernetes rolling deploy",
1557 "Step 1: kubectl apply\nStep 2: verify",
1558 );
1559 write_skill(&dir, "irrelevant", "make tea", "boil water");
1560 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1561 let out = sm.build_skills_catalog_for_query("how do i do a kubernetes deploy?", 3);
1562 assert!(out.contains("## deploy-helper"), "got: {out}");
1563 assert!(
1564 out.contains("Step 1: kubectl apply"),
1565 "body not inlined: {out}"
1566 );
1567 assert!(out.contains("- irrelevant: make tea"), "got: {out}");
1569 assert!(!out.contains("boil water"), "irrelevant body leaked: {out}");
1570 let _ = std::fs::remove_dir_all(&dir);
1571 }
1572
1573 #[test]
1574 fn build_skills_catalog_for_query_falls_back_when_no_match() {
1575 let dir =
1576 std::env::temp_dir().join(format!("mc_skills_query_nomatch_{}", uuid::Uuid::new_v4()));
1577 write_skill(&dir, "alpha", "alpha skill", "ok");
1578 write_skill(&dir, "beta", "beta skill", "ok");
1579 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1580 let out = sm.build_skills_catalog_for_query("xyz unrelated", 3);
1582 assert!(!out.contains("## alpha"));
1584 assert!(!out.contains("## beta"));
1585 assert!(out.contains("- alpha: alpha skill"));
1587 assert!(out.contains("- beta: beta skill"));
1588 let _ = std::fs::remove_dir_all(&dir);
1589 }
1590
1591 #[test]
1592 fn build_skills_catalog_for_query_caps_inlined_body() {
1593 let dir =
1594 std::env::temp_dir().join(format!("mc_skills_query_cap_{}", uuid::Uuid::new_v4()));
1595 let big_body = "x".repeat(MAX_INLINED_SKILL_BODY_CHARS + 500);
1596 write_skill(&dir, "matchme", "matchme description", &big_body);
1597 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1598 let out = sm.build_skills_catalog_for_query("matchme description", 3);
1599 assert!(out.contains("## matchme"));
1600 assert!(out.contains("..."), "expected truncation marker: {out}");
1602 let _ = std::fs::remove_dir_all(&dir);
1603 }
1604
1605 #[test]
1606 fn build_skills_catalog_for_query_respects_top_k_bucket() {
1607 let dir =
1608 std::env::temp_dir().join(format!("mc_skills_query_top_k_{}", uuid::Uuid::new_v4()));
1609 for n in 0..5 {
1612 let name = format!("deploy-{n}");
1613 write_skill(&dir, &name, "deploy stuff", &format!("body {n}"));
1614 }
1615 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1616 let out = sm.build_skills_catalog_for_query("deploy", 2);
1617 let inlined = out.matches("## deploy-").count();
1618 assert_eq!(inlined, 2, "expected 2 inlined hot matches; got: {out}");
1619 let cold = out.matches("- deploy-").count();
1621 assert_eq!(cold, 3, "expected 3 cold entries; got: {out}");
1622 let _ = std::fs::remove_dir_all(&dir);
1623 }
1624
1625 #[test]
1626 fn strip_frontmatter_removes_yaml_block() {
1627 let body = "---\nname: x\ndescription: y\n---\nthe body\n";
1628 assert_eq!(strip_frontmatter(body), "the body\n");
1629 }
1630
1631 #[test]
1632 fn strip_frontmatter_returns_input_when_no_frontmatter() {
1633 let body = "no frontmatter here\n";
1634 assert_eq!(strip_frontmatter(body), body);
1635 }
1636
1637 #[test]
1638 fn test_build_skills_catalog_applies_item_cap() {
1639 let dir = std::env::temp_dir().join(format!(
1640 "microclaw_skills_catalog_cap_{}",
1641 uuid::Uuid::new_v4()
1642 ));
1643 std::fs::create_dir_all(&dir).unwrap();
1644 for idx in 0..=MAX_SKILLS_CATALOG_ITEMS {
1645 let name = format!("skill-{idx:02}");
1646 let skill_dir = dir.join(&name);
1647 std::fs::create_dir_all(&skill_dir).unwrap();
1648 std::fs::write(
1649 skill_dir.join("SKILL.md"),
1650 format!("---\nname: {name}\ndescription: test skill {idx}\n---\nbody\n"),
1651 )
1652 .unwrap();
1653 }
1654 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1655 let catalog = sm.build_skills_catalog();
1656 assert!(catalog.contains("additional skills omitted for prompt budget"));
1657 let rendered_items = catalog
1658 .lines()
1659 .filter(|line| line.starts_with("- skill-"))
1660 .count();
1661 assert_eq!(rendered_items, MAX_SKILLS_CATALOG_ITEMS);
1662 let _ = std::fs::remove_dir_all(&dir);
1663 }
1664
1665 #[test]
1666 fn test_build_skills_catalog_enters_compact_mode_when_many_skills() {
1667 let dir = std::env::temp_dir().join(format!(
1668 "microclaw_skills_catalog_compact_mode_{}",
1669 uuid::Uuid::new_v4()
1670 ));
1671 std::fs::create_dir_all(&dir).unwrap();
1672 for idx in 0..=COMPACT_SKILLS_MODE_THRESHOLD {
1673 let name = format!("compact-skill-{idx:02}");
1674 let skill_dir = dir.join(&name);
1675 std::fs::create_dir_all(&skill_dir).unwrap();
1676 std::fs::write(
1677 skill_dir.join("SKILL.md"),
1678 format!(
1679 "---\nname: {name}\ndescription: this description should not appear in compact mode\n---\nbody\n"
1680 ),
1681 )
1682 .unwrap();
1683 }
1684
1685 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1686 let catalog = sm.build_skills_catalog();
1687 assert!(catalog.contains("compact mode: use activate_skill"));
1688 assert!(!catalog.contains(": this description should not appear"));
1689 let _ = std::fs::remove_dir_all(&dir);
1690 }
1691
1692 #[test]
1693 fn test_list_skills_formatted_all_includes_unavailable_reasons() {
1694 let dir = std::env::temp_dir().join(format!(
1695 "microclaw_skills_all_test_{}",
1696 uuid::Uuid::new_v4()
1697 ));
1698 let available = dir.join("available");
1699 std::fs::create_dir_all(&available).unwrap();
1700 std::fs::write(
1701 available.join("SKILL.md"),
1702 r#"---
1703name: available
1704description: Available skill
1705---
1706ok
1707"#,
1708 )
1709 .unwrap();
1710
1711 let unavailable = dir.join("unavailable");
1712 std::fs::create_dir_all(&unavailable).unwrap();
1713 std::fs::write(
1714 unavailable.join("SKILL.md"),
1715 r#"---
1716name: unavailable
1717description: Missing dependency
1718deps: [definitely_missing_dep_123456]
1719---
1720nope
1721"#,
1722 )
1723 .unwrap();
1724
1725 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1726 let text = sm.list_skills_formatted_all();
1727 assert!(text.contains("Available skills (1)"));
1728 assert!(text.contains("available"));
1729 assert!(text.contains("Unavailable skills (1)"));
1730 assert!(text.contains("definitely_missing_dep_123456"));
1731 let _ = std::fs::remove_dir_all(&dir);
1732 }
1733
1734 #[test]
1735 fn test_load_skill_checked_unavailable_has_diagnostic_hint() {
1736 let dir = std::env::temp_dir().join(format!(
1737 "microclaw_skills_unavailable_test_{}",
1738 uuid::Uuid::new_v4()
1739 ));
1740 let unavailable = dir.join("bad");
1741 std::fs::create_dir_all(&unavailable).unwrap();
1742 std::fs::write(
1743 unavailable.join("SKILL.md"),
1744 r#"---
1745name: bad
1746description: Missing dependency
1747deps: [definitely_missing_dep_654321]
1748---
1749nope
1750"#,
1751 )
1752 .unwrap();
1753 let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
1754 let err = sm.load_skill_checked("bad").unwrap_err();
1755 assert!(err.contains("currently unavailable"));
1756 assert!(err.contains("available --all"));
1757 let _ = std::fs::remove_dir_all(&dir);
1758 }
1759
1760 #[test]
1761 fn test_parse_skill_md_with_env_file() {
1762 let content = r#"---
1763name: outline
1764description: Manage Outline wiki
1765env_file: .env
1766---
1767Use this skill to interact with Outline.
1768"#;
1769 let dir = PathBuf::from("/tmp/skills/outline");
1770 let result = parse_skill_md(content, &dir);
1771 assert!(result.is_some());
1772 let (meta, _body) = result.unwrap();
1773 assert_eq!(meta.env_file.as_deref(), Some(".env"));
1774 }
1775
1776 #[test]
1777 fn test_disable_skill_is_runtime_scoped() {
1778 let base_dir = std::env::temp_dir().join(format!(
1779 "microclaw_skills_runtime_scoped_{}",
1780 uuid::Uuid::new_v4()
1781 ));
1782 let runtime_a = base_dir.join("runtime-a");
1783 let runtime_b = base_dir.join("runtime-b");
1784 let skills_dir = base_dir.join("skills");
1785 let skill_dir = skills_dir.join("pdf");
1786 std::fs::create_dir_all(&skill_dir).unwrap();
1787 std::fs::write(
1788 skill_dir.join("SKILL.md"),
1789 r#"---
1790name: pdf
1791description: Convert to PDF
1792---
1793Use this skill.
1794"#,
1795 )
1796 .unwrap();
1797
1798 let manager_a = SkillManager::from_skills_and_runtime(
1799 skills_dir.to_str().unwrap(),
1800 runtime_a.to_str().unwrap(),
1801 );
1802 let manager_b = SkillManager::from_skills_and_runtime(
1803 skills_dir.to_str().unwrap(),
1804 runtime_b.to_str().unwrap(),
1805 );
1806
1807 manager_a.set_enabled("pdf", false).unwrap();
1808
1809 let status_a = manager_a.discover_skills_with_status(true);
1810 let status_b = manager_b.discover_skills_with_status(true);
1811
1812 assert!(!status_a[0].available);
1813 assert!(status_a[0]
1814 .reason
1815 .as_deref()
1816 .unwrap_or_default()
1817 .contains("disabled"));
1818 assert!(status_b[0].available);
1819 assert!(skill_dir.join("SKILL.md").exists());
1820 let _ = std::fs::remove_dir_all(&base_dir);
1821 }
1822
1823 #[test]
1824 fn test_set_enabled_missing_skill_returns_error() {
1825 let base_dir = std::env::temp_dir().join(format!(
1826 "microclaw_skills_enable_missing_{}",
1827 uuid::Uuid::new_v4()
1828 ));
1829 let runtime = base_dir.join("runtime");
1830 let skills_dir = base_dir.join("skills");
1831 std::fs::create_dir_all(&skills_dir).unwrap();
1832 let manager = SkillManager::from_skills_and_runtime(
1833 skills_dir.to_str().unwrap(),
1834 runtime.to_str().unwrap(),
1835 );
1836 let err = manager.set_enabled("missing", false).unwrap_err();
1837 assert!(err.contains("Skill not found"));
1838 let _ = std::fs::remove_dir_all(&base_dir);
1839 }
1840}