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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Skill {
15 pub name: String,
16 pub description: String,
17 pub trigger: Vec<String>,
19 pub body: String,
21 #[serde(default)]
23 pub source_file: String,
24 #[serde(default)]
26 pub usage_count: u32,
27 #[serde(default)]
29 pub created_at: String,
30 #[serde(default = "default_score")]
32 pub score: f64,
33 #[serde(default)]
35 pub auto_generated: bool,
36 #[serde(default)]
38 pub references: Vec<String>,
39 #[serde(default)]
41 pub templates: Vec<String>,
42 #[serde(default)]
44 pub scripts: Vec<String>,
45 #[serde(default)]
47 pub assets: Vec<String>,
48 #[serde(default)]
50 pub manifest_version: Option<String>,
51 #[serde(default)]
53 pub allowed_tools: Vec<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SkillInvocation {
58 pub skill: Skill,
59 pub loaded_references: Vec<(String, String)>,
60 pub loaded_templates: Vec<(String, String)>,
61 pub loaded_scripts: Vec<(String, String)>,
62 pub loaded_assets: Vec<(String, String)>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct SkillManifest {
67 #[serde(default)]
68 pub version: Option<String>,
69 #[serde(default)]
70 pub allowed_tools: Vec<String>,
71}
72
73fn default_score() -> f64 {
74 0.5
75}
76
77impl Skill {
78 pub fn from_markdown(content: &str, source_file: &str) -> Option<Self> {
89 let mut name = String::new();
90 let mut description = String::new();
91 let mut trigger = Vec::new();
92 let mut body = String::new();
93 let mut references = Vec::new();
94 let mut templates = Vec::new();
95 let mut scripts = Vec::new();
96 let mut assets = Vec::new();
97 let mut allowed_tools = Vec::new();
98 let mut in_body = false;
99
100 for line in content.lines() {
101 let trimmed = line.trim();
102
103 if trimmed.starts_with("# Skill:") || trimmed.starts_with("# ") {
104 name = trimmed
105 .trim_start_matches("# Skill:")
106 .trim_start_matches("# ")
107 .trim()
108 .to_string();
109 continue;
110 }
111
112 if trimmed.starts_with("**Trigger:**") || trimmed.starts_with("**Triggers:**") {
113 let trig_str = trimmed
114 .trim_start_matches("**Trigger:**")
115 .trim_start_matches("**Triggers:**")
116 .trim();
117 trigger = trig_str
118 .split(',')
119 .map(|s| s.trim().to_lowercase())
120 .filter(|s| !s.is_empty())
121 .collect();
122 continue;
123 }
124
125 if trimmed.starts_with("**Description:**") {
126 description = trimmed
127 .trim_start_matches("**Description:**")
128 .trim()
129 .to_string();
130 continue;
131 }
132 if trimmed.starts_with("**References:**") {
133 references = parse_csv_field(trimmed.trim_start_matches("**References:**"));
134 continue;
135 }
136 if trimmed.starts_with("**Templates:**") {
137 templates = parse_csv_field(trimmed.trim_start_matches("**Templates:**"));
138 continue;
139 }
140 if trimmed.starts_with("**Scripts:**") {
141 scripts = parse_csv_field(trimmed.trim_start_matches("**Scripts:**"));
142 continue;
143 }
144 if trimmed.starts_with("**Assets:**") {
145 assets = parse_csv_field(trimmed.trim_start_matches("**Assets:**"));
146 continue;
147 }
148 if trimmed.starts_with("**Allowed Tools:**") {
149 allowed_tools = parse_csv_field(trimmed.trim_start_matches("**Allowed Tools:**"));
150 continue;
151 }
152
153 if trimmed == "## Body" || trimmed == "### Body" {
154 in_body = true;
155 continue;
156 }
157
158 if in_body {
159 body.push_str(line);
160 body.push('\n');
161 }
162 }
163
164 if name.is_empty() {
165 return None;
166 }
167
168 if body.is_empty() && !in_body {
169 body = content
171 .lines()
172 .skip_while(|l| !l.starts_with("**Trigger"))
173 .skip(1)
174 .collect::<Vec<_>>()
175 .join("\n");
176 }
177
178 Some(Skill {
179 name,
180 description: if description.is_empty() {
181 trigger.join(", ")
182 } else {
183 description
184 },
185 trigger,
186 body: body.trim().to_string(),
187 source_file: source_file.to_string(),
188 usage_count: 0,
189 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
190 score: 0.5,
191 auto_generated: false,
192 references,
193 templates,
194 scripts,
195 assets,
196 manifest_version: None,
197 allowed_tools,
198 })
199 }
200
201 pub fn to_markdown(&self) -> String {
203 format!(
204 "# Skill: {name}\n\n\
205 **Trigger:** {trigger}\n\n\
206 **Description:** {desc}\n\n\
207 **References:** {references}\n\n\
208 **Templates:** {templates}\n\n\
209 **Scripts:** {scripts}\n\n\
210 **Assets:** {assets}\n\n\
211 **Allowed Tools:** {allowed_tools}\n\n\
212 ## Body\n\
213 {body}\n",
214 name = self.name,
215 trigger = self.trigger.join(", "),
216 desc = self.description,
217 references = self.references.join(", "),
218 templates = self.templates.join(", "),
219 scripts = self.scripts.join(", "),
220 assets = self.assets.join(", "),
221 allowed_tools = self.allowed_tools.join(", "),
222 body = self.body,
223 )
224 }
225
226 pub fn relevance(&self, ctx: &str) -> f64 {
229 let lower = ctx.to_lowercase();
230 if self.trigger.is_empty() {
231 return 0.0;
232 }
233 let matches: usize = self
234 .trigger
235 .iter()
236 .filter(|kw| lower.contains(kw.as_str()))
237 .count();
238 if matches == 0 {
239 return 0.0;
240 }
241 matches as f64 / self.trigger.len() as f64
242 }
243}
244
245fn parse_csv_field(value: &str) -> Vec<String> {
246 value
247 .trim()
248 .split(',')
249 .map(|s| s.trim().to_string())
250 .filter(|s| !s.is_empty())
251 .collect()
252}
253
254fn apply_skill_manifest(skill: &mut Skill, dir: &Path) {
255 if let Some(manifest) = load_skill_manifest(dir) {
256 if manifest.version.is_some() {
257 skill.manifest_version = manifest.version;
258 }
259 if !manifest.allowed_tools.is_empty() {
260 skill.allowed_tools = manifest.allowed_tools;
261 }
262 }
263}
264
265fn load_skill_manifest(dir: &Path) -> Option<SkillManifest> {
266 let toml_path = dir.join("manifest.toml");
267 if toml_path.exists() {
268 return std::fs::read_to_string(toml_path)
269 .ok()
270 .and_then(|s| toml::from_str(&s).ok());
271 }
272 let json_path = dir.join("manifest.json");
273 if json_path.exists() {
274 return std::fs::read_to_string(json_path)
275 .ok()
276 .and_then(|s| serde_json::from_str(&s).ok());
277 }
278 None
279}
280
281pub trait SkillLibrary: Send + Sync {
284 fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill>;
285 fn add(&self, skill: Skill) -> anyhow::Result<()>;
286 fn all(&self) -> Vec<Skill>;
287 fn curate(&self) -> anyhow::Result<()>;
288 fn prune(&self, min_score: f64) -> anyhow::Result<usize>;
289 fn get(&self, name: &str) -> Option<Skill>;
290 fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>>;
291 fn remove(&self, name: &str) -> anyhow::Result<bool>;
293 fn skills_root(&self) -> Option<std::path::PathBuf> {
296 None
297 }
298}
299
300pub struct FsSkillLibrary {
303 skills_dir: PathBuf,
304 memory: Option<Arc<dyn Memory>>,
305}
306
307impl FsSkillLibrary {
308 pub fn new(skills_dir: PathBuf) -> Self {
309 std::fs::create_dir_all(&skills_dir).ok();
310 Self {
311 skills_dir,
312 memory: None,
313 }
314 }
315
316 pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
317 self.memory = Some(memory);
318 self
319 }
320
321 pub fn skills_dir(&self) -> &std::path::Path {
325 &self.skills_dir
326 }
327
328 pub fn scan(&self) -> Vec<Skill> {
330 let mut skills = Vec::new();
331 if let Ok(entries) = std::fs::read_dir(&self.skills_dir) {
332 for entry in entries.flatten() {
333 let path = entry.path();
334 if path.is_dir() {
335 let skill_file = path.join("SKILL.md");
337 if skill_file.exists() {
338 if let Ok(content) = std::fs::read_to_string(&skill_file) {
339 let rel = path
340 .file_name()
341 .map(|n| n.to_string_lossy().to_string())
342 .unwrap_or_default();
343 if let Some(mut skill) = Skill::from_markdown(&content, &rel) {
344 apply_skill_manifest(&mut skill, &path);
345 skills.push(skill);
346 }
347 }
348 }
349 } else if path
350 .file_name()
351 .map(|n| n.to_string_lossy().to_lowercase().ends_with(".skill.md"))
352 .unwrap_or(false)
353 {
354 if let Ok(content) = std::fs::read_to_string(&path) {
355 let rel = path
356 .file_name()
357 .map(|n| n.to_string_lossy().to_string())
358 .unwrap_or_default();
359 if let Some(mut skill) = Skill::from_markdown(&content, &rel) {
360 apply_skill_manifest(
361 &mut skill,
362 path.parent().unwrap_or(&self.skills_dir),
363 );
364 skills.push(skill);
365 }
366 }
367 }
368 }
369 }
370 skills
371 }
372}
373
374impl SkillLibrary for FsSkillLibrary {
375 fn skills_root(&self) -> Option<std::path::PathBuf> {
376 Some(self.skills_dir.clone())
377 }
378
379 fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill> {
380 let mut scored: Vec<(f64, Skill)> = self
381 .scan()
382 .into_iter()
383 .map(|s| {
384 let r = s.relevance(ctx);
385 (r, s)
386 })
387 .filter(|(r, _)| *r > 0.0)
388 .collect();
389
390 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
391
392 scored.into_iter().take(limit).map(|(_, s)| s).collect()
393 }
394
395 fn add(&self, skill: Skill) -> anyhow::Result<()> {
396 let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
398 std::fs::create_dir_all(&skill_dir)?;
399
400 let skill_file = skill_dir.join("SKILL.md");
401 let content = skill.to_markdown();
402 std::fs::write(&skill_file, content)?;
403
404 if let Some(mem) = &self.memory {
406 let _ = mem.upsert_doc(crate::memory::WorkingDoc {
407 id: format!("skill-{}", skill.name),
408 title: format!("Skill: {}", skill.name),
409 content: skill.body.clone(),
410 updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
411 });
412 }
413
414 Ok(())
415 }
416
417 fn all(&self) -> Vec<Skill> {
418 self.scan()
419 }
420
421 fn curate(&self) -> anyhow::Result<()> {
422 let curator = Curator::new();
423 curator.curate(&self.skills_dir)
424 }
425
426 fn prune(&self, min_score: f64) -> anyhow::Result<usize> {
427 let skills = self.scan();
428 let mut removed = 0;
429
430 for skill in &skills {
431 if skill.score < min_score && skill.auto_generated {
432 let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
433 if skill_dir.exists() {
434 std::fs::remove_dir_all(&skill_dir)?;
435 removed += 1;
436 }
437 }
438 }
439
440 Ok(removed)
441 }
442
443 fn get(&self, name: &str) -> Option<Skill> {
444 self.scan().into_iter().find(|s| s.name == name)
445 }
446
447 fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>> {
448 let Some(skill) = self.get(name) else {
449 return Ok(None);
450 };
451 let base = if skill.source_file.ends_with(".skill.md") {
452 self.skills_dir.clone()
453 } else {
454 self.skills_dir.join(&skill.source_file)
455 };
456 let load_files = |files: &[String]| -> Vec<(String, String)> {
457 let mut loaded = Vec::new();
458 for f in files {
459 let Ok(candidate) = safe_relative_path(&base, f) else {
460 continue;
461 };
462 if let Ok(content) = std::fs::read_to_string(&candidate) {
463 loaded.push((f.clone(), content));
464 }
465 }
466 loaded
467 };
468 Ok(Some(SkillInvocation {
469 loaded_references: load_files(&skill.references),
470 loaded_templates: load_files(&skill.templates),
471 loaded_scripts: load_files(&skill.scripts),
472 loaded_assets: load_files(&skill.assets),
473 skill,
474 }))
475 }
476
477 fn remove(&self, name: &str) -> anyhow::Result<bool> {
478 let skill_dir = self.skills_dir.join(safe_skill_dir_name(name)?);
480 let existed = skill_dir.exists();
481 if existed {
482 std::fs::remove_dir_all(&skill_dir)?;
483 }
484 Ok(existed)
485 }
486}
487
488fn safe_skill_dir_name(name: &str) -> anyhow::Result<String> {
489 let trimmed = name.trim();
490 if trimmed.is_empty()
491 || trimmed.contains("..")
492 || trimmed.contains('/')
493 || trimmed.contains('\\')
494 || trimmed.contains(':')
495 {
496 anyhow::bail!("invalid skill name '{}'", name);
497 }
498 Ok(trimmed.to_string())
499}
500
501fn safe_relative_path(base: &Path, relative: &str) -> anyhow::Result<PathBuf> {
502 let rel = Path::new(relative);
503 if rel.is_absolute()
504 || rel
505 .components()
506 .any(|c| matches!(c, std::path::Component::ParentDir))
507 {
508 anyhow::bail!("skill reference escapes skill directory: {}", relative);
509 }
510 let candidate = base.join(rel);
511 let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
512 let canonical_candidate = candidate
513 .canonicalize()
514 .unwrap_or_else(|_| candidate.to_path_buf());
515 if !canonical_candidate.starts_with(&canonical_base) || !canonical_candidate.exists() {
516 anyhow::bail!("skill reference outside base or missing: {}", relative);
517 }
518 Ok(canonical_candidate)
519}
520
521pub struct Curator {
524 min_score: f64,
525 max_skills: usize,
526}
527
528impl Curator {
529 pub fn new() -> Self {
530 Self {
531 min_score: 0.2,
532 max_skills: 100,
533 }
534 }
535
536 pub fn curate(&self, skills_dir: &Path) -> anyhow::Result<()> {
540 let library = FsSkillLibrary::new(skills_dir.to_path_buf());
541 let mut skills = library.all();
542
543 if skills.is_empty() {
544 return Ok(());
545 }
546
547 skills.retain(|s| {
551 let poisoned = is_unfit_for_skill(&s.description) || is_unfit_for_skill(&s.body);
552 if poisoned {
553 if let Ok(dir_name) = safe_skill_dir_name(&s.name) {
554 let _ = std::fs::remove_dir_all(skills_dir.join(dir_name));
555 }
556 tracing::warn!("Curator: purged poisoned skill `{}`", s.name);
557 }
558 !poisoned
559 });
560
561 if skills.is_empty() {
562 return Ok(());
563 }
564
565 for skill in &mut skills {
567 skill.score += skill.usage_count as f64 * 0.05;
569 skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
571 skill.score = skill.score.min(1.0);
573 }
574
575 let mut merged = Vec::new();
577 let mut merged_indices = std::collections::HashSet::new();
578
579 for i in 0..skills.len() {
580 if merged_indices.contains(&i) {
581 continue;
582 }
583 let mut current = skills[i].clone();
584
585 for j in (i + 1)..skills.len() {
586 if merged_indices.contains(&j) {
587 continue;
588 }
589 let name_overlap = current.name
591 [..current.name.len().min(3).min(skills[j].name.len())]
592 == skills[j].name[..skills[j].name.len().min(3).min(current.name.len())];
593
594 let trigger_overlap = {
595 let a: std::collections::HashSet<_> = current.trigger.iter().cloned().collect();
596 let b: std::collections::HashSet<_> =
597 skills[j].trigger.iter().cloned().collect();
598 let intersection = a.intersection(&b).count();
599 let union = a.union(&b).count();
600 if union == 0 {
601 false
602 } else {
603 intersection as f64 / union as f64 > 0.5
604 }
605 };
606
607 if name_overlap || trigger_overlap {
608 current.body = format!("{}\n\n---\n\n{}", current.body, skills[j].body);
610 current.score = current.score.max(skills[j].score);
611 current.trigger.extend(skills[j].trigger.clone());
612 current.trigger.sort();
613 current.trigger.dedup();
614 merged_indices.insert(j);
615 }
616 }
617 merged.push(current);
618 }
619
620 merged.retain(|s| !s.auto_generated || s.score >= self.min_score);
622 merged.sort_by(|a, b| {
623 b.score
624 .partial_cmp(&a.score)
625 .unwrap_or(std::cmp::Ordering::Equal)
626 });
627 if merged.len() > self.max_skills {
628 merged.truncate(self.max_skills);
629 }
630
631 for skill in &merged {
634 let skill_dir = skills_dir.join(safe_skill_dir_name(&skill.name)?);
635 std::fs::create_dir_all(&skill_dir)?;
636 std::fs::write(skill_dir.join("SKILL.md"), skill.to_markdown())?;
637 }
638
639 tracing::info!(
640 "Curator: {} skills before → {} after (deduped {}, pruned {})",
641 skills.len(),
642 merged.len(),
643 skills.len() - merged_indices.len(),
644 skills.len() + merged_indices.len() - merged.len() - skills.len().min(merged.len()),
645 );
646
647 Ok(())
648 }
649
650 pub fn propose_skill(run_description: &str, outcome: &str) -> Option<Skill> {
653 let words: Vec<&str> = run_description.split_whitespace().collect();
654 let lower = run_description.to_lowercase();
655 let outcome_lower = outcome.to_lowercase();
656
657 if words.len() < 5 || outcome_lower.contains("error") {
658 return None;
659 }
660
661 if is_unfit_for_skill(run_description) || is_unfit_for_skill(outcome) {
667 return None;
668 }
669
670 let specificity_markers = [
671 "github.com",
672 "http",
673 "https",
674 "this ",
675 "that ",
676 "the file",
677 "my ",
678 "your ",
679 "2024",
680 "2025",
681 "2026",
682 ];
683 if specificity_markers
684 .iter()
685 .any(|marker| lower.contains(marker))
686 {
687 return None;
688 }
689 if words.iter().any(|word| {
690 let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
691 cleaned
694 .chars()
695 .next()
696 .map(|c| c.is_uppercase())
697 .unwrap_or(false)
698 && cleaned.chars().count() > 12
699 }) {
700 return None;
701 }
702
703 let has_concrete_output = [
704 "diff", "fn ", "struct ", "impl ", "test", "fixed", "refactor", "added", "updated",
705 "created", "modified", "patch", "write", "edit", "return", "async", "pub ", "let ",
706 "const ", "mod ",
707 ]
708 .iter()
709 .any(|needle| outcome_lower.contains(needle));
710 if !has_concrete_output {
711 return None;
712 }
713
714 let name = skill_name_from_pattern(run_description)?.to_string();
715 let triggers = skill_triggers_for_pattern(&name);
716
717 Some(Skill {
718 name,
719 description: format!("Reusable pattern learned from: {}", run_description),
720 trigger: triggers,
721 body: format!(
722 "## Context\nTask: {}\n\n## Approach\n{}",
723 run_description, outcome
724 ),
725 source_file: String::new(),
726 usage_count: 0,
727 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
728 score: 0.3,
729 auto_generated: true,
730 references: Vec::new(),
731 templates: Vec::new(),
732 scripts: Vec::new(),
733 assets: Vec::new(),
734 manifest_version: None,
735 allowed_tools: Vec::new(),
736 })
737 }
738
739 pub fn propose_skill_if_missing(
740 run_description: &str,
741 outcome: &str,
742 library: &dyn SkillLibrary,
743 ) -> Option<Skill> {
744 let candidate = Self::propose_skill(run_description, outcome)?;
745 if library.get(&candidate.name).is_some() {
746 None
747 } else {
748 Some(candidate)
749 }
750 }
751}
752
753pub fn is_unfit_for_skill(text: &str) -> bool {
758 let lower = text.to_lowercase();
759 const UI_ARTIFACTS: &[&str] = &[
761 "◌",
762 "consulting ",
763 "parsing request",
764 "↑",
765 "↓",
766 "completed ·",
767 "route set",
768 "reusable pattern learned from",
769 "metrics captured",
770 ];
771 if UI_ARTIFACTS.iter().any(|m| lower.contains(m)) {
772 return true;
773 }
774 const COMPLAINT_MARKERS: &[&str] = &[
776 "tu as vraiment un problème",
777 "regarde ce que tu m'as",
778 "n'importe quoi",
779 "ça marche pas",
780 "ne marche pas",
781 "you have a problem",
782 "this is broken",
783 "that's wrong",
784 ];
785 if COMPLAINT_MARKERS.iter().any(|m| lower.contains(m)) {
786 return true;
787 }
788 false
789}
790
791pub fn skill_name_from_pattern(description: &str) -> Option<&'static str> {
792 let d = description.to_lowercase();
793 if d.contains("test") && (d.contains("add") || d.contains("write") || d.contains("fix")) {
794 return Some("write-and-fix-tests");
795 }
796 if d.contains("refactor") || d.contains("rename") || d.contains("extract") {
797 return Some("refactor-safely");
798 }
799 if d.contains("debug") || d.contains("error") || d.contains("panic") || d.contains("crash") {
800 return Some("debug-systematically");
801 }
802 if d.contains("document")
803 || d.contains("comment")
804 || d.contains("readme")
805 || d.contains("docstring")
806 {
807 return Some("write-docs");
808 }
809 if d.contains("secur") || d.contains("vulnerab") || d.contains("audit") {
810 return Some("security-audit");
811 }
812 if d.contains("performance") || d.contains("slow") || d.contains("optim") || d.contains("bench")
813 {
814 return Some("performance-profile");
815 }
816 if d.contains("upgrade") || d.contains("bump") || d.contains("depend") || d.contains("package")
817 {
818 return Some("upgrade-dependencies");
819 }
820 if d.contains("review") || d.contains("pr") || d.contains("pull request") || d.contains("diff")
821 {
822 return Some("code-review");
823 }
824 if d.contains("git") || d.contains("commit") || d.contains("branch") || d.contains("merge") {
825 return Some("git-workflow");
826 }
827 None
828}
829
830fn skill_triggers_for_pattern(name: &str) -> Vec<String> {
831 match name {
832 "write-and-fix-tests" => vec!["test", "unit", "fix", "assert"],
833 "refactor-safely" => vec!["refactor", "rename", "extract", "safe"],
834 "debug-systematically" => vec!["debug", "error", "panic", "crash"],
835 "write-docs" => vec!["document", "readme", "comment", "docstring"],
836 "security-audit" => vec!["security", "audit", "vulnerability", "safe"],
837 "performance-profile" => vec!["performance", "slow", "optimize", "bench"],
838 "upgrade-dependencies" => vec!["upgrade", "bump", "dependency", "package"],
839 "code-review" => vec!["review", "pr", "diff", "pull-request"],
840 "git-workflow" => vec!["git", "commit", "branch", "merge"],
841 _ => vec!["skill"],
842 }
843 .into_iter()
844 .map(String::from)
845 .collect()
846}
847
848impl Default for Curator {
849 fn default() -> Self {
850 Self::new()
851 }
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857
858 fn temp_dir(name: &str) -> PathBuf {
859 std::env::temp_dir().join(format!(
860 "sparrow-tier2-{name}-{}",
861 std::time::SystemTime::now()
862 .duration_since(std::time::UNIX_EPOCH)
863 .unwrap()
864 .as_nanos()
865 ))
866 }
867
868 #[test]
869 fn skill_invocation_rejects_parent_dir_references() {
870 let root = temp_dir("skill-ref-escape");
871 std::fs::create_dir_all(root.join("review").join("references")).unwrap();
872 std::fs::write(
873 root.join("review").join("SKILL.md"),
874 "# Skill: review\n\n**Trigger:** review\n\n**References:** ../secret.txt, references/checklist.md\n\n## Body\nReview carefully.",
875 )
876 .unwrap();
877 std::fs::write(
878 root.join("review").join("references").join("checklist.md"),
879 "ok",
880 )
881 .unwrap();
882 std::fs::write(root.join("secret.txt"), "nope").unwrap();
883
884 let lib = FsSkillLibrary::new(root.clone());
885 let invocation = lib.invoke("review").unwrap().expect("skill should exist");
886
887 assert_eq!(invocation.loaded_references.len(), 1);
888 assert_eq!(invocation.loaded_references[0].0, "references/checklist.md");
889
890 let _ = std::fs::remove_dir_all(root);
891 }
892
893 #[test]
894 fn curator_preserves_skill_assets_and_updates_skill_md_only() {
895 let root = temp_dir("curator-assets");
896 let skill_dir = root.join("refactor-safely");
897 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
898 std::fs::write(skill_dir.join("references").join("checklist.md"), "keep me").unwrap();
899 std::fs::write(
900 skill_dir.join("SKILL.md"),
901 "# Skill: refactor-safely\n\n**Trigger:** refactor, rename\n\n**References:** references/checklist.md\n\n## Body\nMove in small steps.",
902 )
903 .unwrap();
904
905 Curator::new().curate(&root).unwrap();
906
907 assert!(
908 skill_dir.join("references").join("checklist.md").exists(),
909 "curator must not delete progressive-disclosure assets"
910 );
911 let lib = FsSkillLibrary::new(root.clone());
912 let invocation = lib
913 .invoke("refactor-safely")
914 .unwrap()
915 .expect("skill should remain");
916 assert_eq!(invocation.loaded_references[0].1, "keep me");
917
918 let _ = std::fs::remove_dir_all(root);
919 }
920
921 #[test]
922 fn curator_purges_poisoned_skill_from_disk() {
923 let root = temp_dir("curator-purge");
926 let toxic = root.join("code-review");
927 std::fs::create_dir_all(&toxic).unwrap();
928 std::fs::write(
929 toxic.join("SKILL.md"),
930 "# Skill: code-review\n\n**Trigger:** review, pr, diff\n\n**Description:** Reusable pattern learned from: non tu as vraiment un problème regarde ce que tu m'as écris : coder ◌ consulting deepseek-v4-pro\n\n## Body\n✓ coder completed · 4487↑ 150↓ tok",
931 )
932 .unwrap();
933 let good = root.join("refactor-safely");
935 std::fs::create_dir_all(&good).unwrap();
936 std::fs::write(
937 good.join("SKILL.md"),
938 "# Skill: refactor-safely\n\n**Trigger:** refactor\n\n**Description:** Move code in small verified steps.\n\n## Body\nExtract, compile, test, repeat.",
939 )
940 .unwrap();
941
942 Curator::new().curate(&root).unwrap();
943
944 assert!(!toxic.exists(), "poisoned skill dir must be removed");
945 assert!(good.exists(), "legitimate skill must survive");
946
947 let _ = std::fs::remove_dir_all(root);
948 }
949
950 #[test]
951 fn propose_skill_rejects_ui_status_and_complaints() {
952 assert!(
954 Curator::propose_skill(
955 "non tu as vraiment un problème regarde ce que tu m'as écris coder",
956 "✓ coder completed · 4487↑ 150↓ tok",
957 )
958 .is_none()
959 );
960 assert!(is_unfit_for_skill(
961 "coder ◌ consulting deepseek-v4-pro · parsing request…"
962 ));
963 assert!(!is_unfit_for_skill(
964 "Refactor the auth module by extracting the token parser into its own function."
965 ));
966 }
967
968 #[test]
969 fn skill_names_cannot_escape_skill_root() {
970 let root = temp_dir("skill-name-escape");
971 let lib = FsSkillLibrary::new(root.clone());
972 let skill = Skill {
973 name: "../outside".into(),
974 description: "bad".into(),
975 trigger: vec!["bad".into()],
976 body: "bad".into(),
977 source_file: String::new(),
978 usage_count: 0,
979 created_at: String::new(),
980 score: 0.5,
981 auto_generated: false,
982 references: Vec::new(),
983 templates: Vec::new(),
984 scripts: Vec::new(),
985 assets: Vec::new(),
986 manifest_version: None,
987 allowed_tools: Vec::new(),
988 };
989
990 assert!(lib.add(skill).is_err());
991 assert!(!root.join("..").join("outside").exists());
992
993 let _ = std::fs::remove_dir_all(root);
994 }
995
996 #[test]
997 fn skill_manifest_restricts_tool_specs() {
998 let root = temp_dir("skill-manifest-tools");
999 let skill_dir = root.join("read-only-review");
1000 std::fs::create_dir_all(&skill_dir).unwrap();
1001 std::fs::write(
1002 skill_dir.join("SKILL.md"),
1003 "# Skill: read-only-review\n\n**Trigger:** review\n\n## Body\nInspect only.",
1004 )
1005 .unwrap();
1006 std::fs::write(
1007 skill_dir.join("manifest.toml"),
1008 "version = \"2\"\nallowed_tools = [\"fs_read\"]\n",
1009 )
1010 .unwrap();
1011
1012 let lib = FsSkillLibrary::new(root.clone());
1013 let invocation = lib
1014 .invoke("read-only-review")
1015 .unwrap()
1016 .expect("skill should load");
1017 assert_eq!(invocation.skill.manifest_version.as_deref(), Some("2"));
1018 assert_eq!(invocation.skill.allowed_tools, vec!["fs_read"]);
1019
1020 let mut registry = crate::tools::ToolRegistry::new();
1021 registry.register(std::sync::Arc::new(crate::tools::fs::FsRead));
1022 registry.register(std::sync::Arc::new(crate::tools::fs::FsWrite));
1023 let specs = registry.to_specs_for_skill(&invocation.skill);
1024 let names: Vec<_> = specs.into_iter().map(|s| s.name).collect();
1025 assert_eq!(names, vec!["fs_read"]);
1026
1027 let _ = std::fs::remove_dir_all(root);
1028 }
1029}