1353 lines
42 KiB
Rust
1353 lines
42 KiB
Rust
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",
|
||
xml_escape(&skill.name),
|
||
xml_escape(&skill.description),
|
||
xml_escape(&format!("file://{}", skill.path.display())),
|
||
);
|
||
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 home_dir() -> Option<PathBuf> {
|
||
// First check HOME environment variable (useful for testing)
|
||
std::env::var_os("HOME")
|
||
.map(PathBuf::from)
|
||
.or_else(|| dirs::home_dir())
|
||
}
|
||
|
||
fn user_skills_root() -> Option<PathBuf> {
|
||
home_dir().map(|p| p.join(".picobot").join("skills"))
|
||
}
|
||
|
||
fn user_skill_state_path() -> Option<PathBuf> {
|
||
home_dir().map(|p| p.join(".picobot").join("skill-state.json"))
|
||
}
|
||
|
||
fn user_agent_skills_root() -> Option<PathBuf> {
|
||
home_dir().map(|p| p.join(".agents").join("skills"))
|
||
}
|
||
|
||
fn user_openclaw_skills_root() -> Option<PathBuf> {
|
||
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))?;
|
||
fs::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))
|
||
}
|
||
|
||
fn xml_escape(value: &str) -> String {
|
||
value
|
||
.replace('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::ffi::OsString;
|
||
|
||
struct CurrentDirGuard {
|
||
previous: PathBuf,
|
||
}
|
||
|
||
struct HomeDirGuard {
|
||
previous: 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 previous = std::env::var_os("HOME");
|
||
unsafe {
|
||
std::env::set_var("HOME", path);
|
||
}
|
||
Self { previous }
|
||
}
|
||
}
|
||
|
||
impl Drop for HomeDirGuard {
|
||
fn drop(&mut self) {
|
||
match &self.previous {
|
||
Some(value) => unsafe {
|
||
std::env::set_var("HOME", value);
|
||
},
|
||
None => unsafe {
|
||
std::env::remove_var("HOME");
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
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 catalog = SkillCatalog {
|
||
skills: vec![Skill {
|
||
name: "demo-skill".to_string(),
|
||
description: "demo <skill> & usage".to_string(),
|
||
body: String::new(),
|
||
source: SkillSource::Project,
|
||
path: PathBuf::from("/tmp/demo-skill/SKILL.md"),
|
||
}],
|
||
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 <skill> & usage</description>"));
|
||
assert!(prompt.contains("<location>file:///tmp/demo-skill/SKILL.md</location>"));
|
||
assert!(prompt.contains("</available_skills>"));
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|