1use crate::skills::cli_bridge::{CliToolBridge, CliToolConfig, discover_cli_tools};
2use crate::skills::command_skills::{
3 BuiltInCommandSkill, built_in_command_skill, merge_built_in_command_skill_contexts,
4};
5use crate::skills::container_validation::{
6 ContainerSkillsValidator, ContainerValidationReport, ContainerValidationResult,
7};
8use crate::skills::discovery::{DiscoveryConfig, DiscoveryResult, SkillDiscovery};
9use crate::skills::model::{SkillErrorInfo, SkillLoadOutcome, SkillMetadata, SkillScope};
10use crate::skills::system::{install_system_skills, system_cache_root_dir};
11use crate::skills::types::{Skill, SkillContext, SkillManifest};
12use crate::tools::error_messages::skill_ops;
13use anyhow::{Context, Result};
14use hashbrown::{HashMap, HashSet};
15use serde::Deserialize;
16use std::collections::VecDeque;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{OnceLock, RwLock};
20use std::time::{Duration, SystemTime};
21use tracing::{error, warn};
22
23#[derive(Debug, Clone)]
25pub struct SkillLoaderConfig {
26 pub codex_home: PathBuf,
27 pub cwd: PathBuf,
28 pub project_root: Option<PathBuf>,
29 pub include_bundled_system_skills: bool,
30}
31
32pub struct SkillRoot {
33 pub path: PathBuf,
34 pub scope: SkillScope,
35 pub is_tool_root: bool,
36 pub is_plugin_root: bool,
37}
38
39const LIGHTWEIGHT_SKILL_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
40const LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES: usize = 32;
41
42static LIGHTWEIGHT_SKILL_METADATA_CACHE: OnceLock<
43 RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>>,
44> = OnceLock::new();
45
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47struct LightweightSkillCacheKey {
48 codex_home: PathBuf,
49 cwd: PathBuf,
50 project_root: Option<PathBuf>,
51 include_bundled_system_skills: bool,
52 home_dir: Option<PathBuf>,
53}
54
55impl LightweightSkillCacheKey {
56 fn new(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> Self {
57 Self {
58 codex_home: normalize_cache_path(&config.codex_home),
59 cwd: normalize_cache_path(&config.cwd),
60 project_root: config.project_root.as_deref().map(normalize_cache_path),
61 include_bundled_system_skills: config.include_bundled_system_skills,
62 home_dir: home_dir.map(normalize_cache_path),
63 }
64 }
65}
66
67#[derive(Clone)]
68struct CachedLightweightSkillOutcome {
69 outcome: SkillLoadOutcome,
70 timestamp: SystemTime,
71}
72
73impl CachedLightweightSkillOutcome {
74 fn is_expired(&self) -> bool {
75 self.timestamp
76 .elapsed()
77 .unwrap_or(LIGHTWEIGHT_SKILL_CACHE_TTL)
78 > LIGHTWEIGHT_SKILL_CACHE_TTL
79 }
80}
81
82fn normalize_cache_path(path: &Path) -> PathBuf {
83 dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
84}
85
86fn lightweight_skill_metadata_cache()
87-> &'static RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>> {
88 LIGHTWEIGHT_SKILL_METADATA_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
89}
90
91fn get_cached_lightweight_skill_outcome(
92 key: &LightweightSkillCacheKey,
93) -> Option<SkillLoadOutcome> {
94 match lightweight_skill_metadata_cache().read() {
95 Ok(cache) => cache
96 .get(key)
97 .filter(|cached| !cached.is_expired())
98 .map(|cached| cached.outcome.clone()),
99 Err(_) => {
100 warn!("lightweight skill metadata cache lock poisoned while reading cache");
101 None
102 }
103 }
104}
105
106fn cache_lightweight_skill_outcome(key: LightweightSkillCacheKey, outcome: &SkillLoadOutcome) {
107 match lightweight_skill_metadata_cache().write() {
108 Ok(mut cache) => {
109 if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES && !cache.contains_key(&key) {
110 let expired: Vec<_> = cache
111 .iter()
112 .filter(|(_, value)| value.is_expired())
113 .map(|(cache_key, _)| cache_key.clone())
114 .collect();
115
116 for cache_key in expired {
117 cache.remove(&cache_key);
118 }
119
120 if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES {
121 let oldest_key = cache
122 .iter()
123 .min_by_key(|(_, value)| value.timestamp)
124 .map(|(cache_key, _)| cache_key.clone());
125 if let Some(oldest_key) = oldest_key {
126 cache.remove(&oldest_key);
127 }
128 }
129 }
130
131 cache.insert(
132 key,
133 CachedLightweightSkillOutcome {
134 outcome: outcome.clone(),
135 timestamp: SystemTime::now(),
136 },
137 );
138 }
139 Err(_) => warn!("lightweight skill metadata cache lock poisoned while writing cache"),
140 }
141}
142
143pub(crate) fn clear_lightweight_skill_metadata_cache() {
144 match lightweight_skill_metadata_cache().write() {
145 Ok(mut cache) => cache.clear(),
146 Err(_) => warn!("lightweight skill metadata cache lock poisoned while clearing cache"),
147 }
148}
149
150pub fn load_skills(config: &SkillLoaderConfig) -> SkillLoadOutcome {
151 let home_dir = dirs::home_dir();
152 load_skills_with_home_dir(config, home_dir.as_deref())
153}
154
155pub fn discover_skill_metadata_lightweight(config: &SkillLoaderConfig) -> SkillLoadOutcome {
159 let home_dir = dirs::home_dir();
160 discover_skill_metadata_lightweight_with_home_dir(config, home_dir.as_deref())
161}
162
163fn load_skills_with_home_dir(
166 config: &SkillLoaderConfig,
167 home_dir: Option<&Path>,
168) -> SkillLoadOutcome {
169 let mut outcome = SkillLoadOutcome::default();
170 let roots = skill_roots_with_home_dir(config, home_dir);
171
172 for root in roots {
173 discover_skills_under_root(&root, &mut outcome);
174 }
175
176 add_system_cli_tools(&mut outcome);
177 dedup_and_sort(&mut outcome);
178 filter_disabled_skills(&mut outcome, home_dir);
179 outcome
180}
181
182fn discover_skill_metadata_lightweight_with_home_dir(
185 config: &SkillLoaderConfig,
186 home_dir: Option<&Path>,
187) -> SkillLoadOutcome {
188 let cache_key = LightweightSkillCacheKey::new(config, home_dir);
189 if let Some(cached) = get_cached_lightweight_skill_outcome(&cache_key) {
190 return cached;
191 }
192
193 let outcome = discover_skill_metadata_lightweight_uncached(config, home_dir);
194 cache_lightweight_skill_outcome(cache_key, &outcome);
195 outcome
196}
197
198fn discover_skill_metadata_lightweight_uncached(
199 config: &SkillLoaderConfig,
200 home_dir: Option<&Path>,
201) -> SkillLoadOutcome {
202 let mut outcome = SkillLoadOutcome::default();
203 let roots = skill_roots_with_home_dir(config, home_dir);
204
205 for root in roots {
206 discover_metadata_under_root(&root, &mut outcome);
207 }
208
209 add_system_cli_tools(&mut outcome);
210 dedup_and_sort(&mut outcome);
211 filter_disabled_skills(&mut outcome, home_dir);
212 outcome
213}
214
215fn add_system_cli_tools(outcome: &mut SkillLoadOutcome) {
216 if let Ok(system_tools) = discover_cli_tools() {
217 for tool in system_tools {
218 if let Ok(skill) = tool_config_to_metadata(&tool, SkillScope::System) {
219 outcome.skills.push(skill);
220 }
221 }
222 }
223}
224
225fn dedup_and_sort(outcome: &mut SkillLoadOutcome) {
226 let mut seen: HashSet<String> = HashSet::new();
227 outcome
228 .skills
229 .retain(|skill| seen.insert(skill.name.clone()));
230 outcome.skills.sort_by(|a, b| a.name.cmp(&b.name));
231}
232
233#[derive(Debug, Default, Deserialize)]
234struct CodexConfig {
235 #[serde(default)]
236 skills: CodexSkillsConfig,
237}
238
239#[derive(Debug, Default, Deserialize)]
240struct CodexSkillsConfig {
241 #[serde(default)]
242 config: Vec<CodexSkillToggle>,
243}
244
245#[derive(Debug, Deserialize)]
246struct CodexSkillToggle {
247 #[serde(default)]
248 path: Option<PathBuf>,
249 #[serde(default)]
250 name: Option<String>,
251 #[serde(default = "default_skill_toggle_enabled")]
252 enabled: bool,
253}
254
255#[derive(Debug, Default)]
256struct DisabledSkillSelectors {
257 paths: HashSet<PathBuf>,
258 names: HashSet<String>,
259}
260
261fn default_skill_toggle_enabled() -> bool {
262 true
263}
264
265fn filter_disabled_skills(outcome: &mut SkillLoadOutcome, home_dir: Option<&Path>) {
266 let disabled = disabled_skill_selectors(home_dir);
267 if disabled.paths.is_empty() && disabled.names.is_empty() {
268 return;
269 }
270
271 outcome.skills.retain(|skill| {
272 let canonical = dunce::canonicalize(&skill.path).unwrap_or_else(|_| skill.path.clone());
273 !disabled.paths.contains(&canonical) && !disabled.names.contains(&skill.name)
274 });
275}
276
277fn disabled_skill_selectors(home_dir: Option<&Path>) -> DisabledSkillSelectors {
278 let Some(home_dir) = home_dir else {
279 return DisabledSkillSelectors::default();
280 };
281
282 let config_path = home_dir.join(".codex").join("config.toml");
283 let Ok(content) = fs::read_to_string(&config_path) else {
284 return DisabledSkillSelectors::default();
285 };
286 let Ok(config) = toml::from_str::<CodexConfig>(&content) else {
287 return DisabledSkillSelectors::default();
288 };
289
290 let mut selectors = DisabledSkillSelectors::default();
291 for entry in config
292 .skills
293 .config
294 .into_iter()
295 .filter(|entry| !entry.enabled)
296 {
297 if let Some(path) = entry.path {
298 selectors
299 .paths
300 .insert(dunce::canonicalize(&path).unwrap_or(path));
301 }
302 if let Some(name) = entry.name.map(|name| name.trim().to_string())
303 && !name.is_empty()
304 {
305 selectors.names.insert(name);
306 }
307 }
308
309 selectors
310}
311
312fn skill_roots_with_home_dir(
313 config: &SkillLoaderConfig,
314 home_dir: Option<&Path>,
315) -> 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/skills"),
351 scope: SkillScope::User,
352 is_tool_root: false,
353 is_plugin_root: false,
354 });
355 }
356
357 #[cfg(unix)]
358 roots.push(SkillRoot {
359 path: PathBuf::from("/etc/codex/skills"),
360 scope: SkillScope::Admin,
361 is_tool_root: false,
362 is_plugin_root: false,
363 });
364
365 if config.include_bundled_system_skills {
366 roots.push(SkillRoot {
367 path: system_cache_root_dir(&config.codex_home),
368 scope: SkillScope::System,
369 is_tool_root: false,
370 is_plugin_root: false,
371 });
372 }
373
374 roots
375}
376
377fn repo_skill_search_dirs(config: &SkillLoaderConfig) -> Vec<PathBuf> {
378 let stop = config
379 .project_root
380 .clone()
381 .unwrap_or_else(|| config.cwd.clone());
382 let mut dirs = Vec::new();
383 let mut current = config.cwd.clone();
384
385 loop {
386 dirs.push(current.clone());
387 if current == stop {
388 break;
389 }
390 let Some(parent) = current.parent() else {
391 break;
392 };
393 current = parent.to_path_buf();
394 }
395
396 dirs
397}
398
399fn find_git_root(path: &Path) -> Option<PathBuf> {
400 let mut current = Some(path);
401 while let Some(dir) = current {
402 if dir.join(".git").exists() {
403 return Some(dir.to_path_buf());
404 }
405 current = dir.parent();
406 }
407 None
408}
409
410fn discover_skills_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
411 let Ok(root_path) = dunce::canonicalize(&root.path) else {
412 return;
413 };
414
415 if !root_path.is_dir() {
416 return;
417 }
418
419 let mut queue: VecDeque<PathBuf> = VecDeque::from([root_path]);
420 while let Some(dir) = queue.pop_front() {
421 let entries = match fs::read_dir(&dir) {
422 Ok(entries) => entries,
423 Err(e) => {
424 error!("failed to read skills dir {}: {e:#}", dir.display());
425 continue;
426 }
427 };
428
429 for entry in entries.flatten() {
430 let path = entry.path();
431 let file_name = match path.file_name().and_then(|f| f.to_str()) {
432 Some(name) => name,
433 None => continue,
434 };
435
436 if file_name.starts_with('.') {
437 continue;
438 }
439
440 if path.is_dir() {
441 queue.push_back(path.clone());
442
443 if root.is_tool_root
446 && let Ok(Some(tool_meta)) = try_load_tool_from_dir(&path, root.scope)
447 {
448 outcome.skills.push(tool_meta);
449 }
450
451 if root.is_plugin_root
453 && let Ok(Some(plugin_meta)) = try_load_plugin_from_dir(&path, root.scope)
454 {
455 outcome.skills.push(plugin_meta);
456 }
457 continue;
458 }
459
460 if file_name == "SKILL.md" {
462 let Some(skill_dir) = path.parent() else {
463 continue;
464 };
465 match crate::skills::manifest::parse_skill_file(skill_dir) {
466 Ok((manifest, _)) => {
467 outcome.skills.push(SkillMetadata {
468 name: manifest.name.clone(),
469 description: manifest.description.clone(),
470 short_description: None,
471 path: path.clone(),
472 scope: root.scope,
473 manifest: Some(manifest.into()),
474 });
475 }
476 Err(err) => {
477 if root.scope != SkillScope::System {
478 outcome.errors.push(SkillErrorInfo {
479 path: path.clone(),
480 message: err.to_string(),
481 });
482 }
483 }
484 }
485 } else if root.is_tool_root && is_executable_file(&path) {
486 }
490 }
491 }
492}
493
494fn discover_metadata_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
497 let Ok(root_path) = dunce::canonicalize(&root.path) else {
498 return;
499 };
500
501 if !root_path.is_dir() {
502 return;
503 }
504
505 let mut queue: VecDeque<PathBuf> = VecDeque::from([root_path]);
506 while let Some(dir) = queue.pop_front() {
507 let entries = match fs::read_dir(&dir) {
508 Ok(entries) => entries,
509 Err(e) => {
510 tracing::debug!("failed to read skills dir {}: {e:#}", dir.display());
511 continue;
512 }
513 };
514
515 for entry in entries.flatten() {
516 let path = entry.path();
517 let file_name = match path.file_name().and_then(|f| f.to_str()) {
518 Some(name) => name,
519 None => continue,
520 };
521
522 if file_name.starts_with('.') {
523 continue;
524 }
525
526 if path.is_dir() {
527 queue.push_back(path.clone());
528
529 if root.is_tool_root
531 && let Ok(Some(tool_meta)) = try_load_tool_from_dir(&path, root.scope)
532 {
533 outcome.skills.push(tool_meta);
534 }
535 continue;
536 }
537
538 if file_name == "SKILL.md" {
539 match fs::read_to_string(&path)
540 .with_context(|| format!("reading {}", path.display()))
541 {
542 Ok(contents) => match crate::skills::manifest::parse_skill_content(&contents) {
543 Ok((manifest, _)) => {
544 outcome.skills.push(SkillMetadata {
545 name: manifest.name.clone(),
546 description: manifest.description.clone(),
547 short_description: None,
548 path: path.clone(),
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 {
556 path: path.clone(),
557 message: err.to_string(),
558 });
559 }
560 }
561 },
562 Err(err) => {
563 if root.scope != SkillScope::System {
564 outcome.errors.push(SkillErrorInfo {
565 path: path.clone(),
566 message: err.to_string(),
567 });
568 }
569 }
570 }
571 }
572 }
573 }
574}
575
576fn try_load_tool_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
577 let tool_bridge = if path.join("tool.json").exists() {
580 CliToolBridge::from_directory(path)?
581 } else {
582 match CliToolBridge::from_directory(path) {
586 Ok(b) => b,
587 Err(_) => return Ok(None),
588 }
589 };
590
591 tool_config_to_metadata(&tool_bridge.config, scope).map(Some)
592}
593
594fn tool_config_to_metadata(config: &CliToolConfig, scope: SkillScope) -> Result<SkillMetadata> {
595 Ok(SkillMetadata {
596 name: config.name.clone(),
597 description: config.description.clone(),
598 short_description: None,
599 path: config.executable_path.clone(), scope,
603 manifest: None, })
605}
606
607fn try_load_plugin_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
608 let plugin_json_path = path.join("plugin.json");
610 if !plugin_json_path.exists() {
611 return Ok(None);
612 }
613
614 let plugin_json_content =
616 fs::read_to_string(&plugin_json_path).context("Failed to read plugin.json")?;
617
618 let plugin_metadata: crate::skills::native_plugin::PluginMetadata =
619 serde_json::from_str(&plugin_json_content).context("Invalid plugin.json format")?;
620
621 let lib_name =
623 crate::skills::native_plugin::PluginLoader::new().library_filename(&plugin_metadata.name);
624
625 if !path.join(&lib_name).exists() {
626 let alternatives = [
628 format!("lib{}.dylib", plugin_metadata.name),
629 format!("{}.dylib", plugin_metadata.name),
630 format!("lib{}.so", plugin_metadata.name),
631 format!("{}.so", plugin_metadata.name),
632 format!("{}.dll", plugin_metadata.name),
633 ];
634
635 let has_lib = alternatives.iter().any(|alt| path.join(alt).exists());
636 if !has_lib {
637 return Ok(None); }
639 }
640
641 Ok(Some(SkillMetadata {
642 name: plugin_metadata.name.clone(),
643 description: plugin_metadata.description.clone(),
644 short_description: None,
645 path: path.to_path_buf(),
646 scope,
647 manifest: None, }))
649}
650
651pub fn load_skill_resources(skill_path: &Path) -> Result<Vec<crate::skills::types::SkillResource>> {
652 let mut resources = Vec::new();
653 let resource_dir = skill_path.join("scripts");
654
655 if resource_dir.exists() {
656 for entry in fs::read_dir(&resource_dir)? {
657 let entry = entry?;
658 let path = entry.path();
659
660 if path.is_file() {
661 let rel_path = path
662 .strip_prefix(skill_path)
663 .map(|p| p.to_string_lossy().to_string())
664 .unwrap_or_default();
665
666 let resource_type = match path.extension().and_then(|e| e.to_str()) {
667 Some("py") | Some("sh") | Some("bash") => {
668 crate::skills::types::ResourceType::Script
669 }
670 Some("md") => crate::skills::types::ResourceType::Markdown,
671 Some("json") | Some("yaml") | Some("yml") => {
672 crate::skills::types::ResourceType::Reference
673 }
674 _ => {
675 crate::skills::types::ResourceType::Other(format!("{:?}", path.extension()))
676 }
677 };
678
679 resources.push(crate::skills::types::SkillResource {
680 path: rel_path,
681 resource_type,
682 content: None,
683 });
684 }
685 }
686 }
687
688 let references_dir = skill_path.join("references");
690 if references_dir.exists() {
691 for entry in fs::read_dir(&references_dir)? {
692 let entry = entry?;
693 let path = entry.path();
694
695 if path.is_file() {
696 let rel_path = path
697 .strip_prefix(skill_path)
698 .map(|p| p.to_string_lossy().to_string())
699 .unwrap_or_default();
700
701 let resource_type = match path.extension().and_then(|e| e.to_str()) {
702 Some("md") => crate::skills::types::ResourceType::Reference,
703 Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
704 crate::skills::types::ResourceType::Reference
705 }
706 _ => {
707 crate::skills::types::ResourceType::Other(format!("{:?}", path.extension()))
708 }
709 };
710
711 resources.push(crate::skills::types::SkillResource {
712 path: rel_path,
713 resource_type,
714 content: None,
715 });
716 }
717 }
718 }
719
720 let assets_dir = skill_path.join("assets");
722 if assets_dir.exists() {
723 for entry in fs::read_dir(&assets_dir)? {
724 let entry = entry?;
725 let path = entry.path();
726
727 if path.is_file() {
728 let rel_path = path
729 .strip_prefix(skill_path)
730 .map(|p| p.to_string_lossy().to_string())
731 .unwrap_or_default();
732
733 let resource_type = match path.extension().and_then(|e| e.to_str()) {
734 Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("svg") => {
735 crate::skills::types::ResourceType::Asset
736 }
737 Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
738 crate::skills::types::ResourceType::Asset
739 }
740 _ => crate::skills::types::ResourceType::Asset,
741 };
742
743 resources.push(crate::skills::types::SkillResource {
744 path: rel_path,
745 resource_type,
746 content: None,
747 });
748 }
749 }
750 }
751
752 Ok(resources)
753}
754
755fn is_executable_file(path: &Path) -> bool {
756 #[cfg(unix)]
757 {
758 use std::os::unix::fs::PermissionsExt;
759 if let Ok(meta) = path.metadata() {
760 return meta.permissions().mode() & 0o111 != 0;
761 }
762 }
763 #[cfg(windows)]
764 {
765 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
766 return matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd");
767 }
768 }
769 false
770}
771
772pub enum EnhancedSkill {
774 Traditional(Box<Skill>),
776 CliTool(Box<CliToolBridge>),
778 BuiltInCommand(Box<BuiltInCommandSkill>),
780 NativePlugin(Box<dyn crate::skills::native_plugin::NativePluginTrait>),
782}
783
784impl std::fmt::Debug for EnhancedSkill {
785 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
786 match self {
787 Self::Traditional(skill) => f.debug_tuple("Traditional").field(skill).finish(),
788 Self::CliTool(tool) => f.debug_tuple("CliTool").field(tool).finish(),
789 Self::BuiltInCommand(skill) => f.debug_tuple("BuiltInCommand").field(skill).finish(),
790 Self::NativePlugin(plugin) => f.debug_tuple("NativePlugin").field(plugin).finish(),
791 }
792 }
793}
794
795pub struct EnhancedSkillLoader {
797 workspace_root: PathBuf,
798 codex_home: PathBuf,
799 discovery: SkillDiscovery,
800 plugin_loader: crate::skills::native_plugin::PluginLoader,
801}
802
803fn plugin_loader_for_workspace(
804 workspace_root: &Path,
805 codex_home: Option<&Path>,
806) -> crate::skills::native_plugin::PluginLoader {
807 let mut plugin_loader = crate::skills::native_plugin::PluginLoader::new();
808
809 if let Some(codex_home) = codex_home {
810 plugin_loader.add_trusted_dir(codex_home.join("plugins"));
811 } else {
812 plugin_loader.add_trusted_dir(dirs::home_dir().unwrap_or_default().join(".vtcode/plugins"));
813 }
814
815 plugin_loader
816 .add_trusted_dir(workspace_root.join(".vtcode/plugins"))
817 .add_trusted_dir(workspace_root.join(".agents/plugins"));
818
819 plugin_loader
820}
821
822fn default_codex_home() -> PathBuf {
823 std::env::var_os("CODEX_HOME")
824 .filter(|value| !value.is_empty())
825 .map(PathBuf::from)
826 .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
827 .unwrap_or_else(|| PathBuf::from(".codex"))
828}
829
830fn discovery_config_for_codex_home(workspace_root: &Path, codex_home: &Path) -> DiscoveryConfig {
831 let home_dir = dirs::home_dir();
832 let loader_config = SkillLoaderConfig {
833 codex_home: codex_home.to_path_buf(),
834 cwd: workspace_root.to_path_buf(),
835 project_root: find_git_root(workspace_root),
836 include_bundled_system_skills: true,
837 };
838 let roots = skill_roots_with_home_dir(&loader_config, home_dir.as_deref());
839
840 DiscoveryConfig {
841 skill_paths: roots
842 .iter()
843 .filter(|root| !root.is_tool_root && !root.is_plugin_root)
844 .map(|root| root.path.clone())
845 .collect(),
846 tool_paths: roots
847 .iter()
848 .filter(|root| root.is_tool_root)
849 .map(|root| root.path.clone())
850 .collect(),
851 ..Default::default()
852 }
853}
854
855impl EnhancedSkillLoader {
856 pub fn new(workspace_root: PathBuf) -> Self {
858 let codex_home = default_codex_home();
859 let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(
860 &workspace_root,
861 &codex_home,
862 ));
863 let plugin_loader = plugin_loader_for_workspace(&workspace_root, Some(&codex_home));
864 Self {
865 workspace_root,
866 codex_home,
867 discovery,
868 plugin_loader,
869 }
870 }
871
872 pub fn with_codex_home(workspace_root: PathBuf, codex_home: PathBuf) -> Self {
874 let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(
875 &workspace_root,
876 &codex_home,
877 ));
878 let plugin_loader = plugin_loader_for_workspace(&workspace_root, Some(&codex_home));
879 Self {
880 workspace_root,
881 codex_home,
882 discovery,
883 plugin_loader,
884 }
885 }
886
887 fn ensure_system_skills_installed(&self) {
888 if let Err(err) = install_system_skills(&self.codex_home) {
889 tracing::warn!("enhanced skill loader failed to install bundled system skills: {err}");
890 }
891 }
892
893 pub async fn discover_all_skills(&mut self) -> Result<DiscoveryResult> {
895 self.ensure_system_skills_installed();
896 let mut result = self.discovery.discover_all(&self.workspace_root).await?;
897 merge_built_in_command_skill_contexts(&mut result.skills);
898 Ok(result)
899 }
900
901 pub async fn get_skill(&mut self, name: &str) -> Result<EnhancedSkill> {
903 self.ensure_system_skills_installed();
904 let result = self.discovery.discover_all(&self.workspace_root).await?;
905
906 for skill_ctx in &result.skills {
908 if skill_ctx.manifest().name == name {
909 let path = skill_ctx.path();
910 let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
911 let skill = Skill::with_scope(
912 manifest,
913 path.clone(),
914 infer_scope_from_skill_path(path, &self.workspace_root),
915 instructions,
916 )?;
917 return Ok(EnhancedSkill::Traditional(Box::new(skill)));
918 }
919 }
920
921 for tool_config in &result.tools {
923 if tool_config.name == name {
924 let bridge = CliToolBridge::new(tool_config.clone())?;
925 return Ok(EnhancedSkill::CliTool(Box::new(bridge)));
926 }
927 }
928
929 if let Some(skill) = built_in_command_skill(name) {
930 return Ok(EnhancedSkill::BuiltInCommand(Box::new(skill)));
931 }
932
933 for plugin_dir in self.get_plugin_directories() {
936 if !plugin_dir.exists() {
937 continue;
938 }
939
940 let plugin_json = plugin_dir.join("plugin.json");
942 if let Ok(content) = fs::read_to_string(&plugin_json)
943 && let Ok(metadata) =
944 serde_json::from_str::<crate::skills::native_plugin::PluginMetadata>(&content)
945 && metadata.name == name
946 {
947 let plugin = self.plugin_loader.load_plugin(&plugin_dir)?;
949 return Ok(EnhancedSkill::NativePlugin(plugin));
950 }
951 }
952
953 Err(skill_ops::skill_not_found_error(name))
954 }
955
956 fn get_plugin_directories(&self) -> Vec<PathBuf> {
958 self.plugin_loader.trusted_dirs().to_vec()
959 }
960
961 pub async fn generate_validation_report(&mut self) -> Result<ContainerValidationReport> {
963 let result = self.discovery.discover_all(&self.workspace_root).await?;
964 let mut report = ContainerValidationReport::new();
965 let validator = ContainerSkillsValidator::new();
966
967 for skill_ctx in &result.skills {
968 match self.load_full_skill_from_ctx(skill_ctx) {
969 Ok(skill) => {
970 let analysis = validator.analyze_skill(&skill);
971 report.add_skill_analysis(skill.name().to_string(), analysis);
972 }
973 Err(e) => {
974 report.add_incompatible_skill(
975 skill_ctx.manifest().name.clone(),
976 skill_ctx.manifest().description.clone(),
977 format!("Load error: {}", e),
978 );
979 }
980 }
981 }
982
983 report.finalize();
984 Ok(report)
985 }
986
987 pub fn check_container_requirements(&self, skill: &Skill) -> ContainerValidationResult {
989 let validator = ContainerSkillsValidator::new();
990 validator.analyze_skill(skill)
991 }
992
993 fn load_full_skill_from_ctx(&self, ctx: &SkillContext) -> Result<Skill> {
994 let path = ctx.path();
995 let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
996 Skill::with_scope(
997 manifest,
998 path.clone(),
999 infer_scope_from_skill_path(path, &self.workspace_root),
1000 instructions,
1001 )
1002 }
1003}
1004
1005fn infer_scope_from_skill_path(path: &Path, workspace_root: &Path) -> SkillScope {
1006 if path.starts_with(Path::new("/etc/codex/skills")) {
1007 return SkillScope::Admin;
1008 }
1009 if path.starts_with(system_cache_root_dir(&default_codex_home())) {
1010 return SkillScope::System;
1011 }
1012 if let Some(home) = dirs::home_dir()
1013 && path.starts_with(home.join(".agents/skills"))
1014 {
1015 return SkillScope::User;
1016 }
1017 if path.starts_with(workspace_root)
1018 || path.to_string_lossy().contains("/.agents/skills/")
1019 || path.to_string_lossy().contains("\\.agents\\skills\\")
1020 {
1021 return SkillScope::Repo;
1022 }
1023 SkillScope::User
1024}
1025
1026#[derive(Debug, Clone, Copy)]
1027pub struct SkillMentionDetectionOptions {
1028 pub enable_auto_trigger: bool,
1029 pub enable_description_matching: bool,
1030 pub min_keyword_matches: usize,
1031}
1032
1033impl Default for SkillMentionDetectionOptions {
1034 fn default() -> Self {
1035 Self {
1036 enable_auto_trigger: true,
1037 enable_description_matching: true,
1038 min_keyword_matches: 2,
1039 }
1040 }
1041}
1042
1043pub fn detect_skill_mentions(user_input: &str, available_skills: &[SkillManifest]) -> Vec<String> {
1045 detect_skill_mentions_with_options(
1046 user_input,
1047 available_skills,
1048 &SkillMentionDetectionOptions::default(),
1049 )
1050}
1051
1052pub fn detect_skill_mentions_with_options(
1058 user_input: &str,
1059 available_skills: &[SkillManifest],
1060 options: &SkillMentionDetectionOptions,
1061) -> Vec<String> {
1062 if !options.enable_auto_trigger {
1063 return Vec::new();
1064 }
1065
1066 let mut mentions = Vec::new();
1067 let input_lower = user_input.to_lowercase();
1068 let input_keywords = extract_keywords(user_input);
1069 let min_matches = options.min_keyword_matches.max(1);
1070
1071 for skill in available_skills {
1072 let skill_name_lower = skill.name.to_lowercase();
1073 let explicit_trigger = format!("${skill_name_lower}");
1074 if input_lower.contains(&explicit_trigger) {
1075 mentions.push(skill.name.clone());
1076 continue;
1077 }
1078
1079 if !options.enable_description_matching {
1080 continue;
1081 }
1082
1083 let description_keywords = extract_keywords(&skill.description);
1084 let description_matches = overlap_count(&input_keywords, &description_keywords);
1085 if description_matches >= min_matches {
1086 mentions.push(skill.name.clone());
1087 }
1088 }
1089
1090 mentions.sort();
1091 mentions.dedup();
1092 mentions
1093}
1094
1095fn overlap_count(input_keywords: &HashSet<String>, skill_keywords: &HashSet<String>) -> usize {
1096 input_keywords.intersection(skill_keywords).count()
1097}
1098
1099fn extract_keywords(text: &str) -> HashSet<String> {
1100 const STOPWORDS: &[&str] = &[
1101 "the", "and", "with", "from", "that", "this", "when", "where", "what", "your", "for",
1102 "into", "onto", "than", "then", "also", "only", "should", "would", "could", "have", "has",
1103 "had", "use", "using", "task", "tasks", "help", "need", "want",
1104 ];
1105
1106 text.split(|c: char| !c.is_alphanumeric())
1107 .map(|part| part.trim().to_lowercase())
1108 .filter(|part| part.len() > 2)
1109 .filter(|part| !STOPWORDS.contains(&part.as_str()))
1110 .collect()
1111}
1112
1113#[cfg(test)]
1116pub fn load_skills_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1117 load_skills_with_home_dir(config, None)
1118}
1119
1120#[cfg(test)]
1122pub fn discover_skill_metadata_lightweight_hermetic(
1123 config: &SkillLoaderConfig,
1124) -> SkillLoadOutcome {
1125 discover_skill_metadata_lightweight_with_home_dir(config, None)
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131 use crate::skills::CommandSkillBackend;
1132 use crate::skills::command_skills::command_skill_specs;
1133 use crate::skills::system::{install_system_skills, system_cache_root_dir};
1134 use serial_test::serial;
1135 use std::fs;
1136 use tempfile::TempDir;
1137 use tempfile::tempdir;
1138
1139 fn manifest(name: &str, description: &str) -> SkillManifest {
1140 SkillManifest {
1141 name: name.to_string(),
1142 description: description.to_string(),
1143 ..Default::default()
1144 }
1145 }
1146
1147 #[test]
1148 fn detects_explicit_skill_mentions() {
1149 let skills = vec![manifest(
1150 "pdf-analyzer",
1151 "Analyze PDF files and extract tables",
1152 )];
1153 let mentions = detect_skill_mentions("Use $pdf-analyzer for this file", &skills);
1154 assert_eq!(mentions, vec!["pdf-analyzer".to_string()]);
1155 }
1156
1157 #[test]
1158 fn description_keywords_drive_implicit_matches() {
1159 let skills = vec![manifest(
1160 "api-fetcher",
1161 "Fetch data from API endpoints and summarize responses",
1162 )];
1163
1164 let mentions = detect_skill_mentions(
1165 "Fetch and summarize API responses for these endpoints",
1166 &skills,
1167 );
1168 assert_eq!(mentions, vec!["api-fetcher".to_string()]);
1169 }
1170
1171 #[test]
1172 fn unrelated_input_does_not_match_description() {
1173 let skills = vec![manifest(
1174 "api-fetcher",
1175 "Fetch data from API endpoints and summarize responses",
1176 )];
1177
1178 let mentions = detect_skill_mentions(
1179 "Please update this local markdown file and fix headings",
1180 &skills,
1181 );
1182 assert!(mentions.is_empty());
1183 }
1184
1185 #[test]
1186 fn auto_trigger_can_be_disabled() {
1187 let skills = vec![manifest(
1188 "sql-checker",
1189 "Validate SQL migration scripts for safety",
1190 )];
1191 let options = SkillMentionDetectionOptions {
1192 enable_auto_trigger: false,
1193 ..Default::default()
1194 };
1195 let mentions = detect_skill_mentions_with_options("Use $sql-checker", &skills, &options);
1196 assert!(mentions.is_empty());
1197 }
1198
1199 #[test]
1200 #[serial]
1201 fn lightweight_metadata_discovery_reuses_process_wide_cache() {
1202 clear_lightweight_skill_metadata_cache();
1203
1204 let codex_home = tempdir().expect("codex home");
1205 let workspace = tempdir().expect("workspace");
1206 let skill_dir = workspace
1207 .path()
1208 .join(".agents/skills/process-wide-cache-skill");
1209 fs::create_dir_all(&skill_dir).expect("create skill dir");
1210 fs::write(
1211 skill_dir.join("SKILL.md"),
1212 "---\nname: process-wide-cache-skill\ndescription: process-wide cache test\n---\n# Body\n",
1213 )
1214 .expect("write skill");
1215
1216 let config = SkillLoaderConfig {
1217 codex_home: codex_home.path().to_path_buf(),
1218 cwd: workspace.path().to_path_buf(),
1219 project_root: Some(workspace.path().to_path_buf()),
1220 include_bundled_system_skills: false,
1221 };
1222
1223 let first = discover_skill_metadata_lightweight_hermetic(&config);
1224 assert!(
1225 first
1226 .skills
1227 .iter()
1228 .any(|skill| skill.name == "process-wide-cache-skill"),
1229 "expected first discovery to find test skill",
1230 );
1231
1232 fs::remove_dir_all(&skill_dir).expect("remove cached skill dir");
1233
1234 let second = discover_skill_metadata_lightweight_hermetic(&config);
1235 assert!(
1236 second
1237 .skills
1238 .iter()
1239 .any(|skill| skill.name == "process-wide-cache-skill"),
1240 "expected cached discovery to preserve removed skill until cache is cleared",
1241 );
1242
1243 clear_lightweight_skill_metadata_cache();
1244
1245 let third = discover_skill_metadata_lightweight_hermetic(&config);
1246 assert!(
1247 !third
1248 .skills
1249 .iter()
1250 .any(|skill| skill.name == "process-wide-cache-skill"),
1251 "expected cleared cache to force rediscovery",
1252 );
1253 }
1254
1255 #[tokio::test]
1256 async fn enhanced_loader_discovers_and_loads_built_in_command_skills() {
1257 let temp_dir = TempDir::new().expect("temp dir");
1258 let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1259
1260 let discovery = loader.discover_all_skills().await.expect("discover skills");
1261 assert!(
1262 discovery
1263 .skills
1264 .iter()
1265 .any(|skill_ctx| skill_ctx.manifest().name == "cmd-status")
1266 );
1267
1268 let skill = loader
1269 .get_skill("cmd-status")
1270 .await
1271 .expect("load cmd-status");
1272 assert!(matches!(skill, EnhancedSkill::BuiltInCommand(_)));
1273 }
1274
1275 #[tokio::test]
1276 async fn enhanced_loader_discovers_and_loads_bundled_command_skills() {
1277 let workspace = TempDir::new().expect("workspace");
1278 let codex_home = TempDir::new().expect("codex home");
1279 install_system_skills(codex_home.path()).expect("install bundled system skills");
1280 let cmd_review_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1281 assert!(
1282 cmd_review_dir.join("SKILL.md").exists(),
1283 "expected bundled cmd-review at {}",
1284 cmd_review_dir.display()
1285 );
1286 let (manifest, _) =
1287 crate::skills::manifest::parse_skill_file(&cmd_review_dir).expect("parse cmd-review");
1288 assert_eq!(manifest.name, "cmd-review");
1289 let config = discovery_config_for_codex_home(workspace.path(), codex_home.path());
1290 assert!(
1291 config
1292 .skill_paths
1293 .iter()
1294 .any(|path| path == &system_cache_root_dir(codex_home.path()))
1295 );
1296 let mut loader = EnhancedSkillLoader::with_codex_home(
1297 workspace.path().to_path_buf(),
1298 codex_home.path().to_path_buf(),
1299 );
1300
1301 let discovery = loader.discover_all_skills().await.expect("discover skills");
1302 assert!(
1303 discovery
1304 .skills
1305 .iter()
1306 .any(|skill_ctx| skill_ctx.manifest().name == "cmd-review")
1307 );
1308
1309 let skill = loader
1310 .get_skill("cmd-review")
1311 .await
1312 .expect("load cmd-review");
1313 assert!(matches!(skill, EnhancedSkill::Traditional(_)));
1314 }
1315
1316 #[tokio::test]
1317 async fn enhanced_loader_discovers_every_command_skill() {
1318 let workspace = TempDir::new().expect("workspace");
1319 let codex_home = TempDir::new().expect("codex home");
1320 let mut loader = EnhancedSkillLoader::with_codex_home(
1321 workspace.path().to_path_buf(),
1322 codex_home.path().to_path_buf(),
1323 );
1324
1325 let discovery = loader.discover_all_skills().await.expect("discover skills");
1326 let discovered_names = discovery
1327 .skills
1328 .iter()
1329 .map(|skill_ctx| skill_ctx.manifest().name.as_str())
1330 .collect::<std::collections::HashSet<_>>();
1331
1332 for spec in command_skill_specs() {
1333 assert!(
1334 discovered_names.contains(spec.skill_name),
1335 "missing command skill {}",
1336 spec.skill_name
1337 );
1338
1339 let skill = loader
1340 .get_skill(spec.skill_name)
1341 .await
1342 .unwrap_or_else(|error| panic!("failed to load {}: {error}", spec.skill_name));
1343
1344 match spec.backend {
1345 CommandSkillBackend::TraditionalSkill { .. } => {
1346 assert!(
1347 matches!(skill, EnhancedSkill::Traditional(_)),
1348 "{} should load as a traditional skill",
1349 spec.skill_name
1350 );
1351 }
1352 CommandSkillBackend::BuiltInCommand { .. } => {
1353 assert!(
1354 matches!(skill, EnhancedSkill::BuiltInCommand(_)),
1355 "{} should load as a built-in command skill",
1356 spec.skill_name
1357 );
1358 }
1359 }
1360 }
1361 }
1362
1363 fn write_skill(dir: &Path, name: &str, description: &str) {
1364 fs::create_dir_all(dir).expect("create skill dir");
1365 fs::write(
1366 dir.join("SKILL.md"),
1367 format!("---\nname: {name}\ndescription: {description}\n---\n\nUse this skill.\n"),
1368 )
1369 .expect("write SKILL.md");
1370 }
1371
1372 fn write_codex_skill_config(home_dir: &Path, contents: &str) {
1373 let config_dir = home_dir.join(".codex");
1374 fs::create_dir_all(&config_dir).expect("create config dir");
1375 fs::write(config_dir.join("config.toml"), contents).expect("write config");
1376 }
1377
1378 fn skill_loader_config_for(workspace: &Path, codex_home: &Path) -> SkillLoaderConfig {
1379 SkillLoaderConfig {
1380 codex_home: codex_home.to_path_buf(),
1381 cwd: workspace.to_path_buf(),
1382 project_root: find_git_root(workspace),
1383 include_bundled_system_skills: false,
1384 }
1385 }
1386
1387 #[test]
1388 fn disabled_skill_config_supports_stable_names() {
1389 let workspace = tempdir().expect("workspace");
1390 fs::create_dir(workspace.path().join(".git")).expect("create .git");
1391
1392 let home = tempdir().expect("home");
1393 let codex_home = tempdir().expect("codex home");
1394
1395 let old_plugin_skill_dir = workspace
1396 .path()
1397 .join(".agents/plugins/example-plugin-v1/skills/release-helper");
1398 write_skill(
1399 &old_plugin_skill_dir,
1400 "release-helper",
1401 "Prepare release notes",
1402 );
1403
1404 write_codex_skill_config(
1405 home.path(),
1406 &format!(
1407 "[[skills.config]]\nname = \"release-helper\"\npath = \"{}\"\nenabled = false\n",
1408 old_plugin_skill_dir.display()
1409 ),
1410 );
1411
1412 let new_plugin_skill_dir = workspace
1413 .path()
1414 .join(".agents/plugins/example-plugin-v2/skills/release-helper");
1415 write_skill(
1416 &new_plugin_skill_dir,
1417 "release-helper",
1418 "Prepare release notes",
1419 );
1420
1421 fs::remove_dir_all(workspace.path().join(".agents/plugins/example-plugin-v1"))
1422 .expect("remove old plugin version");
1423
1424 let outcome = load_skills_with_home_dir(
1425 &skill_loader_config_for(workspace.path(), codex_home.path()),
1426 Some(home.path()),
1427 );
1428
1429 assert!(
1430 outcome
1431 .skills
1432 .iter()
1433 .all(|skill| skill.name != "release-helper"),
1434 "expected release-helper to stay disabled after plugin path changed"
1435 );
1436 }
1437
1438 #[test]
1439 fn disabled_skill_config_preserves_path_based_entries() {
1440 let workspace = tempdir().expect("workspace");
1441 let home = tempdir().expect("home");
1442 let codex_home = tempdir().expect("codex home");
1443
1444 let skill_dir = home.path().join(".agents/skills/path-disabled");
1445 write_skill(&skill_dir, "path-disabled", "Disabled by explicit path");
1446
1447 write_codex_skill_config(
1448 home.path(),
1449 &format!(
1450 "[[skills.config]]\npath = \"{}\"\nenabled = false\n",
1451 skill_dir.join("SKILL.md").display()
1452 ),
1453 );
1454
1455 let outcome = load_skills_with_home_dir(
1456 &skill_loader_config_for(workspace.path(), codex_home.path()),
1457 Some(home.path()),
1458 );
1459
1460 assert!(
1461 outcome
1462 .skills
1463 .iter()
1464 .all(|skill| skill.name != "path-disabled"),
1465 "expected path-disabled to remain filtered by legacy path config"
1466 );
1467 }
1468}