返回 CodeWhale
tests.rs
根目录 / crates / tui / src / skills / tests.rs
1 use tempfile::TempDir;
2
3 #[test]
4 fn frontmatter_handles_bom_exact_fences_and_plain_continuations() {
5 let content = "\u{feff}---\r\nname: demo\r\ndescription: Deploy apps --- safely\r\n for X or Y: really\r\ninvocation: user\r\n---\r\n# Body\r\n";
6 let (metadata, body) = super::parse_frontmatter(content).unwrap().unwrap();
7 assert_eq!(metadata["name"], "demo");
8 assert_eq!(
9 metadata["description"],
10 "Deploy apps --- safely for X or Y: really"
11 );
12 assert_eq!(metadata.len(), 3, "continuation text is not a new key");
13 assert_eq!(body.trim(), "# Body");
14 assert!(
15 super::parse_frontmatter("---not a fence\n# Body")
16 .unwrap()
17 .is_none()
18 );
19 assert!(super::parse_frontmatter("---\nname: demo\ndescription: inline --- only").is_err());
20 }
21
22 #[test]
23 fn frontmatter_block_indentation_is_character_safe_and_keeps_nested_fences() {
24 for indicator in ["|", ">"] {
25 let content = format!(
26 "---\nname: demo\ndescription: {indicator}\n first\n\u{3000}wide\n \u{a0}mixed\n ---\n nested\n---\nbody"
27 );
28 let (metadata, body) = super::parse_frontmatter(&content).unwrap().unwrap();
29 let expected = if indicator == "|" {
30 "first\nwide\nmixed\n---\n nested"
31 } else {
32 "first wide mixed --- nested"
33 };
34 assert_eq!(metadata["description"], expected);
35 assert_eq!(body.trim(), "body");
36 }
37 }
38
39 #[test]
40 fn unavailable_home_fallbacks_are_fresh_nonexistent_paths() {
41 let first = super::unavailable_home_root();
42 let second = super::unavailable_home_root();
43 assert_ne!(first, second);
44 assert!(!first.exists());
45 assert!(!second.exists());
46 assert!(first.starts_with(std::env::temp_dir()));
47 assert_ne!(first, std::path::Path::new("/tmp/codewhale"));
48 }
49
50 fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
51 let skill_dir = tmpdir.path().join("skills").join(skill_name);
52 std::fs::create_dir_all(&skill_dir).unwrap();
53 std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
54 }
55
56 #[test]
57 fn unicode_skill_identity_preserves_bodies_and_legacy_activation() {
58 let tmp = TempDir::new().unwrap();
59 let names = [
60 "技能",
61 "分析",
62 "PDF阅读",
63 "PDF签名",
64 "skill",
65 "pdf",
66 "Café",
67 "Cafe\u{301}",
68 ];
69 for (index, raw) in names.iter().enumerate() {
70 create_skill_dir(
71 &tmp,
72 &format!("source-{index}"),
73 &format!("---\nname: {raw}\ndescription: identity fixture\n---\nbody {index}"),
74 );
75 }
76 let registry = super::SkillRegistry::discover(&tmp.path().join("skills"));
77 assert_eq!(
78 registry.len(),
79 names.len(),
80 "colliding old slugs must not hide bodies"
81 );
82 for (index, raw) in names.iter().enumerate() {
83 let skill = registry.get(raw).unwrap();
84 assert_eq!(skill.body, format!("body {index}"));
85 assert!(super::is_valid_skill_name(&skill.name));
86 assert_eq!(registry.get(&skill.name).unwrap().body, skill.body);
87 assert_eq!(
88 super::normalize_skill_name_for_lookup(&skill.name),
89 skill.name
90 );
91 assert!(skill.path.ends_with(format!("source-{index}/SKILL.md")));
92 }
93 assert_eq!(registry.get("skill").unwrap().name, "skill");
94 assert_eq!(registry.get("pdf阅读").unwrap().body, "body 2");
95 assert_ne!(
96 registry.get("Café").unwrap().name,
97 registry.get("Cafe\u{301}").unwrap().name
98 );
99 let long = format!("{}技能", "a".repeat(100));
100 assert_eq!(super::normalize_skill_name_for_lookup(&long).len(), 64);
101 assert!(
102 registry
103 .warnings()
104 .iter()
105 .all(|warning| warning.contains("differs from directory")),
106 "{:?}",
107 registry.warnings()
108 );
109 let mut malformed = registry.get("技能").unwrap().clone();
110 malformed.name = "bad\u{1}技能".to_string();
111 let mut validation = super::SkillRegistry::default();
112 validation.normalize_skill_name(&mut malformed, std::path::Path::new("SKILL.md"));
113 assert_eq!(
114 validation.warnings().len(),
115 1,
116 "control bytes remain invalid"
117 );
118
119 let path = tmp.path().join("skills_state.toml");
120 let original = b"disabled = [\"skill\", \"pdf\"]\n";
121 std::fs::write(&path, original).unwrap();
122 let filtered = registry
123 .clone()
124 .into_enabled_with_state(crate::skill_state::SkillStateStore::load_from(path.clone()));
125 assert_eq!(filtered.len(), 2);
126 assert!(filtered.get("技能").is_none());
127 assert_eq!(std::fs::read(&path).unwrap(), original);
128 let mut state = crate::skill_state::SkillStateStore::load_from(path.clone()).unwrap();
129 state
130 .set_enabled(&registry.get("技能").unwrap().name, true)
131 .unwrap();
132 state.set_enabled("skill", true).unwrap();
133 let filtered = registry.into_enabled_with_state(Ok(state));
134 assert_eq!(filtered.get("技能").unwrap().body, "body 0");
135 assert_eq!(filtered.get("skill").unwrap().body, "body 4");
136 for hidden in ["分析", "PDF阅读", "PDF签名", "pdf"] {
137 assert!(filtered.get(hidden).is_none(), "must not revive {hidden}");
138 }
139 let rendered = super::render_skills_block(&filtered, "en", tmp.path()).unwrap();
140 assert!(rendered.contains(&filtered.get("技能").unwrap().name));
141 assert!(!rendered.contains("body 1"));
142 }
143
144 #[test]
145 fn unicode_plugin_identity_keeps_exact_namespaces_and_reviewed_metadata() {
146 let tmp = TempDir::new().unwrap();
147 let config = crate::plugins::discovery::DiscoveryConfig {
148 workspace: tmp.path().join("workspace"),
149 user_plugins_dir: tmp.path().join("plugins"),
150 workspace_plugins_dir: tmp.path().join("workspace-plugins"),
151 builtin_plugin_dirs: Vec::new(),
152 state_path: tmp.path().join("plugin-state/state.json"),
153 };
154 for namespace in ["team.plugin", "team-plugin"] {
155 let root = config.user_plugins_dir.join(namespace);
156 std::fs::create_dir_all(&root).unwrap();
157 std::fs::write(
158 root.join("plugin.json"),
159 serde_json::json!({
160 "$schema": "https://agent-plugins.org/schemas/plugin.json",
161 "name": namespace,
162 "version": "1.0.0"
163 })
164 .to_string(),
165 )
166 .unwrap();
167 for name in ["技能", "分析", "skill"] {
168 write_skill(
169 &root.join("skills"),
170 name,
171 "reviewed identity",
172 &format!("{namespace} {name}"),
173 );
174 }
175 }
176 let mut plugins = crate::plugins::discovery::discover_with_config(&config);
177 assert!(plugins.validation_is_clean(), "{:?}", plugins.diagnostics());
178 let mut registry = super::SkillRegistry::default();
179 super::merge_active_plugin_skills(&mut registry, &plugins);
180 assert!(registry.is_empty());
181 for namespace in ["team.plugin", "team-plugin"] {
182 plugins.trust(namespace).unwrap();
183 plugins.enable(namespace).unwrap_or_else(|error| {
184 panic!("{error}: {:?}", plugins.get(namespace).unwrap().diagnostics)
185 });
186 let plugin = plugins.get(namespace).unwrap();
187 let unicode = plugin
188 .skill_snapshots
189 .iter()
190 .find(|s| s.body == format!("{namespace} 技能"))
191 .unwrap();
192 assert_eq!(unicode.legacy_activation_name.as_deref(), Some("skill"));
193 assert!(!unicode.source_hash.is_empty());
194 }
195 super::merge_active_plugin_skills(&mut registry, &plugins);
196 assert_eq!(registry.len(), 6);
197 let dotted = registry.get("TEAM.PLUGIN:技能").unwrap();
198 assert_eq!(dotted.body, "team.plugin 技能");
199 assert_eq!(
200 dotted.legacy_activation_name.as_deref(),
201 Some("team.plugin:skill")
202 );
203 assert_eq!(
204 registry.get("TEAM-PLUGIN:技能").unwrap().body,
205 "team-plugin 技能"
206 );
207 assert!(registry.get("team_plugin:技能").is_none());
208 let path = tmp.path().join("skills_state.toml");
209 std::fs::write(&path, "disabled = [\"team.plugin:skill\"]\n").unwrap();
210 let filtered = registry
211 .clone()
212 .into_enabled_with_state(crate::skill_state::SkillStateStore::load_from(path));
213 assert!(filtered.get("team.plugin:技能").is_none());
214 assert!(filtered.get("team-plugin:技能").is_some());
215 registry
216 .skills
217 .retain(|skill| !skill.name.starts_with("team.plugin:"));
218 assert!(
219 registry.get("team.plugin:技能").is_none(),
220 "absent dotted namespace must not select dashed namespace"
221 );
222 }
223
224 #[test]
225 fn discovery_metrics_reset_and_snapshot_are_exact() {
226 super::reset_discovery_metrics();
227 assert_eq!(
228 super::discovery_metrics_snapshot(),
229 super::SkillDiscoveryMetrics::default()
230 );
231
232 let tmpdir = TempDir::new().unwrap();
233 let skills_root = tmpdir.path().join("skills");
234 let vendor_root = skills_root.join("vendor");
235 write_skill(&vendor_root, "demo", "A demo skill", "Instructions");
236
237 let registry = super::SkillRegistry::discover(&skills_root);
238 assert_eq!(registry.len(), 1);
239 assert_eq!(
240 super::discovery_metrics_snapshot(),
241 super::SkillDiscoveryMetrics {
242 root_discovery_calls: 1,
243 directories_visited: 2,
244 skill_md_read_attempts: 2,
245 }
246 );
247
248 super::reset_discovery_metrics();
249 let missing_root = tmpdir.path().join("missing");
250 let _registry = super::SkillRegistry::discover(&missing_root);
251 assert_eq!(
252 super::discovery_metrics_snapshot(),
253 super::SkillDiscoveryMetrics {
254 root_discovery_calls: 1,
255 directories_visited: 0,
256 skill_md_read_attempts: 0,
257 }
258 );
259
260 super::reset_discovery_metrics();
261 assert_eq!(
262 super::discovery_metrics_snapshot(),
263 super::SkillDiscoveryMetrics::default()
264 );
265 }
266
267 #[test]
268 fn prompt_warning_sanitizer_scrubs_stale_conventional_home_roots() {
269 let workspace = std::path::Path::new("/tmp/workspace");
270 let warning = "Skill at /Users/private-name/.agents/skills/a/SKILL.md is shadowed by /home/other/.skills/a/SKILL.md";
271 let sanitized = super::sanitize_prompt_path_text(warning, workspace, None);
272 assert_eq!(
273 sanitized,
274 "Skill at ~/.agents/skills/a/SKILL.md is shadowed by ~/.skills/a/SKILL.md"
275 );
276 }
277
278 #[test]
279 fn prompt_warning_sanitizer_normalizes_windows_separators() {
280 let workspace = std::path::Path::new(r"C:\workspace");
281 let configured_root = std::path::Path::new(r"C:\runtime\sessions\session-123\skills");
282 let warning = r"Skill in C:\runtime\sessions\session-123\skills\visual-design\SKILL.md is not a safe command name";
283
284 let sanitized = super::sanitize_prompt_path_text(warning, workspace, Some(configured_root));
285
286 assert_eq!(
287 sanitized,
288 "Skill in <configured-skills>/visual-design/SKILL.md is not a safe command name"
289 );
290 }
291
292 #[test]
293 fn prompt_warning_sanitizer_replaces_configured_roots_only_at_path_boundaries() {
294 let workspace = std::path::Path::new("/tmp/workspace");
295 let configured_root = std::path::Path::new("/tmp/work");
296 let warning = "Skill in /tmp/workspace/.agents/skills/a/SKILL.md shadows /tmp/work/a/SKILL.md";
297
298 let sanitized = super::sanitize_prompt_path_text(warning, workspace, Some(configured_root));
299
300 assert_eq!(
301 sanitized,
302 "Skill in ./.agents/skills/a/SKILL.md shadows <configured-skills>/a/SKILL.md"
303 );
304 }
305
306 #[cfg(unix)]
307 #[test]
308 fn prompt_warning_sanitizer_handles_non_utf8_configured_roots() {
309 use std::os::unix::ffi::OsStringExt;
310
311 let workspace = std::path::Path::new("/tmp/workspace");
312 let configured_root = std::path::PathBuf::from(std::ffi::OsString::from_vec(
313 b"/tmp/session-\xff/skills".to_vec(),
314 ));
315 let warning = format!(
316 "Skill in {}/visual-design/SKILL.md is not a safe command name",
317 configured_root.display()
318 );
319
320 let sanitized = super::sanitize_prompt_path_text(&warning, workspace, Some(&configured_root));
321
322 assert_eq!(
323 sanitized,
324 "Skill in <configured-skills>/visual-design/SKILL.md is not a safe command name"
325 );
326 }
327
328 #[test]
329 fn render_available_skills_context_lists_paths_and_usage() {
330 let tmpdir = TempDir::new().unwrap();
331 create_skill_dir(
332 &tmpdir,
333 "test-skill",
334 "---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
335 );
336
337 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
338 .expect("skill context");
339
340 // #4632: paths render relative to the skills base dir (privacy-safe),
341 // so the assertion checks the workspace-relative form.
342 let expected_path = super::prompt_display(&std::path::Path::new("test-skill").join("SKILL.md"));
343
344 assert!(rendered.contains("## Skills"));
345 assert!(rendered.contains("- test-skill: A test skill"));
346 assert!(rendered.contains("call `load_skill` with that exact name before starting the work"));
347 assert!(rendered.contains("do not expand tool, approval, or trust authority"));
348 assert!(
349 rendered.contains(&expected_path),
350 "expected path {expected_path:?} not in rendered output"
351 );
352 assert!(!rendered.contains(tmpdir.path().to_str().unwrap_or("/nonexistent")));
353 assert!(rendered.contains("### Usage"));
354 }
355
356 #[test]
357 fn workspace_prompt_omits_disabled_skills_without_configured_directory() {
358 let _env_lock = crate::test_support::lock_test_env();
359 let tmpdir = TempDir::new().unwrap();
360 let home = tmpdir.path().join("home");
361 let workspace = tmpdir.path().join("workspace");
362 let skills_root = workspace.join(".agents").join("skills");
363 std::fs::create_dir_all(&home).unwrap();
364 write_skill(
365 &skills_root,
366 "enabled-skill",
367 "Enabled skill",
368 "Instructions",
369 );
370 write_skill(
371 &skills_root,
372 "disabled-skill",
373 "Disabled skill",
374 "Instructions",
375 );
376 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
377 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
378 let _codewhale_home =
379 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
380 crate::test_support::trust_workspace(&workspace);
381
382 let mut state = crate::skill_state::SkillStateStore::load_default().unwrap();
383 state.set_enabled("disabled-skill", false).unwrap();
384 super::clear_skill_discovery_cache();
385
386 let rendered = super::render_available_skills_context_for_workspace_with_mode_and_plugins(
387 &workspace,
388 super::SkillDiscoveryMode::Compatible,
389 "en",
390 None,
391 super::MAX_AVAILABLE_SKILLS_CHARS,
392 )
393 .expect("enabled skill context");
394
395 assert!(rendered.contains("enabled-skill"));
396 assert!(!rendered.contains("disabled-skill"));
397 }
398
399 #[test]
400 fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
401 // Regression: when a community-installed or manually-placed skill
402 // lives in a directory whose name differs from its frontmatter
403 // `name`, the rendered prompt must point to the real on-disk file
404 // path, not <skills_dir>/<frontmatter-name>/SKILL.md (which does
405 // not exist).
406 let tmpdir = TempDir::new().unwrap();
407 create_skill_dir(
408 &tmpdir,
409 "weird-dir-name",
410 "---\nname: friendly-name\ndescription: drift case\n---\nbody",
411 );
412
413 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
414 .expect("skill context");
415
416 // #4632: rendered relative to the skills base dir; the regression
417 // intent (real dir name, not frontmatter name) is unchanged.
418 let real_path = super::prompt_display(&std::path::Path::new("weird-dir-name").join("SKILL.md"));
419 let stale_path = super::prompt_display(&std::path::Path::new("friendly-name").join("SKILL.md"));
420
421 assert!(
422 rendered.contains(&real_path),
423 "expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}"
424 );
425 assert!(
426 !rendered.contains(&stale_path),
427 "rendered output must not invent a path under the frontmatter name:\n{rendered}"
428 );
429 }
430
431 #[test]
432 fn render_available_skills_context_returns_none_when_empty() {
433 let tmpdir = TempDir::new().unwrap();
434 let empty = tmpdir.path().join("skills");
435 std::fs::create_dir_all(&empty).unwrap();
436 assert!(crate::skills::render_available_skills_context(&empty).is_none());
437
438 let missing = tmpdir.path().join("does-not-exist");
439 assert!(crate::skills::render_available_skills_context(&missing).is_none());
440 }
441
442 #[test]
443 fn render_skills_block_surfaces_warnings_when_no_skill_loaded() {
444 let tmpdir = TempDir::new().unwrap();
445 let mut registry = super::SkillRegistry::default();
446 registry
447 .warnings
448 .push("broken skill could not be parsed".to_string());
449
450 let rendered =
451 super::render_skills_block(&registry, "en", tmpdir.path()).expect("warning-only block");
452
453 assert!(rendered.contains("### Skill load warnings"));
454 assert!(rendered.contains("broken skill could not be parsed"));
455 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
456 }
457
458 #[test]
459 fn render_available_skills_context_truncates_long_descriptions() {
460 let tmpdir = TempDir::new().unwrap();
461 let long_desc = "x".repeat(2_000);
462 let body = format!("---\nname: bigdesc\ndescription: {long_desc}\n---\nbody");
463 create_skill_dir(&tmpdir, "bigdesc", &body);
464
465 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
466 .expect("skill context");
467
468 let max = super::MAX_SKILL_DESCRIPTION_CHARS;
469 assert!(rendered.contains('…'), "expected truncation marker");
470 assert!(
471 !rendered.contains(&"x".repeat(max + 1)),
472 "untruncated long run should not appear"
473 );
474 }
475
476 #[test]
477 fn render_available_skills_context_collapses_internal_whitespace() {
478 let tmpdir = TempDir::new().unwrap();
479 create_skill_dir(
480 &tmpdir,
481 "spaced-skill",
482 "---\nname: spaced-skill\ndescription: alpha \t beta gamma\n---\nbody",
483 );
484
485 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
486 .expect("skill context");
487
488 let line = rendered
489 .lines()
490 .find(|l| l.starts_with("- spaced-skill:"))
491 .expect("skill line");
492 assert!(line.contains("alpha beta gamma"), "got: {line:?}");
493 }
494
495 /// Three-tier fitting: when full descriptions overflow the budget the index
496 /// shortens them, then drops to names-only — a skill's name never vanishes
497 /// while the names themselves fit.
498 #[test]
499 fn render_available_skills_context_keeps_every_name_when_descriptions_overflow() {
500 let tmpdir = TempDir::new().unwrap();
501 let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
502 for i in 0..200 {
503 let body = format!("---\nname: skill-{i:03}\ndescription: {big_desc}\n---\nbody");
504 create_skill_dir(&tmpdir, &format!("skill-{i:03}"), &body);
505 }
506
507 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
508 .expect("skill context");
509
510 // 200 × ~380 chars of description is ~76k, far over the default budget:
511 // tier 1 cannot fit, so descriptions shrink or names stand alone.
512 for i in 0..200 {
513 let name = format!("- skill-{i:03}");
514 assert!(rendered.contains(&name), "missing {name}:\n{rendered}");
515 }
516 assert!(
517 !rendered.contains("additional skills omitted"),
518 "names fit the budget; omission is the last resort, not the first"
519 );
520 assert!(
521 !rendered.contains(&big_desc),
522 "a full-length description must not survive an overflowing index"
523 );
524 assert!(
525 rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS,
526 "rendered length must stay within the complete block budget"
527 );
528 }
529
530 /// `Use when:` triggers survive shortening ahead of the summary — they are
531 /// what the model routes on.
532 #[test]
533 fn render_skills_block_shortens_summary_before_trigger() {
534 let tmpdir = TempDir::new().unwrap();
535 let mut registry = super::SkillRegistry::default();
536 let summary = "s".repeat(300);
537 for i in 0..120 {
538 registry.skills.push(super::Skill {
539 legacy_activation_name: None,
540 name: format!("skill-{i:03}"),
541 description: format!("{summary} Use when: the user asks for widget {i}."),
542 localized_descriptions: std::collections::HashMap::new(),
543 invocation: super::SkillInvocation::ModelAndUser,
544 aliases: Vec::new(),
545 argument_hint: None,
546 body: "body".to_string(),
547 path: tmpdir.path().join(format!("skill-{i:03}/SKILL.md")),
548 source: super::SkillSource::Native,
549 });
550 }
551 let rendered =
552 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
553 let line = rendered
554 .lines()
555 .find(|l| l.starts_with("- skill-007:"))
556 .expect("row for skill-007");
557 assert!(
558 line.contains("Use when: the user asks for widget 7"),
559 "trigger must survive shortening intact:\n{line}"
560 );
561 assert!(
562 !line.contains(&summary),
563 "summary must be the half that shrinks:\n{line}"
564 );
565 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
566 }
567
568 /// The budget follows the route window: a 1M route sees a much larger index
569 /// than a small local window, both clamped to sane bounds.
570 #[test]
571 fn skills_prompt_budget_scales_with_context_window() {
572 let small = super::skills_prompt_budget_chars(Some(8_000));
573 let default = super::skills_prompt_budget_chars(None);
574 let large = super::skills_prompt_budget_chars(Some(1_000_000));
575 assert_eq!(small, 2_400, "floor holds for tiny windows");
576 assert_eq!(default, 25_600, "128k window × 4 chars × 5%");
577 assert_eq!(large, 40_000, "ceiling holds for 1M windows");
578 assert_eq!(
579 super::skills_prompt_budget_chars(Some(0)),
580 default,
581 "a zero window is treated as unknown"
582 );
583 }
584
585 #[test]
586 fn render_skills_block_holds_budget_with_five_digit_omission_counts() {
587 let tmpdir = TempDir::new().unwrap();
588 let mut registry = super::SkillRegistry::default();
589 for i in 0..15_000 {
590 registry.skills.push(super::Skill {
591 legacy_activation_name: None,
592 name: format!("skill-{i:05}"),
593 description: "x".to_string(),
594 localized_descriptions: std::collections::HashMap::new(),
595 invocation: super::SkillInvocation::ModelAndUser,
596 aliases: Vec::new(),
597 argument_hint: None,
598 body: "body".to_string(),
599 path: tmpdir.path().join(format!("skill-{i:05}/SKILL.md")),
600 source: super::SkillSource::Native,
601 });
602 registry.warnings.push(format!("warning {i:05}"));
603 }
604
605 let rendered =
606 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
607 let omitted_skills = rendered
608 .lines()
609 .find(|line| line.contains("additional skills omitted"))
610 .and_then(|line| line.split_whitespace().nth(2))
611 .and_then(|count| count.parse::<usize>().ok())
612 .expect("skill omission count");
613 let omitted_warnings = rendered
614 .lines()
615 .find(|line| line.contains("additional warnings omitted"))
616 .and_then(|line| line.split_whitespace().nth(2))
617 .and_then(|count| count.parse::<usize>().ok())
618 .expect("warning omission count");
619
620 assert!(omitted_skills > 9_999, "fixture must exercise five digits");
621 assert!(
622 omitted_warnings > 9_999,
623 "fixture must exercise five digits"
624 );
625 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
626 }
627
628 #[test]
629 fn explicit_only_skills_do_not_reduce_ambient_index_capacity() {
630 let tmpdir = TempDir::new().unwrap();
631 let mut registry = super::SkillRegistry::default();
632 for i in 0..6 {
633 registry.skills.push(super::Skill {
634 legacy_activation_name: None,
635 name: format!("visible-{i:03}"),
636 description: "x".repeat(246),
637 localized_descriptions: std::collections::HashMap::new(),
638 invocation: super::SkillInvocation::ModelAndUser,
639 aliases: Vec::new(),
640 argument_hint: None,
641 body: "body".to_string(),
642 path: tmpdir.path().join(format!("visible-{i:03}/SKILL.md")),
643 source: super::SkillSource::Native,
644 });
645 }
646
647 let baseline =
648 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
649 assert!(!baseline.contains("additional skills omitted"));
650
651 let mut with_explicit_only = registry.clone();
652 for i in 0..10_000 {
653 with_explicit_only.skills.push(super::Skill {
654 legacy_activation_name: None,
655 name: format!("explicit-{i:05}"),
656 description: String::new(),
657 localized_descriptions: std::collections::HashMap::new(),
658 invocation: super::SkillInvocation::ExplicitOnly,
659 aliases: Vec::new(),
660 argument_hint: None,
661 body: "body".to_string(),
662 path: tmpdir.path().join(format!("explicit-{i:05}/SKILL.md")),
663 source: super::SkillSource::Native,
664 });
665 }
666
667 let rendered = super::render_skills_block(&with_explicit_only, "en", tmpdir.path())
668 .expect("skill context");
669 assert_eq!(rendered, baseline);
670 }
671
672 #[test]
673 fn render_skills_block_preserves_registry_precedence_under_prompt_budget() {
674 let tmpdir = TempDir::new().unwrap();
675 let mut registry = super::SkillRegistry::default();
676 registry.skills.push(super::Skill {
677 legacy_activation_name: None,
678 name: "workspace-priority".to_string(),
679 description: "must survive truncation".to_string(),
680 localized_descriptions: std::collections::HashMap::new(),
681 invocation: super::SkillInvocation::ModelAndUser,
682 aliases: Vec::new(),
683 argument_hint: None,
684 body: "body".to_string(),
685 path: tmpdir
686 .path()
687 .join(".claude")
688 .join("skills")
689 .join("workspace-priority")
690 .join("SKILL.md"),
691 source: super::SkillSource::Native,
692 });
693
694 let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
695 for i in 0..200 {
696 registry.skills.push(super::Skill {
697 legacy_activation_name: None,
698 name: format!("aaa-global-{i:03}"),
699 description: big_desc.clone(),
700 localized_descriptions: std::collections::HashMap::new(),
701 invocation: super::SkillInvocation::ModelAndUser,
702 aliases: Vec::new(),
703 argument_hint: None,
704 body: "body".to_string(),
705 path: tmpdir
706 .path()
707 .join(".deepseek")
708 .join("skills")
709 .join(format!("aaa-global-{i:03}"))
710 .join("SKILL.md"),
711 source: super::SkillSource::Native,
712 });
713 }
714
715 let rendered =
716 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
717 assert!(
718 rendered.contains("workspace-priority"),
719 "higher-precedence workspace skills must not be reordered behind globals:\n{rendered}"
720 );
721 let first_row = rendered
722 .lines()
723 .find(|line| line.starts_with("- "))
724 .expect("at least one row");
725 assert!(
726 first_row.starts_with("- workspace-priority"),
727 "registry order is render order:\n{rendered}"
728 );
729 }
730
731 // --- Localized skill descriptions (#3354) ------------------------------
732
733 #[test]
734 fn parse_skill_collects_localized_description_frontmatter() {
735 let content = "---\n\
736 name: demo\n\
737 description: A demo skill\n\
738 description_zh: 一个演示技能\n\
739 description_zh-Hant: 一個示範技能\n\
740 ---\n\
741 body";
742 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), content)
743 .expect("parse should succeed");
744 assert_eq!(skill.description, "A demo skill");
745 assert_eq!(
746 skill.localized_descriptions.get("zh").map(String::as_str),
747 Some("一个演示技能")
748 );
749 // Frontmatter keys are lowercased, so zh-Hant is stored as zh-hant.
750 assert_eq!(
751 skill
752 .localized_descriptions
753 .get("zh-hant")
754 .map(String::as_str),
755 Some("一個示範技能")
756 );
757 }
758
759 #[test]
760 fn parse_skill_exposes_invocation_and_alias_metadata() {
761 let content = "---\n\
762 name: spreadsheets\n\
763 description: Spreadsheet workflows\n\
764 invocation: explicit-only\n\
765 aliases-for: xlsx, spreadsheet\n\
766 ---\n\
767 body";
768 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), content)
769 .expect("parse should succeed");
770
771 assert_eq!(skill.invocation, super::SkillInvocation::ExplicitOnly);
772 assert_eq!(
773 skill.aliases,
774 vec!["xlsx".to_string(), "spreadsheet".to_string()]
775 );
776
777 let mut registry = super::SkillRegistry::default();
778 registry.skills.push(skill);
779 assert_eq!(
780 registry.get("spreadsheet").map(|s| s.name.as_str()),
781 Some("spreadsheets")
782 );
783 assert_eq!(
784 registry.get("xlsx").map(|s| s.name.as_str()),
785 Some("spreadsheets")
786 );
787
788 let rendered = super::render_skills_block(&registry, "en", std::path::Path::new("/"));
789 assert!(
790 rendered.is_some(),
791 "an explicit-only skill remains loadable"
792 );
793 assert!(
794 !rendered.unwrap_or_default().contains("spreadsheets"),
795 "explicit-only skills must not enter the model catalogue"
796 );
797 }
798
799 #[test]
800 fn missing_or_unknown_invocation_keeps_model_and_user_compatibility() {
801 for invocation in [None, Some("future-mode")] {
802 let invocation_line =
803 invocation.map_or(String::new(), |value| format!("invocation: {value}\n"));
804 let content =
805 format!("---\nname: compatible\ndescription: compatible\n{invocation_line}---\nbody");
806 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), &content)
807 .expect("parse should succeed");
808 assert_eq!(skill.invocation, super::SkillInvocation::ModelAndUser);
809 }
810 }
811
812 #[test]
813 fn description_for_locale_matches_exact_then_primary_then_falls_back() {
814 let mut localized = std::collections::HashMap::new();
815 localized.insert("zh".to_string(), "中文描述".to_string());
816 localized.insert("ja".to_string(), "日本語の説明".to_string());
817 let skill = super::Skill {
818 legacy_activation_name: None,
819 name: "demo".to_string(),
820 description: "English description".to_string(),
821 localized_descriptions: localized,
822 invocation: super::SkillInvocation::ModelAndUser,
823 aliases: Vec::new(),
824 argument_hint: None,
825 body: String::new(),
826 path: std::path::PathBuf::new(),
827 source: super::SkillSource::Native,
828 };
829
830 assert_eq!(skill.description_for_locale("zh"), "中文描述"); // exact
831 assert_eq!(skill.description_for_locale("ZH"), "中文描述"); // case-insensitive
832 assert_eq!(skill.description_for_locale("zh-CN"), "中文描述"); // Simplified region → zh
833 assert_eq!(skill.description_for_locale("zh-Hans"), "中文描述"); // Simplified script → zh
834 assert_eq!(skill.description_for_locale("ja"), "日本語の説明");
835 assert_eq!(skill.description_for_locale("fr"), "English description"); // fallback
836 assert_eq!(skill.description_for_locale("en"), "English description");
837
838 // Traditional Chinese must NOT borrow the Simplified `zh` description:
839 // with no exact zh-hant key authored, it falls back to the default.
840 assert_eq!(
841 skill.description_for_locale("zh-Hant"),
842 "English description"
843 );
844 assert_eq!(skill.description_for_locale("zh-TW"), "English description");
845 assert_eq!(skill.description_for_locale("zh-HK"), "English description");
846 }
847
848 #[test]
849 fn description_for_locale_uses_exact_traditional_key_when_authored() {
850 let mut localized = std::collections::HashMap::new();
851 localized.insert("zh".to_string(), "简体描述".to_string());
852 localized.insert("zh-hant".to_string(), "繁體描述".to_string());
853 let skill = super::Skill {
854 legacy_activation_name: None,
855 name: "demo".to_string(),
856 description: "English".to_string(),
857 localized_descriptions: localized,
858 invocation: super::SkillInvocation::ModelAndUser,
859 aliases: Vec::new(),
860 argument_hint: None,
861 body: String::new(),
862 path: std::path::PathBuf::new(),
863 source: super::SkillSource::Native,
864 };
865 // Exact Traditional key wins for a Traditional session.
866 assert_eq!(skill.description_for_locale("zh-Hant"), "繁體描述");
867 // Simplified session still gets the Simplified description.
868 assert_eq!(skill.description_for_locale("zh-Hans"), "简体描述");
869 assert_eq!(skill.description_for_locale("zh"), "简体描述");
870 }
871
872 #[test]
873 fn description_for_locale_uses_default_when_no_localized_variants() {
874 let skill = super::Skill {
875 legacy_activation_name: None,
876 name: "demo".to_string(),
877 description: "only english".to_string(),
878 localized_descriptions: std::collections::HashMap::new(),
879 invocation: super::SkillInvocation::ModelAndUser,
880 aliases: Vec::new(),
881 argument_hint: None,
882 body: String::new(),
883 path: std::path::PathBuf::new(),
884 source: super::SkillSource::Native,
885 };
886 assert_eq!(skill.description_for_locale("zh"), "only english");
887 }
888
889 #[test]
890 fn render_skills_block_selects_description_by_locale() {
891 let mut registry = super::SkillRegistry::default();
892 let mut localized = std::collections::HashMap::new();
893 localized.insert("zh".to_string(), "压缩日志的技能".to_string());
894 registry.skills.push(super::Skill {
895 legacy_activation_name: None,
896 name: "compress".to_string(),
897 description: "Compress logs to save space".to_string(),
898 localized_descriptions: localized,
899 invocation: super::SkillInvocation::ModelAndUser,
900 aliases: Vec::new(),
901 argument_hint: None,
902 body: "body".to_string(),
903 path: std::path::PathBuf::from("/skills/compress/SKILL.md"),
904 source: super::SkillSource::Native,
905 });
906
907 let zh = super::render_skills_block(&registry, "zh-Hans", std::path::Path::new("/"))
908 .expect("zh block");
909 assert!(
910 zh.contains("压缩日志的技能"),
911 "zh session should get the zh description:\n{zh}"
912 );
913 assert!(!zh.contains("Compress logs to save space"));
914
915 let en =
916 super::render_skills_block(&registry, "en", std::path::Path::new("/")).expect("en block");
917 assert!(
918 en.contains("Compress logs to save space"),
919 "en session keeps default:\n{en}"
920 );
921 }
922
923 fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
924 let skill_dir = dir.join(name);
925 std::fs::create_dir_all(&skill_dir).unwrap();
926 std::fs::write(
927 skill_dir.join("SKILL.md"),
928 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
929 )
930 .unwrap();
931 }
932
933 #[cfg(unix)]
934 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
935 std::os::unix::fs::symlink(target, link)
936 }
937
938 #[cfg(windows)]
939 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
940 std::os::windows::fs::symlink_dir(target, link)
941 }
942
943 #[test]
944 fn skills_directories_returns_existing_dirs_in_precedence_order() {
945 let tmpdir = TempDir::new().unwrap();
946 let workspace = tmpdir.path();
947 crate::test_support::trust_workspace(workspace);
948
949 // Create four of the five workspace candidate dirs (skip `.opencode`).
950 std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
951 std::fs::create_dir_all(workspace.join("skills")).unwrap();
952 std::fs::create_dir_all(workspace.join(".claude").join("skills")).unwrap();
953 std::fs::create_dir_all(workspace.join(".cursor").join("skills")).unwrap();
954
955 let dirs = super::skills_directories_for_mode(workspace, super::SkillDiscoveryMode::Compatible);
956 // We don't assert on the global default position because it's
957 // host-dependent (may not exist on the test machine).
958 let mut idx = 0;
959 let agents = workspace.join(".agents").join("skills");
960 let local = workspace.join("skills");
961 let claude = workspace.join(".claude").join("skills");
962 let cursor = workspace.join(".cursor").join("skills");
963
964 assert_eq!(dirs.get(idx), Some(&agents), "agents must come first");
965 idx += 1;
966 assert!(
967 !dirs.contains(&local),
968 "flat product content requires opt-in"
969 );
970 // .opencode/skills was not created — it must NOT appear.
971 assert!(
972 !dirs
973 .iter()
974 .any(|p| p == &workspace.join(".opencode").join("skills")),
975 "missing dir must be omitted, got: {dirs:?}"
976 );
977 assert_eq!(
978 dirs.get(idx),
979 Some(&claude),
980 "claude must come after agents"
981 );
982 idx += 1;
983 assert_eq!(
984 dirs.get(idx),
985 Some(&cursor),
986 "cursor must come after claude"
987 );
988 }
989
990 #[test]
991 fn existing_skill_dirs_orders_globals_agents_then_claude_then_deepseek() {
992 // Pins the precedence among the three global skill roots (#902).
993 // Workspace candidates are tested separately above; here we only
994 // exercise the global ordering at the existing_skill_dirs level
995 // so the assertion is host-independent.
996 let tmpdir = TempDir::new().unwrap();
997 let agents_global = tmpdir.path().join(".agents").join("skills");
998 let claude_global = tmpdir.path().join(".claude").join("skills");
999 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
1000 std::fs::create_dir_all(&agents_global).unwrap();
1001 std::fs::create_dir_all(&claude_global).unwrap();
1002 std::fs::create_dir_all(&deepseek_global).unwrap();
1003
1004 let dirs = super::existing_skill_dirs(vec![
1005 agents_global.clone(),
1006 claude_global.clone(),
1007 deepseek_global.clone(),
1008 ]);
1009
1010 assert_eq!(dirs, vec![agents_global, claude_global, deepseek_global]);
1011 }
1012
1013 #[test]
1014 fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
1015 let tmpdir = TempDir::new().unwrap();
1016 let agents_global = tmpdir.path().join(".agents").join("skills");
1017 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
1018 let missing = tmpdir.path().join("missing").join("skills");
1019 std::fs::create_dir_all(&agents_global).unwrap();
1020 std::fs::create_dir_all(&deepseek_global).unwrap();
1021
1022 let dirs = super::existing_skill_dirs(vec![
1023 missing,
1024 agents_global.clone(),
1025 deepseek_global.clone(),
1026 agents_global.clone(),
1027 ]);
1028
1029 assert_eq!(dirs, vec![agents_global, deepseek_global]);
1030 }
1031
1032 #[test]
1033 fn discover_in_workspace_merges_with_first_wins_precedence() {
1034 let tmpdir = TempDir::new().unwrap();
1035 let workspace = tmpdir.path();
1036 crate::test_support::trust_workspace(workspace);
1037
1038 // Same skill name `shared` in two locations — the higher-precedence
1039 // dir's version should win.
1040 write_skill(
1041 &workspace.join(".agents").join("skills"),
1042 "shared",
1043 "agents wins",
1044 "from agents",
1045 );
1046 write_skill(
1047 &workspace.join(".claude").join("skills"),
1048 "shared",
1049 "claude loses",
1050 "from claude",
1051 );
1052 // Unique skill in claude — should still be discovered.
1053 write_skill(
1054 &workspace.join(".claude").join("skills"),
1055 "unique-claude",
1056 "only here",
1057 "claude-only",
1058 );
1059
1060 let registry = super::discover_in_workspace(workspace);
1061 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1062 assert!(
1063 names.contains(&"shared"),
1064 "shared must be present: {names:?}"
1065 );
1066 assert!(names.contains(&"unique-claude"));
1067
1068 let shared = registry.get("shared").expect("shared present");
1069 assert_eq!(
1070 shared.description, "agents wins",
1071 "first-wins precedence should keep .agents/skills version"
1072 );
1073 assert!(
1074 shared.path.starts_with(workspace.join(".agents")),
1075 "shared.path should be from .agents/skills, got {:?}",
1076 shared.path
1077 );
1078 assert!(
1079 registry
1080 .warnings()
1081 .iter()
1082 .any(|warning| warning.contains("shared") && warning.contains("shadowed by")),
1083 "duplicate shadowing should warn, got {:?}",
1084 registry.warnings()
1085 );
1086 }
1087
1088 #[test]
1089 fn same_root_slug_collision_warns_and_keeps_one() {
1090 let tmpdir = TempDir::new().unwrap();
1091 let root = tmpdir.path();
1092 // Two sibling directories under one root whose frontmatter names
1093 // slugify to the same command name ("my-skill"). Only one can be
1094 // reachable by name; the other must warn rather than silently coexist
1095 // as an unreachable duplicate (#3919 same-root gap).
1096 write_skill(root, "My Skill", "first", "body");
1097 write_skill(root, "my_skill", "second", "body");
1098
1099 let registry = super::SkillRegistry::discover(root);
1100 let claimants = registry
1101 .list()
1102 .iter()
1103 .filter(|s| s.name == "my-skill")
1104 .count();
1105 assert_eq!(
1106 claimants,
1107 1,
1108 "exactly one skill should claim `my-skill`, got {:?}",
1109 registry.list().iter().map(|s| &s.name).collect::<Vec<_>>()
1110 );
1111 assert!(
1112 registry
1113 .warnings()
1114 .iter()
1115 .any(|w| w.contains("my-skill") && w.contains("shadowed by")),
1116 "same-root slug collision should warn, got {:?}",
1117 registry.warnings()
1118 );
1119 }
1120
1121 #[test]
1122 fn discover_in_workspace_pulls_skills_from_opencode_dir() {
1123 let tmpdir = TempDir::new().unwrap();
1124 let workspace = tmpdir.path();
1125 crate::test_support::trust_workspace(workspace);
1126 write_skill(
1127 &workspace.join(".opencode").join("skills"),
1128 "opencode-only",
1129 "for interop",
1130 "body",
1131 );
1132
1133 let registry = super::discover_in_workspace(workspace);
1134 assert!(
1135 registry.get("opencode-only").is_some(),
1136 ".opencode/skills must be scanned (#432)"
1137 );
1138 }
1139
1140 #[test]
1141 fn discover_in_workspace_pulls_skills_from_cursor_dir() {
1142 let tmpdir = TempDir::new().unwrap();
1143 let workspace = tmpdir.path();
1144 crate::test_support::trust_workspace(workspace);
1145 write_skill(
1146 &workspace.join(".cursor").join("skills"),
1147 "cursor-only",
1148 "for cursor interop",
1149 "body",
1150 );
1151
1152 let registry = super::discover_in_workspace(workspace);
1153 assert!(
1154 registry.get("cursor-only").is_some(),
1155 ".cursor/skills must be scanned"
1156 );
1157 }
1158
1159 #[test]
1160 fn discover_accepts_plain_markdown_heading_without_frontmatter() {
1161 let tmpdir = TempDir::new().unwrap();
1162 let skill_dir = tmpdir.path().join("plain-skill");
1163 std::fs::create_dir_all(&skill_dir).unwrap();
1164 std::fs::write(
1165 skill_dir.join("SKILL.md"),
1166 "# Plain Skill\n\nUse this skill without YAML frontmatter.\n",
1167 )
1168 .unwrap();
1169
1170 let registry = super::SkillRegistry::discover(tmpdir.path());
1171 let skill = registry.get("plain-skill").expect("plain skill parsed");
1172 assert_eq!(skill.name, "plain-skill");
1173 assert_eq!(skill.description, "");
1174 assert!(skill.body.contains("Use this skill"));
1175 assert!(
1176 registry
1177 .warnings()
1178 .iter()
1179 .any(|warning| warning.contains("using `plain-skill` instead")),
1180 "expected slug warning, got {:?}",
1181 registry.warnings()
1182 );
1183 }
1184
1185 #[test]
1186 fn discover_slugifies_invalid_frontmatter_names_and_lookup_normalizes() {
1187 let tmpdir = TempDir::new().unwrap();
1188 let root = tmpdir.path().join("skills");
1189 let skill_dir = root.join("my-skill");
1190 std::fs::create_dir_all(&skill_dir).unwrap();
1191 std::fs::write(
1192 skill_dir.join("SKILL.md"),
1193 "---\nname: My Skill\ndescription: spaced name\n---\nbody",
1194 )
1195 .unwrap();
1196
1197 let registry = super::SkillRegistry::discover(&root);
1198 let skill = registry.get(" MY skill ").expect("normalized lookup");
1199 assert_eq!(skill.name, "my-skill");
1200 assert!(
1201 registry
1202 .warnings()
1203 .iter()
1204 .any(|warning| warning.contains("My Skill")
1205 && warning.contains("using `my-skill` instead")),
1206 "expected invalid-name warning, got {:?}",
1207 registry.warnings()
1208 );
1209 }
1210
1211 #[test]
1212 fn discover_warns_for_plain_markdown_without_heading() {
1213 let tmpdir = TempDir::new().unwrap();
1214 let skill_dir = tmpdir.path().join("plain-skill");
1215 std::fs::create_dir_all(&skill_dir).unwrap();
1216 std::fs::write(
1217 skill_dir.join("SKILL.md"),
1218 "Use this skill without a heading or YAML frontmatter.\n",
1219 )
1220 .unwrap();
1221
1222 let registry = super::SkillRegistry::discover(tmpdir.path());
1223 assert!(registry.is_empty());
1224 assert!(
1225 registry
1226 .warnings()
1227 .iter()
1228 .any(|warning| warning.contains("no `# Heading` found")),
1229 "expected missing-heading warning, got {:?}",
1230 registry.warnings()
1231 );
1232 }
1233
1234 #[test]
1235 fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
1236 let tmpdir = TempDir::new().unwrap();
1237 let workspace = tmpdir.path();
1238 crate::test_support::trust_workspace(workspace);
1239 write_skill(
1240 &workspace.join(".claude").join("skills"),
1241 "from-claude",
1242 "claude-style skill",
1243 "body",
1244 );
1245 let rendered =
1246 super::render_available_skills_context_for_workspace(workspace).expect("non-empty");
1247 assert!(rendered.contains("from-claude"));
1248 }
1249
1250 #[test]
1251 fn codewhale_only_mode_ignores_cross_tool_skill_dirs() {
1252 let tmpdir = TempDir::new().unwrap();
1253 let workspace = tmpdir.path().join("workspace");
1254 let home = tmpdir.path().join("home");
1255 let configured_dir = home.join(".codewhale").join("skills");
1256 std::fs::create_dir_all(&workspace).unwrap();
1257 crate::test_support::trust_workspace(&workspace);
1258 write_skill(
1259 &workspace.join(".claude").join("skills"),
1260 "from-claude",
1261 "claude-style skill",
1262 "body",
1263 );
1264 write_skill(
1265 &workspace.join(".codewhale").join("skills"),
1266 "from-codewhale",
1267 "codewhale skill",
1268 "body",
1269 );
1270 write_skill(
1271 &home.join(".agents").join("skills"),
1272 "from-agents",
1273 "agents skill",
1274 "body",
1275 );
1276 write_skill(
1277 &configured_dir,
1278 "configured-codewhale",
1279 "configured skill",
1280 "body",
1281 );
1282
1283 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
1284 &workspace,
1285 &configured_dir,
1286 Some(&home),
1287 super::SkillDiscoveryMode::CodeWhaleOnly,
1288 );
1289 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1290
1291 assert!(names.contains(&"from-codewhale"));
1292 assert!(names.contains(&"configured-codewhale"));
1293 assert!(
1294 !names.contains(&"from-claude") && !names.contains(&"from-agents"),
1295 "CodeWhale-only mode must not import cross-tool skills: {names:?}"
1296 );
1297 }
1298
1299 #[test]
1300 fn codewhale_only_mode_still_honors_explicit_configured_dir() {
1301 let tmpdir = TempDir::new().unwrap();
1302 let workspace = tmpdir.path().join("workspace");
1303 let home = tmpdir.path().join("home");
1304 let configured_dir = tmpdir.path().join("my-skills");
1305 std::fs::create_dir_all(&workspace).unwrap();
1306 write_skill(
1307 &configured_dir,
1308 "configured-skill",
1309 "explicit configured skill",
1310 "body",
1311 );
1312
1313 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
1314 &workspace,
1315 &configured_dir,
1316 Some(&home),
1317 super::SkillDiscoveryMode::CodeWhaleOnly,
1318 );
1319 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1320
1321 assert_eq!(names, vec!["configured-skill"]);
1322 }
1323
1324 #[test]
1325 fn codewhale_only_mode_rejects_workspace_codewhale_symlink_escape() {
1326 let tmpdir = TempDir::new().unwrap();
1327 let workspace = tmpdir.path().join("workspace");
1328 let home = tmpdir.path().join("home");
1329 let escape_target = tmpdir.path().join("escape-target");
1330 std::fs::create_dir_all(workspace.join(".codewhale")).unwrap();
1331 write_skill(&escape_target, "escaped-skill", "escaped skill", "body");
1332
1333 let link_path = workspace.join(".codewhale").join("skills");
1334 if let Err(err) = create_dir_symlink(&escape_target, &link_path) {
1335 eprintln!("skipping symlink escape assertion: {err}");
1336 return;
1337 }
1338
1339 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
1340 &workspace,
1341 &tmpdir.path().join("missing-configured-skills"),
1342 Some(&home),
1343 super::SkillDiscoveryMode::CodeWhaleOnly,
1344 );
1345
1346 assert!(
1347 registry.get("escaped-skill").is_none(),
1348 "CodeWhale-only mode must not follow workspace .codewhale/skills outside the workspace"
1349 );
1350 }
1351
1352 #[test]
1353 fn discover_for_workspace_and_dir_merges_workspace_and_configured_sources() {
1354 let tmpdir = TempDir::new().unwrap();
1355 let workspace = tmpdir.path().join("workspace");
1356 let home = tmpdir.path().join("home");
1357 let configured_dir = tmpdir.path().join("configured-skills");
1358 std::fs::create_dir_all(&workspace).unwrap();
1359 crate::test_support::trust_workspace(&workspace);
1360 write_skill(
1361 &workspace.join(".claude").join("skills"),
1362 "workspace-skill",
1363 "workspace visible skill",
1364 "body",
1365 );
1366 write_skill(
1367 &configured_dir,
1368 "configured-skill",
1369 "configured visible skill",
1370 "body",
1371 );
1372
1373 let registry =
1374 super::discover_for_workspace_and_dir_with_home(&workspace, &configured_dir, Some(&home));
1375 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1376
1377 assert!(names.contains(&"workspace-skill"));
1378 assert!(names.contains(&"configured-skill"));
1379 }
1380
1381 #[test]
1382 fn explicit_configured_skills_dir_precedes_global_defaults() {
1383 let tmpdir = TempDir::new().unwrap();
1384 let workspace = tmpdir.path().join("workspace");
1385 let home = tmpdir.path().join("home");
1386 let configured_dir = tmpdir.path().join("configured-skills");
1387 std::fs::create_dir_all(&workspace).unwrap();
1388 write_skill(
1389 &home.join(".agents").join("skills"),
1390 "shared-skill",
1391 "global skill",
1392 "global body",
1393 );
1394 write_skill(
1395 &configured_dir,
1396 "shared-skill",
1397 "configured skill",
1398 "configured body",
1399 );
1400
1401 let registry =
1402 super::discover_for_workspace_and_dir_with_home(&workspace, &configured_dir, Some(&home));
1403 let skill = registry
1404 .get("shared-skill")
1405 .expect("shared skill discovered");
1406
1407 assert_eq!(skill.description, "configured skill");
1408 }
1409
1410 /// Regression for the GitHub issue where users organize skills under
1411 /// vendor / category subdirectories (e.g. cloned skill repos that
1412 /// bundle several skills together). The old single-level `read_dir`
1413 /// only ever surfaced `<root>/<skill>/SKILL.md` and silently ignored
1414 /// `<root>/<vendor>/<skill>/SKILL.md`.
1415 #[test]
1416 fn discover_finds_skills_nested_under_vendor_subdirectory() {
1417 let tmpdir = TempDir::new().unwrap();
1418 let root = tmpdir.path().join("skills");
1419
1420 // Two-level nesting: `<root>/<vendor>/<skill>/SKILL.md`. This
1421 // matches the `clawhub-skills/clawhub/SKILL.md` layout in the
1422 // bug report.
1423 write_skill(
1424 &root.join("clawhub-skills"),
1425 "clawhub",
1426 "claw search",
1427 "body",
1428 );
1429 write_skill(
1430 &root.join("clawhub-skills"),
1431 "github",
1432 "github helpers",
1433 "body",
1434 );
1435 // Three-level nesting: `<root>/<org>/<repo>/<skill>/SKILL.md`.
1436 write_skill(
1437 &root.join("pasky").join("chrome-cdp-skill"),
1438 "chrome-cdp",
1439 "browser automation",
1440 "body",
1441 );
1442 // Mixed-depth: a flat skill alongside the nested layout still
1443 // works (this is what the bundled `skill-creator` looks like).
1444 write_skill(&root, "skill-creator", "make skills", "body");
1445
1446 let registry = super::SkillRegistry::discover(&root);
1447 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1448 assert!(names.contains(&"clawhub"), "vendor/skill missed: {names:?}");
1449 assert!(names.contains(&"github"), "vendor/skill missed: {names:?}");
1450 assert!(
1451 names.contains(&"chrome-cdp"),
1452 "deeply-nested skill missed: {names:?}"
1453 );
1454 assert!(
1455 names.contains(&"skill-creator"),
1456 "flat top-level skill must still load: {names:?}"
1457 );
1458 assert!(
1459 registry.warnings().is_empty(),
1460 "well-formed nested layout should not warn: {:?}",
1461 registry.warnings()
1462 );
1463 }
1464
1465 #[cfg(any(unix, windows))]
1466 #[test]
1467 fn discover_follows_symlinked_skill_directories() {
1468 let tmpdir = TempDir::new().unwrap();
1469 let source_root = tmpdir.path().join("claude-skills");
1470 let skills_root = tmpdir.path().join(".deepseek").join("skills");
1471 write_skill(&source_root, "agent-browser", "browser automation", "body");
1472 std::fs::create_dir_all(&skills_root).unwrap();
1473 let link_path = skills_root.join("agent-browser");
1474
1475 if let Err(err) = create_dir_symlink(&source_root.join("agent-browser"), &link_path) {
1476 eprintln!("skipping symlink discovery assertion: {err}");
1477 return;
1478 }
1479
1480 let registry = super::SkillRegistry::discover(&skills_root);
1481 let skill = registry
1482 .get("agent-browser")
1483 .expect("symlinked skill directory should be discovered");
1484 assert_eq!(skill.description, "browser automation");
1485 assert_eq!(skill.path, link_path.join("SKILL.md"));
1486 }
1487
1488 #[cfg(any(unix, windows))]
1489 #[test]
1490 fn discover_dedupes_symlink_cycles_by_canonical_directory() {
1491 let tmpdir = TempDir::new().unwrap();
1492 let root = tmpdir.path().join("skills");
1493 write_skill(&root, "real-skill", "ok", "body");
1494 let loop_parent = root.join("vendor");
1495 std::fs::create_dir_all(&loop_parent).unwrap();
1496
1497 if let Err(err) = create_dir_symlink(&root, &loop_parent.join("loop")) {
1498 eprintln!("skipping symlink cycle assertion: {err}");
1499 return;
1500 }
1501
1502 let registry = super::SkillRegistry::discover(&root);
1503 let matches = registry
1504 .list()
1505 .iter()
1506 .filter(|skill| skill.name == "real-skill")
1507 .count();
1508 assert_eq!(
1509 matches, 1,
1510 "symlink cycle should not rediscover the same canonical skill directory"
1511 );
1512 }
1513
1514 /// Once a directory is identified as a skill (has `SKILL.md`), the
1515 /// walker must NOT descend into it: any nested `SKILL.md` would be
1516 /// a fixture / example bundled with the parent skill, not a
1517 /// separately-installable one. This mirrors the contract that
1518 /// `tools::skill::collect_companion_files` already documents
1519 /// ("nested directory — skipped").
1520 #[test]
1521 fn discover_does_not_descend_into_a_skill_directory() {
1522 let tmpdir = TempDir::new().unwrap();
1523 let root = tmpdir.path().join("skills");
1524
1525 // Parent skill: <root>/parent/SKILL.md.
1526 write_skill(&root, "parent", "outer skill", "outer body");
1527 // Fixture bundled inside the parent's directory:
1528 // <root>/parent/examples/inner-fixture/SKILL.md. The walker
1529 // must NOT descend into <root>/parent/ after finding its
1530 // SKILL.md, so `inner-fixture` must not be loaded.
1531 write_skill(
1532 &root.join("parent").join("examples"),
1533 "inner-fixture",
1534 "should not load",
1535 "fixture body",
1536 );
1537
1538 let registry = super::SkillRegistry::discover(&root);
1539 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1540 assert!(names.contains(&"parent"));
1541 assert!(
1542 !names.contains(&"inner-fixture"),
1543 "nested SKILL.md inside an existing skill must be ignored: {names:?}"
1544 );
1545 }
1546
1547 /// Hidden subdirectories below the root (e.g. `.git`, `.cache`) must
1548 /// be skipped so a `skills_dir` that lives inside a checked-out repo
1549 /// doesn't accidentally load random `SKILL.md`-named fixtures from
1550 /// the VCS metadata. The root itself is exempt — the user explicitly
1551 /// pointed `skills_dir` at it.
1552 #[test]
1553 fn discover_skips_hidden_subdirectories_below_root() {
1554 let tmpdir = TempDir::new().unwrap();
1555 let root = tmpdir.path().join("skills");
1556
1557 write_skill(&root, "real-skill", "ok", "body");
1558 // A `<root>/.git/<junk>/SKILL.md` lookalike that mustn't load.
1559 // `.git` is a direct child of the user-provided root (depth 0
1560 // of the walk), which is exactly the case the old `depth > 0`
1561 // gate missed.
1562 write_skill(&root.join(".git"), "vcs-noise", "should not load", "body");
1563
1564 let registry = super::SkillRegistry::discover(&root);
1565 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1566 assert!(names.contains(&"real-skill"));
1567 assert!(
1568 !names.contains(&"vcs-noise"),
1569 "skills under hidden subdirs must be skipped: {names:?}"
1570 );
1571 }
1572
1573 /// The user explicitly chooses the root, so even a hidden path like
1574 /// `~/.agents/skills` (the layout in the bug report) must work.
1575 #[test]
1576 fn discover_honors_a_hidden_root_directory() {
1577 let tmpdir = TempDir::new().unwrap();
1578 let root = tmpdir.path().join(".agents").join("skills");
1579
1580 // Matches the bug report: skills_dir = "~/.agents/skills"
1581 // with a skill nested at <root>/custom-skills/git-conventions/SKILL.md.
1582 write_skill(
1583 &root.join("custom-skills"),
1584 "git-conventions",
1585 "conventions",
1586 "body",
1587 );
1588
1589 let registry = super::SkillRegistry::discover(&root);
1590 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1591 assert!(
1592 names.contains(&"git-conventions"),
1593 "hidden root must still be walked: {names:?}"
1594 );
1595 }
1596
1597 /// Exercises the local/global skill inventory independent of terminal layout.
1598 /// scenario without the PTY harness: a workspace-level skill in
1599 /// `.agents/skills/` and a global skill in `~/.codewhale/skills/`
1600 /// must both be discoverable.
1601 #[test]
1602 fn discover_finds_both_workspace_and_global_skills() {
1603 let tmpdir = TempDir::new().unwrap();
1604 let workspace = tmpdir.path().join("workspace");
1605 let home = tmpdir.path().join("home");
1606 std::fs::create_dir_all(&workspace).unwrap();
1607
1608 write_skill(
1609 &workspace.join(".agents").join("skills"),
1610 "workspace-beta",
1611 "Workspace beta skill",
1612 "body",
1613 );
1614 write_skill(
1615 &home.join(".codewhale").join("skills"),
1616 "global-alpha",
1617 "Global alpha skill",
1618 "body",
1619 );
1620
1621 crate::test_support::trust_workspace(&workspace);
1622 let skills_dir = workspace.join(".agents").join("skills");
1623 let registry =
1624 super::discover_for_workspace_and_dir_with_home(&workspace, &skills_dir, Some(&home));
1625
1626 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1627 assert!(
1628 names.contains(&"workspace-beta"),
1629 "workspace-beta from .agents/skills must be discovered: {names:?}",
1630 );
1631 assert!(
1632 names.contains(&"global-alpha"),
1633 "global-alpha from ~/.codewhale/skills must be discovered: {names:?}",
1634 );
1635 }
1636
1637 // ── Block scalar parsing (YAML `>` and `|`) ────────────────
1638
1639 /// `>` (folded block scalar): subsequent indented lines are folded
1640 /// into a single line joined by spaces.
1641 #[test]
1642 fn parse_skill_folded_block_scalar() {
1643 let tmpdir = TempDir::new().unwrap();
1644 create_skill_dir(
1645 &tmpdir,
1646 "folded-skill",
1647 "---\nname: folded-skill\ndescription: >\n line one chinese\n line two chinese\n---\nbody",
1648 );
1649 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1650 .expect("skill context");
1651 assert!(
1652 rendered.contains("line one chinese line two chinese"),
1653 "folded block scalar should join lines with space, got:\n{rendered}"
1654 );
1655 }
1656
1657 /// `|` (literal block scalar): subsequent indented lines preserve
1658 /// newlines.
1659 #[test]
1660 fn parse_skill_literal_block_scalar() {
1661 let tmpdir = TempDir::new().unwrap();
1662 create_skill_dir(
1663 &tmpdir,
1664 "literal-skill",
1665 "---\nname: literal-skill\ndescription: |\n line one\n line two\n---\nbody",
1666 );
1667 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1668 .expect("skill context");
1669 // `truncate_for_prompt` collapses whitespace, so the newlines
1670 // become spaces. The key assertion is that the content is
1671 // captured (not just `|`).
1672 assert!(
1673 rendered.contains("line one line two"),
1674 "literal block scalar should preserve content, got:\n{rendered}"
1675 );
1676 }
1677
1678 /// `>-` (folded with strip chomping): same as `>` but trailing
1679 /// whitespace is stripped.
1680 #[test]
1681 fn parse_skill_folded_strip_block_scalar() {
1682 let tmpdir = TempDir::new().unwrap();
1683 create_skill_dir(
1684 &tmpdir,
1685 "strip-skill",
1686 "---\nname: strip-skill\ndescription: >-\n alpha\n beta\n\n---\nbody",
1687 );
1688 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1689 .expect("skill context");
1690 assert!(
1691 rendered.contains("alpha beta"),
1692 "strip-chomped folded block should join lines, got:\n{rendered}"
1693 );
1694 }
1695
1696 /// Regression: a single-line description (no block scalar) must
1697 /// still parse correctly after the parser rewrite.
1698 #[test]
1699 fn parse_skill_single_line_description_still_works() {
1700 let tmpdir = TempDir::new().unwrap();
1701 create_skill_dir(
1702 &tmpdir,
1703 "plain-skill",
1704 "---\nname: plain-skill\ndescription: A simple description\n---\nbody",
1705 );
1706 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1707 .expect("skill context");
1708 assert!(
1709 rendered.contains("- plain-skill: A simple description"),
1710 "single-line description should still work, got:\n{rendered}"
1711 );
1712 }
1713
1714 /// Direct unit test on the parsed Skill struct (not through rendering)
1715 /// so we assert the exact description value.
1716 #[test]
1717 fn parse_skill_direct_folded_result() {
1718 let skill = super::SkillRegistry::parse_skill(
1719 std::path::Path::new(""),
1720 "---\nname: test\ndescription: >\n this is a test\n used to verify parsing\n---\nbody",
1721 )
1722 .expect("should parse");
1723 assert_eq!(skill.name, "test");
1724 assert_eq!(skill.description, "this is a test used to verify parsing");
1725 }
1726
1727 // ── Chomping behaviour ────────────────────────────────────
1728
1729 /// `>-` (strip): trailing empty lines are stripped. Paragraph
1730 /// breaks (empty line between text lines) are still folded to a
1731 /// single space in a block-scalar join (no newline — the simplified
1732 /// parser treats intra-block empty lines as paragraph breaks that
1733 /// become a single space in the folded output).
1734 #[test]
1735 fn parse_skill_strip_chomp_strips_trailing_empties() {
1736 let skill = super::SkillRegistry::parse_skill(
1737 std::path::Path::new(""),
1738 "---\nname: s\ndescription: >-\n hello\n world\n\n\n---\nbody",
1739 )
1740 .expect("should parse");
1741 // Trailing empty lines stripped: no whitespace at end, just folded text.
1742 assert_eq!(skill.description, "hello world");
1743 }
1744
1745 /// `>+` (keep): trailing empty lines are preserved. Each trailing
1746 /// empty line in the block becomes a newline in the description.
1747 #[test]
1748 fn parse_skill_keep_chomp_preserves_trailing_empties() {
1749 let skill = super::SkillRegistry::parse_skill(
1750 std::path::Path::new(""),
1751 "---\nname: s\ndescription: >+\n hello\n world\n\n\n---\nbody",
1752 )
1753 .expect("should parse");
1754 // Two trailing empty lines should become two newlines.
1755 assert_eq!(skill.description, "hello world\n\n");
1756 }
1757
1758 /// `>` (clip): trailing empty lines exceeding one are clipped.
1759 /// The result should have at most one trailing newline.
1760 #[test]
1761 fn parse_skill_clip_chomp_clips_excess_trailing_empties() {
1762 let skill = super::SkillRegistry::parse_skill(
1763 std::path::Path::new(""),
1764 "---\nname: s\ndescription: >\n hello\n world\n\n\n---\nbody",
1765 )
1766 .expect("should parse");
1767 // clip: 3 trailing empty lines → at most 1 trailing newline.
1768 assert_eq!(skill.description, "hello world\n");
1769 }
1770
1771 /// `>` with no trailing empty lines: clip should not add anything.
1772 #[test]
1773 fn parse_skill_clip_chomp_no_trailing_empties() {
1774 let skill = super::SkillRegistry::parse_skill(
1775 std::path::Path::new(""),
1776 "---\nname: s\ndescription: >\n hello\n world\n---\nbody",
1777 )
1778 .expect("should parse");
1779 assert_eq!(skill.description, "hello world");
1780 }
1781
1782 /// `>` with exactly one trailing empty line: clip keeps it.
1783 #[test]
1784 fn parse_skill_clip_chomp_one_trailing_empty() {
1785 let skill = super::SkillRegistry::parse_skill(
1786 std::path::Path::new(""),
1787 "---\nname: s\ndescription: >\n hello\n world\n\n---\nbody",
1788 )
1789 .expect("should parse");
1790 assert_eq!(skill.description, "hello world\n");
1791 }
1792
1793 /// `>-` strip vs `>+` keep: same block content, different
1794 /// trailing newline handling.
1795 #[test]
1796 fn parse_skill_strip_vs_keep_trailing() {
1797 let content = "---\nname: s\ndescription: >{}\n hello\n world\n\n\n---\nbody";
1798 let strip_skill =
1799 super::SkillRegistry::parse_skill(std::path::Path::new(""), &content.replace("{}", "-"))
1800 .expect("strip parse");
1801 let keep_skill =
1802 super::SkillRegistry::parse_skill(std::path::Path::new(""), &content.replace("{}", "+"))
1803 .expect("keep parse");
1804 // strip drops trailing empties; keep preserves them.
1805 assert_eq!(strip_skill.description, "hello world");
1806 assert_eq!(keep_skill.description, "hello world\n\n");
1807 }
1808
1809 /// `|-` literal strip: trailing newlines are stripped.
1810 #[test]
1811 fn parse_skill_literal_strip_strips_trailing_newlines() {
1812 let skill = super::SkillRegistry::parse_skill(
1813 std::path::Path::new(""),
1814 "---\nname: s\ndescription: |-\n line one\n line two\n\n\n---\nbody",
1815 )
1816 .expect("should parse");
1817 // literal: newlines preserved between non-empty lines.
1818 // strip: trailing empty lines removed.
1819 assert_eq!(skill.description, "line one\nline two");
1820 }
1821
1822 /// `|+` literal keep: trailing newlines are preserved.
1823 #[test]
1824 fn parse_skill_literal_keep_preserves_trailing_newlines() {
1825 let skill = super::SkillRegistry::parse_skill(
1826 std::path::Path::new(""),
1827 "---\nname: s\ndescription: |+\n line one\n line two\n\n\n---\nbody",
1828 )
1829 .expect("should parse");
1830 // literal: newlines preserved between non-empty lines.
1831 // keep: trailing empty lines are preserved as newlines.
1832 assert_eq!(skill.description, "line one\nline two\n\n");
1833 }
1834
1835 /// Nested relative indentation is preserved in literal (`|`) block
1836 /// scalars: only the content-level indent (from the first non-empty
1837 /// line) is stripped, and any deeper indent stays as-is.
1838 #[test]
1839 fn parse_skill_literal_preserves_relative_indentation() {
1840 let skill = super::SkillRegistry::parse_skill(
1841 std::path::Path::new(""),
1842 "---\nname: s\ndescription: |\n Usage:\n $ deepseek --model auto\n $ deepseek doctor\n---\nbody",
1843 )
1844 .expect("should parse");
1845 assert_eq!(
1846 skill.description,
1847 "Usage:\n $ deepseek --model auto\n $ deepseek doctor"
1848 );
1849 }
1850
1851 /// Folded (`>`) block scalars also preserve relative indentation
1852 /// within lines (the extra spaces survive the fold).
1853 #[test]
1854 fn parse_skill_folded_preserves_relative_indentation() {
1855 let skill = super::SkillRegistry::parse_skill(
1856 std::path::Path::new(""),
1857 "---\nname: s\ndescription: >\n See also:\n the config file\n the env var\n---\nbody",
1858 )
1859 .expect("should parse");
1860 assert_eq!(
1861 skill.description,
1862 "See also: the config file the env var"
1863 );
1864 }
1865
1866 #[test]
1867 fn plugin_skills_are_qualified_and_denied_until_trusted_and_enabled() {
1868 let tmp = TempDir::new().unwrap();
1869 let plugin_root = tmp.path().join("plugins/demo");
1870 std::fs::create_dir_all(plugin_root.join("skills/hello-world")).unwrap();
1871 std::fs::write(
1872 plugin_root.join("plugin.toml"),
1873 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n",
1874 )
1875 .unwrap();
1876 std::fs::write(
1877 plugin_root.join("skills/hello-world/SKILL.md"),
1878 "---\nname: hello-world\ndescription: hello\n---\nbody\n",
1879 )
1880 .unwrap();
1881 let config = crate::plugins::discovery::DiscoveryConfig {
1882 workspace: tmp.path().join("workspace"),
1883 user_plugins_dir: tmp.path().join("plugins"),
1884 workspace_plugins_dir: tmp.path().join("workspace-plugins"),
1885 builtin_plugin_dirs: Vec::new(),
1886 state_path: tmp.path().join("plugin-state/state.json"),
1887 };
1888 let mut plugins = crate::plugins::discovery::discover_with_config(&config);
1889
1890 let mut registry = super::SkillRegistry::default();
1891 super::merge_active_plugin_skills(&mut registry, &plugins);
1892 assert!(registry.get("demo:hello-world").is_none());
1893
1894 plugins.trust("demo").unwrap();
1895 super::merge_active_plugin_skills(&mut registry, &plugins);
1896 assert!(registry.get("demo:hello-world").is_none());
1897
1898 plugins.enable("demo").unwrap();
1899 super::merge_active_plugin_skills(&mut registry, &plugins);
1900 let skill = registry
1901 .get("Demo:Hello_World")
1902 .expect("qualified lookup should normalize each namespace segment");
1903 assert_eq!(skill.name, "demo:hello-world");
1904 assert!(matches!(
1905 skill.source,
1906 super::SkillSource::Plugin { ref plugin_name, .. } if plugin_name == "demo"
1907 ));
1908 let rendered = super::render_skills_block(&registry, "en", tmp.path()).unwrap();
1909 assert!(rendered.contains("reviewed plugin snapshot: demo"));
1910 assert!(rendered.contains("use load_skill"));
1911 assert!(
1912 rendered.contains("hello"),
1913 "plugin skill descriptions must reach the model catalogue like native skills: {rendered}"
1914 );
1915 assert!(
1916 !rendered.contains(&plugin_root.display().to_string()),
1917 "model prompt must not expose mutable plugin files after snapshot review"
1918 );
1919
1920 let mut fail_closed_input = registry.clone();
1921 fail_closed_input.skills.push(super::Skill {
1922 legacy_activation_name: None,
1923 name: "native-recovery".to_string(),
1924 description: "native recovery skill".to_string(),
1925 localized_descriptions: std::collections::HashMap::new(),
1926 invocation: super::SkillInvocation::ModelAndUser,
1927 aliases: Vec::new(),
1928 argument_hint: None,
1929 body: "recovery".to_string(),
1930 path: tmp.path().join("native/SKILL.md"),
1931 source: super::SkillSource::Native,
1932 });
1933 let fail_closed = fail_closed_input.into_enabled_with_state(Err(anyhow::anyhow!(
1934 "injected activation-state read failure"
1935 )));
1936 assert!(fail_closed.get("native-recovery").is_some());
1937 assert!(
1938 fail_closed.get("demo:hello-world").is_none(),
1939 "reviewed plugin Skills must not fail open when activation state is unreadable"
1940 );
1941 assert!(
1942 fail_closed
1943 .warnings()
1944 .iter()
1945 .any(|warning| warning.contains("hidden fail-closed"))
1946 );
1947
1948 std::fs::remove_file(config.state_path.with_file_name("state.json.lock")).unwrap();
1949 let mut denied = super::SkillRegistry::default();
1950 super::merge_active_plugin_skills(&mut denied, &plugins);
1951 assert!(
1952 denied.get("demo:hello-world").is_none(),
1953 "a missing authority lock must remove plugin instructions from the prompt catalogue"
1954 );
1955 }
1956
1957 // --- #3921 merged discovery cache -----------------------------------------
1958
1959 fn discovery_delta_since(earlier: super::SkillDiscoveryMetrics) -> super::SkillDiscoveryMetrics {
1960 super::discovery_metrics_snapshot().delta_since(earlier)
1961 }
1962
1963 #[test]
1964 fn cached_discovery_reuses_unchanged_registry_without_rewalking() {
1965 super::clear_skill_discovery_cache();
1966 let tmpdir = TempDir::new().unwrap();
1967 let skills_root = tmpdir.path().join("skills");
1968 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1969 let dirs = vec![skills_root];
1970
1971 super::reset_discovery_metrics();
1972 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1973 let walked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
1974 let second = super::discover_from_directories_with_plugins(dirs, None);
1975 let rewalked = discovery_delta_since(walked);
1976
1977 assert_eq!(walked.root_discovery_calls, 1);
1978 assert_eq!(rewalked, super::SkillDiscoveryMetrics::default());
1979 assert_eq!(first.len(), second.len());
1980 assert_eq!(first.list()[0].description, second.list()[0].description);
1981 }
1982
1983 #[test]
1984 fn cached_discovery_picks_up_added_skill_on_next_call() {
1985 super::clear_skill_discovery_cache();
1986 let tmpdir = TempDir::new().unwrap();
1987 let skills_root = tmpdir.path().join("skills");
1988 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1989 let dirs = vec![skills_root.clone()];
1990
1991 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1992 assert_eq!(first.len(), 1);
1993
1994 write_skill(&skills_root, "added", "A later skill", "More");
1995 std::thread::sleep(std::time::Duration::from_millis(10));
1996 let second = super::discover_from_directories_with_plugins(dirs, None);
1997 assert_eq!(second.len(), 2);
1998 assert!(second.get("added").is_some());
1999 }
2000
2001 #[test]
2002 fn cached_discovery_picks_up_skill_content_edits() {
2003 super::clear_skill_discovery_cache();
2004 let tmpdir = TempDir::new().unwrap();
2005 let skills_root = tmpdir.path().join("skills");
2006 write_skill(&skills_root, "demo", "Original description", "Instructions");
2007 let dirs = vec![skills_root.clone()];
2008
2009 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
2010 assert_eq!(first.list()[0].description, "Original description");
2011
2012 write_skill(&skills_root, "demo", "Edited description", "Instructions");
2013 std::thread::sleep(std::time::Duration::from_millis(10));
2014 let second = super::discover_from_directories_with_plugins(dirs, None);
2015 assert_eq!(second.list()[0].description, "Edited description");
2016 }
2017
2018 #[test]
2019 fn cached_discovery_drops_removed_skills() {
2020 super::clear_skill_discovery_cache();
2021 let tmpdir = TempDir::new().unwrap();
2022 let skills_root = tmpdir.path().join("skills");
2023 write_skill(&skills_root, "keep", "Keep me", "Instructions");
2024 write_skill(&skills_root, "drop", "Drop me", "Instructions");
2025 let dirs = vec![skills_root.clone()];
2026
2027 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
2028 assert_eq!(first.len(), 2);
2029
2030 std::fs::remove_dir_all(skills_root.join("drop")).unwrap();
2031 std::thread::sleep(std::time::Duration::from_millis(10));
2032 let second = super::discover_from_directories_with_plugins(dirs, None);
2033 assert_eq!(second.len(), 1);
2034 assert!(second.get("drop").is_none());
2035 }
2036
2037 #[test]
2038 fn clear_skill_discovery_cache_forces_a_fresh_walk() {
2039 super::clear_skill_discovery_cache();
2040 let tmpdir = TempDir::new().unwrap();
2041 let skills_root = tmpdir.path().join("skills");
2042 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
2043 let dirs = vec![skills_root];
2044
2045 let _ = super::discover_from_directories_with_plugins(dirs.clone(), None);
2046 super::clear_skill_discovery_cache();
2047
2048 super::reset_discovery_metrics();
2049 let _ = super::discover_from_directories_with_plugins(dirs, None);
2050 let rewalked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
2051 assert_eq!(rewalked.root_discovery_calls, 1);
2052 }
2053
2054 #[test]
2055 fn workspace_and_dir_entry_point_shares_the_same_cache() {
2056 let _env_lock = crate::test_support::lock_test_env();
2057 super::clear_skill_discovery_cache();
2058 let tmpdir = TempDir::new().unwrap();
2059 let home = tmpdir.path().join("home");
2060 let workspace = tmpdir.path().join("workspace");
2061 let skills_dir = tmpdir.path().join("configured-skills");
2062 std::fs::create_dir_all(&home).unwrap();
2063 std::fs::create_dir_all(&workspace).unwrap();
2064 write_skill(
2065 &skills_dir,
2066 "configured",
2067 "Configured skill",
2068 "Instructions",
2069 );
2070 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2071 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
2072 let _codewhale_home =
2073 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
2074
2075 super::reset_discovery_metrics();
2076 let first = super::discover_for_workspace_and_dir_with_mode_and_plugins(
2077 &workspace,
2078 &skills_dir,
2079 super::SkillDiscoveryMode::Compatible,
2080 None,
2081 );
2082 let walked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
2083 let second = super::discover_for_workspace_and_dir_with_mode_and_plugins(
2084 &workspace,
2085 &skills_dir,
2086 super::SkillDiscoveryMode::Compatible,
2087 None,
2088 );
2089 let rewalked = discovery_delta_since(walked);
2090
2091 assert!(walked.root_discovery_calls >= 1);
2092 assert_eq!(rewalked, super::SkillDiscoveryMetrics::default());
2093 assert_eq!(first.len(), second.len());
2094 assert!(second.get("configured").is_some());
2095 }
2096
2097 #[test]
2098 fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() {
2099 let _env_lock = crate::test_support::lock_test_env();
2100 super::clear_skill_discovery_cache();
2101 let tmpdir = TempDir::new().unwrap();
2102 let home = tmpdir.path().join("home");
2103 let workspace = home.join("workspace");
2104 let skills_dir = home
2105 .join("runtime")
2106 .join("sessions")
2107 .join("session-123")
2108 .join("skills");
2109 std::fs::create_dir_all(&workspace).unwrap();
2110 write_skill(
2111 &workspace.join(".claude").join("skills"),
2112 "workspace-skill",
2113 "Workspace skill",
2114 "Instructions",
2115 );
2116 let configured_skill = skills_dir.join("visual-design");
2117 std::fs::create_dir_all(&configured_skill).unwrap();
2118 std::fs::write(
2119 configured_skill.join("SKILL.md"),
2120 "---\nname: Visual Design\ndescription: Design assets\n---\nInstructions",
2121 )
2122 .unwrap();
2123 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2124 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
2125 let _codewhale_home =
2126 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
2127 crate::test_support::trust_workspace(&workspace);
2128
2129 let rendered =
2130 super::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
2131 &workspace,
2132 &skills_dir,
2133 super::SkillDiscoveryMode::Compatible,
2134 "en",
2135 None,
2136 super::MAX_AVAILABLE_SKILLS_CHARS,
2137 )
2138 .expect("configured skill context");
2139
2140 assert!(rendered.contains("- visual-design: Design assets\n"));
2141 assert!(rendered.contains(
2142 "- workspace-skill: Workspace skill (file: .claude/skills/workspace-skill/SKILL.md)"
2143 ));
2144 assert!(
2145 rendered
2146 .contains("in <configured-skills>/visual-design/SKILL.md is not a safe command name")
2147 );
2148 assert!(!rendered.contains("session-123"), "{rendered}");
2149 assert!(!rendered.contains(home.to_str().unwrap()), "{rendered}");
2150 }
2151
2152 #[test]
2153 fn default_workspace_skill_prompt_preserves_its_discoverable_path() {
2154 let _env_lock = crate::test_support::lock_test_env();
2155 super::clear_skill_discovery_cache();
2156 let tmpdir = TempDir::new().unwrap();
2157 let home = tmpdir.path().join("home");
2158 let workspace = home.join("workspace");
2159 let skills_dir = workspace.join(".agents").join("skills");
2160 std::fs::create_dir_all(&home).unwrap();
2161 write_skill(
2162 &skills_dir,
2163 "workspace-skill",
2164 "Workspace skill",
2165 "Instructions",
2166 );
2167 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2168 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
2169 let _codewhale_home =
2170 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
2171 crate::test_support::trust_workspace(&workspace);
2172
2173 let rendered =
2174 super::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
2175 &workspace,
2176 &skills_dir,
2177 super::SkillDiscoveryMode::Compatible,
2178 "en",
2179 None,
2180 super::MAX_AVAILABLE_SKILLS_CHARS,
2181 )
2182 .expect("workspace skill context");
2183
2184 assert!(rendered.contains(
2185 "- workspace-skill: Workspace skill (file: .agents/skills/workspace-skill/SKILL.md)"
2186 ));
2187 }
2188
2189 #[test]
2190 fn global_skill_roots_come_from_the_os_home_only() {
2191 // §2.5: global skill roots resolve under the OS user's home (or an
2192 // explicit `$CODEWHALE_HOME`), never an account/GitHub handle. A wrong
2193 // home once produced `Failed to read /Users/<handle>/.codewhale/skills/
2194 // delegate/SKILL.md`; pin the source so every global root is provably
2195 // under the faked OS home.
2196 let _env_lock = crate::test_support::lock_test_env();
2197 let tmpdir = TempDir::new().unwrap();
2198 let home = tmpdir.path().join("os-home");
2199 let workspace = tmpdir.path().join("workspace");
2200 std::fs::create_dir_all(home.join(".codewhale").join("skills")).unwrap();
2201 std::fs::create_dir_all(&workspace).unwrap();
2202 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2203 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
2204 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
2205
2206 let dirs =
2207 super::skills_directories_for_mode(&workspace, super::SkillDiscoveryMode::Compatible);
2208
2209 assert!(
2210 dirs.iter().any(|dir| dir.starts_with(&home)),
2211 "expected at least one global root under the OS home: {dirs:?}"
2212 );
2213 assert!(
2214 dirs.iter()
2215 .all(|dir| dir.starts_with(&home) || dir.starts_with(&workspace)),
2216 "every runtime root is under the OS home or the workspace: {dirs:?}"
2217 );
2218 }
2219
2220 /// Passing a workspace skills dir as the session's `skills_dir` (as
2221 /// `resolve_skills_dir` and the runtime API did) must not re-admit what the
2222 /// root catalog's trust gate filtered out, and the warning must name the dir.
2223 fn assert_skills_dir_held_to_workspace_trust(
2224 workspace: &std::path::Path,
2225 skills_dir: &std::path::Path,
2226 mode: super::SkillDiscoveryMode,
2227 ) {
2228 write_skill(
2229 skills_dir,
2230 "repo-skill",
2231 "from the repository",
2232 "do repo things",
2233 );
2234
2235 let registry = super::discover_for_workspace_and_dir_with_mode_and_plugins(
2236 workspace, skills_dir, mode, None,
2237 );
2238 assert!(
2239 registry.get("repo-skill").is_none(),
2240 "untrusted workspace skill loaded via skills_dir {}",
2241 skills_dir.display()
2242 );
2243 assert!(
2244 !super::skill_directories_for_workspace_and_dir(workspace, skills_dir, mode)
2245 .iter()
2246 .any(|dir| super::roots::paths_refer_to_same_dir(dir, skills_dir)),
2247 "untrusted workspace skills_dir must not be searched"
2248 );
2249 let dir_name = skills_dir
2250 .file_name()
2251 .unwrap()
2252 .to_string_lossy()
2253 .into_owned();
2254 assert!(
2255 registry
2256 .warnings()
2257 .iter()
2258 .any(|warning| warning.contains("not trusted") && warning.contains(&dir_name)),
2259 "{:?}",
2260 registry.warnings()
2261 );
2262
2263 crate::test_support::trust_workspace(workspace);
2264 let registry = super::discover_for_workspace_and_dir_with_mode_and_plugins(
2265 workspace, skills_dir, mode, None,
2266 );
2267 assert!(registry.get("repo-skill").is_some());
2268 assert!(
2269 !registry
2270 .warnings()
2271 .iter()
2272 .any(|warning| warning.contains("not trusted")),
2273 "{:?}",
2274 registry.warnings()
2275 );
2276 }
2277
2278 #[test]
2279 fn untrusted_workspace_agents_skills_via_skills_dir_not_loaded() {
2280 let tmp = TempDir::new().unwrap();
2281 let workspace = tmp.path().join("ws");
2282 assert_skills_dir_held_to_workspace_trust(
2283 &workspace,
2284 &workspace.join(".agents").join("skills"),
2285 super::SkillDiscoveryMode::Compatible,
2286 );
2287 }
2288
2289 #[test]
2290 fn untrusted_workspace_flat_skills_via_skills_dir_not_loaded() {
2291 let tmp = TempDir::new().unwrap();
2292 let workspace = tmp.path().join("ws");
2293 assert_skills_dir_held_to_workspace_trust(
2294 &workspace,
2295 &workspace.join("skills"),
2296 super::SkillDiscoveryMode::Compatible,
2297 );
2298 }
2299
2300 #[test]
2301 fn untrusted_workspace_codewhale_only_skills_dir_not_loaded() {
2302 let tmp = TempDir::new().unwrap();
2303 let workspace = tmp.path().join("ws");
2304 assert_skills_dir_held_to_workspace_trust(
2305 &workspace,
2306 &workspace.join(".codewhale").join("skills"),
2307 super::SkillDiscoveryMode::CodeWhaleOnly,
2308 );
2309 }
2310
2311 /// An explicitly configured dir that lives inside the repository is still
2312 /// repository content: it waits for trust like the built-in project roots.
2313 #[test]
2314 fn untrusted_workspace_custom_configured_dir_inside_workspace_not_loaded() {
2315 let tmp = TempDir::new().unwrap();
2316 let workspace = tmp.path().join("ws");
2317 assert_skills_dir_held_to_workspace_trust(
2318 &workspace,
2319 &workspace.join("my-skills"),
2320 super::SkillDiscoveryMode::Compatible,
2321 );
2322 }
2323
2324 /// Repository-supplied skills are instructions nobody reviewed: they stay out
2325 /// of runtime discovery until the workspace is trusted, and discovery says so
2326 /// rather than dropping them silently.
2327 #[test]
2328 fn project_skills_require_workspace_trust() {
2329 let tmp = TempDir::new().unwrap();
2330 let workspace = tmp.path();
2331 write_skill(
2332 &workspace.join(".claude").join("skills"),
2333 "repo-skill",
2334 "from the repository",
2335 "do repo things",
2336 );
2337 write_skill(
2338 &workspace.join(".codewhale").join("skills"),
2339 "owned-project-skill",
2340 "project owned",
2341 "owned",
2342 );
2343
2344 let registry = super::discover_in_workspace(workspace);
2345 assert!(registry.get("repo-skill").is_none());
2346 assert!(registry.get("owned-project-skill").is_none());
2347 assert!(
2348 registry
2349 .warnings()
2350 .iter()
2351 .any(|warning| warning.contains("not trusted") && warning.contains("/trust")),
2352 "{:?}",
2353 registry.warnings()
2354 );
2355
2356 crate::config::save_workspace_trust(workspace).expect("trust workspace");
2357 let registry = super::discover_in_workspace(workspace);
2358 assert!(registry.get("repo-skill").is_some());
2359 assert!(registry.get("owned-project-skill").is_some());
2360 assert!(
2361 !registry
2362 .warnings()
2363 .iter()
2364 .any(|warning| warning.contains("not trusted")),
2365 "{:?}",
2366 registry.warnings()
2367 );
2368 }
2369
2370 #[test]
2371 fn hidden_and_backup_payload_changes_stale_the_trust_receipt() {
2372 use super::audit::{self, SkillAuditMode, TrustState};
2373 use super::install::{INSTALLED_FROM_MARKER, TRUSTED_MARKER, write_trust_v2};
2374 use super::package_digest::compute_package_digest;
2375 use std::fs;
2376 let tmp = tempfile::tempdir().unwrap();
2377 let package = tmp.path().join(".codewhale/skills/demo");
2378 fs::create_dir_all(&package).unwrap();
2379 fs::write(
2380 package.join("SKILL.md"),
2381 "---\nname: demo\ndescription: test\n---\nbody",
2382 )
2383 .unwrap();
2384 let payloads = [
2385 ".hidden",
2386 ".hidden-dir/run.sh",
2387 "script.bak",
2388 "script.tmp",
2389 "nested/.trusted",
2390 ];
2391 for relative in payloads {
2392 let path = package.join(relative);
2393 fs::create_dir_all(path.parent().unwrap()).unwrap();
2394 fs::write(path, "safe").unwrap();
2395 }
2396 let digest = compute_package_digest(&package).unwrap();
2397 write_trust_v2(&package, &digest).unwrap();
2398 let initial = audit::scan(tmp.path(), None, SkillAuditMode::OwnedOnly, None);
2399 assert_eq!(initial.skills.len(), 1);
2400 assert_eq!(
2401 initial.skills[0].trust,
2402 TrustState::TrustedForDigest(digest.clone())
2403 );
2404 for relative in payloads {
2405 fs::write(package.join(relative), "evil").unwrap();
2406 assert_ne!(
2407 compute_package_digest(&package).unwrap(),
2408 digest,
2409 "{relative}"
2410 );
2411 let changed = audit::scan(tmp.path(), None, SkillAuditMode::OwnedOnly, None);
2412 assert_eq!(
2413 changed.skills[0].trust,
2414 TrustState::TrustStale,
2415 "{relative}"
2416 );
2417 fs::write(package.join(relative), "safe").unwrap();
2418 }
2419 // These root files are local bookkeeping, not executable payload.
2420 for marker in [
2421 INSTALLED_FROM_MARKER,
2422 TRUSTED_MARKER,
2423 ".system-installed-version",
2424 ] {
2425 fs::write(package.join(marker), "local metadata").unwrap();
2426 assert_eq!(compute_package_digest(&package).unwrap(), digest);
2427 }
2428 }
2429
2430 #[test]
2431 fn owned_global_root_shadows_claude_compat() {
2432 let tmp = TempDir::new().unwrap();
2433 let workspace = tmp.path().join("ws");
2434 let home = tmp.path().join("home");
2435 std::fs::create_dir_all(&workspace).unwrap();
2436 write_skill(
2437 &home.join(".codewhale/skills"),
2438 "shared",
2439 "owned",
2440 "owned body",
2441 );
2442 write_skill(
2443 &home.join(".agents/skills"),
2444 "shared",
2445 "agents",
2446 "agents body",
2447 );
2448 write_skill(
2449 &home.join(".claude/skills"),
2450 "shared",
2451 "claude",
2452 "claude body",
2453 );
2454 let registry = super::discover_for_workspace_and_dir_with_home(
2455 &workspace,
2456 &home.join(".codewhale/skills"),
2457 Some(&home),
2458 );
2459 assert_eq!(registry.get("shared").unwrap().body, "owned body");
2460 assert_eq!(
2461 registry
2462 .warnings()
2463 .iter()
2464 .filter(|warning| warning.contains("shadowed by"))
2465 .count(),
2466 2
2467 );
2468 }
2469
2470 #[test]
2471 fn installed_skill_wins_over_agents_copy() {
2472 let tmp = TempDir::new().unwrap();
2473 let workspace = tmp.path().join("ws");
2474 crate::test_support::trust_workspace(&workspace);
2475 write_skill(
2476 &workspace.join(".codewhale/skills"),
2477 "shared",
2478 "installed",
2479 "installed body",
2480 );
2481 write_skill(
2482 &workspace.join(".agents/skills"),
2483 "shared",
2484 "compat",
2485 "compat body",
2486 );
2487 let registry = super::discover_for_workspace_and_dir_with_home(
2488 &workspace,
2489 &workspace.join(".codewhale/skills"),
2490 None,
2491 );
2492 assert_eq!(registry.get("shared").unwrap().body, "installed body");
2493 assert!(
2494 registry
2495 .warnings()
2496 .iter()
2497 .any(|warning| warning.contains(".agents")
2498 && warning.contains(".codewhale")
2499 && warning.contains("shadowed by"))
2500 );
2501 }
2502
2503 #[test]
2504 fn flat_skills_root_opt_in() {
2505 let tmp = TempDir::new().unwrap();
2506 let workspace = tmp.path().join("ws");
2507 crate::test_support::trust_workspace(&workspace);
2508 write_skill(
2509 &workspace.join("skills"),
2510 "flat",
2511 "product content",
2512 "flat body",
2513 );
2514 let catalog = super::roots::SkillRootCatalog::build(&workspace, None, None);
2515 assert!(
2516 catalog
2517 .audit_compatible_directories()
2518 .iter()
2519 .any(|root| root.path == workspace.join("skills") && !root.active_for_runtime)
2520 );
2521 let default = super::discover_for_workspace_and_dir_with_home_and_mode(
2522 &workspace,
2523 &workspace.join(".codewhale/skills"),
2524 None,
2525 super::SkillDiscoveryMode::Compatible,
2526 );
2527 assert!(default.get("flat").is_none());
2528 let config: crate::config::Config =
2529 toml::from_str("[skills]\nflat_workspace_root = true\n").unwrap();
2530 let mode = super::SkillDiscoveryMode::from_config(&config.skills_config());
2531 assert_eq!(mode, super::SkillDiscoveryMode::CompatibleWithFlatWorkspace);
2532 let enabled = super::discover_for_workspace_and_dir_with_home_and_mode(
2533 &workspace,
2534 &workspace.join(".codewhale/skills"),
2535 None,
2536 mode,
2537 );
2538 assert_eq!(enabled.get("flat").unwrap().body, "flat body");
2539 let owned_only: crate::config::Config =
2540 toml::from_str("[skills]\nflat_workspace_root = true\nscan_codewhale_only = true\n")
2541 .unwrap();
2542 assert_eq!(
2543 super::SkillDiscoveryMode::from_config(&owned_only.skills_config()),
2544 super::SkillDiscoveryMode::CodeWhaleOnly
2545 );
2546 let explicit = super::discover_for_workspace_and_dir_with_home_and_mode(
2547 &workspace,
2548 &workspace.join("skills"),
2549 None,
2550 super::SkillDiscoveryMode::Compatible,
2551 );
2552 assert!(
2553 explicit.get("flat").is_some(),
2554 "explicit skills_dir remains supported"
2555 );
2556 }
2557
2558 #[test]
2559 fn disable_model_invocation_maps_to_explicit_only() {
2560 for value in ["true", "yes", "on", "1"] {
2561 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("demo/SKILL.md"), &format!("---\nname: demo\ndescription: routing\ndisable-model-invocation: {value}\n---\nbody")).unwrap();
2562 assert_eq!(skill.invocation, super::SkillInvocation::ExplicitOnly);
2563 assert!(!skill.invocation.model_invocable());
2564 assert!(skill.invocation.user_invocable());
2565 }
2566 }
2567
2568 #[test]
2569 fn user_invocable_false_hidden_from_slash_menu() {
2570 let content = "---\nname: demo\ndescription: routing\nuser-invocable: false\nargument-hint: '[query] [path]'\n---\nbody";
2571 let skill =
2572 super::SkillRegistry::parse_skill(std::path::Path::new("demo/SKILL.md"), content).unwrap();
2573 assert_eq!(skill.invocation, super::SkillInvocation::ModelOnly);
2574 assert!(skill.invocation.model_invocable());
2575 assert!(!skill.invocation.user_invocable());
2576 assert_eq!(skill.argument_hint.as_deref(), Some("[query] [path]"));
2577 assert!(skill.user_menu_description().contains("[query] [path]"));
2578 let disabled = super::SkillRegistry::parse_skill(
2579 std::path::Path::new("demo/SKILL.md"),
2580 &content.replace(
2581 "user-invocable: false",
2582 "user-invocable: false\ndisable-model-invocation: true",
2583 ),
2584 )
2585 .unwrap();
2586 assert_eq!(disabled.invocation, super::SkillInvocation::Disabled);
2587 assert!(!disabled.invocation.model_invocable());
2588 assert!(!disabled.invocation.user_invocable());
2589 let invalid = super::SkillRegistry::parse_skill(
2590 std::path::Path::new("demo/SKILL.md"),
2591 &content.replace(
2592 "user-invocable: false",
2593 "user-invocable: mystery\ndisable-model-invocation: mystery",
2594 ),
2595 )
2596 .unwrap();
2597 assert_eq!(invalid.invocation, super::SkillInvocation::Disabled);
2598 }
2599
2600 #[test]
2601 fn when_to_use_merged_into_trigger() {
2602 let content = "---\nname: demo\ndescription: Short summary.\nwhen_to_use: fixing a failing build\n---\nbody";
2603 let skill =
2604 super::SkillRegistry::parse_skill(std::path::Path::new("demo/SKILL.md"), content).unwrap();
2605 assert_eq!(
2606 skill.description,
2607 "Short summary. Use when: fixing a failing build"
2608 );
2609 let (summary, trigger) = super::split_trigger(&skill.description);
2610 assert_eq!(summary.trim(), "Short summary");
2611 assert!(trigger.unwrap().contains("fixing a failing build"));
2612 }
2613
2614 #[test]
2615 fn frontmatter_warnings_reach_registry_once() {
2616 let tmp = TempDir::new().unwrap();
2617 create_skill_dir(
2618 &tmp,
2619 "directory",
2620 "---\nname: different\nallowed-tools: [Read]\nmodel: example\ncontext: fork\nmystery: ignored\n---\nbody",
2621 );
2622 let registry = super::SkillRegistry::discover(&tmp.path().join("skills"));
2623 assert!(
2624 registry.get("different").is_some(),
2625 "runtime remains lenient"
2626 );
2627 assert_eq!(registry.warnings().len(), 6, "{:?}", registry.warnings());
2628 for key in [
2629 "allowed-tools",
2630 "model",
2631 "context",
2632 "mystery",
2633 "missing description",
2634 "differs from directory",
2635 ] {
2636 assert_eq!(
2637 registry
2638 .warnings()
2639 .iter()
2640 .filter(|warning| warning.contains(key))
2641 .count(),
2642 1
2643 );
2644 }
2645 }
2646
2647 /// A repository must not make skill discovery read outside itself: a linked
2648 /// skill directory, a linked `SKILL.md` and a linked skills root under the
2649 /// workspace are each refused with a warning, while the same layout under an
2650 /// operator-owned root (no workspace) still follows links on purpose.
2651 #[cfg(unix)]
2652 #[test]
2653 fn workspace_skill_discovery_does_not_follow_links_out_of_the_workspace() {
2654 use std::os::unix::fs::symlink;
2655 super::clear_skill_discovery_cache();
2656 let workspace = TempDir::new().unwrap();
2657 let outside = TempDir::new().unwrap();
2658 write_skill(
2659 outside.path(),
2660 "smuggled-dir",
2661 "outside dir",
2662 "Outside body",
2663 );
2664 let linked_file_skill = outside.path().join("linked-file-source");
2665 write_skill(
2666 &linked_file_skill,
2667 "smuggled-file",
2668 "outside file",
2669 "Outside body",
2670 );
2671
2672 let root = workspace.path().join(".agents").join("skills");
2673 write_skill(&root, "inside", "inside the workspace", "Inside body");
2674 symlink(outside.path().join("smuggled-dir"), root.join("linked-dir")).unwrap();
2675 std::fs::create_dir_all(root.join("linked-file")).unwrap();
2676 symlink(
2677 linked_file_skill.join("smuggled-file").join("SKILL.md"),
2678 root.join("linked-file").join("SKILL.md"),
2679 )
2680 .unwrap();
2681
2682 let registry = super::discover_from_directories_in_workspace(
2683 vec![root.clone()],
2684 Some(workspace.path()),
2685 None,
2686 );
2687 let names = registry
2688 .list()
2689 .iter()
2690 .map(|skill| skill.name.as_str())
2691 .collect::<Vec<_>>();
2692 assert_eq!(names, vec!["inside"], "{:?}", registry.warnings());
2693 let warnings = registry.warnings().join("\n");
2694 assert!(
2695 warnings.contains("Refusing symlinked skill entry"),
2696 "{warnings}"
2697 );
2698 assert!(warnings.contains("linked-file"), "{warnings}");
2699
2700 // The same tree read as an operator-owned root still follows links.
2701 super::clear_skill_discovery_cache();
2702 let operator = super::discover_from_directories_with_plugins(vec![root.clone()], None);
2703 assert!(
2704 operator
2705 .list()
2706 .iter()
2707 .any(|skill| skill.name == "smuggled-dir"),
2708 "operator-owned roots keep following links"
2709 );
2710
2711 // A linked skills root under the workspace is refused as a whole.
2712 super::clear_skill_discovery_cache();
2713 let other = TempDir::new().unwrap();
2714 std::fs::create_dir_all(other.path().join(".agents")).unwrap();
2715 symlink(outside.path(), other.path().join(".agents").join("skills")).unwrap();
2716 let registry = super::discover_from_directories_in_workspace(
2717 vec![other.path().join(".agents").join("skills")],
2718 Some(other.path()),
2719 None,
2720 );
2721 assert!(registry.list().is_empty());
2722 assert!(
2723 registry
2724 .warnings()
2725 .join("\n")
2726 .contains("Refusing symlinked path"),
2727 "{:?}",
2728 registry.warnings()
2729 );
2730 }
2731
2731 lines RUST