From ded9057ed9fbc9aff2dcd0a2a8d72bd13eca5053 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 22:07:17 +0000 Subject: [PATCH] Align Rust plugin skill and agent loading with upstream routing semantics Rust was still treating local skills as flat roots and had no plugin-backed discovery for /skills, /agents, or Skill tool resolution. This patch adds plugin manifest component paths, recursive namespaced discovery, plugin-prefixed skill/agent listing, and bare-name invoke routing that falls back to unique namespaced suffix matches. The implementation stays narrow to loading and routing: plugin tools and UI flows remain unchanged. Focused tests cover manifest parsing, plugin/local discovery, plugin-prefixed reports, unique plugin suffix resolution, and ambiguous bare-name failures. Constraint: Keep scope limited to plugin/skill/agent loading and invoke routing parity; no UI work Rejected: Introduce a new shared discovery crate | unnecessary drift for a parity patch Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep plugin skill and agent names prefixed with the plugin manifest name so bare-name suffix resolution stays deterministic Tested: cargo check; cargo test Not-tested: Runtime interactive UI rendering for /skills and /agents beyond report output --- rust/crates/commands/src/lib.rs | 593 ++++++++++++++++++++++++++------ rust/crates/plugins/src/lib.rs | 103 ++++++ rust/crates/tools/src/lib.rs | 536 +++++++++++++++++++++++++++-- 3 files changed, 1099 insertions(+), 133 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index da7f1a4..24b5e0e 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -6,8 +6,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -use plugins::{PluginError, PluginManager, PluginSummary}; -use runtime::{compact_session, CompactionConfig, Session}; +use plugins::{ + load_plugin_from_directory, PluginError, PluginManager, PluginManagerConfig, PluginSummary, +}; +use runtime::{compact_session, CompactionConfig, ConfigLoader, Session}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandManifestEntry { @@ -631,6 +633,7 @@ enum DefinitionSource { UserCodexHome, UserCodex, UserClaw, + Plugin, } impl DefinitionSource { @@ -641,6 +644,7 @@ impl DefinitionSource { Self::UserCodexHome => "User ($CODEX_HOME)", Self::UserCodex => "User (~/.codex)", Self::UserClaw => "User (~/.claw)", + Self::Plugin => "Plugins", } } } @@ -684,6 +688,14 @@ struct SkillRoot { source: DefinitionSource, path: PathBuf, origin: SkillOrigin, + name_prefix: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentRoot { + source: DefinitionSource, + path: PathBuf, + name_prefix: Option, } #[allow(clippy::too_many_lines)] @@ -801,7 +813,7 @@ pub fn handle_plugins_slash_command( pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { match normalize_optional_args(args) { None | Some("list") => { - let roots = discover_definition_roots(cwd, "agents"); + let roots = discover_agent_roots(cwd); let agents = load_agents_from_roots(&roots)?; Ok(render_agents_report(&agents)) } @@ -1301,6 +1313,20 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P roots } +fn discover_agent_roots(cwd: &Path) -> Vec { + let mut roots = discover_definition_roots(cwd, "agents") + .into_iter() + .map(|(source, path)| AgentRoot { + source, + path, + name_prefix: None, + }) + .collect::>(); + + extend_plugin_agent_roots(cwd, &mut roots); + roots +} + fn discover_skill_roots(cwd: &Path) -> Vec { let mut roots = Vec::new(); @@ -1310,24 +1336,28 @@ fn discover_skill_roots(cwd: &Path) -> Vec { DefinitionSource::ProjectCodex, ancestor.join(".codex").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::ProjectClaw, ancestor.join(".claw").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::ProjectCodex, ancestor.join(".codex").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::ProjectClaw, ancestor.join(".claw").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); } @@ -1338,12 +1368,14 @@ fn discover_skill_roots(cwd: &Path) -> Vec { DefinitionSource::UserCodexHome, codex_home.join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserCodexHome, codex_home.join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); } @@ -1354,27 +1386,32 @@ fn discover_skill_roots(cwd: &Path) -> Vec { DefinitionSource::UserCodex, home.join(".codex").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserCodex, home.join(".codex").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserClaw, home.join(".claw").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserClaw, home.join(".claw").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); } + extend_plugin_skill_roots(cwd, &mut roots); roots } @@ -1393,43 +1430,162 @@ fn push_unique_skill_root( source: DefinitionSource, path: PathBuf, origin: SkillOrigin, + name_prefix: Option, ) { - if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { + if path.exists() && !roots.iter().any(|existing| existing.path == path) { roots.push(SkillRoot { source, path, origin, + name_prefix, }); } } -fn load_agents_from_roots( - roots: &[(DefinitionSource, PathBuf)], -) -> std::io::Result> { +fn push_unique_agent_root( + roots: &mut Vec, + source: DefinitionSource, + path: PathBuf, + name_prefix: Option, +) { + if path.exists() && !roots.iter().any(|existing| existing.path == path) { + roots.push(AgentRoot { + source, + path, + name_prefix, + }); + } +} + +fn extend_plugin_agent_roots(cwd: &Path, roots: &mut Vec) { + for plugin in enabled_plugins_for_cwd(cwd) { + let Some(root) = &plugin.metadata.root else { + continue; + }; + + push_unique_agent_root( + roots, + DefinitionSource::Plugin, + root.join("agents"), + Some(plugin.metadata.name.clone()), + ); + + if let Ok(manifest) = load_plugin_from_directory(root) { + for relative in manifest.agents { + push_unique_agent_root( + roots, + DefinitionSource::Plugin, + resolve_plugin_component_path(root, &relative), + Some(plugin.metadata.name.clone()), + ); + } + } + } +} + +fn extend_plugin_skill_roots(cwd: &Path, roots: &mut Vec) { + for plugin in enabled_plugins_for_cwd(cwd) { + let Some(root) = &plugin.metadata.root else { + continue; + }; + + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + root.join("skills"), + SkillOrigin::SkillsDir, + Some(plugin.metadata.name.clone()), + ); + + if let Ok(manifest) = load_plugin_from_directory(root) { + for relative in manifest.skills { + let path = resolve_plugin_component_path(root, &relative); + let origin = if path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) + { + SkillOrigin::LegacyCommandsDir + } else { + SkillOrigin::SkillsDir + }; + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + path, + origin, + Some(plugin.metadata.name.clone()), + ); + } + } + } +} + +fn enabled_plugins_for_cwd(cwd: &Path) -> Vec { + let Some(manager) = plugin_manager_for_cwd(cwd) else { + return Vec::new(); + }; + + manager + .list_installed_plugins() + .map(|plugins| { + plugins + .into_iter() + .filter(|plugin| plugin.enabled) + .collect::>() + }) + .unwrap_or_default() +} + +fn plugin_manager_for_cwd(cwd: &Path) -> Option { + let loader = ConfigLoader::default_for(cwd); + let runtime_config = loader.load().ok()?; + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + Some(PluginManager::new(plugin_config)) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_path_buf() + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +fn resolve_plugin_component_path(root: &Path, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +fn load_agents_from_roots(roots: &[AgentRoot]) -> std::io::Result> { let mut agents = Vec::new(); let mut active_sources = BTreeMap::::new(); - for (source, root) in roots { + for root in roots { let mut root_agents = Vec::new(); - for entry in fs::read_dir(root)? { - let entry = entry?; - if entry.path().extension().is_none_or(|ext| ext != "toml") { - continue; - } - let contents = fs::read_to_string(entry.path())?; - let fallback_name = entry.path().file_stem().map_or_else( - || entry.file_name().to_string_lossy().to_string(), - |stem| stem.to_string_lossy().to_string(), - ); - root_agents.push(AgentSummary { - name: parse_toml_string(&contents, "name").unwrap_or(fallback_name), - description: parse_toml_string(&contents, "description"), - model: parse_toml_string(&contents, "model"), - reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"), - source: *source, - shadowed_by: None, - }); - } + collect_agents(root, &root.path, &mut root_agents)?; root_agents.sort_by(|left, right| left.name.cmp(&right.name)); for mut agent in root_agents { @@ -1452,61 +1608,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result { - if !entry.path().is_dir() { - continue; - } - let skill_path = entry.path().join("SKILL.md"); - if !skill_path.is_file() { - continue; - } - let contents = fs::read_to_string(skill_path)?; - let (name, description) = parse_skill_frontmatter(&contents); - root_skills.push(SkillSummary { - name: name - .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()), - description, - source: root.source, - shadowed_by: None, - origin: root.origin, - }); - } - SkillOrigin::LegacyCommandsDir => { - let path = entry.path(); - let markdown_path = if path.is_dir() { - let skill_path = path.join("SKILL.md"); - if !skill_path.is_file() { - continue; - } - skill_path - } else if path - .extension() - .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) - { - path - } else { - continue; - }; - - let contents = fs::read_to_string(&markdown_path)?; - let fallback_name = markdown_path.file_stem().map_or_else( - || entry.file_name().to_string_lossy().to_string(), - |stem| stem.to_string_lossy().to_string(), - ); - let (name, description) = parse_skill_frontmatter(&contents); - root_skills.push(SkillSummary { - name: name.unwrap_or(fallback_name), - description, - source: root.source, - shadowed_by: None, - origin: root.origin, - }); - } - } - } + collect_skills(root, &root.path, &mut root_skills)?; root_skills.sort_by(|left, right| left.name.cmp(&right.name)); for mut skill in root_skills { @@ -1523,6 +1625,205 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result, +) -> std::io::Result<()> { + if path.is_file() { + if let Some(agent) = load_agent_summary(path, &root.path, root)? { + agents.push(agent); + } + return Ok(()); + } + + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let entry_path = entry.path(); + if entry_path.is_dir() { + collect_agents(root, &entry_path, agents)?; + } else if let Some(agent) = load_agent_summary(&entry_path, &root.path, root)? { + agents.push(agent); + } + } + + Ok(()) +} + +fn load_agent_summary( + path: &Path, + base_root: &Path, + root: &AgentRoot, +) -> std::io::Result> { + let extension = path + .extension() + .map(|ext| ext.to_string_lossy().to_ascii_lowercase()); + let Some(extension) = extension else { + return Ok(None); + }; + if extension != "toml" && extension != "md" { + return Ok(None); + } + + let contents = fs::read_to_string(path)?; + let base_name = if extension == "toml" { + parse_toml_string(&contents, "name").unwrap_or_else(|| fallback_file_stem(path)) + } else { + let (name, _) = parse_skill_frontmatter(&contents); + name.unwrap_or_else(|| fallback_file_stem(path)) + }; + let description = if extension == "toml" { + parse_toml_string(&contents, "description") + } else { + let (_, description) = parse_skill_frontmatter(&contents); + description + }; + let model = if extension == "toml" { + parse_toml_string(&contents, "model") + } else { + parse_frontmatter_key(&contents, "model") + }; + let reasoning_effort = if extension == "toml" { + parse_toml_string(&contents, "model_reasoning_effort") + } else { + parse_frontmatter_key(&contents, "effort") + }; + + Ok(Some(AgentSummary { + name: prefixed_definition_name( + root.name_prefix.as_deref(), + namespace_for_file(path, base_root, false), + &base_name, + ), + description, + model, + reasoning_effort, + source: root.source, + shadowed_by: None, + })) +} + +fn collect_skills( + root: &SkillRoot, + path: &Path, + skills: &mut Vec, +) -> std::io::Result<()> { + if path.is_file() { + if let Some(skill) = load_skill_summary(path, &root.path, root)? { + skills.push(skill); + } + return Ok(()); + } + + let skill_md_path = path.join("SKILL.md"); + if skill_md_path.is_file() { + if let Some(skill) = load_skill_summary(&skill_md_path, &root.path, root)? { + skills.push(skill); + } + return Ok(()); + } + + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let entry_path = entry.path(); + if entry_path.is_dir() { + collect_skills(root, &entry_path, skills)?; + } else if root.origin == SkillOrigin::LegacyCommandsDir { + if let Some(skill) = load_skill_summary(&entry_path, &root.path, root)? { + skills.push(skill); + } + } + } + + Ok(()) +} + +fn load_skill_summary( + path: &Path, + base_root: &Path, + root: &SkillRoot, +) -> std::io::Result> { + if !path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) + { + return Ok(None); + } + + let is_skill_file = path + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case("SKILL.md")); + if root.origin == SkillOrigin::SkillsDir && !is_skill_file { + return Ok(None); + } + + let contents = fs::read_to_string(path)?; + let (name, description) = parse_skill_frontmatter(&contents); + let base_name = if is_skill_file { + path.parent().and_then(Path::file_name).map_or_else( + || fallback_file_stem(path), + |name| name.to_string_lossy().to_string(), + ) + } else { + fallback_file_stem(path) + }; + let namespace = namespace_for_file(path, base_root, is_skill_file); + + Ok(Some(SkillSummary { + name: prefixed_definition_name( + root.name_prefix.as_deref(), + namespace, + &name.unwrap_or(base_name), + ), + description, + source: root.source, + shadowed_by: None, + origin: root.origin, + })) +} + +fn namespace_for_file(path: &Path, base_root: &Path, is_skill_file: bool) -> Option { + let relative_parent = if is_skill_file { + path.parent() + .and_then(Path::parent) + .and_then(|parent| parent.strip_prefix(base_root).ok()) + } else { + path.parent() + .and_then(|parent| parent.strip_prefix(base_root).ok()) + }?; + + let segments = relative_parent + .iter() + .map(|segment| segment.to_string_lossy()) + .filter(|segment| !segment.is_empty()) + .map(|segment| segment.to_string()) + .collect::>(); + (!segments.is_empty()).then(|| segments.join(":")) +} + +fn prefixed_definition_name( + prefix: Option<&str>, + namespace: Option, + base_name: &str, +) -> String { + let mut parts = Vec::new(); + if let Some(prefix) = prefix.filter(|prefix| !prefix.is_empty()) { + parts.push(prefix.to_string()); + } + if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) { + parts.push(namespace); + } + parts.push(base_name.to_string()); + parts.join(":") +} + +fn fallback_file_stem(path: &Path) -> String { + path.file_stem() + .map_or_else(String::new, |stem| stem.to_string_lossy().to_string()) +} + fn parse_toml_string(contents: &str, key: &str) -> Option { let prefix = format!("{key} ="); for line in contents.lines() { @@ -1548,34 +1849,32 @@ fn parse_toml_string(contents: &str, key: &str) -> Option { } fn parse_skill_frontmatter(contents: &str) -> (Option, Option) { + ( + parse_frontmatter_key(contents, "name"), + parse_frontmatter_key(contents, "description"), + ) +} + +fn parse_frontmatter_key(contents: &str, key: &str) -> Option { let mut lines = contents.lines(); if lines.next().map(str::trim) != Some("---") { - return (None, None); + return None; } - let mut name = None; - let mut description = None; for line in lines { let trimmed = line.trim(); if trimmed == "---" { break; } - if let Some(value) = trimmed.strip_prefix("name:") { + if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) { let value = unquote_frontmatter_value(value.trim()); if !value.is_empty() { - name = Some(value); - } - continue; - } - if let Some(value) = trimmed.strip_prefix("description:") { - let value = unquote_frontmatter_value(value.trim()); - if !value.is_empty() { - description = Some(value); + return Some(value); } } } - (name, description) + None } fn unquote_frontmatter_value(value: &str) -> String { @@ -1613,6 +1912,7 @@ fn render_agents_report(agents: &[AgentSummary]) -> String { DefinitionSource::UserCodexHome, DefinitionSource::UserCodex, DefinitionSource::UserClaw, + DefinitionSource::Plugin, ] { let group = agents .iter() @@ -1671,6 +1971,7 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { DefinitionSource::UserCodexHome, DefinitionSource::UserCodex, DefinitionSource::UserClaw, + DefinitionSource::Plugin, ] { let group = skills .iter() @@ -1710,7 +2011,8 @@ fn render_agents_usage(unexpected: Option<&str>) -> String { "Agents".to_string(), " Usage /agents".to_string(), " Direct CLI claw agents".to_string(), - " Sources .codex/agents, .claw/agents, $CODEX_HOME/agents".to_string(), + " Sources .codex/agents, .claw/agents, $CODEX_HOME/agents, enabled plugin agents" + .to_string(), ]; if let Some(args) = unexpected { lines.push(format!(" Unexpected {args}")); @@ -1723,7 +2025,8 @@ fn render_skills_usage(unexpected: Option<&str>) -> String { "Skills".to_string(), " Usage /skills".to_string(), " Direct CLI claw skills".to_string(), - " Sources .codex/skills, .claw/skills, legacy /commands".to_string(), + " Sources .codex/skills, .claw/skills, legacy /commands, enabled plugin skills" + .to_string(), ]; if let Some(args) = unexpected { lines.push(format!(" Unexpected {args}")); @@ -1795,8 +2098,8 @@ mod tests { handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, - SlashCommand, + suggest_slash_commands, AgentRoot, CommitPushPrRequest, DefinitionSource, SkillOrigin, + SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -1937,6 +2240,17 @@ mod tests { .expect("write agent"); } + fn write_markdown_agent(root: &Path, name: &str, description: &str, model: &str, effort: &str) { + fs::create_dir_all(root).expect("agent root"); + fs::write( + root.join(format!("{name}.md")), + format!( + "---\nname: {name}\ndescription: {description}\nmodel: {model}\neffort: {effort}\n---\n\n# {name}\n" + ), + ) + .expect("write markdown agent"); + } + fn write_skill(root: &Path, name: &str, description: &str) { let skill_root = root.join(name); fs::create_dir_all(&skill_root).expect("skill root"); @@ -2336,8 +2650,16 @@ mod tests { ); let roots = vec![ - (DefinitionSource::ProjectCodex, project_agents), - (DefinitionSource::UserCodex, user_agents), + AgentRoot { + source: DefinitionSource::ProjectCodex, + path: project_agents, + name_prefix: None, + }, + AgentRoot { + source: DefinitionSource::UserCodex, + path: user_agents, + name_prefix: None, + }, ]; let report = render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load")); @@ -2372,16 +2694,19 @@ mod tests { source: DefinitionSource::ProjectCodex, path: project_skills, origin: SkillOrigin::SkillsDir, + name_prefix: None, }, SkillRoot { source: DefinitionSource::ProjectClaw, path: project_commands, origin: SkillOrigin::LegacyCommandsDir, + name_prefix: None, }, SkillRoot { source: DefinitionSource::UserCodex, path: user_skills, origin: SkillOrigin::SkillsDir, + name_prefix: None, }, ]; let report = @@ -2401,6 +2726,71 @@ mod tests { let _ = fs::remove_dir_all(user_home); } + #[test] + fn discovers_namespaced_local_and_plugin_skills_and_agents() { + let _guard = env_lock(); + let workspace = temp_dir("plugin-discovery-workspace"); + let config_home = temp_dir("plugin-discovery-home"); + let plugin_source = temp_dir("plugin-discovery-source"); + + let nested_skill_root = workspace.join(".codex").join("skills").join("ops"); + write_skill(&nested_skill_root, "deploy", "Nested deployment guidance"); + + write_external_plugin(&plugin_source, "demo-plugin", "1.0.0"); + write_skill( + &plugin_source.join("skills").join("reviews"), + "audit", + "Plugin audit guidance", + ); + write_markdown_agent( + &plugin_source.join("agents").join("ops"), + "triage", + "Plugin triage agent", + "gpt-5.4-mini", + "high", + ); + + let previous_home = env::var_os("HOME"); + let previous_claw_config_home = env::var_os("CLAW_CONFIG_HOME"); + env::set_var("HOME", temp_dir("plugin-discovery-user-home")); + env::set_var("CLAW_CONFIG_HOME", &config_home); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + handle_plugins_slash_command( + Some("install"), + Some(plugin_source.to_str().expect("utf8 path")), + &mut manager, + ) + .expect("plugin install should succeed"); + + let skills_report = + super::handle_skills_slash_command(None, &workspace).expect("skills should render"); + assert!(skills_report.contains("ops:deploy · Nested deployment guidance")); + assert!(skills_report.contains("Plugins:")); + assert!(skills_report.contains("demo-plugin:reviews:audit · Plugin audit guidance")); + + let agents_report = + super::handle_agents_slash_command(None, &workspace).expect("agents should render"); + assert!(agents_report.contains("Plugins:")); + assert!(agents_report + .contains("demo-plugin:ops:triage · Plugin triage agent · gpt-5.4-mini · high")); + + if let Some(value) = previous_home { + env::set_var("HOME", value); + } else { + env::remove_var("HOME"); + } + if let Some(value) = previous_claw_config_home { + env::set_var("CLAW_CONFIG_HOME", value); + } else { + env::remove_var("CLAW_CONFIG_HOME"); + } + + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(plugin_source); + } + #[test] fn agents_and_skills_usage_support_help_and_unexpected_args() { let cwd = temp_dir("slash-usage"); @@ -2418,6 +2808,7 @@ mod tests { super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); assert!(skills_help.contains("Usage /skills")); assert!(skills_help.contains("legacy /commands")); + assert!(skills_help.contains("enabled plugin skills")); let skills_unexpected = super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage"); diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 6105ad9..9f33bca 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -119,6 +119,10 @@ pub struct PluginManifest { pub tools: Vec, #[serde(default)] pub commands: Vec, + #[serde(default)] + pub agents: Vec, + #[serde(default)] + pub skills: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -228,6 +232,10 @@ struct RawPluginManifest { pub tools: Vec, #[serde(default)] pub commands: Vec, + #[serde(default, deserialize_with = "deserialize_string_list")] + pub agents: Vec, + #[serde(default, deserialize_with = "deserialize_string_list")] + pub skills: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -246,6 +254,24 @@ struct RawPluginToolManifest { pub required_permission: String, } +fn deserialize_string_list<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringList { + One(String), + Many(Vec), + } + + Ok(match Option::::deserialize(deserializer)? { + Some(StringList::One(value)) => vec![value], + Some(StringList::Many(values)) => values, + None => Vec::new(), + }) +} + #[derive(Debug, Clone, PartialEq)] pub struct PluginTool { plugin_id: String, @@ -1461,6 +1487,8 @@ fn build_plugin_manifest( "lifecycle command", &mut errors, ); + let agents = build_manifest_paths(root, raw.agents, "agent", &mut errors); + let skills = build_manifest_paths(root, raw.skills, "skill", &mut errors); let tools = build_manifest_tools(root, raw.tools, &mut errors); let commands = build_manifest_commands(root, raw.commands, &mut errors); @@ -1478,6 +1506,8 @@ fn build_plugin_manifest( lifecycle: raw.lifecycle, tools, commands, + agents, + skills, }) } @@ -1593,6 +1623,47 @@ fn build_manifest_tools( validated } +fn build_manifest_paths( + root: &Path, + paths: Vec, + kind: &'static str, + errors: &mut Vec, +) -> Vec { + let mut seen = BTreeSet::new(); + let mut validated = Vec::new(); + + for path in paths { + let trimmed = path.trim(); + if trimmed.is_empty() { + errors.push(PluginManifestValidationError::EmptyEntryField { + kind, + field: "path", + name: None, + }); + continue; + } + + let resolved = if Path::new(trimmed).is_absolute() { + PathBuf::from(trimmed) + } else { + root.join(trimmed) + }; + if !resolved.exists() { + errors.push(PluginManifestValidationError::MissingPath { + kind, + path: resolved, + }); + continue; + } + + if seen.insert(trimmed.to_string()) { + validated.push(trimmed.to_string()); + } + } + + validated +} + fn build_manifest_commands( root: &Path, commands: Vec, @@ -2227,6 +2298,38 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn load_plugin_from_directory_parses_agent_and_skill_paths() { + let root = temp_dir("manifest-component-paths"); + write_file( + root.join("agents").join("ops").join("triage.md").as_path(), + "---\nname: triage\ndescription: triage agent\n---\n", + ); + write_file( + root.join("skills") + .join("review") + .join("SKILL.md") + .as_path(), + "---\nname: review\ndescription: review skill\n---\n", + ); + write_file( + root.join(MANIFEST_FILE_NAME).as_path(), + r#"{ + "name": "component-paths", + "version": "1.0.0", + "description": "Manifest component paths", + "agents": "./agents/ops/triage.md", + "skills": ["./skills"] +}"#, + ); + + let manifest = load_plugin_from_directory(&root).expect("manifest should load"); + assert_eq!(manifest.agents, vec!["./agents/ops/triage.md"]); + assert_eq!(manifest.skills, vec!["./skills"]); + + let _ = fs::remove_dir_all(root); + } + #[test] fn load_plugin_from_directory_defaults_optional_fields() { let root = temp_dir("manifest-defaults"); diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 4b42572..3f5c987 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -8,13 +8,15 @@ use api::{ MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; -use plugins::PluginTool; +use plugins::{ + load_plugin_from_directory, PluginManager, PluginManagerConfig, PluginSummary, PluginTool, +}; use reqwest::blocking::Client; use runtime::{ edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, - ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, - ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, - RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock, + ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, + PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -91,7 +93,10 @@ impl GlobalToolRegistry { Ok(Self { plugin_tools }) } - pub fn normalize_allowed_tools(&self, values: &[String]) -> Result>, String> { + pub fn normalize_allowed_tools( + &self, + values: &[String], + ) -> Result>, String> { if values.is_empty() { return Ok(None); } @@ -100,7 +105,11 @@ impl GlobalToolRegistry { let canonical_names = builtin_specs .iter() .map(|spec| spec.name.to_string()) - .chain(self.plugin_tools.iter().map(|tool| tool.definition().name.clone())) + .chain( + self.plugin_tools + .iter() + .map(|tool| tool.definition().name.clone()), + ) .collect::>(); let mut name_map = canonical_names .iter() @@ -151,7 +160,8 @@ impl GlobalToolRegistry { .plugin_tools .iter() .filter(|tool| { - allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) + allowed_tools + .is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) }) .map(|tool| ToolDefinition { name: tool.definition().name.clone(), @@ -174,7 +184,8 @@ impl GlobalToolRegistry { .plugin_tools .iter() .filter(|tool| { - allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) + allowed_tools + .is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) }) .map(|tool| { ( @@ -1454,48 +1465,391 @@ fn todo_store_path() -> Result { Ok(cwd.join(".claw-todos.json")) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillRootKind { + Skills, + LegacyCommands, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillCandidate { + name: String, + path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillCandidateRoot { + path: PathBuf, + kind: SkillRootKind, + name_prefix: Option, +} + fn resolve_skill_path(skill: &str) -> Result { let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); if requested.is_empty() { return Err(String::from("skill must not be empty")); } + let candidates = discover_skill_candidates().map_err(|error| error.to_string())?; + + if let Some(candidate) = candidates + .iter() + .find(|candidate| candidate.name.eq_ignore_ascii_case(requested)) + { + return Ok(candidate.path.clone()); + } + + let suffix = format!(":{requested}"); + let suffix_matches = candidates + .iter() + .filter(|candidate| candidate.name.ends_with(&suffix)) + .collect::>(); + match suffix_matches.as_slice() { + [candidate] => Ok(candidate.path.clone()), + [] => Err(format!("unknown skill: {requested}")), + matches => Err(format!( + "ambiguous skill `{requested}`; use one of: {}", + matches + .iter() + .map(|candidate| candidate.name.as_str()) + .collect::>() + .join(", ") + )), + } +} + +fn discover_skill_candidates() -> std::io::Result> { + let cwd = std::env::current_dir()?; + let mut roots = local_skill_candidate_roots(&cwd); + extend_plugin_skill_candidate_roots(&cwd, &mut roots); + let mut candidates = Vec::new(); + for root in &roots { + collect_skill_candidates(root, &root.path, &mut candidates)?; + } + Ok(candidates) +} + +fn local_skill_candidate_roots(cwd: &Path) -> Vec { + let mut roots = Vec::new(); + + for ancestor in cwd.ancestors() { + push_skill_candidate_root( + &mut roots, + ancestor.join(".codex").join("skills"), + SkillRootKind::Skills, + None, + ); + push_skill_candidate_root( + &mut roots, + ancestor.join(".claw").join("skills"), + SkillRootKind::Skills, + None, + ); + push_skill_candidate_root( + &mut roots, + ancestor.join(".codex").join("commands"), + SkillRootKind::LegacyCommands, + None, + ); + push_skill_candidate_root( + &mut roots, + ancestor.join(".claw").join("commands"), + SkillRootKind::LegacyCommands, + None, + ); + } + if let Ok(codex_home) = std::env::var("CODEX_HOME") { - candidates.push(std::path::PathBuf::from(codex_home).join("skills")); + let codex_home = PathBuf::from(codex_home); + push_skill_candidate_root( + &mut roots, + codex_home.join("skills"), + SkillRootKind::Skills, + None, + ); + push_skill_candidate_root( + &mut roots, + codex_home.join("commands"), + SkillRootKind::LegacyCommands, + None, + ); } if let Ok(home) = std::env::var("HOME") { - let home = std::path::PathBuf::from(home); - candidates.push(home.join(".agents").join("skills")); - candidates.push(home.join(".config").join("opencode").join("skills")); - candidates.push(home.join(".codex").join("skills")); + let home = PathBuf::from(home); + push_skill_candidate_root( + &mut roots, + home.join(".agents").join("skills"), + SkillRootKind::Skills, + None, + ); + push_skill_candidate_root( + &mut roots, + home.join(".config").join("opencode").join("skills"), + SkillRootKind::Skills, + None, + ); + push_skill_candidate_root( + &mut roots, + home.join(".codex").join("skills"), + SkillRootKind::Skills, + None, + ); + push_skill_candidate_root( + &mut roots, + home.join(".claw").join("skills"), + SkillRootKind::Skills, + None, + ); + push_skill_candidate_root( + &mut roots, + home.join(".codex").join("commands"), + SkillRootKind::LegacyCommands, + None, + ); + push_skill_candidate_root( + &mut roots, + home.join(".claw").join("commands"), + SkillRootKind::LegacyCommands, + None, + ); } - candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills")); + push_skill_candidate_root( + &mut roots, + PathBuf::from("/home/bellman/.codex/skills"), + SkillRootKind::Skills, + None, + ); - for root in candidates { - let direct = root.join(requested).join("SKILL.md"); - if direct.exists() { - return Ok(direct); - } + roots +} - if let Ok(entries) = std::fs::read_dir(&root) { - for entry in entries.flatten() { - let path = entry.path().join("SKILL.md"); - if !path.exists() { - continue; - } - if entry - .file_name() - .to_string_lossy() - .eq_ignore_ascii_case(requested) +fn extend_plugin_skill_candidate_roots(cwd: &Path, roots: &mut Vec) { + for plugin in enabled_plugins_for_cwd(cwd) { + let Some(root) = &plugin.metadata.root else { + continue; + }; + + push_skill_candidate_root( + roots, + root.join("skills"), + SkillRootKind::Skills, + Some(plugin.metadata.name.clone()), + ); + + if let Ok(manifest) = load_plugin_from_directory(root) { + for relative in manifest.skills { + let path = resolve_plugin_component_path(root, &relative); + let kind = if path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) { - return Ok(path); - } + SkillRootKind::LegacyCommands + } else { + SkillRootKind::Skills + }; + push_skill_candidate_root(roots, path, kind, Some(plugin.metadata.name.clone())); + } + } + } +} + +fn push_skill_candidate_root( + roots: &mut Vec, + path: PathBuf, + kind: SkillRootKind, + name_prefix: Option, +) { + if path.exists() && !roots.iter().any(|existing| existing.path == path) { + roots.push(SkillCandidateRoot { + path, + kind, + name_prefix, + }); + } +} + +fn collect_skill_candidates( + root: &SkillCandidateRoot, + path: &Path, + candidates: &mut Vec, +) -> std::io::Result<()> { + if path.is_file() { + if let Some(candidate) = load_skill_candidate(root, path, &root.path)? { + candidates.push(candidate); + } + return Ok(()); + } + + let skill_md = path.join("SKILL.md"); + if skill_md.is_file() { + if let Some(candidate) = load_skill_candidate(root, &skill_md, &root.path)? { + candidates.push(candidate); + } + return Ok(()); + } + + let mut entries = std::fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let entry_path = entry.path(); + if entry_path.is_dir() { + collect_skill_candidates(root, &entry_path, candidates)?; + } else if root.kind == SkillRootKind::LegacyCommands { + if let Some(candidate) = load_skill_candidate(root, &entry_path, &root.path)? { + candidates.push(candidate); } } } - Err(format!("unknown skill: {requested}")) + Ok(()) +} + +fn load_skill_candidate( + root: &SkillCandidateRoot, + path: &Path, + base_root: &Path, +) -> std::io::Result> { + if !path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) + { + return Ok(None); + } + + let is_skill_file = path + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case("SKILL.md")); + if root.kind == SkillRootKind::Skills && !is_skill_file { + return Ok(None); + } + + let name = skill_candidate_name(root, path, base_root, is_skill_file); + Ok(Some(SkillCandidate { + name, + path: path.to_path_buf(), + })) +} + +fn skill_candidate_name( + root: &SkillCandidateRoot, + path: &Path, + base_root: &Path, + is_skill_file: bool, +) -> String { + let base_name = if is_skill_file { + path.parent().and_then(Path::file_name).map_or_else( + || fallback_file_stem(path), + |segment| segment.to_string_lossy().to_string(), + ) + } else { + fallback_file_stem(path) + }; + + prefixed_definition_name( + root.name_prefix.as_deref(), + namespace_for_file(path, base_root, is_skill_file), + &base_name, + ) +} + +fn namespace_for_file(path: &Path, base_root: &Path, is_skill_file: bool) -> Option { + let relative_parent = if is_skill_file { + path.parent() + .and_then(Path::parent) + .and_then(|parent| parent.strip_prefix(base_root).ok()) + } else { + path.parent() + .and_then(|parent| parent.strip_prefix(base_root).ok()) + }?; + + let segments = relative_parent + .iter() + .map(|segment| segment.to_string_lossy()) + .filter(|segment| !segment.is_empty()) + .map(|segment| segment.to_string()) + .collect::>(); + (!segments.is_empty()).then(|| segments.join(":")) +} + +fn prefixed_definition_name( + prefix: Option<&str>, + namespace: Option, + base_name: &str, +) -> String { + let mut parts = Vec::new(); + if let Some(prefix) = prefix.filter(|prefix| !prefix.is_empty()) { + parts.push(prefix.to_string()); + } + if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) { + parts.push(namespace); + } + parts.push(base_name.to_string()); + parts.join(":") +} + +fn fallback_file_stem(path: &Path) -> String { + path.file_stem() + .map_or_else(String::new, |stem| stem.to_string_lossy().to_string()) +} + +fn enabled_plugins_for_cwd(cwd: &Path) -> Vec { + let Some(manager) = plugin_manager_for_cwd(cwd) else { + return Vec::new(); + }; + + manager + .list_installed_plugins() + .map(|plugins| { + plugins + .into_iter() + .filter(|plugin| plugin.enabled) + .collect::>() + }) + .unwrap_or_default() +} + +fn plugin_manager_for_cwd(cwd: &Path) -> Option { + let loader = ConfigLoader::default_for(cwd); + let runtime_config = loader.load().ok()?; + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + Some(PluginManager::new(plugin_config)) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_path_buf() + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +fn resolve_plugin_component_path(root: &Path, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } } const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6"; @@ -3092,6 +3446,27 @@ mod tests { std::env::temp_dir().join(format!("claw-tools-{unique}-{name}")) } + fn write_skill(root: &std::path::Path, name: &str, description: &str) { + let skill_root = root.join(name); + fs::create_dir_all(&skill_root).expect("skill root"); + fs::write( + skill_root.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), + ) + .expect("write skill"); + } + + fn write_plugin_manifest(root: &std::path::Path, name: &str, extra_fields: &str) { + fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir"); + fs::write( + root.join(".claw-plugin").join("plugin.json"), + format!( + "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"test plugin\"{extra_fields}\n}}" + ), + ) + .expect("write plugin manifest"); + } + #[test] fn exposes_mvp_tools() { let names = mvp_tool_specs() @@ -3488,6 +3863,103 @@ mod tests { .ends_with("/help/SKILL.md")); } + #[test] + fn skill_resolves_namespaced_plugin_skill_by_unique_suffix() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let workspace = temp_path("skill-plugin-workspace"); + let config_home = temp_path("skill-plugin-home"); + let install_root = config_home.join("plugins").join("installed"); + let plugin_root = install_root.join("demo-plugin"); + + fs::create_dir_all(&config_home).expect("config home"); + fs::write( + config_home.join("settings.json"), + r#"{"plugins":{"enabled":{"demo-plugin@external":true}}}"#, + ) + .expect("write settings"); + write_plugin_manifest(&plugin_root, "demo-plugin", ",\n \"defaultEnabled\": true"); + write_skill( + &plugin_root.join("skills").join("ops"), + "review", + "Plugin review guidance", + ); + fs::create_dir_all(&workspace).expect("workspace"); + + let previous_cwd = std::env::current_dir().expect("cwd"); + let previous_claw_config_home = std::env::var_os("CLAW_CONFIG_HOME"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::set_current_dir(&workspace).expect("set cwd"); + + let result = execute_tool("Skill", &json!({ "skill": "review" })) + .expect("plugin skill should resolve"); + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + let expected_path = plugin_root + .join("skills/ops/review/SKILL.md") + .display() + .to_string(); + assert_eq!(output["path"].as_str(), Some(expected_path.as_str())); + + std::env::set_current_dir(previous_cwd).expect("restore cwd"); + if let Some(value) = previous_claw_config_home { + std::env::set_var("CLAW_CONFIG_HOME", value); + } else { + std::env::remove_var("CLAW_CONFIG_HOME"); + } + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(config_home); + } + + #[test] + fn skill_reports_ambiguous_bare_name_for_multiple_namespaced_matches() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let workspace = temp_path("skill-ambiguous-workspace"); + let config_home = temp_path("skill-ambiguous-home"); + let install_root = config_home.join("plugins").join("installed"); + let plugin_root = install_root.join("demo-plugin"); + + fs::create_dir_all(&config_home).expect("config home"); + fs::write( + config_home.join("settings.json"), + r#"{"plugins":{"enabled":{"demo-plugin@external":true}}}"#, + ) + .expect("write settings"); + write_skill( + &workspace.join(".codex").join("skills").join("ops"), + "review", + "Local review", + ); + write_plugin_manifest(&plugin_root, "demo-plugin", ",\n \"defaultEnabled\": true"); + write_skill( + &plugin_root.join("skills").join("ops"), + "review", + "Plugin review guidance", + ); + + let previous_cwd = std::env::current_dir().expect("cwd"); + let previous_claw_config_home = std::env::var_os("CLAW_CONFIG_HOME"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::set_current_dir(&workspace).expect("set cwd"); + + let error = execute_tool("Skill", &json!({ "skill": "review" })) + .expect_err("review should be ambiguous"); + assert!(error.contains("ambiguous skill `review`")); + assert!(error.contains("ops:review")); + assert!(error.contains("demo-plugin:ops:review")); + + std::env::set_current_dir(previous_cwd).expect("restore cwd"); + if let Some(value) = previous_claw_config_home { + std::env::set_var("CLAW_CONFIG_HOME", value); + } else { + std::env::remove_var("CLAW_CONFIG_HOME"); + } + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(config_home); + } + #[test] fn tool_search_supports_keyword_and_select_queries() { let keyword = execute_tool(