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::{Arc, 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 { 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 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, } #[derive(Debug, Clone)] pub struct SkillAvailabilityChange { pub name: String, pub scope: SkillScope, pub state_path: PathBuf, pub changed: bool, pub disabled_in_scopes: Vec, 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 { 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 { 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 { self.catalog .read() .expect("skills rwlock poisoned") .activation_payload(name) } pub fn activation_event_payload(&self, name: &str) -> Result { self.catalog .read() .expect("skills rwlock poisoned") .activation_event_payload(name) } pub fn list_skills(&self) -> Vec { self.catalog .read() .expect("skills rwlock poisoned") .skills .clone() } pub fn get_skill(&self, name: &str) -> Option { 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 { 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 { 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 { 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 { self.set_skill_enabled(scope, name, false, reload) } pub fn enable_skill( &self, scope: SkillScope, name: &str, reload: bool, ) -> Result { self.set_skill_enabled(scope, name, true, reload) } pub fn has_skill_definition(&self, name: &str) -> Result { 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 { 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 = state.disabled_skills.into_iter().collect(); let changed = if enabled { disabled.remove(name) } else { disabled.insert(name.to_string()) }; let mut disabled_skills: Vec = 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 { SkillRuntime::system_index_prompt(self) } fn matching_skill_summary(&self, name: &str) -> Option { 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, 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 = 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 = 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 { if self.skills.is_empty() { return None; } let mut prompt = String::from( "技能为特定任务提供专用说明和工作流。\n当任务匹配其描述时,使用 skill_activate 工具加载技能。\n技能不是工具名,即使技能名看起来像工具,也不能直接调用技能名。\n如果需要某个技能,必须先调用 tool skill_activate,并传入 {\"name\": \"\"},再根据返回的技能说明执行。\n\n\n", ); for skill in self.skills.iter().take(self.max_listed_skills) { let entry = format!( " \n {}\n {}\n {}\n \n", platform_xml_escape(&skill.name), platform_xml_escape(&skill.description), platform_xml_escape(&path_to_uri(&skill.path)), ); if prompt.len() + entry.len() + "\n".len() > self.max_index_chars { prompt.push_str(" true\n"); break; } prompt.push_str(&entry); } prompt.push_str("\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 { 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 { 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::>() }) } } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] struct SkillStateFile { #[serde(default)] disabled_skills: Vec, } #[derive(Debug, Clone, Default)] struct SkillDisableState { user_disabled: HashSet, project_disabled: HashSet, } 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 { 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 { 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 { 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 { platform_home_dir().map(|p| p.join(".picobot").join("skills")) } fn user_skill_state_path() -> Option { platform_home_dir().map(|p| p.join(".picobot").join("skill-state.json")) } fn user_agent_skills_root() -> Option { platform_home_dir().map(|p| p.join(".agents").join("skills")) } fn user_openclaw_skills_root() -> Option { platform_home_dir().map(|p| p.join(".openclaw").join("skills")) } fn source_root(source: SkillSource, cwd: &Path) -> Option { 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 { 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 { Ok(root_for_scope(scope)?.join(name)) } fn skill_file_path(scope: SkillScope, name: &str) -> Result { Ok(skill_dir_path(scope, name)?.join("SKILL.md")) } fn skill_state_path(scope: SkillScope) -> Result { 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 { 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 { 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 { 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 { 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 { 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, } fn parse_skill_file(path: &Path, source: SkillSource) -> Result { 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 函数 // SkillPromptProvider 实现 use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; /// Skill 提示词提供者 /// /// 负责提供技能的系统索引提示词(system_index_prompt)。 pub struct SkillPromptProvider { skills: Arc, } impl SkillPromptProvider { /// 创建新的 Skill 提示词提供者 pub fn new(skills: Arc) -> Self { Self { skills } } } impl SystemPromptProvider for SkillPromptProvider { fn build(&self, _context: &SystemPromptContext) -> Option { // 调用 SkillRuntime 的 system_index_prompt 方法 self.skills.system_index_prompt().map(|content| SystemPrompt { content, context: Some("skill_index".to_string()), }) } } #[cfg(test)] mod tests { use super::*; use std::ffi::OsString; struct CurrentDirGuard { previous: PathBuf, } struct HomeDirGuard { previous: Option, previous_userprofile: Option, } 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 & 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("")); assert!(prompt.contains("技能为特定任务提供专用说明和工作流。")); assert!(prompt.contains("demo-skill")); assert!(prompt.contains("demo <skill> & usage")); // 验证 location 包含正确的 file:// URI 格式 let expected_uri = path_to_uri(&skill_path); assert!(prompt.contains(&format!("{}", platform_xml_escape(&expected_uri)))); assert!(prompt.contains("")); } #[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"); } }