use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone)]
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub dir_path: PathBuf,
pub platforms: Vec<String>,
pub deps: Vec<String>,
pub source: String,
pub version: Option<String>,
pub updated_at: Option<String>,
pub env_file: Option<String>,
pub license: Option<String>,
pub compatibility: Option<String>,
pub allowed_tools: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SkillAvailability {
pub meta: SkillMetadata,
pub available: bool,
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
#[allow(dead_code)]
struct SkillFrontmatter {
name: Option<String>,
#[serde(default)]
description: String,
#[serde(default)]
platforms: Vec<String>,
#[serde(default)]
deps: Vec<String>,
#[serde(default)]
compatibility: CompatibilityField,
#[serde(default)]
source: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
updated_at: Option<String>,
#[serde(default)]
env_file: Option<String>,
#[serde(default)]
metadata: SkillFrontmatterMetadata,
#[serde(default)]
license: Option<String>,
#[serde(default, rename = "allowed-tools")]
allowed_tools: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CompatibilityField {
Structured(SkillCompatibility),
Free(String),
}
impl Default for CompatibilityField {
fn default() -> Self {
CompatibilityField::Structured(SkillCompatibility::default())
}
}
#[derive(Debug, Deserialize, Default)]
#[allow(dead_code)]
struct SkillFrontmatterMetadata {
#[serde(default)]
pub openclaw: Option<OpenClaw>,
#[serde(default)]
pub clawdbot: Option<OpenClaw>, }
#[derive(Debug, Deserialize, Default)]
#[allow(dead_code)]
struct OpenClaw {
#[serde(default)]
pub requires: Option<Requires>,
#[serde(default)]
pub os: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
#[allow(dead_code)]
struct Requires {
#[serde(default)]
pub bins: Vec<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default, rename = "anyBins")]
pub any_bins: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct SkillCompatibility {
#[serde(default)]
os: Vec<String>,
#[serde(default)]
deps: Vec<String>,
}
pub struct SkillManager {
skills_dir: PathBuf,
state_file: Option<PathBuf>,
clawhub_lock: Option<PathBuf>,
clawhub_block: bool,
clawhub_verdicts: Mutex<Option<HashMap<String, String>>>,
}
const MAX_SKILLS_CATALOG_ITEMS: usize = 40;
const MAX_SKILL_DESCRIPTION_CHARS: usize = 120;
const COMPACT_SKILLS_MODE_THRESHOLD: usize = 20;
const SKILLS_STATE_FILENAME: &str = "skills_state.json";
const MAX_INLINED_SKILL_BODY_CHARS: usize = 1500;
impl SkillManager {
fn base(skills_dir: PathBuf, state_file: Option<PathBuf>) -> Self {
SkillManager {
skills_dir,
state_file,
clawhub_lock: None,
clawhub_block: true,
clawhub_verdicts: Mutex::new(None),
}
}
pub fn from_skills_dir(skills_dir: &str) -> Self {
Self::base(PathBuf::from(skills_dir), None)
}
pub fn from_skills_and_runtime(skills_dir: &str, runtime_dir: &str) -> Self {
Self::base(
PathBuf::from(skills_dir),
Some(PathBuf::from(runtime_dir).join(SKILLS_STATE_FILENAME)),
)
}
#[allow(dead_code)]
pub fn new(data_dir: &str) -> Self {
Self::base(PathBuf::from(data_dir).join("skills"), None)
}
pub fn with_clawhub_verification(mut self, lockfile: PathBuf, block: bool) -> Self {
self.clawhub_lock = Some(lockfile);
self.clawhub_block = block;
self
}
pub fn with_config_verification(self, config: &crate::config::Config) -> Self {
match config.clawhub_load_verification() {
Some((lockfile, block)) => self.with_clawhub_verification(lockfile, block),
None => self,
}
}
pub fn discover_skills(&self) -> Vec<SkillMetadata> {
self.discover_skills_internal(false)
}
pub fn discover_skills_with_status(&self, include_unavailable: bool) -> Vec<SkillAvailability> {
let mut statuses = self.discover_skill_statuses();
if !include_unavailable {
statuses.retain(|s| s.available);
}
statuses
}
pub fn reload(&self) -> Vec<SkillMetadata> {
match self.clawhub_verdicts.lock() {
Ok(mut cache) => *cache = None,
Err(poisoned) => *poisoned.into_inner() = None,
}
self.discover_skills()
}
fn clawhub_unavailable_reasons(&self) -> HashMap<String, String> {
let Some(lock_path) = &self.clawhub_lock else {
return HashMap::new();
};
let mut cache = match self.clawhub_verdicts.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(map) = cache.as_ref() {
return map.clone();
}
let mut reasons = HashMap::new();
let lock = match crate::internal::clawhub::lockfile::read_lockfile(lock_path) {
Ok(lock) => lock,
Err(e) => {
tracing::error!(
"ClawHub lockfile {} is unreadable: {e}",
lock_path.display()
);
*cache = Some(reasons.clone());
return reasons;
}
};
use crate::internal::clawhub::install::TreeVerification;
for (slug, entry) in &lock.skills {
match crate::internal::clawhub::install::verify_tree(entry, &self.skills_dir) {
TreeVerification::Ok => {}
TreeVerification::Unpinned => {
tracing::warn!(
"ClawHub skill '{slug}' has no recorded tree hash (installed before \
pinning); reinstall to pin"
);
}
TreeVerification::Missing => {
tracing::warn!(
"ClawHub skill '{slug}' is in the lockfile but its installed directory \
is missing or unreadable"
);
}
TreeVerification::Modified { expected, actual } => {
if self.clawhub_block {
tracing::error!(
"ClawHub skill '{slug}' tree MODIFIED since install (expected \
{expected}, actual {actual}); the skill is unavailable until \
reinstalled"
);
reasons.insert(
slug.clone(),
"Installed files no longer match the ClawHub lockfile hash. Run \
`microclaw skill verify` for details and reinstall the skill to \
restore integrity."
.to_string(),
);
} else {
tracing::warn!(
"ClawHub skill '{slug}' tree MODIFIED since install (expected \
{expected}, actual {actual}); clawhub_verify_on_load is 'warn' so \
the skill remains available"
);
}
}
}
}
*cache = Some(reasons.clone());
reasons
}
fn discover_skills_internal(&self, include_unavailable: bool) -> Vec<SkillMetadata> {
self.discover_skills_with_status(include_unavailable)
.into_iter()
.map(|s| s.meta)
.collect()
}
fn discover_skill_statuses(&self) -> Vec<SkillAvailability> {
let mut statuses = Vec::new();
let state = self.read_state_file();
let clawhub_blocked = self.clawhub_unavailable_reasons();
let entries = match std::fs::read_dir(&self.skills_dir) {
Ok(e) => e,
Err(_) => return statuses,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
if let Ok(content) = std::fs::read_to_string(&skill_md) {
if let Some((meta, _body)) = parse_skill_md(&content, &path) {
warn_once_if_legacy_name(&meta.name);
if matches!(state.get(&meta.name), Some(false)) {
statuses.push(SkillAvailability {
meta,
available: false,
reason: Some("Skill is disabled for this runtime.".to_string()),
});
continue;
}
let dir_slug = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default();
if let Some(reason) = clawhub_blocked.get(dir_slug) {
statuses.push(SkillAvailability {
meta,
available: false,
reason: Some(reason.clone()),
});
continue;
}
match self.skill_is_available(&meta) {
Ok(()) => statuses.push(SkillAvailability {
meta,
available: true,
reason: None,
}),
Err(reason) => statuses.push(SkillAvailability {
meta,
available: false,
reason: Some(reason),
}),
};
}
}
}
statuses.sort_by(|a, b| a.meta.name.cmp(&b.meta.name));
statuses
}
pub fn has_skill(&self, name: &str) -> bool {
self.discover_skill_statuses()
.iter()
.any(|skill| skill.meta.name == name)
}
pub fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), String> {
if !self.has_skill(name) {
return Err(format!("Skill not found: {name}"));
}
let mut state = self.read_state_file();
if enabled {
state.remove(name);
} else {
state.insert(name.to_string(), false);
}
self.write_state_file(&state)
}
pub fn load_skill(&self, name: &str) -> Option<(SkillMetadata, String)> {
self.load_skill_checked(name).ok()
}
pub fn load_skill_checked(&self, name: &str) -> Result<(SkillMetadata, String), String> {
let all_skills = self.discover_skills_with_status(true);
for skill in all_skills {
if skill.meta.name != name {
continue;
}
if !skill.available {
let reason = skill
.reason
.unwrap_or_else(|| "unknown availability failure".to_string());
return Err(format!(
"Skill '{name}' is currently unavailable: {reason}\nRun `microclaw skill available --all` for full diagnostics."
));
}
let skill_md = skill.meta.dir_path.join("SKILL.md");
if let Ok(content) = std::fs::read_to_string(&skill_md) {
if let Some((meta, body)) = parse_skill_md(&content, &skill.meta.dir_path) {
return Ok((meta, body));
}
}
return Err(format!("Skill '{name}' exists but could not be loaded."));
}
let available = self.discover_skills();
if available.is_empty() {
Err(format!(
"Skill '{name}' not found. No skills are currently available."
))
} else {
let names: Vec<&str> = available.iter().map(|s| s.name.as_str()).collect();
Err(format!(
"Skill '{name}' not found. Available skills: {}",
names.join(", ")
))
}
}
fn skill_is_available(&self, skill: &SkillMetadata) -> Result<(), String> {
if !platform_allowed(&skill.platforms) {
return Err(format!(
"Skill '{}' is not available on this platform (current: {}, supported: {}).",
skill.name,
current_platform(),
skill.platforms.join(", ")
));
}
let missing = missing_deps(&skill.deps);
if !missing.is_empty() {
return Err(format!(
"Skill '{}' is missing required dependencies: {}",
skill.name,
missing.join(", ")
));
}
Ok(())
}
pub fn build_skills_catalog(&self) -> String {
let mut skills = self.discover_skills();
if skills.is_empty() {
return String::new();
}
skills.sort_by_key(|s| s.name.to_ascii_lowercase());
let total_count = skills.len();
let omitted = total_count.saturating_sub(MAX_SKILLS_CATALOG_ITEMS);
let visible = skills
.into_iter()
.take(MAX_SKILLS_CATALOG_ITEMS)
.collect::<Vec<_>>();
let compact_mode = total_count > COMPACT_SKILLS_MODE_THRESHOLD || omitted > 0;
let mut catalog = String::from("<available_skills>\n");
for skill in &visible {
if compact_mode {
catalog.push_str(&format!("- {}\n", skill.name));
} else {
let desc = truncate_chars(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
catalog.push_str(&format!("- {}: {}\n", skill.name, desc));
}
}
if compact_mode {
catalog.push_str("- (compact mode: use activate_skill to load full instructions)\n");
}
if omitted > 0 {
catalog.push_str(&format!(
"- ... ({} additional skills omitted for prompt budget)\n",
omitted
));
}
catalog.push_str("</available_skills>");
catalog
}
fn rank_skills_for_query(
&self,
query: &str,
top_k: usize,
) -> (Vec<SkillMetadata>, Vec<SkillMetadata>) {
let mut skills = self.discover_skills();
if skills.is_empty() {
return (Vec::new(), Vec::new());
}
let query_tokens = crate::memory_service::tokenize_for_relevance(query);
let mut scored: Vec<(usize, SkillMetadata)> = skills
.drain(..)
.map(|s| {
let blob = format!("{} {}", s.name, s.description);
let blob_tokens = crate::memory_service::tokenize_for_relevance(&blob);
let score = blob_tokens
.iter()
.filter(|t| query_tokens.contains(*t))
.count();
(score, s)
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
let mut hot: Vec<SkillMetadata> = Vec::new();
let mut cold: Vec<SkillMetadata> = Vec::new();
for (score, meta) in scored {
if score > 0 && hot.len() < top_k {
hot.push(meta);
} else {
cold.push(meta);
}
}
(hot, cold)
}
pub fn build_skills_hot_bodies_for_query(&self, query: &str, top_k: usize) -> String {
if top_k == 0 || query.trim().is_empty() {
return String::new();
}
let (hot, _cold) = self.rank_skills_for_query(query, top_k);
if hot.is_empty() {
return String::new();
}
render_hot_skill_bodies(&hot)
}
pub fn build_skills_catalog_for_query(&self, query: &str, top_k: usize) -> String {
if top_k == 0 || query.trim().is_empty() {
return self.build_skills_catalog();
}
let (hot, mut cold) = self.rank_skills_for_query(query, top_k);
if hot.is_empty() {
return self.build_skills_catalog();
}
let mut out = String::from("<available_skills>\n");
out.push_str(render_hot_skill_bodies(&hot).as_str());
if !cold.is_empty() {
out.push_str(
"<!-- Other available skills (call activate_skill to load full body): -->\n",
);
cold.sort_by_key(|s| s.name.to_ascii_lowercase());
let total_cold = cold.len();
let omitted = total_cold.saturating_sub(MAX_SKILLS_CATALOG_ITEMS);
for skill in cold.into_iter().take(MAX_SKILLS_CATALOG_ITEMS) {
let desc = truncate_chars(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
out.push_str(&format!("- {}: {}\n", skill.name, desc));
}
if omitted > 0 {
out.push_str(&format!(
"- ... ({omitted} additional skills omitted for prompt budget)\n"
));
}
}
out.push_str("</available_skills>");
out
}
pub fn build_skills_catalog_for_names_and_query(
&self,
names: &[String],
_query: &str,
_top_k: usize,
) -> String {
let mut seen = std::collections::HashSet::new();
let mut catalog = String::from("<available_skills>\n");
for name in names {
let name = name.trim();
if name.is_empty() || !seen.insert(name.to_ascii_lowercase()) {
continue;
}
match self.load_skill_checked(name) {
Ok((metadata, body)) => {
catalog.push_str(&format!(
"<skill name=\"{}\" description=\"{}\">\n{}\n</skill>\n",
escape_xml_attribute(&metadata.name),
escape_xml_attribute(&metadata.description),
truncate_chars(&body, MAX_INLINED_SKILL_BODY_CHARS),
));
}
Err(reason) => catalog.push_str(&format!(
"<skill-unavailable name=\"{}\">{}</skill-unavailable>\n",
escape_xml_attribute(name),
escape_xml_text(&reason),
)),
}
}
catalog.push_str("</available_skills>");
catalog
}
pub fn list_skills_formatted(&self) -> String {
let skills = self.discover_skills();
if skills.is_empty() {
return "No skills available on this platform/runtime.".into();
}
let mut output = format!("Available skills ({}):\n\n", skills.len());
for skill in &skills {
output.push_str(&format!(
"• {} — {} [{}]\n",
skill.name, skill.description, skill.source
));
append_agentskills_metadata(&mut output, skill);
}
output
}
pub fn list_skills_formatted_all(&self) -> String {
let statuses = self.discover_skills_with_status(true);
if statuses.is_empty() {
return "No skills found in skills directory.".into();
}
let available: Vec<&SkillAvailability> = statuses.iter().filter(|s| s.available).collect();
let unavailable: Vec<&SkillAvailability> =
statuses.iter().filter(|s| !s.available).collect();
let mut output = String::new();
output.push_str(&format!("Available skills ({}):\n\n", available.len()));
for skill in available {
output.push_str(&format!(
"• {} — {} [{}]\n",
skill.meta.name, skill.meta.description, skill.meta.source
));
append_agentskills_metadata(&mut output, &skill.meta);
}
output.push('\n');
output.push_str(&format!("Unavailable skills ({}):\n\n", unavailable.len()));
for skill in unavailable {
output.push_str(&format!(
"• {} — {}\n",
skill.meta.name,
skill
.reason
.as_deref()
.unwrap_or("unavailable for unknown reason")
));
}
output
}
#[allow(dead_code)]
pub fn skills_dir(&self) -> &PathBuf {
&self.skills_dir
}
pub fn state_file_path(&self) -> Option<&Path> {
self.state_file.as_deref()
}
fn read_state_file(&self) -> HashMap<String, bool> {
let Some(path) = self.state_file.as_ref() else {
return HashMap::new();
};
if !path.exists() {
return HashMap::new();
}
match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str::<HashMap<String, bool>>(&raw).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
fn write_state_file(&self, state: &HashMap<String, bool>) -> Result<(), String> {
let Some(path) = self.state_file.as_ref() else {
return Err("Skill state is not configured for this runtime.".to_string());
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let body = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
std::fs::write(path, body).map_err(|e| e.to_string())
}
}
fn current_platform() -> &'static str {
if cfg!(target_os = "macos") {
"darwin"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"unknown"
}
}
fn normalize_platform(value: &str) -> String {
let v = value.trim().to_ascii_lowercase();
match v.as_str() {
"macos" | "osx" => "darwin".to_string(),
_ => v,
}
}
fn platform_allowed(platforms: &[String]) -> bool {
if platforms.is_empty() {
return true;
}
let current = current_platform();
platforms.iter().any(|p| {
let p = normalize_platform(p);
p == "all" || p == "*" || p == current
})
}
fn command_exists(command: &str) -> bool {
if command.trim().is_empty() {
return true;
}
let path_var = std::env::var_os("PATH").unwrap_or_default();
let paths = std::env::split_paths(&path_var);
#[cfg(target_os = "windows")]
let candidates: Vec<String> = {
let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
let ext_list: Vec<String> = exts
.split(';')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
let lower = command.to_ascii_lowercase();
if ext_list.iter().any(|ext| lower.ends_with(ext)) {
vec![command.to_string()]
} else {
let mut c = vec![command.to_string()];
for ext in ext_list {
c.push(format!("{command}{ext}"));
}
c
}
};
#[cfg(not(target_os = "windows"))]
let candidates: Vec<String> = vec![command.to_string()];
for base in paths {
for candidate in &candidates {
let full = base.join(candidate);
if full.is_file() {
return true;
}
}
}
false
}
fn missing_deps(deps: &[String]) -> Vec<String> {
deps.iter()
.filter(|dep| !command_exists(dep))
.cloned()
.collect()
}
fn strip_frontmatter(content: &str) -> &str {
let trimmed = content.trim_start_matches('\u{feff}');
if !trimmed.starts_with("---\n") {
return trimmed;
}
match trimmed[4..].find("\n---\n") {
Some(end) => trimmed[4 + end + 5..].trim_start_matches('\n'),
None => trimmed,
}
}
fn render_hot_skill_bodies(hot: &[SkillMetadata]) -> String {
let mut out =
String::from("<!-- Hot matches: full body inlined for the most relevant skills. -->\n");
for meta in hot {
out.push_str(&format!("## {}\n", meta.name));
out.push_str(&format!("Description: {}\n", meta.description));
if let Some(version) = &meta.version {
out.push_str(&format!("Version: {}\n", version));
}
let skill_md = meta.dir_path.join("SKILL.md");
if let Ok(content) = std::fs::read_to_string(&skill_md) {
let body = strip_frontmatter(&content);
let body = truncate_chars(body, MAX_INLINED_SKILL_BODY_CHARS);
out.push_str("Instructions:\n");
out.push_str(&body);
if !body.ends_with('\n') {
out.push('\n');
}
}
out.push('\n');
}
out
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let truncated: String = s.chars().take(max_chars).collect();
format!("{truncated}...")
}
fn escape_xml_attribute(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn escape_xml_text(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn normalize_single_line_frontmatter(content: &str) -> Option<String> {
if !content.starts_with("--- ") {
return None;
}
let after_open = &content[4..]; let close_idx = after_open.find(" ---")?;
let yaml_part = after_open[..close_idx].trim();
if yaml_part.is_empty() {
return None;
}
let body = after_open[close_idx + 4..].trim_start();
let known_keys: &[&str] = &[
"name:",
"description:",
"license:",
"platforms:",
"deps:",
"compatibility:",
"source:",
"version:",
"updated_at:",
];
let mut yaml = yaml_part.to_string();
for key in known_keys {
yaml = yaml.replacen(&format!(" {key}"), &format!("\n{key}"), 1);
}
Some(format!("---\n{yaml}\n---\n{body}"))
}
pub(crate) fn parse_skill_md(
content: &str,
dir_path: &std::path::Path,
) -> Option<(SkillMetadata, String)> {
let trimmed = content.trim_start_matches('\u{feff}');
let normalized;
let input = if !trimmed.starts_with("---\n") && !trimmed.starts_with("---\r\n") {
normalized = normalize_single_line_frontmatter(trimmed)?;
&normalized
} else {
trimmed
};
let mut lines = input.lines();
let _ = lines.next()?;
let mut yaml_block = String::new();
let mut consumed = 0usize;
for line in lines {
consumed += line.len() + 1;
if line.trim() == "---" || line.trim() == "..." {
break;
}
yaml_block.push_str(line);
yaml_block.push('\n');
}
if yaml_block.trim().is_empty() {
return None;
}
let fm: SkillFrontmatter = serde_yaml::from_str(&yaml_block).ok()?;
let name = fm.name?.trim().to_string();
if name.is_empty() {
return None;
}
let (compat_os, compat_deps, compatibility_str) = match fm.compatibility {
CompatibilityField::Structured(c) => (c.os, c.deps, None),
CompatibilityField::Free(s) => {
let trimmed = s.trim();
if trimmed.is_empty() {
(Vec::new(), Vec::new(), None)
} else {
(Vec::new(), Vec::new(), Some(trimmed.to_string()))
}
}
};
let mut platforms: Vec<String> = fm
.platforms
.into_iter()
.chain(compat_os)
.map(|p| normalize_platform(&p))
.filter(|p| !p.is_empty())
.collect();
platforms.sort();
platforms.dedup();
let mut deps: Vec<String> = fm
.deps
.into_iter()
.chain(compat_deps)
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty())
.collect();
deps.sort();
deps.dedup();
let header_len = if let Some(idx) = input.find("\n---\n") {
idx + 5
} else if let Some(idx) = input.find("\n...\n") {
idx + 5
} else {
4 + consumed
};
let body = input
.get(header_len..)
.unwrap_or_default()
.trim()
.to_string();
Some((
SkillMetadata {
name,
description: fm.description,
dir_path: dir_path.to_path_buf(),
platforms,
deps,
source: fm
.source
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "local".to_string()),
version: fm
.version
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
updated_at: fm
.updated_at
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
env_file: fm
.env_file
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
license: fm
.license
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
compatibility: compatibility_str,
allowed_tools: fm
.allowed_tools
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
},
body,
))
}
fn warn_once_if_legacy_name(name: &str) {
static SEEN: OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = OnceLock::new();
if validate_agentskills_name(name).is_ok() {
return;
}
let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
let mut guard = match seen.lock() {
Ok(g) => g,
Err(_) => return,
};
if guard.insert(name.to_string()) {
tracing::warn!(
skill = %name,
"skill name is not agentskills.io spec-compliant (lowercase a-z, 0-9, '-' only; no leading/trailing/consecutive '-'); rename before publishing"
);
}
}
fn append_agentskills_metadata(output: &mut String, meta: &SkillMetadata) {
if let Some(license) = meta.license.as_deref() {
output.push_str(&format!(" license: {license}\n"));
}
if let Some(compat) = meta.compatibility.as_deref() {
output.push_str(&format!(" compatibility: {compat}\n"));
}
if let Some(tools) = meta.allowed_tools.as_deref() {
output.push_str(&format!(" allowed-tools: {tools}\n"));
}
}
pub fn validate_agentskills_name(name: &str) -> Result<(), String> {
if name.is_empty() || name.len() > 64 {
return Err(format!(
"skill name must be 1-64 characters (got {})",
name.len()
));
}
if name.starts_with('-') || name.ends_with('-') {
return Err("skill name must not start or end with a hyphen".into());
}
if name.contains("--") {
return Err("skill name must not contain consecutive hyphens".into());
}
for ch in name.chars() {
if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') {
return Err(format!(
"skill name contains invalid character '{ch}'; only lowercase a-z, 0-9, and '-' are allowed"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_skill_md_valid() {
let content = r#"---
name: pdf
description: Convert documents to PDF
platforms: [linux, darwin]
deps: [pandoc]
---
Use this skill to convert documents.
"#;
let dir = PathBuf::from("/tmp/skills/pdf");
let result = parse_skill_md(content, &dir);
assert!(result.is_some());
let (meta, body) = result.unwrap();
assert_eq!(meta.name, "pdf");
assert_eq!(meta.description, "Convert documents to PDF");
assert_eq!(meta.platforms, vec!["darwin", "linux"]);
assert_eq!(meta.deps, vec!["pandoc"]);
assert_eq!(meta.source, "local");
assert!(body.contains("Use this skill"));
}
#[test]
fn test_parse_skill_md_agentskills_minimal_spec() {
let content = r#"---
name: pdf-processing
description: Extract text and tables from PDF files. Use when working with PDFs.
---
Body.
"#;
let dir = PathBuf::from("/tmp/skills/pdf-processing");
let (meta, body) = parse_skill_md(content, &dir).expect("minimal spec form should parse");
assert_eq!(meta.name, "pdf-processing");
assert!(meta.description.starts_with("Extract text"));
assert!(meta.license.is_none());
assert!(meta.compatibility.is_none());
assert!(meta.allowed_tools.is_none());
assert_eq!(body, "Body.");
}
#[test]
fn test_parse_skill_md_agentskills_optional_fields() {
let content = r#"---
name: pdf-processing
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
license: Apache-2.0
compatibility: Requires git, docker, jq, and access to the internet
allowed-tools: Bash(git:*) Bash(jq:*) Read
metadata:
author: example-org
version: "1.0"
---
Body.
"#;
let dir = PathBuf::from("/tmp/skills/pdf-processing");
let (meta, _) = parse_skill_md(content, &dir).expect("spec optional fields should parse");
assert_eq!(meta.license.as_deref(), Some("Apache-2.0"));
assert_eq!(
meta.compatibility.as_deref(),
Some("Requires git, docker, jq, and access to the internet")
);
assert_eq!(
meta.allowed_tools.as_deref(),
Some("Bash(git:*) Bash(jq:*) Read")
);
}
#[test]
fn test_validate_agentskills_name_accepts_valid_names() {
assert!(validate_agentskills_name("pdf-processing").is_ok());
assert!(validate_agentskills_name("data-analysis").is_ok());
assert!(validate_agentskills_name("a").is_ok());
assert!(validate_agentskills_name("a1").is_ok());
}
#[test]
fn test_validate_agentskills_name_rejects_invalid_names() {
assert!(validate_agentskills_name("").is_err());
assert!(validate_agentskills_name("PDF-Processing").is_err());
assert!(validate_agentskills_name("-pdf").is_err());
assert!(validate_agentskills_name("pdf-").is_err());
assert!(validate_agentskills_name("pdf--processing").is_err());
assert!(validate_agentskills_name("pdf_processing").is_err());
assert!(validate_agentskills_name("pdf processing").is_err());
assert!(validate_agentskills_name(&"a".repeat(65)).is_err());
}
#[test]
fn test_parse_skill_md_compatibility_os() {
let content = r#"---
name: apple-notes
description: Apple Notes
compatibility:
os:
- darwin
deps:
- memo
---
Instructions.
"#;
let dir = PathBuf::from("/tmp/skills/apple-notes");
let (meta, _) = parse_skill_md(content, &dir).unwrap();
assert_eq!(meta.platforms, vec!["darwin"]);
assert_eq!(meta.deps, vec!["memo"]);
}
#[test]
fn test_parse_skill_md_no_frontmatter() {
let content = "Just some markdown without frontmatter.";
let dir = PathBuf::from("/tmp/skills/test");
assert!(parse_skill_md(content, &dir).is_none());
}
#[test]
fn test_parse_skill_md_single_line_frontmatter() {
let content = "--- name: frontend-design description: Create distinctive UIs license: Complete terms in LICENSE.txt --- This skill guides creation of distinctive interfaces.";
let dir = PathBuf::from("/tmp/skills/frontend-design");
let result = parse_skill_md(content, &dir);
assert!(result.is_some(), "single-line frontmatter should parse");
let (meta, body) = result.unwrap();
assert_eq!(meta.name, "frontend-design");
assert!(meta.description.starts_with("Create distinctive"));
assert!(body.contains("This skill guides"));
}
#[test]
fn test_normalize_single_line_frontmatter() {
let content = "--- name: test description: A test skill --- Body here";
let result = normalize_single_line_frontmatter(content);
assert!(result.is_some());
let norm = result.unwrap();
assert!(norm.starts_with("---\n"));
assert!(norm.contains("\nname: test"));
assert!(norm.contains("\ndescription: A test skill"));
assert!(norm.contains("---\nBody here"));
}
#[test]
fn test_platform_allowed_empty_means_all() {
assert!(platform_allowed(&[]));
}
#[test]
fn test_list_skills_formatted_surfaces_agentskills_metadata() {
let dir =
std::env::temp_dir().join(format!("microclaw_skills_meta_{}", uuid::Uuid::new_v4()));
let pdf = dir.join("skills").join("pdf-processing");
std::fs::create_dir_all(&pdf).unwrap();
std::fs::write(
pdf.join("SKILL.md"),
"---\n\
name: pdf-processing\n\
description: Extract PDF text\n\
license: Apache-2.0\n\
compatibility: Requires git, docker\n\
allowed-tools: Bash(git:*) Read\n\
---\nbody",
)
.unwrap();
let sm = SkillManager::new(dir.to_str().unwrap());
let listed = sm.list_skills_formatted();
assert!(listed.contains("license: Apache-2.0"), "listing: {listed}");
assert!(listed.contains("compatibility: Requires git, docker"));
assert!(listed.contains("allowed-tools: Bash(git:*) Read"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_skills_catalog_empty() {
let dir =
std::env::temp_dir().join(format!("microclaw_skills_test_{}", uuid::Uuid::new_v4()));
let sm = SkillManager::new(dir.to_str().unwrap());
let catalog = sm.build_skills_catalog();
assert!(catalog.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn selected_skill_catalog_includes_only_profile_assignments() {
let dir = std::env::temp_dir().join(format!(
"microclaw_selected_skills_{}",
uuid::Uuid::new_v4()
));
write_skill(
&dir,
"rust-review",
"Review Rust",
"Check ownership and errors.",
);
write_skill(&dir, "deploy", "Deploy services", "Push the image.");
let manager = SkillManager::from_skills_dir(dir.to_str().unwrap());
let catalog = manager.build_skills_catalog_for_names_and_query(
&["rust-review".into()],
"deploy rust",
3,
);
assert!(catalog.contains("rust-review"), "catalog: {catalog}");
assert!(
catalog.contains("Check ownership and errors."),
"catalog: {catalog}"
);
assert!(!catalog.contains("Push the image."), "catalog: {catalog}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_skills_catalog_sorted_and_truncated() {
let dir = std::env::temp_dir().join(format!(
"microclaw_skills_catalog_sorted_{}",
uuid::Uuid::new_v4()
));
let long_desc = "z".repeat(MAX_SKILL_DESCRIPTION_CHARS + 32);
let zeta = dir.join("zeta");
let alpha = dir.join("alpha");
std::fs::create_dir_all(&zeta).unwrap();
std::fs::create_dir_all(&alpha).unwrap();
std::fs::write(
zeta.join("SKILL.md"),
format!("---\nname: zeta\ndescription: {long_desc}\n---\nok\n"),
)
.unwrap();
std::fs::write(
alpha.join("SKILL.md"),
r#"---
name: alpha
description: alpha skill
---
ok
"#,
)
.unwrap();
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let catalog = sm.build_skills_catalog();
let alpha_pos = catalog.find("- alpha: alpha skill").unwrap();
let zeta_pos = catalog.find("- zeta: ").unwrap();
assert!(alpha_pos < zeta_pos);
assert!(catalog.contains("..."));
let _ = std::fs::remove_dir_all(&dir);
}
fn write_skill(root: &std::path::Path, name: &str, description: &str, body: &str) {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let content = format!(
"---\nname: {name}\ndescription: {description}\nsource: agent-created\n---\n{body}\n"
);
std::fs::write(dir.join("SKILL.md"), content).unwrap();
}
fn write_lock_entry(lock_path: &std::path::Path, slug: &str, tree_hash: Option<String>) {
let mut skills = std::collections::HashMap::new();
skills.insert(
slug.to_string(),
crate::internal::clawhub::types::LockEntry {
slug: slug.to_string(),
installed_version: "1.0.0".into(),
installed_at: "2026-08-07T00:00:00Z".into(),
content_hash: "sha256:unused".into(),
tree_hash,
local_path: slug.to_string(),
},
);
let lock = crate::internal::clawhub::types::LockFile { version: 1, skills };
crate::internal::clawhub::lockfile::write_lockfile(lock_path, &lock).unwrap();
}
#[test]
fn clawhub_tampered_skill_blocks_at_load_after_reload() {
let dir = std::env::temp_dir().join(format!(
"microclaw_clawhub_verify_block_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
write_skill(&dir, "managed", "managed skill", "ok");
write_skill(&dir, "local", "local skill", "ok");
let tree =
crate::internal::clawhub::install::compute_tree_hash(&dir.join("managed")).unwrap();
let lock_path = dir.join("clawhub.lock.json");
write_lock_entry(&lock_path, "managed", Some(tree));
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
.with_clawhub_verification(lock_path.clone(), true);
assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
std::fs::write(
dir.join("managed").join("SKILL.md"),
"---\nname: managed\ndescription: mutated\n---\npayload\n",
)
.unwrap();
assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
sm.reload();
let statuses = sm.discover_skills_with_status(true);
let managed = statuses.iter().find(|s| s.meta.name == "managed").unwrap();
assert!(!managed.available);
assert!(managed
.reason
.as_deref()
.unwrap_or_default()
.contains("lockfile hash"));
assert!(sm.load_skill_checked("managed").is_err());
assert!(sm.discover_skills().iter().any(|s| s.name == "local"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn clawhub_warn_mode_keeps_tampered_skill_available() {
let dir = std::env::temp_dir().join(format!(
"microclaw_clawhub_verify_warn_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
write_skill(&dir, "managed", "managed skill", "ok");
let tree =
crate::internal::clawhub::install::compute_tree_hash(&dir.join("managed")).unwrap();
let lock_path = dir.join("clawhub.lock.json");
write_lock_entry(&lock_path, "managed", Some(tree));
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
.with_clawhub_verification(lock_path, false);
std::fs::write(
dir.join("managed").join("SKILL.md"),
"---\nname: managed\ndescription: mutated\n---\npayload\n",
)
.unwrap();
assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn clawhub_unpinned_entry_stays_available() {
let dir = std::env::temp_dir().join(format!(
"microclaw_clawhub_verify_unpinned_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
write_skill(&dir, "managed", "managed skill", "ok");
let lock_path = dir.join("clawhub.lock.json");
write_lock_entry(&lock_path, "managed", None);
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap())
.with_clawhub_verification(lock_path, true);
assert!(sm.discover_skills().iter().any(|s| s.name == "managed"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_hot_bodies_inlines_only_matched_skills() {
let dir = std::env::temp_dir().join(format!("mc_skills_hot_{}", uuid::Uuid::new_v4()));
write_skill(
&dir,
"deploy-helper",
"kubernetes deploy helper",
"Step 1: kubectl apply",
);
write_skill(&dir, "irrelevant", "make tea", "boil water");
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let out = sm.build_skills_hot_bodies_for_query("how do i do a kubernetes deploy?", 3);
assert!(out.contains("## deploy-helper"), "got: {out}");
assert!(
out.contains("Step 1: kubectl apply"),
"body not inlined: {out}"
);
assert!(
!out.contains("Other available skills"),
"cold list leaked: {out}"
);
assert!(
!out.contains("- irrelevant: make tea"),
"cold list leaked: {out}"
);
assert!(!out.contains("boil water"), "unmatched body leaked: {out}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_hot_bodies_empty_when_nothing_matches() {
let dir =
std::env::temp_dir().join(format!("mc_skills_hot_nomatch_{}", uuid::Uuid::new_v4()));
write_skill(&dir, "alpha", "alpha skill", "ok");
write_skill(&dir, "beta", "beta skill", "ok");
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
assert!(sm
.build_skills_hot_bodies_for_query("xyz unrelated", 3)
.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_hot_bodies_empty_for_zero_top_k_and_blank_query() {
let dir = std::env::temp_dir().join(format!("mc_skills_hot_edge_{}", uuid::Uuid::new_v4()));
write_skill(&dir, "alpha", "alpha skill", "ok");
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
assert!(sm
.build_skills_hot_bodies_for_query("alpha skill", 0)
.is_empty());
assert!(sm.build_skills_hot_bodies_for_query("", 3).is_empty());
assert!(sm.build_skills_hot_bodies_for_query(" ", 3).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_catalog_is_stable_and_name_ordered() {
let dir = std::env::temp_dir().join(format!("mc_skills_stable_{}", uuid::Uuid::new_v4()));
write_skill(&dir, "zebra", "zebra skill", "z");
write_skill(&dir, "alpha", "alpha skill", "a");
write_skill(&dir, "mango", "mango skill", "m");
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let first = sm.build_skills_catalog();
let second = sm.build_skills_catalog();
assert_eq!(first, second, "canonical catalog must be deterministic");
let alpha = first.find("- alpha:").expect("alpha listed");
let mango = first.find("- mango:").expect("mango listed");
let zebra = first.find("- zebra:").expect("zebra listed");
assert!(alpha < mango && mango < zebra, "not name-ordered: {first}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_catalog_for_query_falls_back_when_top_k_zero() {
let dir = std::env::temp_dir().join(format!(
"mc_skills_query_top_k_zero_{}",
uuid::Uuid::new_v4()
));
write_skill(&dir, "alpha", "alpha skill", "ok");
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let plain = sm.build_skills_catalog();
let queried = sm.build_skills_catalog_for_query("alpha", 0);
assert_eq!(plain, queried);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_catalog_for_query_inlines_relevant_skill_body() {
let dir =
std::env::temp_dir().join(format!("mc_skills_query_inline_{}", uuid::Uuid::new_v4()));
write_skill(
&dir,
"deploy-helper",
"kubernetes rolling deploy",
"Step 1: kubectl apply\nStep 2: verify",
);
write_skill(&dir, "irrelevant", "make tea", "boil water");
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let out = sm.build_skills_catalog_for_query("how do i do a kubernetes deploy?", 3);
assert!(out.contains("## deploy-helper"), "got: {out}");
assert!(
out.contains("Step 1: kubectl apply"),
"body not inlined: {out}"
);
assert!(out.contains("- irrelevant: make tea"), "got: {out}");
assert!(!out.contains("boil water"), "irrelevant body leaked: {out}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_catalog_for_query_falls_back_when_no_match() {
let dir =
std::env::temp_dir().join(format!("mc_skills_query_nomatch_{}", uuid::Uuid::new_v4()));
write_skill(&dir, "alpha", "alpha skill", "ok");
write_skill(&dir, "beta", "beta skill", "ok");
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let out = sm.build_skills_catalog_for_query("xyz unrelated", 3);
assert!(!out.contains("## alpha"));
assert!(!out.contains("## beta"));
assert!(out.contains("- alpha: alpha skill"));
assert!(out.contains("- beta: beta skill"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_catalog_for_query_caps_inlined_body() {
let dir =
std::env::temp_dir().join(format!("mc_skills_query_cap_{}", uuid::Uuid::new_v4()));
let big_body = "x".repeat(MAX_INLINED_SKILL_BODY_CHARS + 500);
write_skill(&dir, "matchme", "matchme description", &big_body);
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let out = sm.build_skills_catalog_for_query("matchme description", 3);
assert!(out.contains("## matchme"));
assert!(out.contains("..."), "expected truncation marker: {out}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_skills_catalog_for_query_respects_top_k_bucket() {
let dir =
std::env::temp_dir().join(format!("mc_skills_query_top_k_{}", uuid::Uuid::new_v4()));
for n in 0..5 {
let name = format!("deploy-{n}");
write_skill(&dir, &name, "deploy stuff", &format!("body {n}"));
}
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let out = sm.build_skills_catalog_for_query("deploy", 2);
let inlined = out.matches("## deploy-").count();
assert_eq!(inlined, 2, "expected 2 inlined hot matches; got: {out}");
let cold = out.matches("- deploy-").count();
assert_eq!(cold, 3, "expected 3 cold entries; got: {out}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn strip_frontmatter_removes_yaml_block() {
let body = "---\nname: x\ndescription: y\n---\nthe body\n";
assert_eq!(strip_frontmatter(body), "the body\n");
}
#[test]
fn strip_frontmatter_returns_input_when_no_frontmatter() {
let body = "no frontmatter here\n";
assert_eq!(strip_frontmatter(body), body);
}
#[test]
fn test_build_skills_catalog_applies_item_cap() {
let dir = std::env::temp_dir().join(format!(
"microclaw_skills_catalog_cap_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
for idx in 0..=MAX_SKILLS_CATALOG_ITEMS {
let name = format!("skill-{idx:02}");
let skill_dir = dir.join(&name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: test skill {idx}\n---\nbody\n"),
)
.unwrap();
}
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let catalog = sm.build_skills_catalog();
assert!(catalog.contains("additional skills omitted for prompt budget"));
let rendered_items = catalog
.lines()
.filter(|line| line.starts_with("- skill-"))
.count();
assert_eq!(rendered_items, MAX_SKILLS_CATALOG_ITEMS);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_skills_catalog_enters_compact_mode_when_many_skills() {
let dir = std::env::temp_dir().join(format!(
"microclaw_skills_catalog_compact_mode_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
for idx in 0..=COMPACT_SKILLS_MODE_THRESHOLD {
let name = format!("compact-skill-{idx:02}");
let skill_dir = dir.join(&name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
format!(
"---\nname: {name}\ndescription: this description should not appear in compact mode\n---\nbody\n"
),
)
.unwrap();
}
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let catalog = sm.build_skills_catalog();
assert!(catalog.contains("compact mode: use activate_skill"));
assert!(!catalog.contains(": this description should not appear"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_list_skills_formatted_all_includes_unavailable_reasons() {
let dir = std::env::temp_dir().join(format!(
"microclaw_skills_all_test_{}",
uuid::Uuid::new_v4()
));
let available = dir.join("available");
std::fs::create_dir_all(&available).unwrap();
std::fs::write(
available.join("SKILL.md"),
r#"---
name: available
description: Available skill
---
ok
"#,
)
.unwrap();
let unavailable = dir.join("unavailable");
std::fs::create_dir_all(&unavailable).unwrap();
std::fs::write(
unavailable.join("SKILL.md"),
r#"---
name: unavailable
description: Missing dependency
deps: [definitely_missing_dep_123456]
---
nope
"#,
)
.unwrap();
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let text = sm.list_skills_formatted_all();
assert!(text.contains("Available skills (1)"));
assert!(text.contains("available"));
assert!(text.contains("Unavailable skills (1)"));
assert!(text.contains("definitely_missing_dep_123456"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_skill_checked_unavailable_has_diagnostic_hint() {
let dir = std::env::temp_dir().join(format!(
"microclaw_skills_unavailable_test_{}",
uuid::Uuid::new_v4()
));
let unavailable = dir.join("bad");
std::fs::create_dir_all(&unavailable).unwrap();
std::fs::write(
unavailable.join("SKILL.md"),
r#"---
name: bad
description: Missing dependency
deps: [definitely_missing_dep_654321]
---
nope
"#,
)
.unwrap();
let sm = SkillManager::from_skills_dir(dir.to_str().unwrap());
let err = sm.load_skill_checked("bad").unwrap_err();
assert!(err.contains("currently unavailable"));
assert!(err.contains("available --all"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_parse_skill_md_with_env_file() {
let content = r#"---
name: outline
description: Manage Outline wiki
env_file: .env
---
Use this skill to interact with Outline.
"#;
let dir = PathBuf::from("/tmp/skills/outline");
let result = parse_skill_md(content, &dir);
assert!(result.is_some());
let (meta, _body) = result.unwrap();
assert_eq!(meta.env_file.as_deref(), Some(".env"));
}
#[test]
fn test_disable_skill_is_runtime_scoped() {
let base_dir = std::env::temp_dir().join(format!(
"microclaw_skills_runtime_scoped_{}",
uuid::Uuid::new_v4()
));
let runtime_a = base_dir.join("runtime-a");
let runtime_b = base_dir.join("runtime-b");
let skills_dir = base_dir.join("skills");
let skill_dir = skills_dir.join("pdf");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
r#"---
name: pdf
description: Convert to PDF
---
Use this skill.
"#,
)
.unwrap();
let manager_a = SkillManager::from_skills_and_runtime(
skills_dir.to_str().unwrap(),
runtime_a.to_str().unwrap(),
);
let manager_b = SkillManager::from_skills_and_runtime(
skills_dir.to_str().unwrap(),
runtime_b.to_str().unwrap(),
);
manager_a.set_enabled("pdf", false).unwrap();
let status_a = manager_a.discover_skills_with_status(true);
let status_b = manager_b.discover_skills_with_status(true);
assert!(!status_a[0].available);
assert!(status_a[0]
.reason
.as_deref()
.unwrap_or_default()
.contains("disabled"));
assert!(status_b[0].available);
assert!(skill_dir.join("SKILL.md").exists());
let _ = std::fs::remove_dir_all(&base_dir);
}
#[test]
fn test_set_enabled_missing_skill_returns_error() {
let base_dir = std::env::temp_dir().join(format!(
"microclaw_skills_enable_missing_{}",
uuid::Uuid::new_v4()
));
let runtime = base_dir.join("runtime");
let skills_dir = base_dir.join("skills");
std::fs::create_dir_all(&skills_dir).unwrap();
let manager = SkillManager::from_skills_and_runtime(
skills_dir.to_str().unwrap(),
runtime.to_str().unwrap(),
);
let err = manager.set_enabled("missing", false).unwrap_err();
assert!(err.contains("Skill not found"));
let _ = std::fs::remove_dir_all(&base_dir);
}
}