claude_agent/skills/
index.rs

1//! Skill Index for Progressive Disclosure.
2//!
3//! `SkillIndex` contains minimal metadata (name, description, triggers) that is
4//! always loaded in the system prompt. The full skill content is loaded on-demand
5//! only when the skill is executed.
6
7use 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/// Skill index entry - minimal metadata always available in context.
17///
18/// This enables the progressive disclosure pattern where:
19/// - Metadata (~20 tokens per skill) is always in the system prompt
20/// - Full content (~500 tokens per skill) is loaded only when executed
21///
22/// # Token Efficiency
23///
24/// With 50 skills:
25/// - Index only: 50 × 20 = ~1,000 tokens
26/// - Full load: 50 × 500 = ~25,000 tokens
27/// - Savings: ~96%
28#[derive(Clone, Debug, Serialize, Deserialize)]
29pub struct SkillIndex {
30    /// Skill name (unique identifier)
31    pub name: String,
32
33    /// Skill description - used by Claude for semantic matching
34    pub description: String,
35
36    /// Trigger keywords for fast matching
37    #[serde(default)]
38    pub triggers: Vec<String>,
39
40    /// Allowed tools for this skill (if restricted)
41    #[serde(default)]
42    pub allowed_tools: Vec<String>,
43
44    /// Source location for loading full content
45    pub source: ContentSource,
46
47    /// Source type (builtin, user, project, managed)
48    #[serde(default)]
49    pub source_type: SourceType,
50
51    /// Optional model override
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub model: Option<String>,
54
55    /// Argument hint for display
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub argument_hint: Option<String>,
58
59    /// Base directory for relative path resolution (override for InMemory sources)
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    base_dir_override: Option<PathBuf>,
62}
63
64impl SkillIndex {
65    /// Create a new skill index entry.
66    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    /// Set the base directory for relative path resolution.
81    /// This is useful for InMemory sources where the ContentSource doesn't have a file path.
82    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    /// Set triggers for keyword matching.
88    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    /// Set allowed tools.
94    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    /// Set the content source.
103    pub fn with_source(mut self, source: ContentSource) -> Self {
104        self.source = source;
105        self
106    }
107
108    /// Set the source type.
109    pub fn with_source_type(mut self, source_type: SourceType) -> Self {
110        self.source_type = source_type;
111        self
112    }
113
114    /// Set the model override.
115    pub fn with_model(mut self, model: impl Into<String>) -> Self {
116        self.model = Some(model.into());
117        self
118    }
119
120    /// Set the argument hint.
121    pub fn with_argument_hint(mut self, hint: impl Into<String>) -> Self {
122        self.argument_hint = Some(hint.into());
123        self
124    }
125
126    /// Check if input matches any trigger keyword.
127    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    /// Check if this is a slash command match (e.g., /skill-name).
135    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    /// Get the base directory for this skill (for relative path resolution).
145    /// Checks base_dir_override first, then falls back to ContentSource's base_dir.
146    pub fn base_dir(&self) -> Option<PathBuf> {
147        self.base_dir_override
148            .clone()
149            .or_else(|| self.source.base_dir())
150    }
151
152    /// Resolve a relative path against this skill's base directory.
153    pub fn resolve_path(&self, relative: &str) -> Option<PathBuf> {
154        self.base_dir().map(|base| base.join(relative))
155    }
156
157    /// Load content with resolved relative paths.
158    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    /// Substitute arguments in content.
169    ///
170    /// Supports: $ARGUMENTS, ${ARGUMENTS}, $1-$9 positional args.
171    pub fn substitute_args(content: &str, args: Option<&str>) -> String {
172        processing::substitute_args(content, args.unwrap_or(""))
173    }
174
175    /// Execute skill with full content processing.
176    ///
177    /// Processing steps:
178    /// 1. Strip frontmatter
179    /// 2. Process bash backticks (!`command`)
180    /// 3. Process file references (@file.txt)
181    /// 4. Resolve markdown paths
182    /// 5. Substitute arguments ($ARGUMENTS, $1-$9)
183    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        // 1. Strip frontmatter
189        let content = processing::strip_frontmatter(content);
190        // 2. Process bash backticks
191        let content = processing::process_bash_backticks(content, &base_dir).await;
192        // 3. Process file references
193        let content = processing::process_file_references(&content, &base_dir).await;
194        // 4. Resolve markdown paths
195        let content = processing::resolve_markdown_paths(&content, &base_dir);
196        // 5. Substitute arguments
197        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}