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 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
466pub 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 ToolPolicy::Prompt
516 }
517
518 fn is_mutating(&self) -> bool {
519 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
572pub 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 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 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 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
729pub 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(®istration, 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(®istry),
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(®istry),
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(®istry),
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(®istry),
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(®istry),
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(®istry),
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}