mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
Let /skills invocations reach the prompt skill path
The CLI still treated every /skills payload other than list/install/help as local usage text, so skills that appeared in /skills could not actually be invoked. This restores prompt dispatch for /skills <skill> [args], keeps list/install on the local path, and shares skill resolution with the Skill tool so project-local and legacy /commands entries resolve consistently. Constraint: --resume local slash execution still only supports local commands without provider turns Rejected: Implement full resumed prompt-turn execution for /skills | larger behavior change outside this bugfix Rejected: Keep separate skill lookups in tools and commands | drift already caused listing/invocation mismatches Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep /skills discovery, CLI prompt dispatch, and Tool Skill resolution on the same registry semantics Tested: cargo fmt --all; cargo clippy -p commands -p tools -p rusty-claude-cli --all-targets -- -D warnings; cargo test --workspace -- --nocapture Not-tested: Live provider-backed /skills invocation against external skill packs in an interactive REPL
This commit is contained in:
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -1579,6 +1579,7 @@ name = "tools"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
"plugins",
|
||||
"reqwest",
|
||||
"runtime",
|
||||
|
||||
@@ -50,6 +50,12 @@ pub struct SlashCommandSpec {
|
||||
pub resume_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SkillSlashDispatch {
|
||||
Local,
|
||||
Invoke(String),
|
||||
}
|
||||
|
||||
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
SlashCommandSpec {
|
||||
name: "help",
|
||||
@@ -238,8 +244,8 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
SlashCommandSpec {
|
||||
name: "skills",
|
||||
aliases: &[],
|
||||
summary: "List or install available skills",
|
||||
argument_hint: Some("[list|install <path>|help]"),
|
||||
summary: "List, install, or invoke available skills",
|
||||
argument_hint: Some("[list|install <path>|help|<skill> [args]]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
@@ -1686,13 +1692,7 @@ fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandP
|
||||
}
|
||||
}
|
||||
|
||||
Err(command_error(
|
||||
&format!(
|
||||
"Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
|
||||
),
|
||||
"skills",
|
||||
"/skills [list|install <path>|help]",
|
||||
))
|
||||
Ok(Some(args.to_string()))
|
||||
}
|
||||
|
||||
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
|
||||
@@ -2286,6 +2286,89 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
|
||||
Some(args) if args == "install" || args.starts_with("install ") => {
|
||||
SkillSlashDispatch::Local
|
||||
}
|
||||
Some(args) => SkillSlashDispatch::Invoke(format!("${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(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"skill must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let roots = discover_skill_roots(cwd);
|
||||
for root in &roots {
|
||||
let mut entries = 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, _) = parse_skill_frontmatter(&contents);
|
||||
entries.push((
|
||||
name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
|
||||
skill_path,
|
||||
));
|
||||
}
|
||||
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, _) = parse_skill_frontmatter(&contents);
|
||||
entries.push((name.unwrap_or(fallback_name), markdown_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
if let Some((_, path)) = entries
|
||||
.into_iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(requested))
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("unknown skill: {requested}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_mcp_report_for(
|
||||
loader: &ConfigLoader,
|
||||
cwd: &Path,
|
||||
@@ -3383,8 +3466,9 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
|
||||
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"Skills".to_string(),
|
||||
" Usage /skills [list|install <path>|help]".to_string(),
|
||||
" Direct CLI claw skills [list|install <path>|help]".to_string(),
|
||||
" Usage /skills [list|install <path>|help|<skill> [args]]".to_string(),
|
||||
" Direct CLI claw skills [list|install <path>|help|<skill> [args]]".to_string(),
|
||||
" Invoke /skills help overview -> $help overview".to_string(),
|
||||
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(),
|
||||
" Sources .claw/skills, ~/.claw/skills, legacy /commands".to_string(),
|
||||
];
|
||||
@@ -3399,8 +3483,9 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
|
||||
"kind": "skills",
|
||||
"action": "help",
|
||||
"usage": {
|
||||
"slash_command": "/skills [list|install <path>|help]",
|
||||
"direct_cli": "claw skills [list|install <path>|help]",
|
||||
"slash_command": "/skills [list|install <path>|help|<skill> [args]]",
|
||||
"direct_cli": "claw skills [list|install <path>|help|<skill> [args]]",
|
||||
"invoke": "/skills help overview -> $help overview",
|
||||
"install_root": "$CLAW_CONFIG_HOME/skills or ~/.claw/skills",
|
||||
"sources": [".claw/skills", "legacy /commands", "legacy fallback dirs still load automatically"],
|
||||
},
|
||||
@@ -3751,13 +3836,14 @@ pub fn handle_slash_command(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
handle_agents_slash_command_json, handle_plugins_slash_command,
|
||||
handle_skills_slash_command_json, handle_slash_command, load_agents_from_roots,
|
||||
load_skills_from_roots, render_agents_report, render_agents_report_json,
|
||||
render_mcp_report_json_for, render_plugins_report, render_skills_report,
|
||||
render_slash_command_help, render_slash_command_help_detail,
|
||||
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
||||
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
||||
classify_skills_slash_command, handle_agents_slash_command_json,
|
||||
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
||||
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
||||
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
|
||||
render_skills_report, render_slash_command_help, render_slash_command_help_detail,
|
||||
resolve_skill_path, resume_supported_slash_commands, slash_command_specs,
|
||||
suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin,
|
||||
SkillRoot, SkillSlashDispatch, SlashCommand,
|
||||
};
|
||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||
use runtime::{
|
||||
@@ -4105,24 +4191,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_agents_and_skills_arguments() {
|
||||
fn rejects_invalid_agents_arguments() {
|
||||
// given
|
||||
let agents_input = "/agents show planner";
|
||||
let skills_input = "/skills show help";
|
||||
|
||||
// when
|
||||
let agents_error = parse_error_message(agents_input);
|
||||
let skills_error = parse_error_message(skills_input);
|
||||
|
||||
// then
|
||||
assert!(agents_error.contains(
|
||||
"Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
|
||||
));
|
||||
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
||||
assert!(skills_error.contains(
|
||||
"Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install <path>, or /skills help."
|
||||
));
|
||||
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/skills help overview"),
|
||||
Ok(Some(SlashCommand::Skills {
|
||||
args: Some("help overview".to_string()),
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some("help overview")),
|
||||
SkillSlashDispatch::Invoke("$help overview".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some("install ./skill-pack")),
|
||||
SkillSlashDispatch::Local
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4176,7 +4274,7 @@ mod tests {
|
||||
));
|
||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||
assert!(help.contains("/agents [list|help]"));
|
||||
assert!(help.contains("/skills [list|install <path>|help]"));
|
||||
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert_eq!(slash_command_specs().len(), 141);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
@@ -4541,6 +4639,25 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_project_skills_and_legacy_commands_from_shared_registry() {
|
||||
let workspace = temp_dir("resolve-project-skills");
|
||||
let project_skills = workspace.join(".claw").join("skills");
|
||||
let legacy_commands = workspace.join(".claw").join("commands");
|
||||
|
||||
write_skill(&project_skills, "plan", "Project planning guidance");
|
||||
write_legacy_command(&legacy_commands, "handoff", "Legacy handoff guidance");
|
||||
|
||||
assert_eq!(
|
||||
resolve_skill_path(&workspace, "$plan").expect("project skill should resolve"),
|
||||
project_skills.join("plan").join("SKILL.md")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skill_path(&workspace, "/handoff").expect("legacy command should resolve"),
|
||||
legacy_commands.join("handoff.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_skills_reports_as_json() {
|
||||
let workspace = temp_dir("skills-json-workspace");
|
||||
@@ -4589,7 +4706,7 @@ mod tests {
|
||||
assert_eq!(help["action"], "help");
|
||||
assert_eq!(
|
||||
help["usage"]["direct_cli"],
|
||||
"claw skills [list|install <path>|help]"
|
||||
"claw skills [list|install <path>|help|<skill> [args]]"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
@@ -4613,7 +4730,9 @@ mod tests {
|
||||
|
||||
let skills_help =
|
||||
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
||||
assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_help
|
||||
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert!(skills_help.contains("Invoke /skills help overview -> $help overview"));
|
||||
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
|
||||
assert!(skills_help.contains("legacy /commands"));
|
||||
|
||||
@@ -4623,12 +4742,14 @@ mod tests {
|
||||
|
||||
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
|
||||
.expect("nested skills help");
|
||||
assert!(skills_install_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_install_help
|
||||
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert!(skills_install_help.contains("Unexpected install"));
|
||||
|
||||
let skills_unknown_help =
|
||||
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
|
||||
assert!(skills_unknown_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_unknown_help
|
||||
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert!(skills_unknown_help.contains("Unexpected show"));
|
||||
|
||||
let _ = fs::remove_dir_all(cwd);
|
||||
|
||||
@@ -31,10 +31,11 @@ use api::{
|
||||
};
|
||||
|
||||
use commands::{
|
||||
handle_agents_slash_command, handle_agents_slash_command_json, handle_mcp_slash_command,
|
||||
handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command,
|
||||
handle_skills_slash_command_json, render_slash_command_help, resume_supported_slash_commands,
|
||||
slash_command_specs, validate_slash_command_input, SlashCommand,
|
||||
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||
resume_supported_slash_commands, slash_command_specs, validate_slash_command_input,
|
||||
SkillSlashDispatch, SlashCommand,
|
||||
};
|
||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||
use init::initialize_repo;
|
||||
@@ -419,10 +420,22 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
output_format,
|
||||
}),
|
||||
"skills" => Ok(CliAction::Skills {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
output_format,
|
||||
}),
|
||||
"skills" => {
|
||||
let args = join_optional_args(&rest[1..]);
|
||||
match classify_skills_slash_command(args.as_deref()) {
|
||||
SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt {
|
||||
prompt,
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
}),
|
||||
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
||||
args,
|
||||
output_format,
|
||||
}),
|
||||
}
|
||||
}
|
||||
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
|
||||
"login" => Ok(CliAction::Login { output_format }),
|
||||
"logout" => Ok(CliAction::Logout { output_format }),
|
||||
@@ -440,7 +453,13 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
permission_mode,
|
||||
})
|
||||
}
|
||||
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest, output_format),
|
||||
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,
|
||||
@@ -532,7 +551,10 @@ fn join_optional_args(args: &[String]) -> Option<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) {
|
||||
@@ -550,10 +572,21 @@ fn parse_direct_slash_cli_action(
|
||||
},
|
||||
output_format,
|
||||
}),
|
||||
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills {
|
||||
args,
|
||||
output_format,
|
||||
}),
|
||||
Ok(Some(SlashCommand::Skills { args })) => {
|
||||
match classify_skills_slash_command(args.as_deref()) {
|
||||
SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt {
|
||||
prompt,
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
}),
|
||||
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
||||
args,
|
||||
output_format,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
|
||||
Ok(Some(command)) => Err({
|
||||
let _ = command;
|
||||
@@ -2281,6 +2314,11 @@ fn run_resume_command(
|
||||
})
|
||||
}
|
||||
SlashCommand::Skills { args } => {
|
||||
if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) {
|
||||
return Err(
|
||||
"resumed /skills invocations are interactive-only; start `claw` and run `/skills <skill>` in the REPL".into(),
|
||||
);
|
||||
}
|
||||
let cwd = env::current_dir()?;
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
@@ -3203,7 +3241,12 @@ impl LiveCli {
|
||||
false
|
||||
}
|
||||
SlashCommand::Skills { args } => {
|
||||
Self::print_skills(args.as_deref(), CliOutputFormat::Text)?;
|
||||
match classify_skills_slash_command(args.as_deref()) {
|
||||
SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?,
|
||||
SkillSlashDispatch::Local => {
|
||||
Self::print_skills(args.as_deref(), CliOutputFormat::Text)?;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::Doctor => {
|
||||
@@ -7258,6 +7301,21 @@ mod tests {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
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: crate::default_permission_mode(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["agents".to_string(), "--help".to_string()])
|
||||
.expect("agents help should parse"),
|
||||
@@ -7399,6 +7457,21 @@ mod tests {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
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: crate::default_permission_mode(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&[
|
||||
"/skills".to_string(),
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -2973,53 +2973,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(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
|
||||
candidates.push(std::path::PathBuf::from(claw_config_home).join("skills"));
|
||||
}
|
||||
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(".claw").join("skills"));
|
||||
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(home.join(".claude").join("skills"));
|
||||
}
|
||||
candidates.push(std::path::PathBuf::from("/home/bellman/.claw/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())?;
|
||||
commands::resolve_skill_path(&cwd, skill).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||
@@ -5797,6 +5752,50 @@ mod tests {
|
||||
fs::remove_dir_all(home).expect("temp home should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_resolves_project_local_skills_and_legacy_commands() {
|
||||
let _guard = env_lock().lock().expect("env lock should acquire");
|
||||
let root = temp_path("project-skills");
|
||||
let skill_dir = root.join(".claw").join("skills").join("plan");
|
||||
let command_dir = root.join(".claw").join("commands");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
|
||||
fs::create_dir_all(&command_dir).expect("command dir should exist");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: plan\ndescription: Project planning guidance\n---\n\n# plan\n",
|
||||
)
|
||||
.expect("skill file should exist");
|
||||
fs::write(
|
||||
command_dir.join("handoff.md"),
|
||||
"---\nname: handoff\ndescription: Legacy handoff guidance\n---\n\n# handoff\n",
|
||||
)
|
||||
.expect("command file should exist");
|
||||
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let skill_result = execute_tool("Skill", &json!({ "skill": "$plan" }))
|
||||
.expect("project-local skill should resolve");
|
||||
let skill_output: serde_json::Value =
|
||||
serde_json::from_str(&skill_result).expect("valid json");
|
||||
assert!(skill_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".claw/skills/plan/SKILL.md"));
|
||||
|
||||
let command_result = execute_tool("Skill", &json!({ "skill": "/handoff" }))
|
||||
.expect("legacy command should resolve");
|
||||
let command_output: serde_json::Value =
|
||||
serde_json::from_str(&command_result).expect("valid json");
|
||||
assert!(command_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".claw/commands/handoff.md"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
fs::remove_dir_all(root).expect("temp project should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_supports_keyword_and_select_queries() {
|
||||
let keyword = execute_tool(
|
||||
|
||||
Reference in New Issue
Block a user