1use crate::config::ConfigManager;
2use crate::config::ToolDocumentationMode;
3use crate::config::types::CapabilityLevel;
4use crate::llm::provider::ToolDefinition;
5use crate::skills::cli_bridge::CliToolConfig;
6use crate::skills::command_skills::merge_built_in_command_skill_metadata;
7use crate::skills::discovery::{DiscoveryConfig, SkillDiscovery};
8use crate::skills::executor::{ForkSkillExecutor, SkillToolAdapter};
9use crate::skills::file_references::FileReferenceValidator;
10use crate::skills::loader::{EnhancedSkill, EnhancedSkillLoader, SkillLoaderConfig};
11use crate::skills::manager::SkillsManager;
12use crate::skills::model::{SkillErrorInfo, SkillLoadOutcome};
13use crate::skills::types::{Skill, SkillVariety};
14use crate::tool_policy::ToolPolicy;
15use crate::tools::handlers::{
16 DeferredToolPolicy, SessionSurface, SessionToolsConfig, ToolModelCapabilities,
17};
18use crate::tools::registry::{
19 ToolMetadata, ToolRegistration, ToolRegistry, native_cgp_tool_factory,
20};
21use crate::tools::traits::Tool;
22use crate::utils::file_utils::read_file_with_context_sync;
23use anyhow::Context;
24use async_trait::async_trait;
25use hashbrown::{HashMap, HashSet};
26use serde_json::{Value, json};
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use tokio::sync::RwLock;
30use tracing::{debug, warn};
31
32#[cfg(test)]
33use crate::tools::CgpRuntimeMode;
34use crate::tools::error_messages::skill_ops;
35
36type SkillMap = Arc<RwLock<HashMap<String, Skill>>>;
37type ToolDefList = Arc<RwLock<Vec<ToolDefinition>>>;
38type ToolChangeNotifier = Arc<dyn Fn(&'static str) + Send + Sync>;
39
40const SKILL_TOOL_PROMPT_PATH: &str = "skills/skill_instructions.md";
41const SKILL_ACTIVATED_STATUS: &str = "Associated tools activated and added to context.";
42const SKILL_ALREADY_ACTIVE_STATUS: &str = "Associated tools were already active.";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum SkillActivationState {
46 Activated,
47 AlreadyActive,
48}
49
50#[derive(Clone)]
51pub struct SkillToolSessionRuntime {
52 tool_registry: Arc<ToolRegistry>,
53 active_tools: Option<ToolDefList>,
54 tool_documentation_mode: ToolDocumentationMode,
55 model_capabilities: ToolModelCapabilities,
56 deferred_tool_policy: DeferredToolPolicy,
57 anthropic_native_memory_enabled: bool,
58 on_tools_changed: Option<ToolChangeNotifier>,
59 fork_executor: Option<Arc<dyn ForkSkillExecutor>>,
60}
61
62impl SkillToolSessionRuntime {
63 pub fn new(
64 tool_registry: Arc<ToolRegistry>,
65 active_tools: Option<ToolDefList>,
66 tool_documentation_mode: ToolDocumentationMode,
67 model_capabilities: ToolModelCapabilities,
68 on_tools_changed: Option<ToolChangeNotifier>,
69 ) -> Self {
70 Self {
71 tool_registry,
72 active_tools,
73 tool_documentation_mode,
74 model_capabilities,
75 deferred_tool_policy: DeferredToolPolicy::default(),
76 anthropic_native_memory_enabled: false,
77 on_tools_changed,
78 fork_executor: None,
79 }
80 }
81
82 pub fn with_fork_executor(mut self, fork_executor: Arc<dyn ForkSkillExecutor>) -> Self {
83 self.fork_executor = Some(fork_executor);
84 self
85 }
86
87 pub fn with_deferred_tool_policy(mut self, deferred_tool_policy: DeferredToolPolicy) -> Self {
88 self.deferred_tool_policy = deferred_tool_policy;
89 self
90 }
91
92 pub fn with_anthropic_native_memory_enabled(mut self, enabled: bool) -> Self {
93 self.anthropic_native_memory_enabled = enabled;
94 self
95 }
96
97 pub async fn activate_skill(
98 &self,
99 active_skills: &Arc<RwLock<HashMap<String, Skill>>>,
100 skill: Skill,
101 ) -> anyhow::Result<SkillActivationState> {
102 let skill_name = skill.name().to_string();
103 if active_skills.read().await.contains_key(skill_name.as_str()) {
104 return Ok(SkillActivationState::AlreadyActive);
105 }
106
107 if !self.tool_registry.has_tool(skill_name.as_str()).await {
108 self.tool_registry
109 .register_tool(build_traditional_skill_tool_registration(
110 &skill,
111 self.fork_executor.clone(),
112 ))
113 .await
114 .with_context(|| format!("failed to register skill tool '{skill_name}'"))?;
115 self.refresh_tool_snapshot("load_skill").await;
116 }
117
118 active_skills.write().await.insert(skill_name, skill);
119 Ok(SkillActivationState::Activated)
120 }
121
122 pub async fn deactivate_skill(
123 &self,
124 active_skills: &Arc<RwLock<HashMap<String, Skill>>>,
125 skill_name: &str,
126 ) -> anyhow::Result<bool> {
127 let removed = active_skills.write().await.remove(skill_name).is_some();
128 let unregistered = self.tool_registry.unregister_tool(skill_name).await?;
129 if unregistered {
130 self.refresh_tool_snapshot("unload_skill").await;
131 }
132 Ok(removed || unregistered)
133 }
134
135 async fn refresh_tool_snapshot(&self, reason: &'static str) {
136 if let Some(active_tools) = &self.active_tools {
137 let refreshed = self
138 .tool_registry
139 .model_tools(
140 SessionToolsConfig::full_public(
141 SessionSurface::Interactive,
142 CapabilityLevel::CodeSearch,
143 self.tool_documentation_mode,
144 self.model_capabilities,
145 )
146 .with_deferred_tool_policy(self.deferred_tool_policy.clone())
147 .with_anthropic_native_memory_enabled(self.anthropic_native_memory_enabled),
148 )
149 .await;
150 *active_tools.write().await = refreshed;
151 }
152
153 if let Some(notifier) = &self.on_tools_changed {
154 notifier(reason);
155 }
156 }
157}
158
159fn build_skill_tool_adapter(
160 skill: Skill,
161 fork_executor: Option<Arc<dyn ForkSkillExecutor>>,
162) -> SkillToolAdapter {
163 if skill.manifest.context.as_deref() == Some("fork") {
164 match fork_executor {
165 Some(executor) => SkillToolAdapter::with_fork_executor(skill, executor),
166 None => SkillToolAdapter::new(skill),
167 }
168 } else {
169 SkillToolAdapter::new(skill)
170 }
171}
172
173pub fn build_traditional_skill_tool_registration(
174 skill: &Skill,
175 fork_executor: Option<Arc<dyn ForkSkillExecutor>>,
176) -> ToolRegistration {
177 let metadata = ToolMetadata::default()
178 .with_description(skill.description())
179 .with_parameter_schema(skill_tool_parameter_schema())
180 .with_permission(ToolPolicy::Prompt)
181 .with_prompt_path(SKILL_TOOL_PROMPT_PATH);
182
183 let adapter: Arc<dyn Tool> = Arc::new(build_skill_tool_adapter(
187 skill.clone(),
188 fork_executor.clone(),
189 ));
190 let native_skill = skill.clone();
191 let native_fork_executor = fork_executor;
192
193 ToolRegistration::from_tool_with_metadata(
194 skill.name().to_string(),
195 CapabilityLevel::Basic,
196 adapter,
197 metadata,
198 )
199 .with_native_cgp_factory(native_cgp_tool_factory(move || {
200 build_skill_tool_adapter(native_skill.clone(), native_fork_executor.clone())
201 }))
202}
203
204pub fn build_skill_tool_registration(skill: &Skill) -> ToolRegistration {
205 build_traditional_skill_tool_registration(skill, None)
206}
207
208fn skill_tool_parameter_schema() -> Value {
209 json!({
210 "type": "object",
211 "properties": {},
212 "description": "Flexible input for skill execution",
213 "additionalProperties": true,
214 })
215}
216
217fn load_skill_instructions(skill: &Skill, activation_status: &str) -> String {
218 if !skill.instructions.is_empty() {
219 return skill.instructions.clone();
220 }
221
222 let skill_file = skill.path.join("SKILL.md");
223 if skill_file.exists() {
224 return match read_file_with_context_sync(&skill_file, "skill file") {
225 Ok(content) => content,
226 Err(error) => format!("Error reading skill file: {error}"),
227 };
228 }
229
230 format!(
231 "No detailed instructions available for {}. {}",
232 skill.name(),
233 activation_status
234 )
235}
236
237fn build_skill_response(skill: &Skill, activation_status: &str) -> Value {
238 let instructions = load_skill_instructions(skill, activation_status);
239 let validator = FileReferenceValidator::new(skill.path.clone());
240 let resources: Vec<String> = validator
241 .list_valid_references()
242 .iter()
243 .map(|path| path.to_string_lossy().to_string())
244 .collect();
245
246 json!({
247 "name": skill.name(),
248 "variety": skill.variety,
249 "instructions": instructions,
250 "instructions_status": "These instructions are now [ACTIVE] and will persist in your system prompt for the remainder of this session.",
251 "activation_status": activation_status,
252 "resources": resources,
253 "path": skill.path,
254 "description": skill.description()
255 })
256}
257
258fn default_vtcode_home_dir() -> PathBuf {
259 std::env::var_os("VTCODE_HOME")
260 .filter(|value| !value.is_empty())
261 .map(PathBuf::from)
262 .or_else(|| dirs::home_dir().map(|home| home.join(".vtcode")))
263 .unwrap_or_else(|| PathBuf::from(".vtcode"))
264}
265
266fn effective_codex_home(explicit_home: Option<&Path>) -> PathBuf {
267 explicit_home
268 .map(Path::to_path_buf)
269 .unwrap_or_else(default_vtcode_home_dir)
270}
271
272fn find_project_root(path: &Path) -> Option<PathBuf> {
273 let mut current = Some(path);
274 while let Some(dir) = current {
275 if dir.join(".git").exists() {
276 return Some(dir.to_path_buf());
277 }
278 current = dir.parent();
279 }
280 None
281}
282
283fn build_skill_loader_config(
284 workspace_root: &Path,
285 codex_home: &Path,
286 include_bundled_system_skills: bool,
287) -> SkillLoaderConfig {
288 SkillLoaderConfig {
289 codex_home: codex_home.to_path_buf(),
290 cwd: workspace_root.to_path_buf(),
291 project_root: find_project_root(workspace_root)
292 .or_else(|| Some(workspace_root.to_path_buf())),
293 include_bundled_system_skills,
294 }
295}
296
297fn discover_session_skill_metadata(workspace_root: &Path, codex_home: &Path) -> SkillLoadOutcome {
298 let bundled_skills_enabled = ConfigManager::load_from_workspace(workspace_root)
299 .map(|manager| manager.config().skills.bundled.enabled)
300 .unwrap_or(true);
301 let manager = SkillsManager::new_with_bundled_skills_enabled(
302 codex_home.to_path_buf(),
303 bundled_skills_enabled,
304 );
305 manager.ensure_system_skills_installed();
306 let config = build_skill_loader_config(workspace_root, codex_home, bundled_skills_enabled);
307
308 #[cfg(test)]
309 let mut discovery =
310 crate::skills::loader::discover_skill_metadata_lightweight_hermetic(&config);
311
312 #[cfg(not(test))]
313 let mut discovery = crate::skills::loader::discover_skill_metadata_lightweight(&config);
314
315 merge_built_in_command_skill_metadata(&mut discovery.skills);
316 discovery
317}
318
319async fn discover_session_utilities(
320 workspace_root: &Path,
321 codex_home: &Path,
322) -> anyhow::Result<Vec<CliToolConfig>> {
323 let mut config = DiscoveryConfig::default();
324 config.skill_paths.clear();
325 config.tool_paths = vec![
326 PathBuf::from("./tools"),
327 PathBuf::from("./vendor/tools"),
328 codex_home.join("tools"),
329 ];
330
331 let mut discovery = SkillDiscovery::with_config(config);
332 Ok(discovery.discover_all(workspace_root).await?.tools)
333}
334
335fn discovery_error_samples(errors: &[SkillErrorInfo]) -> Vec<String> {
336 errors
337 .iter()
338 .take(3)
339 .map(|error| format!("{}: {}", error.path.display(), error.message))
340 .collect()
341}
342
343fn log_discovery_warnings(operation: &'static str, errors: &[SkillErrorInfo]) {
344 if errors.is_empty() {
345 return;
346 }
347
348 warn!(
349 operation,
350 error_count = errors.len(),
351 sample = ?discovery_error_samples(errors),
352 "Session skill discovery reported warnings"
353 );
354}
355
356fn discover_skill_catalog(
357 workspace_root: &Path,
358 explicit_codex_home: Option<&Path>,
359 operation: &'static str,
360) -> (PathBuf, SkillLoadOutcome) {
361 let codex_home = effective_codex_home(explicit_codex_home);
362 debug!(
363 operation,
364 workspace = %workspace_root.display(),
365 codex_home = %codex_home.display(),
366 "Running session skill discovery"
367 );
368
369 let metadata = discover_session_skill_metadata(workspace_root, &codex_home);
370 log_discovery_warnings(operation, &metadata.errors);
371 (codex_home, metadata)
372}
373
374fn required_string_arg<'a>(args: &'a Value, key: &str) -> anyhow::Result<&'a str> {
375 args.get(key)
376 .and_then(Value::as_str)
377 .ok_or_else(|| anyhow::anyhow!("Missing '{}' argument", key))
378}
379
380fn unsupported_activation_error(skill_name: &str, skill: EnhancedSkill) -> anyhow::Error {
381 let message = match skill {
382 EnhancedSkill::CliTool(_) => {
383 format!(
384 "Skill '{}' is a system utility and cannot be activated via load_skill",
385 skill_name
386 )
387 }
388 EnhancedSkill::BuiltInCommand(_) => {
389 format!(
390 "Skill '{}' is a built-in command skill and cannot be activated via load_skill; use /skills use {} instead",
391 skill_name, skill_name
392 )
393 }
394 EnhancedSkill::NativePlugin(_) => {
395 format!(
396 "Skill '{}' is a native plugin and cannot be activated via load_skill",
397 skill_name
398 )
399 }
400 EnhancedSkill::Traditional(_) => {
401 format!("Skill '{}' is already a traditional skill", skill_name)
402 }
403 };
404
405 anyhow::anyhow!(message)
406}
407
408fn resolve_skill_resource_path(skill_root: &Path, resource_path: &str) -> anyhow::Result<PathBuf> {
409 let relative_path = Path::new(resource_path);
410 if relative_path.is_absolute()
411 || relative_path.components().any(|component| {
412 matches!(
413 component,
414 std::path::Component::ParentDir
415 | std::path::Component::RootDir
416 | std::path::Component::Prefix(_)
417 )
418 })
419 {
420 return Err(anyhow::anyhow!(
421 "Resource path '{}' must be relative to the skill directory",
422 resource_path
423 ));
424 }
425
426 let full_path = skill_root.join(relative_path);
427 let canonical_root = skill_root
428 .canonicalize()
429 .with_context(|| format!("Failed to resolve skill root {}", skill_root.display()))?;
430 let canonical_path = full_path
431 .canonicalize()
432 .with_context(|| format!("Resource '{}' not found", resource_path))?;
433
434 if !canonical_path.starts_with(&canonical_root) {
435 return Err(anyhow::anyhow!(
436 "Resource '{}' escapes the skill directory",
437 resource_path
438 ));
439 }
440
441 if !canonical_path.is_file() {
442 return Err(anyhow::anyhow!(
443 "Resource '{}' is not a readable file",
444 resource_path
445 ));
446 }
447
448 Ok(canonical_path)
449}
450
451fn matches_skill_filters(
452 name: &str,
453 description: &str,
454 variety: SkillVariety,
455 query: Option<&str>,
456 variety_filter: Option<&str>,
457) -> bool {
458 let normalized_variety = format!("{variety:?}").to_lowercase();
459 if let Some(filter) = variety_filter
460 && !normalized_variety.contains(&filter.replace('_', "").to_lowercase())
461 {
462 return false;
463 }
464
465 if let Some(query) = query {
466 let query = query.to_lowercase();
467 if !name.to_lowercase().contains(query.as_str())
468 && !description.to_lowercase().contains(query.as_str())
469 {
470 return false;
471 }
472 }
473
474 true
475}
476
477pub struct LoadSkillTool {
479 workspace_root: PathBuf,
480 codex_home: Option<PathBuf>,
481 active_skills: SkillMap,
482 runtime: SkillToolSessionRuntime,
483}
484
485impl LoadSkillTool {
486 pub fn new(
487 workspace_root: PathBuf,
488 active_skills: SkillMap,
489 runtime: SkillToolSessionRuntime,
490 ) -> Self {
491 Self::with_codex_home(workspace_root, active_skills, runtime, None)
492 }
493
494 pub fn with_codex_home(
495 workspace_root: PathBuf,
496 active_skills: SkillMap,
497 runtime: SkillToolSessionRuntime,
498 codex_home: Option<PathBuf>,
499 ) -> Self {
500 Self {
501 workspace_root,
502 codex_home,
503 active_skills,
504 runtime,
505 }
506 }
507}
508
509#[async_trait]
510impl Tool for LoadSkillTool {
511 fn name(&self) -> &str {
512 "load_skill"
513 }
514
515 fn description(&self) -> &str {
516 "Load detailed instructions for a specific traditional skill and activate its associated tool into your environment."
517 }
518
519 fn parameter_schema(&self) -> Option<Value> {
520 Some(serde_json::json!({
521 "type": "object",
522 "properties": {
523 "name": {
524 "type": "string",
525 "description": "The name of the skill to load"
526 }
527 },
528 "required": ["name"]
529 }))
530 }
531
532 fn default_permission(&self) -> ToolPolicy {
533 ToolPolicy::Allow
535 }
536
537 fn is_mutating(&self) -> bool {
538 false
539 }
540
541 fn is_parallel_safe(&self) -> bool {
542 false
543 }
544
545 async fn execute(&self, args: Value) -> anyhow::Result<Value> {
546 let name = required_string_arg(&args, "name")?;
547
548 if let Some(skill) = self.active_skills.read().await.get(name).cloned() {
549 return Ok(build_skill_response(&skill, SKILL_ALREADY_ACTIVE_STATUS));
550 }
551
552 let (codex_home, metadata) = discover_skill_catalog(
553 &self.workspace_root,
554 self.codex_home.as_deref(),
555 "load_skill",
556 );
557
558 let mut loader =
559 EnhancedSkillLoader::with_codex_home(self.workspace_root.clone(), codex_home.clone());
560 let skill = match loader.get_skill(name).await {
561 Ok(EnhancedSkill::Traditional(skill)) => *skill,
562 Ok(skill) => return Err(unsupported_activation_error(name, skill)),
563 Err(error) => {
564 let tools = discover_session_utilities(&self.workspace_root, &codex_home).await?;
565 if tools.iter().any(|tool| tool.name == name) {
566 return Err(anyhow::anyhow!(
567 "Skill '{}' is a system utility and cannot be activated via load_skill",
568 name
569 ));
570 }
571
572 let detail = if metadata.errors.is_empty() {
573 String::new()
574 } else {
575 format!(
576 " Session discovery also reported {} issue(s); use `list_skills` to inspect warning samples.",
577 metadata.errors.len()
578 )
579 };
580
581 return Err(anyhow::anyhow!(
582 "Failed to load skill '{}': {}.{}",
583 name,
584 error,
585 detail
586 ));
587 }
588 };
589
590 let activation_status = match self
591 .runtime
592 .activate_skill(&self.active_skills, skill.clone())
593 .await?
594 {
595 SkillActivationState::Activated => SKILL_ACTIVATED_STATUS,
596 SkillActivationState::AlreadyActive => SKILL_ALREADY_ACTIVE_STATUS,
597 };
598
599 Ok(build_skill_response(&skill, activation_status))
600 }
601}
602
603pub struct ListSkillsTool {
605 workspace_root: PathBuf,
606 codex_home: Option<PathBuf>,
607 active_skills: SkillMap,
608}
609
610impl ListSkillsTool {
611 pub fn new(workspace_root: PathBuf, active_skills: SkillMap) -> Self {
612 Self::with_codex_home(workspace_root, active_skills, None)
613 }
614
615 pub fn with_codex_home(
616 workspace_root: PathBuf,
617 active_skills: SkillMap,
618 codex_home: Option<PathBuf>,
619 ) -> Self {
620 Self {
621 workspace_root,
622 codex_home,
623 active_skills,
624 }
625 }
626}
627
628#[async_trait]
629impl Tool for ListSkillsTool {
630 fn name(&self) -> &str {
631 "list_skills"
632 }
633
634 fn description(&self) -> &str {
635 "List all available skills and system utilities. Use 'query' to filter by name, description, or routing hints, or 'variety' to filter by type ('agent_skill' or 'system_utility'). Traditional skills stay inactive until activated via 'load_skill'."
636 }
637
638 fn parameter_schema(&self) -> Option<Value> {
639 Some(serde_json::json!({
640 "type": "object",
641 "properties": {
642 "query": {
643 "type": "string",
644 "description": "Optional search term to filter skills by name, description, or routing hints (case-insensitive)"
645 },
646 "variety": {
647 "type": "string",
648 "enum": ["agent_skill", "system_utility", "built_in"],
649 "description": "Optional variety to filter by"
650 }
651 },
652 "additionalProperties": false
653 }))
654 }
655
656 fn default_permission(&self) -> ToolPolicy {
657 ToolPolicy::Allow
658 }
659
660 fn is_mutating(&self) -> bool {
661 false
662 }
663
664 fn is_parallel_safe(&self) -> bool {
665 true
666 }
667
668 async fn execute(&self, args: Value) -> anyhow::Result<Value> {
669 let query = args
670 .get("query")
671 .and_then(|v| v.as_str())
672 .map(|s| s.to_lowercase());
673 let variety_filter = args.get("variety").and_then(|v| v.as_str());
674
675 let active_names: HashSet<String> =
676 self.active_skills.read().await.keys().cloned().collect();
677 let (codex_home, discovery) = discover_skill_catalog(
678 &self.workspace_root,
679 self.codex_home.as_deref(),
680 "list_skills",
681 );
682
683 let mut skill_list = Vec::new();
684
685 for skill_meta in discovery
686 .skills
687 .iter()
688 .filter(|skill| skill.manifest.is_some())
689 {
690 let manifest = skill_meta
691 .manifest
692 .as_ref()
693 .expect("filtered to skills with manifests");
694 if !matches_skill_filters(
695 manifest.name.as_str(),
696 manifest.description.as_str(),
697 manifest.variety,
698 query.as_deref(),
699 variety_filter,
700 ) {
701 continue;
702 }
703
704 let status = if active_names.contains(manifest.name.as_str()) {
705 "active"
706 } else {
707 "dormant"
708 };
709
710 skill_list.push(json!({
711 "name": manifest.name,
712 "description": manifest.description,
713 "path": skill_meta.path,
714 "scope": skill_meta.scope,
715 "variety": manifest.variety,
716 "status": status,
717 }));
718 }
719
720 for tool in discover_session_utilities(&self.workspace_root, &codex_home).await? {
721 if !matches_skill_filters(
722 tool.name.as_str(),
723 tool.description.as_str(),
724 SkillVariety::SystemUtility,
725 query.as_deref(),
726 variety_filter,
727 ) {
728 continue;
729 }
730
731 skill_list.push(json!({
732 "name": tool.name,
733 "description": tool.description,
734 "variety": SkillVariety::SystemUtility,
735 "status": "dormant",
736 }));
737 }
738
739 skill_list.sort_by(|a, b| {
741 let na = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
742 let nb = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
743 na.cmp(nb)
744 });
745
746 let mut grouped = HashMap::with_capacity(skill_list.len());
748 for skill in &skill_list {
749 let variety = skill
750 .get("variety")
751 .and_then(|v| v.as_str())
752 .unwrap_or("unknown");
753 grouped
754 .entry(variety.to_string())
755 .or_insert_with(Vec::new)
756 .push(skill.clone());
757 }
758
759 let mut response = serde_json::json!({
760 "count": skill_list.len(),
761 "groups": grouped,
762 });
763
764 if (query.is_some() || variety_filter.is_some())
766 && let Some(response_object) = response.as_object_mut()
767 {
768 response_object.insert("filter_applied".to_string(), serde_json::json!(true));
769 }
770
771 if !discovery.errors.is_empty()
772 && let Some(response_object) = response.as_object_mut()
773 {
774 response_object.insert(
775 "discovery_errors".to_string(),
776 serde_json::json!(discovery.errors.len()),
777 );
778 response_object.insert(
779 "discovery_error_samples".to_string(),
780 serde_json::json!(discovery_error_samples(&discovery.errors)),
781 );
782 }
783
784 Ok(response)
785 }
786}
787
788pub struct LoadSkillResourceTool {
790 skills: SkillMap,
791}
792
793impl LoadSkillResourceTool {
794 pub fn new(skills: SkillMap) -> Self {
795 Self { skills }
796 }
797}
798
799#[async_trait]
800impl Tool for LoadSkillResourceTool {
801 fn name(&self) -> &str {
802 "load_skill_resource"
803 }
804
805 fn description(&self) -> &str {
806 "Access Level 3 resources (scripts, templates, technical docs) referenced in a skill's SKILL.md. Use this to read files from 'scripts/', 'references/', or 'assets/' when the high-level instructions require them."
807 }
808
809 fn parameter_schema(&self) -> Option<Value> {
810 Some(serde_json::json!({
811 "type": "object",
812 "properties": {
813 "skill_name": {
814 "type": "string",
815 "description": "The name of the skill"
816 },
817 "resource_path": {
818 "type": "string",
819 "description": "The relative path of the resource (e.g. 'scripts/helper.py')"
820 }
821 },
822 "required": ["skill_name", "resource_path"]
823 }))
824 }
825
826 fn default_permission(&self) -> ToolPolicy {
827 ToolPolicy::Allow
828 }
829
830 fn is_mutating(&self) -> bool {
831 false
832 }
833
834 fn is_parallel_safe(&self) -> bool {
835 true
836 }
837
838 async fn execute(&self, args: Value) -> anyhow::Result<Value> {
839 let skill_name = required_string_arg(&args, "skill_name")?;
840 let resource_path = required_string_arg(&args, "resource_path")?;
841
842 let skills = self.skills.read().await;
843 if skills.is_empty() {
844 return Err(anyhow::anyhow!(
845 "No skills are active in this session yet. Use `load_skill` (or `/skills load <name>`) first."
846 ));
847 }
848 if let Some(skill) = skills.get(skill_name) {
849 let full_path = resolve_skill_resource_path(&skill.path, resource_path)?;
850 let content = read_file_with_context_sync(&full_path, "skill resource").context(
851 format!("Failed to read resource at {}", full_path.display()),
852 )?;
853
854 Ok(serde_json::json!({
855 "skill_name": skill_name,
856 "resource_path": resource_path,
857 "content": content
858 }))
859 } else {
860 Err(skill_ops::skill_not_found_error(skill_name))
861 }
862 }
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868 use serde_json::json;
869 use std::sync::atomic::{AtomicUsize, Ordering};
870 use std::{fs, path::Path};
871 use tempfile::TempDir;
872
873 const DEMO_SKILL_TOOL_NAME: &str = "demo-skill";
874
875 fn temp_codex_home(workspace: &Path) -> PathBuf {
876 workspace.join(".test-vtcode-home")
877 }
878
879 fn write_skill_fixture(workspace: &Path, name: &str) {
880 let skill_dir = workspace.join(".agents/skills").join(name);
881 let references_dir = skill_dir.join("references");
882 fs::create_dir_all(&references_dir).expect("skill fixture dirs");
883 fs::write(
884 skill_dir.join("SKILL.md"),
885 format!(
886 r#"---
887name: {name}
888description: Demo skill
889---
890Use the activated helper.
891
892See `references/notes.txt`.
893"#
894 ),
895 )
896 .expect("skill file");
897 fs::write(references_dir.join("notes.txt"), "demo notes").expect("skill resource");
898 }
899
900 fn write_invalid_skill_fixture(workspace: &Path, name: &str) {
901 let skill_dir = workspace.join(".agents/skills").join(name);
902 fs::create_dir_all(&skill_dir).expect("invalid skill dir");
903 fs::write(
904 skill_dir.join("SKILL.md"),
905 format!(
906 r#"---
907name: {name}
908description:
909 - invalid
910---
911Broken skill
912"#
913 ),
914 )
915 .expect("invalid skill file");
916 }
917
918 fn write_rust_skills_metadata_fixture(workspace: &Path) {
919 let skill_dir = workspace.join(".agents/skills").join("rust-skills");
920 fs::create_dir_all(&skill_dir).expect("rust-skills dir");
921 fs::write(
922 skill_dir.join("SKILL.md"),
923 r#"---
924name: rust-skills
925description: Rust guidance
926license: MIT
927metadata:
928 author: leonardomso
929 version: "1.0.0"
930 sources:
931 - Rust API Guidelines
932 - Rust Performance Book
933---
934Use `/rust-skills`.
935"#,
936 )
937 .expect("rust-skills skill file");
938 }
939
940 #[tokio::test]
941 async fn traditional_skill_registration_exposes_native_cgp_factory() {
942 let temp_dir = TempDir::new().expect("temp dir");
943 write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
944
945 let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
946 let skill = match loader
947 .get_skill(DEMO_SKILL_TOOL_NAME)
948 .await
949 .expect("discover skill")
950 {
951 EnhancedSkill::Traditional(skill) => *skill,
952 _ => panic!("expected traditional skill"),
953 };
954
955 let registration = build_traditional_skill_tool_registration(&skill, None);
956 assert!(registration.native_cgp_factory().is_some());
957 }
958
959 #[tokio::test]
960 async fn traditional_skill_native_factory_preserves_registration_metadata() {
961 let temp_dir = TempDir::new().expect("temp dir");
962 write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
963
964 let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
965 let skill = match loader
966 .get_skill(DEMO_SKILL_TOOL_NAME)
967 .await
968 .expect("discover skill")
969 {
970 EnhancedSkill::Traditional(skill) => *skill,
971 _ => panic!("expected traditional skill"),
972 };
973
974 let registration = build_traditional_skill_tool_registration(&skill, None);
975 let native_factory = registration
976 .native_cgp_factory()
977 .expect("registration should expose native factory");
978 let wrapped = native_factory(
979 ®istration,
980 temp_dir.path().to_path_buf(),
981 CgpRuntimeMode::Interactive,
982 );
983
984 assert_eq!(wrapped.name(), DEMO_SKILL_TOOL_NAME);
985 assert_eq!(wrapped.description(), skill.description());
986 assert_eq!(
987 wrapped.prompt_path().as_deref(),
988 Some(SKILL_TOOL_PROMPT_PATH)
989 );
990 assert_eq!(wrapped.default_permission(), ToolPolicy::Prompt);
991 assert!(wrapped.parameter_schema().is_some());
992 }
993
994 #[tokio::test]
995 async fn traditional_skill_registration_schema_includes_empty_properties() {
996 let temp_dir = TempDir::new().expect("temp dir");
997 write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
998
999 let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1000 let skill = match loader
1001 .get_skill(DEMO_SKILL_TOOL_NAME)
1002 .await
1003 .expect("discover skill")
1004 {
1005 EnhancedSkill::Traditional(skill) => *skill,
1006 _ => panic!("expected traditional skill"),
1007 };
1008
1009 let registration = build_traditional_skill_tool_registration(&skill, None);
1010 let schema = registration.parameter_schema().expect("skill schema");
1011
1012 assert_eq!(schema["type"].as_str(), Some("object"));
1013 assert_eq!(schema["properties"], json!({}));
1014 assert_eq!(schema["additionalProperties"], json!(true));
1015 }
1016
1017 #[tokio::test]
1018 async fn load_skill_notifies_when_tool_snapshot_changes() {
1019 let temp_dir = TempDir::new().expect("temp dir");
1020 let skill_name = DEMO_SKILL_TOOL_NAME;
1021 write_skill_fixture(temp_dir.path(), skill_name);
1022
1023 let active_tools = Arc::new(RwLock::new(Vec::new()));
1024 let change_count = Arc::new(AtomicUsize::new(0));
1025 let notifier_count = Arc::clone(&change_count);
1026 let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1027 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1028 let runtime = SkillToolSessionRuntime::new(
1029 Arc::clone(®istry),
1030 Some(Arc::clone(&active_tools)),
1031 ToolDocumentationMode::Full,
1032 ToolModelCapabilities::default(),
1033 Some(Arc::new(move |_| {
1034 notifier_count.fetch_add(1, Ordering::SeqCst);
1035 })),
1036 );
1037
1038 let tool = LoadSkillTool::with_codex_home(
1039 temp_dir.path().to_path_buf(),
1040 Arc::clone(&active_skills),
1041 runtime,
1042 Some(temp_codex_home(temp_dir.path())),
1043 );
1044
1045 let result = tool
1046 .execute(json!({ "name": skill_name }))
1047 .await
1048 .expect("load skill succeeds");
1049
1050 assert_eq!(
1051 result["activation_status"].as_str(),
1052 Some("Associated tools activated and added to context.")
1053 );
1054 assert_eq!(change_count.load(Ordering::SeqCst), 1);
1055 assert!(active_skills.read().await.contains_key(skill_name));
1056 assert!(
1057 active_tools
1058 .read()
1059 .await
1060 .iter()
1061 .any(|tool| tool.function_name() == skill_name)
1062 );
1063 }
1064
1065 #[tokio::test]
1066 async fn load_skill_resource_reads_from_active_skill_map() {
1067 let temp_dir = TempDir::new().expect("temp dir");
1068 let skill_name = DEMO_SKILL_TOOL_NAME;
1069 write_skill_fixture(temp_dir.path(), skill_name);
1070
1071 let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1072 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1073 let runtime = SkillToolSessionRuntime::new(
1074 Arc::clone(®istry),
1075 None,
1076 ToolDocumentationMode::Full,
1077 ToolModelCapabilities::default(),
1078 None,
1079 );
1080 let tool = LoadSkillTool::with_codex_home(
1081 temp_dir.path().to_path_buf(),
1082 Arc::clone(&active_skills),
1083 runtime,
1084 Some(temp_codex_home(temp_dir.path())),
1085 );
1086
1087 tool.execute(json!({ "name": skill_name }))
1088 .await
1089 .expect("skill loads");
1090
1091 let resource_tool = LoadSkillResourceTool::new(Arc::clone(&active_skills));
1092 let result = resource_tool
1093 .execute(json!({
1094 "skill_name": skill_name,
1095 "resource_path": "references/notes.txt"
1096 }))
1097 .await
1098 .expect("resource loads");
1099
1100 assert_eq!(result["content"].as_str(), Some("demo notes"));
1101 }
1102
1103 #[tokio::test]
1104 async fn load_skill_resource_rejects_path_traversal() {
1105 let temp_dir = TempDir::new().expect("temp dir");
1106 let skill_name = DEMO_SKILL_TOOL_NAME;
1107 write_skill_fixture(temp_dir.path(), skill_name);
1108
1109 let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1110 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1111 let runtime = SkillToolSessionRuntime::new(
1112 Arc::clone(®istry),
1113 None,
1114 ToolDocumentationMode::Full,
1115 ToolModelCapabilities::default(),
1116 None,
1117 );
1118 let tool = LoadSkillTool::with_codex_home(
1119 temp_dir.path().to_path_buf(),
1120 Arc::clone(&active_skills),
1121 runtime,
1122 Some(temp_codex_home(temp_dir.path())),
1123 );
1124
1125 tool.execute(json!({ "name": skill_name }))
1126 .await
1127 .expect("skill loads");
1128
1129 let resource_tool = LoadSkillResourceTool::new(Arc::clone(&active_skills));
1130 let error = resource_tool
1131 .execute(json!({
1132 "skill_name": skill_name,
1133 "resource_path": "../outside.txt"
1134 }))
1135 .await
1136 .expect_err("path traversal should fail");
1137
1138 assert!(error.to_string().contains("must be relative"));
1139 }
1140
1141 #[tokio::test]
1142 async fn load_skill_resource_fails_before_activation() {
1143 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1144 let resource_tool = LoadSkillResourceTool::new(active_skills);
1145
1146 let error = resource_tool
1147 .execute(json!({
1148 "skill_name": DEMO_SKILL_TOOL_NAME,
1149 "resource_path": "references/notes.txt"
1150 }))
1151 .await
1152 .expect_err("resource load should fail before activation");
1153
1154 assert!(
1155 error
1156 .to_string()
1157 .contains("Use `load_skill` (or `/skills load <name>`) first.")
1158 );
1159 }
1160
1161 #[tokio::test]
1162 async fn deactivate_skill_unregisters_tool() {
1163 let temp_dir = TempDir::new().expect("temp dir");
1164 let skill_name = DEMO_SKILL_TOOL_NAME;
1165 write_skill_fixture(temp_dir.path(), skill_name);
1166
1167 let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1168 let active_tools = Arc::new(RwLock::new(Vec::new()));
1169 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1170 let runtime = SkillToolSessionRuntime::new(
1171 Arc::clone(®istry),
1172 Some(Arc::clone(&active_tools)),
1173 ToolDocumentationMode::Full,
1174 ToolModelCapabilities::default(),
1175 None,
1176 );
1177 let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1178 let skill = match loader
1179 .get_skill(skill_name)
1180 .await
1181 .expect("discover skill for activation")
1182 {
1183 EnhancedSkill::Traditional(skill) => *skill,
1184 _ => panic!("expected traditional skill"),
1185 };
1186
1187 let activation_state = runtime
1188 .activate_skill(&active_skills, skill)
1189 .await
1190 .expect("activate skill");
1191 assert_eq!(activation_state, SkillActivationState::Activated);
1192 assert!(registry.has_tool(skill_name).await);
1193
1194 let removed = runtime
1195 .deactivate_skill(&active_skills, skill_name)
1196 .await
1197 .expect("deactivate skill");
1198 assert!(removed);
1199 assert!(!active_skills.read().await.contains_key(skill_name));
1200 assert!(!registry.has_tool(skill_name).await);
1201 assert!(
1202 active_tools
1203 .read()
1204 .await
1205 .iter()
1206 .all(|tool| tool.function_name() != skill_name)
1207 );
1208 }
1209
1210 #[tokio::test]
1211 async fn list_skills_discovers_bundled_skill_creator_from_vtcode_home() {
1212 let temp_dir = TempDir::new().expect("temp dir");
1213 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1214 let tool = ListSkillsTool::with_codex_home(
1215 temp_dir.path().to_path_buf(),
1216 active_skills,
1217 Some(temp_codex_home(temp_dir.path())),
1218 );
1219
1220 let result = tool
1221 .execute(json!({ "query": "skill-creator" }))
1222 .await
1223 .expect("list skills succeeds");
1224
1225 assert_eq!(result["count"].as_u64(), Some(1));
1226 let groups = result["groups"]["agent_skill"]
1227 .as_array()
1228 .expect("agent skill group");
1229 assert_eq!(groups.len(), 1);
1230 assert_eq!(groups[0]["name"].as_str(), Some("skill-creator"));
1231 }
1232
1233 #[tokio::test]
1234 async fn load_skill_activates_bundled_skill_creator_from_vtcode_home() {
1235 let temp_dir = TempDir::new().expect("temp dir");
1236 let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1237 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1238 let runtime = SkillToolSessionRuntime::new(
1239 Arc::clone(®istry),
1240 None,
1241 ToolDocumentationMode::Full,
1242 ToolModelCapabilities::default(),
1243 None,
1244 );
1245 let tool = LoadSkillTool::with_codex_home(
1246 temp_dir.path().to_path_buf(),
1247 Arc::clone(&active_skills),
1248 runtime,
1249 Some(temp_codex_home(temp_dir.path())),
1250 );
1251
1252 let result = tool
1253 .execute(json!({ "name": "skill-creator" }))
1254 .await
1255 .expect("load bundled skill succeeds");
1256
1257 assert_eq!(result["name"].as_str(), Some("skill-creator"));
1258 assert_eq!(
1259 result["activation_status"].as_str(),
1260 Some("Associated tools activated and added to context.")
1261 );
1262 assert!(active_skills.read().await.contains_key("skill-creator"));
1263 }
1264
1265 #[tokio::test]
1266 async fn list_skills_discovers_bundled_ast_grep_from_vtcode_home() {
1267 let temp_dir = TempDir::new().expect("temp dir");
1268 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1269 let tool = ListSkillsTool::with_codex_home(
1270 temp_dir.path().to_path_buf(),
1271 active_skills,
1272 Some(temp_codex_home(temp_dir.path())),
1273 );
1274
1275 let result = tool
1276 .execute(json!({ "query": "ast-grep" }))
1277 .await
1278 .expect("list skills succeeds");
1279
1280 assert_eq!(result["count"].as_u64(), Some(1));
1281 let groups = result["groups"]["agent_skill"]
1282 .as_array()
1283 .expect("agent skill group");
1284 assert_eq!(groups.len(), 1);
1285 assert_eq!(groups[0]["name"].as_str(), Some("ast-grep"));
1286 }
1287
1288 #[tokio::test]
1289 async fn load_skill_activates_bundled_ast_grep_from_vtcode_home() {
1290 let temp_dir = TempDir::new().expect("temp dir");
1291 let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1292 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1293 let runtime = SkillToolSessionRuntime::new(
1294 Arc::clone(®istry),
1295 None,
1296 ToolDocumentationMode::Full,
1297 ToolModelCapabilities::default(),
1298 None,
1299 );
1300 let tool = LoadSkillTool::with_codex_home(
1301 temp_dir.path().to_path_buf(),
1302 Arc::clone(&active_skills),
1303 runtime,
1304 Some(temp_codex_home(temp_dir.path())),
1305 );
1306
1307 let result = tool
1308 .execute(json!({ "name": "ast-grep" }))
1309 .await
1310 .expect("load bundled skill succeeds");
1311
1312 assert_eq!(result["name"].as_str(), Some("ast-grep"));
1313 assert_eq!(
1314 result["activation_status"].as_str(),
1315 Some("Associated tools activated and added to context.")
1316 );
1317 assert!(active_skills.read().await.contains_key("ast-grep"));
1318 }
1319
1320 async fn assert_bundled_ast_grep_query(query: &str) {
1321 let temp_dir = TempDir::new().expect("temp dir");
1322 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1323 let tool = ListSkillsTool::with_codex_home(
1324 temp_dir.path().to_path_buf(),
1325 active_skills,
1326 Some(temp_codex_home(temp_dir.path())),
1327 );
1328
1329 let result = tool
1330 .execute(json!({ "query": query }))
1331 .await
1332 .expect("list skills succeeds");
1333
1334 assert_eq!(result["count"].as_u64(), Some(1));
1335 let groups = result["groups"]["agent_skill"]
1336 .as_array()
1337 .expect("agent skill group");
1338 assert_eq!(groups[0]["name"].as_str(), Some("ast-grep"));
1339 }
1340
1341 macro_rules! ast_grep_query_tests {
1342 ($($test_name:ident => $query:literal),+ $(,)?) => {
1343 $(
1344 #[tokio::test]
1345 async fn $test_name() {
1346 assert_bundled_ast_grep_query($query).await;
1347 }
1348 )+
1349 };
1350 }
1351
1352 ast_grep_query_tests! {
1353 list_skills_discovers_bundled_ast_grep_by_inline_rules_query => "inline-rules",
1354 list_skills_discovers_bundled_ast_grep_by_new_rule_query => "new rule",
1355 list_skills_discovers_bundled_ast_grep_by_expand_end_query => "expandEnd",
1356 list_skills_discovers_bundled_ast_grep_by_fix_config_query => "fix config",
1357 list_skills_discovers_bundled_ast_grep_by_string_fix_query => "string fix",
1358 list_skills_discovers_bundled_ast_grep_by_nth_child_stop_by_query => "nthChild stopBy",
1359 list_skills_discovers_bundled_ast_grep_by_range_field_query => "range field",
1360 list_skills_discovers_bundled_ast_grep_by_metadata_url_query => "metadata url",
1361 list_skills_discovers_bundled_ast_grep_by_severity_off_query => "severity off",
1362 list_skills_discovers_bundled_ast_grep_by_include_metadata_query => "include metadata",
1363 list_skills_discovers_bundled_ast_grep_by_case_insensitive_glob_query => "caseInsensitive glob",
1364 list_skills_discovers_bundled_ast_grep_by_rule_order_query => "rule order",
1365 list_skills_discovers_bundled_ast_grep_by_kind_pattern_query => "kind pattern",
1366 list_skills_discovers_bundled_ast_grep_by_positive_rule_query => "positive rule",
1367 list_skills_discovers_bundled_ast_grep_by_kind_esquery_query => "kind esquery",
1368 list_skills_discovers_bundled_ast_grep_by_static_analysis_query => "static analysis",
1369 list_skills_discovers_bundled_ast_grep_by_tree_sitter_parser_query => "tree-sitter parser",
1370 list_skills_discovers_bundled_ast_grep_by_pattern_yaml_api_query => "pattern yaml api",
1371 list_skills_discovers_bundled_ast_grep_by_search_rewrite_lint_analyze_query => "search rewrite lint analyze",
1372 list_skills_discovers_bundled_ast_grep_by_textual_structural_query => "textual structural",
1373 list_skills_discovers_bundled_ast_grep_by_ast_cst_query => "ast cst",
1374 list_skills_discovers_bundled_ast_grep_by_named_unnamed_query => "named unnamed",
1375 list_skills_discovers_bundled_ast_grep_by_kind_field_query => "kind field",
1376 list_skills_discovers_bundled_ast_grep_by_ambiguous_pattern_query => "ambiguous pattern",
1377 list_skills_discovers_bundled_ast_grep_by_effective_selector_query => "effective selector",
1378 list_skills_discovers_bundled_ast_grep_by_meta_variable_detection_query => "meta variable detection",
1379 list_skills_discovers_bundled_ast_grep_by_lazy_multi_query => "lazy multi",
1380 list_skills_discovers_bundled_ast_grep_by_strictness_smart_query => "strictness smart",
1381 list_skills_discovers_bundled_ast_grep_by_relaxed_signature_query => "relaxed signature",
1382 list_skills_discovers_bundled_ast_grep_by_find_patch_query => "find patch",
1383 list_skills_discovers_bundled_ast_grep_by_rewrite_join_by_query => "rewrite joinBy",
1384 list_skills_discovers_bundled_ast_grep_by_replace_substring_query => "replace substring",
1385 list_skills_discovers_bundled_ast_grep_by_to_case_separated_by_query => "toCase separatedBy",
1386 list_skills_discovers_bundled_ast_grep_by_rewriter_query => "rewriter",
1387 list_skills_discovers_bundled_ast_grep_by_rule_dirs_test_configs_query => "ruleDirs testConfigs",
1388 list_skills_discovers_bundled_ast_grep_by_library_path_language_symbol_query => "libraryPath languageSymbol",
1389 list_skills_discovers_bundled_ast_grep_by_dynamic_injected_query => "dynamic injected",
1390 list_skills_discovers_bundled_ast_grep_by_barrel_import_query => "barrel import",
1391 list_skills_discovers_bundled_ast_grep_by_custom_language_query => "custom language",
1392 list_skills_discovers_bundled_ast_grep_by_tree_sitter_libdir_query => "TREE_SITTER_LIBDIR",
1393 list_skills_discovers_bundled_ast_grep_by_language_injection_query => "language injection",
1394 list_skills_discovers_bundled_ast_grep_by_styled_components_query => "styled components",
1395 list_skills_discovers_bundled_ast_grep_by_language_alias_query => "language alias",
1396 list_skills_discovers_bundled_ast_grep_by_stdin_query => "stdin",
1397 list_skills_discovers_bundled_ast_grep_by_programmatic_api_query => "programmatic API",
1398 list_skills_discovers_bundled_ast_grep_by_napi_parse_query => "napi parse",
1399 list_skills_discovers_bundled_ast_grep_by_python_api_query => "python api",
1400 list_skills_discovers_bundled_ast_grep_by_meta_variable_query => "meta variables",
1401 list_skills_discovers_bundled_ast_grep_by_optional_chaining_query => "optional chaining",
1402 list_skills_discovers_bundled_ast_grep_by_rule_catalog_query => "rule catalog",
1403 list_skills_discovers_bundled_ast_grep_by_walrus_operator_query => "walrus operator",
1404 list_skills_discovers_bundled_ast_grep_by_list_comprehension_query => "list comprehension",
1405 list_skills_discovers_bundled_ast_grep_by_isinstance_tuple_query => "isinstance tuple",
1406 }
1407
1408 #[tokio::test]
1409 async fn list_skills_surfaces_discovery_errors() {
1410 let temp_dir = TempDir::new().expect("temp dir");
1411 write_invalid_skill_fixture(temp_dir.path(), "broken-skill");
1412 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1413 let tool = ListSkillsTool::with_codex_home(
1414 temp_dir.path().to_path_buf(),
1415 active_skills,
1416 Some(temp_codex_home(temp_dir.path())),
1417 );
1418
1419 let result = tool.execute(json!({})).await.expect("list skills succeeds");
1420
1421 assert_eq!(result["discovery_errors"].as_u64(), Some(1));
1422 let samples = result["discovery_error_samples"]
1423 .as_array()
1424 .expect("error samples");
1425 assert_eq!(samples.len(), 1);
1426 assert!(
1427 samples[0]
1428 .as_str()
1429 .expect("sample string")
1430 .contains("broken-skill")
1431 );
1432 }
1433
1434 #[tokio::test]
1435 async fn list_skills_accepts_rust_skills_metadata_arrays() {
1436 let temp_dir = TempDir::new().expect("temp dir");
1437 write_rust_skills_metadata_fixture(temp_dir.path());
1438 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1439 let tool = ListSkillsTool::with_codex_home(
1440 temp_dir.path().to_path_buf(),
1441 active_skills,
1442 Some(temp_codex_home(temp_dir.path())),
1443 );
1444
1445 let result = tool
1446 .execute(json!({ "query": "rust-skills" }))
1447 .await
1448 .expect("list skills succeeds");
1449
1450 assert_eq!(result["count"].as_u64(), Some(1));
1451 let groups = result["groups"]["agent_skill"]
1452 .as_array()
1453 .expect("agent skill group");
1454 assert_eq!(groups[0]["name"].as_str(), Some("rust-skills"));
1455 let samples = result
1456 .get("discovery_error_samples")
1457 .and_then(Value::as_array)
1458 .cloned()
1459 .unwrap_or_default();
1460 assert!(samples.iter().all(|sample| {
1461 !sample
1462 .as_str()
1463 .expect("discovery error sample")
1464 .contains("rust-skills")
1465 }));
1466 }
1467
1468 #[tokio::test]
1469 async fn list_skills_emits_agent_skill_routing_metadata() {
1470 let temp_dir = TempDir::new().expect("temp dir");
1471 write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
1472 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1473 let tool = ListSkillsTool::with_codex_home(
1474 temp_dir.path().to_path_buf(),
1475 active_skills,
1476 Some(temp_codex_home(temp_dir.path())),
1477 );
1478
1479 let result = tool
1480 .execute(json!({ "query": DEMO_SKILL_TOOL_NAME }))
1481 .await
1482 .expect("list skills succeeds");
1483
1484 let groups = result["groups"]["agent_skill"]
1485 .as_array()
1486 .expect("agent skill group");
1487 assert_eq!(groups.len(), 1);
1488 let entry = &groups[0];
1489 assert!(
1490 entry["path"]
1491 .as_str()
1492 .expect("path string")
1493 .contains(DEMO_SKILL_TOOL_NAME)
1494 );
1495 assert_eq!(entry["scope"].as_str(), Some("repo"));
1496 }
1497
1498 #[tokio::test]
1499 async fn list_skills_query_matches_description() {
1500 let temp_dir = TempDir::new().expect("temp dir");
1501 write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
1502 let active_skills = Arc::new(RwLock::new(HashMap::new()));
1503 let tool = ListSkillsTool::with_codex_home(
1504 temp_dir.path().to_path_buf(),
1505 active_skills,
1506 Some(temp_codex_home(temp_dir.path())),
1507 );
1508
1509 let result = tool
1510 .execute(json!({ "query": "demo skill" }))
1511 .await
1512 .expect("list skills succeeds");
1513
1514 assert_eq!(result["count"].as_u64(), Some(1));
1515 let groups = result["groups"]["agent_skill"]
1516 .as_array()
1517 .expect("agent skill group");
1518 assert_eq!(groups[0]["name"].as_str(), Some(DEMO_SKILL_TOOL_NAME));
1519 }
1520}