PicoBot/src/skills/mod.rs

1384 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::platform::{atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
#[cfg(test)]
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
SKILL_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
}
use crate::config::SkillsConfig;
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub body: String,
pub source: SkillSource,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillSource {
User,
UserAgent,
UserOpenclaw,
Project,
ProjectAgent,
ProjectOpenclaw,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SkillScope {
User,
Project,
}
impl SkillScope {
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
}
}
}
impl From<SkillScope> for SkillSource {
fn from(value: SkillScope) -> Self {
match value {
SkillScope::User => SkillSource::User,
SkillScope::Project => SkillSource::Project,
}
}
}
#[derive(Debug)]
pub struct SkillRuntime {
config: SkillsConfig,
catalog: RwLock<SkillCatalog>,
}
#[derive(Debug, Clone)]
pub struct SkillAvailabilityChange {
pub name: String,
pub scope: SkillScope,
pub state_path: PathBuf,
pub changed: bool,
pub disabled_in_scopes: Vec<SkillScope>,
pub available: bool,
}
impl Default for SkillRuntime {
fn default() -> Self {
Self {
config: SkillsConfig::default(),
catalog: RwLock::new(SkillCatalog::default()),
}
}
}
impl SkillRuntime {
pub fn from_config(config: SkillsConfig) -> Self {
let catalog = SkillCatalog::discover(&config);
Self {
config,
catalog: RwLock::new(catalog),
}
}
pub fn reload(&self) -> Result<SkillCatalog, String> {
let catalog = SkillCatalog::discover(&self.config);
let mut guard = self.catalog.write().expect("skills rwlock poisoned");
*guard = catalog.clone();
Ok(catalog)
}
pub fn is_empty(&self) -> bool {
self.catalog
.read()
.expect("skills rwlock poisoned")
.is_empty()
}
pub fn len(&self) -> usize {
self.catalog.read().expect("skills rwlock poisoned").len()
}
pub fn system_index_prompt(&self) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt()
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.discovery_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.offered_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_payload(name)
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_event_payload(name)
}
pub fn list_skills(&self) -> Vec<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.skills
.clone()
}
pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.find_skill(name)
.cloned()
}
pub fn create_skill(
&self,
scope: SkillScope,
name: &str,
description: &str,
body: &str,
reload: bool,
) -> Result<Skill, String> {
validate_skill_name(name)?;
let path = skill_file_path(scope, name)?;
if path.exists() {
return Err(format!(
"skill '{}' already exists at {}",
name,
path.display()
));
}
write_skill_file(&path, name, description, body)?;
let skill = parse_skill_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(skill)
}
pub fn update_skill(
&self,
scope: SkillScope,
name: &str,
description: Option<&str>,
body: Option<&str>,
reload: bool,
) -> Result<Skill, String> {
validate_skill_name(name)?;
let path = skill_file_path(scope, name)?;
if !path.exists() {
return Err(format!("skill '{}' not found at {}", name, path.display()));
}
let existing = parse_skill_file(&path, scope.into())?;
let next_description = description.unwrap_or(&existing.description);
let next_body = body.unwrap_or(&existing.body);
write_skill_file(&path, name, next_description, next_body)?;
let skill = parse_skill_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(skill)
}
pub fn delete_skill(
&self,
scope: SkillScope,
name: &str,
reload: bool,
) -> Result<PathBuf, String> {
validate_skill_name(name)?;
let dir = skill_dir_path(scope, name)?;
if !dir.exists() {
return Err(format!("skill '{}' not found at {}", name, dir.display()));
}
fs::remove_dir_all(&dir)
.map_err(|err| format!("failed to delete skill directory: {}", err))?;
if reload {
let _ = self.reload()?;
}
Ok(dir)
}
pub fn disable_skill(
&self,
scope: SkillScope,
name: &str,
reload: bool,
) -> Result<SkillAvailabilityChange, String> {
self.set_skill_enabled(scope, name, false, reload)
}
pub fn enable_skill(
&self,
scope: SkillScope,
name: &str,
reload: bool,
) -> Result<SkillAvailabilityChange, String> {
self.set_skill_enabled(scope, name, true, reload)
}
pub fn has_skill_definition(&self, name: &str) -> Result<bool, String> {
validate_skill_name(name)?;
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(SkillCatalog::discover_without_state(&self.config, &cwd)
.find_skill(name)
.is_some())
}
fn set_skill_enabled(
&self,
scope: SkillScope,
name: &str,
enabled: bool,
reload: bool,
) -> Result<SkillAvailabilityChange, String> {
validate_skill_name(name)?;
if !self.has_skill_definition(name)? {
return Err(format!("skill '{}' not found", name));
}
let state_path = skill_state_path(scope)?;
let mut state = load_skill_state_file(&state_path)?;
let mut disabled: HashSet<String> = state.disabled_skills.into_iter().collect();
let changed = if enabled {
disabled.remove(name)
} else {
disabled.insert(name.to_string())
};
let mut disabled_skills: Vec<String> = disabled.into_iter().collect();
disabled_skills.sort();
state.disabled_skills = disabled_skills;
save_skill_state_file(&state_path, &state)?;
if reload {
let _ = self.reload()?;
}
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
let effective_state = load_skill_disable_state(&cwd);
let disabled_in_scopes = effective_state.disabled_scopes_for(name);
Ok(SkillAvailabilityChange {
name: name.to_string(),
scope,
state_path,
changed,
available: disabled_in_scopes.is_empty(),
disabled_in_scopes,
})
}
}
impl crate::agent::SkillProvider for SkillRuntime {
fn system_index_prompt(&self) -> Option<String> {
SkillRuntime::system_index_prompt(self)
}
fn matching_skill_summary(&self, name: &str) -> Option<String> {
self.get_skill(name).map(|skill| skill.description)
}
}
impl SkillSource {
fn as_str(&self) -> &'static str {
match self {
SkillSource::User => "user",
SkillSource::UserAgent => "user_agent",
SkillSource::UserOpenclaw => "user_openclaw",
SkillSource::Project => "project",
SkillSource::ProjectAgent => "project_agent",
SkillSource::ProjectOpenclaw => "project_openclaw",
}
}
}
#[derive(Debug, Clone)]
pub struct SkillCatalog {
skills: Vec<Skill>,
max_index_chars: usize,
max_listed_skills: usize,
}
impl Default for SkillCatalog {
fn default() -> Self {
Self {
skills: Vec::new(),
max_index_chars: 4_000,
max_listed_skills: 32,
}
}
}
impl SkillCatalog {
pub fn discover(config: &SkillsConfig) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let disable_state = load_skill_disable_state(&cwd);
Self::discover_with_state(config, &cwd, Some(&disable_state))
}
fn discover_without_state(config: &SkillsConfig, cwd: &Path) -> Self {
Self::discover_with_state(config, cwd, None)
}
fn discover_with_state(
config: &SkillsConfig,
cwd: &Path,
disable_state: Option<&SkillDisableState>,
) -> Self {
if !config.enabled {
return Self {
max_index_chars: config.max_index_chars,
max_listed_skills: config.max_listed_skills,
..Self::default()
};
}
let mut merged: HashMap<String, Skill> = HashMap::new();
let mut sources_seen = 0usize;
// Load from least specific to most specific so later sources win on conflicts.
for source in source_order(&config.sources) {
sources_seen += 1;
let root = source_root(source, &cwd);
let Some(root) = root else { continue };
for skill in load_skills_from_root(&root, source) {
if let Some(existing) = merged.get(&skill.name) {
tracing::warn!(
skill = %skill.name,
old_source = %existing.source.as_str(),
new_source = %skill.source.as_str(),
"Duplicate skill name found; overriding with later source"
);
}
merged.insert(skill.name.clone(), skill);
}
}
let mut skills: Vec<Skill> = merged.into_values().collect();
if let Some(disable_state) = disable_state {
skills.retain(|skill| !disable_state.is_disabled(&skill.name));
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
tracing::info!(
sources_seen,
discovered = skills.len(),
"Skills discovery completed"
);
Self {
skills,
max_index_chars: config.max_index_chars,
max_listed_skills: config.max_listed_skills,
}
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn system_index_prompt(&self) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let mut prompt = String::from(
"技能为特定任务提供专用说明和工作流。\n当任务匹配其描述时,使用 skill_activate 工具加载技能。\n技能不是工具名,即使技能名看起来像工具,也不能直接调用技能名。\n如果需要某个技能,必须先调用 tool skill_activate并传入 {\"name\": \"<skill-name>\"},再根据返回的技能说明执行。\n\n<available_skills>\n",
);
for skill in self.skills.iter().take(self.max_listed_skills) {
let entry = format!(
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>\n",
platform_xml_escape(&skill.name),
platform_xml_escape(&skill.description),
platform_xml_escape(&path_to_uri(&skill.path)),
);
if prompt.len() + entry.len() + "</available_skills>\n".len() > self.max_index_chars {
prompt.push_str(" <truncated>true</truncated>\n");
break;
}
prompt.push_str(&entry);
}
prompt.push_str("</available_skills>\n");
Some(prompt)
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
let skill = self
.find_skill(name)
.ok_or_else(|| format!("skill '{}' not found", name))?;
if skill.body.is_empty() {
return Ok(format!(
"SKILL LOADED: {}\nDescription: {}\nNo additional body instructions found.",
skill.name, skill.description
));
}
Ok(format!(
"SKILL LOADED: {}\nDescription: {}\nSource: {}\nPath: {}\n\n{}",
skill.name,
skill.description,
skill.source.as_str(),
skill.path.display(),
skill.body
))
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
let skill = self
.find_skill(name)
.ok_or_else(|| format!("skill '{}' not found", name))?;
Ok(json!({
"name": skill.name,
"description": skill.description,
"source": skill.source.as_str(),
"path": skill.path.display().to_string(),
"body_chars": skill.body.len(),
}))
}
fn find_skill(&self, name: &str) -> Option<&Skill> {
self.skills.iter().find(|s| s.name == name)
}
fn catalog_event_payload(&self) -> serde_json::Value {
json!({
"count": self.skills.len(),
"skills": self.skills.iter().map(|skill| json!({
"name": skill.name,
"description": skill.description,
"source": skill.source.as_str(),
"path": skill.path.display().to_string(),
})).collect::<Vec<_>>()
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct SkillStateFile {
#[serde(default)]
disabled_skills: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct SkillDisableState {
user_disabled: HashSet<String>,
project_disabled: HashSet<String>,
}
impl SkillDisableState {
fn is_disabled(&self, name: &str) -> bool {
self.user_disabled.contains(name) || self.project_disabled.contains(name)
}
fn disabled_scopes_for(&self, name: &str) -> Vec<SkillScope> {
let mut scopes = Vec::new();
if self.user_disabled.contains(name) {
scopes.push(SkillScope::User);
}
if self.project_disabled.contains(name) {
scopes.push(SkillScope::Project);
}
scopes
}
}
fn source_order(sources: &[String]) -> Vec<SkillSource> {
let mut result = Vec::new();
for source in sources {
match source.as_str() {
"user" => {
if !result.contains(&SkillSource::User) {
result.push(SkillSource::User);
}
}
"user_agent" => {
if !result.contains(&SkillSource::UserAgent) {
result.push(SkillSource::UserAgent);
}
}
"user_openclaw" => {
if !result.contains(&SkillSource::UserOpenclaw) {
result.push(SkillSource::UserOpenclaw);
}
}
"project" => {
if !result.contains(&SkillSource::Project) {
result.push(SkillSource::Project);
}
}
"project_agent" => {
if !result.contains(&SkillSource::ProjectAgent) {
result.push(SkillSource::ProjectAgent);
}
}
"project_openclaw" => {
if !result.contains(&SkillSource::ProjectOpenclaw) {
result.push(SkillSource::ProjectOpenclaw);
}
}
unknown => {
tracing::warn!(source = %unknown, "Unknown skills source ignored");
}
}
}
if result.is_empty() {
vec![
SkillSource::User,
SkillSource::UserAgent,
SkillSource::UserOpenclaw,
SkillSource::Project,
SkillSource::ProjectAgent,
SkillSource::ProjectOpenclaw,
]
} else {
result
}
}
fn validate_skill_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("skill name cannot be empty".to_string());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("skill name must not contain path separators or '..'".to_string());
}
Ok(())
}
pub fn project_skills_root() -> Result<PathBuf, String> {
let cwd =
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(cwd.join(".picobot").join("skills"))
}
fn project_agent_skills_root(cwd: &Path) -> PathBuf {
cwd.join(".agents").join("skills")
}
fn project_openclaw_skills_root(cwd: &Path) -> PathBuf {
cwd.join(".openclaw").join("skills")
}
fn project_skill_state_path(cwd: &Path) -> PathBuf {
cwd.join(".picobot").join("skill-state.json")
}
fn user_skills_root() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".picobot").join("skills"))
}
fn user_skill_state_path() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".picobot").join("skill-state.json"))
}
fn user_agent_skills_root() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".agents").join("skills"))
}
fn user_openclaw_skills_root() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".openclaw").join("skills"))
}
fn source_root(source: SkillSource, cwd: &Path) -> Option<PathBuf> {
match source {
SkillSource::User => user_skills_root(),
SkillSource::UserAgent => user_agent_skills_root(),
SkillSource::UserOpenclaw => user_openclaw_skills_root(),
SkillSource::Project => Some(cwd.join(".picobot").join("skills")),
SkillSource::ProjectAgent => Some(project_agent_skills_root(cwd)),
SkillSource::ProjectOpenclaw => Some(project_openclaw_skills_root(cwd)),
}
}
fn root_for_scope(scope: SkillScope) -> Result<PathBuf, String> {
match scope {
SkillScope::User => {
user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string())
}
SkillScope::Project => project_skills_root(),
}
}
fn skill_dir_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
Ok(root_for_scope(scope)?.join(name))
}
fn skill_file_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
Ok(skill_dir_path(scope, name)?.join("SKILL.md"))
}
fn skill_state_path(scope: SkillScope) -> Result<PathBuf, String> {
match scope {
SkillScope::User => user_skill_state_path()
.ok_or_else(|| "failed to resolve home directory".to_string()),
SkillScope::Project => {
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(project_skill_state_path(&cwd))
}
}
}
fn load_skill_disable_state(cwd: &Path) -> SkillDisableState {
SkillDisableState {
user_disabled: user_skill_state_path()
.map(|path| load_disabled_skill_names(&path))
.unwrap_or_default(),
project_disabled: load_disabled_skill_names(&project_skill_state_path(cwd)),
}
}
fn load_disabled_skill_names(path: &Path) -> HashSet<String> {
match load_skill_state_file(path) {
Ok(state) => state
.disabled_skills
.into_iter()
.filter_map(|name| normalize_skill_name(name, path))
.collect(),
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "Failed to load skill state file");
HashSet::new()
}
}
}
fn normalize_skill_name(name: String, path: &Path) -> Option<String> {
let trimmed = name.trim();
match validate_skill_name(trimmed) {
Ok(()) => Some(trimmed.to_string()),
Err(err) => {
tracing::warn!(path = %path.display(), skill = %name, error = %err, "Ignoring invalid disabled skill entry");
None
}
}
}
fn load_skill_state_file(path: &Path) -> Result<SkillStateFile, String> {
if !path.exists() {
return Ok(SkillStateFile::default());
}
let content = fs::read_to_string(path)
.map_err(|err| format!("failed to read skill state file: {}", err))?;
serde_json::from_str(&content)
.map_err(|err| format!("failed to parse skill state file: {}", err))
}
fn save_skill_state_file(path: &Path, state: &SkillStateFile) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create skill state directory: {}", err))?;
}
let content = serde_json::to_string_pretty(state)
.map_err(|err| format!("failed to render skill state file: {}", err))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, format!("{}\n", content))
.map_err(|err| format!("failed to write temporary skill state file: {}", err))?;
// 使用平台抽象的原子重命名
atomic_rename(&tmp_path, path)
.map_err(|err| format!("failed to persist skill state file: {}", err))
}
fn render_skill_file(name: &str, description: &str, body: &str) -> Result<String, String> {
if description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string());
}
#[derive(serde::Serialize)]
struct SkillFrontmatterOwned {
name: String,
description: String,
}
let yaml = serde_yaml::to_string(&SkillFrontmatterOwned {
name: name.to_string(),
description: description.to_string(),
})
.map_err(|err| format!("failed to render skill frontmatter: {}", err))?;
let yaml = yaml.trim_start_matches("---\n");
let body = body.trim();
if body.is_empty() {
Ok(format!("---\n{}---\n", yaml))
} else {
Ok(format!("---\n{}---\n{}\n", yaml, body))
}
}
fn write_skill_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> {
let content = render_skill_file(name, description, body)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create skill directory: {}", err))?;
}
fs::write(path, content).map_err(|err| format!("failed to write skill file: {}", err))
}
fn load_skills_from_root(root: &Path, source: SkillSource) -> Vec<Skill> {
let mut out = Vec::new();
if !root.exists() {
return out;
}
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(err) => {
tracing::warn!(path = %root.display(), error = %err, "Failed to read skills directory");
return out;
}
};
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;
}
match parse_skill_file(&skill_md, source) {
Ok(skill) => out.push(skill),
Err(err) => {
tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file");
}
}
}
out
}
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
description: String,
#[serde(default)]
name: Option<String>,
}
fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter_raw, body) =
split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?;
let frontmatter: SkillFrontmatter = serde_yaml::from_str(frontmatter_raw)
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
let description = frontmatter.description.trim();
if description.is_empty() {
return Err("description is required and cannot be empty".to_string());
}
let dir_name = path
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown-skill".to_string());
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
Ok(Skill {
name,
description: description.to_string(),
body: body.trim().to_string(),
source,
path: path.to_path_buf(),
})
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("---\n")?;
let marker = "\n---\n";
let idx = rest.find(marker)?;
let frontmatter = &rest[..idx];
let body = &rest[idx + marker.len()..];
Some((frontmatter, body))
}
// 使用 platform 模块提供的 xml_escape 和 path_to_uri 函数
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
struct CurrentDirGuard {
previous: PathBuf,
}
struct HomeDirGuard {
previous: Option<OsString>,
previous_userprofile: Option<OsString>,
}
impl CurrentDirGuard {
fn enter(path: &Path) -> Self {
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
Self { previous }
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.previous);
}
}
impl HomeDirGuard {
fn enter(path: &Path) -> Self {
let home_backup = std::env::var_os("HOME");
let userprofile_backup = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", path);
// Windows 环境下同时设置 USERPROFILE
std::env::set_var("USERPROFILE", path);
}
Self {
previous: home_backup,
previous_userprofile: userprofile_backup,
}
}
}
impl Drop for HomeDirGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match &self.previous_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
}
}
}
fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
acquire_skill_test_env_lock()
}
#[test]
fn test_split_frontmatter() {
let input = "---\ndescription: demo\n---\nhello";
let (fm, body) = split_frontmatter(input).unwrap();
assert!(fm.contains("description"));
assert_eq!(body, "hello");
}
#[test]
fn test_parse_skill_file_requires_description() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, "---\nname: demo\n---\ncontent").unwrap();
let err = parse_skill_file(&skill_md, SkillSource::Project).unwrap_err();
assert!(err.contains("description"));
}
#[test]
fn test_activation_payload_contains_body() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(
&skill_md,
"---\nname: demo\ndescription: demo skill\n---\nStep A\nStep B",
)
.unwrap();
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
let catalog = SkillCatalog {
skills: vec![skill],
max_index_chars: 1000,
max_listed_skills: 10,
};
let payload = catalog.activation_payload("demo").unwrap();
assert!(payload.contains("SKILL LOADED: demo"));
assert!(payload.contains("Step A"));
}
#[test]
fn test_system_index_prompt_uses_available_skills_markup() {
// 使用临时目录创建测试路径,确保跨平台兼容
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("demo-skill").join("SKILL.md");
let catalog = SkillCatalog {
skills: vec![Skill {
name: "demo-skill".to_string(),
description: "demo <skill> & usage".to_string(),
body: String::new(),
source: SkillSource::Project,
path: skill_path.clone(),
}],
max_index_chars: 4000,
max_listed_skills: 32,
};
let prompt = catalog.system_index_prompt().unwrap();
assert!(prompt.contains("<available_skills>"));
assert!(prompt.contains("技能为特定任务提供专用说明和工作流。"));
assert!(prompt.contains("<name>demo-skill</name>"));
assert!(prompt.contains("<description>demo &lt;skill&gt; &amp; usage</description>"));
// 验证 location 包含正确的 file:// URI 格式
let expected_uri = path_to_uri(&skill_path);
assert!(prompt.contains(&format!("<location>{}</location>", platform_xml_escape(&expected_uri))));
assert!(prompt.contains("</available_skills>"));
}
#[test]
fn test_path_to_uri() {
// Unix 路径
let unix_path = PathBuf::from("/tmp/demo-skill/SKILL.md");
let unix_uri = path_to_uri(&unix_path);
if cfg!(target_os = "windows") {
// Windows 上运行时,路径可能被转换
assert!(unix_uri.contains("file://"));
} else {
assert_eq!(unix_uri, "file:///tmp/demo-skill/SKILL.md");
}
// Windows 路径格式测试(仅在 Windows 上)
if cfg!(target_os = "windows") {
let win_path = PathBuf::from("C:\\Users\\test\\.picobot\\skills\\demo\\SKILL.md");
let win_uri = path_to_uri(&win_path);
assert!(win_uri.starts_with("file:///C:/"));
assert!(win_uri.contains("/SKILL.md"));
assert!(!win_uri.contains('\\')); // 不应包含反斜杠
}
}
#[test]
fn test_runtime_create_update_delete_reload() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(runtime.len(), 0);
let created = runtime
.create_skill(
SkillScope::Project,
"demo-skill",
"demo desc",
"line 1",
true,
)
.unwrap();
assert_eq!(created.name, "demo-skill");
assert_eq!(runtime.len(), 1);
let updated = runtime
.update_skill(
SkillScope::Project,
"demo-skill",
Some("updated desc"),
Some("line 2"),
true,
)
.unwrap();
assert_eq!(updated.description, "updated desc");
assert!(
runtime
.activation_payload("demo-skill")
.unwrap()
.contains("line 2")
);
let deleted_path = runtime
.delete_skill(SkillScope::Project, "demo-skill", true)
.unwrap();
assert!(!deleted_path.exists());
assert_eq!(runtime.len(), 0);
}
#[test]
fn test_source_order_supports_agent_sources() {
let ordered = source_order(&[
"user".to_string(),
"user_agent".to_string(),
"user_openclaw".to_string(),
"project".to_string(),
"project_agent".to_string(),
"project_openclaw".to_string(),
"project".to_string(),
"unknown".to_string(),
]);
assert_eq!(
ordered,
vec![
SkillSource::User,
SkillSource::UserAgent,
SkillSource::UserOpenclaw,
SkillSource::Project,
SkillSource::ProjectAgent,
SkillSource::ProjectOpenclaw,
]
);
}
#[test]
fn test_discover_loads_project_agent_skills() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let agent_skill_dir = project_dir
.join(".agents")
.join("skills")
.join("demo-agent");
fs::create_dir_all(&agent_skill_dir).unwrap();
fs::write(
agent_skill_dir.join("SKILL.md"),
"---\ndescription: agent skill\n---\nUse agent",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project_agent".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 1);
let payload = catalog.activation_event_payload("demo-agent").unwrap();
assert_eq!(payload["source"], "project_agent");
}
#[test]
fn test_discover_prefers_project_agent_on_conflict() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.join(".picobot").join("skills").join("demo");
fs::create_dir_all(&project_skill_dir).unwrap();
fs::write(
project_skill_dir.join("SKILL.md"),
"---\ndescription: project version\n---\nProject body",
)
.unwrap();
let agent_skill_dir = project_dir.join(".agents").join("skills").join("demo");
fs::create_dir_all(&agent_skill_dir).unwrap();
fs::write(
agent_skill_dir.join("SKILL.md"),
"---\ndescription: agent version\n---\nAgent body",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project".to_string(), "project_agent".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
let payload = catalog.activation_payload("demo").unwrap();
assert!(payload.contains("Source: project_agent"));
assert!(payload.contains("Agent body"));
}
#[test]
fn test_skill_state_file_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("skill-state.json");
let state = SkillStateFile {
disabled_skills: vec!["demo".to_string(), "other".to_string()],
};
save_skill_state_file(&path, &state).unwrap();
let loaded = load_skill_state_file(&path).unwrap();
assert_eq!(loaded, state);
}
#[test]
fn test_discover_filters_disabled_skills_from_sidecar() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.join(".picobot").join("skills").join("demo");
fs::create_dir_all(&project_skill_dir).unwrap();
fs::write(
project_skill_dir.join("SKILL.md"),
"---\ndescription: project version\n---\nProject body",
)
.unwrap();
save_skill_state_file(
&project_dir.join(".picobot").join("skill-state.json"),
&SkillStateFile {
disabled_skills: vec!["demo".to_string()],
},
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 0);
assert!(catalog.activation_payload("demo").is_err());
}
#[test]
fn test_runtime_disable_and_enable_skill_updates_visibility() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.join(".picobot").join("skills").join("demo");
fs::create_dir_all(&project_skill_dir).unwrap();
fs::write(
project_skill_dir.join("SKILL.md"),
"---\ndescription: project version\n---\nProject body",
)
.unwrap();
let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
let disabled = runtime.disable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(disabled.changed);
assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]);
assert!(!disabled.available);
assert!(runtime.get_skill("demo").is_none());
let enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(enabled.changed);
assert!(enabled.disabled_in_scopes.is_empty());
assert!(enabled.available);
assert!(runtime.get_skill("demo").is_some());
}
#[test]
fn test_user_scope_disable_overrides_project_scope_enable() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.join(".picobot").join("skills").join("demo");
fs::create_dir_all(&project_skill_dir).unwrap();
fs::write(
project_skill_dir.join("SKILL.md"),
"---\ndescription: project version\n---\nProject body",
)
.unwrap();
let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
let user_disabled = runtime.disable_skill(SkillScope::User, "demo", true).unwrap();
assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none());
let project_enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(!project_enabled.available);
assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none());
let user_enabled = runtime.enable_skill(SkillScope::User, "demo", true).unwrap();
assert!(user_enabled.available);
assert!(user_enabled.disabled_in_scopes.is_empty());
assert!(runtime.get_skill("demo").is_some());
}
#[test]
fn test_discover_loads_project_openclaw_skills() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let openclaw_skill_dir = project_dir
.join(".openclaw")
.join("skills")
.join("demo-openclaw");
fs::create_dir_all(&openclaw_skill_dir).unwrap();
fs::write(
openclaw_skill_dir.join("SKILL.md"),
"---\ndescription: openclaw skill\n---\nUse openclaw",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project_openclaw".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 1);
let payload = catalog.activation_event_payload("demo-openclaw").unwrap();
assert_eq!(payload["source"], "project_openclaw");
}
#[test]
fn test_discover_loads_user_openclaw_skills() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let user_openclaw_skill_dir = home_dir
.join(".openclaw")
.join("skills")
.join("demo-user-openclaw");
fs::create_dir_all(&user_openclaw_skill_dir).unwrap();
fs::write(
user_openclaw_skill_dir.join("SKILL.md"),
"---\ndescription: user openclaw skill\n---\nUse user openclaw",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["user_openclaw".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 1);
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
assert_eq!(payload["source"], "user_openclaw");
}
}