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