1use crate::capability_types::{MountDirectoryBuilder, MountPoint};
9use everruns_capability::CapabilityId;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13pub const SKILL_CAPABILITY_PREFIX: &str = "skill:";
15
16pub const SKILLS_DISCOVERY_PATH: &str = "/.agents/skills";
18
19pub const MAX_SKILLS_PER_CAPABILITY: usize = 50;
21
22pub fn skill_capability_id(skill_id: Uuid) -> String {
24 format!("{SKILL_CAPABILITY_PREFIX}{skill_id}")
25}
26
27pub fn is_skill_capability(capability_id: &str) -> bool {
29 capability_id.starts_with(SKILL_CAPABILITY_PREFIX)
30}
31
32pub fn parse_skill_capability_id(capability_id: &str) -> Option<Uuid> {
34 capability_id
35 .strip_prefix(SKILL_CAPABILITY_PREFIX)
36 .and_then(|value| Uuid::parse_str(value).ok())
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SkillMeta {
42 pub name: String,
44 pub description: String,
46 pub source: SkillSource,
48 #[serde(default = "default_true")]
50 pub user_invocable: bool,
51 #[serde(default)]
53 pub disable_model_invocation: bool,
54}
55
56fn default_true() -> bool {
57 true
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub enum SkillSource {
63 Filesystem { path: String },
65 Registry { skill_id: String },
67}
68
69#[derive(Debug, Clone)]
71pub struct SkillInstructions {
72 pub instructions: String,
74 pub files: Vec<(String, String)>,
76}
77
78#[derive(Debug, Clone)]
80pub struct SkillContribution {
81 pub name: String,
83 pub description: String,
85 pub instructions: String,
87 pub files: Vec<(String, String)>,
89 pub user_invocable: bool,
91 pub disable_model_invocation: bool,
93}
94
95impl SkillContribution {
96 pub fn new(
98 name: impl Into<String>,
99 description: impl Into<String>,
100 instructions: impl Into<String>,
101 ) -> Self {
102 Self {
103 name: name.into(),
104 description: description.into(),
105 instructions: instructions.into(),
106 files: Vec::new(),
107 user_invocable: true,
108 disable_model_invocation: false,
109 }
110 }
111
112 pub fn with_files(mut self, files: Vec<(String, String)>) -> Self {
114 self.files = files;
115 self
116 }
117
118 pub fn with_user_invocable(mut self, flag: bool) -> Self {
120 self.user_invocable = flag;
121 self
122 }
123
124 pub fn with_disable_model_invocation(mut self, flag: bool) -> Self {
126 self.disable_model_invocation = flag;
127 self
128 }
129
130 pub fn to_mount(&self, owner_id: &str) -> MountPoint {
132 let skill_md = reconstruct_skill_md(
133 &self.name,
134 &self.description,
135 &self.instructions,
136 self.user_invocable,
137 self.disable_model_invocation,
138 );
139 let mut builder = MountDirectoryBuilder::new().file("SKILL.md", &skill_md);
140 for (path, content) in &self.files {
141 builder = builder.file(path, content);
142 }
143 MountPoint::readonly(
144 format!("{SKILLS_DISCOVERY_PATH}/{}", self.name),
145 builder.build(),
146 owner_id,
147 )
148 }
149}
150
151pub fn reconstruct_skill_md(
153 name: &str,
154 description: &str,
155 instructions: &str,
156 user_invocable: bool,
157 disable_model_invocation: bool,
158) -> String {
159 let safe_description = serde_yaml::to_string(description)
161 .expect("a string is serializable")
162 .trim_end_matches('\n')
163 .to_string();
164 let invocable_line = if user_invocable {
165 String::new()
166 } else {
167 "user-invocable: false\n".to_string()
168 };
169 let model_invocation_line = if disable_model_invocation {
170 "disable-model-invocation: true\n".to_string()
171 } else {
172 String::new()
173 };
174 format!(
175 "---\nname: {name}\ndescription: {safe_description}\n{invocable_line}{model_invocation_line}---\n\n{instructions}"
176 )
177}
178
179pub fn discover_skills_from_entries(
181 entries: &[(String, String)],
182) -> Vec<(SkillMeta, SkillInstructions)> {
183 let mut results = Vec::new();
184 for (path, content) in entries {
185 match crate::skill::parse_skill_md(content) {
186 Ok(parsed) => results.push((
187 SkillMeta {
188 name: parsed.name,
189 description: parsed.description,
190 source: SkillSource::Filesystem { path: path.clone() },
191 user_invocable: parsed.user_invocable,
192 disable_model_invocation: parsed.disable_model_invocation,
193 },
194 SkillInstructions {
195 instructions: parsed.instructions,
196 files: Vec::new(),
197 },
198 )),
199 Err(errors) => tracing::warn!(
200 path = %path,
201 errors = ?errors,
202 "Skipping invalid SKILL.md"
203 ),
204 }
205 }
206 results
207}
208
209pub trait SkillCapabilityIdExt: Sized {
211 fn is_skill(&self) -> bool;
213 fn skill(skill_id: Uuid) -> Self;
215 fn skill_id(&self) -> Option<Uuid>;
217}
218
219impl SkillCapabilityIdExt for CapabilityId {
220 fn is_skill(&self) -> bool {
221 is_skill_capability(self.as_str())
222 }
223
224 fn skill(skill_id: Uuid) -> Self {
225 Self::new(skill_capability_id(skill_id))
226 }
227
228 fn skill_id(&self) -> Option<Uuid> {
229 parse_skill_capability_id(self.as_str())
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn skill_identity_uses_literal_namespace_and_rejects_invalid_ids() {
239 let uuid = Uuid::parse_str("12345678-1234-5678-9abc-123456789abc").unwrap();
240 let wire = "skill:12345678-1234-5678-9abc-123456789abc";
241 assert_eq!(skill_capability_id(uuid), wire);
242 let typed = CapabilityId::skill(uuid);
243 assert_eq!(typed.as_str(), wire);
244 assert!(typed.is_skill());
245 assert_eq!(typed.skill_id(), Some(uuid));
246 assert_eq!(parse_skill_capability_id(wire), Some(uuid));
247 for (candidate, namespace) in [
248 ("skill:", true),
249 ("skill:not-a-uuid", true),
250 ("skills:12345678-1234-5678-9abc-123456789abc", false),
251 ("mcp:docs", false),
252 ("", false),
253 ] {
254 assert_eq!(is_skill_capability(candidate), namespace);
255 assert_eq!(CapabilityId::new(candidate).is_skill(), namespace);
256 assert_eq!(parse_skill_capability_id(candidate), None);
257 assert_eq!(CapabilityId::new(candidate).skill_id(), None);
258 }
259 }
260
261 #[test]
262 fn contribution_mount_preserves_metadata_files_and_invocation_flags() {
263 use crate::capability_types::{MountAccess, MountSource};
264 for user in [false, true] {
265 for model_disabled in [false, true] {
266 let mount =
267 SkillContribution::new("ops", "Operations", "Run safely.\nKeep exact body.")
268 .with_files(vec![("reference.txt".into(), "Reference α".into())])
269 .with_user_invocable(user)
270 .with_disable_model_invocation(model_disabled)
271 .to_mount("owner-42");
272 assert_eq!(mount.path, "/.agents/skills/ops");
273 assert_eq!(mount.capability_id, "owner-42");
274 assert_eq!(mount.access, MountAccess::ReadOnly);
275 let MountSource::InlineDirectory { entries } = mount.source else {
276 panic!("expected directory")
277 };
278 assert_eq!(entries.len(), 2);
279 assert_eq!(
280 entries["reference.txt"].source,
281 MountSource::text_file("Reference α")
282 );
283 let MountSource::InlineFile { content, encoding } = &entries["SKILL.md"].source
284 else {
285 panic!("expected SKILL.md")
286 };
287 assert_eq!(encoding, "text");
288 let parsed = crate::skill::parse_skill_md(content).unwrap();
289 assert_eq!(parsed.name, "ops");
290 assert_eq!(parsed.description, "Operations");
291 assert_eq!(parsed.instructions, "Run safely.\nKeep exact body.");
292 assert_eq!(parsed.user_invocable, user);
293 assert_eq!(parsed.disable_model_invocation, model_disabled);
294 }
295 }
296 }
297
298 #[test]
299 fn reconstruction_preserves_yaml_sensitive_description_text() {
300 for description in [
301 r#"Use "quotes" and C:\new\tools"#,
302 "Line one\nLine two",
303 "Carriage\rreturn\ttab",
304 "Before\n---\nafter",
305 "Unicode α\u{85}β\u{2028}γ\u{2029}δ",
306 "Backslash \\",
307 ] {
308 let content = reconstruct_skill_md("ops", description, "Instructions.", true, false);
309 let parsed = crate::skill::parse_skill_md(&content).unwrap();
310 assert_eq!(parsed.description, description, "{content}");
311 assert_eq!(parsed.instructions, "Instructions.");
312 assert!(parsed.user_invocable);
313 assert!(!parsed.disable_model_invocation);
314 }
315 }
316
317 #[test]
318 fn discovery_skips_invalid_entries_and_preserves_valid_content_and_source() {
319 let entries = vec![
320 ("/bad/SKILL.md".into(), "invalid".into()),
321 (
322 "/first/SKILL.md".into(),
323 reconstruct_skill_md("first", "First", "Body one", false, true),
324 ),
325 (
326 "/second/SKILL.md".into(),
327 reconstruct_skill_md("second", "Second", "Body two", true, false),
328 ),
329 ];
330 let found = discover_skills_from_entries(&entries);
331 assert_eq!(found.len(), 2);
332 for ((meta, instructions), (name, description, path, body, user, model_disabled)) in
333 found.iter().zip([
334 ("first", "First", "/first/SKILL.md", "Body one", false, true),
335 (
336 "second",
337 "Second",
338 "/second/SKILL.md",
339 "Body two",
340 true,
341 false,
342 ),
343 ])
344 {
345 assert_eq!(meta.name, name);
346 assert_eq!(meta.description, description);
347 assert_eq!(meta.source, SkillSource::Filesystem { path: path.into() });
348 assert_eq!(meta.user_invocable, user);
349 assert_eq!(meta.disable_model_invocation, model_disabled);
350 assert_eq!(instructions.instructions, body);
351 assert!(instructions.files.is_empty());
352 }
353 }
354 #[test]
355 fn skill_metadata_pins_both_source_wire_shapes_and_missing_flag_defaults() {
356 for (source, wire) in [
357 (
358 SkillSource::Registry {
359 skill_id: "skill-42".into(),
360 },
361 serde_json::json!({"Registry":{"skill_id":"skill-42"}}),
362 ),
363 (
364 SkillSource::Filesystem {
365 path: "/.agents/skills/ops/SKILL.md".into(),
366 },
367 serde_json::json!({"Filesystem":{"path":"/.agents/skills/ops/SKILL.md"}}),
368 ),
369 ] {
370 let meta = SkillMeta {
371 name: "ops".into(),
372 description: "Operations".into(),
373 source: source.clone(),
374 user_invocable: false,
375 disable_model_invocation: true,
376 };
377 let value = serde_json::json!({"name":"ops","description":"Operations","source":wire,"user_invocable":false,"disable_model_invocation":true});
378 assert_eq!(serde_json::to_value(&meta).unwrap(), value);
379 let parsed: SkillMeta = serde_json::from_value(value).unwrap();
380 assert_eq!(parsed.source, source);
381 assert!(!parsed.user_invocable);
382 assert!(parsed.disable_model_invocation);
383 let defaulted: SkillMeta = serde_json::from_value(
384 serde_json::json!({"name":"ops","description":"Operations","source":wire}),
385 )
386 .unwrap();
387 assert!(defaulted.user_invocable);
388 assert!(!defaulted.disable_model_invocation);
389 }
390 }
391}