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    /// Rank discovered skills against `query`, splitting them into a "hot"
490    /// bucket (score > 0, capped at `top_k`) and the remaining "cold" bucket.
491    ///
492    /// An empty `hot` vec means the query matched nothing relevant and callers
493    /// should fall back to the canonical catalog.
494    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        // Score by description-token overlap. Name is also factored in so
506        // a query like "tokyo-deploy" matches a skill literally named
507        // `tokyo-deploy`.
508        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        // Higher score first; tie-break by name for stability.
521        scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
522
523        // Hot bucket: top_k entries with score > 0.
524        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    /// Render the query-matched skills with their full instruction bodies
537    /// inlined, as a self-contained `<turn_skills>` block.
538    ///
539    /// This is the query-scoped half of the skills context: it changes with
540    /// every user question, so it belongs in the per-turn context block rather
541    /// than in the cached system-prompt prefix. Returns an empty string when
542    /// the query matched nothing.
543    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    /// Build a query-aware skills catalog: inline the full body of the
555    /// top-`top_k` skills whose descriptions overlap the query, and fall
556    /// back to a compact `name: description` listing for the rest.
557    ///
558    /// Tradeoff vs `build_skills_catalog`: spending a bigger token slice
559    /// on the most-relevant skills (so the agent has the procedural
560    /// knowledge inline and doesn't need an extra `activate_skill`
561    /// round-trip) while keeping the long tail cheap.
562    ///
563    /// `top_k = 0` falls back to [`build_skills_catalog`] verbatim. An
564    /// empty `query` also falls back — without a query the relevance
565    /// score is meaningless.
566    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            // No relevant matches — degenerate to the plain catalog.
573            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    /// Build a catalog restricted to an Agent Profile's explicit skill selection.
600    ///
601    /// Unlike the global query-aware catalog, explicitly assigned skills are all included with
602    /// their instructions (subject to the same per-skill body cap). Missing, disabled, or
603    /// incompatible skills are represented as unavailable instead of silently enabling a
604    /// different skill.
605    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    /// Build a user-facing formatted list of available skills.
639    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    /// Build a user-facing list, optionally including unavailable skills and reasons.
656    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
804/// Return the SKILL.md body with its YAML frontmatter (`---\n…\n---\n`)
805/// removed. If no frontmatter is present, the input is returned as-is.
806fn 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
817/// Render query-matched skills with their full instruction bodies inlined.
818fn 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('&', "&amp;")
853        .replace('<', "&lt;")
854        .replace('>', "&gt;")
855        .replace('"', "&quot;")
856        .replace('\'', "&apos;")
857}
858
859fn escape_xml_text(value: &str) -> String {
860    value
861        .replace('&', "&amp;")
862        .replace('<', "&lt;")
863        .replace('>', "&gt;")
864}
865
866/// Attempt to convert single-line frontmatter (`--- name: x description: y --- body`)
867/// into standard multi-line YAML format for parsing.
868fn normalize_single_line_frontmatter(content: &str) -> Option<String> {
869    if !content.starts_with("--- ") {
870        return None;
871    }
872    let after_open = &content[4..]; // skip "--- "
873    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    // Insert newlines before known frontmatter keys so serde_yaml can parse them
881    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
900/// Parse a SKILL.md file, extracting frontmatter via YAML and body.
901/// Returns None if the file lacks valid frontmatter with a name field.
902pub(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    // Try normalizing single-line frontmatter if standard format not found
909    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()?; // opening ---
919
920    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        // fallback to consumed length from line-by-line scan
979        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
1026/// Warn once per process per non-spec-compliant skill name. Legacy skills
1027/// with underscores or uppercase still load fine, but operators get a
1028/// portability nudge so they know newly published skills should rename
1029/// before pushing to agentskills.io-compatible registries.
1030fn 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
1048/// Append agentskills.io optional metadata to a multi-line skill listing
1049/// entry — license / spec compatibility string / allowed-tools — only when
1050/// they were actually populated, so the existing per-skill line stays
1051/// terse for skills that don't declare them.
1052fn 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
1064/// Validate a skill name against the agentskills.io spec rules:
1065/// 1-64 characters, lowercase a-z, digits, and hyphens only, must not
1066/// start/end with a hyphen and must not contain consecutive hyphens.
1067/// Returns Ok(()) on conformance, otherwise a human-readable diagnostic.
1068/// Used by skill-creation tooling so newly-authored skills are spec-clean
1069/// without rejecting any pre-existing locally installed skills.
1070pub 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        // The spec requires only `name` and `description`; everything else is
1122        // optional. A minimal spec-compliant SKILL.md must round-trip.
1123        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        // SkillManager joins `skills/` onto the data_dir; write the skill
1246        // there so it gets discovered.
1247        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        // The verdict is memoized until an explicit reload, so discovery
1396        // stays cheap between reloads.
1397        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        // Unmanaged skills are untouched.
1410        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        // The cold listing belongs to the catalog half, not the turn block.
1474        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        // top_k = 0 disables inlining entirely.
1505        assert!(sm
1506            .build_skills_hot_bodies_for_query("alpha skill", 0)
1507            .is_empty());
1508        // Without a query there is nothing to rank against.
1509        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        // The canonical catalog is what lands in the cached prompt prefix, so
1517        // repeated calls and filesystem enumeration order must not matter.
1518        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        // Irrelevant skill ends up in the cold list with just name+desc.
1568        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        // Query has no overlap with any skill description.
1581        let out = sm.build_skills_catalog_for_query("xyz unrelated", 3);
1582        // No "## name" inlined-body sections.
1583        assert!(!out.contains("## alpha"));
1584        assert!(!out.contains("## beta"));
1585        // Plain catalog still lists both.
1586        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        // Body got truncated with the "..." sentinel from truncate_chars.
1601        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        // Five skills all matching "deploy"; top_k=2 should inline 2 hot
1610        // bodies, others go to the cold list.
1611        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        // Remaining 3 should be in the cold list with name+description.
1620        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}