1use crate::skills::SkillMetadata;
2#[cfg(test)]
3use crate::skills::loader::discover_skill_metadata_lightweight_hermetic;
4use crate::skills::loader::{
5 SkillLoaderConfig, clear_lightweight_skill_metadata_cache, discover_skill_metadata_lightweight, load_skills,
6};
7use crate::skills::model::SkillLoadOutcome;
8use crate::skills::system::install_system_skills;
9use crate::skills::system::uninstall_system_skills;
10use crate::skills::types::Skill;
11use anyhow::Result;
12use hashbrown::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::OnceLock;
15use std::sync::RwLock;
16use std::time::{Duration, SystemTime};
17
18use crate::utils::file_utils::read_file_with_context_sync;
19
20#[derive(Clone)]
22struct CachedEntry<T> {
23 value: T,
24 timestamp: SystemTime,
25}
26
27impl<T> CachedEntry<T> {
28 fn new(value: T) -> Self {
29 Self { value, timestamp: SystemTime::now() }
30 }
31
32 fn is_expired(&self, ttl: Duration) -> bool {
33 self.timestamp.elapsed().unwrap_or(ttl) > ttl
34 }
35}
36
37pub struct SkillsManager {
38 codex_home: PathBuf,
39 bundled_skills_enabled: bool,
40 cache_by_cwd: RwLock<HashMap<PathBuf, CachedEntry<SkillLoadOutcome>>>,
42 max_cache_size: usize,
44 cache_ttl: Duration,
46 system_skills_initialized: OnceLock<()>,
48 instruction_cache: RwLock<HashMap<String, CachedEntry<Skill>>>,
50 max_instruction_cache_size: usize,
52 instruction_cache_ttl: Duration,
54}
55
56fn evict_cache<K, V>(
58 cache: &mut HashMap<K, V>,
59 max_size: usize,
60 ttl: Duration,
61 get_timestamp: impl Fn(&V) -> SystemTime,
62 is_expired: impl Fn(&V, Duration) -> bool,
63) where
64 K: Eq + std::hash::Hash + Clone,
65{
66 let expired: Vec<_> = cache
68 .iter()
69 .filter(|(_, v)| is_expired(v, ttl))
70 .map(|(k, _)| k.clone())
71 .collect();
72 for key in expired {
73 cache.remove(&key);
74 }
75
76 if cache.len() >= max_size {
78 let oldest_key = cache.iter().min_by_key(|(_, v)| get_timestamp(v)).map(|(k, _)| k.clone());
79 if let Some(key) = oldest_key {
80 cache.remove(&key);
81 }
82 }
83}
84
85impl SkillsManager {
86 pub fn new(codex_home: PathBuf) -> Self {
87 Self::new_with_bundled_skills_enabled(codex_home, true)
88 }
89
90 pub fn new_with_bundled_skills_enabled(codex_home: PathBuf, bundled_skills_enabled: bool) -> Self {
91 let manager = Self {
92 codex_home,
93 bundled_skills_enabled,
94 cache_by_cwd: RwLock::new(HashMap::new()),
95 max_cache_size: 10,
96 cache_ttl: Duration::from_secs(5 * 60), system_skills_initialized: OnceLock::new(),
98 instruction_cache: RwLock::new(HashMap::new()),
99 max_instruction_cache_size: 50, instruction_cache_ttl: Duration::from_secs(10 * 60), };
102
103 if !manager.bundled_skills_enabled {
104 uninstall_system_skills(&manager.codex_home);
105 }
106
107 manager
108 }
109
110 pub fn bundled_skills_enabled(&self) -> bool {
111 self.bundled_skills_enabled
112 }
113
114 pub fn ensure_system_skills_installed(&self) {
116 self.system_skills_initialized.get_or_init(|| {
117 if !self.bundled_skills_enabled {
118 return;
119 }
120
121 if let Err(err) = install_system_skills(&self.codex_home) {
123 tracing::warn!("lazy system skills installation failed: {err}");
124 }
125 });
126 }
127
128 pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
129 self.skills_for_cwd_with_options(cwd, false)
130 }
131
132 pub fn skills_for_cwd_with_options(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
133 self.ensure_system_skills_installed();
135
136 if !force_reload {
137 if let Ok(cache) = self.cache_by_cwd.read() {
138 if let Some(cached) = cache.get(cwd)
139 && !cached.is_expired(self.cache_ttl)
140 {
141 return cached.value.clone();
142 }
143 } else {
144 tracing::warn!("skills metadata cache lock poisoned while reading cache");
145 }
146 }
147
148 let project_root = find_git_root(cwd);
149
150 let config = SkillLoaderConfig {
151 codex_home: self.codex_home.clone(),
152 cwd: cwd.to_path_buf(),
153 project_root,
154 include_bundled_system_skills: self.bundled_skills_enabled,
155 };
156
157 let outcome = load_skills(&config);
158
159 if let Ok(mut cache) = self.cache_by_cwd.write() {
160 if cache.len() >= self.max_cache_size && !cache.contains_key(cwd) {
162 evict_cache(
163 &mut cache,
164 self.max_cache_size,
165 self.cache_ttl,
166 |v| v.timestamp,
167 |v, ttl| v.is_expired(ttl),
168 );
169 }
170
171 cache.insert(cwd.to_path_buf(), CachedEntry::new(outcome.clone()));
172 } else {
173 tracing::warn!("skills metadata cache lock poisoned while writing cache");
174 }
175
176 outcome
177 }
178
179 pub fn clear_cache(&self) {
181 if let Ok(mut cache) = self.cache_by_cwd.write() {
182 cache.clear();
183 } else {
184 tracing::warn!("skills metadata cache lock poisoned while clearing cache");
185 }
186 clear_lightweight_skill_metadata_cache();
187 }
188
189 pub fn cache_size(&self) -> usize {
191 if let Ok(cache) = self.cache_by_cwd.read() {
192 cache.len()
193 } else {
194 tracing::warn!("skills metadata cache lock poisoned while reading cache size");
195 0
196 }
197 }
198
199 pub fn skills_metadata_lightweight(&self, cwd: &Path) -> SkillLoadOutcome {
203 self.ensure_system_skills_installed();
205
206 let project_root = find_git_root(cwd);
207
208 let config = SkillLoaderConfig {
209 codex_home: self.codex_home.clone(),
210 cwd: cwd.to_path_buf(),
211 project_root,
212 include_bundled_system_skills: self.bundled_skills_enabled,
213 };
214
215 discover_skill_metadata_lightweight(&config)
217 }
218
219 #[cfg(test)]
220 fn skills_metadata_lightweight_hermetic(&self, cwd: &Path) -> SkillLoadOutcome {
221 let project_root = find_git_root(cwd);
222
223 let config = SkillLoaderConfig {
224 codex_home: self.codex_home.clone(),
225 cwd: cwd.to_path_buf(),
226 project_root,
227 include_bundled_system_skills: self.bundled_skills_enabled,
228 };
229
230 discover_skill_metadata_lightweight_hermetic(&config)
231 }
232
233 pub fn load_skill_instructions(&self, skill_name: &str, skill_path: &Path) -> Result<Skill> {
238 let cache_key = format!("{}:{}", skill_name, skill_path.display());
241
242 {
244 if let Ok(cache) = self.instruction_cache.read() {
245 if let Some(cached) = cache.get(&cache_key)
246 && !cached.is_expired(self.instruction_cache_ttl)
247 {
248 return Ok(cached.value.clone());
249 }
250 } else {
251 tracing::warn!("skill instruction cache lock poisoned while reading cache");
252 }
253 }
254
255 let skill_md = skill_path.join("SKILL.md");
257 let content = read_file_with_context_sync(&skill_md, "skill instructions")
258 .map_err(|e| anyhow::anyhow!("Failed to read SKILL.md for '{skill_name}': {e}"))?;
259
260 let (manifest, instructions) = crate::skills::manifest::parse_skill_content(&content)?;
261 let skill = Skill::new(manifest, skill_path.to_path_buf(), instructions)?;
262
263 if let Ok(mut cache) = self.instruction_cache.write() {
265 if cache.len() >= self.max_instruction_cache_size && !cache.contains_key(&cache_key) {
267 evict_cache(
268 &mut cache,
269 self.max_instruction_cache_size,
270 self.instruction_cache_ttl,
271 |v| v.timestamp,
272 |v, ttl| v.is_expired(ttl),
273 );
274 }
275
276 cache.insert(cache_key, CachedEntry::new(skill.clone()));
277 } else {
278 tracing::warn!("skill instruction cache lock poisoned while writing cache");
279 }
280
281 Ok(skill)
282 }
283
284 pub fn resolve_skill_by_name(&self, cwd: &Path, skill_name: &str) -> Result<SkillMetadata> {
287 let outcome = self.skills_for_cwd(cwd);
288 let available: Vec<String> = outcome.skills.iter().map(|s| s.name.clone()).collect();
289 outcome.skills.into_iter().find(|s| s.name == skill_name).ok_or_else(|| {
290 anyhow::anyhow!("Skill '{}' not found. Available skills: [{}]", skill_name, available.join(", "))
291 })
292 }
293
294 pub fn loop_skills(&self, cwd: &Path, skill_names: &[String]) -> Vec<SkillMetadata> {
298 if skill_names.is_empty() {
299 return Vec::new();
300 }
301
302 let outcome = self.skills_for_cwd(cwd);
303 let available: HashMap<String, &SkillMetadata> = outcome.skills.iter().map(|s| (s.name.clone(), s)).collect();
304
305 let mut loaded = Vec::new();
306 for name in skill_names {
307 if let Some(meta) = available.get(name) {
308 loaded.push((*meta).clone());
309 } else {
310 tracing::warn!(
311 skill = %name,
312 "Preload skill not found; skipping. Available: {}",
313 available
314 .keys()
315 .cloned()
316 .collect::<Vec<_>>()
317 .join(", ")
318 );
319 }
320 }
321 loaded
322 }
323
324 pub fn clear_instruction_cache(&self) {
326 if let Ok(mut cache) = self.instruction_cache.write() {
327 cache.clear();
328 } else {
329 tracing::warn!("skill instruction cache lock poisoned while clearing cache");
330 }
331 }
332
333 pub fn instruction_cache_size(&self) -> usize {
335 if let Ok(cache) = self.instruction_cache.read() {
336 cache.len()
337 } else {
338 tracing::warn!("skill instruction cache lock poisoned while reading cache size");
339 0
340 }
341 }
342}
343
344fn find_git_root(path: &Path) -> Option<PathBuf> {
345 let mut current = path;
346 loop {
347 if current.join(".git").exists() {
348 return Some(current.to_path_buf());
349 }
350 current = current.parent()?;
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use serial_test::serial;
358 use std::fs;
359 use tempfile::TempDir;
360
361 #[test]
362 fn test_skills_manager_lazy_initialization() {
363 let temp_home = TempDir::new().unwrap();
364 let manager = SkillsManager::new(temp_home.path().to_path_buf());
365
366 assert_eq!(manager.cache_size(), 0);
368
369 manager.ensure_system_skills_installed();
371
372 assert_eq!(manager.cache_size(), 0);
374 }
375
376 #[test]
377 fn test_skills_manager_cache_with_ttl() {
378 let temp_home = TempDir::new().unwrap();
379 let manager = SkillsManager::new(temp_home.path().to_path_buf());
380
381 let cwd = temp_home.path();
382
383 let outcome1 = manager.skills_for_cwd(cwd);
385 assert_eq!(manager.cache_size(), 1);
386
387 let outcome2 = manager.skills_for_cwd(cwd);
389 assert_eq!(outcome1.skills.len(), outcome2.skills.len());
390 assert_eq!(manager.cache_size(), 1);
391
392 let outcome3 = manager.skills_for_cwd_with_options(cwd, true);
394 assert_eq!(manager.cache_size(), 1);
395 assert_eq!(outcome1.skills.len(), outcome3.skills.len());
396 }
397
398 #[test]
399 fn test_skills_manager_max_cache_size() {
400 let temp_home = TempDir::new().unwrap();
401 let manager = SkillsManager::new(temp_home.path().to_path_buf());
402
403 for _ in 0..15 {
405 let dir = TempDir::new().unwrap();
406 let cwd = dir.path();
407 manager.skills_for_cwd(cwd);
408 }
409
410 assert!(manager.cache_size() <= 10);
412 }
413
414 #[test]
415 fn test_skills_manager_clear_cache() {
416 let temp_home = TempDir::new().unwrap();
417 let manager = SkillsManager::new(temp_home.path().to_path_buf());
418
419 let cwd = temp_home.path();
420 manager.skills_for_cwd(cwd);
421 assert_eq!(manager.cache_size(), 1);
422
423 manager.clear_cache();
424 assert_eq!(manager.cache_size(), 0);
425 }
426
427 #[test]
428 #[serial]
429 fn test_skills_manager_clear_cache_clears_lightweight_discovery_cache() {
430 clear_lightweight_skill_metadata_cache();
431
432 let temp_home = TempDir::new().unwrap();
433 let workspace = TempDir::new().unwrap();
434 let manager = SkillsManager::new(temp_home.path().to_path_buf());
435 let skill_dir = workspace.path().join(".agents/skills/clear-cache-skill");
436
437 fs::create_dir_all(&skill_dir).unwrap();
438 fs::write(
439 skill_dir.join("SKILL.md"),
440 "---\nname: clear-cache-skill\ndescription: clear cache test\n---\n# Body\n",
441 )
442 .unwrap();
443
444 let first = manager.skills_metadata_lightweight_hermetic(workspace.path());
445 assert!(
446 first.skills.iter().any(|skill| skill.name == "clear-cache-skill"),
447 "expected first discovery to find test skill",
448 );
449
450 fs::remove_dir_all(&skill_dir).unwrap();
451
452 let second = manager.skills_metadata_lightweight_hermetic(workspace.path());
453 assert!(
454 second.skills.iter().any(|skill| skill.name == "clear-cache-skill"),
455 "expected cached discovery to preserve removed skill before clear_cache",
456 );
457
458 manager.clear_cache();
459
460 let third = manager.skills_metadata_lightweight_hermetic(workspace.path());
461 assert!(
462 !third.skills.iter().any(|skill| skill.name == "clear-cache-skill"),
463 "expected clear_cache to flush lightweight discovery cache",
464 );
465 }
466
467 #[test]
468 fn test_skills_metadata_lightweight() {
469 let temp_home = TempDir::new().unwrap();
470 let manager = SkillsManager::new(temp_home.path().to_path_buf());
471
472 let cwd = temp_home.path();
473
474 let outcome = manager.skills_metadata_lightweight_hermetic(cwd);
476
477 assert_eq!(outcome.errors.len(), 0);
480 }
481
482 #[test]
483 fn test_lightweight_vs_full_discovery() {
484 let temp_home = TempDir::new().unwrap();
485 let manager = SkillsManager::new(temp_home.path().to_path_buf());
486
487 let cwd = temp_home.path();
488
489 let lightweight = manager.skills_metadata_lightweight(cwd);
491 let full = manager.skills_for_cwd(cwd);
492
493 let light_count = lightweight.skills.len() as i32;
497 let full_count = full.skills.len() as i32;
498 let diff = (light_count - full_count).abs();
499 let tolerance = (full_count / 10).max(5); assert!(
502 diff <= tolerance,
503 "Lightweight discovery found {light_count} skills, full discovery found {full_count}. Difference {diff} exceeds tolerance {tolerance}"
504 );
505 }
506
507 #[test]
508 fn test_instruction_cache_initialization() {
509 let temp_home = TempDir::new().unwrap();
510 let manager = SkillsManager::new(temp_home.path().to_path_buf());
511
512 assert_eq!(manager.instruction_cache_size(), 0);
514
515 manager.clear_instruction_cache();
517 assert_eq!(manager.instruction_cache_size(), 0);
518 }
519
520 #[test]
521 fn test_instruction_cache_max_size() {
522 let temp_home = TempDir::new().unwrap();
523 let manager = SkillsManager::new(temp_home.path().to_path_buf());
524
525 assert_eq!(manager.instruction_cache_size(), 0);
529
530 manager.clear_instruction_cache();
532 assert_eq!(manager.instruction_cache_size(), 0);
533 }
534
535 #[test]
536 fn test_instruction_cache_clear() {
537 let temp_home = TempDir::new().unwrap();
538 let manager = SkillsManager::new(temp_home.path().to_path_buf());
539
540 assert_eq!(manager.instruction_cache_size(), 0);
542
543 manager.clear_instruction_cache();
545 assert_eq!(manager.instruction_cache_size(), 0);
546 }
547
548 #[test]
549 fn test_disabled_bundled_skills_remove_stale_system_cache() {
550 let temp_home = TempDir::new().unwrap();
551 let stale_skill_dir = temp_home.path().join("skills/.system/stale-skill");
552 fs::create_dir_all(&stale_skill_dir).unwrap();
553 fs::write(stale_skill_dir.join("SKILL.md"), "# stale\n").unwrap();
554
555 let _manager = SkillsManager::new_with_bundled_skills_enabled(temp_home.path().to_path_buf(), false);
556
557 assert!(!temp_home.path().join("skills/.system").exists());
558 }
559
560 #[test]
561 fn test_disabled_bundled_skills_exclude_system_root_even_if_recreated() {
562 let temp_home = TempDir::new().unwrap();
563 let workspace = TempDir::new().unwrap();
564 let bundled_skill_dir = temp_home.path().join("skills/.system/bundled-skill");
565 fs::create_dir_all(&bundled_skill_dir).unwrap();
566 fs::write(
567 bundled_skill_dir.join("SKILL.md"),
568 "---\nname: bundled-skill\ndescription: bundled\n---\n\n# Body\n",
569 )
570 .unwrap();
571
572 let manager = SkillsManager::new_with_bundled_skills_enabled(temp_home.path().to_path_buf(), false);
573
574 fs::create_dir_all(&bundled_skill_dir).unwrap();
575 fs::write(
576 bundled_skill_dir.join("SKILL.md"),
577 "---\nname: bundled-skill\ndescription: bundled\n---\n\n# Body\n",
578 )
579 .unwrap();
580
581 let outcome = manager.skills_for_cwd(workspace.path());
582 assert!(outcome.skills.iter().all(|skill| skill.name != "bundled-skill"));
583 }
584}