diff --git a/Cargo.lock b/Cargo.lock index 9934cb01f..812c87749 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "bollard", "chrono", "clap", + "dirs", "dotenvy", "futures", "glob", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 9943dafb0..f126dae6f 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -36,6 +36,7 @@ async-trait.workspace = true jsonschema.workspace = true chrono.workspace = true tokio-util.workspace = true +dirs = "6" glob = "0.3" bollard = { workspace = true, optional = true } tar = { workspace = true, optional = true } diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index 7980e5572..87763d5c7 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -39,6 +39,10 @@ struct Cli { /// Print full LLM request/response JSON to stderr #[arg(long)] verbose: bool, + + /// Directory containing skill files (overrides default discovery) + #[arg(long)] + skills_dir: Option, } #[derive(Clone, Copy, Debug, ValueEnum)] @@ -330,6 +334,7 @@ pub async fn run() -> anyhow::Result<()> { let config = SessionConfig { tool_approval: Some(tool_approval), + skill_dirs: cli.skills_dir.map(|d| vec![d]), ..SessionConfig::default() }; diff --git a/crates/agent/src/config.rs b/crates/agent/src/config.rs index 74a17d0b7..f1614dfb1 100644 --- a/crates/agent/src/config.rs +++ b/crates/agent/src/config.rs @@ -24,6 +24,8 @@ pub struct SessionConfig { pub enable_context_compaction: bool, pub compaction_threshold_percent: usize, pub compaction_preserve_turns: usize, + /// Skill directories. `None` = use convention defaults, `Some(dirs)` = use these instead. + pub skill_dirs: Option>, } impl std::fmt::Debug for SessionConfig { @@ -54,6 +56,7 @@ impl std::fmt::Debug for SessionConfig { .field("enable_context_compaction", &self.enable_context_compaction) .field("compaction_threshold_percent", &self.compaction_threshold_percent) .field("compaction_preserve_turns", &self.compaction_preserve_turns) + .field("skill_dirs", &self.skill_dirs) .finish() } } @@ -77,6 +80,7 @@ impl Default for SessionConfig { enable_context_compaction: true, compaction_threshold_percent: 80, compaction_preserve_turns: 6, + skill_dirs: None, } } } diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 9efc956f1..ae820a2c9 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -12,6 +12,7 @@ pub mod loop_detection; pub mod profiles; pub mod project_docs; pub mod read_before_write_env; +pub mod skills; pub mod provider_profile; pub mod session; pub mod subagent; @@ -31,6 +32,7 @@ pub use local_env::LocalExecutionEnvironment; pub use loop_detection::detect_loop; pub use read_before_write_env::ReadBeforeWriteEnvironment; pub use project_docs::discover_project_docs; +pub use skills::Skill; pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile}; pub use provider_profile::{ProfileCapabilities, ProviderProfile}; pub use session::Session; diff --git a/crates/agent/src/profiles/anthropic.rs b/crates/agent/src/profiles/anthropic.rs index 15e090228..c3fefb10e 100644 --- a/crates/agent/src/profiles/anthropic.rs +++ b/crates/agent/src/profiles/anthropic.rs @@ -3,6 +3,7 @@ use crate::execution_env::ExecutionEnvironment; use crate::profiles::assemble_system_prompt; use crate::profiles::BaseProfile; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; +use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{ make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool, @@ -64,6 +65,7 @@ impl ProviderProfile for AnthropicProfile { env_context: &EnvContext, project_docs: &[String], user_instructions: Option<&str>, + skills: &[Skill], ) -> String { let core_prompt = "\ You are Claude, an AI coding assistant made by Anthropic. You help users with software \ @@ -134,7 +136,7 @@ finding files rather than using shell find or ls commands. Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ in the project. Keep changes minimal and focused on the task."; - assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions) + assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions, skills) } fn capabilities(&self) -> ProfileCapabilities { @@ -207,7 +209,7 @@ mod tests { fn anthropic_system_prompt_contains_env_context() { let profile = AnthropicProfile::new("claude-sonnet-4-20250514"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("You are Claude, an AI coding assistant made by Anthropic")); assert!(prompt.contains("")); assert!(prompt.contains("linux")); @@ -241,7 +243,7 @@ mod tests { let profile = AnthropicProfile::new("claude-sonnet-4-20250514"); let env = MockExecutionEnvironment::linux(); let docs = vec!["# Project README".into(), "# CONTRIBUTING guide".into()]; - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None, &[]); assert!(prompt.contains("# Project README")); assert!(prompt.contains("# CONTRIBUTING guide")); } @@ -259,7 +261,7 @@ mod tests { git_status_short: None, git_recent_commits: None, }; - let prompt = profile.build_system_prompt(&env, &ctx, &[], None); + let prompt = profile.build_system_prompt(&env, &ctx, &[], None, &[]); assert!(prompt.contains("Git branch: feature-branch")); assert!(prompt.contains("Is git repository: true")); assert!(prompt.contains("Today's date: 2026-02-20")); @@ -272,7 +274,7 @@ mod tests { let profile = AnthropicProfile::new("claude-opus-4-6"); let env = MockExecutionEnvironment::linux(); let ctx = EnvContext::default(); - let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always write tests first")); + let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always write tests first"), &[]); assert!(prompt.contains("Always write tests first")); assert!(prompt.contains("# User Instructions")); } diff --git a/crates/agent/src/profiles/gemini.rs b/crates/agent/src/profiles/gemini.rs index 359dfa87c..d921a93f9 100644 --- a/crates/agent/src/profiles/gemini.rs +++ b/crates/agent/src/profiles/gemini.rs @@ -2,6 +2,7 @@ use crate::execution_env::ExecutionEnvironment; use crate::profiles::assemble_system_prompt; use crate::profiles::BaseProfile; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; +use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{ make_edit_file_tool, make_glob_tool, make_grep_tool, make_list_dir_tool, @@ -64,6 +65,7 @@ impl ProviderProfile for GeminiProfile { env_context: &EnvContext, project_docs: &[String], user_instructions: Option<&str>, + skills: &[Skill], ) -> String { let core_prompt = "\ You are Gemini CLI, an interactive CLI agent specializing in software engineering tasks \ @@ -181,7 +183,7 @@ These are foundational mandates that take precedence over defaults in this promp Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ in the project."; - assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions) + assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions, skills) } fn capabilities(&self) -> ProfileCapabilities { @@ -235,7 +237,7 @@ mod tests { fn gemini_system_prompt_contains_identity() { let profile = GeminiProfile::new("gemini-2.0-flash"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("You are Gemini CLI")); assert!(prompt.contains("solving bugs")); assert!(prompt.contains("adding new functionality")); @@ -247,7 +249,7 @@ mod tests { fn gemini_system_prompt_contains_tool_guidance() { let profile = GeminiProfile::new("gemini-2.0-flash"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("read_file")); assert!(prompt.contains("read_many_files")); assert!(prompt.contains("edit_file")); @@ -265,7 +267,7 @@ mod tests { fn gemini_system_prompt_contains_project_docs_convention() { let profile = GeminiProfile::new("gemini-2.0-flash"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("GEMINI.md")); assert!(prompt.contains("AGENTS.md")); } @@ -274,7 +276,7 @@ mod tests { fn gemini_system_prompt_contains_coding_best_practices() { let profile = GeminiProfile::new("gemini-2.0-flash"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("clean, maintainable code")); assert!(prompt.contains("Handle errors appropriately")); assert!(prompt.contains("existing code conventions")); @@ -284,7 +286,7 @@ mod tests { fn gemini_system_prompt_contains_env_context() { let profile = GeminiProfile::new("gemini-2.0-flash"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("")); assert!(prompt.contains("linux")); } diff --git a/crates/agent/src/profiles/mod.rs b/crates/agent/src/profiles/mod.rs index b00c0cd04..d16765951 100644 --- a/crates/agent/src/profiles/mod.rs +++ b/crates/agent/src/profiles/mod.rs @@ -7,6 +7,7 @@ pub use gemini::GeminiProfile; pub use openai::OpenAiProfile; use crate::execution_env::ExecutionEnvironment; +use crate::skills::{format_skills_prompt_section, Skill}; use crate::tool_registry::ToolRegistry; /// Common fields shared by all provider profiles. @@ -42,6 +43,7 @@ pub fn assemble_system_prompt( env_context: &EnvContext, project_docs: &[String], user_instructions: Option<&str>, + skills: &[Skill], ) -> String { let env_block = build_env_context_block_with(env, env_context); let docs_section = if project_docs.is_empty() { @@ -49,13 +51,21 @@ pub fn assemble_system_prompt( } else { format!("\n\n{}", project_docs.join("\n\n")) }; + let skills_section = { + let s = format_skills_prompt_section(skills); + if s.is_empty() { + String::new() + } else { + format!("\n\n{s}") + } + }; let user_section = match user_instructions { Some(instructions) => format!("\n\n# User Instructions\n{instructions}"), None => String::new(), }; let prompt = core_prompt.replace("{env_block}", &env_block); - format!("{prompt}{docs_section}{user_section}") + format!("{prompt}{docs_section}{skills_section}{user_section}") } #[cfg(test)] diff --git a/crates/agent/src/profiles/openai.rs b/crates/agent/src/profiles/openai.rs index 44fa8fca2..f3b1a659c 100644 --- a/crates/agent/src/profiles/openai.rs +++ b/crates/agent/src/profiles/openai.rs @@ -2,6 +2,7 @@ use crate::execution_env::ExecutionEnvironment; use crate::profiles::assemble_system_prompt; use crate::profiles::BaseProfile; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; +use crate::skills::Skill; use crate::tool_registry::{RegisteredTool, ToolRegistry}; use llm::types::ToolDefinition; use crate::tools::{ @@ -66,6 +67,7 @@ impl ProviderProfile for OpenAiProfile { env_context: &EnvContext, project_docs: &[String], user_instructions: Option<&str>, + skills: &[Skill], ) -> String { let core_prompt = "\ You are a coding agent powered by OpenAI, running in a terminal-based agentic coding assistant. \ @@ -147,7 +149,7 @@ Find files by name pattern. Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions \ in the project."; - assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions) + assemble_system_prompt(core_prompt, env, env_context, project_docs, user_instructions, skills) } fn capabilities(&self) -> ProfileCapabilities { @@ -442,7 +444,7 @@ mod tests { fn openai_system_prompt_contains_env_context() { let profile = OpenAiProfile::new("o3-mini"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("You are a coding agent powered by OpenAI")); assert!(prompt.contains("")); assert!(prompt.contains("linux")); @@ -454,7 +456,7 @@ mod tests { fn openai_system_prompt_contains_tool_guidance() { let profile = OpenAiProfile::new("o3-mini"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("read_file")); assert!(prompt.contains("apply_patch")); assert!(prompt.contains("write_file")); @@ -468,7 +470,7 @@ mod tests { fn openai_system_prompt_contains_coding_best_practices() { let profile = OpenAiProfile::new("o3-mini"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); assert!(prompt.contains("clean, maintainable code")); assert!(prompt.contains("existing code conventions")); } @@ -478,7 +480,7 @@ mod tests { let profile = OpenAiProfile::new("o3-mini"); let env = MockExecutionEnvironment::linux(); let docs = vec!["# Project README".into(), "# CONTRIBUTING guide".into()]; - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None, &[]); assert!(prompt.contains("# Project README")); assert!(prompt.contains("# CONTRIBUTING guide")); } @@ -487,7 +489,7 @@ mod tests { fn openai_system_prompt_includes_user_instructions() { let profile = OpenAiProfile::new("o3-mini"); let env = MockExecutionEnvironment::linux(); - let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], Some("Always write tests first")); + let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], Some("Always write tests first"), &[]); assert!(prompt.contains("Always write tests first")); assert!(prompt.contains("# User Instructions")); } diff --git a/crates/agent/src/provider_profile.rs b/crates/agent/src/provider_profile.rs index 9c2e8b42a..8cb3a1388 100644 --- a/crates/agent/src/provider_profile.rs +++ b/crates/agent/src/provider_profile.rs @@ -1,5 +1,6 @@ use crate::execution_env::ExecutionEnvironment; use crate::profiles::EnvContext; +use crate::skills::Skill; use crate::subagent::{ make_close_agent_tool, make_send_input_tool, make_spawn_agent_tool, SessionFactory, SubAgentManager, @@ -27,6 +28,7 @@ pub trait ProviderProfile: Send + Sync { env_context: &EnvContext, project_docs: &[String], user_instructions: Option<&str>, + skills: &[Skill], ) -> String; fn capabilities(&self) -> ProfileCapabilities; fn knowledge_cutoff(&self) -> &str; @@ -102,7 +104,7 @@ mod tests { let env = MockExecutionEnvironment::linux(); let ctx = EnvContext::default(); let docs = vec!["README.md contents".into()]; - let prompt = profile.build_system_prompt(&env, &ctx, &docs, None); + let prompt = profile.build_system_prompt(&env, &ctx, &docs, None, &[]); assert!(prompt.contains("test assistant")); } @@ -111,7 +113,7 @@ mod tests { let profile = TestProfile::new(); let env = MockExecutionEnvironment::default(); let ctx = EnvContext::default(); - let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always use TDD")); + let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always use TDD"), &[]); assert!(prompt.contains("Always use TDD")); } diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index 64dda6c4b..f70d7d197 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -7,6 +7,7 @@ use crate::loop_detection::detect_loop; use crate::profiles::EnvContext; use crate::project_docs::discover_project_docs; use crate::provider_profile::ProviderProfile; +use crate::skills::{default_skill_dirs, discover_skills, expand_skill, Skill}; use crate::tool_registry::ToolRegistry; use crate::truncation::truncate_tool_output; use crate::types::{AgentEvent, SessionState, Turn}; @@ -34,6 +35,7 @@ pub struct Session { cancel_token: CancellationToken, project_docs: Vec, env_context: EnvContext, + skills: Vec, } impl Session { @@ -58,6 +60,7 @@ impl Session { cancel_token: CancellationToken::new(), project_docs: Vec::new(), env_context: EnvContext::default(), + skills: Vec::new(), } } @@ -80,6 +83,16 @@ impl Session { ) .await; + // Discover skills + let skill_dirs = match &self.config.skill_dirs { + Some(dirs) => dirs.clone(), + None => { + let home = dirs::home_dir().map(|p| p.to_string_lossy().to_string()); + default_skill_dirs(home.as_deref(), self.config.git_root.as_deref()) + } + }; + self.skills = discover_skills(self.execution_env.as_ref(), &skill_dirs).await; + // Populate environment context self.env_context = self.build_env_context().await; } @@ -222,9 +235,17 @@ impl Session { self.state = SessionState::Processing; + // Expand skill references in input + let expanded_input = if self.skills.is_empty() { + input.to_string() + } else { + expand_skill(&self.skills, input) + .map_err(AgentError::InvalidState)? + }; + // Append user turn and emit event self.history.push(Turn::User { - content: input.to_string(), + content: expanded_input.clone(), timestamp: SystemTime::now(), }); self.event_emitter @@ -239,6 +260,7 @@ impl Session { &self.env_context, &self.project_docs, self.config.user_instructions.as_deref(), + &self.skills, ); let mut round_count: usize = 0; diff --git a/crates/agent/src/skills.rs b/crates/agent/src/skills.rs new file mode 100644 index 000000000..33eed34d0 --- /dev/null +++ b/crates/agent/src/skills.rs @@ -0,0 +1,474 @@ +use crate::execution_env::ExecutionEnvironment; + +#[derive(Debug, Clone)] +pub struct Skill { + pub name: String, + pub description: String, + pub template: String, +} + +pub fn parse_skill(content: &str) -> Result { + let trimmed = content.trim(); + if !trimmed.starts_with("---") { + return Err("Missing YAML frontmatter delimiters".into()); + } + + let after_first = &trimmed[3..]; + let end_idx = after_first + .find("\n---") + .ok_or("Missing closing frontmatter delimiter")?; + let frontmatter = &after_first[..end_idx]; + let body = &after_first[end_idx + 4..]; + + let mut name: Option = None; + let mut description = String::new(); + + for line in frontmatter.lines() { + let line = line.trim(); + if let Some(val) = line.strip_prefix("name:") { + name = Some(val.trim().to_string()); + } else if let Some(val) = line.strip_prefix("description:") { + description = val.trim().to_string(); + } + } + + let name = name.ok_or("Missing required 'name' field in frontmatter")?; + let template = body.trim().to_string(); + + Ok(Skill { + name, + description, + template, + }) +} + +/// A detected skill reference in user input: the name and byte range of the `/name` token. +struct SkillMatch { + name: String, + /// Byte offset of the `/` character + start: usize, + /// Byte offset just past the skill name + end: usize, +} + +fn is_skill_name_char(c: char) -> bool { + c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' +} + +/// Find all `/skill-name` tokens in input where the `/` is preceded by whitespace (or +/// start-of-string) and the name is followed by whitespace (or end-of-string). +fn find_skill_references(input: &str) -> Vec { + let mut results = Vec::new(); + let bytes = input.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + if bytes[i] == b'/' { + // Check that preceding char is whitespace or this is start of string + let preceded_by_boundary = i == 0 || bytes[i - 1].is_ascii_whitespace(); + if !preceded_by_boundary { + i += 1; + continue; + } + + // The first char after `/` must be a lowercase letter + let name_start = i + 1; + if name_start >= len || !bytes[name_start].is_ascii_lowercase() { + i += 1; + continue; + } + + // Consume the rest of the name + let mut j = name_start + 1; + while j < len && is_skill_name_char(bytes[j] as char) { + j += 1; + } + + // Check that following char is whitespace or end of string + let followed_by_boundary = j >= len || bytes[j].is_ascii_whitespace(); + if followed_by_boundary { + results.push(SkillMatch { + name: input[name_start..j].to_string(), + start: i, + end: j, + }); + } + + i = j; + } else { + i += 1; + } + } + + results +} + +pub fn expand_skill(skills: &[Skill], input: &str) -> Result { + let refs = find_skill_references(input); + + if refs.is_empty() { + return Ok(input.to_string()); + } + + if refs.len() > 1 { + return Err("Only one skill reference per input is allowed".into()); + } + + let skill_ref = &refs[0]; + + let skill = skills + .iter() + .find(|s| s.name == skill_ref.name) + .ok_or_else(|| format!("Unknown skill: /{}", skill_ref.name))?; + + // Remove the /skill-name token from input to get user_input + let before = &input[..skill_ref.start]; + let after = &input[skill_ref.end..]; + let user_input = format!("{before}{after}").trim().to_string(); + + if skill.template.contains("{{user_input}}") { + Ok(skill.template.replace("{{user_input}}", &user_input)) + } else { + Ok(skill.template.clone()) + } +} + +pub fn format_skills_prompt_section(skills: &[Skill]) -> String { + if skills.is_empty() { + return String::new(); + } + + let mut lines = vec!["# Available Skills".to_string()]; + for skill in skills { + if skill.description.is_empty() { + lines.push(format!("- /{}", skill.name)); + } else { + lines.push(format!("- /{}: {}", skill.name, skill.description)); + } + } + lines.join("\n") +} + +pub fn default_skill_dirs(home_dir: Option<&str>, git_root: Option<&str>) -> Vec { + let mut dirs = Vec::new(); + + if let Some(home) = home_dir { + dirs.push(format!("{home}/.attractor/skills")); + } + + if let Some(root) = git_root { + dirs.push(format!("{root}/.attractor/skills")); + dirs.push(format!("{root}/skills")); + } + + dirs +} + +pub async fn discover_skills( + env: &dyn ExecutionEnvironment, + dirs: &[String], +) -> Vec { + let mut skills_by_name: std::collections::HashMap = + std::collections::HashMap::new(); + + for dir in dirs { + let paths = match env.glob("*.md", Some(dir)).await { + Ok(paths) => paths, + Err(_) => continue, + }; + + for path in paths { + let content = match env.read_file(&path, None, None).await { + Ok(c) => c, + Err(_) => continue, + }; + + match parse_skill(&content) { + Ok(skill) => { + skills_by_name.insert(skill.name.clone(), skill); + } + Err(_) => continue, + } + } + } + + let mut skills: Vec = skills_by_name.into_values().collect(); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + skills +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::MockExecutionEnvironment; + use std::collections::HashMap; + + // --- parse_skill tests --- + + #[test] + fn parse_skill_basic() { + let content = "\ +--- +name: commit +description: Create a git commit following best practices +--- + +Review staged and unstaged changes, then create a well-crafted commit. + +{{user_input}}"; + + let skill = parse_skill(content).unwrap(); + assert_eq!(skill.name, "commit"); + assert_eq!( + skill.description, + "Create a git commit following best practices" + ); + assert!(skill.template.contains("Review staged")); + assert!(skill.template.contains("{{user_input}}")); + } + + #[test] + fn parse_skill_no_frontmatter() { + let content = "Just some markdown without frontmatter"; + let result = parse_skill(content); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("frontmatter")); + } + + #[test] + fn parse_skill_missing_name() { + let content = "\ +--- +description: A skill without a name +--- + +Some template"; + + let result = parse_skill(content); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn parse_skill_description_optional() { + let content = "\ +--- +name: simple +--- + +Just a template"; + + let skill = parse_skill(content).unwrap(); + assert_eq!(skill.name, "simple"); + assert_eq!(skill.description, ""); + assert_eq!(skill.template, "Just a template"); + } + + #[test] + fn parse_skill_trims_template() { + let content = "\ +--- +name: trimmed +--- + + + Body with leading/trailing whitespace + + +"; + + let skill = parse_skill(content).unwrap(); + assert_eq!(skill.template, "Body with leading/trailing whitespace"); + } + + // --- expand_skill tests --- + + fn test_skills() -> Vec { + vec![ + Skill { + name: "commit".into(), + description: "Create a commit".into(), + template: "Review changes and commit.\n\n{{user_input}}".into(), + }, + Skill { + name: "test".into(), + description: "Run tests".into(), + template: "Run the test suite.".into(), + }, + ] + } + + #[test] + fn expand_no_skill_reference() { + let skills = test_skills(); + let result = expand_skill(&skills, "just some plain text").unwrap(); + assert_eq!(result, "just some plain text"); + } + + #[test] + fn expand_skill_at_start() { + let skills = test_skills(); + let result = expand_skill(&skills, "/commit do the thing").unwrap(); + assert_eq!(result, "Review changes and commit.\n\ndo the thing"); + } + + #[test] + fn expand_skill_mid_line() { + let skills = test_skills(); + let result = expand_skill(&skills, "please /commit the auth changes").unwrap(); + assert_eq!( + result, + "Review changes and commit.\n\nplease the auth changes" + ); + } + + #[test] + fn expand_skill_alone() { + let skills = test_skills(); + let result = expand_skill(&skills, "/commit").unwrap(); + assert_eq!(result, "Review changes and commit.\n\n"); + } + + #[test] + fn expand_unknown_skill() { + let skills = test_skills(); + let result = expand_skill(&skills, "/nonexistent"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown skill")); + } + + #[test] + fn expand_does_not_match_paths() { + let skills = test_skills(); + let result = expand_skill(&skills, "/usr/bin/bash").unwrap(); + assert_eq!(result, "/usr/bin/bash"); + } + + #[test] + fn expand_multiple_skills_errors() { + let skills = test_skills(); + let result = expand_skill(&skills, "/commit and /test"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Only one skill")); + } + + #[test] + fn expand_template_without_placeholder() { + let skills = test_skills(); + let result = expand_skill(&skills, "/test please run").unwrap(); + assert_eq!(result, "Run the test suite."); + } + + // --- format_skills_prompt_section tests --- + + #[test] + fn format_empty() { + assert_eq!(format_skills_prompt_section(&[]), ""); + } + + #[test] + fn format_lists_skills() { + let skills = test_skills(); + let section = format_skills_prompt_section(&skills); + assert!(section.contains("# Available Skills")); + assert!(section.contains("- /commit: Create a commit")); + assert!(section.contains("- /test: Run tests")); + } + + // --- discover_skills tests --- + + #[tokio::test] + async fn discover_loads_files() { + let mut files = HashMap::new(); + files.insert( + "/skills/commit.md".into(), + "---\nname: commit\ndescription: Make a commit\n---\nDo commit".into(), + ); + let env = MockExecutionEnvironment { + files, + glob_results: vec!["/skills/commit.md".into()], + ..Default::default() + }; + + let skills = discover_skills(&env, &["/skills".into()]).await; + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "commit"); + assert_eq!(skills[0].description, "Make a commit"); + } + + #[tokio::test] + async fn discover_skips_invalid() { + let mut files = HashMap::new(); + files.insert( + "/skills/good.md".into(), + "---\nname: good\n---\nGood template".into(), + ); + files.insert("/skills/bad.md".into(), "no frontmatter here".into()); + let env = MockExecutionEnvironment { + files, + glob_results: vec!["/skills/good.md".into(), "/skills/bad.md".into()], + ..Default::default() + }; + + let skills = discover_skills(&env, &["/skills".into()]).await; + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "good"); + } + + #[tokio::test] + async fn discover_empty_dirs() { + let env = MockExecutionEnvironment::default(); + let skills = discover_skills(&env, &[]).await; + assert!(skills.is_empty()); + } + + #[tokio::test] + async fn discover_project_overrides_global() { + let mut files = HashMap::new(); + files.insert( + "/global/commit.md".into(), + "---\nname: commit\ndescription: Global commit\n---\nGlobal template".into(), + ); + files.insert( + "/project/commit.md".into(), + "---\nname: commit\ndescription: Project commit\n---\nProject template".into(), + ); + + // We need separate envs because MockExecutionEnvironment returns the same glob_results + // for all calls. Instead, we test with a single env that has both files + // and glob returns both — the later dir overrides the earlier. + let env = MockExecutionEnvironment { + files, + glob_results: vec!["/global/commit.md".into(), "/project/commit.md".into()], + ..Default::default() + }; + + // discover_skills iterates dirs in order; later dirs override earlier names + let skills = + discover_skills(&env, &["/global".into(), "/project".into()]).await; + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].description, "Project commit"); + } + + // --- default_skill_dirs tests --- + + #[test] + fn default_dirs_with_git_root() { + let dirs = default_skill_dirs(Some("/home/user"), Some("/repo")); + assert_eq!( + dirs, + vec![ + "/home/user/.attractor/skills", + "/repo/.attractor/skills", + "/repo/skills", + ] + ); + } + + #[test] + fn default_dirs_without_git_root() { + let dirs = default_skill_dirs(Some("/home/user"), None); + assert_eq!(dirs, vec!["/home/user/.attractor/skills"]); + } +} diff --git a/crates/agent/src/test_support.rs b/crates/agent/src/test_support.rs index 5359c8924..d312ba7ef 100644 --- a/crates/agent/src/test_support.rs +++ b/crates/agent/src/test_support.rs @@ -3,6 +3,7 @@ use crate::execution_env::*; use crate::profiles::EnvContext; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; use crate::session::Session; +use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use async_trait::async_trait; use std::collections::HashMap; @@ -344,10 +345,17 @@ impl ProviderProfile for TestProfile { _env_context: &EnvContext, _project_docs: &[String], user_instructions: Option<&str>, + skills: &[Skill], ) -> String { + let skills_section = crate::skills::format_skills_prompt_section(skills); + let skills_part = if skills_section.is_empty() { + String::new() + } else { + format!("\n\n{skills_section}") + }; match user_instructions { - Some(instructions) => format!("You are a test assistant.\n\n# User Instructions\n{instructions}"), - None => "You are a test assistant.".into(), + Some(instructions) => format!("You are a test assistant.{skills_part}\n\n# User Instructions\n{instructions}"), + None => format!("You are a test assistant.{skills_part}"), } }