Skip to main content

cookie_agent_config/
skills.rs

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
265/// Loads skills once for engine construction. Skill files are intentionally not hot-reloaded.
266pub 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
276/// Loads explicit skill roots in low-to-high project precedence order.
277///
278/// `shared_user_root` is the cross-client `~/.agents/skills` convention and ranks below the
279/// cookie-agent-native user root. Each project directory contributes its `.agents/skills` root
280/// before its `.cookie-agent/skills` root, so native roots win same-scope name collisions.
281pub 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('&', "&amp;")
607        .replace('<', "&lt;")
608        .replace('>', "&gt;")
609        .replace('"', "&quot;")
610        .replace('\'', "&apos;")
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        // Distinct names per root all load.
754        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        // Same name at every level: native project wins, then project .agents,
770        // then native user, then shared user.
771        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        // Every shadowed user-scope discovery records a diagnostic.
812        assert_eq!(registry.diagnostics().len(), 2);
813        assert_eq!(registry.discoveries().len(), 7);
814        // Native user beats shared user.
815        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        // Project `.agents/skills` beats native user.
830        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        // Native project beats project `.agents/skills` within the same directory.
849        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}