Compare commits

...

1 Commits

Author SHA1 Message Date
Yeachan-Heo
685d5fef9f Restore slash skill invocation parity after the main merge
The merged command surface still listed /skills but treated every positional argument as unexpected usage text, so slash-based skill invocation regressed. This wires /skills and /agents invocations back through the prompt path, shares skill resolution between the slash/discovery layer and the Skill tool, and teaches skill discovery to see enabled plugin roots plus namespaced plugin skills such as oh-my-claudecode:ralplan.

Constraint: Keep documentation files untouched while restoring the runtime behavior
Rejected: Add a separate skill-invoke tool name | existing Skill tool already covered the loading surface once resolution was fixed
Rejected: Resolve plugin skills only inside the slash handler | would leave the Skill tool and direct invocation path inconsistent
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep slash discovery/help behavior and Skill-tool resolution on the same registry path so plugin and project skills do not drift again
Tested: cargo check; cargo test; direct /skills help overview smoke run
Not-tested: End-to-end live provider execution for a real installed oh-my-claudecode plugin beyond synthetic fixture coverage
2026-04-01 21:25:00 +00:00
5 changed files with 642 additions and 113 deletions

1
rust/Cargo.lock generated
View File

@@ -1753,6 +1753,7 @@ name = "tools"
version = "0.1.0"
dependencies = [
"api",
"commands",
"plugins",
"reqwest",
"runtime",

View File

@@ -22,9 +22,10 @@ use api::{
};
use commands::{
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
suggest_slash_commands, SlashCommand,
classify_agents_slash_command, classify_skills_slash_command, handle_agents_slash_command,
handle_plugins_slash_command, handle_skills_slash_command, render_slash_command_help,
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
InvokeCommandAction, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
@@ -286,12 +287,30 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"agents" => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]),
}),
"skills" => Ok(CliAction::Skills {
args: join_optional_args(&rest[1..]),
}),
"agents" => match classify_agents_slash_command(join_optional_args(&rest[1..]).as_deref()) {
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
}),
_ => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]),
}),
},
"skills" => match classify_skills_slash_command(join_optional_args(&rest[1..]).as_deref()) {
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
}),
_ => Ok(CliAction::Skills {
args: join_optional_args(&rest[1..]),
}),
},
"system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout),
@@ -309,7 +328,13 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode,
})
}
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
other if other.starts_with('/') => parse_direct_slash_cli_action(
&rest,
model,
output_format,
allowed_tools,
permission_mode,
),
_other => Ok(CliAction::Prompt {
prompt: rest.join(" "),
model,
@@ -326,12 +351,40 @@ fn join_optional_args(args: &[String]) -> Option<String> {
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
fn parse_direct_slash_cli_action(
rest: &[String],
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<CliAction, String> {
let raw = rest.join(" ");
match SlashCommand::parse(&raw) {
Some(SlashCommand::Help) => Ok(CliAction::Help),
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
Some(SlashCommand::Agents { args }) => {
match classify_agents_slash_command(args.as_deref()) {
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
}),
_ => Ok(CliAction::Agents { args }),
}
}
Some(SlashCommand::Skills { args }) => {
match classify_skills_slash_command(args.as_deref()) {
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
}),
_ => Ok(CliAction::Skills { args }),
}
}
Some(command) => Err(format_direct_slash_command_error(
match &command {
SlashCommand::Unknown(name) => format!("/{name}"),
@@ -1321,11 +1374,17 @@ impl LiveCli {
self.handle_plugins_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Agents { args } => {
Self::print_agents(args.as_deref())?;
match classify_agents_slash_command(args.as_deref()) {
InvokeCommandAction::Invoke(prompt) => self.run_turn(&prompt)?,
_ => Self::print_agents(args.as_deref())?,
}
false
}
SlashCommand::Skills { args } => {
Self::print_skills(args.as_deref())?;
match classify_skills_slash_command(args.as_deref()) {
InvokeCommandAction::Invoke(prompt) => self.run_turn(&prompt)?,
_ => Self::print_skills(args.as_deref())?,
}
false
}
SlashCommand::Branch { .. } => {
@@ -4332,6 +4391,17 @@ mod tests {
args: Some("--help".to_string())
}
);
assert_eq!(
parse_args(&["skills".to_string(), "ralplan".to_string()])
.expect("skills invoke should parse"),
CliAction::Prompt {
prompt: "$ralplan".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
}
);
}
#[test]
@@ -4345,10 +4415,36 @@ mod tests {
CliAction::Skills { args: None }
);
assert_eq!(
parse_args(&["/skills".to_string(), "help".to_string()])
.expect("/skills help should parse"),
CliAction::Skills {
args: Some("help".to_string())
parse_args(&["/skills".to_string(), "help".to_string(), "overview".to_string()])
.expect("/skills help overview should invoke"),
CliAction::Prompt {
prompt: "$help overview".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
}
);
assert_eq!(
parse_args(&["/skills".to_string(), "oh-my-claudecode:ralplan".to_string()])
.expect("/skills namespaced invoke should parse"),
CliAction::Prompt {
prompt: "$oh-my-claudecode:ralplan".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
}
);
assert_eq!(
parse_args(&["/agents".to_string(), "planner".to_string()])
.expect("/agents planner should invoke"),
CliAction::Prompt {
prompt: "/prompts:planner".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
}
);
let error = parse_args(&["/status".to_string()])

View File

@@ -6,8 +6,8 @@ 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::{PluginError, PluginManager, PluginManagerConfig, PluginSummary};
use runtime::{compact_session, CompactionConfig, ConfigLoader, Session};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandManifestEntry {
@@ -284,16 +284,16 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "agents",
aliases: &[],
summary: "List configured agents",
argument_hint: None,
summary: "List or invoke configured agents",
argument_hint: Some("[list|--help|<agent>]"),
resume_supported: true,
category: SlashCommandCategory::Automation,
},
SlashCommandSpec {
name: "skills",
aliases: &[],
summary: "List available skills",
argument_hint: None,
summary: "List or invoke available skills",
argument_hint: Some("[list|--help|<skill> [args]]"),
resume_supported: true,
category: SlashCommandCategory::Automation,
},
@@ -631,6 +631,7 @@ enum DefinitionSource {
UserCodexHome,
UserCodex,
UserClaw,
Plugin,
}
impl DefinitionSource {
@@ -641,6 +642,7 @@ impl DefinitionSource {
Self::UserCodexHome => "User ($CODEX_HOME)",
Self::UserCodex => "User (~/.codex)",
Self::UserClaw => "User (~/.claw)",
Self::Plugin => "Plugins",
}
}
}
@@ -684,6 +686,30 @@ struct SkillRoot {
source: DefinitionSource,
path: PathBuf,
origin: SkillOrigin,
namespace: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DefinitionRoot {
source: DefinitionSource,
path: PathBuf,
namespace: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillEntry {
name: String,
path: PathBuf,
description: Option<String>,
source: DefinitionSource,
origin: SkillOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InvokeCommandAction {
Browse,
Help,
Invoke(String),
}
#[allow(clippy::too_many_lines)]
@@ -813,7 +839,7 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_skill_roots(cwd);
let roots = discover_skill_roots(cwd)?;
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills))
}
@@ -1260,7 +1286,72 @@ fn resolve_plugin_target(
}
}
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
pub fn classify_agents_slash_command(args: Option<&str>) -> InvokeCommandAction {
classify_invoke_command(args, "/prompts:")
}
pub fn classify_skills_slash_command(args: Option<&str>) -> InvokeCommandAction {
classify_invoke_command(args, "$")
}
pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
if requested.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"skill must not be empty",
));
}
let roots = discover_skill_roots(cwd)?;
let entries = load_skill_entries_from_roots(&roots)?;
if let Some((namespace, name)) = requested.split_once(':') {
return entries
.into_iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&format!("{namespace}:{name}")))
.map(|entry| entry.path)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown skill: {requested}")));
}
if let Some(entry) = entries
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(requested))
{
return Ok(entry.path.clone());
}
let plugin_matches = entries
.into_iter()
.filter(|entry| {
entry
.name
.split_once(':')
.is_some_and(|(_, name)| name.eq_ignore_ascii_case(requested))
})
.collect::<Vec<_>>();
match plugin_matches.len() {
0 => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("unknown skill: {requested}"),
)),
1 => Ok(plugin_matches[0].path.clone()),
_ => Err(io::Error::other(format!(
"skill `{requested}` is provided by multiple plugins; use <plugin>:<skill>"
))),
}
}
fn classify_invoke_command(args: Option<&str>, prefix: &str) -> InvokeCommandAction {
match normalize_optional_args(args) {
None | Some("list") => InvokeCommandAction::Browse,
Some("-h" | "--help" | "help") => InvokeCommandAction::Help,
Some(args) => InvokeCommandAction::Invoke(format!("{prefix}{args}")),
}
}
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<DefinitionRoot> {
let mut roots = Vec::new();
for ancestor in cwd.ancestors() {
@@ -1268,11 +1359,13 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
&mut roots,
DefinitionSource::ProjectCodex,
ancestor.join(".codex").join(leaf),
None,
);
push_unique_root(
&mut roots,
DefinitionSource::ProjectClaw,
ancestor.join(".claw").join(leaf),
None,
);
}
@@ -1281,6 +1374,7 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
&mut roots,
DefinitionSource::UserCodexHome,
PathBuf::from(codex_home).join(leaf),
None,
);
}
@@ -1290,18 +1384,22 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
&mut roots,
DefinitionSource::UserCodex,
home.join(".codex").join(leaf),
None,
);
push_unique_root(
&mut roots,
DefinitionSource::UserClaw,
home.join(".claw").join(leaf),
None,
);
}
append_plugin_definition_roots(&mut roots, cwd, leaf);
roots
}
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
fn discover_skill_roots(cwd: &Path) -> io::Result<Vec<SkillRoot>> {
let mut roots = Vec::new();
for ancestor in cwd.ancestors() {
@@ -1310,24 +1408,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 +1440,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,37 +1458,52 @@ 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,
);
}
roots
append_plugin_skill_roots(&mut roots, cwd)?;
Ok(roots)
}
fn push_unique_root(
roots: &mut Vec<(DefinitionSource, PathBuf)>,
roots: &mut Vec<DefinitionRoot>,
source: DefinitionSource,
path: PathBuf,
namespace: Option<String>,
) {
if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
roots.push((source, path));
if path.is_dir()
&& !roots
.iter()
.any(|existing| existing.path == path && existing.namespace == namespace)
{
roots.push(DefinitionRoot {
source,
path,
namespace,
});
}
}
@@ -1393,25 +1512,29 @@ fn push_unique_skill_root(
source: DefinitionSource,
path: PathBuf,
origin: SkillOrigin,
namespace: Option<String>,
) {
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
if path.is_dir()
&& !roots
.iter()
.any(|existing| existing.path == path && existing.namespace == namespace)
{
roots.push(SkillRoot {
source,
path,
origin,
namespace,
});
}
}
fn load_agents_from_roots(
roots: &[(DefinitionSource, PathBuf)],
) -> std::io::Result<Vec<AgentSummary>> {
fn load_agents_from_roots(roots: &[DefinitionRoot]) -> 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)? {
for entry in fs::read_dir(&root.path)? {
let entry = entry?;
if entry.path().extension().is_none_or(|ext| ext != "toml") {
continue;
@@ -1421,12 +1544,13 @@ fn load_agents_from_roots(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
let name = parse_toml_string(&contents, "name").unwrap_or(fallback_name);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
name: namespaced_definition_name(root.namespace.as_deref(), &name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
source: root.source,
shadowed_by: None,
});
}
@@ -1447,9 +1571,33 @@ fn load_agents_from_roots(
}
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
let entries = load_skill_entries_from_roots(roots)?;
let mut skills = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for entry in entries {
let mut skill = SkillSummary {
name: entry.name,
description: entry.description,
source: entry.source,
shadowed_by: None,
origin: entry.origin,
};
let key = skill.name.to_ascii_lowercase();
if let Some(existing) = active_sources.get(&key) {
skill.shadowed_by = Some(*existing);
} else {
active_sources.insert(key, skill.source);
}
skills.push(skill);
}
Ok(skills)
}
fn load_skill_entries_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillEntry>> {
let mut skills = Vec::new();
for root in roots {
let mut root_skills = Vec::new();
for entry in fs::read_dir(&root.path)? {
@@ -1463,14 +1611,17 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
if !skill_path.is_file() {
continue;
}
let contents = fs::read_to_string(skill_path)?;
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()),
let fallback_name = entry.file_name().to_string_lossy().to_string();
root_skills.push(SkillEntry {
name: namespaced_definition_name(
root.namespace.as_deref(),
&name.unwrap_or(fallback_name),
),
path: skill_path,
description,
source: root.source,
shadowed_by: None,
origin: root.origin,
});
}
@@ -1497,32 +1648,153 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|stem| stem.to_string_lossy().to_string(),
);
let (name, description) = parse_skill_frontmatter(&contents);
root_skills.push(SkillSummary {
name: name.unwrap_or(fallback_name),
root_skills.push(SkillEntry {
name: namespaced_definition_name(
root.namespace.as_deref(),
&name.unwrap_or(fallback_name),
),
path: markdown_path,
description,
source: root.source,
shadowed_by: None,
origin: root.origin,
});
}
}
}
root_skills.sort_by(|left, right| left.name.cmp(&right.name));
for mut skill in root_skills {
let key = skill.name.to_ascii_lowercase();
if let Some(existing) = active_sources.get(&key) {
skill.shadowed_by = Some(*existing);
} else {
active_sources.insert(key, skill.source);
}
skills.push(skill);
}
skills.extend(root_skills);
}
Ok(skills)
}
fn namespaced_definition_name(namespace: Option<&str>, name: &str) -> String {
namespace.map_or_else(|| name.to_string(), |namespace| format!("{namespace}:{name}"))
}
fn append_plugin_definition_roots(roots: &mut Vec<DefinitionRoot>, cwd: &Path, leaf: &str) {
if let Ok(plugins) = discover_enabled_plugins(cwd) {
for plugin in plugins {
let Some(root) = plugin.metadata.root.as_ref() else {
continue;
};
let namespace = Some(plugin_namespace(&plugin));
push_unique_root(
roots,
DefinitionSource::Plugin,
root.join(".codex").join(leaf),
namespace.clone(),
);
push_unique_root(
roots,
DefinitionSource::Plugin,
root.join(".claw").join(leaf),
namespace.clone(),
);
push_unique_root(
roots,
DefinitionSource::Plugin,
root.join(leaf),
namespace,
);
}
}
}
fn append_plugin_skill_roots(roots: &mut Vec<SkillRoot>, cwd: &Path) -> io::Result<()> {
for plugin in discover_enabled_plugins(cwd)? {
let Some(root) = plugin.metadata.root.as_ref() else {
continue;
};
let namespace = Some(plugin_namespace(&plugin));
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
root.join(".codex").join("skills"),
SkillOrigin::SkillsDir,
namespace.clone(),
);
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
root.join(".claw").join("skills"),
SkillOrigin::SkillsDir,
namespace.clone(),
);
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
root.join("skills"),
SkillOrigin::SkillsDir,
namespace.clone(),
);
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
root.join(".codex").join("commands"),
SkillOrigin::LegacyCommandsDir,
namespace.clone(),
);
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
root.join(".claw").join("commands"),
SkillOrigin::LegacyCommandsDir,
namespace.clone(),
);
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
root.join("commands"),
SkillOrigin::LegacyCommandsDir,
namespace,
);
}
Ok(())
}
fn discover_enabled_plugins(cwd: &Path) -> io::Result<Vec<PluginSummary>> {
let loader = ConfigLoader::default_for(cwd);
let runtime_config = loader.load().map_err(io::Error::other)?;
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));
PluginManager::new(plugin_config)
.list_installed_plugins()
.map(|plugins| plugins.into_iter().filter(|plugin| plugin.enabled).collect())
.map_err(io::Error::other)
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
}
fn plugin_namespace(plugin: &PluginSummary) -> String {
plugin.metadata.name.clone()
}
fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
let prefix = format!("{key} =");
for line in contents.lines() {
@@ -1613,6 +1885,7 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
DefinitionSource::UserCodexHome,
DefinitionSource::UserCodex,
DefinitionSource::UserClaw,
DefinitionSource::Plugin,
] {
let group = agents
.iter()
@@ -1671,6 +1944,7 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
DefinitionSource::UserCodexHome,
DefinitionSource::UserCodex,
DefinitionSource::UserClaw,
DefinitionSource::Plugin,
] {
let group = skills
.iter()
@@ -1708,9 +1982,11 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
fn render_agents_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Agents".to_string(),
" Usage /agents".to_string(),
" Direct CLI claw agents".to_string(),
" Sources .codex/agents, .claw/agents, $CODEX_HOME/agents".to_string(),
" Usage /agents [list|--help|<agent>]".to_string(),
" Direct CLI claw agents [list|--help|<agent>]".to_string(),
" Invoke /agents planner -> /prompts:planner".to_string(),
" Sources .codex/agents, .claw/agents, $CODEX_HOME/agents, enabled plugins"
.to_string(),
];
if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}"));
@@ -1721,9 +1997,12 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
" Usage /skills".to_string(),
" Direct CLI claw skills".to_string(),
" Sources .codex/skills, .claw/skills, legacy /commands".to_string(),
" Usage /skills [list|--help|<skill> [args]]".to_string(),
" Direct CLI claw skills [list|--help|<skill> [args]]".to_string(),
" Invoke /skills help overview -> $help overview".to_string(),
" Namespacing /skills plugin-name:skill".to_string(),
" Sources .codex/skills, .claw/skills, legacy /commands, enabled plugins"
.to_string(),
];
if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}"));
@@ -1790,13 +2069,15 @@ pub fn handle_slash_command(
#[cfg(test)]
mod tests {
use super::{
classify_agents_slash_command, classify_skills_slash_command,
handle_branch_slash_command, handle_commit_push_pr_slash_command,
handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command,
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,
handle_commit_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
handle_slash_command, 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, resolve_skill_path,
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
CommitPushPrRequest, DefinitionRoot, DefinitionSource, InvokeCommandAction,
SkillOrigin, SkillRoot, SlashCommand,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
@@ -2336,8 +2617,16 @@ mod tests {
);
let roots = vec![
(DefinitionSource::ProjectCodex, project_agents),
(DefinitionSource::UserCodex, user_agents),
DefinitionRoot {
source: DefinitionSource::ProjectCodex,
path: project_agents,
namespace: None,
},
DefinitionRoot {
source: DefinitionSource::UserCodex,
path: user_agents,
namespace: None,
},
];
let report =
render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
@@ -2372,16 +2661,19 @@ mod tests {
source: DefinitionSource::ProjectCodex,
path: project_skills,
origin: SkillOrigin::SkillsDir,
namespace: None,
},
SkillRoot {
source: DefinitionSource::ProjectClaw,
path: project_commands,
origin: SkillOrigin::LegacyCommandsDir,
namespace: None,
},
SkillRoot {
source: DefinitionSource::UserCodex,
path: user_skills,
origin: SkillOrigin::SkillsDir,
namespace: None,
},
];
let report =
@@ -2426,6 +2718,97 @@ mod tests {
let _ = fs::remove_dir_all(cwd);
}
#[test]
fn classifies_agents_and_skills_invocation_args() {
assert_eq!(
classify_agents_slash_command(None),
InvokeCommandAction::Browse
);
assert_eq!(
classify_agents_slash_command(Some("--help")),
InvokeCommandAction::Help
);
assert_eq!(
classify_agents_slash_command(Some("planner")),
InvokeCommandAction::Invoke("/prompts:planner".to_string())
);
assert_eq!(
classify_skills_slash_command(Some("help overview")),
InvokeCommandAction::Invoke("$help overview".to_string())
);
assert_eq!(
classify_skills_slash_command(Some("oh-my-claudecode:ralplan")),
InvokeCommandAction::Invoke("$oh-my-claudecode:ralplan".to_string())
);
}
#[test]
fn resolves_project_and_plugin_scoped_skills() {
let _guard = env_lock();
let workspace = temp_dir("skill-resolve-workspace");
let home = temp_dir("skill-resolve-home");
let plugin_root = home
.join(".claw")
.join("plugins")
.join("installed")
.join("oh-my-claudecode-external");
write_skill(
&workspace.join(".codex").join("skills"),
"ralplan",
"Project ralplan",
);
fs::create_dir_all(plugin_root.join(".claw-plugin")).expect("plugin manifest dir");
fs::write(
plugin_root.join(".claw-plugin").join("plugin.json"),
r#"{
"name": "oh-my-claudecode",
"version": "1.0.0",
"description": "Plugin-scoped skills"
}"#,
)
.expect("plugin manifest");
write_skill(&plugin_root.join("skills"), "ralplan", "Plugin ralplan");
fs::create_dir_all(home.join(".claw")).expect("config home");
fs::write(
home.join(".claw").join("settings.json"),
r#"{
"enabledPlugins": {
"oh-my-claudecode@external": true
}
}"#,
)
.expect("settings");
let old_home = env::var_os("HOME");
let old_codex_home = env::var_os("CODEX_HOME");
env::set_var("HOME", &home);
env::remove_var("CODEX_HOME");
let local = resolve_skill_path(&workspace, "ralplan").expect("local skill should resolve");
assert!(local.ends_with(".codex/skills/ralplan/SKILL.md"));
let plugin = resolve_skill_path(&workspace, "oh-my-claudecode:ralplan")
.expect("plugin skill should resolve");
assert!(plugin.ends_with("skills/ralplan/SKILL.md"));
let skills_report = handle_skills_slash_command(None, &workspace).expect("skills report");
assert!(skills_report.contains("Plugins:"));
assert!(skills_report.contains("oh-my-claudecode:ralplan · Plugin ralplan"));
match old_home {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
match old_codex_home {
Some(value) => env::set_var("CODEX_HOME", value),
None => env::remove_var("CODEX_HOME"),
}
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(home);
}
#[test]
fn parses_quoted_skill_frontmatter_values() {
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
api = { path = "../api" }
commands = { path = "../commands" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }

View File

@@ -8,6 +8,7 @@ use api::{
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::resolve_skill_path as resolve_workspace_skill_path;
use plugins::PluginTool;
use reqwest::blocking::Client;
use runtime::{
@@ -1455,47 +1456,8 @@ fn todo_store_path() -> Result<std::path::PathBuf, 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 mut candidates = Vec::new();
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
}
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"));
}
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
for root in candidates {
let direct = root.join(requested).join("SKILL.md");
if direct.exists() {
return Ok(direct);
}
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)
{
return Ok(path);
}
}
}
}
Err(format!("unknown skill: {requested}"))
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
resolve_workspace_skill_path(&cwd, skill).map_err(|error| error.to_string())
}
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
@@ -3488,6 +3450,92 @@ mod tests {
.ends_with("/help/SKILL.md"));
}
#[test]
fn skill_resolves_project_and_plugin_scoped_prompts() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let workspace = temp_path("skill-workspace");
let home = temp_path("skill-home");
let plugin_root = home
.join(".claw")
.join("plugins")
.join("installed")
.join("oh-my-claudecode-external");
let project_skill_root = workspace.join(".codex").join("skills").join("ralplan");
std::fs::create_dir_all(&project_skill_root).expect("project skill dir");
std::fs::write(
project_skill_root.join("SKILL.md"),
"---\nname: ralplan\ndescription: Project skill\n---\n",
)
.expect("project skill");
std::fs::create_dir_all(plugin_root.join(".claw-plugin")).expect("plugin manifest dir");
std::fs::write(
plugin_root.join(".claw-plugin").join("plugin.json"),
r#"{
"name": "oh-my-claudecode",
"version": "1.0.0",
"description": "Plugin skills"
}"#,
)
.expect("plugin manifest");
std::fs::create_dir_all(home.join(".claw")).expect("config home");
std::fs::write(
home.join(".claw").join("settings.json"),
r#"{
"enabledPlugins": {
"oh-my-claudecode@external": true
}
}"#,
)
.expect("settings");
let plugin_skill_root = plugin_root.join("skills").join("ralplan");
std::fs::create_dir_all(&plugin_skill_root).expect("plugin skill dir");
std::fs::write(
plugin_skill_root.join("SKILL.md"),
"---\nname: ralplan\ndescription: Plugin skill\n---\n",
)
.expect("plugin skill");
let original_dir = std::env::current_dir().expect("cwd");
let old_home = std::env::var_os("HOME");
let old_codex_home = std::env::var_os("CODEX_HOME");
std::env::set_current_dir(&workspace).expect("set cwd");
std::env::set_var("HOME", &home);
std::env::remove_var("CODEX_HOME");
let project_result = execute_tool("Skill", &json!({ "skill": "ralplan" }))
.expect("project skill should resolve");
let project_output: serde_json::Value =
serde_json::from_str(&project_result).expect("valid json");
assert!(project_output["path"]
.as_str()
.expect("path")
.ends_with(".codex/skills/ralplan/SKILL.md"));
let plugin_result =
execute_tool("Skill", &json!({ "skill": "$oh-my-claudecode:ralplan" }))
.expect("plugin skill should resolve");
let plugin_output: serde_json::Value =
serde_json::from_str(&plugin_result).expect("valid json");
assert!(plugin_output["path"]
.as_str()
.expect("path")
.ends_with("skills/ralplan/SKILL.md"));
std::env::set_current_dir(&original_dir).expect("restore cwd");
match old_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match old_codex_home {
Some(value) => std::env::set_var("CODEX_HOME", value),
None => std::env::remove_var("CODEX_HOME"),
}
let _ = std::fs::remove_dir_all(workspace);
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn tool_search_supports_keyword_and_select_queries() {
let keyword = execute_tool(