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 skill_keywords = skill_routing_keywords(skill);
1084 let keyword_matches = overlap_count(&input_keywords, &skill_keywords);
1085 if keyword_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 skill_routing_keywords(skill: &SkillManifest) -> HashSet<String> {
1100 let mut keywords = extract_keywords(&skill.description);
1101
1102 let Some(metadata) = &skill.metadata else {
1103 return keywords;
1104 };
1105 for metadata_keywords in [metadata.get("keywords"), metadata.get("tags")]
1106 .into_iter()
1107 .flatten()
1108 {
1109 match metadata_keywords {
1110 serde_json::Value::Array(values) => {
1111 for value in values {
1112 if let Some(keyword) = value.as_str() {
1113 keywords.extend(extract_keywords(keyword));
1114 }
1115 }
1116 }
1117 serde_json::Value::String(value) => {
1118 keywords.extend(extract_keywords(value));
1119 }
1120 _ => {}
1121 }
1122 }
1123
1124 keywords
1125}
1126
1127fn extract_keywords(text: &str) -> HashSet<String> {
1128 const STOPWORDS: &[&str] = &[
1129 "the", "and", "with", "from", "that", "this", "when", "where", "what", "your", "for",
1130 "into", "onto", "than", "then", "also", "only", "should", "would", "could", "have", "has",
1131 "had", "use", "using", "task", "tasks", "help", "need", "want",
1132 ];
1133
1134 text.split(|c: char| !c.is_alphanumeric())
1135 .map(|part| part.trim().to_lowercase())
1136 .filter(|part| part.len() > 2)
1137 .filter(|part| !STOPWORDS.contains(&part.as_str()))
1138 .collect()
1139}
1140
1141#[cfg(test)]
1144pub fn load_skills_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1145 load_skills_with_home_dir(config, None)
1146}
1147
1148#[cfg(test)]
1150pub fn discover_skill_metadata_lightweight_hermetic(
1151 config: &SkillLoaderConfig,
1152) -> SkillLoadOutcome {
1153 discover_skill_metadata_lightweight_with_home_dir(config, None)
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159 use crate::skills::CommandSkillBackend;
1160 use crate::skills::command_skills::command_skill_specs;
1161 use crate::skills::system::{install_system_skills, system_cache_root_dir};
1162 use serial_test::serial;
1163 use std::fs;
1164 use tempfile::TempDir;
1165 use tempfile::tempdir;
1166
1167 fn manifest(name: &str, description: &str) -> SkillManifest {
1168 SkillManifest {
1169 name: name.to_string(),
1170 description: description.to_string(),
1171 ..Default::default()
1172 }
1173 }
1174
1175 #[test]
1176 fn detects_explicit_skill_mentions() {
1177 let skills = vec![manifest(
1178 "pdf-analyzer",
1179 "Analyze PDF files and extract tables",
1180 )];
1181 let mentions = detect_skill_mentions("Use $pdf-analyzer for this file", &skills);
1182 assert_eq!(mentions, vec!["pdf-analyzer".to_string()]);
1183 }
1184
1185 #[test]
1186 fn description_keywords_drive_implicit_matches() {
1187 let skills = vec![manifest(
1188 "api-fetcher",
1189 "Fetch data from API endpoints and summarize responses",
1190 )];
1191
1192 let mentions = detect_skill_mentions(
1193 "Fetch and summarize API responses for these endpoints",
1194 &skills,
1195 );
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 (
1204 "keywords".to_string(),
1205 serde_json::json!(["tree-sitter parser"]),
1206 ),
1207 ("tags".to_string(), serde_json::json!(["optional chaining"])),
1208 ]));
1209
1210 let mentions = detect_skill_mentions("Debug an optional chaining rewrite", &[ast_grep]);
1211 assert_eq!(mentions, vec!["ast-grep".to_string()]);
1212 }
1213
1214 #[test]
1215 fn unrelated_input_does_not_match_description() {
1216 let skills = vec![manifest(
1217 "api-fetcher",
1218 "Fetch data from API endpoints and summarize responses",
1219 )];
1220
1221 let mentions = detect_skill_mentions(
1222 "Please update this local markdown file and fix headings",
1223 &skills,
1224 );
1225 assert!(mentions.is_empty());
1226 }
1227
1228 #[test]
1229 fn auto_trigger_can_be_disabled() {
1230 let skills = vec![manifest(
1231 "sql-checker",
1232 "Validate SQL migration scripts for safety",
1233 )];
1234 let options = SkillMentionDetectionOptions {
1235 enable_auto_trigger: false,
1236 ..Default::default()
1237 };
1238 let mentions = detect_skill_mentions_with_options("Use $sql-checker", &skills, &options);
1239 assert!(mentions.is_empty());
1240 }
1241
1242 #[test]
1243 #[serial]
1244 fn lightweight_metadata_discovery_reuses_process_wide_cache() {
1245 clear_lightweight_skill_metadata_cache();
1246
1247 let codex_home = tempdir().expect("codex home");
1248 let workspace = tempdir().expect("workspace");
1249 let skill_dir = workspace
1250 .path()
1251 .join(".agents/skills/process-wide-cache-skill");
1252 fs::create_dir_all(&skill_dir).expect("create skill dir");
1253 fs::write(
1254 skill_dir.join("SKILL.md"),
1255 "---\nname: process-wide-cache-skill\ndescription: process-wide cache test\n---\n# Body\n",
1256 )
1257 .expect("write skill");
1258
1259 let config = SkillLoaderConfig {
1260 codex_home: codex_home.path().to_path_buf(),
1261 cwd: workspace.path().to_path_buf(),
1262 project_root: Some(workspace.path().to_path_buf()),
1263 include_bundled_system_skills: false,
1264 };
1265
1266 let first = discover_skill_metadata_lightweight_hermetic(&config);
1267 assert!(
1268 first
1269 .skills
1270 .iter()
1271 .any(|skill| skill.name == "process-wide-cache-skill"),
1272 "expected first discovery to find test skill",
1273 );
1274
1275 fs::remove_dir_all(&skill_dir).expect("remove cached skill dir");
1276
1277 let second = discover_skill_metadata_lightweight_hermetic(&config);
1278 assert!(
1279 second
1280 .skills
1281 .iter()
1282 .any(|skill| skill.name == "process-wide-cache-skill"),
1283 "expected cached discovery to preserve removed skill until cache is cleared",
1284 );
1285
1286 clear_lightweight_skill_metadata_cache();
1287
1288 let third = discover_skill_metadata_lightweight_hermetic(&config);
1289 assert!(
1290 !third
1291 .skills
1292 .iter()
1293 .any(|skill| skill.name == "process-wide-cache-skill"),
1294 "expected cleared cache to force rediscovery",
1295 );
1296 }
1297
1298 #[tokio::test]
1299 async fn enhanced_loader_discovers_and_loads_built_in_command_skills() {
1300 let temp_dir = TempDir::new().expect("temp dir");
1301 let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1302
1303 let discovery = loader.discover_all_skills().await.expect("discover skills");
1304 assert!(
1305 discovery
1306 .skills
1307 .iter()
1308 .any(|skill_ctx| skill_ctx.manifest().name == "cmd-status")
1309 );
1310
1311 let skill = loader
1312 .get_skill("cmd-status")
1313 .await
1314 .expect("load cmd-status");
1315 assert!(matches!(skill, EnhancedSkill::BuiltInCommand(_)));
1316 }
1317
1318 #[tokio::test]
1319 async fn enhanced_loader_discovers_and_loads_bundled_command_skills() {
1320 let workspace = TempDir::new().expect("workspace");
1321 let codex_home = TempDir::new().expect("codex home");
1322 install_system_skills(codex_home.path()).expect("install bundled system skills");
1323 let cmd_review_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1324 assert!(
1325 cmd_review_dir.join("SKILL.md").exists(),
1326 "expected bundled cmd-review at {}",
1327 cmd_review_dir.display()
1328 );
1329 let (manifest, _) =
1330 crate::skills::manifest::parse_skill_file(&cmd_review_dir).expect("parse cmd-review");
1331 assert_eq!(manifest.name, "cmd-review");
1332 let config = discovery_config_for_codex_home(workspace.path(), codex_home.path());
1333 assert!(
1334 config
1335 .skill_paths
1336 .iter()
1337 .any(|path| path == &system_cache_root_dir(codex_home.path()))
1338 );
1339 let mut loader = EnhancedSkillLoader::with_codex_home(
1340 workspace.path().to_path_buf(),
1341 codex_home.path().to_path_buf(),
1342 );
1343
1344 let discovery = loader.discover_all_skills().await.expect("discover skills");
1345 assert!(
1346 discovery
1347 .skills
1348 .iter()
1349 .any(|skill_ctx| skill_ctx.manifest().name == "cmd-review")
1350 );
1351
1352 let skill = loader
1353 .get_skill("cmd-review")
1354 .await
1355 .expect("load cmd-review");
1356 assert!(matches!(skill, EnhancedSkill::Traditional(_)));
1357 }
1358
1359 #[tokio::test]
1360 async fn enhanced_loader_discovers_every_command_skill() {
1361 let workspace = TempDir::new().expect("workspace");
1362 let codex_home = TempDir::new().expect("codex home");
1363 let mut loader = EnhancedSkillLoader::with_codex_home(
1364 workspace.path().to_path_buf(),
1365 codex_home.path().to_path_buf(),
1366 );
1367
1368 let discovery = loader.discover_all_skills().await.expect("discover skills");
1369 let discovered_names = discovery
1370 .skills
1371 .iter()
1372 .map(|skill_ctx| skill_ctx.manifest().name.as_str())
1373 .collect::<std::collections::HashSet<_>>();
1374
1375 for spec in command_skill_specs() {
1376 assert!(
1377 discovered_names.contains(spec.skill_name),
1378 "missing command skill {}",
1379 spec.skill_name
1380 );
1381
1382 let skill = loader
1383 .get_skill(spec.skill_name)
1384 .await
1385 .unwrap_or_else(|error| panic!("failed to load {}: {error}", spec.skill_name));
1386
1387 match spec.backend {
1388 CommandSkillBackend::TraditionalSkill { .. } => {
1389 assert!(
1390 matches!(skill, EnhancedSkill::Traditional(_)),
1391 "{} should load as a traditional skill",
1392 spec.skill_name
1393 );
1394 }
1395 CommandSkillBackend::BuiltInCommand { .. } => {
1396 assert!(
1397 matches!(skill, EnhancedSkill::BuiltInCommand(_)),
1398 "{} should load as a built-in command skill",
1399 spec.skill_name
1400 );
1401 }
1402 }
1403 }
1404 }
1405
1406 fn write_skill(dir: &Path, name: &str, description: &str) {
1407 fs::create_dir_all(dir).expect("create skill dir");
1408 fs::write(
1409 dir.join("SKILL.md"),
1410 format!("---\nname: {name}\ndescription: {description}\n---\n\nUse this skill.\n"),
1411 )
1412 .expect("write SKILL.md");
1413 }
1414
1415 fn write_codex_skill_config(home_dir: &Path, contents: &str) {
1416 let config_dir = home_dir.join(".codex");
1417 fs::create_dir_all(&config_dir).expect("create config dir");
1418 fs::write(config_dir.join("config.toml"), contents).expect("write config");
1419 }
1420
1421 fn skill_loader_config_for(workspace: &Path, codex_home: &Path) -> SkillLoaderConfig {
1422 SkillLoaderConfig {
1423 codex_home: codex_home.to_path_buf(),
1424 cwd: workspace.to_path_buf(),
1425 project_root: find_git_root(workspace),
1426 include_bundled_system_skills: false,
1427 }
1428 }
1429
1430 #[test]
1431 fn disabled_skill_config_supports_stable_names() {
1432 let workspace = tempdir().expect("workspace");
1433 fs::create_dir(workspace.path().join(".git")).expect("create .git");
1434
1435 let home = tempdir().expect("home");
1436 let codex_home = tempdir().expect("codex home");
1437
1438 let old_plugin_skill_dir = workspace
1439 .path()
1440 .join(".agents/plugins/example-plugin-v1/skills/release-helper");
1441 write_skill(
1442 &old_plugin_skill_dir,
1443 "release-helper",
1444 "Prepare release notes",
1445 );
1446
1447 write_codex_skill_config(
1448 home.path(),
1449 &format!(
1450 "[[skills.config]]\nname = \"release-helper\"\npath = \"{}\"\nenabled = false\n",
1451 old_plugin_skill_dir.display()
1452 ),
1453 );
1454
1455 let new_plugin_skill_dir = workspace
1456 .path()
1457 .join(".agents/plugins/example-plugin-v2/skills/release-helper");
1458 write_skill(
1459 &new_plugin_skill_dir,
1460 "release-helper",
1461 "Prepare release notes",
1462 );
1463
1464 fs::remove_dir_all(workspace.path().join(".agents/plugins/example-plugin-v1"))
1465 .expect("remove old plugin version");
1466
1467 let outcome = load_skills_with_home_dir(
1468 &skill_loader_config_for(workspace.path(), codex_home.path()),
1469 Some(home.path()),
1470 );
1471
1472 assert!(
1473 outcome
1474 .skills
1475 .iter()
1476 .all(|skill| skill.name != "release-helper"),
1477 "expected release-helper to stay disabled after plugin path changed"
1478 );
1479 }
1480
1481 #[test]
1482 fn disabled_skill_config_preserves_path_based_entries() {
1483 let workspace = tempdir().expect("workspace");
1484 let home = tempdir().expect("home");
1485 let codex_home = tempdir().expect("codex home");
1486
1487 let skill_dir = home.path().join(".agents/skills/path-disabled");
1488 write_skill(&skill_dir, "path-disabled", "Disabled by explicit path");
1489
1490 write_codex_skill_config(
1491 home.path(),
1492 &format!(
1493 "[[skills.config]]\npath = \"{}\"\nenabled = false\n",
1494 skill_dir.join("SKILL.md").display()
1495 ),
1496 );
1497
1498 let outcome = load_skills_with_home_dir(
1499 &skill_loader_config_for(workspace.path(), codex_home.path()),
1500 Some(home.path()),
1501 );
1502
1503 assert!(
1504 outcome
1505 .skills
1506 .iter()
1507 .all(|skill| skill.name != "path-disabled"),
1508 "expected path-disabled to remain filtered by legacy path config"
1509 );
1510 }
1511}