1use crate::skills::cli_bridge::{CliToolBridge, CliToolConfig, discover_cli_tools};
2use crate::skills::command_skills::{
3 BuiltInCommandSkill, CommandSkillBackend, built_in_command_skill, find_command_skill_by_skill_name,
4 merge_built_in_command_skill_contexts,
5};
6use crate::skills::container_validation::{
7 ContainerSkillsValidator, ContainerValidationReport, ContainerValidationResult,
8};
9use crate::skills::discovery::{DiscoveryConfig, DiscoveryResult, SkillDiscovery};
10use crate::skills::locations::{MAX_DISCOVERY_DEPTH, MAX_DISCOVERY_DIRS, discovery_dir_skipped};
11use crate::skills::model::{SkillErrorInfo, SkillLoadOutcome, SkillMetadata, SkillScope};
12use crate::skills::system::{install_system_skills, system_cache_root_dir};
13use crate::skills::types::{Skill, SkillContext, SkillManifest};
14use crate::tools::error_messages::skill_ops;
15use anyhow::{Context, Result};
16use hashbrown::{HashMap, HashSet};
17use serde::Deserialize;
18use std::collections::VecDeque;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::sync::{OnceLock, RwLock};
22use std::time::{Duration, SystemTime};
23use tracing::{error, warn};
24use vtcode_agent_plugins::LoadedPlugin;
25use vtcode_commons::VtCodePaths;
26
27#[derive(Debug, Clone)]
29pub struct SkillLoaderConfig {
30 pub codex_home: PathBuf,
31 pub cwd: PathBuf,
32 pub project_root: Option<PathBuf>,
33 pub include_bundled_system_skills: bool,
34}
35
36pub struct SkillRoot {
37 pub path: PathBuf,
38 pub scope: SkillScope,
39 pub is_tool_root: bool,
40 pub is_plugin_root: bool,
41}
42
43const LIGHTWEIGHT_SKILL_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
44const LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES: usize = 32;
45
46static LIGHTWEIGHT_SKILL_METADATA_CACHE: OnceLock<
47 RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>>,
48> = OnceLock::new();
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51struct LightweightSkillCacheKey {
52 codex_home: PathBuf,
53 cwd: PathBuf,
54 project_root: Option<PathBuf>,
55 include_bundled_system_skills: bool,
56 home_dir: Option<PathBuf>,
57}
58
59impl LightweightSkillCacheKey {
60 fn new(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> Self {
61 Self {
62 codex_home: normalize_cache_path(&config.codex_home),
63 cwd: normalize_cache_path(&config.cwd),
64 project_root: config.project_root.as_deref().map(normalize_cache_path),
65 include_bundled_system_skills: config.include_bundled_system_skills,
66 home_dir: home_dir.map(normalize_cache_path),
67 }
68 }
69}
70
71#[derive(Clone)]
72struct CachedLightweightSkillOutcome {
73 outcome: SkillLoadOutcome,
74 timestamp: SystemTime,
75}
76
77impl CachedLightweightSkillOutcome {
78 fn is_expired(&self) -> bool {
79 self.timestamp.elapsed().unwrap_or(LIGHTWEIGHT_SKILL_CACHE_TTL) > LIGHTWEIGHT_SKILL_CACHE_TTL
80 }
81}
82
83fn normalize_cache_path(path: &Path) -> PathBuf {
84 dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
85}
86
87fn lightweight_skill_metadata_cache()
88-> &'static RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>> {
89 LIGHTWEIGHT_SKILL_METADATA_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
90}
91
92fn get_cached_lightweight_skill_outcome(key: &LightweightSkillCacheKey) -> Option<SkillLoadOutcome> {
93 match lightweight_skill_metadata_cache().read() {
94 Ok(cache) => cache
95 .get(key)
96 .filter(|cached| !cached.is_expired())
97 .map(|cached| cached.outcome.clone()),
98 Err(_) => {
99 warn!("lightweight skill metadata cache lock poisoned while reading cache");
100 None
101 }
102 }
103}
104
105fn cache_lightweight_skill_outcome(key: LightweightSkillCacheKey, outcome: &SkillLoadOutcome) {
106 match lightweight_skill_metadata_cache().write() {
107 Ok(mut cache) => {
108 if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES && !cache.contains_key(&key) {
109 let expired: Vec<_> = cache
110 .iter()
111 .filter(|(_, value)| value.is_expired())
112 .map(|(cache_key, _)| cache_key.clone())
113 .collect();
114
115 for cache_key in expired {
116 cache.remove(&cache_key);
117 }
118
119 if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES {
120 let oldest_key = cache
121 .iter()
122 .min_by_key(|(_, value)| value.timestamp)
123 .map(|(cache_key, _)| cache_key.clone());
124 if let Some(oldest_key) = oldest_key {
125 cache.remove(&oldest_key);
126 }
127 }
128 }
129
130 cache.insert(
131 key,
132 CachedLightweightSkillOutcome {
133 outcome: outcome.clone(),
134 timestamp: SystemTime::now(),
135 },
136 );
137 }
138 Err(_) => warn!("lightweight skill metadata cache lock poisoned while writing cache"),
139 }
140}
141
142pub(crate) fn clear_lightweight_skill_metadata_cache() {
143 match lightweight_skill_metadata_cache().write() {
144 Ok(mut cache) => cache.clear(),
145 Err(_) => warn!("lightweight skill metadata cache lock poisoned while clearing cache"),
146 }
147}
148
149pub fn load_skills(config: &SkillLoaderConfig) -> SkillLoadOutcome {
150 let home_dir = dirs::home_dir();
151 load_skills_with_home_dir(config, home_dir.as_deref())
152}
153
154pub fn discover_skill_metadata_lightweight(config: &SkillLoaderConfig) -> SkillLoadOutcome {
158 let home_dir = dirs::home_dir();
159 discover_skill_metadata_lightweight_with_home_dir(config, home_dir.as_deref())
160}
161
162fn load_skills_with_home_dir(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> SkillLoadOutcome {
165 let mut outcome = SkillLoadOutcome::default();
166 let roots = skill_roots_with_home_dir(config, home_dir);
167
168 for root in roots {
169 discover_skills_under_root(&root, &mut outcome);
170 }
171
172 add_system_cli_tools(&mut outcome);
173 dedup_and_sort(&mut outcome);
174 filter_disabled_skills(&mut outcome, home_dir);
175 outcome
176}
177
178fn discover_skill_metadata_lightweight_with_home_dir(
181 config: &SkillLoaderConfig,
182 home_dir: Option<&Path>,
183) -> SkillLoadOutcome {
184 let cache_key = LightweightSkillCacheKey::new(config, home_dir);
185 if let Some(cached) = get_cached_lightweight_skill_outcome(&cache_key) {
186 return cached;
187 }
188
189 let outcome = discover_skill_metadata_lightweight_uncached(config, home_dir);
190 cache_lightweight_skill_outcome(cache_key, &outcome);
191 outcome
192}
193
194fn discover_skill_metadata_lightweight_uncached(
195 config: &SkillLoaderConfig,
196 home_dir: Option<&Path>,
197) -> SkillLoadOutcome {
198 let mut outcome = SkillLoadOutcome::default();
199 let roots = skill_roots_with_home_dir(config, home_dir);
200
201 for root in roots {
202 discover_metadata_under_root(&root, &mut outcome);
203 }
204
205 add_system_cli_tools(&mut outcome);
206 dedup_and_sort(&mut outcome);
207 filter_disabled_skills(&mut outcome, home_dir);
208 outcome
209}
210
211fn add_system_cli_tools(outcome: &mut SkillLoadOutcome) {
212 if let Ok(system_tools) = discover_cli_tools() {
213 for tool in system_tools {
214 if let Ok(skill) = tool_config_to_metadata(&tool, SkillScope::System) {
215 outcome.skills.push(skill);
216 }
217 }
218 }
219}
220
221fn dedup_and_sort(outcome: &mut SkillLoadOutcome) {
222 let mut seen: HashSet<String> = HashSet::new();
223 let mut shadowed: Vec<String> = Vec::new();
224 outcome.skills.retain(|skill| {
225 if seen.insert(skill.name.clone()) {
226 return true;
227 }
228 shadowed.push(format!("{} ({})", skill.name, skill.path.display()));
232 false
233 });
234 if !shadowed.is_empty() {
235 tracing::warn!(
236 shadowed = ?shadowed,
237 "duplicate skill names shadowed by earlier discovery; only the first occurrence is loaded"
238 );
239 }
240 outcome.skills.sort_by(|a, b| a.name.cmp(&b.name));
241}
242
243#[derive(Debug, Default, Deserialize)]
244struct CodexConfig {
245 #[serde(default)]
246 skills: CodexSkillsConfig,
247}
248
249#[derive(Debug, Default, Deserialize)]
250struct CodexSkillsConfig {
251 #[serde(default)]
252 config: Vec<CodexSkillToggle>,
253}
254
255#[derive(Debug, Deserialize)]
256struct CodexSkillToggle {
257 #[serde(default)]
258 path: Option<PathBuf>,
259 #[serde(default)]
260 name: Option<String>,
261 #[serde(default = "default_skill_toggle_enabled")]
262 enabled: bool,
263}
264
265#[derive(Debug, Default)]
266struct DisabledSkillSelectors {
267 paths: HashSet<PathBuf>,
268 names: HashSet<String>,
269}
270
271fn default_skill_toggle_enabled() -> bool {
272 true
273}
274
275fn filter_disabled_skills(outcome: &mut SkillLoadOutcome, home_dir: Option<&Path>) {
276 let disabled = disabled_skill_selectors(home_dir);
277 if disabled.paths.is_empty() && disabled.names.is_empty() {
278 return;
279 }
280
281 outcome.skills.retain(|skill| {
282 let canonical = dunce::canonicalize(&skill.path).unwrap_or_else(|_| skill.path.clone());
283 !disabled.paths.contains(&canonical) && !disabled.names.contains(&skill.name)
284 });
285}
286
287fn disabled_skill_selectors(home_dir: Option<&Path>) -> DisabledSkillSelectors {
288 let Some(home_dir) = home_dir else {
289 return DisabledSkillSelectors::default();
290 };
291
292 let config_path = home_dir.join(".codex").join("config.toml");
293 let Ok(content) = fs::read_to_string(&config_path) else {
294 return DisabledSkillSelectors::default();
295 };
296 let Ok(config) = toml::from_str::<CodexConfig>(&content) else {
297 return DisabledSkillSelectors::default();
298 };
299
300 let mut selectors = DisabledSkillSelectors::default();
301 for entry in config.skills.config.into_iter().filter(|entry| !entry.enabled) {
302 if let Some(path) = entry.path {
303 selectors.paths.insert(dunce::canonicalize(&path).unwrap_or(path));
304 }
305 if let Some(name) = entry.name.map(|name| name.trim().to_string())
306 && !name.is_empty()
307 {
308 selectors.names.insert(name);
309 }
310 }
311
312 selectors
313}
314
315fn skill_roots_with_home_dir(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> Vec<SkillRoot> {
316 let mut roots = Vec::new();
317
318 for repo_dir in repo_skill_search_dirs(config) {
319 roots.push(SkillRoot {
320 path: repo_dir.join(".agents/skills"),
321 scope: SkillScope::Repo,
322 is_tool_root: false,
323 is_plugin_root: false,
324 });
325 }
326
327 if let Some(project_root) = &config.project_root {
328 roots.push(SkillRoot {
329 path: project_root.join(".agents/plugins"),
330 scope: SkillScope::Repo,
331 is_tool_root: false,
332 is_plugin_root: true,
333 });
334 roots.push(SkillRoot {
335 path: project_root.join("tools"),
336 scope: SkillScope::Repo,
337 is_tool_root: true,
338 is_plugin_root: false,
339 });
340 roots.push(SkillRoot {
341 path: project_root.join("vendor/tools"),
342 scope: SkillScope::Repo,
343 is_tool_root: true,
344 is_plugin_root: false,
345 });
346 }
347
348 if let Some(home) = home_dir {
349 roots.push(SkillRoot {
350 path: home.join(".agents/plugins"),
351 scope: SkillScope::User,
352 is_tool_root: false,
353 is_plugin_root: true,
354 });
355 roots.push(SkillRoot {
356 path: home.join(".agents/skills"),
357 scope: SkillScope::User,
358 is_tool_root: false,
359 is_plugin_root: false,
360 });
361
362 if let Ok(paths) = VtCodePaths::resolve()
363 && dirs::home_dir().as_deref() == Some(home)
364 {
365 roots.push(SkillRoot {
366 path: paths.data_dir().join("skills"),
367 scope: SkillScope::User,
368 is_tool_root: false,
369 is_plugin_root: false,
370 });
371 roots.push(SkillRoot {
372 path: paths.legacy_dir().join("skills"),
373 scope: SkillScope::User,
374 is_tool_root: false,
375 is_plugin_root: false,
376 });
377 roots.push(SkillRoot {
378 path: paths.plugins_dir(),
379 scope: SkillScope::User,
380 is_tool_root: false,
381 is_plugin_root: true,
382 });
383 roots.push(SkillRoot {
384 path: paths.legacy_dir().join("plugins"),
385 scope: SkillScope::User,
386 is_tool_root: false,
387 is_plugin_root: true,
388 });
389 }
390 }
391
392 #[cfg(unix)]
393 roots.push(SkillRoot {
394 path: PathBuf::from("/etc/codex/skills"),
395 scope: SkillScope::Admin,
396 is_tool_root: false,
397 is_plugin_root: false,
398 });
399
400 if config.include_bundled_system_skills {
401 roots.push(SkillRoot {
402 path: system_cache_root_dir(&config.codex_home),
403 scope: SkillScope::System,
404 is_tool_root: false,
405 is_plugin_root: false,
406 });
407 }
408
409 roots
410}
411
412fn repo_skill_search_dirs(config: &SkillLoaderConfig) -> Vec<PathBuf> {
413 let stop = config.project_root.clone().unwrap_or_else(|| config.cwd.clone());
414 let mut dirs = Vec::new();
415 let mut current = config.cwd.clone();
416
417 loop {
418 dirs.push(current.clone());
419 if current == stop {
420 break;
421 }
422 let Some(parent) = current.parent() else {
423 break;
424 };
425 current = parent.to_path_buf();
426 }
427
428 dirs
429}
430
431fn find_git_root(path: &Path) -> Option<PathBuf> {
432 let mut current = Some(path);
433 while let Some(dir) = current {
434 if dir.join(".git").exists() {
435 return Some(dir.to_path_buf());
436 }
437 current = dir.parent();
438 }
439 None
440}
441
442fn discover_skills_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
443 walk_skills_dir(root, outcome, WalkMode::Full)
444}
445
446fn discover_metadata_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
447 walk_skills_dir(root, outcome, WalkMode::Metadata)
448}
449
450#[derive(Clone, Copy)]
451enum WalkMode {
452 Full,
453 Metadata,
454}
455
456fn walk_skills_dir(root: &SkillRoot, outcome: &mut SkillLoadOutcome, mode: WalkMode) {
457 let Ok(root_path) = dunce::canonicalize(&root.path) else {
458 return;
459 };
460
461 if !root_path.is_dir() {
462 return;
463 }
464
465 let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(root_path, 0)]);
466 let mut dirs_visited: usize = 0;
467 while let Some((dir, depth)) = queue.pop_front() {
468 dirs_visited += 1;
469 if dirs_visited > MAX_DISCOVERY_DIRS {
470 warn!(
471 "skill discovery dir budget ({MAX_DISCOVERY_DIRS}) exhausted under {}; some skills may be missing",
472 root.path.display()
473 );
474 return;
475 }
476 let entries = match fs::read_dir(&dir) {
477 Ok(entries) => entries,
478 Err(e) => {
479 if matches!(mode, WalkMode::Full) {
480 error!("failed to read skills dir {}: {e:#}", dir.display());
481 } else {
482 tracing::debug!("failed to read skills dir {}: {e:#}", dir.display());
483 }
484 continue;
485 }
486 };
487
488 for entry in entries.flatten() {
489 let path = entry.path();
490 let file_name = match path.file_name().and_then(|f| f.to_str()) {
491 Some(name) => name,
492 None => continue,
493 };
494
495 if file_name.starts_with('.') || discovery_dir_skipped(file_name) {
496 continue;
497 }
498
499 if path.is_dir() {
500 if root.is_tool_root
504 && let Ok(Some(tool_meta)) = try_load_tool_from_dir(&path, root.scope)
505 {
506 outcome.skills.push(tool_meta);
507 }
508
509 if root.is_plugin_root
510 && let Ok(Some(plugin_meta)) = try_load_plugin_from_dir(&path, root.scope)
511 {
512 outcome.skills.push(plugin_meta);
513 continue;
514 }
515
516 if try_ingest_plugin_skills(&path, root, outcome) {
517 continue;
518 }
519
520 if depth < MAX_DISCOVERY_DEPTH {
521 queue.push_back((path.clone(), depth + 1));
522 }
523 }
524
525 if file_name == "SKILL.md" {
526 handle_skill_md(path.clone(), root, outcome, mode);
527 } else if matches!(mode, WalkMode::Full) && root.is_tool_root && is_executable_file(&path) {
528 }
531 }
532 }
533}
534
535fn handle_skill_md(path: PathBuf, root: &SkillRoot, outcome: &mut SkillLoadOutcome, mode: WalkMode) {
536 let skill_dir = match path.parent() {
537 Some(dir) => dir,
538 None => return,
539 };
540
541 match mode {
542 WalkMode::Full => match crate::skills::manifest::parse_skill_file(skill_dir) {
543 Ok((manifest, _)) => {
544 outcome.skills.push(SkillMetadata {
545 name: manifest.name.clone(),
546 description: manifest.description.clone(),
547 short_description: None,
548 path,
549 scope: root.scope,
550 manifest: Some(manifest.into()),
551 });
552 }
553 Err(err) => {
554 if root.scope != SkillScope::System {
555 outcome.errors.push(SkillErrorInfo { path, message: err.to_string() });
556 }
557 }
558 },
559 WalkMode::Metadata => match fs::read_to_string(&path).with_context(|| format!("reading {}", path.display())) {
560 Ok(contents) => match crate::skills::manifest::parse_skill_content(&contents) {
561 Ok((manifest, _)) => {
562 outcome.skills.push(SkillMetadata {
563 name: manifest.name.clone(),
564 description: manifest.description.clone(),
565 short_description: None,
566 path,
567 scope: root.scope,
568 manifest: Some(manifest.into()),
569 });
570 }
571 Err(err) => {
572 if root.scope != SkillScope::System {
573 outcome.errors.push(SkillErrorInfo { path, message: err.to_string() });
574 }
575 }
576 },
577 Err(err) => {
578 if root.scope != SkillScope::System {
579 outcome.errors.push(SkillErrorInfo { path, message: err.to_string() });
580 }
581 }
582 },
583 }
584}
585
586fn try_load_tool_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
587 let tool_bridge = if path.join("tool.json").exists() {
590 CliToolBridge::from_directory(path)?
591 } else {
592 match CliToolBridge::from_directory(path) {
596 Ok(b) => b,
597 Err(_) => return Ok(None),
598 }
599 };
600
601 tool_config_to_metadata(&tool_bridge.config, scope).map(Some)
602}
603
604fn try_ingest_plugin_skills(path: &Path, root: &SkillRoot, outcome: &mut SkillLoadOutcome) -> bool {
607 if !root.is_plugin_root {
608 return false;
609 }
610 let Ok(loaded) = LoadedPlugin::load_from_dir(path) else {
611 return false;
612 };
613 for skill in &loaded.skills {
614 outcome.skills.push(SkillMetadata {
615 name: skill.name.clone(),
616 description: skill.manifest.description.clone(),
617 short_description: None,
618 path: skill.skill_md_path.clone(),
619 scope: root.scope,
620 manifest: Some(Box::new(skill.manifest.clone())),
621 });
622 }
623 true
624}
625
626fn tool_config_to_metadata(config: &CliToolConfig, scope: SkillScope) -> Result<SkillMetadata> {
627 Ok(SkillMetadata {
628 name: config.name.clone(),
629 description: config.description.clone(),
630 short_description: None,
631 path: config.executable_path.clone(), scope,
635 manifest: None, })
637}
638
639fn try_load_plugin_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
640 let plugin_json_path = path.join("plugin.json");
642 if !plugin_json_path.exists() {
643 return Ok(None);
644 }
645
646 let plugin_json_content = fs::read_to_string(&plugin_json_path).context("Failed to read plugin.json")?;
648
649 let plugin_metadata: crate::skills::native_plugin::PluginMetadata =
650 serde_json::from_str(&plugin_json_content).context("Invalid plugin.json format")?;
651
652 let lib_name = crate::skills::native_plugin::PluginLoader::new().library_filename(&plugin_metadata.name);
654
655 if !path.join(&lib_name).exists() {
656 let alternatives = [
658 format!("lib{}.dylib", plugin_metadata.name),
659 format!("{}.dylib", plugin_metadata.name),
660 format!("lib{}.so", plugin_metadata.name),
661 format!("{}.so", plugin_metadata.name),
662 format!("{}.dll", plugin_metadata.name),
663 ];
664
665 let has_lib = alternatives.iter().any(|alt| path.join(alt).exists());
666 if !has_lib {
667 return Ok(None); }
669 }
670
671 Ok(Some(SkillMetadata {
672 name: plugin_metadata.name.clone(),
673 description: plugin_metadata.description.clone(),
674 short_description: None,
675 path: path.to_path_buf(),
676 scope,
677 manifest: None, }))
679}
680
681pub fn load_skill_resources(skill_path: &Path) -> Result<Vec<crate::skills::types::SkillResource>> {
682 let mut resources = Vec::new();
683 let resource_dir = skill_path.join("scripts");
684
685 if resource_dir.exists() {
686 for entry in fs::read_dir(&resource_dir)? {
687 let entry = entry?;
688 let path = entry.path();
689
690 if path.is_file() {
691 let rel_path = path
692 .strip_prefix(skill_path)
693 .map(|p| p.to_string_lossy().to_string())
694 .unwrap_or_default();
695
696 let resource_type = match path.extension().and_then(|e| e.to_str()) {
697 Some("py") | Some("sh") | Some("bash") => crate::skills::types::ResourceType::Script,
698 Some("md") => crate::skills::types::ResourceType::Markdown,
699 Some("json") | Some("yaml") | Some("yml") => crate::skills::types::ResourceType::Reference,
700 _ => crate::skills::types::ResourceType::Other(format!("{:?}", path.extension())),
701 };
702
703 resources.push(crate::skills::types::SkillResource { path: rel_path, resource_type, content: None });
704 }
705 }
706 }
707
708 let references_dir = skill_path.join("references");
710 if references_dir.exists() {
711 for entry in fs::read_dir(&references_dir)? {
712 let entry = entry?;
713 let path = entry.path();
714
715 if path.is_file() {
716 let rel_path = path
717 .strip_prefix(skill_path)
718 .map(|p| p.to_string_lossy().to_string())
719 .unwrap_or_default();
720
721 let resource_type = match path.extension().and_then(|e| e.to_str()) {
722 Some("md") => crate::skills::types::ResourceType::Reference,
723 Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
724 crate::skills::types::ResourceType::Reference
725 }
726 _ => crate::skills::types::ResourceType::Other(format!("{:?}", path.extension())),
727 };
728
729 resources.push(crate::skills::types::SkillResource { path: rel_path, resource_type, content: None });
730 }
731 }
732 }
733
734 let assets_dir = skill_path.join("assets");
736 if assets_dir.exists() {
737 for entry in fs::read_dir(&assets_dir)? {
738 let entry = entry?;
739 let path = entry.path();
740
741 if path.is_file() {
742 let rel_path = path
743 .strip_prefix(skill_path)
744 .map(|p| p.to_string_lossy().to_string())
745 .unwrap_or_default();
746
747 let resource_type = match path.extension().and_then(|e| e.to_str()) {
748 Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("svg") => {
749 crate::skills::types::ResourceType::Asset
750 }
751 Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
752 crate::skills::types::ResourceType::Asset
753 }
754 _ => crate::skills::types::ResourceType::Asset,
755 };
756
757 resources.push(crate::skills::types::SkillResource { path: rel_path, resource_type, content: None });
758 }
759 }
760 }
761
762 Ok(resources)
763}
764
765fn is_executable_file(path: &Path) -> bool {
766 #[cfg(unix)]
767 {
768 use std::os::unix::fs::PermissionsExt;
769 if let Ok(meta) = path.metadata() {
770 return meta.permissions().mode() & 0o111 != 0;
771 }
772 }
773 #[cfg(windows)]
774 {
775 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
776 return matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd");
777 }
778 }
779 false
780}
781
782pub enum EnhancedSkill {
784 Traditional(Box<Skill>),
786 CliTool(Box<CliToolBridge>),
788 BuiltInCommand(Box<BuiltInCommandSkill>),
790 NativePlugin(Box<dyn crate::skills::native_plugin::NativePluginTrait>),
792}
793
794impl std::fmt::Debug for EnhancedSkill {
795 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796 match self {
797 Self::Traditional(skill) => f.debug_tuple("Traditional").field(skill).finish(),
798 Self::CliTool(tool) => f.debug_tuple("CliTool").field(tool).finish(),
799 Self::BuiltInCommand(skill) => f.debug_tuple("BuiltInCommand").field(skill).finish(),
800 Self::NativePlugin(plugin) => f.debug_tuple("NativePlugin").field(plugin).finish(),
801 }
802 }
803}
804
805pub struct EnhancedSkillLoader {
807 workspace_root: PathBuf,
808 codex_home: PathBuf,
809 discovery: SkillDiscovery,
810}
811
812fn default_codex_home() -> PathBuf {
813 std::env::var_os("CODEX_HOME")
814 .filter(|value| !value.is_empty())
815 .map(PathBuf::from)
816 .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
817 .unwrap_or_else(|| PathBuf::from(".codex"))
818}
819
820fn discovery_config_for_codex_home(workspace_root: &Path, codex_home: &Path) -> DiscoveryConfig {
821 let home_dir = dirs::home_dir();
822 let loader_config = SkillLoaderConfig {
823 codex_home: codex_home.to_path_buf(),
824 cwd: workspace_root.to_path_buf(),
825 project_root: find_git_root(workspace_root),
826 include_bundled_system_skills: true,
827 };
828 let roots = skill_roots_with_home_dir(&loader_config, home_dir.as_deref());
829
830 DiscoveryConfig {
831 skill_paths: roots
832 .iter()
833 .filter(|root| !root.is_tool_root && !root.is_plugin_root)
834 .map(|root| root.path.clone())
835 .collect(),
836 tool_paths: roots
837 .iter()
838 .filter(|root| root.is_tool_root)
839 .map(|root| root.path.clone())
840 .collect(),
841 ..Default::default()
842 }
843}
844
845impl EnhancedSkillLoader {
846 pub fn new(workspace_root: PathBuf) -> Self {
848 let codex_home = default_codex_home();
849 let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(&workspace_root, &codex_home));
850 Self { workspace_root, codex_home, discovery }
851 }
852
853 pub fn with_codex_home(workspace_root: PathBuf, codex_home: PathBuf) -> Self {
855 let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(&workspace_root, &codex_home));
856 Self { workspace_root, codex_home, discovery }
857 }
858
859 fn ensure_system_skills_installed(&self) {
860 if let Err(err) = install_system_skills(&self.codex_home) {
861 tracing::warn!("enhanced skill loader failed to install bundled system skills: {err}");
862 }
863 }
864
865 pub async fn discover_all_skills(&mut self) -> Result<DiscoveryResult> {
867 self.ensure_system_skills_installed();
868 let mut result = self.discovery.discover_all(&self.workspace_root).await?;
869 merge_built_in_command_skill_contexts(&mut result.skills);
870 Ok(result)
871 }
872
873 pub async fn get_skill(&mut self, name: &str) -> Result<EnhancedSkill> {
875 self.ensure_system_skills_installed();
876 let result = self.discovery.discover_all(&self.workspace_root).await?;
877
878 if let Some(skill_ctx) = select_traditional_skill_ctx(&result.skills, name, &self.workspace_root) {
882 let path = skill_ctx.path();
883 let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
884 let skill = Skill::with_scope(
885 manifest,
886 path.clone(),
887 infer_scope_from_skill_path(path, &self.workspace_root),
888 instructions,
889 )?;
890 return Ok(EnhancedSkill::Traditional(Box::new(skill)));
891 }
892
893 for tool_config in &result.tools {
895 if tool_config.name == name {
896 let bridge = CliToolBridge::new(tool_config.clone())?;
897 return Ok(EnhancedSkill::CliTool(Box::new(bridge)));
898 }
899 }
900
901 if let Some(skill) = built_in_command_skill(name) {
902 return Ok(EnhancedSkill::BuiltInCommand(Box::new(skill)));
903 }
904
905 Err(skill_ops::skill_not_found_error(name))
910 }
911
912 pub async fn generate_validation_report(&mut self) -> Result<ContainerValidationReport> {
914 let result = self.discovery.discover_all(&self.workspace_root).await?;
915 let mut report = ContainerValidationReport::new();
916 let validator = ContainerSkillsValidator::new();
917
918 for skill_ctx in &result.skills {
919 match self.load_full_skill_from_ctx(skill_ctx) {
920 Ok(skill) => {
921 let analysis = validator.analyze_skill(&skill);
922 report.add_skill_analysis(skill.name().to_string(), analysis);
923 }
924 Err(e) => {
925 report.add_incompatible_skill(
926 skill_ctx.manifest().name.clone(),
927 skill_ctx.manifest().description.clone(),
928 format!("Load error: {e}"),
929 );
930 }
931 }
932 }
933
934 report.finalize();
935 Ok(report)
936 }
937
938 pub fn check_container_requirements(&self, skill: &Skill) -> ContainerValidationResult {
940 let validator = ContainerSkillsValidator::new();
941 validator.analyze_skill(skill)
942 }
943
944 fn load_full_skill_from_ctx(&self, ctx: &SkillContext) -> Result<Skill> {
945 let path = ctx.path();
946 let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
947 Skill::with_scope(manifest, path.clone(), infer_scope_from_skill_path(path, &self.workspace_root), instructions)
948 }
949}
950
951fn infer_scope_from_skill_path(path: &Path, workspace_root: &Path) -> SkillScope {
952 if path.starts_with(Path::new("/etc/codex/skills")) {
953 return SkillScope::Admin;
954 }
955 if path.starts_with(system_cache_root_dir(&default_codex_home())) {
956 return SkillScope::System;
957 }
958 if let Some(home) = dirs::home_dir()
959 && path.starts_with(home.join(".agents/skills"))
960 {
961 return SkillScope::User;
962 }
963 if path.starts_with(workspace_root) {
964 return SkillScope::Repo;
965 }
966
967 if has_agents_skills_ancestor(path) {
972 return SkillScope::Repo;
973 }
974
975 SkillScope::User
976}
977
978fn has_agents_skills_ancestor(path: &Path) -> bool {
979 let mut ancestors = path.parent().map(Path::new).into_iter().peekable();
980 while let Some(current) = ancestors.next() {
981 let is_skills = current.file_name().and_then(|f| f.to_str()) == Some("skills");
982 let parent_is_agents = ancestors.peek().and_then(|p| p.file_name().and_then(|f| f.to_str())) == Some(".agents");
983 if is_skills && parent_is_agents {
984 return true;
985 }
986 }
987 false
988}
989
990fn is_bundled_command_skill(name: &str) -> bool {
991 find_command_skill_by_skill_name(name)
992 .is_some_and(|spec| matches!(spec.backend, CommandSkillBackend::TraditionalSkill { .. }))
993}
994
995fn is_system_skill_path(path: &Path) -> bool {
996 let mut components = path.components().peekable();
997 while let Some(component) = components.next() {
998 let is_skills = component.as_os_str() == "skills";
999 let next_is_system = components.peek().is_some_and(|next| next.as_os_str() == ".system");
1000 if is_skills && next_is_system {
1001 return true;
1002 }
1003 }
1004 false
1005}
1006
1007fn select_traditional_skill_ctx<'a>(
1008 skills: &'a [SkillContext],
1009 name: &str,
1010 workspace_root: &Path,
1011) -> Option<&'a SkillContext> {
1012 let mut first_match: Option<&'a SkillContext> = None;
1013 let mut system_match: Option<&'a SkillContext> = None;
1014 for skill_ctx in skills {
1015 if skill_ctx.manifest().name != name {
1016 continue;
1017 }
1018 if first_match.is_none() {
1019 first_match = Some(skill_ctx);
1020 }
1021 let scope = infer_scope_from_skill_path(skill_ctx.path(), workspace_root);
1025 if system_match.is_none()
1026 && (scope == SkillScope::System || (scope != SkillScope::Repo && is_system_skill_path(skill_ctx.path())))
1027 {
1028 system_match = Some(skill_ctx);
1029 }
1030 if first_match.is_some() && system_match.is_some() {
1031 break;
1032 }
1033 }
1034 if is_bundled_command_skill(name) {
1035 system_match.or(first_match)
1036 } else {
1037 first_match
1038 }
1039}
1040
1041#[derive(Debug, Clone, Copy)]
1042pub struct SkillMentionDetectionOptions {
1043 pub enable_auto_trigger: bool,
1044 pub enable_description_matching: bool,
1045 pub min_keyword_matches: usize,
1046}
1047
1048impl Default for SkillMentionDetectionOptions {
1049 fn default() -> Self {
1050 Self {
1051 enable_auto_trigger: true,
1052 enable_description_matching: true,
1053 min_keyword_matches: 2,
1054 }
1055 }
1056}
1057
1058pub fn detect_skill_mentions(user_input: &str, available_skills: &[SkillManifest]) -> Vec<String> {
1060 detect_skill_mentions_with_options(user_input, available_skills, &SkillMentionDetectionOptions::default())
1061}
1062
1063pub fn detect_skill_mentions_with_options(
1069 user_input: &str,
1070 available_skills: &[SkillManifest],
1071 options: &SkillMentionDetectionOptions,
1072) -> Vec<String> {
1073 if !options.enable_auto_trigger {
1074 return Vec::new();
1075 }
1076
1077 let mut mentions = Vec::new();
1078 let input_lower = user_input.to_lowercase();
1079 let input_keywords = extract_keywords(user_input);
1080 let min_matches = options.min_keyword_matches.max(1);
1081
1082 for skill in available_skills {
1083 let skill_name_lower = skill.name.to_lowercase();
1084 let explicit_trigger = format!("${skill_name_lower}");
1085 if input_lower.contains(&explicit_trigger) {
1086 mentions.push(skill.name.clone());
1087 continue;
1088 }
1089
1090 if !options.enable_description_matching {
1091 continue;
1092 }
1093
1094 let skill_keywords = skill_routing_keywords(skill);
1095 let keyword_matches = overlap_count(&input_keywords, &skill_keywords);
1096 if keyword_matches >= min_matches {
1097 mentions.push(skill.name.clone());
1098 }
1099 }
1100
1101 mentions.sort();
1102 mentions.dedup();
1103 mentions
1104}
1105
1106fn overlap_count(input_keywords: &HashSet<String>, skill_keywords: &HashSet<String>) -> usize {
1107 input_keywords.intersection(skill_keywords).count()
1108}
1109
1110fn skill_routing_keywords(skill: &SkillManifest) -> HashSet<String> {
1111 let mut keywords = extract_keywords(&skill.description);
1112
1113 let Some(metadata) = &skill.metadata else {
1114 return keywords;
1115 };
1116 for metadata_keywords in [metadata.get("keywords"), metadata.get("tags")].into_iter().flatten() {
1117 match metadata_keywords {
1118 serde_json::Value::Array(values) => {
1119 for value in values {
1120 if let Some(keyword) = value.as_str() {
1121 keywords.extend(extract_keywords(keyword));
1122 }
1123 }
1124 }
1125 serde_json::Value::String(value) => {
1126 keywords.extend(extract_keywords(value));
1127 }
1128 _ => {}
1129 }
1130 }
1131
1132 keywords
1133}
1134
1135fn extract_keywords(text: &str) -> HashSet<String> {
1136 const STOPWORDS: &[&str] = &[
1137 "the", "and", "with", "from", "that", "this", "when", "where", "what", "your", "for", "into", "onto", "than",
1138 "then", "also", "only", "should", "would", "could", "have", "has", "had", "use", "using", "task", "tasks",
1139 "help", "need", "want",
1140 ];
1141
1142 text.split(|c: char| !c.is_alphanumeric())
1143 .map(|part| part.trim().to_lowercase())
1144 .filter(|part| part.len() > 2)
1145 .filter(|part| !STOPWORDS.contains(&part.as_str()))
1146 .collect()
1147}
1148
1149#[cfg(test)]
1152pub fn load_skills_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1153 load_skills_with_home_dir(config, None)
1154}
1155
1156#[cfg(test)]
1158pub fn discover_skill_metadata_lightweight_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1159 discover_skill_metadata_lightweight_with_home_dir(config, None)
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165 use crate::skills::CommandSkillBackend;
1166 use crate::skills::command_skills::command_skill_specs;
1167 use crate::skills::system::{install_system_skills, system_cache_root_dir};
1168 use serial_test::serial;
1169 use std::fs;
1170 use tempfile::TempDir;
1171 use tempfile::tempdir;
1172
1173 fn manifest(name: &str, description: &str) -> SkillManifest {
1174 SkillManifest {
1175 name: name.to_string(),
1176 description: description.to_string(),
1177 ..Default::default()
1178 }
1179 }
1180
1181 #[test]
1182 fn detects_explicit_skill_mentions() {
1183 let skills = vec![manifest("pdf-analyzer", "Analyze PDF files and extract tables")];
1184 let mentions = detect_skill_mentions("Use $pdf-analyzer for this file", &skills);
1185 assert_eq!(mentions, vec!["pdf-analyzer".to_string()]);
1186 }
1187
1188 #[test]
1189 fn description_keywords_drive_implicit_matches() {
1190 let skills = vec![manifest(
1191 "api-fetcher",
1192 "Fetch data from API endpoints and summarize responses",
1193 )];
1194
1195 let mentions = detect_skill_mentions("Fetch and summarize API responses for these endpoints", &skills);
1196 assert_eq!(mentions, vec!["api-fetcher".to_string()]);
1197 }
1198
1199 #[test]
1200 fn metadata_keywords_drive_implicit_matches() {
1201 let mut ast_grep = manifest("ast-grep", "Structural search workflows");
1202 ast_grep.metadata = Some(HashMap::from([
1203 ("keywords".to_string(), serde_json::json!(["tree-sitter parser"])),
1204 ("tags".to_string(), serde_json::json!(["optional chaining"])),
1205 ]));
1206
1207 let mentions = detect_skill_mentions("Debug an optional chaining rewrite", &[ast_grep]);
1208 assert_eq!(mentions, vec!["ast-grep".to_string()]);
1209 }
1210
1211 #[test]
1212 fn unrelated_input_does_not_match_description() {
1213 let skills = vec![manifest(
1214 "api-fetcher",
1215 "Fetch data from API endpoints and summarize responses",
1216 )];
1217
1218 let mentions = detect_skill_mentions("Please update this local markdown file and fix headings", &skills);
1219 assert!(mentions.is_empty());
1220 }
1221
1222 #[test]
1223 fn auto_trigger_can_be_disabled() {
1224 let skills = vec![manifest("sql-checker", "Validate SQL migration scripts for safety")];
1225 let options = SkillMentionDetectionOptions { enable_auto_trigger: false, ..Default::default() };
1226 let mentions = detect_skill_mentions_with_options("Use $sql-checker", &skills, &options);
1227 assert!(mentions.is_empty());
1228 }
1229
1230 #[test]
1231 #[serial]
1232 fn lightweight_metadata_discovery_reuses_process_wide_cache() {
1233 clear_lightweight_skill_metadata_cache();
1234
1235 let codex_home = tempdir().expect("codex home");
1236 let workspace = tempdir().expect("workspace");
1237 let skill_dir = workspace.path().join(".agents/skills/process-wide-cache-skill");
1238 fs::create_dir_all(&skill_dir).expect("create skill dir");
1239 fs::write(
1240 skill_dir.join("SKILL.md"),
1241 "---\nname: process-wide-cache-skill\ndescription: process-wide cache test\n---\n# Body\n",
1242 )
1243 .expect("write skill");
1244
1245 let config = SkillLoaderConfig {
1246 codex_home: codex_home.path().to_path_buf(),
1247 cwd: workspace.path().to_path_buf(),
1248 project_root: Some(workspace.path().to_path_buf()),
1249 include_bundled_system_skills: false,
1250 };
1251
1252 let first = discover_skill_metadata_lightweight_hermetic(&config);
1253 assert!(
1254 first.skills.iter().any(|skill| skill.name == "process-wide-cache-skill"),
1255 "expected first discovery to find test skill",
1256 );
1257
1258 fs::remove_dir_all(&skill_dir).expect("remove cached skill dir");
1259
1260 let second = discover_skill_metadata_lightweight_hermetic(&config);
1261 assert!(
1262 second.skills.iter().any(|skill| skill.name == "process-wide-cache-skill"),
1263 "expected cached discovery to preserve removed skill until cache is cleared",
1264 );
1265
1266 clear_lightweight_skill_metadata_cache();
1267
1268 let third = discover_skill_metadata_lightweight_hermetic(&config);
1269 assert!(
1270 !third.skills.iter().any(|skill| skill.name == "process-wide-cache-skill"),
1271 "expected cleared cache to force rediscovery",
1272 );
1273 }
1274
1275 #[test]
1276 #[serial]
1277 fn lightweight_discovery_honors_disable_model_invocation() {
1278 use crate::skills::command_skills::is_model_catalog_eligible;
1279
1280 clear_lightweight_skill_metadata_cache();
1281
1282 let codex_home = tempdir().expect("codex home");
1283 let workspace = tempdir().expect("workspace");
1284 let hidden_dir = workspace.path().join(".agents/skills/manual-only-skill");
1285 fs::create_dir_all(&hidden_dir).expect("create hidden skill dir");
1286 fs::write(
1287 hidden_dir.join("SKILL.md"),
1288 "---\nname: manual-only-skill\ndescription: human invoked only\ndisable-model-invocation: true\n---\n# Body\n",
1289 )
1290 .expect("write hidden skill");
1291 let visible_dir = workspace.path().join(".agents/skills/auto-skill");
1292 fs::create_dir_all(&visible_dir).expect("create visible skill dir");
1293 fs::write(visible_dir.join("SKILL.md"), "---\nname: auto-skill\ndescription: model invocable\n---\n# Body\n")
1294 .expect("write visible skill");
1295
1296 let config = SkillLoaderConfig {
1297 codex_home: codex_home.path().to_path_buf(),
1298 cwd: workspace.path().to_path_buf(),
1299 project_root: Some(workspace.path().to_path_buf()),
1300 include_bundled_system_skills: false,
1301 };
1302
1303 let outcome = discover_skill_metadata_lightweight_hermetic(&config);
1304 let hidden = outcome
1305 .skills
1306 .iter()
1307 .find(|skill| skill.name == "manual-only-skill")
1308 .expect("hidden skill is still discovered");
1309 assert!(
1310 !is_model_catalog_eligible(hidden),
1311 "disable-model-invocation skill must stay out of the model catalog",
1312 );
1313 let visible = outcome
1314 .skills
1315 .iter()
1316 .find(|skill| skill.name == "auto-skill")
1317 .expect("visible skill is discovered");
1318 assert!(is_model_catalog_eligible(visible));
1319
1320 clear_lightweight_skill_metadata_cache();
1321 }
1322
1323 #[test]
1324 #[serial]
1325 fn lightweight_discovery_skips_dependency_and_vcs_trees() {
1326 clear_lightweight_skill_metadata_cache();
1327
1328 let codex_home = tempdir().expect("codex home");
1329 let workspace = tempdir().expect("workspace");
1330 let skills_root = workspace.path().join(".agents/skills");
1331 let write_skill = |dir: &Path, name: &str| {
1332 fs::create_dir_all(dir).expect("create skill dir");
1333 fs::write(
1334 dir.join("SKILL.md"),
1335 format!("---\nname: {name}\ndescription: test skill {name}\n---\n# Body\n"),
1336 )
1337 .expect("write skill");
1338 };
1339 write_skill(&skills_root.join("visible-skill"), "visible-skill");
1340 write_skill(&skills_root.join("node_modules/hidden-skill"), "hidden-skill");
1341 write_skill(&skills_root.join(".git/hidden-git-skill"), "hidden-git-skill");
1342
1343 let config = SkillLoaderConfig {
1344 codex_home: codex_home.path().to_path_buf(),
1345 cwd: workspace.path().to_path_buf(),
1346 project_root: Some(workspace.path().to_path_buf()),
1347 include_bundled_system_skills: false,
1348 };
1349
1350 let outcome = discover_skill_metadata_lightweight_hermetic(&config);
1351 let names: Vec<&str> = outcome.skills.iter().map(|skill| skill.name.as_str()).collect();
1352 assert!(names.contains(&"visible-skill"), "expected visible skill, got {names:?}");
1353 assert!(!names.contains(&"hidden-skill"), "node_modules must not be scanned, got {names:?}");
1354 assert!(!names.contains(&"hidden-git-skill"), ".git must not be scanned, got {names:?}");
1355
1356 clear_lightweight_skill_metadata_cache();
1357 }
1358
1359 #[test]
1360 #[serial]
1361 fn lightweight_discovery_loads_skill_with_renamed_directory() {
1362 clear_lightweight_skill_metadata_cache();
1364
1365 let codex_home = tempdir().expect("codex home");
1366 let workspace = tempdir().expect("workspace");
1367 let skill_dir = workspace.path().join(".agents/skills/renamed-dir");
1368 fs::create_dir_all(&skill_dir).expect("create skill dir");
1369 fs::write(
1370 skill_dir.join("SKILL.md"),
1371 "---\nname: original-name\ndescription: renamed on install\n---\n# Body\n",
1372 )
1373 .expect("write skill");
1374
1375 let config = SkillLoaderConfig {
1376 codex_home: codex_home.path().to_path_buf(),
1377 cwd: workspace.path().to_path_buf(),
1378 project_root: Some(workspace.path().to_path_buf()),
1379 include_bundled_system_skills: false,
1380 };
1381
1382 let outcome = discover_skill_metadata_lightweight_hermetic(&config);
1383 assert!(outcome.skills.iter().any(|skill| skill.name == "original-name"), "renamed skill must still load",);
1384
1385 clear_lightweight_skill_metadata_cache();
1386 }
1387
1388 #[test]
1389 fn full_discovery_loads_skill_with_renamed_directory() {
1390 let codex_home = tempdir().expect("codex home");
1393 let workspace = tempdir().expect("workspace");
1394 let skill_dir = workspace.path().join(".agents/skills/renamed-dir");
1395 fs::create_dir_all(&skill_dir).expect("create skill dir");
1396 fs::write(
1397 skill_dir.join("SKILL.md"),
1398 "---\nname: original-name\ndescription: renamed on install\n---\n# Body\n",
1399 )
1400 .expect("write skill");
1401
1402 let config = SkillLoaderConfig {
1403 codex_home: codex_home.path().to_path_buf(),
1404 cwd: workspace.path().to_path_buf(),
1405 project_root: Some(workspace.path().to_path_buf()),
1406 include_bundled_system_skills: false,
1407 };
1408
1409 let outcome = load_skills_hermetic(&config);
1410 assert!(
1411 outcome.skills.iter().any(|skill| skill.name == "original-name"),
1412 "renamed skill must still load in full mode",
1413 );
1414 assert!(
1415 outcome.errors.is_empty(),
1416 "renamed skill must not record a load error, got {:?}",
1417 outcome.errors.iter().map(|e| e.to_string()).collect::<Vec<_>>(),
1418 );
1419 }
1420
1421 #[tokio::test]
1422 async fn enhanced_loader_discovers_and_loads_built_in_command_skills() {
1423 let temp_dir = TempDir::new().expect("temp dir");
1424 let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1425
1426 let discovery = loader.discover_all_skills().await.expect("discover skills");
1427 assert!(
1428 discovery
1429 .skills
1430 .iter()
1431 .any(|skill_ctx| skill_ctx.manifest().name == "cmd-status")
1432 );
1433
1434 let skill = loader.get_skill("cmd-status").await.expect("load cmd-status");
1435 assert!(matches!(skill, EnhancedSkill::BuiltInCommand(_)));
1436 }
1437
1438 #[tokio::test]
1439 async fn enhanced_loader_discovers_and_loads_bundled_command_skills() {
1440 let workspace = TempDir::new().expect("workspace");
1441 let codex_home = TempDir::new().expect("codex home");
1442 install_system_skills(codex_home.path()).expect("install bundled system skills");
1443 let cmd_review_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1444 assert!(
1445 cmd_review_dir.join("SKILL.md").exists(),
1446 "expected bundled cmd-review at {}",
1447 cmd_review_dir.display()
1448 );
1449 let (manifest, _) = crate::skills::manifest::parse_skill_file(&cmd_review_dir).expect("parse cmd-review");
1450 assert_eq!(manifest.name, "cmd-review");
1451 let config = discovery_config_for_codex_home(workspace.path(), codex_home.path());
1452 assert!(
1453 config
1454 .skill_paths
1455 .iter()
1456 .any(|path| path == &system_cache_root_dir(codex_home.path()))
1457 );
1458 let mut loader =
1459 EnhancedSkillLoader::with_codex_home(workspace.path().to_path_buf(), codex_home.path().to_path_buf());
1460
1461 let discovery = loader.discover_all_skills().await.expect("discover skills");
1462 assert!(
1463 discovery
1464 .skills
1465 .iter()
1466 .any(|skill_ctx| skill_ctx.manifest().name == "cmd-review")
1467 );
1468
1469 let skill = loader.get_skill("cmd-review").await.expect("load cmd-review");
1470 assert!(matches!(skill, EnhancedSkill::Traditional(_)));
1471 }
1472
1473 #[tokio::test]
1474 async fn enhanced_loader_does_not_open_repository_native_plugins() {
1475 let workspace = tempdir().expect("workspace");
1476 let codex_home = tempdir().expect("codex home");
1477 let plugin_root = workspace.path().join(".agents/plugins");
1478 fs::create_dir_all(&plugin_root).expect("create repository plugin root");
1479
1480 let plugin_name = format!("repository-native-plugin-{}", std::process::id());
1481 fs::write(
1482 plugin_root.join("plugin.json"),
1483 format!(
1484 r#"{{
1485 "name": "{plugin_name}",
1486 "description": "repository-controlled native plugin",
1487 "version": "1.0.0",
1488 "abi_version": 1
1489 }}"#
1490 ),
1491 )
1492 .expect("write plugin metadata");
1493 let library_name = crate::skills::native_plugin::PluginLoader::new().library_filename(&plugin_name);
1494 fs::write(plugin_root.join(library_name), b"not a dynamic library").expect("write fake library");
1495
1496 let mut loader =
1497 EnhancedSkillLoader::with_codex_home(workspace.path().to_path_buf(), codex_home.path().to_path_buf());
1498 let error = loader
1499 .get_skill(&plugin_name)
1500 .await
1501 .expect_err("repository native plugin must not be opened by generic skill lookup");
1502
1503 assert!(error.to_string().contains("not found"), "unexpected error: {error}");
1504 assert!(!error.to_string().contains("dynamic library"));
1505 }
1506
1507 #[tokio::test]
1508 async fn enhanced_loader_discovers_every_command_skill() {
1509 let workspace = TempDir::new().expect("workspace");
1510 let codex_home = TempDir::new().expect("codex home");
1511 let mut loader =
1512 EnhancedSkillLoader::with_codex_home(workspace.path().to_path_buf(), codex_home.path().to_path_buf());
1513
1514 let discovery = loader.discover_all_skills().await.expect("discover skills");
1515 let discovered_names = discovery
1516 .skills
1517 .iter()
1518 .map(|skill_ctx| skill_ctx.manifest().name.as_str())
1519 .collect::<std::collections::HashSet<_>>();
1520
1521 for spec in command_skill_specs() {
1522 assert!(discovered_names.contains(spec.skill_name), "missing command skill {}", spec.skill_name);
1523
1524 let skill = loader
1525 .get_skill(spec.skill_name)
1526 .await
1527 .unwrap_or_else(|error| panic!("failed to load {}: {error}", spec.skill_name));
1528
1529 match spec.backend {
1530 CommandSkillBackend::TraditionalSkill { .. } => {
1531 assert!(
1532 matches!(skill, EnhancedSkill::Traditional(_)),
1533 "{} should load as a traditional skill",
1534 spec.skill_name
1535 );
1536 }
1537 CommandSkillBackend::BuiltInCommand { .. } => {
1538 assert!(
1539 matches!(skill, EnhancedSkill::BuiltInCommand(_)),
1540 "{} should load as a built-in command skill",
1541 spec.skill_name
1542 );
1543 }
1544 }
1545 }
1546 }
1547
1548 #[test]
1549 fn bundled_command_skill_prefers_system_over_workspace_shadow() {
1550 use crate::skills::types::SkillContext;
1551 let workspace = TempDir::new().expect("workspace");
1552 let codex_home = TempDir::new().expect("codex home");
1553 install_system_skills(codex_home.path()).expect("install bundled system skills");
1554 let system_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1555 let workspace_dir = workspace.path().join(".agents/skills/cmd-review");
1556 let system_ctx = SkillContext::MetadataOnly(
1558 crate::skills::manifest::parse_skill_file(&system_dir).expect("parse system").0,
1559 system_dir.clone(),
1560 );
1561 let workspace_ctx = SkillContext::MetadataOnly(manifest("cmd-review", "stale workspace shadow"), workspace_dir);
1562 let skills = vec![workspace_ctx, system_ctx];
1563 let selected = select_traditional_skill_ctx(&skills, "cmd-review", workspace.path()).expect("select skill");
1564 assert_eq!(selected.path(), &system_dir);
1565 assert!(
1566 selected.manifest().description.contains("[instructions |"),
1567 "bundled cmd-review must support free-form instructions, got: {}",
1568 selected.manifest().description
1569 );
1570 }
1571
1572 #[test]
1573 fn non_command_skill_keeps_workspace_first_match() {
1574 use crate::skills::types::SkillContext;
1575 let workspace = TempDir::new().expect("workspace");
1576 let workspace_dir = workspace.path().join(".agents/skills/my-skill");
1577 let other_dir = workspace.path().join("other/my-skill");
1578 let skills = vec![
1579 SkillContext::MetadataOnly(manifest("my-skill", "workspace"), workspace_dir),
1580 SkillContext::MetadataOnly(manifest("my-skill", "other"), other_dir),
1581 ];
1582 let selected = select_traditional_skill_ctx(&skills, "my-skill", workspace.path()).expect("select skill");
1583 assert_eq!(selected.manifest().description, "workspace");
1584 }
1585
1586 #[test]
1587 fn workspace_nested_system_shape_does_not_spoof_command_skill() {
1588 use crate::skills::types::SkillContext;
1589 let workspace = TempDir::new().expect("workspace");
1590 let codex_home = TempDir::new().expect("codex home");
1591 install_system_skills(codex_home.path()).expect("install bundled system skills");
1592 let system_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1593 let spoof_dir = workspace.path().join(".agents/skills/skills/.system/cmd-review");
1594 let system_ctx = SkillContext::MetadataOnly(
1595 crate::skills::manifest::parse_skill_file(&system_dir).expect("parse system").0,
1596 system_dir.clone(),
1597 );
1598 let spoof_ctx = SkillContext::MetadataOnly(manifest("cmd-review", "workspace spoof"), spoof_dir);
1599 let skills = vec![spoof_ctx, system_ctx];
1600 let selected = select_traditional_skill_ctx(&skills, "cmd-review", workspace.path()).expect("select skill");
1601 assert_eq!(selected.path(), &system_dir);
1602 }
1603
1604 fn write_skill(dir: &Path, name: &str, description: &str) {
1605 fs::create_dir_all(dir).expect("create skill dir");
1606 fs::write(
1607 dir.join("SKILL.md"),
1608 format!("---\nname: {name}\ndescription: {description}\n---\n\nUse this skill.\n"),
1609 )
1610 .expect("write SKILL.md");
1611 }
1612
1613 fn write_codex_skill_config(home_dir: &Path, contents: &str) {
1614 let config_dir = home_dir.join(".codex");
1615 fs::create_dir_all(&config_dir).expect("create config dir");
1616 fs::write(config_dir.join("config.toml"), contents).expect("write config");
1617 }
1618
1619 fn skill_loader_config_for(workspace: &Path, codex_home: &Path) -> SkillLoaderConfig {
1620 SkillLoaderConfig {
1621 codex_home: codex_home.to_path_buf(),
1622 cwd: workspace.to_path_buf(),
1623 project_root: find_git_root(workspace),
1624 include_bundled_system_skills: false,
1625 }
1626 }
1627
1628 #[test]
1629 fn disabled_skill_config_supports_stable_names() {
1630 let workspace = tempdir().expect("workspace");
1631 fs::create_dir(workspace.path().join(".git")).expect("create .git");
1632
1633 let home = tempdir().expect("home");
1634 let codex_home = tempdir().expect("codex home");
1635
1636 let old_plugin_skill_dir = workspace.path().join(".agents/plugins/example-plugin-v1/skills/release-helper");
1637 write_skill(&old_plugin_skill_dir, "release-helper", "Prepare release notes");
1638
1639 write_codex_skill_config(
1640 home.path(),
1641 &format!(
1642 "[[skills.config]]\nname = \"release-helper\"\npath = \"{}\"\nenabled = false\n",
1643 old_plugin_skill_dir.display()
1644 ),
1645 );
1646
1647 let new_plugin_skill_dir = workspace.path().join(".agents/plugins/example-plugin-v2/skills/release-helper");
1648 write_skill(&new_plugin_skill_dir, "release-helper", "Prepare release notes");
1649
1650 fs::remove_dir_all(workspace.path().join(".agents/plugins/example-plugin-v1"))
1651 .expect("remove old plugin version");
1652
1653 let outcome =
1654 load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1655
1656 assert!(
1657 outcome.skills.iter().all(|skill| skill.name != "release-helper"),
1658 "expected release-helper to stay disabled after plugin path changed"
1659 );
1660 }
1661
1662 #[test]
1663 fn disabled_skill_config_preserves_path_based_entries() {
1664 let workspace = tempdir().expect("workspace");
1665 let home = tempdir().expect("home");
1666 let codex_home = tempdir().expect("codex home");
1667
1668 let skill_dir = home.path().join(".agents/skills/path-disabled");
1669 write_skill(&skill_dir, "path-disabled", "Disabled by explicit path");
1670
1671 write_codex_skill_config(
1672 home.path(),
1673 &format!("[[skills.config]]\npath = \"{}\"\nenabled = false\n", skill_dir.join("SKILL.md").display()),
1674 );
1675
1676 let outcome =
1677 load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1678
1679 assert!(
1680 outcome.skills.iter().all(|skill| skill.name != "path-disabled"),
1681 "expected path-disabled to remain filtered by legacy path config"
1682 );
1683 }
1684
1685 #[test]
1686 fn discovers_agent_plugin_skills_from_user_plugin_root() {
1687 let workspace = tempdir().expect("workspace");
1688 fs::create_dir(workspace.path().join(".git")).expect("create .git");
1689 let home = tempdir().expect("home");
1690 let codex_home = tempdir().expect("codex home");
1691
1692 let plugin_root = home.path().join(".agents/plugins/my-plugin");
1693 fs::create_dir_all(plugin_root.join("skills/migrate-agent-plugin")).expect("create skill dirs");
1694
1695 fs::write(
1696 plugin_root.join("plugin.json"),
1697 r#"{
1698 "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1699 "name": "my-plugin",
1700 "version": "1.0.0",
1701 "description": "Test plugin"
1702 }"#,
1703 )
1704 .expect("write plugin.json");
1705
1706 fs::write(
1707 plugin_root.join("skills/migrate-agent-plugin/SKILL.md"),
1708 r#"---
1709name: migrate-agent-plugin
1710description: Migrate an existing agent plugin to Agent Plugins v1.
1711license: MIT
1712---
1713
1714# Migrate an Agent Plugin
1715
1716Steps here.
1717"#,
1718 )
1719 .expect("write skill");
1720
1721 let outcome =
1722 load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1723
1724 assert!(
1725 outcome.skills.iter().any(|s| s.name == "migrate-agent-plugin"),
1726 "expected migrate-agent-plugin skill to be discovered from the user agent plugin root, got: {:?}",
1727 outcome.skills.iter().map(|s| &s.name).collect::<Vec<_>>()
1728 );
1729 }
1730
1731 #[test]
1732 fn discovers_agent_plugin_skills_from_plugin_root() {
1733 let workspace = tempdir().expect("workspace");
1734 fs::create_dir(workspace.path().join(".git")).expect("create .git");
1735 let home = tempdir().expect("home");
1736 let codex_home = tempdir().expect("codex home");
1737
1738 let plugin_root = workspace.path().join(".agents/plugins/my-plugin");
1739 fs::create_dir_all(plugin_root.join("skills/migrate-agent-plugin/references")).expect("create skill dirs");
1740
1741 fs::write(
1742 plugin_root.join("plugin.json"),
1743 r#"{
1744 "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1745 "name": "my-plugin",
1746 "version": "1.0.0",
1747 "description": "Test plugin"
1748 }"#,
1749 )
1750 .expect("write plugin.json");
1751
1752 fs::write(
1753 plugin_root.join("skills/migrate-agent-plugin/SKILL.md"),
1754 r#"---
1755name: migrate-agent-plugin
1756description: Migrate an existing agent plugin to Agent Plugins v1.
1757license: MIT
1758---
1759
1760# Migrate an Agent Plugin
1761
1762Steps here.
1763"#,
1764 )
1765 .expect("write skill");
1766
1767 let outcome =
1768 load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1769
1770 assert!(
1771 outcome.skills.iter().any(|s| s.name == "migrate-agent-plugin"),
1772 "expected migrate-agent-plugin skill to be discovered from agent plugin, got: {:?}",
1773 outcome.skills.iter().map(|s| &s.name).collect::<Vec<_>>()
1774 );
1775 }
1776
1777 #[test]
1778 fn does_not_descent_into_agent_plugin_subdirs() {
1779 let workspace = tempdir().expect("workspace");
1780 fs::create_dir(workspace.path().join(".git")).expect("create .git");
1781 let home = tempdir().expect("home");
1782 let codex_home = tempdir().expect("codex home");
1783
1784 let plugin_root = workspace.path().join(".agents/plugins/my-plugin");
1785 fs::create_dir_all(plugin_root.join("skills/nested/deep-nested")).expect("create nested dirs");
1786
1787 fs::write(
1788 plugin_root.join("plugin.json"),
1789 r#"{
1790 "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1791 "name": "my-plugin",
1792 "description": "Test native plugin",
1793 "version": "1.0.0",
1794 "abi_version": 1
1795 }"#,
1796 )
1797 .expect("write plugin.json");
1798 let lib_name = crate::skills::native_plugin::PluginLoader::new().library_filename("my-plugin");
1801 fs::write(plugin_root.join(&lib_name), b"").expect("write dummy plugin library");
1802
1803 fs::write(
1804 plugin_root.join("skills/nested/deep-nested/SKILL.md"),
1805 r#"---
1806name: deep-nested
1807description: Should not be discovered because it is not an immediate child of skills/.
1808---
1809
1810# Deep Nested
1811"#,
1812 )
1813 .expect("write deeply nested skill");
1814
1815 let outcome =
1816 load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1817
1818 assert!(
1819 outcome.skills.iter().all(|s| s.name != "deep-nested"),
1820 "expected deeply nested skill outside skills/* to be ignored, got: {:?}",
1821 outcome.skills.iter().map(|s| &s.name).collect::<Vec<_>>()
1822 );
1823 }
1824
1825 #[test]
1826 fn discovers_plugin_mcp_providers() {
1827 let workspace = tempdir().expect("workspace");
1828 fs::create_dir(workspace.path().join(".git")).expect("create .git");
1829
1830 let plugin_root = workspace.path().join(".agents/plugins/mcp-plugin");
1831 fs::create_dir_all(plugin_root.join("bin")).expect("create bin dir");
1832
1833 fs::write(
1834 plugin_root.join("plugin.json"),
1835 r#"{
1836 "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1837 "name": "mcp-plugin"
1838 }"#,
1839 )
1840 .expect("write plugin.json");
1841
1842 fs::write(
1843 plugin_root.join("mcp.json"),
1844 r#"{
1845 "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
1846 "mcpServers": {
1847 "local": {
1848 "type": "stdio",
1849 "command": "./bin/server",
1850 "args": ["--data", "${PLUGIN_DATA}/db"]
1851 },
1852 "remote": {
1853 "type": "streamable-http",
1854 "url": "https://example.com/mcp"
1855 }
1856 }
1857 }"#,
1858 )
1859 .expect("write mcp.json");
1860
1861 fs::write(plugin_root.join("bin/server"), "#!/bin/sh\necho hello").expect("write server script");
1862
1863 let providers = crate::mcp::plugin_providers::discover_plugin_mcp_providers(workspace.path());
1864
1865 let names: Vec<_> = providers.iter().map(|p| p.name.as_str()).collect();
1866 assert!(names.contains(&"mcp-plugin.local"), "expected mcp-plugin.local, got: {:?}", names);
1867 assert!(names.contains(&"mcp-plugin.remote"), "expected mcp-plugin.remote, got: {:?}", names);
1868 assert_eq!(providers.len(), 2);
1869 }
1870}