Skip to main content

vtcode_core/tools/skills/
mod.rs

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    // Traditional skills already flow through shared fork executors, so keep
184    // the trait-object bridge here and let the native CGP factory handle the
185    // ownership-first path when runtime mode is known.
186    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
477/// Tool to load skill instructions on demand (Progressive Disclosure)
478pub 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        // Loading instructions is safe and read-only
534        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
603/// Tool to list all available skills
604pub 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        // Sort by name for stable output
740        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        // Group by variety for "better" discovery
747        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        // Add context message for queries
765        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
788/// Tool to load a specific resource from a skill (Level 3)
789pub 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            &registration,
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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}