1use crate::skill::types::TrustLevel;
5use crate::skill::{DriftStatus, SkillManifest, content_sha256, drift_status, local};
6use crate::trust::skills::SkillTrustStore;
7use std::path::Path;
8
9pub fn is_valid_skill_name(name: &str) -> bool {
15 !name.is_empty()
16 && name.len() <= 64
17 && name != "."
20 && name != ".."
21 && name
24 .chars()
25 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SkillScope {
30 Global,
31 Agent,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SkillRefStatus {
42 Loadable,
44 Missing { path: std::path::PathBuf },
46 Malformed {
48 path: std::path::PathBuf,
49 error: String,
50 },
51 CorruptRef { reason: String },
62}
63
64pub fn skill_ref_status(agent_home: &Path, rel_ref: &str) -> SkillRefStatus {
73 let joined = agent_home.join(rel_ref);
74 let ext = joined
75 .extension()
76 .and_then(|e| e.to_str())
77 .unwrap_or("")
78 .to_ascii_lowercase();
79 let file = if joined.is_dir() || !matches!(ext.as_str(), "yaml" | "yml" | "md" | "markdown") {
80 joined.join("skill.yaml")
84 } else {
85 joined
86 };
87 if !file.is_file() {
88 if rel_ref.split_whitespace().count() > 1 {
92 return SkillRefStatus::CorruptRef {
93 reason: "a skill ref cannot contain whitespace; this entry looks like several \
94 refs concatenated — split it into separate list items, or remove it"
95 .to_string(),
96 };
97 }
98 return SkillRefStatus::Missing { path: file };
99 }
100 let text = match std::fs::read_to_string(&file) {
101 Ok(t) => t,
102 Err(e) => {
103 return SkillRefStatus::Malformed {
104 path: file,
105 error: format!("unreadable: {e}"),
106 };
107 }
108 };
109 let ext = file
110 .extension()
111 .and_then(|e| e.to_str())
112 .unwrap_or("")
113 .to_ascii_lowercase();
114 let parsed = match ext.as_str() {
115 "yaml" | "yml" => crate::skill::parse_canonical(&text),
116 "md" | "markdown" => crate::skill::parse_markdown(&text)
117 .or_else(|_| crate::skill::parse_legacy_markdown(&text)),
118 other => {
119 return SkillRefStatus::Malformed {
120 path: file,
121 error: format!("unsupported manifest extension '.{other}'"),
122 };
123 }
124 };
125 match parsed {
126 Ok(m) => match crate::skill::validate(&m) {
127 Ok(()) => SkillRefStatus::Loadable,
128 Err(e) => SkillRefStatus::Malformed {
129 path: file,
130 error: format!("invalid manifest: {e}"),
131 },
132 },
133 Err(e) => SkillRefStatus::Malformed {
134 path: file,
135 error: format!("parse failed: {e}"),
136 },
137 }
138}
139
140#[derive(Debug, Clone)]
141pub struct LoadedSkill {
142 pub name: String,
143 pub manifest: SkillManifest,
144 pub trust: TrustLevel,
145 pub scope: SkillScope,
146 pub content_hash: String,
147 pub dir: std::path::PathBuf,
149}
150
151pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
152 let trust = SkillTrustStore::load(mur_home).unwrap_or_default();
153 let mut out: Vec<LoadedSkill> = Vec::new();
154 let mut seen_names: std::collections::HashSet<String> = Default::default();
155
156 if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
158 for name in names {
159 if !crate::skill::store::agent_skill_dir(mur_home, agent_name)
164 .join(&name)
165 .join("skill.yaml")
166 .is_file()
167 {
168 continue;
169 }
170 if let Some(mut loaded) =
171 load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
172 local::load_installed_agent(m, agent_name, n)
173 })
174 {
175 loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
176 seen_names.insert(loaded.name.clone());
177 out.push(loaded);
178 }
179 }
180 }
181 let cache_dir = mur_home
186 .join("agents")
187 .join(agent_name)
188 .join("knowledge_cache");
189 if let Ok(entries) = std::fs::read_dir(&cache_dir) {
190 let mut names: Vec<String> = entries
191 .filter_map(|e| e.ok())
192 .filter(|e| e.path().join("skill.yaml").is_file())
193 .filter_map(|e| e.file_name().to_str().map(String::from))
194 .collect();
195 names.sort(); for name in names {
197 if seen_names.contains(&name) {
198 continue;
199 }
200 let dir = cache_dir.join(&name);
201 let dir_for_loader = dir.clone();
202 if let Some(mut loaded) = load_one(
203 mur_home,
204 &name,
205 SkillScope::Global,
206 &trust,
207 move |_m, _n| crate::skill::read_from_dir(&dir_for_loader),
208 ) {
209 loaded.dir = dir;
210 seen_names.insert(loaded.name.clone());
211 out.push(loaded);
212 }
213 }
214 }
215
216 if let Ok(names) = local::list_installed(mur_home) {
217 for name in names {
218 if seen_names.contains(&name) {
219 continue;
220 }
221 if !crate::skill::store::global_skill_dir(mur_home, &name)
224 .join("skill.yaml")
225 .is_file()
226 {
227 continue;
228 }
229 if let Some(mut loaded) = load_one(
230 mur_home,
231 &name,
232 SkillScope::Global,
233 &trust,
234 local::load_installed,
235 ) {
236 loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
237 out.push(loaded);
238 }
239 }
240 }
241 out
242}
243
244fn load_one<F>(
245 mur_home: &Path,
246 name: &str,
247 scope: SkillScope,
248 trust: &SkillTrustStore,
249 loader: F,
250) -> Option<LoadedSkill>
251where
252 F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
253{
254 if !is_valid_skill_name(name) {
259 tracing::warn!(
260 skill = %name,
261 "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
262 );
263 return None;
264 }
265
266 let manifest = match loader(mur_home, name) {
267 Ok(m) => m,
268 Err(e) => {
269 tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
270 return None;
271 }
272 };
273 let hash = match content_sha256(&manifest) {
274 Ok(h) => h,
275 Err(e) => {
276 tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
277 return None;
278 }
279 };
280 let entry = trust.entries.get(&hash);
283 if let Some(pinned) = entry {
284 if let Ok(DriftStatus::Drift { expected, actual }) = drift_status(&manifest, Some(&hash)) {
285 tracing::warn!(skill = %name, expected, actual, "skill drift detected; skipping");
286 return None;
287 }
288 if trust.is_revoked(&hash) {
289 tracing::warn!(skill = %name, "skill hash revoked; skipping");
290 return None;
291 }
292 Some(LoadedSkill {
293 name: name.into(),
294 manifest,
295 trust: pinned.level,
296 scope,
297 content_hash: hash,
298 dir: std::path::PathBuf::new(), })
300 } else {
301 Some(LoadedSkill {
303 name: name.into(),
304 manifest,
305 trust: TrustLevel::Sandboxed,
306 scope,
307 content_hash: hash,
308 dir: std::path::PathBuf::new(), })
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::skill::{parse_canonical, write_to_dir};
317 use tempfile::tempdir;
318
319 #[test]
320 fn load_all_sets_agent_skill_dir() {
321 let dir = tempdir().unwrap();
322 let home = dir.path();
323 let sdir = home.join("agents").join("a1").join("skills").join("demo");
324 write_to_dir(&sdir, &make("demo")).unwrap();
325
326 let loaded = load_all(home, "a1");
327 let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
328 assert_eq!(demo.dir, sdir);
329 }
330
331 fn make(name: &str) -> SkillManifest {
332 make_desc(name, "test")
333 }
334
335 fn make_desc(name: &str, desc: &str) -> SkillManifest {
336 parse_canonical(&format!(
337 r#"name: {name}
338version: 1.0.0
339publisher: human:t
340description: {desc}
341category: context
342content:
343 abstract: hi
344 context: body
345"#
346 ))
347 .unwrap()
348 }
349
350 #[test]
351 fn knowledge_cache_skill_loads() {
352 let dir = tempdir().unwrap();
353 let home = dir.path();
354 let cdir = home
355 .join("agents/a1/knowledge_cache")
356 .join("federated-skill");
357 write_to_dir(&cdir, &make("federated-skill")).unwrap();
358
359 let loaded = load_all(home, "a1");
360 let hit = loaded
361 .iter()
362 .find(|s| s.name == "federated-skill")
363 .expect("cached skill must be visible to the loader");
364 assert_eq!(hit.dir, cdir);
365 }
366
367 #[test]
368 fn agent_local_wins_over_cache_wins_over_global() {
369 let dir = tempdir().unwrap();
370 let home = dir.path();
371 write_to_dir(
372 &home.join("agents/a1/skills/dup"),
373 &make_desc("dup", "agent-local"),
374 )
375 .unwrap();
376 write_to_dir(
377 &home.join("agents/a1/knowledge_cache/dup"),
378 &make_desc("dup", "cache"),
379 )
380 .unwrap();
381 write_to_dir(&home.join("skills/dup"), &make_desc("dup", "global")).unwrap();
382
383 let loaded = load_all(home, "a1");
384 let dups: Vec<_> = loaded.iter().filter(|s| s.name == "dup").collect();
385 assert_eq!(dups.len(), 1, "name collision must resolve to ONE copy");
386 assert_eq!(dups[0].manifest.description, "agent-local");
387
388 std::fs::remove_dir_all(home.join("agents/a1/skills/dup")).unwrap();
390 let loaded = load_all(home, "a1");
391 let dup = loaded.iter().find(|s| s.name == "dup").unwrap();
392 assert_eq!(dup.manifest.description, "cache");
393 }
394
395 #[test]
396 fn empty_mur_home_returns_empty() {
397 let dir = tempdir().unwrap();
398 let loaded = load_all(dir.path(), "alice");
399 assert!(loaded.is_empty());
400 }
401
402 #[test]
403 fn load_all_skips_non_skill_dirs() {
404 let dir = tempdir().unwrap();
405 let home = dir.path();
406 write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
408 let ledger = home.join("skills").join("not-a-skill");
414 std::fs::create_dir_all(&ledger).unwrap();
415 std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
416
417 let loaded = load_all(home, "a1");
418 let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
419 assert_eq!(
420 names,
421 vec!["real"],
422 "ledger dir must not be loaded as a skill"
423 );
424 }
425
426 #[test]
427 fn is_valid_skill_name_rejects_traversal_and_reserved() {
428 assert!(is_valid_skill_name("web-search"));
430 assert!(is_valid_skill_name("my.skill_v2"));
431 assert!(!is_valid_skill_name("."));
433 assert!(!is_valid_skill_name(".."));
434 assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
436 assert!(!is_valid_skill_name("a/b"));
437 assert!(!is_valid_skill_name("a\\b"));
438 assert!(!is_valid_skill_name("/etc/passwd"));
439 assert!(!is_valid_skill_name(""));
441 assert!(!is_valid_skill_name(&"x".repeat(65)));
442 }
443
444 #[test]
445 fn global_skill_returns_sandboxed_when_no_trust_entry() {
446 let dir = tempdir().unwrap();
447 write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
448 let loaded = load_all(dir.path(), "alice");
449 assert_eq!(loaded.len(), 1);
450 assert_eq!(loaded[0].name, "demo");
451 assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
452 assert_eq!(loaded[0].scope, SkillScope::Global);
453 }
454
455 #[test]
456 fn agent_overrides_global_by_name() {
457 let dir = tempdir().unwrap();
458 write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
460 write_to_dir(
461 &dir.path()
462 .join("agents")
463 .join("alice")
464 .join("skills")
465 .join("shared"),
466 &make("shared"),
467 )
468 .unwrap();
469 let loaded = load_all(dir.path(), "alice");
470 let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
471 assert_eq!(shared.len(), 1);
472 assert_eq!(shared[0].scope, SkillScope::Agent);
473 }
474
475 #[test]
478 fn skill_ref_status_loadable_for_installed_dir_skill() {
479 let home = tempdir().unwrap();
480 write_to_dir(&home.path().join("skills").join("demo"), &make("demo")).unwrap();
481 assert_eq!(
482 skill_ref_status(home.path(), "skills/demo"),
483 SkillRefStatus::Loadable
484 );
485 }
486
487 #[test]
488 fn skill_ref_status_absent_ref_is_missing_with_manifest_path() {
489 let home = tempdir().unwrap();
490 match skill_ref_status(home.path(), "skills/executing-plans") {
491 SkillRefStatus::Missing { path } => {
492 assert!(path.ends_with("skills/executing-plans/skill.yaml"));
494 }
495 other => panic!("expected Missing, got {other:?}"),
496 }
497 }
498
499 #[test]
504 fn skill_ref_status_concatenated_refs_are_corrupt_not_missing() {
505 let home = tempdir().unwrap();
506 let ref_ = "skills/pm-spec-handoff - skills/brainstorming - skills/brainstorming";
507 match skill_ref_status(home.path(), ref_) {
508 SkillRefStatus::CorruptRef { reason } => {
509 assert!(reason.contains("whitespace"), "unhelpful reason: {reason}");
510 }
511 other => panic!("expected CorruptRef, got {other:?}"),
512 }
513 }
514
515 #[test]
518 fn skill_ref_status_plain_absent_ref_stays_missing() {
519 let home = tempdir().unwrap();
520 assert!(matches!(
521 skill_ref_status(home.path(), "skills/never-installed"),
522 SkillRefStatus::Missing { .. }
523 ));
524 }
525
526 #[test]
527 fn skill_ref_status_garbage_yaml_is_malformed() {
528 let home = tempdir().unwrap();
529 let sdir = home.path().join("skills").join("broken");
530 std::fs::create_dir_all(&sdir).unwrap();
531 std::fs::write(sdir.join("skill.yaml"), "{{{ not: [valid").unwrap();
532 assert!(matches!(
533 skill_ref_status(home.path(), "skills/broken"),
534 SkillRefStatus::Malformed { .. }
535 ));
536 }
537
538 #[test]
539 fn skill_ref_status_legacy_md_file_resolves_directly() {
540 let home = tempdir().unwrap();
541 let sdir = home.path().join("skills");
542 std::fs::create_dir_all(&sdir).unwrap();
543 match skill_ref_status(home.path(), "skills/old.md") {
545 SkillRefStatus::Missing { path } => assert!(path.ends_with("skills/old.md")),
546 other => panic!("expected Missing, got {other:?}"),
547 }
548 std::fs::write(sdir.join("old.md"), "no frontmatter here").unwrap();
550 assert!(matches!(
551 skill_ref_status(home.path(), "skills/old.md"),
552 SkillRefStatus::Malformed { .. }
553 ));
554 }
555}