817 lines
24 KiB
Rust
817 lines
24 KiB
Rust
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::RwLock;
|
|
|
|
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,
|
|
Project,
|
|
ProjectAgent,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
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>,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
impl crate::agent::SkillProvider for SkillRuntime {
|
|
fn system_index_prompt(&self) -> Option<String> {
|
|
SkillRuntime::system_index_prompt(self)
|
|
}
|
|
}
|
|
|
|
impl SkillSource {
|
|
fn as_str(&self) -> &'static str {
|
|
match self {
|
|
SkillSource::User => "user",
|
|
SkillSource::UserAgent => "user_agent",
|
|
SkillSource::Project => "project",
|
|
SkillSource::ProjectAgent => "project_agent",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 {
|
|
if !config.enabled {
|
|
return Self {
|
|
max_index_chars: config.max_index_chars,
|
|
max_listed_skills: config.max_listed_skills,
|
|
..Self::default()
|
|
};
|
|
}
|
|
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
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();
|
|
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(
|
|
"You have access to skills discovered from local skill directories. Use a skill only when the user's request clearly matches the skill description.\nIf a skill is needed, call tool skill_activate with {\"name\": \"<skill-name>\"}.\nAvailable skills:\n",
|
|
);
|
|
|
|
for skill in self.skills.iter().take(self.max_listed_skills) {
|
|
let line = format!("- {}: {}\n", skill.name, skill.description);
|
|
if prompt.len() + line.len() > self.max_index_chars {
|
|
prompt.push_str("- ... (truncated)\n");
|
|
break;
|
|
}
|
|
prompt.push_str(&line);
|
|
}
|
|
|
|
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<_>>()
|
|
})
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
"project" => {
|
|
if !result.contains(&SkillSource::Project) {
|
|
result.push(SkillSource::Project);
|
|
}
|
|
}
|
|
"project_agent" => {
|
|
if !result.contains(&SkillSource::ProjectAgent) {
|
|
result.push(SkillSource::ProjectAgent);
|
|
}
|
|
}
|
|
unknown => {
|
|
tracing::warn!(source = %unknown, "Unknown skills source ignored");
|
|
}
|
|
}
|
|
}
|
|
|
|
if result.is_empty() {
|
|
vec![
|
|
SkillSource::User,
|
|
SkillSource::UserAgent,
|
|
SkillSource::Project,
|
|
SkillSource::ProjectAgent,
|
|
]
|
|
} 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 user_skills_root() -> Option<PathBuf> {
|
|
dirs::home_dir().map(|p| p.join(".picobot").join("skills"))
|
|
}
|
|
|
|
fn user_agent_skills_root() -> Option<PathBuf> {
|
|
dirs::home_dir().map(|p| p.join(".agents").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::Project => Some(cwd.join(".picobot").join("skills")),
|
|
SkillSource::ProjectAgent => Some(project_agent_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 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))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::Mutex;
|
|
|
|
static CWD_TEST_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
struct CurrentDirGuard {
|
|
previous: PathBuf,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
#[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_runtime_create_update_delete_reload() {
|
|
let _lock = CWD_TEST_LOCK.lock().unwrap();
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let _guard = CurrentDirGuard::enter(temp_dir.path());
|
|
|
|
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(),
|
|
"project".to_string(),
|
|
"project_agent".to_string(),
|
|
"project".to_string(),
|
|
"unknown".to_string(),
|
|
]);
|
|
|
|
assert_eq!(
|
|
ordered,
|
|
vec![
|
|
SkillSource::User,
|
|
SkillSource::UserAgent,
|
|
SkillSource::Project,
|
|
SkillSource::ProjectAgent,
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_discover_loads_project_agent_skills() {
|
|
let _lock = CWD_TEST_LOCK.lock().unwrap();
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let _guard = CurrentDirGuard::enter(temp_dir.path());
|
|
|
|
let agent_skill_dir = temp_dir
|
|
.path()
|
|
.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 = CWD_TEST_LOCK.lock().unwrap();
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let _guard = CurrentDirGuard::enter(temp_dir.path());
|
|
|
|
let project_skill_dir = temp_dir.path().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 = temp_dir.path().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"));
|
|
}
|
|
}
|