Skip to main content

sparrow/capabilities/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::memory::Memory;
6
7pub mod mcp;
8pub mod plugin;
9
10// ─── Skill ──────────────────────────────────────────────────────────────────────
11
12/// A reusable capability: a SKILL.md file loaded into agent context when relevant.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Skill {
15    pub name: String,
16    pub description: String,
17    /// Keywords used to match this skill against a task context
18    pub trigger: Vec<String>,
19    /// The body/content loaded into the agent's system prompt or context
20    pub body: String,
21    /// Source file path (relative to skills dir)
22    #[serde(default)]
23    pub source_file: String,
24    /// How many times this skill was used/loaded
25    #[serde(default)]
26    pub usage_count: u32,
27    /// Creation timestamp
28    #[serde(default)]
29    pub created_at: String,
30    /// Quality score (0.0–1.0), used by curator for pruning
31    #[serde(default = "default_score")]
32    pub score: f64,
33    /// Whether this skill was auto-generated (by Curator) or user-created
34    #[serde(default)]
35    pub auto_generated: bool,
36    /// Optional references loaded only when the skill is explicitly invoked.
37    #[serde(default)]
38    pub references: Vec<String>,
39    /// Optional templates loaded only when explicitly requested.
40    #[serde(default)]
41    pub templates: Vec<String>,
42    /// Optional scripts advertised by the skill.
43    #[serde(default)]
44    pub scripts: Vec<String>,
45    /// Optional assets advertised by the skill.
46    #[serde(default)]
47    pub assets: Vec<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SkillInvocation {
52    pub skill: Skill,
53    pub loaded_references: Vec<(String, String)>,
54    pub loaded_templates: Vec<(String, String)>,
55    pub loaded_scripts: Vec<(String, String)>,
56    pub loaded_assets: Vec<(String, String)>,
57}
58
59fn default_score() -> f64 {
60    0.5
61}
62
63impl Skill {
64    /// Parse a SKILL.md file into a Skill struct.
65    /// Format:
66    /// ```markdown
67    /// # Skill: <name>
68    ///
69    /// **Trigger:** comma, separated, keywords
70    ///
71    /// ## Body
72    /// <content>
73    /// ```
74    pub fn from_markdown(content: &str, source_file: &str) -> Option<Self> {
75        let mut name = String::new();
76        let mut description = String::new();
77        let mut trigger = Vec::new();
78        let mut body = String::new();
79        let mut references = Vec::new();
80        let mut templates = Vec::new();
81        let mut scripts = Vec::new();
82        let mut assets = Vec::new();
83        let mut in_body = false;
84
85        for line in content.lines() {
86            let trimmed = line.trim();
87
88            if trimmed.starts_with("# Skill:") || trimmed.starts_with("# ") {
89                name = trimmed
90                    .trim_start_matches("# Skill:")
91                    .trim_start_matches("# ")
92                    .trim()
93                    .to_string();
94                continue;
95            }
96
97            if trimmed.starts_with("**Trigger:**") || trimmed.starts_with("**Triggers:**") {
98                let trig_str = trimmed
99                    .trim_start_matches("**Trigger:**")
100                    .trim_start_matches("**Triggers:**")
101                    .trim();
102                trigger = trig_str
103                    .split(',')
104                    .map(|s| s.trim().to_lowercase())
105                    .filter(|s| !s.is_empty())
106                    .collect();
107                continue;
108            }
109
110            if trimmed.starts_with("**Description:**") {
111                description = trimmed
112                    .trim_start_matches("**Description:**")
113                    .trim()
114                    .to_string();
115                continue;
116            }
117            if trimmed.starts_with("**References:**") {
118                references = parse_csv_field(trimmed.trim_start_matches("**References:**"));
119                continue;
120            }
121            if trimmed.starts_with("**Templates:**") {
122                templates = parse_csv_field(trimmed.trim_start_matches("**Templates:**"));
123                continue;
124            }
125            if trimmed.starts_with("**Scripts:**") {
126                scripts = parse_csv_field(trimmed.trim_start_matches("**Scripts:**"));
127                continue;
128            }
129            if trimmed.starts_with("**Assets:**") {
130                assets = parse_csv_field(trimmed.trim_start_matches("**Assets:**"));
131                continue;
132            }
133
134            if trimmed == "## Body" || trimmed == "### Body" {
135                in_body = true;
136                continue;
137            }
138
139            if in_body {
140                body.push_str(line);
141                body.push('\n');
142            }
143        }
144
145        if name.is_empty() {
146            return None;
147        }
148
149        if body.is_empty() && !in_body {
150            // If no ## Body header, take everything after the header as body
151            body = content
152                .lines()
153                .skip_while(|l| !l.starts_with("**Trigger"))
154                .skip(1)
155                .collect::<Vec<_>>()
156                .join("\n");
157        }
158
159        Some(Skill {
160            name,
161            description: if description.is_empty() {
162                trigger.join(", ")
163            } else {
164                description
165            },
166            trigger,
167            body: body.trim().to_string(),
168            source_file: source_file.to_string(),
169            usage_count: 0,
170            created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
171            score: 0.5,
172            auto_generated: false,
173            references,
174            templates,
175            scripts,
176            assets,
177        })
178    }
179
180    /// Convert a Skill back to SKILL.md markdown format
181    pub fn to_markdown(&self) -> String {
182        format!(
183            "# Skill: {name}\n\n\
184             **Trigger:** {trigger}\n\n\
185             **Description:** {desc}\n\n\
186             **References:** {references}\n\n\
187             **Templates:** {templates}\n\n\
188             **Scripts:** {scripts}\n\n\
189             **Assets:** {assets}\n\n\
190             ## Body\n\
191             {body}\n",
192            name = self.name,
193            trigger = self.trigger.join(", "),
194            desc = self.description,
195            references = self.references.join(", "),
196            templates = self.templates.join(", "),
197            scripts = self.scripts.join(", "),
198            assets = self.assets.join(", "),
199            body = self.body,
200        )
201    }
202
203    /// Check if this skill is relevant to a given context string.
204    /// Returns a relevance score 0.0–1.0.
205    pub fn relevance(&self, ctx: &str) -> f64 {
206        let lower = ctx.to_lowercase();
207        if self.trigger.is_empty() {
208            return 0.0;
209        }
210        let matches: usize = self
211            .trigger
212            .iter()
213            .filter(|kw| lower.contains(kw.as_str()))
214            .count();
215        if matches == 0 {
216            return 0.0;
217        }
218        matches as f64 / self.trigger.len() as f64
219    }
220}
221
222fn parse_csv_field(value: &str) -> Vec<String> {
223    value
224        .trim()
225        .split(',')
226        .map(|s| s.trim().to_string())
227        .filter(|s| !s.is_empty())
228        .collect()
229}
230
231// ─── THE SKILL LIBRARY TRAIT ────────────────────────────────────────────────────
232
233pub trait SkillLibrary: Send + Sync {
234    fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill>;
235    fn add(&self, skill: Skill) -> anyhow::Result<()>;
236    fn all(&self) -> Vec<Skill>;
237    fn curate(&self) -> anyhow::Result<()>;
238    fn prune(&self, min_score: f64) -> anyhow::Result<usize>;
239    fn get(&self, name: &str) -> Option<Skill>;
240    fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>>;
241    /// Remove a skill by name (any kind). Returns true if it existed.
242    fn remove(&self, name: &str) -> anyhow::Result<bool>;
243    /// On-disk root for the library, if any. In-memory implementations
244    /// return None; `sparrow skills update` reads SKILL.md from here.
245    fn skills_root(&self) -> Option<std::path::PathBuf> {
246        None
247    }
248}
249
250// ─── Filesystem-backed skill library ────────────────────────────────────────────
251
252pub struct FsSkillLibrary {
253    skills_dir: PathBuf,
254    memory: Option<Arc<dyn Memory>>,
255}
256
257impl FsSkillLibrary {
258    pub fn new(skills_dir: PathBuf) -> Self {
259        std::fs::create_dir_all(&skills_dir).ok();
260        Self {
261            skills_dir,
262            memory: None,
263        }
264    }
265
266    pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
267        self.memory = Some(memory);
268        self
269    }
270
271    /// Path to the skills root. Exposed so commands like
272    /// `sparrow skills update` can re-read a skill from disk without going
273    /// through the cached library view.
274    pub fn skills_dir(&self) -> &std::path::Path {
275        &self.skills_dir
276    }
277
278    /// Scan the skills directory and load all SKILL.md files
279    pub fn scan(&self) -> Vec<Skill> {
280        let mut skills = Vec::new();
281        if let Ok(entries) = std::fs::read_dir(&self.skills_dir) {
282            for entry in entries.flatten() {
283                let path = entry.path();
284                if path.is_dir() {
285                    // Look for SKILL.md inside subdirectories
286                    let skill_file = path.join("SKILL.md");
287                    if skill_file.exists() {
288                        if let Ok(content) = std::fs::read_to_string(&skill_file) {
289                            let rel = path
290                                .file_name()
291                                .map(|n| n.to_string_lossy().to_string())
292                                .unwrap_or_default();
293                            if let Some(skill) = Skill::from_markdown(&content, &rel) {
294                                skills.push(skill);
295                            }
296                        }
297                    }
298                } else if path
299                    .file_name()
300                    .map(|n| n.to_string_lossy().to_lowercase().ends_with(".skill.md"))
301                    .unwrap_or(false)
302                {
303                    if let Ok(content) = std::fs::read_to_string(&path) {
304                        let rel = path
305                            .file_name()
306                            .map(|n| n.to_string_lossy().to_string())
307                            .unwrap_or_default();
308                        if let Some(skill) = Skill::from_markdown(&content, &rel) {
309                            skills.push(skill);
310                        }
311                    }
312                }
313            }
314        }
315        skills
316    }
317}
318
319impl SkillLibrary for FsSkillLibrary {
320    fn skills_root(&self) -> Option<std::path::PathBuf> {
321        Some(self.skills_dir.clone())
322    }
323
324    fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill> {
325        let mut scored: Vec<(f64, Skill)> = self
326            .scan()
327            .into_iter()
328            .map(|s| {
329                let r = s.relevance(ctx);
330                (r, s)
331            })
332            .filter(|(r, _)| *r > 0.0)
333            .collect();
334
335        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
336
337        scored.into_iter().take(limit).map(|(_, s)| s).collect()
338    }
339
340    fn add(&self, skill: Skill) -> anyhow::Result<()> {
341        // Create a directory for the skill
342        let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
343        std::fs::create_dir_all(&skill_dir)?;
344
345        let skill_file = skill_dir.join("SKILL.md");
346        let content = skill.to_markdown();
347        std::fs::write(&skill_file, content)?;
348
349        // Also store in memory if available
350        if let Some(mem) = &self.memory {
351            let _ = mem.upsert_doc(crate::memory::WorkingDoc {
352                id: format!("skill-{}", skill.name),
353                title: format!("Skill: {}", skill.name),
354                content: skill.body.clone(),
355                updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
356            });
357        }
358
359        Ok(())
360    }
361
362    fn all(&self) -> Vec<Skill> {
363        self.scan()
364    }
365
366    fn curate(&self) -> anyhow::Result<()> {
367        let curator = Curator::new();
368        curator.curate(&self.skills_dir)
369    }
370
371    fn prune(&self, min_score: f64) -> anyhow::Result<usize> {
372        let skills = self.scan();
373        let mut removed = 0;
374
375        for skill in &skills {
376            if skill.score < min_score && skill.auto_generated {
377                let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
378                if skill_dir.exists() {
379                    std::fs::remove_dir_all(&skill_dir)?;
380                    removed += 1;
381                }
382            }
383        }
384
385        Ok(removed)
386    }
387
388    fn get(&self, name: &str) -> Option<Skill> {
389        self.scan().into_iter().find(|s| s.name == name)
390    }
391
392    fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>> {
393        let Some(skill) = self.get(name) else {
394            return Ok(None);
395        };
396        let base = if skill.source_file.ends_with(".skill.md") {
397            self.skills_dir.clone()
398        } else {
399            self.skills_dir.join(&skill.source_file)
400        };
401        let load_files = |files: &[String]| -> Vec<(String, String)> {
402            let mut loaded = Vec::new();
403            for f in files {
404                let Ok(candidate) = safe_relative_path(&base, f) else {
405                    continue;
406                };
407                if let Ok(content) = std::fs::read_to_string(&candidate) {
408                    loaded.push((f.clone(), content));
409                }
410            }
411            loaded
412        };
413        Ok(Some(SkillInvocation {
414            loaded_references: load_files(&skill.references),
415            loaded_templates: load_files(&skill.templates),
416            loaded_scripts: load_files(&skill.scripts),
417            loaded_assets: load_files(&skill.assets),
418            skill,
419        }))
420    }
421
422    fn remove(&self, name: &str) -> anyhow::Result<bool> {
423        // Skills live under a directory equal to their name. Remove it.
424        let skill_dir = self.skills_dir.join(safe_skill_dir_name(name)?);
425        let existed = skill_dir.exists();
426        if existed {
427            std::fs::remove_dir_all(&skill_dir)?;
428        }
429        Ok(existed)
430    }
431}
432
433fn safe_skill_dir_name(name: &str) -> anyhow::Result<String> {
434    let trimmed = name.trim();
435    if trimmed.is_empty()
436        || trimmed.contains("..")
437        || trimmed.contains('/')
438        || trimmed.contains('\\')
439        || trimmed.contains(':')
440    {
441        anyhow::bail!("invalid skill name '{}'", name);
442    }
443    Ok(trimmed.to_string())
444}
445
446fn safe_relative_path(base: &Path, relative: &str) -> anyhow::Result<PathBuf> {
447    let rel = Path::new(relative);
448    if rel.is_absolute()
449        || rel
450            .components()
451            .any(|c| matches!(c, std::path::Component::ParentDir))
452    {
453        anyhow::bail!("skill reference escapes skill directory: {}", relative);
454    }
455    let candidate = base.join(rel);
456    let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
457    let canonical_candidate = candidate
458        .canonicalize()
459        .unwrap_or_else(|_| candidate.to_path_buf());
460    if !canonical_candidate.starts_with(&canonical_base) || !canonical_candidate.exists() {
461        anyhow::bail!("skill reference outside base or missing: {}", relative);
462    }
463    Ok(canonical_candidate)
464}
465
466// ─── Curator: self-improvement loop ─────────────────────────────────────────────
467
468pub struct Curator {
469    min_score: f64,
470    max_skills: usize,
471}
472
473impl Curator {
474    pub fn new() -> Self {
475        Self {
476            min_score: 0.2,
477            max_skills: 100,
478        }
479    }
480
481    /// Grade, consolidate, dedupe, and prune the skill library.
482    /// This is the closed learning loop — skills are created from experience
483    /// and improved during use.
484    pub fn curate(&self, skills_dir: &Path) -> anyhow::Result<()> {
485        let library = FsSkillLibrary::new(skills_dir.to_path_buf());
486        let mut skills = library.all();
487
488        if skills.is_empty() {
489            return Ok(());
490        }
491
492        // 1. Grade: score each skill based on usage and content quality
493        for skill in &mut skills {
494            // More usage = higher score
495            skill.score += skill.usage_count as f64 * 0.05;
496            // Longer body = more detailed = slightly higher score
497            skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
498            // Cap at 1.0
499            skill.score = skill.score.min(1.0);
500        }
501
502        // 2. Dedupe: merge skills with similar names or overlapping triggers
503        let mut merged = Vec::new();
504        let mut merged_indices = std::collections::HashSet::new();
505
506        for i in 0..skills.len() {
507            if merged_indices.contains(&i) {
508                continue;
509            }
510            let mut current = skills[i].clone();
511
512            for j in (i + 1)..skills.len() {
513                if merged_indices.contains(&j) {
514                    continue;
515                }
516                // Check similarity: same first 3 chars of name, or >50% trigger overlap
517                let name_overlap = current.name
518                    [..current.name.len().min(3).min(skills[j].name.len())]
519                    == skills[j].name[..skills[j].name.len().min(3).min(current.name.len())];
520
521                let trigger_overlap = {
522                    let a: std::collections::HashSet<_> = current.trigger.iter().cloned().collect();
523                    let b: std::collections::HashSet<_> =
524                        skills[j].trigger.iter().cloned().collect();
525                    let intersection = a.intersection(&b).count();
526                    let union = a.union(&b).count();
527                    if union == 0 {
528                        false
529                    } else {
530                        intersection as f64 / union as f64 > 0.5
531                    }
532                };
533
534                if name_overlap || trigger_overlap {
535                    // Merge: combine bodies, take higher score
536                    current.body = format!("{}\n\n---\n\n{}", current.body, skills[j].body);
537                    current.score = current.score.max(skills[j].score);
538                    current.trigger.extend(skills[j].trigger.clone());
539                    current.trigger.sort();
540                    current.trigger.dedup();
541                    merged_indices.insert(j);
542                }
543            }
544            merged.push(current);
545        }
546
547        // 3. Prune: remove low-score auto-generated skills, keep total under max
548        merged.retain(|s| !s.auto_generated || s.score >= self.min_score);
549        merged.sort_by(|a, b| {
550            b.score
551                .partial_cmp(&a.score)
552                .unwrap_or(std::cmp::Ordering::Equal)
553        });
554        if merged.len() > self.max_skills {
555            merged.truncate(self.max_skills);
556        }
557
558        // 4. Write back updated SKILL.md files without deleting references,
559        // templates, scripts or assets stored beside them.
560        for skill in &merged {
561            let skill_dir = skills_dir.join(safe_skill_dir_name(&skill.name)?);
562            std::fs::create_dir_all(&skill_dir)?;
563            std::fs::write(skill_dir.join("SKILL.md"), skill.to_markdown())?;
564        }
565
566        tracing::info!(
567            "Curator: {} skills before → {} after (deduped {}, pruned {})",
568            skills.len(),
569            merged.len(),
570            skills.len() - merged_indices.len(),
571            skills.len() + merged_indices.len() - merged.len() - skills.len().min(merged.len()),
572        );
573
574        Ok(())
575    }
576
577    /// Generate a skill candidate from a successful run trajectory.
578    /// Called by the engine after a `RunFinished` event.
579    pub fn propose_skill(run_description: &str, outcome: &str) -> Option<Skill> {
580        let words: Vec<&str> = run_description.split_whitespace().collect();
581        let lower = run_description.to_lowercase();
582        let outcome_lower = outcome.to_lowercase();
583
584        if words.len() < 5 || outcome_lower.contains("error") {
585            return None;
586        }
587
588        let specificity_markers = [
589            "github.com",
590            "http",
591            "https",
592            "this ",
593            "that ",
594            "the file",
595            "my ",
596            "your ",
597            "2024",
598            "2025",
599            "2026",
600        ];
601        if specificity_markers
602            .iter()
603            .any(|marker| lower.contains(marker))
604        {
605            return None;
606        }
607        if words.iter().any(|word| {
608            let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
609            // Only bail on long proper-noun-looking tokens (> 12 chars, starts uppercase).
610            // Shorter capitalized words (structs, types) are normal in coding tasks.
611            cleaned
612                .chars()
613                .next()
614                .map(|c| c.is_uppercase())
615                .unwrap_or(false)
616                && cleaned.chars().count() > 12
617        }) {
618            return None;
619        }
620
621        let has_concrete_output = [
622            "diff", "fn ", "struct ", "impl ", "test", "fixed", "refactor", "added", "updated",
623            "created", "modified", "patch", "write", "edit", "return", "async", "pub ", "let ",
624            "const ", "mod ",
625        ]
626        .iter()
627        .any(|needle| outcome_lower.contains(needle));
628        if !has_concrete_output {
629            return None;
630        }
631
632        let name = skill_name_from_pattern(run_description)?.to_string();
633        let triggers = skill_triggers_for_pattern(&name);
634
635        Some(Skill {
636            name,
637            description: format!("Reusable pattern learned from: {}", run_description),
638            trigger: triggers,
639            body: format!(
640                "## Context\nTask: {}\n\n## Approach\n{}",
641                run_description, outcome
642            ),
643            source_file: String::new(),
644            usage_count: 0,
645            created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
646            score: 0.3,
647            auto_generated: true,
648            references: Vec::new(),
649            templates: Vec::new(),
650            scripts: Vec::new(),
651            assets: Vec::new(),
652        })
653    }
654
655    pub fn propose_skill_if_missing(
656        run_description: &str,
657        outcome: &str,
658        library: &dyn SkillLibrary,
659    ) -> Option<Skill> {
660        let candidate = Self::propose_skill(run_description, outcome)?;
661        if library.get(&candidate.name).is_some() {
662            None
663        } else {
664            Some(candidate)
665        }
666    }
667}
668
669pub fn skill_name_from_pattern(description: &str) -> Option<&'static str> {
670    let d = description.to_lowercase();
671    if d.contains("test") && (d.contains("add") || d.contains("write") || d.contains("fix")) {
672        return Some("write-and-fix-tests");
673    }
674    if d.contains("refactor") || d.contains("rename") || d.contains("extract") {
675        return Some("refactor-safely");
676    }
677    if d.contains("debug") || d.contains("error") || d.contains("panic") || d.contains("crash") {
678        return Some("debug-systematically");
679    }
680    if d.contains("document")
681        || d.contains("comment")
682        || d.contains("readme")
683        || d.contains("docstring")
684    {
685        return Some("write-docs");
686    }
687    if d.contains("secur") || d.contains("vulnerab") || d.contains("audit") {
688        return Some("security-audit");
689    }
690    if d.contains("performance") || d.contains("slow") || d.contains("optim") || d.contains("bench")
691    {
692        return Some("performance-profile");
693    }
694    if d.contains("upgrade") || d.contains("bump") || d.contains("depend") || d.contains("package")
695    {
696        return Some("upgrade-dependencies");
697    }
698    if d.contains("review") || d.contains("pr") || d.contains("pull request") || d.contains("diff")
699    {
700        return Some("code-review");
701    }
702    if d.contains("git") || d.contains("commit") || d.contains("branch") || d.contains("merge") {
703        return Some("git-workflow");
704    }
705    None
706}
707
708fn skill_triggers_for_pattern(name: &str) -> Vec<String> {
709    match name {
710        "write-and-fix-tests" => vec!["test", "unit", "fix", "assert"],
711        "refactor-safely" => vec!["refactor", "rename", "extract", "safe"],
712        "debug-systematically" => vec!["debug", "error", "panic", "crash"],
713        "write-docs" => vec!["document", "readme", "comment", "docstring"],
714        "security-audit" => vec!["security", "audit", "vulnerability", "safe"],
715        "performance-profile" => vec!["performance", "slow", "optimize", "bench"],
716        "upgrade-dependencies" => vec!["upgrade", "bump", "dependency", "package"],
717        "code-review" => vec!["review", "pr", "diff", "pull-request"],
718        "git-workflow" => vec!["git", "commit", "branch", "merge"],
719        _ => vec!["skill"],
720    }
721    .into_iter()
722    .map(String::from)
723    .collect()
724}
725
726impl Default for Curator {
727    fn default() -> Self {
728        Self::new()
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    fn temp_dir(name: &str) -> PathBuf {
737        std::env::temp_dir().join(format!(
738            "sparrow-tier2-{name}-{}",
739            std::time::SystemTime::now()
740                .duration_since(std::time::UNIX_EPOCH)
741                .unwrap()
742                .as_nanos()
743        ))
744    }
745
746    #[test]
747    fn skill_invocation_rejects_parent_dir_references() {
748        let root = temp_dir("skill-ref-escape");
749        std::fs::create_dir_all(root.join("review").join("references")).unwrap();
750        std::fs::write(
751            root.join("review").join("SKILL.md"),
752            "# Skill: review\n\n**Trigger:** review\n\n**References:** ../secret.txt, references/checklist.md\n\n## Body\nReview carefully.",
753        )
754        .unwrap();
755        std::fs::write(
756            root.join("review").join("references").join("checklist.md"),
757            "ok",
758        )
759        .unwrap();
760        std::fs::write(root.join("secret.txt"), "nope").unwrap();
761
762        let lib = FsSkillLibrary::new(root.clone());
763        let invocation = lib.invoke("review").unwrap().expect("skill should exist");
764
765        assert_eq!(invocation.loaded_references.len(), 1);
766        assert_eq!(invocation.loaded_references[0].0, "references/checklist.md");
767
768        let _ = std::fs::remove_dir_all(root);
769    }
770
771    #[test]
772    fn curator_preserves_skill_assets_and_updates_skill_md_only() {
773        let root = temp_dir("curator-assets");
774        let skill_dir = root.join("refactor-safely");
775        std::fs::create_dir_all(skill_dir.join("references")).unwrap();
776        std::fs::write(skill_dir.join("references").join("checklist.md"), "keep me").unwrap();
777        std::fs::write(
778            skill_dir.join("SKILL.md"),
779            "# Skill: refactor-safely\n\n**Trigger:** refactor, rename\n\n**References:** references/checklist.md\n\n## Body\nMove in small steps.",
780        )
781        .unwrap();
782
783        Curator::new().curate(&root).unwrap();
784
785        assert!(
786            skill_dir.join("references").join("checklist.md").exists(),
787            "curator must not delete progressive-disclosure assets"
788        );
789        let lib = FsSkillLibrary::new(root.clone());
790        let invocation = lib
791            .invoke("refactor-safely")
792            .unwrap()
793            .expect("skill should remain");
794        assert_eq!(invocation.loaded_references[0].1, "keep me");
795
796        let _ = std::fs::remove_dir_all(root);
797    }
798
799    #[test]
800    fn skill_names_cannot_escape_skill_root() {
801        let root = temp_dir("skill-name-escape");
802        let lib = FsSkillLibrary::new(root.clone());
803        let skill = Skill {
804            name: "../outside".into(),
805            description: "bad".into(),
806            trigger: vec!["bad".into()],
807            body: "bad".into(),
808            source_file: String::new(),
809            usage_count: 0,
810            created_at: String::new(),
811            score: 0.5,
812            auto_generated: false,
813            references: Vec::new(),
814            templates: Vec::new(),
815            scripts: Vec::new(),
816            assets: Vec::new(),
817        };
818
819        assert!(lib.add(skill).is_err());
820        assert!(!root.join("..").join("outside").exists());
821
822        let _ = std::fs::remove_dir_all(root);
823    }
824}