claude_agent/skills/
index.rs1use std::path::PathBuf;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11
12use crate::common::{ContentSource, Index, Named, SourceType, ToolRestricted};
13
14use super::processing;
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
29pub struct SkillIndex {
30 pub name: String,
32
33 pub description: String,
35
36 #[serde(default)]
38 pub triggers: Vec<String>,
39
40 #[serde(default)]
42 pub allowed_tools: Vec<String>,
43
44 pub source: ContentSource,
46
47 #[serde(default)]
49 pub source_type: SourceType,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub model: Option<String>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub argument_hint: Option<String>,
58
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 base_dir_override: Option<PathBuf>,
62}
63
64impl SkillIndex {
65 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
67 Self {
68 name: name.into(),
69 description: description.into(),
70 triggers: Vec::new(),
71 allowed_tools: Vec::new(),
72 source: ContentSource::default(),
73 source_type: SourceType::default(),
74 model: None,
75 argument_hint: None,
76 base_dir_override: None,
77 }
78 }
79
80 pub fn with_base_dir(mut self, dir: impl Into<PathBuf>) -> Self {
83 self.base_dir_override = Some(dir.into());
84 self
85 }
86
87 pub fn with_triggers(mut self, triggers: impl IntoIterator<Item = impl Into<String>>) -> Self {
89 self.triggers = triggers.into_iter().map(Into::into).collect();
90 self
91 }
92
93 pub fn with_allowed_tools(
95 mut self,
96 tools: impl IntoIterator<Item = impl Into<String>>,
97 ) -> Self {
98 self.allowed_tools = tools.into_iter().map(Into::into).collect();
99 self
100 }
101
102 pub fn with_source(mut self, source: ContentSource) -> Self {
104 self.source = source;
105 self
106 }
107
108 pub fn with_source_type(mut self, source_type: SourceType) -> Self {
110 self.source_type = source_type;
111 self
112 }
113
114 pub fn with_model(mut self, model: impl Into<String>) -> Self {
116 self.model = Some(model.into());
117 self
118 }
119
120 pub fn with_argument_hint(mut self, hint: impl Into<String>) -> Self {
122 self.argument_hint = Some(hint.into());
123 self
124 }
125
126 pub fn matches_triggers(&self, input: &str) -> bool {
128 let input_lower = input.to_lowercase();
129 self.triggers
130 .iter()
131 .any(|trigger| input_lower.contains(&trigger.to_lowercase()))
132 }
133
134 pub fn matches_command(&self, input: &str) -> bool {
136 if let Some(cmd) = input.strip_prefix('/') {
137 let cmd_lower = cmd.split_whitespace().next().unwrap_or("").to_lowercase();
138 self.name.to_lowercase() == cmd_lower
139 } else {
140 false
141 }
142 }
143
144 pub fn base_dir(&self) -> Option<PathBuf> {
147 self.base_dir_override
148 .clone()
149 .or_else(|| self.source.base_dir())
150 }
151
152 pub fn resolve_path(&self, relative: &str) -> Option<PathBuf> {
154 self.base_dir().map(|base| base.join(relative))
155 }
156
157 pub async fn load_content_with_resolved_paths(&self) -> crate::Result<String> {
159 let content = self.load_content().await?;
160
161 if let Some(base_dir) = self.base_dir() {
162 Ok(processing::resolve_markdown_paths(&content, &base_dir))
163 } else {
164 Ok(content)
165 }
166 }
167
168 pub fn substitute_args(content: &str, args: Option<&str>) -> String {
172 processing::substitute_args(content, args.unwrap_or(""))
173 }
174
175 pub async fn execute(&self, arguments: &str, content: &str) -> String {
184 let base_dir = self
185 .base_dir()
186 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
187
188 let content = processing::strip_frontmatter(content);
190 let content = processing::process_bash_backticks(content, &base_dir).await;
192 let content = processing::process_file_references(&content, &base_dir).await;
194 let content = processing::resolve_markdown_paths(&content, &base_dir);
196 processing::substitute_args(&content, arguments)
198 }
199}
200
201impl Named for SkillIndex {
202 fn name(&self) -> &str {
203 &self.name
204 }
205}
206
207impl ToolRestricted for SkillIndex {
208 fn allowed_tools(&self) -> &[String] {
209 &self.allowed_tools
210 }
211}
212
213#[async_trait]
214impl Index for SkillIndex {
215 fn source(&self) -> &ContentSource {
216 &self.source
217 }
218
219 fn source_type(&self) -> SourceType {
220 self.source_type
221 }
222
223 fn to_summary_line(&self) -> String {
224 let tools_str = if self.allowed_tools.is_empty() {
225 String::new()
226 } else {
227 format!(" [tools: {}]", self.allowed_tools.join(", "))
228 };
229
230 format!("- {}: {}{}", self.name, self.description, tools_str)
231 }
232
233 fn description(&self) -> &str {
234 &self.description
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::processing;
241 use super::*;
242
243 #[test]
244 fn test_skill_index_creation() {
245 let skill = SkillIndex::new("commit", "Create a git commit with conventional format")
246 .with_triggers(["git commit", "commit changes"])
247 .with_source_type(SourceType::User);
248
249 assert_eq!(skill.name, "commit");
250 assert!(skill.matches_triggers("I want to git commit these changes"));
251 assert!(!skill.matches_triggers("deploy the application"));
252 }
253
254 #[test]
255 fn test_command_matching() {
256 let skill = SkillIndex::new("commit", "Create a git commit");
257
258 assert!(skill.matches_command("/commit"));
259 assert!(skill.matches_command("/commit -m 'message'"));
260 assert!(!skill.matches_command("/other"));
261 assert!(!skill.matches_command("commit"));
262 }
263
264 #[test]
265 fn test_summary_line() {
266 let skill = SkillIndex::new("test", "A test skill").with_source_type(SourceType::Project);
267
268 let summary = skill.to_summary_line();
269 assert!(summary.contains("test"));
270 assert!(summary.contains("A test skill"));
271 }
272
273 #[test]
274 fn test_summary_line_with_tools() {
275 let skill =
276 SkillIndex::new("reader", "Read files only").with_allowed_tools(["Read", "Grep"]);
277
278 let summary = skill.to_summary_line();
279 assert!(summary.contains("[tools: Read, Grep]"));
280 }
281
282 #[test]
283 fn test_substitute_args() {
284 let content = "Do something with $ARGUMENTS and ${ARGUMENTS}";
285 let result = SkillIndex::substitute_args(content, Some("test args"));
286 assert_eq!(result, "Do something with test args and test args");
287 }
288
289 #[tokio::test]
290 async fn test_load_content() {
291 let skill = SkillIndex::new("test", "Test skill")
292 .with_source(ContentSource::in_memory("Full skill content here"));
293
294 let content = skill.load_content().await.unwrap();
295 assert_eq!(content, "Full skill content here");
296 }
297
298 #[test]
299 fn test_priority() {
300 let builtin = SkillIndex::new("a", "").with_source_type(SourceType::Builtin);
301 let user = SkillIndex::new("b", "").with_source_type(SourceType::User);
302 let project = SkillIndex::new("c", "").with_source_type(SourceType::Project);
303
304 assert!(project.priority() > user.priority());
305 assert!(user.priority() > builtin.priority());
306 }
307
308 #[test]
309 fn test_resolve_markdown_paths() {
310 let content = r#"# Review Process
311Check [style-guide.md](style-guide.md) for standards.
312Also see [docs/api.md](docs/api.md).
313External: [Rust Docs](https://doc.rust-lang.org)
314Absolute: [config](/etc/config.md)"#;
315
316 let resolved =
317 processing::resolve_markdown_paths(content, std::path::Path::new("/skills/test"));
318
319 assert!(resolved.contains("[style-guide.md](/skills/test/style-guide.md)"));
320 assert!(resolved.contains("[docs/api.md](/skills/test/docs/api.md)"));
321 assert!(resolved.contains("[Rust Docs](https://doc.rust-lang.org)"));
322 assert!(resolved.contains("[config](/etc/config.md)"));
323 }
324
325 #[test]
326 fn test_substitute_args_positional() {
327 let content = "File: $1, Action: $2, All: $ARGUMENTS";
328 let result = SkillIndex::substitute_args(content, Some("main.rs build"));
329 assert_eq!(result, "File: main.rs, Action: build, All: main.rs build");
330 }
331
332 #[tokio::test]
333 async fn test_execute() {
334 let skill = SkillIndex::new("test", "Test skill")
335 .with_source(ContentSource::in_memory("Process: $ARGUMENTS"));
336
337 let content = skill.load_content().await.unwrap();
338 let result = skill.execute("my argument", &content).await;
339 assert_eq!(result.trim(), "Process: my argument");
340 }
341}