返回 CodeWhale
tests.rs
根目录 / crates / tui / src / skills / tests.rs
1 use tempfile::TempDir;
2
3 fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
4 let skill_dir = tmpdir.path().join("skills").join(skill_name);
5 std::fs::create_dir_all(&skill_dir).unwrap();
6 std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
7 }
8
9 #[test]
10 fn discovery_metrics_reset_and_snapshot_are_exact() {
11 super::reset_discovery_metrics();
12 assert_eq!(
13 super::discovery_metrics_snapshot(),
14 super::SkillDiscoveryMetrics::default()
15 );
16
17 let tmpdir = TempDir::new().unwrap();
18 let skills_root = tmpdir.path().join("skills");
19 let vendor_root = skills_root.join("vendor");
20 write_skill(&vendor_root, "demo", "A demo skill", "Instructions");
21
22 let registry = super::SkillRegistry::discover(&skills_root);
23 assert_eq!(registry.len(), 1);
24 assert_eq!(
25 super::discovery_metrics_snapshot(),
26 super::SkillDiscoveryMetrics {
27 root_discovery_calls: 1,
28 directories_visited: 2,
29 skill_md_read_attempts: 2,
30 }
31 );
32
33 super::reset_discovery_metrics();
34 let missing_root = tmpdir.path().join("missing");
35 let _registry = super::SkillRegistry::discover(&missing_root);
36 assert_eq!(
37 super::discovery_metrics_snapshot(),
38 super::SkillDiscoveryMetrics {
39 root_discovery_calls: 1,
40 directories_visited: 0,
41 skill_md_read_attempts: 0,
42 }
43 );
44
45 super::reset_discovery_metrics();
46 assert_eq!(
47 super::discovery_metrics_snapshot(),
48 super::SkillDiscoveryMetrics::default()
49 );
50 }
51
52 #[test]
53 fn prompt_warning_sanitizer_scrubs_stale_conventional_home_roots() {
54 let workspace = std::path::Path::new("/tmp/workspace");
55 let warning = "Skill at /Users/private-name/.agents/skills/a/SKILL.md is shadowed by /home/other/.skills/a/SKILL.md";
56 let sanitized = super::sanitize_prompt_path_text(warning, workspace, None);
57 assert_eq!(
58 sanitized,
59 "Skill at ~/.agents/skills/a/SKILL.md is shadowed by ~/.skills/a/SKILL.md"
60 );
61 }
62
63 #[test]
64 fn prompt_warning_sanitizer_normalizes_windows_separators() {
65 let workspace = std::path::Path::new(r"C:\workspace");
66 let configured_root = std::path::Path::new(r"C:\runtime\sessions\session-123\skills");
67 let warning = r"Skill in C:\runtime\sessions\session-123\skills\visual-design\SKILL.md is not a safe command name";
68
69 let sanitized = super::sanitize_prompt_path_text(warning, workspace, Some(configured_root));
70
71 assert_eq!(
72 sanitized,
73 "Skill in <configured-skills>/visual-design/SKILL.md is not a safe command name"
74 );
75 }
76
77 #[test]
78 fn prompt_warning_sanitizer_replaces_configured_roots_only_at_path_boundaries() {
79 let workspace = std::path::Path::new("/tmp/workspace");
80 let configured_root = std::path::Path::new("/tmp/work");
81 let warning = "Skill in /tmp/workspace/.agents/skills/a/SKILL.md shadows /tmp/work/a/SKILL.md";
82
83 let sanitized = super::sanitize_prompt_path_text(warning, workspace, Some(configured_root));
84
85 assert_eq!(
86 sanitized,
87 "Skill in ./.agents/skills/a/SKILL.md shadows <configured-skills>/a/SKILL.md"
88 );
89 }
90
91 #[cfg(unix)]
92 #[test]
93 fn prompt_warning_sanitizer_handles_non_utf8_configured_roots() {
94 use std::os::unix::ffi::OsStringExt;
95
96 let workspace = std::path::Path::new("/tmp/workspace");
97 let configured_root = std::path::PathBuf::from(std::ffi::OsString::from_vec(
98 b"/tmp/session-\xff/skills".to_vec(),
99 ));
100 let warning = format!(
101 "Skill in {}/visual-design/SKILL.md is not a safe command name",
102 configured_root.display()
103 );
104
105 let sanitized = super::sanitize_prompt_path_text(&warning, workspace, Some(&configured_root));
106
107 assert_eq!(
108 sanitized,
109 "Skill in <configured-skills>/visual-design/SKILL.md is not a safe command name"
110 );
111 }
112
113 #[test]
114 fn render_available_skills_context_lists_paths_and_usage() {
115 let tmpdir = TempDir::new().unwrap();
116 create_skill_dir(
117 &tmpdir,
118 "test-skill",
119 "---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
120 );
121
122 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
123 .expect("skill context");
124
125 // #4632: paths render relative to the skills base dir (privacy-safe),
126 // so the assertion checks the workspace-relative form.
127 let expected_path = super::prompt_display(&std::path::Path::new("test-skill").join("SKILL.md"));
128
129 assert!(rendered.contains("## Skills"));
130 assert!(rendered.contains("- test-skill: A test skill"));
131 assert!(rendered.contains("load the exact skill before use"));
132 assert!(rendered.contains("do not expand tool, approval, or trust authority"));
133 assert!(
134 rendered.contains(&expected_path),
135 "expected path {expected_path:?} not in rendered output"
136 );
137 assert!(!rendered.contains(tmpdir.path().to_str().unwrap_or("/nonexistent")));
138 assert!(rendered.contains("### Usage"));
139 }
140
141 #[test]
142 fn workspace_prompt_omits_disabled_skills_without_configured_directory() {
143 let _env_lock = crate::test_support::lock_test_env();
144 let tmpdir = TempDir::new().unwrap();
145 let home = tmpdir.path().join("home");
146 let workspace = tmpdir.path().join("workspace");
147 let skills_root = workspace.join(".agents").join("skills");
148 std::fs::create_dir_all(&home).unwrap();
149 write_skill(
150 &skills_root,
151 "enabled-skill",
152 "Enabled skill",
153 "Instructions",
154 );
155 write_skill(
156 &skills_root,
157 "disabled-skill",
158 "Disabled skill",
159 "Instructions",
160 );
161 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
162 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
163 let _codewhale_home =
164 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
165
166 let mut state = crate::skill_state::SkillStateStore::load_default().unwrap();
167 state.set_enabled("disabled-skill", false).unwrap();
168 super::clear_skill_discovery_cache();
169
170 let rendered = super::render_available_skills_context_for_workspace_with_mode_and_plugins(
171 &workspace,
172 super::SkillDiscoveryMode::Compatible,
173 "en",
174 None,
175 super::MAX_AVAILABLE_SKILLS_CHARS,
176 )
177 .expect("enabled skill context");
178
179 assert!(rendered.contains("enabled-skill"));
180 assert!(!rendered.contains("disabled-skill"));
181 }
182
183 #[test]
184 fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
185 // Regression: when a community-installed or manually-placed skill
186 // lives in a directory whose name differs from its frontmatter
187 // `name`, the rendered prompt must point to the real on-disk file
188 // path, not <skills_dir>/<frontmatter-name>/SKILL.md (which does
189 // not exist).
190 let tmpdir = TempDir::new().unwrap();
191 create_skill_dir(
192 &tmpdir,
193 "weird-dir-name",
194 "---\nname: friendly-name\ndescription: drift case\n---\nbody",
195 );
196
197 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
198 .expect("skill context");
199
200 // #4632: rendered relative to the skills base dir; the regression
201 // intent (real dir name, not frontmatter name) is unchanged.
202 let real_path = super::prompt_display(&std::path::Path::new("weird-dir-name").join("SKILL.md"));
203 let stale_path = super::prompt_display(&std::path::Path::new("friendly-name").join("SKILL.md"));
204
205 assert!(
206 rendered.contains(&real_path),
207 "expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}"
208 );
209 assert!(
210 !rendered.contains(&stale_path),
211 "rendered output must not invent a path under the frontmatter name:\n{rendered}"
212 );
213 }
214
215 #[test]
216 fn render_available_skills_context_returns_none_when_empty() {
217 let tmpdir = TempDir::new().unwrap();
218 let empty = tmpdir.path().join("skills");
219 std::fs::create_dir_all(&empty).unwrap();
220 assert!(crate::skills::render_available_skills_context(&empty).is_none());
221
222 let missing = tmpdir.path().join("does-not-exist");
223 assert!(crate::skills::render_available_skills_context(&missing).is_none());
224 }
225
226 #[test]
227 fn render_skills_block_surfaces_warnings_when_no_skill_loaded() {
228 let tmpdir = TempDir::new().unwrap();
229 let mut registry = super::SkillRegistry::default();
230 registry
231 .warnings
232 .push("broken skill could not be parsed".to_string());
233
234 let rendered =
235 super::render_skills_block(&registry, "en", tmpdir.path()).expect("warning-only block");
236
237 assert!(rendered.contains("### Skill load warnings"));
238 assert!(rendered.contains("broken skill could not be parsed"));
239 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
240 }
241
242 #[test]
243 fn render_available_skills_context_truncates_long_descriptions() {
244 let tmpdir = TempDir::new().unwrap();
245 let long_desc = "x".repeat(2_000);
246 let body = format!("---\nname: bigdesc\ndescription: {long_desc}\n---\nbody");
247 create_skill_dir(&tmpdir, "bigdesc", &body);
248
249 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
250 .expect("skill context");
251
252 let max = super::MAX_SKILL_DESCRIPTION_CHARS;
253 assert!(rendered.contains('…'), "expected truncation marker");
254 assert!(
255 !rendered.contains(&"x".repeat(max + 1)),
256 "untruncated long run should not appear"
257 );
258 }
259
260 #[test]
261 fn render_available_skills_context_collapses_internal_whitespace() {
262 let tmpdir = TempDir::new().unwrap();
263 create_skill_dir(
264 &tmpdir,
265 "spaced-skill",
266 "---\nname: spaced-skill\ndescription: alpha \t beta gamma\n---\nbody",
267 );
268
269 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
270 .expect("skill context");
271
272 let line = rendered
273 .lines()
274 .find(|l| l.starts_with("- spaced-skill:"))
275 .expect("skill line");
276 assert!(line.contains("alpha beta gamma"), "got: {line:?}");
277 }
278
279 /// Three-tier fitting: when full descriptions overflow the budget the index
280 /// shortens them, then drops to names-only — a skill's name never vanishes
281 /// while the names themselves fit.
282 #[test]
283 fn render_available_skills_context_keeps_every_name_when_descriptions_overflow() {
284 let tmpdir = TempDir::new().unwrap();
285 let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
286 for i in 0..200 {
287 let body = format!("---\nname: skill-{i:03}\ndescription: {big_desc}\n---\nbody");
288 create_skill_dir(&tmpdir, &format!("skill-{i:03}"), &body);
289 }
290
291 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
292 .expect("skill context");
293
294 // 200 × ~380 chars of description is ~76k, far over the default budget:
295 // tier 1 cannot fit, so descriptions shrink or names stand alone.
296 for i in 0..200 {
297 let name = format!("- skill-{i:03}");
298 assert!(rendered.contains(&name), "missing {name}:\n{rendered}");
299 }
300 assert!(
301 !rendered.contains("additional skills omitted"),
302 "names fit the budget; omission is the last resort, not the first"
303 );
304 assert!(
305 !rendered.contains(&big_desc),
306 "a full-length description must not survive an overflowing index"
307 );
308 assert!(
309 rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS,
310 "rendered length must stay within the complete block budget"
311 );
312 }
313
314 /// `Use when:` triggers survive shortening ahead of the summary — they are
315 /// what the model routes on.
316 #[test]
317 fn render_skills_block_shortens_summary_before_trigger() {
318 let tmpdir = TempDir::new().unwrap();
319 let mut registry = super::SkillRegistry::default();
320 let summary = "s".repeat(300);
321 for i in 0..120 {
322 registry.skills.push(super::Skill {
323 name: format!("skill-{i:03}"),
324 description: format!("{summary} Use when: the user asks for widget {i}."),
325 localized_descriptions: std::collections::HashMap::new(),
326 invocation: super::SkillInvocation::ModelAndUser,
327 aliases: Vec::new(),
328 body: "body".to_string(),
329 path: tmpdir.path().join(format!("skill-{i:03}/SKILL.md")),
330 source: super::SkillSource::Native,
331 });
332 }
333 let rendered =
334 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
335 let line = rendered
336 .lines()
337 .find(|l| l.starts_with("- skill-007:"))
338 .expect("row for skill-007");
339 assert!(
340 line.contains("Use when: the user asks for widget 7"),
341 "trigger must survive shortening intact:\n{line}"
342 );
343 assert!(
344 !line.contains(&summary),
345 "summary must be the half that shrinks:\n{line}"
346 );
347 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
348 }
349
350 /// The budget follows the route window: a 1M route sees a much larger index
351 /// than a small local window, both clamped to sane bounds.
352 #[test]
353 fn skills_prompt_budget_scales_with_context_window() {
354 let small = super::skills_prompt_budget_chars(Some(8_000));
355 let default = super::skills_prompt_budget_chars(None);
356 let large = super::skills_prompt_budget_chars(Some(1_000_000));
357 assert_eq!(small, 2_400, "floor holds for tiny windows");
358 assert_eq!(default, 25_600, "128k window × 4 chars × 5%");
359 assert_eq!(large, 40_000, "ceiling holds for 1M windows");
360 assert_eq!(
361 super::skills_prompt_budget_chars(Some(0)),
362 default,
363 "a zero window is treated as unknown"
364 );
365 }
366
367 #[test]
368 fn render_skills_block_holds_budget_with_five_digit_omission_counts() {
369 let tmpdir = TempDir::new().unwrap();
370 let mut registry = super::SkillRegistry::default();
371 for i in 0..15_000 {
372 registry.skills.push(super::Skill {
373 name: format!("skill-{i:05}"),
374 description: "x".to_string(),
375 localized_descriptions: std::collections::HashMap::new(),
376 invocation: super::SkillInvocation::ModelAndUser,
377 aliases: Vec::new(),
378 body: "body".to_string(),
379 path: tmpdir.path().join(format!("skill-{i:05}/SKILL.md")),
380 source: super::SkillSource::Native,
381 });
382 registry.warnings.push(format!("warning {i:05}"));
383 }
384
385 let rendered =
386 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
387 let omitted_skills = rendered
388 .lines()
389 .find(|line| line.contains("additional skills omitted"))
390 .and_then(|line| line.split_whitespace().nth(2))
391 .and_then(|count| count.parse::<usize>().ok())
392 .expect("skill omission count");
393 let omitted_warnings = rendered
394 .lines()
395 .find(|line| line.contains("additional warnings omitted"))
396 .and_then(|line| line.split_whitespace().nth(2))
397 .and_then(|count| count.parse::<usize>().ok())
398 .expect("warning omission count");
399
400 assert!(omitted_skills > 9_999, "fixture must exercise five digits");
401 assert!(
402 omitted_warnings > 9_999,
403 "fixture must exercise five digits"
404 );
405 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
406 }
407
408 #[test]
409 fn explicit_only_skills_do_not_reduce_ambient_index_capacity() {
410 let tmpdir = TempDir::new().unwrap();
411 let mut registry = super::SkillRegistry::default();
412 for i in 0..6 {
413 registry.skills.push(super::Skill {
414 name: format!("visible-{i:03}"),
415 description: "x".repeat(246),
416 localized_descriptions: std::collections::HashMap::new(),
417 invocation: super::SkillInvocation::ModelAndUser,
418 aliases: Vec::new(),
419 body: "body".to_string(),
420 path: tmpdir.path().join(format!("visible-{i:03}/SKILL.md")),
421 source: super::SkillSource::Native,
422 });
423 }
424
425 let baseline =
426 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
427 assert!(!baseline.contains("additional skills omitted"));
428
429 let mut with_explicit_only = registry.clone();
430 for i in 0..10_000 {
431 with_explicit_only.skills.push(super::Skill {
432 name: format!("explicit-{i:05}"),
433 description: String::new(),
434 localized_descriptions: std::collections::HashMap::new(),
435 invocation: super::SkillInvocation::ExplicitOnly,
436 aliases: Vec::new(),
437 body: "body".to_string(),
438 path: tmpdir.path().join(format!("explicit-{i:05}/SKILL.md")),
439 source: super::SkillSource::Native,
440 });
441 }
442
443 let rendered = super::render_skills_block(&with_explicit_only, "en", tmpdir.path())
444 .expect("skill context");
445 assert_eq!(rendered, baseline);
446 }
447
448 #[test]
449 fn render_skills_block_preserves_registry_precedence_under_prompt_budget() {
450 let tmpdir = TempDir::new().unwrap();
451 let mut registry = super::SkillRegistry::default();
452 registry.skills.push(super::Skill {
453 name: "workspace-priority".to_string(),
454 description: "must survive truncation".to_string(),
455 localized_descriptions: std::collections::HashMap::new(),
456 invocation: super::SkillInvocation::ModelAndUser,
457 aliases: Vec::new(),
458 body: "body".to_string(),
459 path: tmpdir
460 .path()
461 .join(".claude")
462 .join("skills")
463 .join("workspace-priority")
464 .join("SKILL.md"),
465 source: super::SkillSource::Native,
466 });
467
468 let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
469 for i in 0..200 {
470 registry.skills.push(super::Skill {
471 name: format!("aaa-global-{i:03}"),
472 description: big_desc.clone(),
473 localized_descriptions: std::collections::HashMap::new(),
474 invocation: super::SkillInvocation::ModelAndUser,
475 aliases: Vec::new(),
476 body: "body".to_string(),
477 path: tmpdir
478 .path()
479 .join(".deepseek")
480 .join("skills")
481 .join(format!("aaa-global-{i:03}"))
482 .join("SKILL.md"),
483 source: super::SkillSource::Native,
484 });
485 }
486
487 let rendered =
488 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
489 assert!(
490 rendered.contains("workspace-priority"),
491 "higher-precedence workspace skills must not be reordered behind globals:\n{rendered}"
492 );
493 let first_row = rendered
494 .lines()
495 .find(|line| line.starts_with("- "))
496 .expect("at least one row");
497 assert!(
498 first_row.starts_with("- workspace-priority"),
499 "registry order is render order:\n{rendered}"
500 );
501 }
502
503 // --- Localized skill descriptions (#3354) ------------------------------
504
505 #[test]
506 fn parse_skill_collects_localized_description_frontmatter() {
507 let content = "---\n\
508 name: demo\n\
509 description: A demo skill\n\
510 description_zh: 一个演示技能\n\
511 description_zh-Hant: 一個示範技能\n\
512 ---\n\
513 body";
514 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), content)
515 .expect("parse should succeed");
516 assert_eq!(skill.description, "A demo skill");
517 assert_eq!(
518 skill.localized_descriptions.get("zh").map(String::as_str),
519 Some("一个演示技能")
520 );
521 // Frontmatter keys are lowercased, so zh-Hant is stored as zh-hant.
522 assert_eq!(
523 skill
524 .localized_descriptions
525 .get("zh-hant")
526 .map(String::as_str),
527 Some("一個示範技能")
528 );
529 }
530
531 #[test]
532 fn parse_skill_exposes_invocation_and_alias_metadata() {
533 let content = "---\n\
534 name: spreadsheets\n\
535 description: Spreadsheet workflows\n\
536 invocation: explicit-only\n\
537 aliases-for: xlsx, spreadsheet\n\
538 ---\n\
539 body";
540 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), content)
541 .expect("parse should succeed");
542
543 assert_eq!(skill.invocation, super::SkillInvocation::ExplicitOnly);
544 assert_eq!(
545 skill.aliases,
546 vec!["xlsx".to_string(), "spreadsheet".to_string()]
547 );
548
549 let mut registry = super::SkillRegistry::default();
550 registry.skills.push(skill);
551 assert_eq!(
552 registry.get("spreadsheet").map(|s| s.name.as_str()),
553 Some("spreadsheets")
554 );
555 assert_eq!(
556 registry.get("xlsx").map(|s| s.name.as_str()),
557 Some("spreadsheets")
558 );
559
560 let rendered = super::render_skills_block(&registry, "en", std::path::Path::new("/"));
561 assert!(
562 rendered.is_some(),
563 "an explicit-only skill remains loadable"
564 );
565 assert!(
566 !rendered.unwrap_or_default().contains("spreadsheets"),
567 "explicit-only skills must not enter the model catalogue"
568 );
569 }
570
571 #[test]
572 fn missing_or_unknown_invocation_keeps_model_and_user_compatibility() {
573 for invocation in [None, Some("future-mode")] {
574 let invocation_line =
575 invocation.map_or(String::new(), |value| format!("invocation: {value}\n"));
576 let content =
577 format!("---\nname: compatible\ndescription: compatible\n{invocation_line}---\nbody");
578 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), &content)
579 .expect("parse should succeed");
580 assert_eq!(skill.invocation, super::SkillInvocation::ModelAndUser);
581 }
582 }
583
584 #[test]
585 fn description_for_locale_matches_exact_then_primary_then_falls_back() {
586 let mut localized = std::collections::HashMap::new();
587 localized.insert("zh".to_string(), "中文描述".to_string());
588 localized.insert("ja".to_string(), "日本語の説明".to_string());
589 let skill = super::Skill {
590 name: "demo".to_string(),
591 description: "English description".to_string(),
592 localized_descriptions: localized,
593 invocation: super::SkillInvocation::ModelAndUser,
594 aliases: Vec::new(),
595 body: String::new(),
596 path: std::path::PathBuf::new(),
597 source: super::SkillSource::Native,
598 };
599
600 assert_eq!(skill.description_for_locale("zh"), "中文描述"); // exact
601 assert_eq!(skill.description_for_locale("ZH"), "中文描述"); // case-insensitive
602 assert_eq!(skill.description_for_locale("zh-CN"), "中文描述"); // Simplified region → zh
603 assert_eq!(skill.description_for_locale("zh-Hans"), "中文描述"); // Simplified script → zh
604 assert_eq!(skill.description_for_locale("ja"), "日本語の説明");
605 assert_eq!(skill.description_for_locale("fr"), "English description"); // fallback
606 assert_eq!(skill.description_for_locale("en"), "English description");
607
608 // Traditional Chinese must NOT borrow the Simplified `zh` description:
609 // with no exact zh-hant key authored, it falls back to the default.
610 assert_eq!(
611 skill.description_for_locale("zh-Hant"),
612 "English description"
613 );
614 assert_eq!(skill.description_for_locale("zh-TW"), "English description");
615 assert_eq!(skill.description_for_locale("zh-HK"), "English description");
616 }
617
618 #[test]
619 fn description_for_locale_uses_exact_traditional_key_when_authored() {
620 let mut localized = std::collections::HashMap::new();
621 localized.insert("zh".to_string(), "简体描述".to_string());
622 localized.insert("zh-hant".to_string(), "繁體描述".to_string());
623 let skill = super::Skill {
624 name: "demo".to_string(),
625 description: "English".to_string(),
626 localized_descriptions: localized,
627 invocation: super::SkillInvocation::ModelAndUser,
628 aliases: Vec::new(),
629 body: String::new(),
630 path: std::path::PathBuf::new(),
631 source: super::SkillSource::Native,
632 };
633 // Exact Traditional key wins for a Traditional session.
634 assert_eq!(skill.description_for_locale("zh-Hant"), "繁體描述");
635 // Simplified session still gets the Simplified description.
636 assert_eq!(skill.description_for_locale("zh-Hans"), "简体描述");
637 assert_eq!(skill.description_for_locale("zh"), "简体描述");
638 }
639
640 #[test]
641 fn description_for_locale_uses_default_when_no_localized_variants() {
642 let skill = super::Skill {
643 name: "demo".to_string(),
644 description: "only english".to_string(),
645 localized_descriptions: std::collections::HashMap::new(),
646 invocation: super::SkillInvocation::ModelAndUser,
647 aliases: Vec::new(),
648 body: String::new(),
649 path: std::path::PathBuf::new(),
650 source: super::SkillSource::Native,
651 };
652 assert_eq!(skill.description_for_locale("zh"), "only english");
653 }
654
655 #[test]
656 fn render_skills_block_selects_description_by_locale() {
657 let mut registry = super::SkillRegistry::default();
658 let mut localized = std::collections::HashMap::new();
659 localized.insert("zh".to_string(), "压缩日志的技能".to_string());
660 registry.skills.push(super::Skill {
661 name: "compress".to_string(),
662 description: "Compress logs to save space".to_string(),
663 localized_descriptions: localized,
664 invocation: super::SkillInvocation::ModelAndUser,
665 aliases: Vec::new(),
666 body: "body".to_string(),
667 path: std::path::PathBuf::from("/skills/compress/SKILL.md"),
668 source: super::SkillSource::Native,
669 });
670
671 let zh = super::render_skills_block(&registry, "zh-Hans", std::path::Path::new("/"))
672 .expect("zh block");
673 assert!(
674 zh.contains("压缩日志的技能"),
675 "zh session should get the zh description:\n{zh}"
676 );
677 assert!(!zh.contains("Compress logs to save space"));
678
679 let en =
680 super::render_skills_block(&registry, "en", std::path::Path::new("/")).expect("en block");
681 assert!(
682 en.contains("Compress logs to save space"),
683 "en session keeps default:\n{en}"
684 );
685 }
686
687 fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
688 let skill_dir = dir.join(name);
689 std::fs::create_dir_all(&skill_dir).unwrap();
690 std::fs::write(
691 skill_dir.join("SKILL.md"),
692 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
693 )
694 .unwrap();
695 }
696
697 #[cfg(unix)]
698 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
699 std::os::unix::fs::symlink(target, link)
700 }
701
702 #[cfg(windows)]
703 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
704 std::os::windows::fs::symlink_dir(target, link)
705 }
706
707 #[test]
708 fn skills_directories_returns_existing_dirs_in_precedence_order() {
709 let tmpdir = TempDir::new().unwrap();
710 let workspace = tmpdir.path();
711
712 // Create four of the five workspace candidate dirs (skip `.opencode`).
713 std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
714 std::fs::create_dir_all(workspace.join("skills")).unwrap();
715 std::fs::create_dir_all(workspace.join(".claude").join("skills")).unwrap();
716 std::fs::create_dir_all(workspace.join(".cursor").join("skills")).unwrap();
717
718 let dirs = super::skills_directories_for_mode(workspace, super::SkillDiscoveryMode::Compatible);
719 // We don't assert on the global default position because it's
720 // host-dependent (may not exist on the test machine).
721 let mut idx = 0;
722 let agents = workspace.join(".agents").join("skills");
723 let local = workspace.join("skills");
724 let claude = workspace.join(".claude").join("skills");
725 let cursor = workspace.join(".cursor").join("skills");
726
727 assert_eq!(dirs.get(idx), Some(&agents), "agents must come first");
728 idx += 1;
729 assert_eq!(dirs.get(idx), Some(&local), "local must come second");
730 idx += 1;
731 // .opencode/skills was not created — it must NOT appear.
732 assert!(
733 !dirs
734 .iter()
735 .any(|p| p == &workspace.join(".opencode").join("skills")),
736 "missing dir must be omitted, got: {dirs:?}"
737 );
738 assert_eq!(dirs.get(idx), Some(&claude), "claude must come after local");
739 idx += 1;
740 assert_eq!(
741 dirs.get(idx),
742 Some(&cursor),
743 "cursor must come after claude"
744 );
745 }
746
747 #[test]
748 fn existing_skill_dirs_orders_globals_agents_then_claude_then_deepseek() {
749 // Pins the precedence among the three global skill roots (#902).
750 // Workspace candidates are tested separately above; here we only
751 // exercise the global ordering at the existing_skill_dirs level
752 // so the assertion is host-independent.
753 let tmpdir = TempDir::new().unwrap();
754 let agents_global = tmpdir.path().join(".agents").join("skills");
755 let claude_global = tmpdir.path().join(".claude").join("skills");
756 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
757 std::fs::create_dir_all(&agents_global).unwrap();
758 std::fs::create_dir_all(&claude_global).unwrap();
759 std::fs::create_dir_all(&deepseek_global).unwrap();
760
761 let dirs = super::existing_skill_dirs(vec![
762 agents_global.clone(),
763 claude_global.clone(),
764 deepseek_global.clone(),
765 ]);
766
767 assert_eq!(dirs, vec![agents_global, claude_global, deepseek_global]);
768 }
769
770 #[test]
771 fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
772 let tmpdir = TempDir::new().unwrap();
773 let agents_global = tmpdir.path().join(".agents").join("skills");
774 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
775 let missing = tmpdir.path().join("missing").join("skills");
776 std::fs::create_dir_all(&agents_global).unwrap();
777 std::fs::create_dir_all(&deepseek_global).unwrap();
778
779 let dirs = super::existing_skill_dirs(vec![
780 missing,
781 agents_global.clone(),
782 deepseek_global.clone(),
783 agents_global.clone(),
784 ]);
785
786 assert_eq!(dirs, vec![agents_global, deepseek_global]);
787 }
788
789 #[test]
790 fn discover_in_workspace_merges_with_first_wins_precedence() {
791 let tmpdir = TempDir::new().unwrap();
792 let workspace = tmpdir.path();
793
794 // Same skill name `shared` in two locations — the higher-precedence
795 // dir's version should win.
796 write_skill(
797 &workspace.join(".agents").join("skills"),
798 "shared",
799 "agents wins",
800 "from agents",
801 );
802 write_skill(
803 &workspace.join(".claude").join("skills"),
804 "shared",
805 "claude loses",
806 "from claude",
807 );
808 // Unique skill in claude — should still be discovered.
809 write_skill(
810 &workspace.join(".claude").join("skills"),
811 "unique-claude",
812 "only here",
813 "claude-only",
814 );
815
816 let registry = super::discover_in_workspace(workspace);
817 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
818 assert!(
819 names.contains(&"shared"),
820 "shared must be present: {names:?}"
821 );
822 assert!(names.contains(&"unique-claude"));
823
824 let shared = registry.get("shared").expect("shared present");
825 assert_eq!(
826 shared.description, "agents wins",
827 "first-wins precedence should keep .agents/skills version"
828 );
829 assert!(
830 shared.path.starts_with(workspace.join(".agents")),
831 "shared.path should be from .agents/skills, got {:?}",
832 shared.path
833 );
834 assert!(
835 registry
836 .warnings()
837 .iter()
838 .any(|warning| warning.contains("shared") && warning.contains("shadowed by")),
839 "duplicate shadowing should warn, got {:?}",
840 registry.warnings()
841 );
842 }
843
844 #[test]
845 fn same_root_slug_collision_warns_and_keeps_one() {
846 let tmpdir = TempDir::new().unwrap();
847 let root = tmpdir.path();
848 // Two sibling directories under one root whose frontmatter names
849 // slugify to the same command name ("my-skill"). Only one can be
850 // reachable by name; the other must warn rather than silently coexist
851 // as an unreachable duplicate (#3919 same-root gap).
852 write_skill(root, "My Skill", "first", "body");
853 write_skill(root, "my_skill", "second", "body");
854
855 let registry = super::SkillRegistry::discover(root);
856 let claimants = registry
857 .list()
858 .iter()
859 .filter(|s| s.name == "my-skill")
860 .count();
861 assert_eq!(
862 claimants,
863 1,
864 "exactly one skill should claim `my-skill`, got {:?}",
865 registry.list().iter().map(|s| &s.name).collect::<Vec<_>>()
866 );
867 assert!(
868 registry
869 .warnings()
870 .iter()
871 .any(|w| w.contains("my-skill") && w.contains("shadowed by")),
872 "same-root slug collision should warn, got {:?}",
873 registry.warnings()
874 );
875 }
876
877 #[test]
878 fn discover_in_workspace_pulls_skills_from_opencode_dir() {
879 let tmpdir = TempDir::new().unwrap();
880 let workspace = tmpdir.path();
881 write_skill(
882 &workspace.join(".opencode").join("skills"),
883 "opencode-only",
884 "for interop",
885 "body",
886 );
887
888 let registry = super::discover_in_workspace(workspace);
889 assert!(
890 registry.get("opencode-only").is_some(),
891 ".opencode/skills must be scanned (#432)"
892 );
893 }
894
895 #[test]
896 fn discover_in_workspace_pulls_skills_from_cursor_dir() {
897 let tmpdir = TempDir::new().unwrap();
898 let workspace = tmpdir.path();
899 write_skill(
900 &workspace.join(".cursor").join("skills"),
901 "cursor-only",
902 "for cursor interop",
903 "body",
904 );
905
906 let registry = super::discover_in_workspace(workspace);
907 assert!(
908 registry.get("cursor-only").is_some(),
909 ".cursor/skills must be scanned"
910 );
911 }
912
913 #[test]
914 fn discover_accepts_plain_markdown_heading_without_frontmatter() {
915 let tmpdir = TempDir::new().unwrap();
916 let skill_dir = tmpdir.path().join("plain-skill");
917 std::fs::create_dir_all(&skill_dir).unwrap();
918 std::fs::write(
919 skill_dir.join("SKILL.md"),
920 "# Plain Skill\n\nUse this skill without YAML frontmatter.\n",
921 )
922 .unwrap();
923
924 let registry = super::SkillRegistry::discover(tmpdir.path());
925 let skill = registry.get("plain-skill").expect("plain skill parsed");
926 assert_eq!(skill.name, "plain-skill");
927 assert_eq!(skill.description, "");
928 assert!(skill.body.contains("Use this skill"));
929 assert!(
930 registry
931 .warnings()
932 .iter()
933 .any(|warning| warning.contains("using `plain-skill` instead")),
934 "expected slug warning, got {:?}",
935 registry.warnings()
936 );
937 }
938
939 #[test]
940 fn discover_slugifies_invalid_frontmatter_names_and_lookup_normalizes() {
941 let tmpdir = TempDir::new().unwrap();
942 let root = tmpdir.path().join("skills");
943 let skill_dir = root.join("my-skill");
944 std::fs::create_dir_all(&skill_dir).unwrap();
945 std::fs::write(
946 skill_dir.join("SKILL.md"),
947 "---\nname: My Skill\ndescription: spaced name\n---\nbody",
948 )
949 .unwrap();
950
951 let registry = super::SkillRegistry::discover(&root);
952 let skill = registry.get(" MY skill ").expect("normalized lookup");
953 assert_eq!(skill.name, "my-skill");
954 assert!(
955 registry
956 .warnings()
957 .iter()
958 .any(|warning| warning.contains("My Skill")
959 && warning.contains("using `my-skill` instead")),
960 "expected invalid-name warning, got {:?}",
961 registry.warnings()
962 );
963 }
964
965 #[test]
966 fn discover_warns_for_plain_markdown_without_heading() {
967 let tmpdir = TempDir::new().unwrap();
968 let skill_dir = tmpdir.path().join("plain-skill");
969 std::fs::create_dir_all(&skill_dir).unwrap();
970 std::fs::write(
971 skill_dir.join("SKILL.md"),
972 "Use this skill without a heading or YAML frontmatter.\n",
973 )
974 .unwrap();
975
976 let registry = super::SkillRegistry::discover(tmpdir.path());
977 assert!(registry.is_empty());
978 assert!(
979 registry
980 .warnings()
981 .iter()
982 .any(|warning| warning.contains("no `# Heading` found")),
983 "expected missing-heading warning, got {:?}",
984 registry.warnings()
985 );
986 }
987
988 #[test]
989 fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
990 let tmpdir = TempDir::new().unwrap();
991 let workspace = tmpdir.path();
992 write_skill(
993 &workspace.join(".claude").join("skills"),
994 "from-claude",
995 "claude-style skill",
996 "body",
997 );
998 let rendered =
999 super::render_available_skills_context_for_workspace(workspace).expect("non-empty");
1000 assert!(rendered.contains("from-claude"));
1001 }
1002
1003 #[test]
1004 fn codewhale_only_mode_ignores_cross_tool_skill_dirs() {
1005 let tmpdir = TempDir::new().unwrap();
1006 let workspace = tmpdir.path().join("workspace");
1007 let home = tmpdir.path().join("home");
1008 let configured_dir = home.join(".codewhale").join("skills");
1009 std::fs::create_dir_all(&workspace).unwrap();
1010 write_skill(
1011 &workspace.join(".claude").join("skills"),
1012 "from-claude",
1013 "claude-style skill",
1014 "body",
1015 );
1016 write_skill(
1017 &workspace.join(".codewhale").join("skills"),
1018 "from-codewhale",
1019 "codewhale skill",
1020 "body",
1021 );
1022 write_skill(
1023 &home.join(".agents").join("skills"),
1024 "from-agents",
1025 "agents skill",
1026 "body",
1027 );
1028 write_skill(
1029 &configured_dir,
1030 "configured-codewhale",
1031 "configured skill",
1032 "body",
1033 );
1034
1035 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
1036 &workspace,
1037 &configured_dir,
1038 Some(&home),
1039 super::SkillDiscoveryMode::CodeWhaleOnly,
1040 );
1041 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1042
1043 assert!(names.contains(&"from-codewhale"));
1044 assert!(names.contains(&"configured-codewhale"));
1045 assert!(
1046 !names.contains(&"from-claude") && !names.contains(&"from-agents"),
1047 "CodeWhale-only mode must not import cross-tool skills: {names:?}"
1048 );
1049 }
1050
1051 #[test]
1052 fn codewhale_only_mode_still_honors_explicit_configured_dir() {
1053 let tmpdir = TempDir::new().unwrap();
1054 let workspace = tmpdir.path().join("workspace");
1055 let home = tmpdir.path().join("home");
1056 let configured_dir = tmpdir.path().join("my-skills");
1057 std::fs::create_dir_all(&workspace).unwrap();
1058 write_skill(
1059 &configured_dir,
1060 "configured-skill",
1061 "explicit configured skill",
1062 "body",
1063 );
1064
1065 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
1066 &workspace,
1067 &configured_dir,
1068 Some(&home),
1069 super::SkillDiscoveryMode::CodeWhaleOnly,
1070 );
1071 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1072
1073 assert_eq!(names, vec!["configured-skill"]);
1074 }
1075
1076 #[test]
1077 fn codewhale_only_mode_rejects_workspace_codewhale_symlink_escape() {
1078 let tmpdir = TempDir::new().unwrap();
1079 let workspace = tmpdir.path().join("workspace");
1080 let home = tmpdir.path().join("home");
1081 let escape_target = tmpdir.path().join("escape-target");
1082 std::fs::create_dir_all(workspace.join(".codewhale")).unwrap();
1083 write_skill(&escape_target, "escaped-skill", "escaped skill", "body");
1084
1085 let link_path = workspace.join(".codewhale").join("skills");
1086 if let Err(err) = create_dir_symlink(&escape_target, &link_path) {
1087 eprintln!("skipping symlink escape assertion: {err}");
1088 return;
1089 }
1090
1091 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
1092 &workspace,
1093 &tmpdir.path().join("missing-configured-skills"),
1094 Some(&home),
1095 super::SkillDiscoveryMode::CodeWhaleOnly,
1096 );
1097
1098 assert!(
1099 registry.get("escaped-skill").is_none(),
1100 "CodeWhale-only mode must not follow workspace .codewhale/skills outside the workspace"
1101 );
1102 }
1103
1104 #[test]
1105 fn discover_for_workspace_and_dir_merges_workspace_and_configured_sources() {
1106 let tmpdir = TempDir::new().unwrap();
1107 let workspace = tmpdir.path().join("workspace");
1108 let home = tmpdir.path().join("home");
1109 let configured_dir = tmpdir.path().join("configured-skills");
1110 std::fs::create_dir_all(&workspace).unwrap();
1111 write_skill(
1112 &workspace.join(".claude").join("skills"),
1113 "workspace-skill",
1114 "workspace visible skill",
1115 "body",
1116 );
1117 write_skill(
1118 &configured_dir,
1119 "configured-skill",
1120 "configured visible skill",
1121 "body",
1122 );
1123
1124 let registry =
1125 super::discover_for_workspace_and_dir_with_home(&workspace, &configured_dir, Some(&home));
1126 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1127
1128 assert!(names.contains(&"workspace-skill"));
1129 assert!(names.contains(&"configured-skill"));
1130 }
1131
1132 #[test]
1133 fn explicit_configured_skills_dir_precedes_global_defaults() {
1134 let tmpdir = TempDir::new().unwrap();
1135 let workspace = tmpdir.path().join("workspace");
1136 let home = tmpdir.path().join("home");
1137 let configured_dir = tmpdir.path().join("configured-skills");
1138 std::fs::create_dir_all(&workspace).unwrap();
1139 write_skill(
1140 &home.join(".agents").join("skills"),
1141 "shared-skill",
1142 "global skill",
1143 "global body",
1144 );
1145 write_skill(
1146 &configured_dir,
1147 "shared-skill",
1148 "configured skill",
1149 "configured body",
1150 );
1151
1152 let registry =
1153 super::discover_for_workspace_and_dir_with_home(&workspace, &configured_dir, Some(&home));
1154 let skill = registry
1155 .get("shared-skill")
1156 .expect("shared skill discovered");
1157
1158 assert_eq!(skill.description, "configured skill");
1159 }
1160
1161 /// Regression for the GitHub issue where users organize skills under
1162 /// vendor / category subdirectories (e.g. cloned skill repos that
1163 /// bundle several skills together). The old single-level `read_dir`
1164 /// only ever surfaced `<root>/<skill>/SKILL.md` and silently ignored
1165 /// `<root>/<vendor>/<skill>/SKILL.md`.
1166 #[test]
1167 fn discover_finds_skills_nested_under_vendor_subdirectory() {
1168 let tmpdir = TempDir::new().unwrap();
1169 let root = tmpdir.path().join("skills");
1170
1171 // Two-level nesting: `<root>/<vendor>/<skill>/SKILL.md`. This
1172 // matches the `clawhub-skills/clawhub/SKILL.md` layout in the
1173 // bug report.
1174 write_skill(
1175 &root.join("clawhub-skills"),
1176 "clawhub",
1177 "claw search",
1178 "body",
1179 );
1180 write_skill(
1181 &root.join("clawhub-skills"),
1182 "github",
1183 "github helpers",
1184 "body",
1185 );
1186 // Three-level nesting: `<root>/<org>/<repo>/<skill>/SKILL.md`.
1187 write_skill(
1188 &root.join("pasky").join("chrome-cdp-skill"),
1189 "chrome-cdp",
1190 "browser automation",
1191 "body",
1192 );
1193 // Mixed-depth: a flat skill alongside the nested layout still
1194 // works (this is what the bundled `skill-creator` looks like).
1195 write_skill(&root, "skill-creator", "make skills", "body");
1196
1197 let registry = super::SkillRegistry::discover(&root);
1198 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1199 assert!(names.contains(&"clawhub"), "vendor/skill missed: {names:?}");
1200 assert!(names.contains(&"github"), "vendor/skill missed: {names:?}");
1201 assert!(
1202 names.contains(&"chrome-cdp"),
1203 "deeply-nested skill missed: {names:?}"
1204 );
1205 assert!(
1206 names.contains(&"skill-creator"),
1207 "flat top-level skill must still load: {names:?}"
1208 );
1209 assert!(
1210 registry.warnings().is_empty(),
1211 "well-formed nested layout should not warn: {:?}",
1212 registry.warnings()
1213 );
1214 }
1215
1216 #[cfg(any(unix, windows))]
1217 #[test]
1218 fn discover_follows_symlinked_skill_directories() {
1219 let tmpdir = TempDir::new().unwrap();
1220 let source_root = tmpdir.path().join("claude-skills");
1221 let skills_root = tmpdir.path().join(".deepseek").join("skills");
1222 write_skill(&source_root, "agent-browser", "browser automation", "body");
1223 std::fs::create_dir_all(&skills_root).unwrap();
1224 let link_path = skills_root.join("agent-browser");
1225
1226 if let Err(err) = create_dir_symlink(&source_root.join("agent-browser"), &link_path) {
1227 eprintln!("skipping symlink discovery assertion: {err}");
1228 return;
1229 }
1230
1231 let registry = super::SkillRegistry::discover(&skills_root);
1232 let skill = registry
1233 .get("agent-browser")
1234 .expect("symlinked skill directory should be discovered");
1235 assert_eq!(skill.description, "browser automation");
1236 assert_eq!(skill.path, link_path.join("SKILL.md"));
1237 }
1238
1239 #[cfg(any(unix, windows))]
1240 #[test]
1241 fn discover_dedupes_symlink_cycles_by_canonical_directory() {
1242 let tmpdir = TempDir::new().unwrap();
1243 let root = tmpdir.path().join("skills");
1244 write_skill(&root, "real-skill", "ok", "body");
1245 let loop_parent = root.join("vendor");
1246 std::fs::create_dir_all(&loop_parent).unwrap();
1247
1248 if let Err(err) = create_dir_symlink(&root, &loop_parent.join("loop")) {
1249 eprintln!("skipping symlink cycle assertion: {err}");
1250 return;
1251 }
1252
1253 let registry = super::SkillRegistry::discover(&root);
1254 let matches = registry
1255 .list()
1256 .iter()
1257 .filter(|skill| skill.name == "real-skill")
1258 .count();
1259 assert_eq!(
1260 matches, 1,
1261 "symlink cycle should not rediscover the same canonical skill directory"
1262 );
1263 }
1264
1265 /// Once a directory is identified as a skill (has `SKILL.md`), the
1266 /// walker must NOT descend into it: any nested `SKILL.md` would be
1267 /// a fixture / example bundled with the parent skill, not a
1268 /// separately-installable one. This mirrors the contract that
1269 /// `tools::skill::collect_companion_files` already documents
1270 /// ("nested directory — skipped").
1271 #[test]
1272 fn discover_does_not_descend_into_a_skill_directory() {
1273 let tmpdir = TempDir::new().unwrap();
1274 let root = tmpdir.path().join("skills");
1275
1276 // Parent skill: <root>/parent/SKILL.md.
1277 write_skill(&root, "parent", "outer skill", "outer body");
1278 // Fixture bundled inside the parent's directory:
1279 // <root>/parent/examples/inner-fixture/SKILL.md. The walker
1280 // must NOT descend into <root>/parent/ after finding its
1281 // SKILL.md, so `inner-fixture` must not be loaded.
1282 write_skill(
1283 &root.join("parent").join("examples"),
1284 "inner-fixture",
1285 "should not load",
1286 "fixture body",
1287 );
1288
1289 let registry = super::SkillRegistry::discover(&root);
1290 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1291 assert!(names.contains(&"parent"));
1292 assert!(
1293 !names.contains(&"inner-fixture"),
1294 "nested SKILL.md inside an existing skill must be ignored: {names:?}"
1295 );
1296 }
1297
1298 /// Hidden subdirectories below the root (e.g. `.git`, `.cache`) must
1299 /// be skipped so a `skills_dir` that lives inside a checked-out repo
1300 /// doesn't accidentally load random `SKILL.md`-named fixtures from
1301 /// the VCS metadata. The root itself is exempt — the user explicitly
1302 /// pointed `skills_dir` at it.
1303 #[test]
1304 fn discover_skips_hidden_subdirectories_below_root() {
1305 let tmpdir = TempDir::new().unwrap();
1306 let root = tmpdir.path().join("skills");
1307
1308 write_skill(&root, "real-skill", "ok", "body");
1309 // A `<root>/.git/<junk>/SKILL.md` lookalike that mustn't load.
1310 // `.git` is a direct child of the user-provided root (depth 0
1311 // of the walk), which is exactly the case the old `depth > 0`
1312 // gate missed.
1313 write_skill(&root.join(".git"), "vcs-noise", "should not load", "body");
1314
1315 let registry = super::SkillRegistry::discover(&root);
1316 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1317 assert!(names.contains(&"real-skill"));
1318 assert!(
1319 !names.contains(&"vcs-noise"),
1320 "skills under hidden subdirs must be skipped: {names:?}"
1321 );
1322 }
1323
1324 /// The user explicitly chooses the root, so even a hidden path like
1325 /// `~/.agents/skills` (the layout in the bug report) must work.
1326 #[test]
1327 fn discover_honors_a_hidden_root_directory() {
1328 let tmpdir = TempDir::new().unwrap();
1329 let root = tmpdir.path().join(".agents").join("skills");
1330
1331 // Matches the bug report: skills_dir = "~/.agents/skills"
1332 // with a skill nested at <root>/custom-skills/git-conventions/SKILL.md.
1333 write_skill(
1334 &root.join("custom-skills"),
1335 "git-conventions",
1336 "conventions",
1337 "body",
1338 );
1339
1340 let registry = super::SkillRegistry::discover(&root);
1341 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1342 assert!(
1343 names.contains(&"git-conventions"),
1344 "hidden root must still be walked: {names:?}"
1345 );
1346 }
1347
1348 /// Exercises the local/global skill inventory independent of terminal layout.
1349 /// scenario without the PTY harness: a workspace-level skill in
1350 /// `.agents/skills/` and a global skill in `~/.codewhale/skills/`
1351 /// must both be discoverable.
1352 #[test]
1353 fn discover_finds_both_workspace_and_global_skills() {
1354 let tmpdir = TempDir::new().unwrap();
1355 let workspace = tmpdir.path().join("workspace");
1356 let home = tmpdir.path().join("home");
1357 std::fs::create_dir_all(&workspace).unwrap();
1358
1359 write_skill(
1360 &workspace.join(".agents").join("skills"),
1361 "workspace-beta",
1362 "Workspace beta skill",
1363 "body",
1364 );
1365 write_skill(
1366 &home.join(".codewhale").join("skills"),
1367 "global-alpha",
1368 "Global alpha skill",
1369 "body",
1370 );
1371
1372 let skills_dir = workspace.join(".agents").join("skills");
1373 let registry =
1374 super::discover_for_workspace_and_dir_with_home(&workspace, &skills_dir, Some(&home));
1375
1376 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1377 assert!(
1378 names.contains(&"workspace-beta"),
1379 "workspace-beta from .agents/skills must be discovered: {names:?}",
1380 );
1381 assert!(
1382 names.contains(&"global-alpha"),
1383 "global-alpha from ~/.codewhale/skills must be discovered: {names:?}",
1384 );
1385 }
1386
1387 // ── Block scalar parsing (YAML `>` and `|`) ────────────────
1388
1389 /// `>` (folded block scalar): subsequent indented lines are folded
1390 /// into a single line joined by spaces.
1391 #[test]
1392 fn parse_skill_folded_block_scalar() {
1393 let tmpdir = TempDir::new().unwrap();
1394 create_skill_dir(
1395 &tmpdir,
1396 "folded-skill",
1397 "---\nname: folded-skill\ndescription: >\n line one chinese\n line two chinese\n---\nbody",
1398 );
1399 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1400 .expect("skill context");
1401 assert!(
1402 rendered.contains("line one chinese line two chinese"),
1403 "folded block scalar should join lines with space, got:\n{rendered}"
1404 );
1405 }
1406
1407 /// `|` (literal block scalar): subsequent indented lines preserve
1408 /// newlines.
1409 #[test]
1410 fn parse_skill_literal_block_scalar() {
1411 let tmpdir = TempDir::new().unwrap();
1412 create_skill_dir(
1413 &tmpdir,
1414 "literal-skill",
1415 "---\nname: literal-skill\ndescription: |\n line one\n line two\n---\nbody",
1416 );
1417 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1418 .expect("skill context");
1419 // `truncate_for_prompt` collapses whitespace, so the newlines
1420 // become spaces. The key assertion is that the content is
1421 // captured (not just `|`).
1422 assert!(
1423 rendered.contains("line one line two"),
1424 "literal block scalar should preserve content, got:\n{rendered}"
1425 );
1426 }
1427
1428 /// `>-` (folded with strip chomping): same as `>` but trailing
1429 /// whitespace is stripped.
1430 #[test]
1431 fn parse_skill_folded_strip_block_scalar() {
1432 let tmpdir = TempDir::new().unwrap();
1433 create_skill_dir(
1434 &tmpdir,
1435 "strip-skill",
1436 "---\nname: strip-skill\ndescription: >-\n alpha\n beta\n\n---\nbody",
1437 );
1438 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1439 .expect("skill context");
1440 assert!(
1441 rendered.contains("alpha beta"),
1442 "strip-chomped folded block should join lines, got:\n{rendered}"
1443 );
1444 }
1445
1446 /// Regression: a single-line description (no block scalar) must
1447 /// still parse correctly after the parser rewrite.
1448 #[test]
1449 fn parse_skill_single_line_description_still_works() {
1450 let tmpdir = TempDir::new().unwrap();
1451 create_skill_dir(
1452 &tmpdir,
1453 "plain-skill",
1454 "---\nname: plain-skill\ndescription: A simple description\n---\nbody",
1455 );
1456 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1457 .expect("skill context");
1458 assert!(
1459 rendered.contains("- plain-skill: A simple description"),
1460 "single-line description should still work, got:\n{rendered}"
1461 );
1462 }
1463
1464 /// Direct unit test on the parsed Skill struct (not through rendering)
1465 /// so we assert the exact description value.
1466 #[test]
1467 fn parse_skill_direct_folded_result() {
1468 let skill = super::SkillRegistry::parse_skill(
1469 std::path::Path::new(""),
1470 "---\nname: test\ndescription: >\n this is a test\n used to verify parsing\n---\nbody",
1471 )
1472 .expect("should parse");
1473 assert_eq!(skill.name, "test");
1474 assert_eq!(skill.description, "this is a test used to verify parsing");
1475 }
1476
1477 // ── Chomping behaviour ────────────────────────────────────
1478
1479 /// `>-` (strip): trailing empty lines are stripped. Paragraph
1480 /// breaks (empty line between text lines) are still folded to a
1481 /// single space in a block-scalar join (no newline — the simplified
1482 /// parser treats intra-block empty lines as paragraph breaks that
1483 /// become a single space in the folded output).
1484 #[test]
1485 fn parse_skill_strip_chomp_strips_trailing_empties() {
1486 let skill = super::SkillRegistry::parse_skill(
1487 std::path::Path::new(""),
1488 "---\nname: s\ndescription: >-\n hello\n world\n\n\n---\nbody",
1489 )
1490 .expect("should parse");
1491 // Trailing empty lines stripped: no whitespace at end, just folded text.
1492 assert_eq!(skill.description, "hello world");
1493 }
1494
1495 /// `>+` (keep): trailing empty lines are preserved. Each trailing
1496 /// empty line in the block becomes a newline in the description.
1497 #[test]
1498 fn parse_skill_keep_chomp_preserves_trailing_empties() {
1499 let skill = super::SkillRegistry::parse_skill(
1500 std::path::Path::new(""),
1501 "---\nname: s\ndescription: >+\n hello\n world\n\n\n---\nbody",
1502 )
1503 .expect("should parse");
1504 // Two trailing empty lines should become two newlines.
1505 assert_eq!(skill.description, "hello world\n\n");
1506 }
1507
1508 /// `>` (clip): trailing empty lines exceeding one are clipped.
1509 /// The result should have at most one trailing newline.
1510 #[test]
1511 fn parse_skill_clip_chomp_clips_excess_trailing_empties() {
1512 let skill = super::SkillRegistry::parse_skill(
1513 std::path::Path::new(""),
1514 "---\nname: s\ndescription: >\n hello\n world\n\n\n---\nbody",
1515 )
1516 .expect("should parse");
1517 // clip: 3 trailing empty lines → at most 1 trailing newline.
1518 assert_eq!(skill.description, "hello world\n");
1519 }
1520
1521 /// `>` with no trailing empty lines: clip should not add anything.
1522 #[test]
1523 fn parse_skill_clip_chomp_no_trailing_empties() {
1524 let skill = super::SkillRegistry::parse_skill(
1525 std::path::Path::new(""),
1526 "---\nname: s\ndescription: >\n hello\n world\n---\nbody",
1527 )
1528 .expect("should parse");
1529 assert_eq!(skill.description, "hello world");
1530 }
1531
1532 /// `>` with exactly one trailing empty line: clip keeps it.
1533 #[test]
1534 fn parse_skill_clip_chomp_one_trailing_empty() {
1535 let skill = super::SkillRegistry::parse_skill(
1536 std::path::Path::new(""),
1537 "---\nname: s\ndescription: >\n hello\n world\n\n---\nbody",
1538 )
1539 .expect("should parse");
1540 assert_eq!(skill.description, "hello world\n");
1541 }
1542
1543 /// `>-` strip vs `>+` keep: same block content, different
1544 /// trailing newline handling.
1545 #[test]
1546 fn parse_skill_strip_vs_keep_trailing() {
1547 let content = "---\nname: s\ndescription: >{}\n hello\n world\n\n\n---\nbody";
1548 let strip_skill =
1549 super::SkillRegistry::parse_skill(std::path::Path::new(""), &content.replace("{}", "-"))
1550 .expect("strip parse");
1551 let keep_skill =
1552 super::SkillRegistry::parse_skill(std::path::Path::new(""), &content.replace("{}", "+"))
1553 .expect("keep parse");
1554 // strip drops trailing empties; keep preserves them.
1555 assert_eq!(strip_skill.description, "hello world");
1556 assert_eq!(keep_skill.description, "hello world\n\n");
1557 }
1558
1559 /// `|-` literal strip: trailing newlines are stripped.
1560 #[test]
1561 fn parse_skill_literal_strip_strips_trailing_newlines() {
1562 let skill = super::SkillRegistry::parse_skill(
1563 std::path::Path::new(""),
1564 "---\nname: s\ndescription: |-\n line one\n line two\n\n\n---\nbody",
1565 )
1566 .expect("should parse");
1567 // literal: newlines preserved between non-empty lines.
1568 // strip: trailing empty lines removed.
1569 assert_eq!(skill.description, "line one\nline two");
1570 }
1571
1572 /// `|+` literal keep: trailing newlines are preserved.
1573 #[test]
1574 fn parse_skill_literal_keep_preserves_trailing_newlines() {
1575 let skill = super::SkillRegistry::parse_skill(
1576 std::path::Path::new(""),
1577 "---\nname: s\ndescription: |+\n line one\n line two\n\n\n---\nbody",
1578 )
1579 .expect("should parse");
1580 // literal: newlines preserved between non-empty lines.
1581 // keep: trailing empty lines are preserved as newlines.
1582 assert_eq!(skill.description, "line one\nline two\n\n");
1583 }
1584
1585 /// Nested relative indentation is preserved in literal (`|`) block
1586 /// scalars: only the content-level indent (from the first non-empty
1587 /// line) is stripped, and any deeper indent stays as-is.
1588 #[test]
1589 fn parse_skill_literal_preserves_relative_indentation() {
1590 let skill = super::SkillRegistry::parse_skill(
1591 std::path::Path::new(""),
1592 "---\nname: s\ndescription: |\n Usage:\n $ deepseek --model auto\n $ deepseek doctor\n---\nbody",
1593 )
1594 .expect("should parse");
1595 assert_eq!(
1596 skill.description,
1597 "Usage:\n $ deepseek --model auto\n $ deepseek doctor"
1598 );
1599 }
1600
1601 /// Folded (`>`) block scalars also preserve relative indentation
1602 /// within lines (the extra spaces survive the fold).
1603 #[test]
1604 fn parse_skill_folded_preserves_relative_indentation() {
1605 let skill = super::SkillRegistry::parse_skill(
1606 std::path::Path::new(""),
1607 "---\nname: s\ndescription: >\n See also:\n the config file\n the env var\n---\nbody",
1608 )
1609 .expect("should parse");
1610 assert_eq!(
1611 skill.description,
1612 "See also: the config file the env var"
1613 );
1614 }
1615
1616 #[test]
1617 fn plugin_skills_are_qualified_and_denied_until_trusted_and_enabled() {
1618 let tmp = TempDir::new().unwrap();
1619 let plugin_root = tmp.path().join("plugins/demo");
1620 std::fs::create_dir_all(plugin_root.join("skills/hello-world")).unwrap();
1621 std::fs::write(
1622 plugin_root.join("plugin.toml"),
1623 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n",
1624 )
1625 .unwrap();
1626 std::fs::write(
1627 plugin_root.join("skills/hello-world/SKILL.md"),
1628 "---\nname: hello-world\ndescription: hello\n---\nbody\n",
1629 )
1630 .unwrap();
1631 let config = crate::plugins::discovery::DiscoveryConfig {
1632 workspace: tmp.path().join("workspace"),
1633 user_plugins_dir: tmp.path().join("plugins"),
1634 workspace_plugins_dir: tmp.path().join("workspace-plugins"),
1635 builtin_plugin_dirs: Vec::new(),
1636 state_path: tmp.path().join("plugin-state/state.json"),
1637 };
1638 let mut plugins = crate::plugins::discovery::discover_with_config(&config);
1639
1640 let mut registry = super::SkillRegistry::default();
1641 super::merge_active_plugin_skills(&mut registry, &plugins);
1642 assert!(registry.get("demo:hello-world").is_none());
1643
1644 plugins.trust("demo").unwrap();
1645 super::merge_active_plugin_skills(&mut registry, &plugins);
1646 assert!(registry.get("demo:hello-world").is_none());
1647
1648 plugins.enable("demo").unwrap();
1649 super::merge_active_plugin_skills(&mut registry, &plugins);
1650 let skill = registry
1651 .get("Demo:Hello_World")
1652 .expect("qualified lookup should normalize each namespace segment");
1653 assert_eq!(skill.name, "demo:hello-world");
1654 assert!(matches!(
1655 skill.source,
1656 super::SkillSource::Plugin { ref plugin_name, .. } if plugin_name == "demo"
1657 ));
1658 let rendered = super::render_skills_block(&registry, "en", tmp.path()).unwrap();
1659 assert!(rendered.contains("reviewed plugin snapshot: demo"));
1660 assert!(rendered.contains("use load_skill"));
1661 assert!(
1662 rendered.contains("hello"),
1663 "plugin skill descriptions must reach the model catalogue like native skills: {rendered}"
1664 );
1665 assert!(
1666 !rendered.contains(&plugin_root.display().to_string()),
1667 "model prompt must not expose mutable plugin files after snapshot review"
1668 );
1669
1670 let mut fail_closed_input = registry.clone();
1671 fail_closed_input.skills.push(super::Skill {
1672 name: "native-recovery".to_string(),
1673 description: "native recovery skill".to_string(),
1674 localized_descriptions: std::collections::HashMap::new(),
1675 invocation: super::SkillInvocation::ModelAndUser,
1676 aliases: Vec::new(),
1677 body: "recovery".to_string(),
1678 path: tmp.path().join("native/SKILL.md"),
1679 source: super::SkillSource::Native,
1680 });
1681 let fail_closed = fail_closed_input.into_enabled_with_state(Err(anyhow::anyhow!(
1682 "injected activation-state read failure"
1683 )));
1684 assert!(fail_closed.get("native-recovery").is_some());
1685 assert!(
1686 fail_closed.get("demo:hello-world").is_none(),
1687 "reviewed plugin Skills must not fail open when activation state is unreadable"
1688 );
1689 assert!(
1690 fail_closed
1691 .warnings()
1692 .iter()
1693 .any(|warning| warning.contains("hidden fail-closed"))
1694 );
1695
1696 std::fs::remove_file(config.state_path.with_file_name("state.json.lock")).unwrap();
1697 let mut denied = super::SkillRegistry::default();
1698 super::merge_active_plugin_skills(&mut denied, &plugins);
1699 assert!(
1700 denied.get("demo:hello-world").is_none(),
1701 "a missing authority lock must remove plugin instructions from the prompt catalogue"
1702 );
1703 }
1704
1705 // --- #3921 merged discovery cache -----------------------------------------
1706
1707 fn discovery_delta_since(earlier: super::SkillDiscoveryMetrics) -> super::SkillDiscoveryMetrics {
1708 super::discovery_metrics_snapshot().delta_since(earlier)
1709 }
1710
1711 #[test]
1712 fn cached_discovery_reuses_unchanged_registry_without_rewalking() {
1713 super::clear_skill_discovery_cache();
1714 let tmpdir = TempDir::new().unwrap();
1715 let skills_root = tmpdir.path().join("skills");
1716 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1717 let dirs = vec![skills_root];
1718
1719 super::reset_discovery_metrics();
1720 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1721 let walked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
1722 let second = super::discover_from_directories_with_plugins(dirs, None);
1723 let rewalked = discovery_delta_since(walked);
1724
1725 assert_eq!(walked.root_discovery_calls, 1);
1726 assert_eq!(rewalked, super::SkillDiscoveryMetrics::default());
1727 assert_eq!(first.len(), second.len());
1728 assert_eq!(first.list()[0].description, second.list()[0].description);
1729 }
1730
1731 #[test]
1732 fn cached_discovery_picks_up_added_skill_on_next_call() {
1733 super::clear_skill_discovery_cache();
1734 let tmpdir = TempDir::new().unwrap();
1735 let skills_root = tmpdir.path().join("skills");
1736 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1737 let dirs = vec![skills_root.clone()];
1738
1739 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1740 assert_eq!(first.len(), 1);
1741
1742 write_skill(&skills_root, "added", "A later skill", "More");
1743 std::thread::sleep(std::time::Duration::from_millis(10));
1744 let second = super::discover_from_directories_with_plugins(dirs, None);
1745 assert_eq!(second.len(), 2);
1746 assert!(second.get("added").is_some());
1747 }
1748
1749 #[test]
1750 fn cached_discovery_picks_up_skill_content_edits() {
1751 super::clear_skill_discovery_cache();
1752 let tmpdir = TempDir::new().unwrap();
1753 let skills_root = tmpdir.path().join("skills");
1754 write_skill(&skills_root, "demo", "Original description", "Instructions");
1755 let dirs = vec![skills_root.clone()];
1756
1757 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1758 assert_eq!(first.list()[0].description, "Original description");
1759
1760 write_skill(&skills_root, "demo", "Edited description", "Instructions");
1761 std::thread::sleep(std::time::Duration::from_millis(10));
1762 let second = super::discover_from_directories_with_plugins(dirs, None);
1763 assert_eq!(second.list()[0].description, "Edited description");
1764 }
1765
1766 #[test]
1767 fn cached_discovery_drops_removed_skills() {
1768 super::clear_skill_discovery_cache();
1769 let tmpdir = TempDir::new().unwrap();
1770 let skills_root = tmpdir.path().join("skills");
1771 write_skill(&skills_root, "keep", "Keep me", "Instructions");
1772 write_skill(&skills_root, "drop", "Drop me", "Instructions");
1773 let dirs = vec![skills_root.clone()];
1774
1775 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1776 assert_eq!(first.len(), 2);
1777
1778 std::fs::remove_dir_all(skills_root.join("drop")).unwrap();
1779 std::thread::sleep(std::time::Duration::from_millis(10));
1780 let second = super::discover_from_directories_with_plugins(dirs, None);
1781 assert_eq!(second.len(), 1);
1782 assert!(second.get("drop").is_none());
1783 }
1784
1785 #[test]
1786 fn clear_skill_discovery_cache_forces_a_fresh_walk() {
1787 super::clear_skill_discovery_cache();
1788 let tmpdir = TempDir::new().unwrap();
1789 let skills_root = tmpdir.path().join("skills");
1790 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1791 let dirs = vec![skills_root];
1792
1793 let _ = super::discover_from_directories_with_plugins(dirs.clone(), None);
1794 super::clear_skill_discovery_cache();
1795
1796 super::reset_discovery_metrics();
1797 let _ = super::discover_from_directories_with_plugins(dirs, None);
1798 let rewalked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
1799 assert_eq!(rewalked.root_discovery_calls, 1);
1800 }
1801
1802 #[test]
1803 fn workspace_and_dir_entry_point_shares_the_same_cache() {
1804 let _env_lock = crate::test_support::lock_test_env();
1805 super::clear_skill_discovery_cache();
1806 let tmpdir = TempDir::new().unwrap();
1807 let home = tmpdir.path().join("home");
1808 let workspace = tmpdir.path().join("workspace");
1809 let skills_dir = tmpdir.path().join("configured-skills");
1810 std::fs::create_dir_all(&home).unwrap();
1811 std::fs::create_dir_all(&workspace).unwrap();
1812 write_skill(
1813 &skills_dir,
1814 "configured",
1815 "Configured skill",
1816 "Instructions",
1817 );
1818 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1819 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
1820 let _codewhale_home =
1821 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
1822
1823 super::reset_discovery_metrics();
1824 let first = super::discover_for_workspace_and_dir_with_mode_and_plugins(
1825 &workspace,
1826 &skills_dir,
1827 super::SkillDiscoveryMode::Compatible,
1828 None,
1829 );
1830 let walked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
1831 let second = super::discover_for_workspace_and_dir_with_mode_and_plugins(
1832 &workspace,
1833 &skills_dir,
1834 super::SkillDiscoveryMode::Compatible,
1835 None,
1836 );
1837 let rewalked = discovery_delta_since(walked);
1838
1839 assert!(walked.root_discovery_calls >= 1);
1840 assert_eq!(rewalked, super::SkillDiscoveryMetrics::default());
1841 assert_eq!(first.len(), second.len());
1842 assert!(second.get("configured").is_some());
1843 }
1844
1845 #[test]
1846 fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() {
1847 let _env_lock = crate::test_support::lock_test_env();
1848 super::clear_skill_discovery_cache();
1849 let tmpdir = TempDir::new().unwrap();
1850 let home = tmpdir.path().join("home");
1851 let workspace = home.join("workspace");
1852 let skills_dir = home
1853 .join("runtime")
1854 .join("sessions")
1855 .join("session-123")
1856 .join("skills");
1857 std::fs::create_dir_all(&workspace).unwrap();
1858 write_skill(
1859 &workspace.join(".claude").join("skills"),
1860 "workspace-skill",
1861 "Workspace skill",
1862 "Instructions",
1863 );
1864 let configured_skill = skills_dir.join("visual-design");
1865 std::fs::create_dir_all(&configured_skill).unwrap();
1866 std::fs::write(
1867 configured_skill.join("SKILL.md"),
1868 "---\nname: Visual Design\ndescription: Design assets\n---\nInstructions",
1869 )
1870 .unwrap();
1871 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1872 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
1873 let _codewhale_home =
1874 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
1875
1876 let rendered =
1877 super::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
1878 &workspace,
1879 &skills_dir,
1880 super::SkillDiscoveryMode::Compatible,
1881 "en",
1882 None,
1883 super::MAX_AVAILABLE_SKILLS_CHARS,
1884 )
1885 .expect("configured skill context");
1886
1887 assert!(rendered.contains("- visual-design: Design assets\n"));
1888 assert!(rendered.contains(
1889 "- workspace-skill: Workspace skill (file: .claude/skills/workspace-skill/SKILL.md)"
1890 ));
1891 assert!(
1892 rendered
1893 .contains("in <configured-skills>/visual-design/SKILL.md is not a safe command name")
1894 );
1895 assert!(!rendered.contains("session-123"), "{rendered}");
1896 assert!(!rendered.contains(home.to_str().unwrap()), "{rendered}");
1897 }
1898
1899 #[test]
1900 fn default_workspace_skill_prompt_preserves_its_discoverable_path() {
1901 let _env_lock = crate::test_support::lock_test_env();
1902 super::clear_skill_discovery_cache();
1903 let tmpdir = TempDir::new().unwrap();
1904 let home = tmpdir.path().join("home");
1905 let workspace = home.join("workspace");
1906 let skills_dir = workspace.join(".agents").join("skills");
1907 std::fs::create_dir_all(&home).unwrap();
1908 write_skill(
1909 &skills_dir,
1910 "workspace-skill",
1911 "Workspace skill",
1912 "Instructions",
1913 );
1914 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1915 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
1916 let _codewhale_home =
1917 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
1918
1919 let rendered =
1920 super::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
1921 &workspace,
1922 &skills_dir,
1923 super::SkillDiscoveryMode::Compatible,
1924 "en",
1925 None,
1926 super::MAX_AVAILABLE_SKILLS_CHARS,
1927 )
1928 .expect("workspace skill context");
1929
1930 assert!(rendered.contains(
1931 "- workspace-skill: Workspace skill (file: .agents/skills/workspace-skill/SKILL.md)"
1932 ));
1933 }
1934
1935 #[test]
1936 fn global_skill_roots_come_from_the_os_home_only() {
1937 // §2.5: global skill roots resolve under the OS user's home (or an
1938 // explicit `$CODEWHALE_HOME`), never an account/GitHub handle. A wrong
1939 // home once produced `Failed to read /Users/<handle>/.codewhale/skills/
1940 // delegate/SKILL.md`; pin the source so every global root is provably
1941 // under the faked OS home.
1942 let _env_lock = crate::test_support::lock_test_env();
1943 let tmpdir = TempDir::new().unwrap();
1944 let home = tmpdir.path().join("os-home");
1945 let workspace = tmpdir.path().join("workspace");
1946 std::fs::create_dir_all(home.join(".codewhale").join("skills")).unwrap();
1947 std::fs::create_dir_all(&workspace).unwrap();
1948 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1949 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
1950 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
1951
1952 let dirs =
1953 super::skills_directories_for_mode(&workspace, super::SkillDiscoveryMode::Compatible);
1954
1955 assert!(
1956 dirs.iter().any(|dir| dir.starts_with(&home)),
1957 "expected at least one global root under the OS home: {dirs:?}"
1958 );
1959 assert!(
1960 dirs.iter()
1961 .all(|dir| dir.starts_with(&home) || dir.starts_with(&workspace)),
1962 "every runtime root is under the OS home or the workspace: {dirs:?}"
1963 );
1964 }
1965
1965 lines RUST