Add skills system for reusable prompt templates

Skills are markdown files with YAML frontmatter that define reusable
prompt templates (e.g., /commit, /review-pr). When a user references
/skill-name in their input, the skill template expands in place with
{{user_input}} receiving the remaining text.

- Add skills.rs with parse, expand, discover, and formatting functions
- Discover skills from ~/.attractor/skills/, <git-root>/.attractor/skills/,
  and <git-root>/skills/ (overridable with --skills-dir CLI flag)
- Inject available skills into system prompt between project docs and
  user instructions
- Expand skill references in session input before recording user turn

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2a8ed77f26c5
This commit is contained in:
Bryan Helmkamp 2026-02-25 11:32:07 -05:00
parent 1ec822781c
commit 3d39e06c29
13 changed files with 558 additions and 23 deletions

1
Cargo.lock generated
View file

@ -11,6 +11,7 @@ dependencies = [
"bollard",
"chrono",
"clap",
"dirs",
"dotenvy",
"futures",
"glob",

View file

@ -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 }

View file

@ -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<String>,
}
#[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()
};

View file

@ -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<Vec<String>>,
}
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,
}
}
}

View file

@ -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;

View file

@ -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("<environment>"));
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"));
}

View file

@ -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("<environment>"));
assert!(prompt.contains("linux"));
}

View file

@ -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)]

View file

@ -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("<environment>"));
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"));
}

View file

@ -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"));
}

View file

@ -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<String>,
env_context: EnvContext,
skills: Vec<Skill>,
}
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;

474
crates/agent/src/skills.rs Normal file
View file

@ -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<Skill, String> {
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<String> = 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<SkillMatch> {
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<String, String> {
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<String> {
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<Skill> {
let mut skills_by_name: std::collections::HashMap<String, Skill> =
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<Skill> = 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<Skill> {
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"]);
}
}

View file

@ -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}"),
}
}