use crate::capability_types::{MountDirectoryBuilder, MountPoint};
use everruns_capability::CapabilityId;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub const SKILL_CAPABILITY_PREFIX: &str = "skill:";
pub const SKILLS_DISCOVERY_PATH: &str = "/.agents/skills";
pub const MAX_SKILLS_PER_CAPABILITY: usize = 50;
pub fn skill_capability_id(skill_id: Uuid) -> String {
format!("{SKILL_CAPABILITY_PREFIX}{skill_id}")
}
pub fn is_skill_capability(capability_id: &str) -> bool {
capability_id.starts_with(SKILL_CAPABILITY_PREFIX)
}
pub fn parse_skill_capability_id(capability_id: &str) -> Option<Uuid> {
capability_id
.strip_prefix(SKILL_CAPABILITY_PREFIX)
.and_then(|value| Uuid::parse_str(value).ok())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMeta {
pub name: String,
pub description: String,
pub source: SkillSource,
#[serde(default = "default_true")]
pub user_invocable: bool,
#[serde(default)]
pub disable_model_invocation: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SkillSource {
Filesystem { path: String },
Registry { skill_id: String },
}
#[derive(Debug, Clone)]
pub struct SkillInstructions {
pub instructions: String,
pub files: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct SkillContribution {
pub name: String,
pub description: String,
pub instructions: String,
pub files: Vec<(String, String)>,
pub user_invocable: bool,
pub disable_model_invocation: bool,
}
impl SkillContribution {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
instructions: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
instructions: instructions.into(),
files: Vec::new(),
user_invocable: true,
disable_model_invocation: false,
}
}
pub fn with_files(mut self, files: Vec<(String, String)>) -> Self {
self.files = files;
self
}
pub fn with_user_invocable(mut self, flag: bool) -> Self {
self.user_invocable = flag;
self
}
pub fn with_disable_model_invocation(mut self, flag: bool) -> Self {
self.disable_model_invocation = flag;
self
}
pub fn to_mount(&self, owner_id: &str) -> MountPoint {
let skill_md = reconstruct_skill_md(
&self.name,
&self.description,
&self.instructions,
self.user_invocable,
self.disable_model_invocation,
);
let mut builder = MountDirectoryBuilder::new().file("SKILL.md", &skill_md);
for (path, content) in &self.files {
builder = builder.file(path, content);
}
MountPoint::readonly(
format!("{SKILLS_DISCOVERY_PATH}/{}", self.name),
builder.build(),
owner_id,
)
}
}
pub fn reconstruct_skill_md(
name: &str,
description: &str,
instructions: &str,
user_invocable: bool,
disable_model_invocation: bool,
) -> String {
let safe_description = serde_yaml::to_string(description)
.expect("a string is serializable")
.trim_end_matches('\n')
.to_string();
let invocable_line = if user_invocable {
String::new()
} else {
"user-invocable: false\n".to_string()
};
let model_invocation_line = if disable_model_invocation {
"disable-model-invocation: true\n".to_string()
} else {
String::new()
};
format!(
"---\nname: {name}\ndescription: {safe_description}\n{invocable_line}{model_invocation_line}---\n\n{instructions}"
)
}
pub fn discover_skills_from_entries(
entries: &[(String, String)],
) -> Vec<(SkillMeta, SkillInstructions)> {
let mut results = Vec::new();
for (path, content) in entries {
match crate::skill::parse_skill_md(content) {
Ok(parsed) => results.push((
SkillMeta {
name: parsed.name,
description: parsed.description,
source: SkillSource::Filesystem { path: path.clone() },
user_invocable: parsed.user_invocable,
disable_model_invocation: parsed.disable_model_invocation,
},
SkillInstructions {
instructions: parsed.instructions,
files: Vec::new(),
},
)),
Err(errors) => tracing::warn!(
path = %path,
errors = ?errors,
"Skipping invalid SKILL.md"
),
}
}
results
}
pub trait SkillCapabilityIdExt: Sized {
fn is_skill(&self) -> bool;
fn skill(skill_id: Uuid) -> Self;
fn skill_id(&self) -> Option<Uuid>;
}
impl SkillCapabilityIdExt for CapabilityId {
fn is_skill(&self) -> bool {
is_skill_capability(self.as_str())
}
fn skill(skill_id: Uuid) -> Self {
Self::new(skill_capability_id(skill_id))
}
fn skill_id(&self) -> Option<Uuid> {
parse_skill_capability_id(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_identity_uses_literal_namespace_and_rejects_invalid_ids() {
let uuid = Uuid::parse_str("12345678-1234-5678-9abc-123456789abc").unwrap();
let wire = "skill:12345678-1234-5678-9abc-123456789abc";
assert_eq!(skill_capability_id(uuid), wire);
let typed = CapabilityId::skill(uuid);
assert_eq!(typed.as_str(), wire);
assert!(typed.is_skill());
assert_eq!(typed.skill_id(), Some(uuid));
assert_eq!(parse_skill_capability_id(wire), Some(uuid));
for (candidate, namespace) in [
("skill:", true),
("skill:not-a-uuid", true),
("skills:12345678-1234-5678-9abc-123456789abc", false),
("mcp:docs", false),
("", false),
] {
assert_eq!(is_skill_capability(candidate), namespace);
assert_eq!(CapabilityId::new(candidate).is_skill(), namespace);
assert_eq!(parse_skill_capability_id(candidate), None);
assert_eq!(CapabilityId::new(candidate).skill_id(), None);
}
}
#[test]
fn contribution_mount_preserves_metadata_files_and_invocation_flags() {
use crate::capability_types::{MountAccess, MountSource};
for user in [false, true] {
for model_disabled in [false, true] {
let mount =
SkillContribution::new("ops", "Operations", "Run safely.\nKeep exact body.")
.with_files(vec![("reference.txt".into(), "Reference α".into())])
.with_user_invocable(user)
.with_disable_model_invocation(model_disabled)
.to_mount("owner-42");
assert_eq!(mount.path, "/.agents/skills/ops");
assert_eq!(mount.capability_id, "owner-42");
assert_eq!(mount.access, MountAccess::ReadOnly);
let MountSource::InlineDirectory { entries } = mount.source else {
panic!("expected directory")
};
assert_eq!(entries.len(), 2);
assert_eq!(
entries["reference.txt"].source,
MountSource::text_file("Reference α")
);
let MountSource::InlineFile { content, encoding } = &entries["SKILL.md"].source
else {
panic!("expected SKILL.md")
};
assert_eq!(encoding, "text");
let parsed = crate::skill::parse_skill_md(content).unwrap();
assert_eq!(parsed.name, "ops");
assert_eq!(parsed.description, "Operations");
assert_eq!(parsed.instructions, "Run safely.\nKeep exact body.");
assert_eq!(parsed.user_invocable, user);
assert_eq!(parsed.disable_model_invocation, model_disabled);
}
}
}
#[test]
fn reconstruction_preserves_yaml_sensitive_description_text() {
for description in [
r#"Use "quotes" and C:\new\tools"#,
"Line one\nLine two",
"Carriage\rreturn\ttab",
"Before\n---\nafter",
"Unicode α\u{85}β\u{2028}γ\u{2029}δ",
"Backslash \\",
] {
let content = reconstruct_skill_md("ops", description, "Instructions.", true, false);
let parsed = crate::skill::parse_skill_md(&content).unwrap();
assert_eq!(parsed.description, description, "{content}");
assert_eq!(parsed.instructions, "Instructions.");
assert!(parsed.user_invocable);
assert!(!parsed.disable_model_invocation);
}
}
#[test]
fn discovery_skips_invalid_entries_and_preserves_valid_content_and_source() {
let entries = vec![
("/bad/SKILL.md".into(), "invalid".into()),
(
"/first/SKILL.md".into(),
reconstruct_skill_md("first", "First", "Body one", false, true),
),
(
"/second/SKILL.md".into(),
reconstruct_skill_md("second", "Second", "Body two", true, false),
),
];
let found = discover_skills_from_entries(&entries);
assert_eq!(found.len(), 2);
for ((meta, instructions), (name, description, path, body, user, model_disabled)) in
found.iter().zip([
("first", "First", "/first/SKILL.md", "Body one", false, true),
(
"second",
"Second",
"/second/SKILL.md",
"Body two",
true,
false,
),
])
{
assert_eq!(meta.name, name);
assert_eq!(meta.description, description);
assert_eq!(meta.source, SkillSource::Filesystem { path: path.into() });
assert_eq!(meta.user_invocable, user);
assert_eq!(meta.disable_model_invocation, model_disabled);
assert_eq!(instructions.instructions, body);
assert!(instructions.files.is_empty());
}
}
#[test]
fn skill_metadata_pins_both_source_wire_shapes_and_missing_flag_defaults() {
for (source, wire) in [
(
SkillSource::Registry {
skill_id: "skill-42".into(),
},
serde_json::json!({"Registry":{"skill_id":"skill-42"}}),
),
(
SkillSource::Filesystem {
path: "/.agents/skills/ops/SKILL.md".into(),
},
serde_json::json!({"Filesystem":{"path":"/.agents/skills/ops/SKILL.md"}}),
),
] {
let meta = SkillMeta {
name: "ops".into(),
description: "Operations".into(),
source: source.clone(),
user_invocable: false,
disable_model_invocation: true,
};
let value = serde_json::json!({"name":"ops","description":"Operations","source":wire,"user_invocable":false,"disable_model_invocation":true});
assert_eq!(serde_json::to_value(&meta).unwrap(), value);
let parsed: SkillMeta = serde_json::from_value(value).unwrap();
assert_eq!(parsed.source, source);
assert!(!parsed.user_invocable);
assert!(parsed.disable_model_invocation);
let defaulted: SkillMeta = serde_json::from_value(
serde_json::json!({"name":"ops","description":"Operations","source":wire}),
)
.unwrap();
assert!(defaulted.user_invocable);
assert!(!defaulted.disable_model_invocation);
}
}
}