use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
pub const DEFAULT_PREFIX: &str = "@skill:";
pub const SKILL_SCHEME: &str = "skill://";
pub const SKILL_MIME: &str = "text/x-skill+markdown";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkillMeta {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when_to_use: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub arguments: Vec<Value>,
pub source: SkillSourceRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillSourceRef {
pub server: String,
#[serde(rename = "kind")]
pub kind: SkillSourceKind,
#[serde(rename = "ref")]
pub reference: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillSourceKind {
Prompt,
Resource,
Inline,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkillBody {
pub name: String,
pub hash: String,
pub body: String,
}
pub trait SkillServer {
fn server_name(&self) -> String;
fn supports_prompts(&self) -> bool;
fn supports_resources(&self) -> bool;
fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String>;
fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String>;
fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String>;
fn read_resource(&self, uri: &str) -> Result<String, String>;
}
impl SkillServer for crate::mcp::client::McpClient {
fn server_name(&self) -> String {
self.name().to_string()
}
fn supports_prompts(&self) -> bool {
self.capabilities().supports_prompts()
}
fn supports_resources(&self) -> bool {
self.capabilities().supports_resources()
}
fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String> {
crate::mcp::client::McpClient::list_prompts(self).map_err(|e| e.to_string())
}
fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
crate::mcp::client::McpClient::get_prompt(self, name, arguments)
.map(|r| r.messages)
.map_err(|e| e.to_string())
}
fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String> {
crate::mcp::client::McpClient::list_resources(self).map_err(|e| e.to_string())
}
fn read_resource(&self, uri: &str) -> Result<String, String> {
crate::mcp::client::McpClient::read_resource(self, uri)
.map(|r| r.text())
.map_err(|e| e.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Discover {
Prompts,
Resources,
Auto,
}
#[derive(Debug, Default)]
pub struct Catalogue {
skills: BTreeMap<String, SkillMeta>,
bodies: BTreeMap<String, SkillBody>, max_bytes: usize,
pub prefix: String,
pub errors: BTreeMap<String, String>,
}
impl Catalogue {
pub fn new(prefix: &str, max_bytes: usize) -> Catalogue {
Catalogue {
prefix: prefix.to_string(),
max_bytes,
..Default::default()
}
}
pub fn discover(
&mut self,
server: &dyn SkillServer,
mode: Discover,
filter: Option<&str>,
) -> Vec<String> {
let name = server.server_name();
let mut found = Vec::new();
let want_prompts =
matches!(mode, Discover::Prompts | Discover::Auto) && server.supports_prompts();
let want_resources =
matches!(mode, Discover::Resources | Discover::Auto) && server.supports_resources();
if want_prompts {
match server.list_prompts() {
Ok(prompts) => {
for p in prompts {
if !passes(filter, &p.name) {
continue;
}
let (description, when) =
split_when(p.description.as_deref().unwrap_or(""));
let meta = SkillMeta {
name: p.name.clone(),
description,
when_to_use: when,
arguments: p
.arguments
.iter()
.map(|a| serde_json::to_value(a).unwrap_or(Value::Null))
.collect(),
source: SkillSourceRef {
server: name.clone(),
kind: SkillSourceKind::Prompt,
reference: p.name.clone(),
body: None,
},
};
if self.insert(meta) {
found.push(p.name);
}
}
}
Err(e) => {
self.errors
.insert(name.clone(), format!("prompts/list: {e}"));
}
}
}
if want_resources {
match server.list_resources() {
Ok(resources) => {
for r in resources {
let is_skill = r.uri.starts_with(SKILL_SCHEME)
|| r.mime_type.as_deref() == Some(SKILL_MIME);
if !is_skill {
continue;
}
let skill_name = r
.uri
.strip_prefix(SKILL_SCHEME)
.map(|s| s.trim_matches('/').to_string())
.filter(|s| !s.is_empty())
.or_else(|| r.name.clone())
.unwrap_or_else(|| r.uri.clone());
if skill_name.is_empty() {
continue;
}
if !passes(filter, &skill_name) {
continue;
}
let (description, when) =
split_when(r.description.as_deref().unwrap_or(""));
let meta = SkillMeta {
name: skill_name.clone(),
description,
when_to_use: when,
arguments: Vec::new(),
source: SkillSourceRef {
server: name.clone(),
kind: SkillSourceKind::Resource,
reference: r.uri.clone(),
body: None,
},
};
if self.insert(meta) {
found.push(skill_name);
}
}
}
Err(e) => {
self.errors
.entry(name.clone())
.and_modify(|m| m.push_str(&format!("; resources/list: {e}")))
.or_insert(format!("resources/list: {e}"));
}
}
}
found
}
fn insert(&mut self, meta: SkillMeta) -> bool {
if let Some(existing) = self.skills.get(&meta.name) {
if existing.source == meta.source {
self.skills.insert(meta.name.clone(), meta);
return true;
}
return false;
}
self.skills.insert(meta.name.clone(), meta);
true
}
pub fn forget_server(&mut self, server: &str) {
self.skills.retain(|_, m| m.source.server != server);
self.errors.remove(server);
}
pub fn get(&self, name: &str) -> Option<&SkillMeta> {
self.skills.get(name)
}
pub fn names(&self) -> Vec<String> {
self.skills.keys().cloned().collect()
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn list_value(&self) -> Value {
json!({
"skills": self.skills.values().map(|m| json!({
"name": m.name, "description": m.description, "when_to_use": m.when_to_use,
"arguments": m.arguments, "source": {"server": m.source.server, "kind": m.source.kind}
})).collect::<Vec<_>>(),
"errors": self.errors,
})
}
pub fn render_catalogue(&self) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let mut out = format!(
"Available skills (reference one as {}<name> or call skills.load to read its full instructions):\n",
self.prefix
);
for m in self.skills.values() {
out.push_str(&format!("- {}: {}", m.name, m.description));
if let Some(w) = &m.when_to_use {
out.push_str(&format!(" (use when: {w})"));
}
out.push('\n');
}
Some(out)
}
pub fn load(
&mut self,
name: &str,
arguments: Option<Value>,
servers: &dyn Fn(&str) -> Option<std::sync::Arc<dyn SkillServer>>,
) -> Result<SkillBody, String> {
let meta = self
.skills
.get(name)
.cloned()
.ok_or_else(|| format!("unknown skill {name:?}"))?;
let text = if meta.source.kind == SkillSourceKind::Inline {
meta.source
.body
.clone()
.ok_or_else(|| format!("inline skill {name:?} lost its body"))?
} else {
let server = servers(&meta.source.server).ok_or_else(|| {
format!(
"skill {name:?}: server {:?} is not connected",
meta.source.server
)
})?;
match meta.source.kind {
SkillSourceKind::Prompt => {
let messages = server.get_prompt(&meta.source.reference, arguments)?;
prompt_messages_text(&messages)
}
SkillSourceKind::Resource => server.read_resource(&meta.source.reference)?,
SkillSourceKind::Inline => unreachable!("handled above"),
}
};
if text.trim().is_empty() {
return Err(format!("skill {name:?} has an empty body"));
}
let text = if text.len() > self.max_bytes {
let mut cut = self.max_bytes;
while !text.is_char_boundary(cut) {
cut -= 1;
}
format!(
"{}\n\n[skill body truncated to skills.max_bytes = {} bytes]",
&text[..cut],
self.max_bytes
)
} else {
text
};
let hash = crate::sha::sha256_hex(text.as_bytes());
let body = SkillBody {
name: name.to_string(),
hash: hash.clone(),
body: text,
};
self.bodies.insert(hash, body.clone());
Ok(body)
}
pub fn add_inline(&mut self, skills: &[crate::config::directives::InlineSkill]) -> Vec<String> {
let mut names = Vec::new();
for sk in skills {
self.skills.insert(
sk.name.clone(),
SkillMeta {
name: sk.name.clone(),
description: sk.description.clone(),
when_to_use: sk.when_to_use.clone(),
arguments: Vec::new(),
source: SkillSourceRef {
server: "instruction".into(),
kind: SkillSourceKind::Inline,
reference: sk.name.clone(),
body: Some(sk.body.clone()),
},
},
);
names.push(sk.name.clone());
}
names
}
pub fn add_dir(&mut self, dir: &std::path::Path) -> (Vec<String>, Vec<String>) {
let (mut names, mut errs) = (Vec::new(), Vec::new());
let Ok(rd) = std::fs::read_dir(dir) else {
return (names, errs);
};
let mut candidates: Vec<(String, std::path::PathBuf)> = Vec::new();
for ent in rd.flatten() {
let path = ent.path();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
if path.is_dir() {
let inner = path.join("SKILL.md");
if inner.is_file() {
candidates.push((stem, inner));
}
} else if path.extension().and_then(|e| e.to_str()) == Some("md") {
candidates.push((stem, path));
}
}
candidates.sort();
for (stem, path) in candidates {
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
errs.push(format!("{}: {e}", path.display()));
continue;
}
};
let (meta, body) = split_frontmatter(&text);
if body.trim().is_empty() {
errs.push(format!("{}: empty skill body", path.display()));
continue;
}
let name = meta
.as_ref()
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or(stem);
let raw_desc = meta
.as_ref()
.and_then(|m| m.get("description"))
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| first_paragraph(&body));
let (description, when_to_use) = split_when(&raw_desc);
self.skills.insert(
name.clone(),
SkillMeta {
name: name.clone(),
description,
when_to_use,
arguments: Vec::new(),
source: SkillSourceRef {
server: "file".into(),
kind: SkillSourceKind::Inline,
reference: path.to_string_lossy().into_owned(),
body: Some(body),
},
},
);
names.push(name);
}
(names, errs)
}
pub fn body(&self, hash: &str) -> Option<&SkillBody> {
self.bodies.get(hash)
}
pub fn evict_except(&mut self, keep: &[String]) {
self.bodies.retain(|h, _| keep.iter().any(|k| k == h));
}
pub fn references(&self, text: &str) -> Vec<String> {
find_references(text, &self.prefix)
}
}
pub fn find_references(text: &str, prefix: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if prefix.is_empty() {
return out;
}
let mut rest = text;
while let Some(pos) = rest.find(prefix) {
let after = &rest[pos + prefix.len()..];
let name: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
.collect();
let name = name.trim_end_matches(['.', '/']).to_string();
let consumed = name.len().min(after.len());
if !name.is_empty() && !out.contains(&name) {
out.push(name);
}
rest = &after[consumed..];
}
out
}
pub fn prompt_messages_text(messages: &[Value]) -> String {
let mut out = String::new();
for m in messages {
let content = m.get("content").unwrap_or(&Value::Null);
let text = match content {
Value::String(s) => s.clone(),
Value::Object(o) => o
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
Value::Array(parts) => parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
};
if !text.is_empty() {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&text);
}
}
out
}
pub fn render_bodies(bodies: &[&SkillBody]) -> Option<String> {
if bodies.is_empty() {
return None;
}
let mut out = String::from("Loaded skills — follow these instructions when relevant:\n");
for b in bodies {
out.push_str(&format!("\n### Skill: {}\n{}\n", b.name, b.body.trim()));
}
Some(out)
}
fn passes(filter: Option<&str>, name: &str) -> bool {
match filter {
None => true,
Some(f) => {
let f = f.trim();
if let Some(prefix) = f.strip_suffix('*') {
name.starts_with(prefix)
} else {
f == name || f.is_empty()
}
}
}
}
fn split_frontmatter(text: &str) -> (Option<serde_json::Value>, String) {
let rest = match text.strip_prefix("---\n") {
Some(r) => r,
None => return (None, text.to_string()),
};
let Some((head, body)) = rest.split_once("\n---\n") else {
return (None, text.to_string());
};
match crate::config::file::parse_document(head, crate::config::file::Format::Yaml) {
Ok(v) if v.is_object() => (Some(v), body.to_string()),
_ => (None, text.to_string()),
}
}
fn first_paragraph(body: &str) -> String {
body.split("\n\n")
.map(str::trim)
.find(|p| !p.is_empty() && !p.starts_with('#'))
.unwrap_or("")
.replace('\n', " ")
}
fn split_when(desc: &str) -> (String, Option<String>) {
for marker in ["When to use:", "when to use:", "Use when:", "use when:"] {
if let Some((a, b)) = desc.split_once(marker) {
let w = b.trim();
return (
a.trim().trim_end_matches('.').to_string(),
(!w.is_empty()).then(|| w.to_string()),
);
}
}
(desc.trim().to_string(), None)
}
#[cfg(test)]
mod tests {
use super::*;
use ::mcp::wire::{Prompt, PromptArgument, Resource};
struct Fake {
name: String,
prompts: Vec<Prompt>,
resources: Vec<Resource>,
bodies: BTreeMap<String, String>,
}
impl SkillServer for Fake {
fn server_name(&self) -> String {
self.name.clone()
}
fn supports_prompts(&self) -> bool {
!self.prompts.is_empty()
}
fn supports_resources(&self) -> bool {
!self.resources.is_empty()
}
fn list_prompts(&self) -> Result<Vec<Prompt>, String> {
Ok(self.prompts.clone())
}
fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
let body = self.bodies.get(name).cloned().ok_or("no such prompt")?;
let body = match arguments
.and_then(|a| a.get("target").and_then(Value::as_str).map(str::to_string))
{
Some(t) => body.replace("{target}", &t),
None => body,
};
Ok(vec![
json!({"role": "user", "content": {"type": "text", "text": body}}),
])
}
fn list_resources(&self) -> Result<Vec<Resource>, String> {
Ok(self.resources.clone())
}
fn read_resource(&self, uri: &str) -> Result<String, String> {
self.bodies
.get(uri)
.cloned()
.ok_or("no such resource".into())
}
}
fn fake() -> Fake {
Fake {
name: "skills".into(),
prompts: vec![
Prompt {
name: "review-pr".into(),
title: None,
description: Some(
"Review a pull request. When to use: any code review request".into(),
),
arguments: vec![PromptArgument {
name: "target".into(),
title: None,
description: None,
required: Some(false),
}],
},
Prompt {
name: "internal-tool".into(),
title: None,
description: None,
arguments: vec![],
},
],
resources: vec![
Resource {
uri: "skill://deploy".into(),
name: Some("deploy".into()),
title: None,
description: Some("Deploy safely".into()),
mime_type: Some(SKILL_MIME.into()),
},
Resource {
uri: "file:///readme.md".into(),
name: None,
title: None,
description: None,
mime_type: Some("text/markdown".into()),
},
Resource {
uri: "notes://x".into(),
name: Some("x".into()),
title: None,
description: None,
mime_type: Some(SKILL_MIME.into()),
},
],
bodies: [
(
"review-pr".to_string(),
"# Review PR\nLook at {target} carefully.".to_string(),
),
("internal-tool".to_string(), "internal".to_string()),
(
"skill://deploy".to_string(),
"# Deploy\n1. plan 2. apply".to_string(),
),
("notes://x".to_string(), "x body".to_string()),
]
.into_iter()
.collect(),
}
}
#[test]
fn discovery_over_prompts_and_resources_with_filters() {
let f = fake();
let mut c = Catalogue::new(DEFAULT_PREFIX, 1024);
let found = c.discover(&f, Discover::Auto, None);
assert_eq!(found, vec!["review-pr", "internal-tool", "deploy", "x"]);
let m = c.get("review-pr").unwrap();
assert_eq!(m.description, "Review a pull request");
assert_eq!(m.when_to_use.as_deref(), Some("any code review request"));
assert_eq!(m.arguments.len(), 1);
assert_eq!(
c.get("deploy").unwrap().source.kind,
SkillSourceKind::Resource
);
assert!(
c.get("readme.md").is_none(),
"a plain markdown resource is not a skill"
);
let cat = c.render_catalogue().unwrap();
assert!(
cat.contains("- review-pr: Review a pull request (use when: any code review request)"),
"{cat}"
);
let mut c2 = Catalogue::new(DEFAULT_PREFIX, 1024);
assert_eq!(
c2.discover(&f, Discover::Prompts, Some("review-*")),
vec!["review-pr"]
);
let mut other = fake();
other.name = "other".into();
assert!(c.discover(&other, Discover::Auto, None).is_empty());
assert_eq!(c.get("deploy").unwrap().source.server, "skills");
c.forget_server("skills");
assert!(c.is_empty());
}
#[test]
fn load_caches_by_hash_truncates_and_renders() {
let f = std::sync::Arc::new(fake());
let mut c = Catalogue::new(DEFAULT_PREFIX, 30);
c.discover(&*f, Discover::Auto, None);
let f2 = f.clone();
let servers = move |n: &str| -> Option<std::sync::Arc<dyn SkillServer>> {
(n == "skills").then(|| f2.clone() as std::sync::Arc<dyn SkillServer>)
};
let b = c
.load("review-pr", Some(json!({"target": "PR #7"})), &servers)
.unwrap();
assert!(b.body.contains("PR #7"));
assert!(b.body.contains("truncated to skills.max_bytes"));
assert!(c.body(&b.hash).is_some());
let d = c.load("deploy", None, &servers).unwrap();
assert!(d.body.starts_with("# Deploy"));
assert!(c.load("nope", None, &servers).is_err());
assert!(
c.load("deploy", None, &|_| None).is_err(),
"server not connected"
);
let block = render_bodies(&[&b, &d]).unwrap();
assert!(block.contains("### Skill: review-pr") && block.contains("### Skill: deploy"));
c.evict_except(std::slice::from_ref(&d.hash));
assert!(c.body(&b.hash).is_none());
assert!(c.body(&d.hash).is_some());
}
#[test]
fn references_are_found_and_deduped() {
let refs = find_references(
"please @skill:review-pr this, then @skill:deploy. Also @skill:review-pr again and @skill:",
"@skill:",
);
assert_eq!(refs, vec!["review-pr", "deploy"]);
assert!(find_references("nothing here", "@skill:").is_empty());
assert_eq!(find_references("use +s:x/y.", "+s:"), vec!["x/y"]);
assert_eq!(
prompt_messages_text(&[
json!({"content": "a"}),
json!({"content": [{"type": "text", "text": "b"}, {"type": "image"}]})
]),
"a\n\nb"
);
}
#[test]
fn a_local_folder_registers_every_layout() {
let dir = std::env::temp_dir().join(format!("agentd-skilldir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("runbook")).unwrap();
std::fs::write(
dir.join("triage.md"),
"---\nname: triage\ndescription: Triage an issue. Use when: it has no labels\n---\n\nRead it, label it.\n",
)
.unwrap();
std::fs::write(
dir.join("runbook/SKILL.md"),
"---\nname: incident\ndescription: Handle an incident. When to use: an alert fires\n---\n\nAcknowledge, then mitigate.\n",
)
.unwrap();
std::fs::write(
dir.join("deploy.md"),
"# Deploy safely\n\nAlways deploy behind a flag.\n",
)
.unwrap();
let mut cat = Catalogue::new(DEFAULT_PREFIX, 32_768);
let (names, errs) = cat.add_dir(&dir);
assert!(errs.is_empty(), "{errs:?}");
assert_eq!(
names,
["deploy", "incident", "triage"],
"sorted by file stem"
);
let triage = cat.skills.get("triage").expect("triage");
assert_eq!(triage.description, "Triage an issue");
assert_eq!(triage.when_to_use.as_deref(), Some("it has no labels"));
let incident = cat.skills.get("incident").expect("frontmatter name wins");
assert_eq!(incident.when_to_use.as_deref(), Some("an alert fires"));
let deploy = cat.skills.get("deploy").expect("deploy");
assert_eq!(deploy.description, "Always deploy behind a flag.");
assert!(deploy.when_to_use.is_none());
let body = cat
.load("deploy", None, &|_| None)
.expect("an inline body needs no server");
assert!(body.body.contains("behind a flag"), "{}", body.body);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn malformed_frontmatter_degrades_to_body() {
let (meta, body) = split_frontmatter("---\n: : not yaml\n---\nhello\n");
assert!(meta.is_none());
assert!(body.contains("hello"), "{body}");
let (meta, body) = split_frontmatter("no header here\n");
assert!(meta.is_none());
assert_eq!(body, "no header here\n");
}
}