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