Skip to main content

agentd/context/
skills.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Skills**: named instruction bundles — the SKILL.md idiom —
3//! discovered from MCP servers as **prompts** (`prompts/list` = catalogue,
4//! `prompts/get` = body) or **resources** (`skill://<name>` URIs or
5//! `mimeType: text/x-skill+markdown`), referenced as `@skill:<name>` in the
6//! instruction, a step, or a chat message, and **preloaded** into the calling
7//! context (progressive disclosure: the catalogue is always visible, a body
8//! only when referenced or `skills.load`ed). Bodies are cached by hash, never
9//! stored; the loaded set `[{name, hash}]` is part of the context record.
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use std::collections::BTreeMap;
14
15/// The default reference prefix (`skills.reference_prefix`).
16pub const DEFAULT_PREFIX: &str = "@skill:";
17/// The URI scheme skills-as-resources use.
18pub const SKILL_SCHEME: &str = "skill://";
19/// The mime type that marks a resource as a skill.
20pub const SKILL_MIME: &str = "text/x-skill+markdown";
21
22/// A catalogue entry (no body).
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SkillMeta {
25    pub name: String,
26    #[serde(default)]
27    pub description: String,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub when_to_use: Option<String>,
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub arguments: Vec<Value>,
32    pub source: SkillSourceRef,
33}
34
35/// Where a skill comes from.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SkillSourceRef {
38    pub server: String,
39    #[serde(rename = "kind")]
40    pub kind: SkillSourceKind,
41    /// The prompt name or the resource URI.
42    #[serde(rename = "ref")]
43    pub reference: String,
44    /// The body, for [`SkillSourceKind::Inline`] only.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub body: Option<String>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum SkillSourceKind {
52    Prompt,
53    Resource,
54    /// Defined by a `:::skill` directive in the instruction; the body lives on
55    /// the meta — no server round trip, no server at all.
56    Inline,
57}
58
59/// A loaded skill body (cached by hash).
60#[derive(Debug, Clone, PartialEq)]
61pub struct SkillBody {
62    pub name: String,
63    pub hash: String,
64    pub body: String,
65}
66
67/// The MCP surface skill discovery needs — implemented for the MCP client and
68/// by test fakes.
69pub trait SkillServer {
70    fn server_name(&self) -> String;
71    fn supports_prompts(&self) -> bool;
72    fn supports_resources(&self) -> bool;
73    fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String>;
74    fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String>;
75    fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String>;
76    fn read_resource(&self, uri: &str) -> Result<String, String>;
77}
78
79impl SkillServer for crate::mcp::client::McpClient {
80    fn server_name(&self) -> String {
81        self.name().to_string()
82    }
83    fn supports_prompts(&self) -> bool {
84        self.capabilities().supports_prompts()
85    }
86    fn supports_resources(&self) -> bool {
87        self.capabilities().supports_resources()
88    }
89    fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String> {
90        crate::mcp::client::McpClient::list_prompts(self).map_err(|e| e.to_string())
91    }
92    fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
93        crate::mcp::client::McpClient::get_prompt(self, name, arguments)
94            .map(|r| r.messages)
95            .map_err(|e| e.to_string())
96    }
97    fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String> {
98        crate::mcp::client::McpClient::list_resources(self).map_err(|e| e.to_string())
99    }
100    fn read_resource(&self, uri: &str) -> Result<String, String> {
101        crate::mcp::client::McpClient::read_resource(self, uri)
102            .map(|r| r.text())
103            .map_err(|e| e.to_string())
104    }
105}
106
107/// How to discover on one source (`skills.sources[].discover`).
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Discover {
110    Prompts,
111    Resources,
112    Auto,
113}
114
115/// The catalogue + body cache.
116#[derive(Debug, Default)]
117pub struct Catalogue {
118    skills: BTreeMap<String, SkillMeta>,
119    bodies: BTreeMap<String, SkillBody>, // by hash
120    max_bytes: usize,
121    pub prefix: String,
122    /// Discovery errors per server (surfaced in status, never fatal).
123    pub errors: BTreeMap<String, String>,
124}
125
126impl Catalogue {
127    pub fn new(prefix: &str, max_bytes: usize) -> Catalogue {
128        Catalogue {
129            prefix: prefix.to_string(),
130            max_bytes,
131            ..Default::default()
132        }
133    }
134
135    /// (Re)discover the skills of one server. Later sources do not override
136    /// an existing name (first source wins; a collision is logged by the caller).
137    /// Returns the names discovered on this server.
138    pub fn discover(
139        &mut self,
140        server: &dyn SkillServer,
141        mode: Discover,
142        filter: Option<&str>,
143    ) -> Vec<String> {
144        let name = server.server_name();
145        let mut found = Vec::new();
146        let want_prompts =
147            matches!(mode, Discover::Prompts | Discover::Auto) && server.supports_prompts();
148        let want_resources =
149            matches!(mode, Discover::Resources | Discover::Auto) && server.supports_resources();
150        if want_prompts {
151            match server.list_prompts() {
152                Ok(prompts) => {
153                    for p in prompts {
154                        if !passes(filter, &p.name) {
155                            continue;
156                        }
157                        let (description, when) =
158                            split_when(p.description.as_deref().unwrap_or(""));
159                        let meta = SkillMeta {
160                            name: p.name.clone(),
161                            description,
162                            when_to_use: when,
163                            arguments: p
164                                .arguments
165                                .iter()
166                                .map(|a| serde_json::to_value(a).unwrap_or(Value::Null))
167                                .collect(),
168                            source: SkillSourceRef {
169                                server: name.clone(),
170                                kind: SkillSourceKind::Prompt,
171                                reference: p.name.clone(),
172                                body: None,
173                            },
174                        };
175                        if self.insert(meta) {
176                            found.push(p.name);
177                        }
178                    }
179                }
180                Err(e) => {
181                    self.errors
182                        .insert(name.clone(), format!("prompts/list: {e}"));
183                }
184            }
185        }
186        if want_resources {
187            match server.list_resources() {
188                Ok(resources) => {
189                    for r in resources {
190                        let is_skill = r.uri.starts_with(SKILL_SCHEME)
191                            || r.mime_type.as_deref() == Some(SKILL_MIME);
192                        if !is_skill {
193                            continue;
194                        }
195                        let skill_name = r
196                            .uri
197                            .strip_prefix(SKILL_SCHEME)
198                            .map(|s| s.trim_matches('/').to_string())
199                            .filter(|s| !s.is_empty())
200                            .or_else(|| r.name.clone())
201                            .unwrap_or_else(|| r.uri.clone());
202                        // The optional index resource `skill://` itself is not a skill.
203                        if skill_name.is_empty() {
204                            continue;
205                        }
206                        if !passes(filter, &skill_name) {
207                            continue;
208                        }
209                        let (description, when) =
210                            split_when(r.description.as_deref().unwrap_or(""));
211                        let meta = SkillMeta {
212                            name: skill_name.clone(),
213                            description,
214                            when_to_use: when,
215                            arguments: Vec::new(),
216                            source: SkillSourceRef {
217                                server: name.clone(),
218                                kind: SkillSourceKind::Resource,
219                                reference: r.uri.clone(),
220                                body: None,
221                            },
222                        };
223                        if self.insert(meta) {
224                            found.push(skill_name);
225                        }
226                    }
227                }
228                Err(e) => {
229                    self.errors
230                        .entry(name.clone())
231                        .and_modify(|m| m.push_str(&format!("; resources/list: {e}")))
232                        .or_insert(format!("resources/list: {e}"));
233                }
234            }
235        }
236        found
237    }
238
239    fn insert(&mut self, meta: SkillMeta) -> bool {
240        if let Some(existing) = self.skills.get(&meta.name) {
241            // Same source refreshed: replace; a different source: first wins.
242            if existing.source == meta.source {
243                self.skills.insert(meta.name.clone(), meta);
244                return true;
245            }
246            return false;
247        }
248        self.skills.insert(meta.name.clone(), meta);
249        true
250    }
251
252    /// Forget every skill of `server` (before a re-discovery).
253    pub fn forget_server(&mut self, server: &str) {
254        self.skills.retain(|_, m| m.source.server != server);
255        self.errors.remove(server);
256    }
257
258    pub fn get(&self, name: &str) -> Option<&SkillMeta> {
259        self.skills.get(name)
260    }
261    pub fn names(&self) -> Vec<String> {
262        self.skills.keys().cloned().collect()
263    }
264    pub fn len(&self) -> usize {
265        self.skills.len()
266    }
267    pub fn is_empty(&self) -> bool {
268        self.skills.is_empty()
269    }
270
271    /// `skills.list` output.
272    pub fn list_value(&self) -> Value {
273        json!({
274            "skills": self.skills.values().map(|m| json!({
275                "name": m.name, "description": m.description, "when_to_use": m.when_to_use,
276                "arguments": m.arguments, "source": {"server": m.source.server, "kind": m.source.kind}
277            })).collect::<Vec<_>>(),
278            "errors": self.errors,
279        })
280    }
281
282    /// The catalogue block for a prompt (names + descriptions), or `None`
283    /// when empty.
284    pub fn render_catalogue(&self) -> Option<String> {
285        if self.skills.is_empty() {
286            return None;
287        }
288        let mut out = format!(
289            "Available skills (reference one as {}<name> or call skills.load to read its full instructions):\n",
290            self.prefix
291        );
292        for m in self.skills.values() {
293            out.push_str(&format!("- {}: {}", m.name, m.description));
294            if let Some(w) = &m.when_to_use {
295                out.push_str(&format!(" (use when: {w})"));
296            }
297            out.push('\n');
298        }
299        Some(out)
300    }
301
302    /// Fetch (or serve from cache) a skill body. `servers` resolves the source
303    /// server by name.
304    pub fn load(
305        &mut self,
306        name: &str,
307        arguments: Option<Value>,
308        servers: &dyn Fn(&str) -> Option<std::sync::Arc<dyn SkillServer>>,
309    ) -> Result<SkillBody, String> {
310        let meta = self
311            .skills
312            .get(name)
313            .cloned()
314            .ok_or_else(|| format!("unknown skill {name:?}"))?;
315        let text = if meta.source.kind == SkillSourceKind::Inline {
316            meta.source
317                .body
318                .clone()
319                .ok_or_else(|| format!("inline skill {name:?} lost its body"))?
320        } else {
321            let server = servers(&meta.source.server).ok_or_else(|| {
322                format!(
323                    "skill {name:?}: server {:?} is not connected",
324                    meta.source.server
325                )
326            })?;
327            match meta.source.kind {
328                SkillSourceKind::Prompt => {
329                    let messages = server.get_prompt(&meta.source.reference, arguments)?;
330                    prompt_messages_text(&messages)
331                }
332                SkillSourceKind::Resource => server.read_resource(&meta.source.reference)?,
333                SkillSourceKind::Inline => unreachable!("handled above"),
334            }
335        };
336        if text.trim().is_empty() {
337            return Err(format!("skill {name:?} has an empty body"));
338        }
339        let text = if text.len() > self.max_bytes {
340            let mut cut = self.max_bytes;
341            while !text.is_char_boundary(cut) {
342                cut -= 1;
343            }
344            format!(
345                "{}\n\n[skill body truncated to skills.max_bytes = {} bytes]",
346                &text[..cut],
347                self.max_bytes
348            )
349        } else {
350            text
351        };
352        let hash = crate::sha::sha256_hex(text.as_bytes());
353        let body = SkillBody {
354            name: name.to_string(),
355            hash: hash.clone(),
356            body: text,
357        };
358        self.bodies.insert(hash, body.clone());
359        Ok(body)
360    }
361
362    /// A cached body by hash.
363    /// Register the instruction's `:::skill` definitions. Inline skills win a
364    /// name collision with discovered ones — the operator wrote them CLOSER to
365    /// this agent than any server did.
366    pub fn add_inline(&mut self, skills: &[crate::config::directives::InlineSkill]) -> Vec<String> {
367        let mut names = Vec::new();
368        for sk in skills {
369            self.skills.insert(
370                sk.name.clone(),
371                SkillMeta {
372                    name: sk.name.clone(),
373                    description: sk.description.clone(),
374                    when_to_use: sk.when_to_use.clone(),
375                    arguments: Vec::new(),
376                    source: SkillSourceRef {
377                        server: "instruction".into(),
378                        kind: SkillSourceKind::Inline,
379                        reference: sk.name.clone(),
380                        body: Some(sk.body.clone()),
381                    },
382                },
383            );
384            names.push(sk.name.clone());
385        }
386        names
387    }
388
389    /// Register a LOCAL folder of skill files.
390    ///
391    /// Two layouts, because both are in the wild: `<dir>/<name>.md`, and the
392    /// Agent Skill directory form `<dir>/<name>/SKILL.md`. Either may carry
393    /// YAML frontmatter (`name`, `description`); without it the file stem is
394    /// the name and the first paragraph is the description, so a plain
395    /// markdown file is a usable skill with no ceremony.
396    ///
397    /// Registered as [`SkillSourceKind::Inline`] with the body already read:
398    /// a skill is prose, so there is nothing to fetch later and nothing that
399    /// can fail at load time. Like `:::skill`, a local file WINS a name
400    /// collision with a discovered one — the operator put it closer to this
401    /// agent than any server did.
402    pub fn add_dir(&mut self, dir: &std::path::Path) -> (Vec<String>, Vec<String>) {
403        let (mut names, mut errs) = (Vec::new(), Vec::new());
404        let Ok(rd) = std::fs::read_dir(dir) else {
405            return (names, errs);
406        };
407        let mut candidates: Vec<(String, std::path::PathBuf)> = Vec::new();
408        for ent in rd.flatten() {
409            let path = ent.path();
410            let stem = path
411                .file_stem()
412                .and_then(|s| s.to_str())
413                .unwrap_or_default()
414                .to_string();
415            if path.is_dir() {
416                let inner = path.join("SKILL.md");
417                if inner.is_file() {
418                    candidates.push((stem, inner));
419                }
420            } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
421                candidates.push((stem, path));
422            }
423        }
424        // Sorted so a folder's registration order is its filename order — the
425        // only ordering an operator can see without reading this function.
426        candidates.sort();
427        for (stem, path) in candidates {
428            let text = match std::fs::read_to_string(&path) {
429                Ok(t) => t,
430                Err(e) => {
431                    errs.push(format!("{}: {e}", path.display()));
432                    continue;
433                }
434            };
435            let (meta, body) = split_frontmatter(&text);
436            if body.trim().is_empty() {
437                errs.push(format!("{}: empty skill body", path.display()));
438                continue;
439            }
440            let name = meta
441                .as_ref()
442                .and_then(|m| m.get("name"))
443                .and_then(|v| v.as_str())
444                .map(str::to_string)
445                .unwrap_or(stem);
446            let raw_desc = meta
447                .as_ref()
448                .and_then(|m| m.get("description"))
449                .and_then(|v| v.as_str())
450                .map(str::to_string)
451                .unwrap_or_else(|| first_paragraph(&body));
452            let (description, when_to_use) = split_when(&raw_desc);
453            self.skills.insert(
454                name.clone(),
455                SkillMeta {
456                    name: name.clone(),
457                    description,
458                    when_to_use,
459                    arguments: Vec::new(),
460                    source: SkillSourceRef {
461                        server: "file".into(),
462                        kind: SkillSourceKind::Inline,
463                        reference: path.to_string_lossy().into_owned(),
464                        body: Some(body),
465                    },
466                },
467            );
468            names.push(name);
469        }
470        (names, errs)
471    }
472
473    pub fn body(&self, hash: &str) -> Option<&SkillBody> {
474        self.bodies.get(hash)
475    }
476
477    /// Drop cached bodies whose hash is not in `keep`.
478    pub fn evict_except(&mut self, keep: &[String]) {
479        self.bodies.retain(|h, _| keep.iter().any(|k| k == h));
480    }
481
482    /// The `@skill:<name>` references in a text (deduplicated, in order).
483    pub fn references(&self, text: &str) -> Vec<String> {
484        find_references(text, &self.prefix)
485    }
486}
487
488/// `@skill:<name>` references in `text` — a name is `[A-Za-z0-9_.:-]+`
489/// (trailing punctuation stripped), deduplicated in order of appearance.
490pub fn find_references(text: &str, prefix: &str) -> Vec<String> {
491    let mut out: Vec<String> = Vec::new();
492    if prefix.is_empty() {
493        return out;
494    }
495    let mut rest = text;
496    while let Some(pos) = rest.find(prefix) {
497        let after = &rest[pos + prefix.len()..];
498        let name: String = after
499            .chars()
500            .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
501            .collect();
502        let name = name.trim_end_matches(['.', '/']).to_string();
503        let consumed = name.len().min(after.len());
504        if !name.is_empty() && !out.contains(&name) {
505            out.push(name);
506        }
507        rest = &after[consumed..];
508    }
509    out
510}
511
512/// A `prompts/get` result's text: the concatenated text parts of its messages.
513pub fn prompt_messages_text(messages: &[Value]) -> String {
514    let mut out = String::new();
515    for m in messages {
516        let content = m.get("content").unwrap_or(&Value::Null);
517        let text = match content {
518            Value::String(s) => s.clone(),
519            Value::Object(o) => o
520                .get("text")
521                .and_then(Value::as_str)
522                .unwrap_or("")
523                .to_string(),
524            Value::Array(parts) => parts
525                .iter()
526                .filter_map(|p| p.get("text").and_then(Value::as_str))
527                .collect::<Vec<_>>()
528                .join("\n"),
529            _ => String::new(),
530        };
531        if !text.is_empty() {
532            if !out.is_empty() {
533                out.push_str("\n\n");
534            }
535            out.push_str(&text);
536        }
537    }
538    out
539}
540
541/// Render the loaded skill bodies as one system block.
542pub fn render_bodies(bodies: &[&SkillBody]) -> Option<String> {
543    if bodies.is_empty() {
544        return None;
545    }
546    let mut out = String::from("Loaded skills — follow these instructions when relevant:\n");
547    for b in bodies {
548        out.push_str(&format!("\n### Skill: {}\n{}\n", b.name, b.body.trim()));
549    }
550    Some(out)
551}
552
553fn passes(filter: Option<&str>, name: &str) -> bool {
554    match filter {
555        None => true,
556        Some(f) => {
557            let f = f.trim();
558            if let Some(prefix) = f.strip_suffix('*') {
559                name.starts_with(prefix)
560            } else {
561                f == name || f.is_empty()
562            }
563        }
564    }
565}
566
567/// Split `---\nyaml\n---\nbody` into its parts. No frontmatter is not an
568/// error: a plain markdown file is a perfectly good skill, and demanding a
569/// header would make the simplest case the annoying one.
570fn split_frontmatter(text: &str) -> (Option<serde_json::Value>, String) {
571    let rest = match text.strip_prefix("---\n") {
572        Some(r) => r,
573        None => return (None, text.to_string()),
574    };
575    let Some((head, body)) = rest.split_once("\n---\n") else {
576        return (None, text.to_string());
577    };
578    match crate::config::file::parse_document(head, crate::config::file::Format::Yaml) {
579        Ok(v) if v.is_object() => (Some(v), body.to_string()),
580        // Malformed frontmatter falls back to treating the whole file as body
581        // rather than failing: the skill still reads, and a wrong name is more
582        // recoverable than a daemon that will not start.
583        _ => (None, text.to_string()),
584    }
585}
586
587/// The first non-empty paragraph, minus a leading markdown heading — the
588/// description for a file that carries no frontmatter.
589fn first_paragraph(body: &str) -> String {
590    body.split("\n\n")
591        .map(str::trim)
592        .find(|p| !p.is_empty() && !p.starts_with('#'))
593        .unwrap_or("")
594        .replace('\n', " ")
595}
596
597/// Split a description of the form `"… When to use: …"` (or `"… Use when …"`).
598fn split_when(desc: &str) -> (String, Option<String>) {
599    for marker in ["When to use:", "when to use:", "Use when:", "use when:"] {
600        if let Some((a, b)) = desc.split_once(marker) {
601            let w = b.trim();
602            return (
603                a.trim().trim_end_matches('.').to_string(),
604                (!w.is_empty()).then(|| w.to_string()),
605            );
606        }
607    }
608    (desc.trim().to_string(), None)
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use ::mcp::wire::{Prompt, PromptArgument, Resource};
615
616    struct Fake {
617        name: String,
618        prompts: Vec<Prompt>,
619        resources: Vec<Resource>,
620        bodies: BTreeMap<String, String>,
621    }
622    impl SkillServer for Fake {
623        fn server_name(&self) -> String {
624            self.name.clone()
625        }
626        fn supports_prompts(&self) -> bool {
627            !self.prompts.is_empty()
628        }
629        fn supports_resources(&self) -> bool {
630            !self.resources.is_empty()
631        }
632        fn list_prompts(&self) -> Result<Vec<Prompt>, String> {
633            Ok(self.prompts.clone())
634        }
635        fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
636            let body = self.bodies.get(name).cloned().ok_or("no such prompt")?;
637            let body = match arguments
638                .and_then(|a| a.get("target").and_then(Value::as_str).map(str::to_string))
639            {
640                Some(t) => body.replace("{target}", &t),
641                None => body,
642            };
643            Ok(vec![
644                json!({"role": "user", "content": {"type": "text", "text": body}}),
645            ])
646        }
647        fn list_resources(&self) -> Result<Vec<Resource>, String> {
648            Ok(self.resources.clone())
649        }
650        fn read_resource(&self, uri: &str) -> Result<String, String> {
651            self.bodies
652                .get(uri)
653                .cloned()
654                .ok_or("no such resource".into())
655        }
656    }
657
658    fn fake() -> Fake {
659        Fake {
660            name: "skills".into(),
661            prompts: vec![
662                Prompt {
663                    name: "review-pr".into(),
664                    title: None,
665                    description: Some(
666                        "Review a pull request. When to use: any code review request".into(),
667                    ),
668                    arguments: vec![PromptArgument {
669                        name: "target".into(),
670                        title: None,
671                        description: None,
672                        required: Some(false),
673                    }],
674                },
675                Prompt {
676                    name: "internal-tool".into(),
677                    title: None,
678                    description: None,
679                    arguments: vec![],
680                },
681            ],
682            resources: vec![
683                Resource {
684                    uri: "skill://deploy".into(),
685                    name: Some("deploy".into()),
686                    title: None,
687                    description: Some("Deploy safely".into()),
688                    mime_type: Some(SKILL_MIME.into()),
689                },
690                Resource {
691                    uri: "file:///readme.md".into(),
692                    name: None,
693                    title: None,
694                    description: None,
695                    mime_type: Some("text/markdown".into()),
696                },
697                Resource {
698                    uri: "notes://x".into(),
699                    name: Some("x".into()),
700                    title: None,
701                    description: None,
702                    mime_type: Some(SKILL_MIME.into()),
703                },
704            ],
705            bodies: [
706                (
707                    "review-pr".to_string(),
708                    "# Review PR\nLook at {target} carefully.".to_string(),
709                ),
710                ("internal-tool".to_string(), "internal".to_string()),
711                (
712                    "skill://deploy".to_string(),
713                    "# Deploy\n1. plan 2. apply".to_string(),
714                ),
715                ("notes://x".to_string(), "x body".to_string()),
716            ]
717            .into_iter()
718            .collect(),
719        }
720    }
721
722    #[test]
723    fn discovery_over_prompts_and_resources_with_filters() {
724        let f = fake();
725        let mut c = Catalogue::new(DEFAULT_PREFIX, 1024);
726        let found = c.discover(&f, Discover::Auto, None);
727        assert_eq!(found, vec!["review-pr", "internal-tool", "deploy", "x"]);
728        let m = c.get("review-pr").unwrap();
729        assert_eq!(m.description, "Review a pull request");
730        assert_eq!(m.when_to_use.as_deref(), Some("any code review request"));
731        assert_eq!(m.arguments.len(), 1);
732        assert_eq!(
733            c.get("deploy").unwrap().source.kind,
734            SkillSourceKind::Resource
735        );
736        assert!(
737            c.get("readme.md").is_none(),
738            "a plain markdown resource is not a skill"
739        );
740        let cat = c.render_catalogue().unwrap();
741        assert!(
742            cat.contains("- review-pr: Review a pull request (use when: any code review request)"),
743            "{cat}"
744        );
745        // Filter + prompts-only.
746        let mut c2 = Catalogue::new(DEFAULT_PREFIX, 1024);
747        assert_eq!(
748            c2.discover(&f, Discover::Prompts, Some("review-*")),
749            vec!["review-pr"]
750        );
751        // A second source does not steal an existing name.
752        let mut other = fake();
753        other.name = "other".into();
754        assert!(c.discover(&other, Discover::Auto, None).is_empty());
755        assert_eq!(c.get("deploy").unwrap().source.server, "skills");
756        c.forget_server("skills");
757        assert!(c.is_empty());
758    }
759
760    #[test]
761    fn load_caches_by_hash_truncates_and_renders() {
762        let f = std::sync::Arc::new(fake());
763        let mut c = Catalogue::new(DEFAULT_PREFIX, 30);
764        c.discover(&*f, Discover::Auto, None);
765        let f2 = f.clone();
766        let servers = move |n: &str| -> Option<std::sync::Arc<dyn SkillServer>> {
767            (n == "skills").then(|| f2.clone() as std::sync::Arc<dyn SkillServer>)
768        };
769        let b = c
770            .load("review-pr", Some(json!({"target": "PR #7"})), &servers)
771            .unwrap();
772        assert!(b.body.contains("PR #7"));
773        assert!(b.body.contains("truncated to skills.max_bytes"));
774        assert!(c.body(&b.hash).is_some());
775        let d = c.load("deploy", None, &servers).unwrap();
776        assert!(d.body.starts_with("# Deploy"));
777        assert!(c.load("nope", None, &servers).is_err());
778        assert!(
779            c.load("deploy", None, &|_| None).is_err(),
780            "server not connected"
781        );
782        let block = render_bodies(&[&b, &d]).unwrap();
783        assert!(block.contains("### Skill: review-pr") && block.contains("### Skill: deploy"));
784        c.evict_except(std::slice::from_ref(&d.hash));
785        assert!(c.body(&b.hash).is_none());
786        assert!(c.body(&d.hash).is_some());
787    }
788
789    #[test]
790    fn references_are_found_and_deduped() {
791        let refs = find_references(
792            "please @skill:review-pr this, then @skill:deploy. Also @skill:review-pr again and @skill:",
793            "@skill:",
794        );
795        assert_eq!(refs, vec!["review-pr", "deploy"]);
796        assert!(find_references("nothing here", "@skill:").is_empty());
797        assert_eq!(find_references("use +s:x/y.", "+s:"), vec!["x/y"]);
798        assert_eq!(
799            prompt_messages_text(&[
800                json!({"content": "a"}),
801                json!({"content": [{"type": "text", "text": "b"}, {"type": "image"}]})
802            ]),
803            "a\n\nb"
804        );
805    }
806
807    /// A local folder: three layouts, one catalogue. The frontmatter `name`
808    /// wins over the filename, because a file can be renamed and a skill
809    /// reference should not break when it is.
810    #[test]
811    fn a_local_folder_registers_every_layout() {
812        let dir = std::env::temp_dir().join(format!("agentd-skilldir-{}", std::process::id()));
813        let _ = std::fs::remove_dir_all(&dir);
814        std::fs::create_dir_all(dir.join("runbook")).unwrap();
815
816        std::fs::write(
817            dir.join("triage.md"),
818            "---\nname: triage\ndescription: Triage an issue. Use when: it has no labels\n---\n\nRead it, label it.\n",
819        )
820        .unwrap();
821        // The Agent Skill directory form, with a frontmatter name that differs
822        // from the directory it lives in.
823        std::fs::write(
824            dir.join("runbook/SKILL.md"),
825            "---\nname: incident\ndescription: Handle an incident. When to use: an alert fires\n---\n\nAcknowledge, then mitigate.\n",
826        )
827        .unwrap();
828        // No frontmatter at all: the stem names it and the first real
829        // paragraph describes it, so a plain note is a usable skill.
830        std::fs::write(
831            dir.join("deploy.md"),
832            "# Deploy safely\n\nAlways deploy behind a flag.\n",
833        )
834        .unwrap();
835
836        let mut cat = Catalogue::new(DEFAULT_PREFIX, 32_768);
837        let (names, errs) = cat.add_dir(&dir);
838        assert!(errs.is_empty(), "{errs:?}");
839        assert_eq!(
840            names,
841            ["deploy", "incident", "triage"],
842            "sorted by file stem"
843        );
844
845        let triage = cat.skills.get("triage").expect("triage");
846        assert_eq!(triage.description, "Triage an issue");
847        assert_eq!(triage.when_to_use.as_deref(), Some("it has no labels"));
848
849        let incident = cat.skills.get("incident").expect("frontmatter name wins");
850        assert_eq!(incident.when_to_use.as_deref(), Some("an alert fires"));
851
852        let deploy = cat.skills.get("deploy").expect("deploy");
853        assert_eq!(deploy.description, "Always deploy behind a flag.");
854        assert!(deploy.when_to_use.is_none());
855
856        // The body is already in hand — a skill is prose, so there is nothing
857        // to fetch later and no server that can be down when it is read.
858        let body = cat
859            .load("deploy", None, &|_| None)
860            .expect("an inline body needs no server");
861        assert!(body.body.contains("behind a flag"), "{}", body.body);
862
863        let _ = std::fs::remove_dir_all(&dir);
864    }
865
866    /// A file that only LOOKS like it has frontmatter still reads as a skill.
867    /// Refusing to start over a stray `---` would be a poor trade.
868    #[test]
869    fn malformed_frontmatter_degrades_to_body() {
870        let (meta, body) = split_frontmatter("---\n: : not yaml\n---\nhello\n");
871        assert!(meta.is_none());
872        assert!(body.contains("hello"), "{body}");
873
874        let (meta, body) = split_frontmatter("no header here\n");
875        assert!(meta.is_none());
876        assert_eq!(body, "no header here\n");
877    }
878}