1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs,
4 path::{Path, PathBuf},
5};
6
7use cookie_agent_protocol::{ModelKey, PermissionAction, SkillSource, WildcardPattern, paths};
8use serde::{Deserialize, Serialize};
9
10use crate::ConfigError;
11
12const MAX_SKILL_BYTES: u64 = 256 * 1024;
13
14#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum SkillContext {
17 Fork,
18}
19
20#[derive(Clone, Debug)]
21pub struct SkillAllowedTool {
22 pub action: PermissionAction,
23 pub pattern: WildcardPattern,
24}
25
26#[derive(Clone, Debug, Deserialize)]
27#[serde(rename_all = "kebab-case", deny_unknown_fields)]
28pub struct SkillFrontmatter {
29 pub name: String,
30 pub description: String,
31 #[serde(default, rename = "when_to_use")]
32 pub when_to_use: Option<String>,
33 #[serde(default, deserialize_with = "deserialize_allowed_tools")]
34 pub allowed_tools: Vec<SkillAllowedTool>,
35 #[serde(default)]
36 pub disable_model_invocation: bool,
37 #[serde(default = "default_true")]
38 pub user_invocable: bool,
39 #[serde(default, deserialize_with = "deserialize_model")]
40 pub model: Option<ModelKey>,
41 #[serde(default)]
42 pub context: Option<SkillContext>,
43 #[serde(default)]
44 pub argument_hint: Option<String>,
45 #[serde(default)]
46 pub license: Option<String>,
47 #[serde(default)]
48 pub compatibility: Option<String>,
49 #[serde(default)]
50 pub metadata: BTreeMap<String, serde_yaml::Value>,
51}
52
53const fn default_true() -> bool {
54 true
55}
56
57fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Vec<SkillAllowedTool>, D::Error>
58where
59 D: serde::Deserializer<'de>,
60{
61 let value = String::deserialize(deserializer)?;
62 parse_allowed_tools(&value).map_err(serde::de::Error::custom)
63}
64
65fn deserialize_model<'de, D>(deserializer: D) -> Result<Option<ModelKey>, D::Error>
66where
67 D: serde::Deserializer<'de>,
68{
69 Option::<String>::deserialize(deserializer)?
70 .map(|value| {
71 value
72 .parse::<ModelKey>()
73 .map_err(|_| serde::de::Error::custom(format!("invalid skill model `{value}`")))
74 })
75 .transpose()
76}
77
78fn parse_allowed_tools(value: &str) -> Result<Vec<SkillAllowedTool>, String> {
79 if value.is_empty() || value.trim().is_empty() {
80 return Err("allowed-tools contains an empty token".into());
81 }
82 let mut parsed = Vec::new();
83 let mut offset = 0;
84 while offset < value.len() {
85 while value[offset..].starts_with(char::is_whitespace) {
86 offset += value[offset..]
87 .chars()
88 .next()
89 .expect("offset is within string")
90 .len_utf8();
91 if offset == value.len() {
92 break;
93 }
94 }
95 if offset == value.len() {
96 break;
97 }
98 let start = offset;
99 let name_end = value[offset..]
100 .char_indices()
101 .find_map(|(index, character)| {
102 (character == '(' || character.is_whitespace()).then_some(offset + index)
103 })
104 .unwrap_or(value.len());
105 if name_end == start {
106 return Err(format!(
107 "malformed allowed-tools token near `{}`: tool name is empty",
108 &value[start..]
109 ));
110 }
111 let name = &value[start..name_end];
112 offset = name_end;
113 let pattern = if offset < value.len() && value[offset..].starts_with('(') {
114 offset += 1;
115 let pattern_start = offset;
116 let mut depth = 1_u32;
117 let mut closing = None;
118 for (index, character) in value[offset..].char_indices() {
119 match character {
120 '(' => depth = depth.saturating_add(1),
121 ')' => {
122 depth -= 1;
123 if depth == 0 {
124 closing = Some(offset + index);
125 break;
126 }
127 }
128 _ => {}
129 }
130 }
131 let closing = closing.ok_or_else(|| {
132 format!("malformed allowed-tools token `{name}`: unbalanced parentheses")
133 })?;
134 if closing == pattern_start {
135 return Err(format!(
136 "malformed allowed-tools token `{name}()`: pattern is empty"
137 ));
138 }
139 offset = closing + 1;
140 if offset < value.len()
141 && value[offset..]
142 .chars()
143 .next()
144 .is_some_and(|character| !character.is_whitespace())
145 {
146 return Err(format!(
147 "malformed allowed-tools token `{}`: expected whitespace after final `)`",
148 &value[start..]
149 ));
150 }
151 &value[pattern_start..closing]
152 } else {
153 "*"
154 };
155 let action = tool_action(name).ok_or_else(|| {
156 format!(
157 "unknown allowed-tools tool `{name}`; valid values are Read, Write, Edit, Bash, Delegate, Skill, Mcp, and Plugin"
158 )
159 })?;
160 if let Some(prefix) = pattern.strip_suffix(":*") {
161 if prefix.is_empty() {
162 return Err(format!(
163 "malformed allowed-tools token `{name}({pattern})`: prefix is empty"
164 ));
165 }
166 for expanded in [prefix.to_owned(), format!("{prefix} *")] {
167 let pattern = WildcardPattern::new(expanded).map_err(|error| {
168 format!("invalid allowed-tools prefix for `{name}`: {error}")
169 })?;
170 parsed.push(SkillAllowedTool { action, pattern });
171 }
172 } else {
173 let pattern = WildcardPattern::new(pattern)
174 .map_err(|error| format!("invalid allowed-tools pattern for `{name}`: {error}"))?;
175 parsed.push(SkillAllowedTool { action, pattern });
176 }
177 }
178 if parsed.is_empty() {
179 return Err("allowed-tools contains an empty token".into());
180 }
181 Ok(parsed)
182}
183
184fn tool_action(name: &str) -> Option<PermissionAction> {
185 if name.eq_ignore_ascii_case("read") {
186 Some(PermissionAction::Read)
187 } else if name.eq_ignore_ascii_case("write") || name.eq_ignore_ascii_case("edit") {
188 Some(PermissionAction::Write)
189 } else if name.eq_ignore_ascii_case("bash") {
190 Some(PermissionAction::Bash)
191 } else if name.eq_ignore_ascii_case("delegate") {
192 Some(PermissionAction::Delegate)
193 } else if name.eq_ignore_ascii_case("skill") {
194 Some(PermissionAction::Skill)
195 } else if name.eq_ignore_ascii_case("webfetch") {
196 Some(PermissionAction::Webfetch)
197 } else if name.eq_ignore_ascii_case("mcp") {
198 Some(PermissionAction::Mcp)
199 } else if name.eq_ignore_ascii_case("plugin") {
200 Some(PermissionAction::Plugin)
201 } else {
202 None
203 }
204}
205
206#[derive(Clone, Debug)]
207pub struct SkillDocument {
208 pub frontmatter: SkillFrontmatter,
209 pub body: String,
210 pub source: SkillSource,
211 pub path: PathBuf,
212 pub base_dir: PathBuf,
213 pub supporting_files: Vec<PathBuf>,
214}
215
216#[derive(Clone, Debug)]
217pub struct SkillDiscovery {
218 pub name: String,
219 pub source: SkillSource,
220 pub path: PathBuf,
221 pub precedence_winner: bool,
222}
223
224#[derive(Clone, Debug)]
225pub struct SkillDiagnostic {
226 pub name: String,
227 pub message: String,
228 pub shadowed_path: PathBuf,
229 pub winner_path: PathBuf,
230}
231
232#[derive(Clone, Debug, Default)]
233pub struct SkillRegistry {
234 skills: BTreeMap<String, SkillDocument>,
235 discoveries: Vec<SkillDiscovery>,
236 diagnostics: Vec<SkillDiagnostic>,
237}
238
239impl SkillRegistry {
240 #[must_use]
241 pub fn get(&self, name: &str) -> Option<&SkillDocument> {
242 self.skills.get(name)
243 }
244
245 pub fn skills(&self) -> impl ExactSizeIterator<Item = (&String, &SkillDocument)> {
246 self.skills.iter()
247 }
248
249 #[must_use]
250 pub fn discoveries(&self) -> &[SkillDiscovery] {
251 &self.discoveries
252 }
253
254 #[must_use]
255 pub fn diagnostics(&self) -> &[SkillDiagnostic] {
256 &self.diagnostics
257 }
258
259 #[must_use]
260 pub fn is_empty(&self) -> bool {
261 self.skills.is_empty()
262 }
263}
264
265pub fn load_skills(cwd: &Path) -> Result<SkillRegistry, ConfigError> {
267 let cwd = cwd.canonicalize().map_err(ConfigError::Io)?;
268 let shared_user = paths::home_dir()
269 .ok()
270 .map(|home| home.join(".agents/skills"));
271 let user = user_skill_root();
272 let project = project_skill_roots(&cwd);
273 load_skill_roots(shared_user.as_deref(), user.as_deref(), &project)
274}
275
276pub fn load_skill_roots(
282 shared_user_root: Option<&Path>,
283 user_root: Option<&Path>,
284 project_roots: &[PathBuf],
285) -> Result<SkillRegistry, ConfigError> {
286 let mut candidates = Vec::new();
287 if let Some(root) = shared_user_root {
288 candidates.extend(load_root(root, SkillSource::User)?);
289 }
290 if let Some(root) = user_root {
291 candidates.extend(load_root(root, SkillSource::User)?);
292 }
293 for directory in project_roots {
294 candidates.extend(load_root(
295 &directory.join(".agents/skills"),
296 SkillSource::Project,
297 )?);
298 candidates.extend(load_root(
299 &directory.join(".cookie-agent/skills"),
300 SkillSource::Project,
301 )?);
302 }
303
304 let mut winner_index = BTreeMap::<String, usize>::new();
305 for (index, document) in candidates.iter().enumerate() {
306 winner_index.insert(document.frontmatter.name.clone(), index);
307 }
308 let mut registry = SkillRegistry::default();
309 for (index, document) in candidates.into_iter().enumerate() {
310 let name = document.frontmatter.name.clone();
311 let precedence_winner = winner_index.get(&name) == Some(&index);
312 registry.discoveries.push(SkillDiscovery {
313 name: name.clone(),
314 source: document.source,
315 path: document.path.clone(),
316 precedence_winner,
317 });
318 if precedence_winner {
319 registry.skills.insert(name, document);
320 }
321 }
322 for discovery in registry
323 .discoveries
324 .iter()
325 .filter(|entry| entry.source == SkillSource::User && !entry.precedence_winner)
326 {
327 let winner = registry
328 .skills
329 .get(&discovery.name)
330 .expect("shadowed skill has a winner");
331 let winner_scope = match winner.source {
332 SkillSource::User => "user",
333 SkillSource::Project => "project",
334 };
335 registry.diagnostics.push(SkillDiagnostic {
336 name: discovery.name.clone(),
337 message: format!(
338 "user skill `{}` is shadowed by {winner_scope} skill {}",
339 discovery.name,
340 winner.path.display()
341 ),
342 shadowed_path: discovery.path.clone(),
343 winner_path: winner.path.clone(),
344 });
345 }
346 registry.discoveries.sort_by(|left, right| {
347 left.name
348 .cmp(&right.name)
349 .then_with(|| left.path.cmp(&right.path))
350 });
351 Ok(registry)
352}
353
354fn user_skill_root() -> Option<PathBuf> {
355 paths::user_data_root().ok().map(|root| root.join("skills"))
356}
357
358fn project_skill_roots(cwd: &Path) -> Vec<PathBuf> {
359 let ancestors = cwd.ancestors().collect::<Vec<_>>();
360 let stop = ancestors
361 .iter()
362 .position(|path| path.join(".git").exists())
363 .unwrap_or(0);
364 ancestors[..=stop]
365 .iter()
366 .rev()
367 .map(|path| path.to_path_buf())
368 .collect()
369}
370
371fn load_root(root: &Path, source: SkillSource) -> Result<Vec<SkillDocument>, ConfigError> {
372 let metadata = match fs::metadata(root) {
373 Ok(metadata) => metadata,
374 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
375 Err(error) => return Err(ConfigError::Io(error)),
376 };
377 if !metadata.is_dir() {
378 return Err(ConfigError::UnsafePath);
379 }
380 let mut directories = fs::read_dir(root)
381 .map_err(ConfigError::Io)?
382 .collect::<Result<Vec<_>, _>>()
383 .map_err(ConfigError::Io)?;
384 directories.sort_by_key(std::fs::DirEntry::file_name);
385 let mut documents = Vec::new();
386 for entry in directories {
387 let file_type = entry.file_type().map_err(ConfigError::Io)?;
388 if !file_type.is_dir() || file_type.is_symlink() {
389 return Err(ConfigError::UnsafePath);
390 }
391 let directory_name =
392 entry
393 .file_name()
394 .into_string()
395 .map_err(|_| ConfigError::SkillName {
396 path: entry.path(),
397 name: "<non-UTF-8>".into(),
398 })?;
399 validate_name(&directory_name, &entry.path())?;
400 let path = entry.path().join("SKILL.md");
401 let metadata = fs::metadata(&path).map_err(|error| {
402 if error.kind() == std::io::ErrorKind::NotFound {
403 ConfigError::SkillDocument {
404 path: path.clone(),
405 message: "required SKILL.md was not found".into(),
406 }
407 } else {
408 ConfigError::Io(error)
409 }
410 })?;
411 if !metadata.is_file() || metadata.len() > MAX_SKILL_BYTES {
412 return Err(ConfigError::SkillDocument {
413 path,
414 message: "SKILL.md is not a regular file or exceeds 256 KiB".into(),
415 });
416 }
417 let bytes = fs::read(&path).map_err(ConfigError::Io)?;
418 documents.push(parse_skill(
419 &directory_name,
420 &bytes,
421 source,
422 &path,
423 &entry.path(),
424 )?);
425 }
426 Ok(documents)
427}
428
429fn parse_skill(
430 directory_name: &str,
431 bytes: &[u8],
432 source: SkillSource,
433 path: &Path,
434 base_dir: &Path,
435) -> Result<SkillDocument, ConfigError> {
436 let text = std::str::from_utf8(bytes)
437 .map_err(|_| skill_document(path, "content is not UTF-8"))?
438 .replace("\r\n", "\n")
439 .replace('\r', "\n");
440 let rest = text
441 .strip_prefix("---\n")
442 .ok_or_else(|| skill_document(path, "frontmatter must start with `---`"))?;
443 let closing = rest
444 .find("\n---\n")
445 .ok_or_else(|| skill_document(path, "frontmatter must end with `---`"))?;
446 let yaml = &rest[..closing];
447 let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml).map_err(|error| {
448 let rendered = error.to_string();
449 let message = if rendered.contains("unknown field") {
450 format!("unknown frontmatter field: {rendered}")
451 } else if rendered.contains("missing field") {
452 format!("missing required frontmatter field: {rendered}")
453 } else {
454 format!("malformed YAML frontmatter: {rendered}")
455 };
456 skill_document(path, message)
457 })?;
458 validate_name(&frontmatter.name, path)?;
459 if frontmatter.name != directory_name {
460 return Err(ConfigError::SkillNameMismatch {
461 path: path.to_owned(),
462 directory: directory_name.to_owned(),
463 frontmatter: frontmatter.name,
464 });
465 }
466 if frontmatter.description.is_empty()
467 || frontmatter.description.len() > 1024
468 || frontmatter.description.chars().any(char::is_control)
469 {
470 return Err(skill_document(
471 path,
472 "description must be 1-1024 control-free UTF-8 bytes",
473 ));
474 }
475 let body = format!(
476 "{}\n",
477 rest[closing + "\n---\n".len()..].trim_end_matches('\n')
478 );
479 if body.trim().is_empty() {
480 return Err(skill_document(path, "skill body is empty"));
481 }
482 let supporting_files = supporting_file_sample(base_dir, path)?;
483 Ok(SkillDocument {
484 frontmatter,
485 body,
486 source,
487 path: path.to_owned(),
488 base_dir: base_dir.to_owned(),
489 supporting_files,
490 })
491}
492
493fn validate_name(name: &str, path: &Path) -> Result<(), ConfigError> {
494 let valid = !name.is_empty()
495 && name.len() <= 64
496 && name.split('-').all(|part| {
497 !part.is_empty()
498 && part
499 .bytes()
500 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
501 });
502 if valid {
503 Ok(())
504 } else {
505 Err(ConfigError::SkillName {
506 path: path.to_owned(),
507 name: name.to_owned(),
508 })
509 }
510}
511
512fn supporting_file_sample(base_dir: &Path, skill_path: &Path) -> Result<Vec<PathBuf>, ConfigError> {
513 let mut pending = vec![base_dir.to_owned()];
514 let mut files = BTreeSet::new();
515 while let Some(directory) = pending.pop() {
516 let mut entries = fs::read_dir(&directory)
517 .map_err(ConfigError::Io)?
518 .collect::<Result<Vec<_>, _>>()
519 .map_err(ConfigError::Io)?;
520 entries.sort_by_key(std::fs::DirEntry::file_name);
521 for entry in entries {
522 let file_type = entry.file_type().map_err(ConfigError::Io)?;
523 if file_type.is_symlink() {
524 continue;
525 }
526 if file_type.is_dir() {
527 pending.push(entry.path());
528 } else if file_type.is_file() && entry.path() != skill_path {
529 files.insert(entry.path());
530 if files.len() == 10 {
531 return Ok(files.into_iter().collect());
532 }
533 }
534 }
535 }
536 Ok(files.into_iter().collect())
537}
538
539fn skill_document(path: &Path, message: impl Into<String>) -> ConfigError {
540 ConfigError::SkillDocument {
541 path: path.to_owned(),
542 message: message.into(),
543 }
544}
545
546pub fn render_available_skills<'a>(
547 skills: impl IntoIterator<Item = &'a SkillDocument>,
548 context_tokens: u64,
549) -> Result<String, ConfigError> {
550 let skills = skills.into_iter().collect::<Vec<_>>();
551 if skills.is_empty() {
552 return Ok(String::new());
553 }
554 let budget = usize::try_from(context_tokens / 100)
555 .unwrap_or(usize::MAX)
556 .saturating_mul(4);
557 let render = |include_when: bool, descriptions: &[String]| {
558 let mut output = String::from("<available_skills>");
559 for (skill, description) in skills.iter().zip(descriptions) {
560 output.push_str("<skill><name>");
561 output.push_str(&escape_xml(&skill.frontmatter.name));
562 output.push_str("</name><description>");
563 output.push_str(&escape_xml(description));
564 if include_when && let Some(when) = &skill.frontmatter.when_to_use {
565 output.push(' ');
566 output.push_str(&escape_xml(when));
567 }
568 output.push_str("</description><location>");
569 output.push_str(&escape_xml(&skill.path.to_string_lossy()));
570 output.push_str("</location></skill>");
571 }
572 output.push_str("</available_skills>");
573 output
574 };
575 let mut descriptions = skills
576 .iter()
577 .map(|skill| skill.frontmatter.description.clone())
578 .collect::<Vec<_>>();
579 let full = render(true, &descriptions);
580 if full.len() <= budget {
581 return Ok(full);
582 }
583 let without_when = render(false, &descriptions);
584 if without_when.len() <= budget {
585 return Ok(without_when);
586 }
587 loop {
588 let current = render(false, &descriptions);
589 if current.len() <= budget {
590 return Ok(current);
591 }
592 let Some((index, _)) = descriptions
593 .iter()
594 .enumerate()
595 .filter(|(_, description)| !description.is_empty())
596 .max_by_key(|(_, description)| description.len())
597 else {
598 return Err(ConfigError::SkillListingBudget);
599 };
600 descriptions[index].pop();
601 }
602}
603
604fn escape_xml(value: &str) -> String {
605 value
606 .replace('&', "&")
607 .replace('<', "<")
608 .replace('>', ">")
609 .replace('"', """)
610 .replace('\'', "'")
611}
612
613#[cfg(test)]
614mod tests {
615 use std::fs;
616
617 use cookie_agent_protocol::PermissionAction;
618
619 use super::{load_skill_roots, parse_allowed_tools, render_available_skills, validate_name};
620 use crate::simple_wildcard_match;
621
622 #[test]
623 fn validates_locked_skill_name_grammar() {
624 for valid in ["a", "review-code", "skill9"] {
625 assert!(validate_name(valid, std::path::Path::new(valid)).is_ok());
626 }
627 for invalid in ["", "UPPER", "two--parts", "-leading", "trailing-"] {
628 assert!(validate_name(invalid, std::path::Path::new(invalid)).is_err());
629 }
630 }
631
632 #[test]
633 fn parses_agentskills_allowed_tools_format() {
634 let parsed = parse_allowed_tools("Bash(git:*) Bash(jq:*) Read").expect("spec example");
635 assert_eq!(parsed.len(), 5);
636 assert_eq!(parsed[0].action, PermissionAction::Bash);
637 assert_eq!(parsed[0].pattern.as_str(), "git");
638 assert_eq!(parsed[1].pattern.as_str(), "git *");
639 assert_eq!(parsed[2].pattern.as_str(), "jq");
640 assert_eq!(parsed[3].pattern.as_str(), "jq *");
641 assert_eq!(parsed[4].action, PermissionAction::Read);
642 assert_eq!(parsed[4].pattern.as_str(), "*");
643 for command in ["git", "git status", "git commit -m x"] {
644 assert!(
645 parsed[..2]
646 .iter()
647 .any(|grant| simple_wildcard_match(grant.pattern.as_str(), command)),
648 "git prefix must grant {command:?}"
649 );
650 }
651 assert!(
652 !parsed[..2]
653 .iter()
654 .any(|grant| { simple_wildcard_match(grant.pattern.as_str(), "cargo test") })
655 );
656
657 let parsed = parse_allowed_tools(
658 "read READ ReAd edit EDIT Write BASH(git commit -m *) McP(api(call *)) Webfetch(https://example.org/*) WEBFETCH",
659 )
660 .expect("case variants and patterns with spaces/parens");
661 assert_eq!(parsed[0].action, PermissionAction::Read);
662 assert_eq!(parsed[1].action, PermissionAction::Read);
663 assert_eq!(parsed[2].action, PermissionAction::Read);
664 assert_eq!(parsed[3].action, PermissionAction::Write);
665 assert_eq!(parsed[4].action, PermissionAction::Write);
666 assert_eq!(parsed[5].action, PermissionAction::Write);
667 assert_eq!(parsed[6].action, PermissionAction::Bash);
668 assert_eq!(parsed[6].pattern.as_str(), "git commit -m *");
669 assert_eq!(parsed[7].action, PermissionAction::Mcp);
670 assert_eq!(parsed[7].pattern.as_str(), "api(call *)");
671 assert_eq!(parsed[8].action, PermissionAction::Webfetch);
672 assert_eq!(parsed[8].pattern.as_str(), "https://example.org/*");
673 assert_eq!(parsed[9].action, PermissionAction::Webfetch);
674 assert_eq!(parsed[9].pattern.as_str(), "*");
675
676 let passthrough =
677 parse_allowed_tools("Write(src/**) Bash(git commit -m *)").expect("pass-through globs");
678 assert_eq!(passthrough[0].pattern.as_str(), "src/**");
679 assert_eq!(passthrough[1].pattern.as_str(), "git commit -m *");
680 }
681
682 #[test]
683 fn rejects_unknown_and_malformed_allowed_tools_tokens() {
684 let unknown = parse_allowed_tools("Unknown").unwrap_err();
685 assert!(unknown.contains("unknown allowed-tools tool `Unknown`"));
686 assert!(unknown.contains("Read, Write, Edit, Bash, Delegate, Skill, Mcp, and Plugin"));
687
688 for malformed in ["", "Bash(", "Bash()", "Bash(:*)", "(git:*)", "Bash(git:*))"] {
689 assert!(
690 parse_allowed_tools(malformed).is_err(),
691 "expected malformed token error for {malformed:?}"
692 );
693 }
694 }
695
696 #[test]
697 fn invalid_model_key_is_a_targeted_load_error() {
698 let temp = tempfile::tempdir().expect("tempdir");
699 write_skill(
700 &temp.path().join(".cookie-agent/skills"),
701 "model-test",
702 "model-test",
703 "model: not-a-model-key\n",
704 "body",
705 );
706 let error = load_skill_roots(None, None, &[temp.path().to_owned()]).unwrap_err();
707 assert!(
708 error
709 .to_string()
710 .contains("invalid skill model `not-a-model-key`"),
711 "{error}"
712 );
713 }
714
715 fn write_skill(root: &std::path::Path, directory: &str, name: &str, extra: &str, body: &str) {
716 let directory = root.join(directory);
717 fs::create_dir_all(&directory).expect("skill directory");
718 fs::write(
719 directory.join("SKILL.md"),
720 format!("---\nname: {name}\ndescription: Test skill\n{extra}---\n{body}\n"),
721 )
722 .expect("skill document");
723 }
724
725 #[test]
726 fn project_skill_shadows_user_and_records_diagnostic() {
727 let temp = tempfile::tempdir().expect("tempdir");
728 let user = temp.path().join("user");
729 let project = temp.path().join("project");
730 write_skill(&user, "review", "review", "", "user body");
731 write_skill(
732 &project.join(".cookie-agent/skills"),
733 "review",
734 "review",
735 "",
736 "project body",
737 );
738 let registry = load_skill_roots(None, Some(&user), &[project]).expect("registry");
739 assert_eq!(
740 registry.get("review").expect("winner").body,
741 "project body\n"
742 );
743 assert_eq!(registry.diagnostics().len(), 1);
744 assert_eq!(registry.discoveries().len(), 2);
745 }
746
747 #[test]
748 fn precedence_runs_shared_user_then_user_then_project_shared_then_native() {
749 let temp = tempfile::tempdir().expect("tempdir");
750 let shared_user = temp.path().join("shared-user");
751 let user = temp.path().join("user");
752 let project = temp.path().join("project");
753 write_skill(
755 &shared_user,
756 "shared-only",
757 "shared-only",
758 "",
759 "shared user body",
760 );
761 write_skill(&user, "user-only", "user-only", "", "user body");
762 write_skill(
763 &project.join(".agents/skills"),
764 "project-shared-only",
765 "project-shared-only",
766 "",
767 "project shared body",
768 );
769 write_skill(
772 &shared_user,
773 "contested",
774 "contested",
775 "",
776 "shared user body",
777 );
778 write_skill(&user, "contested", "contested", "", "user body");
779 write_skill(
780 &project.join(".agents/skills"),
781 "contested",
782 "contested",
783 "",
784 "project shared body",
785 );
786 write_skill(
787 &project.join(".cookie-agent/skills"),
788 "contested",
789 "contested",
790 "",
791 "project native body",
792 );
793 let registry =
794 load_skill_roots(Some(&shared_user), Some(&user), &[project]).expect("registry");
795 assert_eq!(
796 registry.get("shared-only").expect("shared").body,
797 "shared user body\n"
798 );
799 assert_eq!(registry.get("user-only").expect("user").body, "user body\n");
800 assert_eq!(
801 registry
802 .get("project-shared-only")
803 .expect("project shared")
804 .body,
805 "project shared body\n"
806 );
807 assert_eq!(
808 registry.get("contested").expect("winner").body,
809 "project native body\n"
810 );
811 assert_eq!(registry.diagnostics().len(), 2);
813 assert_eq!(registry.discoveries().len(), 7);
814 let without_project = load_skill_roots(Some(&shared_user), Some(&user), &[])
816 .expect("registry without project");
817 assert_eq!(
818 without_project.get("contested").expect("winner").body,
819 "user body\n"
820 );
821 assert_eq!(without_project.diagnostics().len(), 1);
822 assert!(
823 without_project.diagnostics()[0]
824 .message
825 .contains("shadowed by user skill"),
826 "{}",
827 without_project.diagnostics()[0].message
828 );
829 let shared_only_project = temp.path().join("shared-project-only");
831 write_skill(
832 &shared_only_project.join(".agents/skills"),
833 "contested",
834 "contested",
835 "",
836 "project shared body",
837 );
838 let over_user = load_skill_roots(
839 Some(&shared_user),
840 Some(&user),
841 std::slice::from_ref(&shared_only_project),
842 )
843 .expect("registry with shared project root");
844 assert_eq!(
845 over_user.get("contested").expect("winner").body,
846 "project shared body\n"
847 );
848 write_skill(
850 &shared_only_project.join(".cookie-agent/skills"),
851 "contested",
852 "contested",
853 "",
854 "project native body",
855 );
856 let within_project =
857 load_skill_roots(None, None, &[shared_only_project]).expect("registry within project");
858 assert_eq!(
859 within_project.get("contested").expect("winner").body,
860 "project native body\n"
861 );
862 }
863
864 #[test]
865 fn mismatched_name_and_unknown_field_are_targeted_errors() {
866 let temp = tempfile::tempdir().expect("tempdir");
867 write_skill(
868 &temp.path().join(".cookie-agent/skills"),
869 "right",
870 "wrong",
871 "",
872 "body",
873 );
874 assert!(
875 load_skill_roots(None, None, &[temp.path().to_owned()])
876 .unwrap_err()
877 .to_string()
878 .contains("name mismatch")
879 );
880
881 let temp = tempfile::tempdir().expect("tempdir");
882 write_skill(
883 &temp.path().join(".cookie-agent/skills"),
884 "right",
885 "right",
886 "surprise: true\n",
887 "body",
888 );
889 assert!(
890 load_skill_roots(None, None, &[temp.path().to_owned()])
891 .unwrap_err()
892 .to_string()
893 .contains("unknown frontmatter field")
894 );
895 }
896
897 #[test]
898 fn listing_drops_when_to_use_then_truncates_without_dropping_skill() {
899 let temp = tempfile::tempdir().expect("tempdir");
900 let description = "d".repeat(500);
901 let directory = temp.path().join(".cookie-agent/skills/budget");
902 fs::create_dir_all(&directory).expect("skill directory");
903 fs::write(
904 directory.join("SKILL.md"),
905 format!(
906 "---\nname: budget\ndescription: {description}\nwhen_to_use: {}\n---\nbody\n",
907 "w".repeat(500)
908 ),
909 )
910 .expect("skill document");
911 let registry = load_skill_roots(None, None, &[temp.path().to_owned()]).expect("registry");
912 let rendered = render_available_skills(registry.skills().map(|(_, skill)| skill), 10_000)
913 .expect("bounded listing");
914 assert!(rendered.len() <= 400);
915 assert!(rendered.contains("<name>budget</name>"));
916 assert!(!rendered.contains(&"w".repeat(100)));
917 }
918}