diff --git a/src/config/mod.rs b/src/config/mod.rs index 0ecfe4f..9969173 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -69,7 +69,12 @@ fn default_skills_enabled() -> bool { } fn default_skills_sources() -> Vec { - vec!["project".to_string(), "user".to_string()] + vec![ + "user".to_string(), + "user_agent".to_string(), + "project".to_string(), + "project_agent".to_string(), + ] } fn default_skills_max_index_chars() -> usize { @@ -778,6 +783,21 @@ mod tests { assert_eq!(provider_config.llm_timeout_secs, 400); } + #[test] + fn test_default_skills_sources_include_agent_directories() { + let config = SkillsConfig::default(); + + assert_eq!( + config.sources, + vec![ + "user".to_string(), + "user_agent".to_string(), + "project".to_string(), + "project_agent".to_string(), + ] + ); + } + #[test] fn test_default_gateway_config() { let file = write_test_config(); diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 2367b2a..a2d79b3 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -20,7 +20,9 @@ pub struct Skill { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillSource { User, + UserAgent, Project, + ProjectAgent, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -197,7 +199,9 @@ impl SkillSource { fn as_str(&self) -> &'static str { match self { SkillSource::User => "user", + SkillSource::UserAgent => "user_agent", SkillSource::Project => "project", + SkillSource::ProjectAgent => "project_agent", } } } @@ -233,13 +237,10 @@ impl SkillCatalog { let mut merged: HashMap = HashMap::new(); let mut sources_seen = 0usize; - // Load user first, then project. Project wins on conflicts. + // 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 = match source { - SkillSource::User => user_skills_root(), - SkillSource::Project => Some(cwd.join(".picobot").join("skills")), - }; + let root = source_root(source, &cwd); let Some(root) = root else { continue }; for skill in load_skills_from_root(&root, source) { @@ -285,7 +286,7 @@ impl SkillCatalog { } let mut prompt = String::from( - "You have access to project skills. 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\": \"\"}.\nAvailable skills:\n", + "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\": \"\"}.\nAvailable skills:\n", ); for skill in self.skills.iter().take(self.max_listed_skills) { @@ -394,11 +395,21 @@ fn source_order(sources: &[String]) -> Vec { 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"); } @@ -406,7 +417,12 @@ fn source_order(sources: &[String]) -> Vec { } if result.is_empty() { - vec![SkillSource::User, SkillSource::Project] + vec![ + SkillSource::User, + SkillSource::UserAgent, + SkillSource::Project, + SkillSource::ProjectAgent, + ] } else { result } @@ -427,10 +443,27 @@ pub fn project_skills_root() -> Result { Ok(cwd.join(".picobot").join("skills")) } +fn project_agent_skills_root(cwd: &Path) -> PathBuf { + cwd.join(".agents").join("skills") +} + fn user_skills_root() -> Option { dirs::home_dir().map(|p| p.join(".picobot").join("skills")) } +fn user_agent_skills_root() -> Option { + dirs::home_dir().map(|p| p.join(".agents").join("skills")) +} + +fn source_root(source: SkillSource, cwd: &Path) -> Option { + 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 { match scope { SkillScope::User => user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string()), @@ -570,6 +603,27 @@ fn split_frontmatter(content: &str) -> Option<(&str, &str)> { #[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() { @@ -639,9 +693,9 @@ mod tests { #[test] fn test_runtime_create_update_delete_reload() { + let _lock = CWD_TEST_LOCK.lock().unwrap(); let temp_dir = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp_dir.path()).unwrap(); + let _guard = CurrentDirGuard::enter(temp_dir.path()); let runtime = SkillRuntime::from_config(SkillsConfig { enabled: true, @@ -675,7 +729,87 @@ mod tests { .unwrap(); assert!(!deleted_path.exists()); assert_eq!(runtime.len(), 0); + } - std::env::set_current_dir(previous).unwrap(); + #[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")); } } diff --git a/src/tools/skill_manage.rs b/src/tools/skill_manage.rs index 64e0478..0b162f5 100644 --- a/src/tools/skill_manage.rs +++ b/src/tools/skill_manage.rs @@ -32,7 +32,7 @@ impl Tool for SkillManageTool { } fn description(&self) -> &str { - "Manage PicoBot skills stored under .picobot/skills or ~/.picobot/skills. Supports actions: list, get, create, update, delete, reload." + "Manage PicoBot skills stored under .picobot/skills or ~/.picobot/skills, while discovery also reads .agents/skills and ~/.agents/skills. Supports actions: list, get, create, update, delete, reload." } fn parameters_schema(&self) -> serde_json::Value { @@ -47,7 +47,7 @@ impl Tool for SkillManageTool { "scope": { "type": "string", "enum": ["project", "user"], - "description": "Skill scope for create/update/delete. Defaults to project." + "description": "Writable skill scope for create/update/delete. Defaults to project. .agents discovery sources are read-only here." }, "name": { "type": "string", @@ -83,7 +83,7 @@ impl Tool for SkillManageTool { let scope = match args.get("scope").and_then(|v| v.as_str()) { Some(value) => match SkillScope::parse(value) { Some(scope) => scope, - None => return Ok(error_result("scope must be 'project' or 'user'")), + None => return Ok(error_result("scope must be 'project' or 'user'; .agents sources are discovery-only")), }, None => SkillScope::Project, }; @@ -107,7 +107,9 @@ impl Tool for SkillManageTool { "body": skill.body, "source": match skill.source { crate::skills::SkillSource::User => "user", + crate::skills::SkillSource::UserAgent => "user_agent", crate::skills::SkillSource::Project => "project", + crate::skills::SkillSource::ProjectAgent => "project_agent", }, "path": skill.path.display().to_string(), }), @@ -241,7 +243,9 @@ fn list_skills_payload(skills: &Arc) -> serde_json::Value { "description": skill.description, "source": match skill.source { crate::skills::SkillSource::User => "user", + crate::skills::SkillSource::UserAgent => "user_agent", crate::skills::SkillSource::Project => "project", + crate::skills::SkillSource::ProjectAgent => "project_agent", }, "path": skill.path.display().to_string(), })).collect::>()