1#[cfg(test)]
2use crate::skills::loader::discover_skill_metadata_lightweight_hermetic;
3use crate::skills::loader::{
4 SkillLoaderConfig, clear_lightweight_skill_metadata_cache, discover_skill_metadata_lightweight,
5 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 CachedSkillOutcome {
23 outcome: SkillLoadOutcome,
24 timestamp: SystemTime,
25}
26
27impl CachedSkillOutcome {
28 fn is_expired(&self, ttl: Duration) -> bool {
29 self.timestamp.elapsed().unwrap_or(ttl) > ttl
30 }
31}
32
33#[derive(Clone)]
35struct CachedSkillInstruction {
36 skill: Skill,
37 timestamp: SystemTime,
38}
39
40impl CachedSkillInstruction {
41 fn is_expired(&self, ttl: Duration) -> bool {
42 self.timestamp.elapsed().unwrap_or(ttl) > ttl
43 }
44}
45
46pub struct SkillsManager {
47 codex_home: PathBuf,
48 bundled_skills_enabled: bool,
49 cache_by_cwd: RwLock<HashMap<PathBuf, CachedSkillOutcome>>,
51 max_cache_size: usize,
53 cache_ttl: Duration,
55 system_skills_initialized: OnceLock<()>,
57 instruction_cache: RwLock<HashMap<String, CachedSkillInstruction>>,
59 max_instruction_cache_size: usize,
61 instruction_cache_ttl: Duration,
63}
64
65impl SkillsManager {
66 pub fn new(codex_home: PathBuf) -> Self {
67 Self::new_with_bundled_skills_enabled(codex_home, true)
68 }
69
70 pub fn new_with_bundled_skills_enabled(
71 codex_home: PathBuf,
72 bundled_skills_enabled: bool,
73 ) -> Self {
74 let manager = Self {
75 codex_home,
76 bundled_skills_enabled,
77 cache_by_cwd: RwLock::new(HashMap::new()),
78 max_cache_size: 10,
79 cache_ttl: Duration::from_secs(5 * 60), system_skills_initialized: OnceLock::new(),
81 instruction_cache: RwLock::new(HashMap::new()),
82 max_instruction_cache_size: 50, instruction_cache_ttl: Duration::from_secs(10 * 60), };
85
86 if !manager.bundled_skills_enabled {
87 uninstall_system_skills(&manager.codex_home);
88 }
89
90 manager
91 }
92
93 pub fn bundled_skills_enabled(&self) -> bool {
94 self.bundled_skills_enabled
95 }
96
97 pub fn ensure_system_skills_installed(&self) {
99 self.system_skills_initialized.get_or_init(|| {
100 if !self.bundled_skills_enabled {
101 return;
102 }
103
104 if let Err(err) = install_system_skills(&self.codex_home) {
106 tracing::warn!("lazy system skills installation failed: {err}");
107 }
108 });
109 }
110
111 pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
112 self.skills_for_cwd_with_options(cwd, false)
113 }
114
115 pub fn skills_for_cwd_with_options(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
116 self.ensure_system_skills_installed();
118
119 if !force_reload {
120 if let Ok(cache) = self.cache_by_cwd.read() {
121 if let Some(cached) = cache.get(cwd)
122 && !cached.is_expired(self.cache_ttl)
123 {
124 return cached.outcome.clone();
125 }
126 } else {
127 tracing::warn!("skills metadata cache lock poisoned while reading cache");
128 }
129 }
130
131 let project_root = find_git_root(cwd);
132
133 let config = SkillLoaderConfig {
134 codex_home: self.codex_home.clone(),
135 cwd: cwd.to_path_buf(),
136 project_root,
137 include_bundled_system_skills: self.bundled_skills_enabled,
138 };
139
140 let outcome = load_skills(&config);
141
142 if let Ok(mut cache) = self.cache_by_cwd.write() {
143 if cache.len() >= self.max_cache_size && !cache.contains_key(cwd) {
145 let expired: Vec<_> = cache
147 .iter()
148 .filter(|(_, v)| v.timestamp.elapsed().unwrap_or_default() > self.cache_ttl)
149 .map(|(k, _)| k.clone())
150 .collect();
151
152 for key in expired {
153 cache.remove(&key);
154 }
155
156 if cache.len() >= self.max_cache_size {
158 let oldest_key = cache
159 .iter()
160 .min_by_key(|(_, v)| v.timestamp)
161 .map(|(k, _)| k.clone());
162 if let Some(key) = oldest_key {
163 cache.remove(&key);
164 }
165 }
166 }
167
168 cache.insert(
169 cwd.to_path_buf(),
170 CachedSkillOutcome {
171 outcome: outcome.clone(),
172 timestamp: SystemTime::now(),
173 },
174 );
175 } else {
176 tracing::warn!("skills metadata cache lock poisoned while writing cache");
177 }
178
179 outcome
180 }
181
182 pub fn clear_cache(&self) {
184 if let Ok(mut cache) = self.cache_by_cwd.write() {
185 cache.clear();
186 } else {
187 tracing::warn!("skills metadata cache lock poisoned while clearing cache");
188 }
189 clear_lightweight_skill_metadata_cache();
190 }
191
192 pub fn cache_size(&self) -> usize {
194 if let Ok(cache) = self.cache_by_cwd.read() {
195 cache.len()
196 } else {
197 tracing::warn!("skills metadata cache lock poisoned while reading cache size");
198 0
199 }
200 }
201
202 pub fn skills_metadata_lightweight(&self, cwd: &Path) -> SkillLoadOutcome {
206 self.ensure_system_skills_installed();
208
209 let project_root = find_git_root(cwd);
210
211 let config = SkillLoaderConfig {
212 codex_home: self.codex_home.clone(),
213 cwd: cwd.to_path_buf(),
214 project_root,
215 include_bundled_system_skills: self.bundled_skills_enabled,
216 };
217
218 discover_skill_metadata_lightweight(&config)
220 }
221
222 #[cfg(test)]
223 fn skills_metadata_lightweight_hermetic(&self, cwd: &Path) -> SkillLoadOutcome {
224 let project_root = find_git_root(cwd);
225
226 let config = SkillLoaderConfig {
227 codex_home: self.codex_home.clone(),
228 cwd: cwd.to_path_buf(),
229 project_root,
230 include_bundled_system_skills: self.bundled_skills_enabled,
231 };
232
233 discover_skill_metadata_lightweight_hermetic(&config)
234 }
235
236 pub fn load_skill_instructions(&self, skill_name: &str, skill_path: &Path) -> Result<Skill> {
241 {
243 if let Ok(cache) = self.instruction_cache.read() {
244 if let Some(cached) = cache.get(skill_name)
245 && !cached.is_expired(self.instruction_cache_ttl)
246 {
247 return Ok(cached.skill.clone());
248 }
249 } else {
250 tracing::warn!("skill instruction cache lock poisoned while reading cache");
251 }
252 }
253
254 let skill_md = skill_path.join("SKILL.md");
256 let content = read_file_with_context_sync(&skill_md, "skill instructions")
257 .map_err(|e| anyhow::anyhow!("Failed to read SKILL.md for '{}': {}", skill_name, e))?;
258
259 let (manifest, instructions) = crate::skills::manifest::parse_skill_content(&content)?;
260 let skill = Skill::new(manifest, skill_path.to_path_buf(), instructions)?;
261
262 if let Ok(mut cache) = self.instruction_cache.write() {
264 if cache.len() >= self.max_instruction_cache_size && !cache.contains_key(skill_name) {
266 let expired: Vec<_> = cache
268 .iter()
269 .filter(|(_, v)| v.is_expired(self.instruction_cache_ttl))
270 .map(|(k, _)| k.clone())
271 .collect();
272
273 for key in expired {
274 cache.remove(&key);
275 }
276
277 if cache.len() >= self.max_instruction_cache_size {
279 let oldest_key = cache
280 .iter()
281 .min_by_key(|(_, v)| v.timestamp)
282 .map(|(k, _)| k.clone());
283 if let Some(key) = oldest_key {
284 cache.remove(&key);
285 }
286 }
287 }
288
289 cache.insert(
290 skill_name.to_string(),
291 CachedSkillInstruction {
292 skill: skill.clone(),
293 timestamp: SystemTime::now(),
294 },
295 );
296 } else {
297 tracing::warn!("skill instruction cache lock poisoned while writing cache");
298 }
299
300 Ok(skill)
301 }
302
303 pub fn clear_instruction_cache(&self) {
305 if let Ok(mut cache) = self.instruction_cache.write() {
306 cache.clear();
307 } else {
308 tracing::warn!("skill instruction cache lock poisoned while clearing cache");
309 }
310 }
311
312 pub fn instruction_cache_size(&self) -> usize {
314 if let Ok(cache) = self.instruction_cache.read() {
315 cache.len()
316 } else {
317 tracing::warn!("skill instruction cache lock poisoned while reading cache size");
318 0
319 }
320 }
321}
322
323fn find_git_root(path: &Path) -> Option<PathBuf> {
324 let mut current = path;
325 loop {
326 if current.join(".git").exists() {
327 return Some(current.to_path_buf());
328 }
329 if let Some(parent) = current.parent() {
330 current = parent;
331 } else {
332 return None;
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use serial_test::serial;
341 use std::fs;
342 use tempfile::TempDir;
343
344 #[test]
345 fn test_skills_manager_lazy_initialization() {
346 let temp_home = TempDir::new().unwrap();
347 let manager = SkillsManager::new(temp_home.path().to_path_buf());
348
349 assert_eq!(manager.cache_size(), 0);
351
352 manager.ensure_system_skills_installed();
354
355 assert_eq!(manager.cache_size(), 0);
357 }
358
359 #[test]
360 fn test_skills_manager_cache_with_ttl() {
361 let temp_home = TempDir::new().unwrap();
362 let manager = SkillsManager::new(temp_home.path().to_path_buf());
363
364 let cwd = temp_home.path();
365
366 let outcome1 = manager.skills_for_cwd(cwd);
368 assert_eq!(manager.cache_size(), 1);
369
370 let outcome2 = manager.skills_for_cwd(cwd);
372 assert_eq!(outcome1.skills.len(), outcome2.skills.len());
373 assert_eq!(manager.cache_size(), 1);
374
375 let outcome3 = manager.skills_for_cwd_with_options(cwd, true);
377 assert_eq!(manager.cache_size(), 1);
378 assert_eq!(outcome1.skills.len(), outcome3.skills.len());
379 }
380
381 #[test]
382 fn test_skills_manager_max_cache_size() {
383 let temp_home = TempDir::new().unwrap();
384 let manager = SkillsManager::new(temp_home.path().to_path_buf());
385
386 for _ in 0..15 {
388 let dir = TempDir::new().unwrap();
389 let cwd = dir.path();
390 manager.skills_for_cwd(cwd);
391 }
392
393 assert!(manager.cache_size() <= 10);
395 }
396
397 #[test]
398 fn test_skills_manager_clear_cache() {
399 let temp_home = TempDir::new().unwrap();
400 let manager = SkillsManager::new(temp_home.path().to_path_buf());
401
402 let cwd = temp_home.path();
403 manager.skills_for_cwd(cwd);
404 assert_eq!(manager.cache_size(), 1);
405
406 manager.clear_cache();
407 assert_eq!(manager.cache_size(), 0);
408 }
409
410 #[test]
411 #[serial]
412 fn test_skills_manager_clear_cache_clears_lightweight_discovery_cache() {
413 clear_lightweight_skill_metadata_cache();
414
415 let temp_home = TempDir::new().unwrap();
416 let workspace = TempDir::new().unwrap();
417 let manager = SkillsManager::new(temp_home.path().to_path_buf());
418 let skill_dir = workspace.path().join(".agents/skills/clear-cache-skill");
419
420 fs::create_dir_all(&skill_dir).unwrap();
421 fs::write(
422 skill_dir.join("SKILL.md"),
423 "---\nname: clear-cache-skill\ndescription: clear cache test\n---\n# Body\n",
424 )
425 .unwrap();
426
427 let first = manager.skills_metadata_lightweight_hermetic(workspace.path());
428 assert!(
429 first
430 .skills
431 .iter()
432 .any(|skill| skill.name == "clear-cache-skill"),
433 "expected first discovery to find test skill",
434 );
435
436 fs::remove_dir_all(&skill_dir).unwrap();
437
438 let second = manager.skills_metadata_lightweight_hermetic(workspace.path());
439 assert!(
440 second
441 .skills
442 .iter()
443 .any(|skill| skill.name == "clear-cache-skill"),
444 "expected cached discovery to preserve removed skill before clear_cache",
445 );
446
447 manager.clear_cache();
448
449 let third = manager.skills_metadata_lightweight_hermetic(workspace.path());
450 assert!(
451 !third
452 .skills
453 .iter()
454 .any(|skill| skill.name == "clear-cache-skill"),
455 "expected clear_cache to flush lightweight discovery cache",
456 );
457 }
458
459 #[test]
460 fn test_skills_metadata_lightweight() {
461 let temp_home = TempDir::new().unwrap();
462 let manager = SkillsManager::new(temp_home.path().to_path_buf());
463
464 let cwd = temp_home.path();
465
466 let outcome = manager.skills_metadata_lightweight_hermetic(cwd);
468
469 assert_eq!(outcome.errors.len(), 0);
472 }
473
474 #[test]
475 fn test_lightweight_vs_full_discovery() {
476 let temp_home = TempDir::new().unwrap();
477 let manager = SkillsManager::new(temp_home.path().to_path_buf());
478
479 let cwd = temp_home.path();
480
481 let lightweight = manager.skills_metadata_lightweight(cwd);
483 let full = manager.skills_for_cwd(cwd);
484
485 let light_count = lightweight.skills.len() as i32;
489 let full_count = full.skills.len() as i32;
490 let diff = (light_count - full_count).abs();
491 let tolerance = (full_count / 10).max(5); assert!(
494 diff <= tolerance,
495 "Lightweight discovery found {} skills, full discovery found {}. Difference {} exceeds tolerance {}",
496 light_count,
497 full_count,
498 diff,
499 tolerance
500 );
501 }
502
503 #[test]
504 fn test_instruction_cache_initialization() {
505 let temp_home = TempDir::new().unwrap();
506 let manager = SkillsManager::new(temp_home.path().to_path_buf());
507
508 assert_eq!(manager.instruction_cache_size(), 0);
510
511 manager.clear_instruction_cache();
513 assert_eq!(manager.instruction_cache_size(), 0);
514 }
515
516 #[test]
517 fn test_instruction_cache_max_size() {
518 let temp_home = TempDir::new().unwrap();
519 let manager = SkillsManager::new(temp_home.path().to_path_buf());
520
521 assert_eq!(manager.instruction_cache_size(), 0);
525
526 manager.clear_instruction_cache();
528 assert_eq!(manager.instruction_cache_size(), 0);
529 }
530
531 #[test]
532 fn test_instruction_cache_clear() {
533 let temp_home = TempDir::new().unwrap();
534 let manager = SkillsManager::new(temp_home.path().to_path_buf());
535
536 assert_eq!(manager.instruction_cache_size(), 0);
538
539 manager.clear_instruction_cache();
541 assert_eq!(manager.instruction_cache_size(), 0);
542 }
543
544 #[test]
545 fn test_disabled_bundled_skills_remove_stale_system_cache() {
546 let temp_home = TempDir::new().unwrap();
547 let stale_skill_dir = temp_home.path().join("skills/.system/stale-skill");
548 fs::create_dir_all(&stale_skill_dir).unwrap();
549 fs::write(stale_skill_dir.join("SKILL.md"), "# stale\n").unwrap();
550
551 let _manager =
552 SkillsManager::new_with_bundled_skills_enabled(temp_home.path().to_path_buf(), false);
553
554 assert!(!temp_home.path().join("skills/.system").exists());
555 }
556
557 #[test]
558 fn test_disabled_bundled_skills_exclude_system_root_even_if_recreated() {
559 let temp_home = TempDir::new().unwrap();
560 let workspace = TempDir::new().unwrap();
561 let bundled_skill_dir = temp_home.path().join("skills/.system/bundled-skill");
562 fs::create_dir_all(&bundled_skill_dir).unwrap();
563 fs::write(
564 bundled_skill_dir.join("SKILL.md"),
565 "---\nname: bundled-skill\ndescription: bundled\n---\n\n# Body\n",
566 )
567 .unwrap();
568
569 let manager =
570 SkillsManager::new_with_bundled_skills_enabled(temp_home.path().to_path_buf(), false);
571
572 fs::create_dir_all(&bundled_skill_dir).unwrap();
573 fs::write(
574 bundled_skill_dir.join("SKILL.md"),
575 "---\nname: bundled-skill\ndescription: bundled\n---\n\n# Body\n",
576 )
577 .unwrap();
578
579 let outcome = manager.skills_for_cwd(workspace.path());
580 assert!(
581 outcome
582 .skills
583 .iter()
584 .all(|skill| skill.name != "bundled-skill")
585 );
586 }
587}