Compare commits

...

1 Commits

Author SHA1 Message Date
Yeachan-Heo
ded9057ed9 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
2026-04-01 22:07:17 +00:00
3 changed files with 1099 additions and 133 deletions

View File

@@ -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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AgentRoot {
source: DefinitionSource,
path: PathBuf,
name_prefix: Option<String>,
}
#[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<String> {
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<AgentRoot> {
let mut roots = discover_definition_roots(cwd, "agents")
.into_iter()
.map(|(source, path)| AgentRoot {
source,
path,
name_prefix: None,
})
.collect::<Vec<_>>();
extend_plugin_agent_roots(cwd, &mut roots);
roots
}
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
let mut roots = Vec::new();
@@ -1310,24 +1336,28 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
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<SkillRoot> {
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<SkillRoot> {
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<String>,
) {
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<Vec<AgentSummary>> {
fn push_unique_agent_root(
roots: &mut Vec<AgentRoot>,
source: DefinitionSource,
path: PathBuf,
name_prefix: Option<String>,
) {
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<AgentRoot>) {
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<SkillRoot>) {
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<PluginSummary> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn plugin_manager_for_cwd(cwd: &Path) -> Option<PluginManager> {
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<Vec<AgentSummary>> {
let mut agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::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<Vec<SkillSumma
for root in roots {
let mut root_skills = Vec::new();
for entry in fs::read_dir(&root.path)? {
let entry = entry?;
match root.origin {
SkillOrigin::SkillsDir => {
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<Vec<SkillSumma
Ok(skills)
}
fn collect_agents(
root: &AgentRoot,
path: &Path,
agents: &mut Vec<AgentSummary>,
) -> 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::<Result<Vec<_>, _>>()?;
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<Option<AgentSummary>> {
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<SkillSummary>,
) -> 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::<Result<Vec<_>, _>>()?;
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<Option<SkillSummary>> {
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<String> {
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::<Vec<_>>();
(!segments.is_empty()).then(|| segments.join(":"))
}
fn prefixed_definition_name(
prefix: Option<&str>,
namespace: Option<String>,
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<String> {
let prefix = format!("{key} =");
for line in contents.lines() {
@@ -1548,34 +1849,32 @@ fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
}
fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
return (None, None);
(
parse_frontmatter_key(contents, "name"),
parse_frontmatter_key(contents, "description"),
)
}
fn parse_frontmatter_key(contents: &str, key: &str) -> Option<String> {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
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");

View File

@@ -119,6 +119,10 @@ pub struct PluginManifest {
pub tools: Vec<PluginToolManifest>,
#[serde(default)]
pub commands: Vec<PluginCommandManifest>,
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub skills: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -228,6 +232,10 @@ struct RawPluginManifest {
pub tools: Vec<RawPluginToolManifest>,
#[serde(default)]
pub commands: Vec<PluginCommandManifest>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub agents: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub skills: Vec<String>,
}
#[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<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringList {
One(String),
Many(Vec<String>),
}
Ok(match Option::<StringList>::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<String>,
kind: &'static str,
errors: &mut Vec<PluginManifestValidationError>,
) -> Vec<String> {
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<PluginCommandManifest>,
@@ -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");

View File

@@ -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<Option<BTreeSet<String>>, String> {
pub fn normalize_allowed_tools(
&self,
values: &[String],
) -> Result<Option<BTreeSet<String>>, 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::<Vec<_>>();
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<std::path::PathBuf, String> {
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<String>,
}
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
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::<Vec<_>>();
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::<Vec<_>>()
.join(", ")
)),
}
}
fn discover_skill_candidates() -> std::io::Result<Vec<SkillCandidate>> {
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<SkillCandidateRoot> {
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() {
fn extend_plugin_skill_candidate_roots(cwd: &Path, roots: &mut Vec<SkillCandidateRoot>) {
for plugin in enabled_plugins_for_cwd(cwd) {
let Some(root) = &plugin.metadata.root else {
continue;
}
if entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(requested)
};
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()));
}
}
}
}
Err(format!("unknown skill: {requested}"))
fn push_skill_candidate_root(
roots: &mut Vec<SkillCandidateRoot>,
path: PathBuf,
kind: SkillRootKind,
name_prefix: Option<String>,
) {
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<SkillCandidate>,
) -> 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::<Result<Vec<_>, _>>()?;
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);
}
}
}
Ok(())
}
fn load_skill_candidate(
root: &SkillCandidateRoot,
path: &Path,
base_root: &Path,
) -> std::io::Result<Option<SkillCandidate>> {
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<String> {
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::<Vec<_>>();
(!segments.is_empty()).then(|| segments.join(":"))
}
fn prefixed_definition_name(
prefix: Option<&str>,
namespace: Option<String>,
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<PluginSummary> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn plugin_manager_for_cwd(cwd: &Path) -> Option<PluginManager> {
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(