Skip to main content

microclaw_engine/
skills.rs

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    /// agentskills.io spec field. License of the skill (free-form, e.g.
18    /// "Apache-2.0" or a reference to a bundled LICENSE file).
19    pub license: Option<String>,
20    /// agentskills.io spec field. Free-form compatibility string when the
21    /// skill author used the spec's flat form (e.g.
22    /// "Requires git, docker, jq, and access to the internet"). When the
23    /// MicroClaw nested form was used, this is None and the structured
24    /// requirements live in `platforms` and `deps`.
25    pub compatibility: Option<String>,
26    /// agentskills.io spec field (kebab-case in YAML: `allowed-tools`).
27    /// Space-separated string of pre-approved tools the skill may use.
28    /// Experimental in the spec; surfaced verbatim for clients that wish
29    /// to honor it.
30    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    /// agentskills.io spec field.
63    #[serde(default)]
64    license: Option<String>,
65    /// agentskills.io spec field, kebab-case in YAML.
66    #[serde(default, rename = "allowed-tools")]
67    allowed_tools: Option<String>,
68}
69
70/// Accept either MicroClaw's structured `compatibility: { os: [...], deps:
71/// [...] }` form or the agentskills.io spec's flat string form
72/// (`compatibility: "Requires git, docker..."`). Defaults to structured-empty
73/// so existing skills keep parsing.
74#[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>, // alias
94}
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 lockfile whose tree hashes are verified whenever skills
128    /// load; `None` disables load-time verification (tests, standalone).
129    clawhub_lock: Option<PathBuf>,
130    /// When true a hash mismatch makes the skill unavailable; when false
131    /// it only logs.
132    clawhub_block: bool,
133    /// Memoized dir-slug -> unavailable-reason verdicts, so the skill
134    /// trees are hashed once per process instead of on every catalog
135    /// build. Invalidated by `reload()`.
136    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
144/// Per-skill body cap when a hot match's full SKILL.md is inlined into
145/// the system prompt. Keeps the prompt-cost predictable as the skill
146/// library grows; the agent can still call `activate_skill` to read the
147/// full body if it needs more.
148const 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    /// Enable load-time verification of ClawHub-managed skill trees
178    /// against `lockfile`. With `block` true (config `clawhub_verify_on_load:
179    /// block`, the default) a mismatching skill becomes unavailable; with
180    /// `block` false it only logs.
181    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    /// Apply the configured ClawHub load-time verification policy.
188    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    /// Discover all skills that are available on the current platform and satisfy dependency checks.
196    pub fn discover_skills(&self) -> Vec<SkillMetadata> {
197        self.discover_skills_internal(false)
198    }
199
200    /// Discover skills with availability diagnostics.
201    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    /// Reload skills from disk (live reload)
210    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    /// Verify ClawHub-managed skill trees against the lockfile, memoized
219    /// until `reload()`. Returns dir-slug -> reason for skills that must
220    /// be treated as unavailable. Warn-only outcomes (unpinned entries,
221    /// warn mode, lockfile entries whose directory is gone) are logged at
222    /// compute time and produce no map entry.
223    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                // An unreadable lockfile means integrity can no longer be
239                // proven for any managed skill; log loudly but do not
240                // brick unmanaged/local skills.
241                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                    // The lockfile is keyed by install slug == directory
330                    // name, which may differ from the frontmatter name.
331                    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    /// Load a skill by name if it is available on the current platform.
383    pub fn load_skill(&self, name: &str) -> Option<(SkillMetadata, String)> {
384        self.load_skill_checked(name).ok()
385    }
386
387    /// Load a skill with availability diagnostics.
388    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    /// Build a compact skills catalog for the system prompt.
449    /// Returns empty string if no skills are available.
450    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        // Keep prompt injection stable across runs and bounded for token budget.
457        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    /// Build a query-aware skills catalog: inline the full body of the
490    /// top-`top_k` skills whose descriptions overlap the query, and fall
491    /// back to a compact `name: description` listing for the rest.
492    ///
493    /// Tradeoff vs `build_skills_catalog`: spending a bigger token slice
494    /// on the most-relevant skills (so the agent has the procedural
495    /// knowledge inline and doesn't need an extra `activate_skill`
496    /// round-trip) while keeping the long tail cheap.
497    ///
498    /// `top_k = 0` falls back to [`build_skills_catalog`] verbatim. An
499    /// empty `query` also falls back — without a query the relevance
500    /// score is meaningless.
501    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        // Score by description-token overlap. Name is also factored in so
512        // a query like "tokyo-deploy" matches a skill literally named
513        // `tokyo-deploy`.
514        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        // Higher score first; tie-break by name for stability.
527        scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
528
529        // Hot bucket: top_k entries with score > 0.
530        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            // No relevant matches — degenerate to the plain catalog.
541            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    /// Build a catalog restricted to an Agent Profile's explicit skill selection.
586    ///
587    /// Unlike the global query-aware catalog, explicitly assigned skills are all included with
588    /// their instructions (subject to the same per-skill body cap). Missing, disabled, or
589    /// incompatible skills are represented as unavailable instead of silently enabling a
590    /// different skill.
591    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    /// Build a user-facing formatted list of available skills.
625    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    /// Build a user-facing list, optionally including unavailable skills and reasons.
642    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
790/// Return the SKILL.md body with its YAML frontmatter (`---\n…\n---\n`)
791/// removed. If no frontmatter is present, the input is returned as-is.
792fn 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('&', "&amp;")
814        .replace('<', "&lt;")
815        .replace('>', "&gt;")
816        .replace('"', "&quot;")
817        .replace('\'', "&apos;")
818}
819
820fn escape_xml_text(value: &str) -> String {
821    value
822        .replace('&', "&amp;")
823        .replace('<', "&lt;")
824        .replace('>', "&gt;")
825}
826
827/// Attempt to convert single-line frontmatter (`--- name: x description: y --- body`)
828/// into standard multi-line YAML format for parsing.
829fn normalize_single_line_frontmatter(content: &str) -> Option<String> {
830    if !content.starts_with("--- ") {
831        return None;
832    }
833    let after_open = &content[4..]; // skip "--- "
834    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    // Insert newlines before known frontmatter keys so serde_yaml can parse them
842    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
861/// Parse a SKILL.md file, extracting frontmatter via YAML and body.
862/// Returns None if the file lacks valid frontmatter with a name field.
863pub(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    // Try normalizing single-line frontmatter if standard format not found
870    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()?; // opening ---
880
881    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        // fallback to consumed length from line-by-line scan
940        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
987/// Warn once per process per non-spec-compliant skill name. Legacy skills
988/// with underscores or uppercase still load fine, but operators get a
989/// portability nudge so they know newly published skills should rename
990/// before pushing to agentskills.io-compatible registries.
991fn 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
1009/// Append agentskills.io optional metadata to a multi-line skill listing
1010/// entry — license / spec compatibility string / allowed-tools — only when
1011/// they were actually populated, so the existing per-skill line stays
1012/// terse for skills that don't declare them.
1013fn 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
1025/// Validate a skill name against the agentskills.io spec rules:
1026/// 1-64 characters, lowercase a-z, digits, and hyphens only, must not
1027/// start/end with a hyphen and must not contain consecutive hyphens.
1028/// Returns Ok(()) on conformance, otherwise a human-readable diagnostic.
1029/// Used by skill-creation tooling so newly-authored skills are spec-clean
1030/// without rejecting any pre-existing locally installed skills.
1031pub 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        // The spec requires only `name` and `description`; everything else is
1083        // optional. A minimal spec-compliant SKILL.md must round-trip.
1084        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        // SkillManager joins `skills/` onto the data_dir; write the skill
1207        // there so it gets discovered.
1208        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        // The verdict is memoized until an explicit reload, so discovery
1357        // stays cheap between reloads.
1358        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        // Unmanaged skills are untouched.
1371        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        // Irrelevant skill ends up in the cold list with just name+desc.
1449        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        // Query has no overlap with any skill description.
1462        let out = sm.build_skills_catalog_for_query("xyz unrelated", 3);
1463        // No "## name" inlined-body sections.
1464        assert!(!out.contains("## alpha"));
1465        assert!(!out.contains("## beta"));
1466        // Plain catalog still lists both.
1467        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        // Body got truncated with the "..." sentinel from truncate_chars.
1482        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        // Five skills all matching "deploy"; top_k=2 should inline 2 hot
1491        // bodies, others go to the cold list.
1492        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        // Remaining 3 should be in the cold list with name+description.
1501        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}