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        let codex_home = workspace.join(".test-vtcode-home");
877        crate::skills::system::install_system_skills(&codex_home)
878            .expect("install embedded system skills for test codex home");
879        codex_home
880    }
881
882    fn write_skill_fixture(workspace: &Path, name: &str) {
883        let skill_dir = workspace.join(".agents/skills").join(name);
884        let references_dir = skill_dir.join("references");
885        fs::create_dir_all(&references_dir).expect("skill fixture dirs");
886        fs::write(
887            skill_dir.join("SKILL.md"),
888            format!(
889                r#"---
890name: {name}
891description: Demo skill
892---
893Use the activated helper.
894
895See `references/notes.txt`.
896"#
897            ),
898        )
899        .expect("skill file");
900        fs::write(references_dir.join("notes.txt"), "demo notes").expect("skill resource");
901    }
902
903    fn write_invalid_skill_fixture(workspace: &Path, name: &str) {
904        let skill_dir = workspace.join(".agents/skills").join(name);
905        fs::create_dir_all(&skill_dir).expect("invalid skill dir");
906        fs::write(
907            skill_dir.join("SKILL.md"),
908            format!(
909                r#"---
910name: {name}
911description:
912  - invalid
913---
914Broken skill
915"#
916            ),
917        )
918        .expect("invalid skill file");
919    }
920
921    fn write_rust_skills_metadata_fixture(workspace: &Path) {
922        let skill_dir = workspace.join(".agents/skills").join("rust-skills");
923        fs::create_dir_all(&skill_dir).expect("rust-skills dir");
924        fs::write(
925            skill_dir.join("SKILL.md"),
926            r#"---
927name: rust-skills
928description: Rust guidance
929license: MIT
930metadata:
931  author: leonardomso
932  version: "1.0.0"
933  sources:
934    - Rust API Guidelines
935    - Rust Performance Book
936---
937Use `/rust-skills`.
938"#,
939        )
940        .expect("rust-skills skill file");
941    }
942
943    #[tokio::test]
944    async fn traditional_skill_registration_exposes_native_cgp_factory() {
945        let temp_dir = TempDir::new().expect("temp dir");
946        write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
947
948        let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
949        let skill = match loader
950            .get_skill(DEMO_SKILL_TOOL_NAME)
951            .await
952            .expect("discover skill")
953        {
954            EnhancedSkill::Traditional(skill) => *skill,
955            _ => panic!("expected traditional skill"),
956        };
957
958        let registration = build_traditional_skill_tool_registration(&skill, None);
959        assert!(registration.native_cgp_factory().is_some());
960    }
961
962    #[tokio::test]
963    async fn traditional_skill_native_factory_preserves_registration_metadata() {
964        let temp_dir = TempDir::new().expect("temp dir");
965        write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
966
967        let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
968        let skill = match loader
969            .get_skill(DEMO_SKILL_TOOL_NAME)
970            .await
971            .expect("discover skill")
972        {
973            EnhancedSkill::Traditional(skill) => *skill,
974            _ => panic!("expected traditional skill"),
975        };
976
977        let registration = build_traditional_skill_tool_registration(&skill, None);
978        let native_factory = registration
979            .native_cgp_factory()
980            .expect("registration should expose native factory");
981        let wrapped = native_factory(
982            &registration,
983            temp_dir.path().to_path_buf(),
984            CgpRuntimeMode::Interactive,
985        );
986
987        assert_eq!(wrapped.name(), DEMO_SKILL_TOOL_NAME);
988        assert_eq!(wrapped.description(), skill.description());
989        assert_eq!(
990            wrapped.prompt_path().as_deref(),
991            Some(SKILL_TOOL_PROMPT_PATH)
992        );
993        assert_eq!(wrapped.default_permission(), ToolPolicy::Prompt);
994        assert!(wrapped.parameter_schema().is_some());
995    }
996
997    #[tokio::test]
998    async fn traditional_skill_registration_schema_includes_empty_properties() {
999        let temp_dir = TempDir::new().expect("temp dir");
1000        write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
1001
1002        let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1003        let skill = match loader
1004            .get_skill(DEMO_SKILL_TOOL_NAME)
1005            .await
1006            .expect("discover skill")
1007        {
1008            EnhancedSkill::Traditional(skill) => *skill,
1009            _ => panic!("expected traditional skill"),
1010        };
1011
1012        let registration = build_traditional_skill_tool_registration(&skill, None);
1013        let schema = registration.parameter_schema().expect("skill schema");
1014
1015        assert_eq!(schema["type"].as_str(), Some("object"));
1016        assert_eq!(schema["properties"], json!({}));
1017        assert_eq!(schema["additionalProperties"], json!(true));
1018    }
1019
1020    #[tokio::test]
1021    async fn load_skill_notifies_when_tool_snapshot_changes() {
1022        let temp_dir = TempDir::new().expect("temp dir");
1023        let skill_name = DEMO_SKILL_TOOL_NAME;
1024        write_skill_fixture(temp_dir.path(), skill_name);
1025
1026        let active_tools = Arc::new(RwLock::new(Vec::new()));
1027        let change_count = Arc::new(AtomicUsize::new(0));
1028        let notifier_count = Arc::clone(&change_count);
1029        let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1030        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1031        let runtime = SkillToolSessionRuntime::new(
1032            Arc::clone(&registry),
1033            Some(Arc::clone(&active_tools)),
1034            ToolDocumentationMode::Full,
1035            ToolModelCapabilities::default(),
1036            Some(Arc::new(move |_| {
1037                notifier_count.fetch_add(1, Ordering::SeqCst);
1038            })),
1039        );
1040
1041        let tool = LoadSkillTool::with_codex_home(
1042            temp_dir.path().to_path_buf(),
1043            Arc::clone(&active_skills),
1044            runtime,
1045            Some(temp_codex_home(temp_dir.path())),
1046        );
1047
1048        let result = tool
1049            .execute(json!({ "name": skill_name }))
1050            .await
1051            .expect("load skill succeeds");
1052
1053        assert_eq!(
1054            result["activation_status"].as_str(),
1055            Some("Associated tools activated and added to context.")
1056        );
1057        assert_eq!(change_count.load(Ordering::SeqCst), 1);
1058        assert!(active_skills.read().await.contains_key(skill_name));
1059        assert!(
1060            active_tools
1061                .read()
1062                .await
1063                .iter()
1064                .any(|tool| tool.function_name() == skill_name)
1065        );
1066    }
1067
1068    #[tokio::test]
1069    async fn load_skill_resource_reads_from_active_skill_map() {
1070        let temp_dir = TempDir::new().expect("temp dir");
1071        let skill_name = DEMO_SKILL_TOOL_NAME;
1072        write_skill_fixture(temp_dir.path(), skill_name);
1073
1074        let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1075        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1076        let runtime = SkillToolSessionRuntime::new(
1077            Arc::clone(&registry),
1078            None,
1079            ToolDocumentationMode::Full,
1080            ToolModelCapabilities::default(),
1081            None,
1082        );
1083        let tool = LoadSkillTool::with_codex_home(
1084            temp_dir.path().to_path_buf(),
1085            Arc::clone(&active_skills),
1086            runtime,
1087            Some(temp_codex_home(temp_dir.path())),
1088        );
1089
1090        tool.execute(json!({ "name": skill_name }))
1091            .await
1092            .expect("skill loads");
1093
1094        let resource_tool = LoadSkillResourceTool::new(Arc::clone(&active_skills));
1095        let result = resource_tool
1096            .execute(json!({
1097                "skill_name": skill_name,
1098                "resource_path": "references/notes.txt"
1099            }))
1100            .await
1101            .expect("resource loads");
1102
1103        assert_eq!(result["content"].as_str(), Some("demo notes"));
1104    }
1105
1106    #[tokio::test]
1107    async fn load_skill_resource_rejects_path_traversal() {
1108        let temp_dir = TempDir::new().expect("temp dir");
1109        let skill_name = DEMO_SKILL_TOOL_NAME;
1110        write_skill_fixture(temp_dir.path(), skill_name);
1111
1112        let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1113        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1114        let runtime = SkillToolSessionRuntime::new(
1115            Arc::clone(&registry),
1116            None,
1117            ToolDocumentationMode::Full,
1118            ToolModelCapabilities::default(),
1119            None,
1120        );
1121        let tool = LoadSkillTool::with_codex_home(
1122            temp_dir.path().to_path_buf(),
1123            Arc::clone(&active_skills),
1124            runtime,
1125            Some(temp_codex_home(temp_dir.path())),
1126        );
1127
1128        tool.execute(json!({ "name": skill_name }))
1129            .await
1130            .expect("skill loads");
1131
1132        let resource_tool = LoadSkillResourceTool::new(Arc::clone(&active_skills));
1133        let error = resource_tool
1134            .execute(json!({
1135                "skill_name": skill_name,
1136                "resource_path": "../outside.txt"
1137            }))
1138            .await
1139            .expect_err("path traversal should fail");
1140
1141        assert!(error.to_string().contains("must be relative"));
1142    }
1143
1144    #[tokio::test]
1145    async fn load_skill_resource_fails_before_activation() {
1146        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1147        let resource_tool = LoadSkillResourceTool::new(active_skills);
1148
1149        let error = resource_tool
1150            .execute(json!({
1151                "skill_name": DEMO_SKILL_TOOL_NAME,
1152                "resource_path": "references/notes.txt"
1153            }))
1154            .await
1155            .expect_err("resource load should fail before activation");
1156
1157        assert!(
1158            error
1159                .to_string()
1160                .contains("Use `load_skill` (or `/skills load <name>`) first.")
1161        );
1162    }
1163
1164    #[tokio::test]
1165    async fn deactivate_skill_unregisters_tool() {
1166        let temp_dir = TempDir::new().expect("temp dir");
1167        let skill_name = DEMO_SKILL_TOOL_NAME;
1168        write_skill_fixture(temp_dir.path(), skill_name);
1169
1170        let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1171        let active_tools = Arc::new(RwLock::new(Vec::new()));
1172        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1173        let runtime = SkillToolSessionRuntime::new(
1174            Arc::clone(&registry),
1175            Some(Arc::clone(&active_tools)),
1176            ToolDocumentationMode::Full,
1177            ToolModelCapabilities::default(),
1178            None,
1179        );
1180        let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1181        let skill = match loader
1182            .get_skill(skill_name)
1183            .await
1184            .expect("discover skill for activation")
1185        {
1186            EnhancedSkill::Traditional(skill) => *skill,
1187            _ => panic!("expected traditional skill"),
1188        };
1189
1190        let activation_state = runtime
1191            .activate_skill(&active_skills, skill)
1192            .await
1193            .expect("activate skill");
1194        assert_eq!(activation_state, SkillActivationState::Activated);
1195        assert!(registry.has_tool(skill_name).await);
1196
1197        let removed = runtime
1198            .deactivate_skill(&active_skills, skill_name)
1199            .await
1200            .expect("deactivate skill");
1201        assert!(removed);
1202        assert!(!active_skills.read().await.contains_key(skill_name));
1203        assert!(!registry.has_tool(skill_name).await);
1204        assert!(
1205            active_tools
1206                .read()
1207                .await
1208                .iter()
1209                .all(|tool| tool.function_name() != skill_name)
1210        );
1211    }
1212
1213    #[tokio::test]
1214    async fn list_skills_discovers_bundled_skill_creator_from_vtcode_home() {
1215        let temp_dir = TempDir::new().expect("temp dir");
1216        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1217        let tool = ListSkillsTool::with_codex_home(
1218            temp_dir.path().to_path_buf(),
1219            active_skills,
1220            Some(temp_codex_home(temp_dir.path())),
1221        );
1222
1223        let result = tool
1224            .execute(json!({ "query": "skill-creator" }))
1225            .await
1226            .expect("list skills succeeds");
1227
1228        assert_eq!(result["count"].as_u64(), Some(1));
1229        let groups = result["groups"]["agent_skill"]
1230            .as_array()
1231            .expect("agent skill group");
1232        assert_eq!(groups.len(), 1);
1233        assert_eq!(groups[0]["name"].as_str(), Some("skill-creator"));
1234    }
1235
1236    #[tokio::test]
1237    async fn load_skill_activates_bundled_skill_creator_from_vtcode_home() {
1238        let temp_dir = TempDir::new().expect("temp dir");
1239        let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1240        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1241        let runtime = SkillToolSessionRuntime::new(
1242            Arc::clone(&registry),
1243            None,
1244            ToolDocumentationMode::Full,
1245            ToolModelCapabilities::default(),
1246            None,
1247        );
1248        let tool = LoadSkillTool::with_codex_home(
1249            temp_dir.path().to_path_buf(),
1250            Arc::clone(&active_skills),
1251            runtime,
1252            Some(temp_codex_home(temp_dir.path())),
1253        );
1254
1255        let result = tool
1256            .execute(json!({ "name": "skill-creator" }))
1257            .await
1258            .expect("load bundled skill succeeds");
1259
1260        assert_eq!(result["name"].as_str(), Some("skill-creator"));
1261        assert_eq!(
1262            result["activation_status"].as_str(),
1263            Some("Associated tools activated and added to context.")
1264        );
1265        assert!(active_skills.read().await.contains_key("skill-creator"));
1266    }
1267
1268    #[tokio::test]
1269    async fn list_skills_discovers_bundled_ast_grep_from_vtcode_home() {
1270        let temp_dir = TempDir::new().expect("temp dir");
1271        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1272        let tool = ListSkillsTool::with_codex_home(
1273            temp_dir.path().to_path_buf(),
1274            active_skills,
1275            Some(temp_codex_home(temp_dir.path())),
1276        );
1277
1278        let result = tool
1279            .execute(json!({ "query": "ast-grep" }))
1280            .await
1281            .expect("list skills succeeds");
1282
1283        assert_eq!(result["count"].as_u64(), Some(1));
1284        let groups = result["groups"]["agent_skill"]
1285            .as_array()
1286            .expect("agent skill group");
1287        assert_eq!(groups.len(), 1);
1288        assert_eq!(groups[0]["name"].as_str(), Some("ast-grep"));
1289    }
1290
1291    #[tokio::test]
1292    async fn load_skill_activates_bundled_ast_grep_from_vtcode_home() {
1293        let temp_dir = TempDir::new().expect("temp dir");
1294        let registry = Arc::new(ToolRegistry::new(temp_dir.path().to_path_buf()).await);
1295        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1296        let runtime = SkillToolSessionRuntime::new(
1297            Arc::clone(&registry),
1298            None,
1299            ToolDocumentationMode::Full,
1300            ToolModelCapabilities::default(),
1301            None,
1302        );
1303        let tool = LoadSkillTool::with_codex_home(
1304            temp_dir.path().to_path_buf(),
1305            Arc::clone(&active_skills),
1306            runtime,
1307            Some(temp_codex_home(temp_dir.path())),
1308        );
1309
1310        let result = tool
1311            .execute(json!({ "name": "ast-grep" }))
1312            .await
1313            .expect("load bundled skill succeeds");
1314
1315        assert_eq!(result["name"].as_str(), Some("ast-grep"));
1316        assert_eq!(
1317            result["activation_status"].as_str(),
1318            Some("Associated tools activated and added to context.")
1319        );
1320        assert!(active_skills.read().await.contains_key("ast-grep"));
1321    }
1322
1323    async fn assert_bundled_ast_grep_query(query: &str) {
1324        let temp_dir = TempDir::new().expect("temp dir");
1325        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1326        let tool = ListSkillsTool::with_codex_home(
1327            temp_dir.path().to_path_buf(),
1328            active_skills,
1329            Some(temp_codex_home(temp_dir.path())),
1330        );
1331
1332        let result = tool
1333            .execute(json!({ "query": query }))
1334            .await
1335            .expect("list skills succeeds");
1336
1337        assert_eq!(result["count"].as_u64(), Some(1));
1338        let groups = result["groups"]["agent_skill"]
1339            .as_array()
1340            .expect("agent skill group");
1341        assert_eq!(groups[0]["name"].as_str(), Some("ast-grep"));
1342    }
1343
1344    macro_rules! ast_grep_query_tests {
1345        ($($test_name:ident => $query:literal),+ $(,)?) => {
1346            $(
1347                #[tokio::test]
1348                async fn $test_name() {
1349                    assert_bundled_ast_grep_query($query).await;
1350                }
1351            )+
1352        };
1353    }
1354
1355    ast_grep_query_tests! {
1356        list_skills_discovers_bundled_ast_grep_by_inline_rules_query => "inline-rules",
1357        list_skills_discovers_bundled_ast_grep_by_new_rule_query => "new rule",
1358        list_skills_discovers_bundled_ast_grep_by_expand_end_query => "expandEnd",
1359        list_skills_discovers_bundled_ast_grep_by_fix_config_query => "fix config",
1360        list_skills_discovers_bundled_ast_grep_by_string_fix_query => "string fix",
1361        list_skills_discovers_bundled_ast_grep_by_nth_child_stop_by_query => "nthChild stopBy",
1362        list_skills_discovers_bundled_ast_grep_by_range_field_query => "range field",
1363        list_skills_discovers_bundled_ast_grep_by_metadata_url_query => "metadata url",
1364        list_skills_discovers_bundled_ast_grep_by_severity_off_query => "severity off",
1365        list_skills_discovers_bundled_ast_grep_by_include_metadata_query => "include metadata",
1366        list_skills_discovers_bundled_ast_grep_by_case_insensitive_glob_query => "caseInsensitive glob",
1367        list_skills_discovers_bundled_ast_grep_by_rule_order_query => "rule order",
1368        list_skills_discovers_bundled_ast_grep_by_kind_pattern_query => "kind pattern",
1369        list_skills_discovers_bundled_ast_grep_by_positive_rule_query => "positive rule",
1370        list_skills_discovers_bundled_ast_grep_by_kind_esquery_query => "kind esquery",
1371        list_skills_discovers_bundled_ast_grep_by_static_analysis_query => "static analysis",
1372        list_skills_discovers_bundled_ast_grep_by_tree_sitter_parser_query => "tree-sitter parser",
1373        list_skills_discovers_bundled_ast_grep_by_pattern_yaml_api_query => "pattern yaml api",
1374        list_skills_discovers_bundled_ast_grep_by_search_rewrite_lint_analyze_query => "search rewrite lint analyze",
1375        list_skills_discovers_bundled_ast_grep_by_textual_structural_query => "textual structural",
1376        list_skills_discovers_bundled_ast_grep_by_ast_cst_query => "ast cst",
1377        list_skills_discovers_bundled_ast_grep_by_named_unnamed_query => "named unnamed",
1378        list_skills_discovers_bundled_ast_grep_by_kind_field_query => "kind field",
1379        list_skills_discovers_bundled_ast_grep_by_ambiguous_pattern_query => "ambiguous pattern",
1380        list_skills_discovers_bundled_ast_grep_by_effective_selector_query => "effective selector",
1381        list_skills_discovers_bundled_ast_grep_by_meta_variable_detection_query => "meta variable detection",
1382        list_skills_discovers_bundled_ast_grep_by_lazy_multi_query => "lazy multi",
1383        list_skills_discovers_bundled_ast_grep_by_strictness_smart_query => "strictness smart",
1384        list_skills_discovers_bundled_ast_grep_by_relaxed_signature_query => "relaxed signature",
1385        list_skills_discovers_bundled_ast_grep_by_find_patch_query => "find patch",
1386        list_skills_discovers_bundled_ast_grep_by_rewrite_join_by_query => "rewrite joinBy",
1387        list_skills_discovers_bundled_ast_grep_by_replace_substring_query => "replace substring",
1388        list_skills_discovers_bundled_ast_grep_by_to_case_separated_by_query => "toCase separatedBy",
1389        list_skills_discovers_bundled_ast_grep_by_rewriter_query => "rewriter",
1390        list_skills_discovers_bundled_ast_grep_by_rule_dirs_test_configs_query => "ruleDirs testConfigs",
1391        list_skills_discovers_bundled_ast_grep_by_library_path_language_symbol_query => "libraryPath languageSymbol",
1392        list_skills_discovers_bundled_ast_grep_by_dynamic_injected_query => "dynamic injected",
1393        list_skills_discovers_bundled_ast_grep_by_barrel_import_query => "barrel import",
1394        list_skills_discovers_bundled_ast_grep_by_custom_language_query => "custom language",
1395        list_skills_discovers_bundled_ast_grep_by_tree_sitter_libdir_query => "TREE_SITTER_LIBDIR",
1396        list_skills_discovers_bundled_ast_grep_by_language_injection_query => "language injection",
1397        list_skills_discovers_bundled_ast_grep_by_styled_components_query => "styled components",
1398        list_skills_discovers_bundled_ast_grep_by_language_alias_query => "language alias",
1399        list_skills_discovers_bundled_ast_grep_by_stdin_query => "stdin",
1400        list_skills_discovers_bundled_ast_grep_by_programmatic_api_query => "programmatic API",
1401        list_skills_discovers_bundled_ast_grep_by_napi_parse_query => "napi parse",
1402        list_skills_discovers_bundled_ast_grep_by_python_api_query => "python api",
1403        list_skills_discovers_bundled_ast_grep_by_meta_variable_query => "meta variables",
1404        list_skills_discovers_bundled_ast_grep_by_optional_chaining_query => "optional chaining",
1405        list_skills_discovers_bundled_ast_grep_by_rule_catalog_query => "rule catalog",
1406        list_skills_discovers_bundled_ast_grep_by_walrus_operator_query => "walrus operator",
1407        list_skills_discovers_bundled_ast_grep_by_list_comprehension_query => "list comprehension",
1408        list_skills_discovers_bundled_ast_grep_by_isinstance_tuple_query => "isinstance tuple",
1409    }
1410
1411    #[tokio::test]
1412    async fn list_skills_surfaces_discovery_errors() {
1413        let temp_dir = TempDir::new().expect("temp dir");
1414        write_invalid_skill_fixture(temp_dir.path(), "broken-skill");
1415        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1416        let tool = ListSkillsTool::with_codex_home(
1417            temp_dir.path().to_path_buf(),
1418            active_skills,
1419            Some(temp_codex_home(temp_dir.path())),
1420        );
1421
1422        let result = tool.execute(json!({})).await.expect("list skills succeeds");
1423
1424        assert_eq!(result["discovery_errors"].as_u64(), Some(1));
1425        let samples = result["discovery_error_samples"]
1426            .as_array()
1427            .expect("error samples");
1428        assert_eq!(samples.len(), 1);
1429        assert!(
1430            samples[0]
1431                .as_str()
1432                .expect("sample string")
1433                .contains("broken-skill")
1434        );
1435    }
1436
1437    #[tokio::test]
1438    async fn list_skills_accepts_rust_skills_metadata_arrays() {
1439        let temp_dir = TempDir::new().expect("temp dir");
1440        write_rust_skills_metadata_fixture(temp_dir.path());
1441        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1442        let tool = ListSkillsTool::with_codex_home(
1443            temp_dir.path().to_path_buf(),
1444            active_skills,
1445            Some(temp_codex_home(temp_dir.path())),
1446        );
1447
1448        let result = tool
1449            .execute(json!({ "query": "rust-skills" }))
1450            .await
1451            .expect("list skills succeeds");
1452
1453        assert_eq!(result["count"].as_u64(), Some(1));
1454        let groups = result["groups"]["agent_skill"]
1455            .as_array()
1456            .expect("agent skill group");
1457        assert_eq!(groups[0]["name"].as_str(), Some("rust-skills"));
1458        let samples = result
1459            .get("discovery_error_samples")
1460            .and_then(Value::as_array)
1461            .cloned()
1462            .unwrap_or_default();
1463        assert!(samples.iter().all(|sample| {
1464            !sample
1465                .as_str()
1466                .expect("discovery error sample")
1467                .contains("rust-skills")
1468        }));
1469    }
1470
1471    #[tokio::test]
1472    async fn list_skills_emits_agent_skill_routing_metadata() {
1473        let temp_dir = TempDir::new().expect("temp dir");
1474        write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
1475        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1476        let tool = ListSkillsTool::with_codex_home(
1477            temp_dir.path().to_path_buf(),
1478            active_skills,
1479            Some(temp_codex_home(temp_dir.path())),
1480        );
1481
1482        let result = tool
1483            .execute(json!({ "query": DEMO_SKILL_TOOL_NAME }))
1484            .await
1485            .expect("list skills succeeds");
1486
1487        let groups = result["groups"]["agent_skill"]
1488            .as_array()
1489            .expect("agent skill group");
1490        assert_eq!(groups.len(), 1);
1491        let entry = &groups[0];
1492        assert!(
1493            entry["path"]
1494                .as_str()
1495                .expect("path string")
1496                .contains(DEMO_SKILL_TOOL_NAME)
1497        );
1498        assert_eq!(entry["scope"].as_str(), Some("repo"));
1499    }
1500
1501    #[tokio::test]
1502    async fn list_skills_query_matches_description() {
1503        let temp_dir = TempDir::new().expect("temp dir");
1504        write_skill_fixture(temp_dir.path(), DEMO_SKILL_TOOL_NAME);
1505        let active_skills = Arc::new(RwLock::new(HashMap::new()));
1506        let tool = ListSkillsTool::with_codex_home(
1507            temp_dir.path().to_path_buf(),
1508            active_skills,
1509            Some(temp_codex_home(temp_dir.path())),
1510        );
1511
1512        let result = tool
1513            .execute(json!({ "query": "demo skill" }))
1514            .await
1515            .expect("list skills succeeds");
1516
1517        assert_eq!(result["count"].as_u64(), Some(1));
1518        let groups = result["groups"]["agent_skill"]
1519            .as_array()
1520            .expect("agent skill group");
1521        assert_eq!(groups[0]["name"].as_str(), Some(DEMO_SKILL_TOOL_NAME));
1522    }
1523}