diff --git a/PARITY.md b/PARITY.md index c163542..bce5dc3 100644 --- a/PARITY.md +++ b/PARITY.md @@ -104,18 +104,18 @@ Evidence: ### Rust exists Evidence: -- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files. +- `Skill` tool in `rust/crates/tools/src/lib.rs` now resolves workspace-local `.codex/.claw` skills plus legacy `/commands` entries through shared runtime discovery. +- `/skills` exists in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`, listing discoverable local skills and checked skill directories in the current workspace context. - CLAW.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`. - Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`. ### Missing or broken in Rust - No bundled skill registry equivalent. -- No `/skills` command. - No MCP skill-builder pipeline. - No TS-style live skill discovery/reload/change handling. - No comparable session-memory / team-memory integration around skills. -**Status:** basic local skill loading only. +**Status:** local/workspace skill loading plus minimal `/skills` discovery; bundled/MCP parity still missing. --- @@ -130,11 +130,11 @@ Evidence: ### Rust exists Evidence: - Shared slash command registry in `rust/crates/commands/src/lib.rs`. -- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`. +- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`, `plugin`, `agents`, and `skills`. - Main CLI/repl/prompt handling lives in `rust/crates/claw-cli/src/main.rs`. ### Missing or broken in Rust -- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others. +- Missing major TS command families: `/hooks`, `/mcp`, `/plan`, `/review`, `/tasks`, and many others. - No Rust equivalent to TS structured IO / remote transport layers. - No TS-style handler decomposition for auth/plugins/MCP/agents. - JSON prompt mode now maintains clean transport output in tool-capable runs; targeted CLI coverage should guard against regressions. diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index b95447a..50ed476 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -1147,8 +1147,7 @@ impl LiveCli { "/init · then type / to browse commands" } ), - " Autocomplete Type / for command suggestions · Tab accepts or cycles" - .to_string(), + " Autocomplete Type / for command suggestions · Tab accepts or cycles".to_string(), " Editor /vim toggles modal editing · Esc clears menus first".to_string(), " Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(), ]; @@ -3293,7 +3292,11 @@ fn slash_command_descriptors() -> Vec { command: format!("/{}", spec.name), description: Some(spec.summary.to_string()), argument_hint: spec.argument_hint.map(ToOwned::to_owned), - aliases: spec.aliases.iter().map(|alias| format!("/{alias}")).collect(), + aliases: spec + .aliases + .iter() + .map(|alias| format!("/{alias}")) + .collect(), }) .collect::>(); descriptors.extend([ @@ -4056,7 +4059,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!( out, - " claw skills List installed skills" + " claw skills List discoverable local skills" )?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; writeln!( @@ -4146,9 +4149,9 @@ mod tests { print_help_to, push_output_block, render_config_report, render_memory_report, render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events, resume_supported_slash_commands, slash_command_completion_candidates, - slash_command_descriptors, status_context, - CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState, - SlashCommand, StatusUsage, DEFAULT_MODEL, + slash_command_descriptors, status_context, CliAction, CliOutputFormat, + InternalPromptProgressEvent, InternalPromptProgressState, SlashCommand, StatusUsage, + DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index da7f1a4..6ea577e 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -7,7 +7,10 @@ use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; use plugins::{PluginError, PluginManager, PluginSummary}; -use runtime::{compact_session, CompactionConfig, Session}; +use runtime::{ + compact_session, discover_skill_roots, CompactionConfig, Session, SkillDiscoveryRoot, + SkillDiscoverySource, SkillRootKind, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandManifestEntry { @@ -659,31 +662,9 @@ struct AgentSummary { struct SkillSummary { name: String, description: Option, - source: DefinitionSource, - shadowed_by: Option, - origin: SkillOrigin, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SkillOrigin { - SkillsDir, - LegacyCommandsDir, -} - -impl SkillOrigin { - fn detail_label(self) -> Option<&'static str> { - match self { - Self::SkillsDir => None, - Self::LegacyCommandsDir => Some("legacy /commands"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct SkillRoot { - source: DefinitionSource, - path: PathBuf, - origin: SkillOrigin, + source: SkillDiscoverySource, + shadowed_by: Option, + origin: SkillRootKind, } #[allow(clippy::too_many_lines)] @@ -815,7 +796,7 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R None | Some("list") => { let roots = discover_skill_roots(cwd); let skills = load_skills_from_roots(&roots)?; - Ok(render_skills_report(&skills)) + Ok(render_skills_report(&skills, &roots)) } Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)), Some(args) => Ok(render_skills_usage(Some(args))), @@ -1301,83 +1282,6 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P roots } -fn discover_skill_roots(cwd: &Path) -> Vec { - let mut roots = Vec::new(); - - for ancestor in cwd.ancestors() { - push_unique_skill_root( - &mut roots, - DefinitionSource::ProjectCodex, - ancestor.join(".codex").join("skills"), - SkillOrigin::SkillsDir, - ); - push_unique_skill_root( - &mut roots, - DefinitionSource::ProjectClaw, - ancestor.join(".claw").join("skills"), - SkillOrigin::SkillsDir, - ); - push_unique_skill_root( - &mut roots, - DefinitionSource::ProjectCodex, - ancestor.join(".codex").join("commands"), - SkillOrigin::LegacyCommandsDir, - ); - push_unique_skill_root( - &mut roots, - DefinitionSource::ProjectClaw, - ancestor.join(".claw").join("commands"), - SkillOrigin::LegacyCommandsDir, - ); - } - - if let Ok(codex_home) = env::var("CODEX_HOME") { - let codex_home = PathBuf::from(codex_home); - push_unique_skill_root( - &mut roots, - DefinitionSource::UserCodexHome, - codex_home.join("skills"), - SkillOrigin::SkillsDir, - ); - push_unique_skill_root( - &mut roots, - DefinitionSource::UserCodexHome, - codex_home.join("commands"), - SkillOrigin::LegacyCommandsDir, - ); - } - - if let Some(home) = env::var_os("HOME") { - let home = PathBuf::from(home); - push_unique_skill_root( - &mut roots, - DefinitionSource::UserCodex, - home.join(".codex").join("skills"), - SkillOrigin::SkillsDir, - ); - push_unique_skill_root( - &mut roots, - DefinitionSource::UserCodex, - home.join(".codex").join("commands"), - SkillOrigin::LegacyCommandsDir, - ); - push_unique_skill_root( - &mut roots, - DefinitionSource::UserClaw, - home.join(".claw").join("skills"), - SkillOrigin::SkillsDir, - ); - push_unique_skill_root( - &mut roots, - DefinitionSource::UserClaw, - home.join(".claw").join("commands"), - SkillOrigin::LegacyCommandsDir, - ); - } - - roots -} - fn push_unique_root( roots: &mut Vec<(DefinitionSource, PathBuf)>, source: DefinitionSource, @@ -1388,21 +1292,6 @@ fn push_unique_root( } } -fn push_unique_skill_root( - roots: &mut Vec, - source: DefinitionSource, - path: PathBuf, - origin: SkillOrigin, -) { - if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { - roots.push(SkillRoot { - source, - path, - origin, - }); - } -} - fn load_agents_from_roots( roots: &[(DefinitionSource, PathBuf)], ) -> std::io::Result> { @@ -1446,16 +1335,16 @@ fn load_agents_from_roots( Ok(agents) } -fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result> { +fn load_skills_from_roots(roots: &[SkillDiscoveryRoot]) -> std::io::Result> { let mut skills = Vec::new(); - let mut active_sources = BTreeMap::::new(); + let mut active_sources = BTreeMap::::new(); 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 => { + match root.kind { + SkillRootKind::SkillsDir => { if !entry.path().is_dir() { continue; } @@ -1471,10 +1360,10 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result { + SkillRootKind::LegacyCommandsDir => { let path = entry.path(); let markdown_path = if path.is_dir() { let skill_path = path.join("SKILL.md"); @@ -1502,7 +1391,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result String { parts.join(" · ") } -fn render_skills_report(skills: &[SkillSummary]) -> String { +fn render_skills_report(skills: &[SkillSummary], roots: &[SkillDiscoveryRoot]) -> String { if skills.is_empty() { - return "No skills found.".to_string(); + let mut lines = vec!["Skills".to_string(), " No skills found.".to_string()]; + let checked_paths = skill_root_paths(roots); + if !checked_paths.is_empty() { + lines.push(" Checked".to_string()); + lines.extend( + checked_paths + .into_iter() + .map(|path| format!(" {}", path.display())), + ); + } + return lines.join("\n"); } let total_active = skills @@ -1666,11 +1565,11 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { ]; for source in [ - DefinitionSource::ProjectCodex, - DefinitionSource::ProjectClaw, - DefinitionSource::UserCodexHome, - DefinitionSource::UserCodex, - DefinitionSource::UserClaw, + SkillDiscoverySource::ProjectCodex, + SkillDiscoverySource::ProjectClaw, + SkillDiscoverySource::UserCodexHome, + SkillDiscoverySource::UserCodex, + SkillDiscoverySource::UserClaw, ] { let group = skills .iter() @@ -1681,6 +1580,9 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { } lines.push(format!("{}:", source.label())); + for path in skill_root_paths_for_source(roots, source) { + lines.push(format!(" Path {}", path.display())); + } for skill in group { let mut parts = vec![skill.name.clone()]; if let Some(description) = &skill.description { @@ -1701,6 +1603,21 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { lines.join("\n").trim_end().to_string() } +fn skill_root_paths(roots: &[SkillDiscoveryRoot]) -> Vec { + roots.iter().map(|root| root.path.clone()).collect() +} + +fn skill_root_paths_for_source( + roots: &[SkillDiscoveryRoot], + source: SkillDiscoverySource, +) -> Vec { + roots + .iter() + .filter(|root| root.source == source) + .map(|root| root.path.clone()) + .collect() +} + fn normalize_optional_args(args: Option<&str>) -> Option<&str> { args.map(str::trim).filter(|value| !value.is_empty()) } @@ -1795,11 +1712,13 @@ 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, CommitPushPrRequest, DefinitionSource, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; - use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; + use runtime::{ + CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session, + SkillDiscoveryRoot, SkillDiscoverySource, SkillRootKind, + }; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -2368,30 +2287,38 @@ mod tests { write_skill(&user_skills, "help", "Help guidance"); let roots = vec![ - SkillRoot { - source: DefinitionSource::ProjectCodex, + SkillDiscoveryRoot { + source: SkillDiscoverySource::ProjectCodex, path: project_skills, - origin: SkillOrigin::SkillsDir, + kind: SkillRootKind::SkillsDir, }, - SkillRoot { - source: DefinitionSource::ProjectClaw, + SkillDiscoveryRoot { + source: SkillDiscoverySource::ProjectClaw, path: project_commands, - origin: SkillOrigin::LegacyCommandsDir, + kind: SkillRootKind::LegacyCommandsDir, }, - SkillRoot { - source: DefinitionSource::UserCodex, + SkillDiscoveryRoot { + source: SkillDiscoverySource::UserCodex, path: user_skills, - origin: SkillOrigin::SkillsDir, + kind: SkillRootKind::SkillsDir, }, ]; - let report = - render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load")); + let skills = load_skills_from_roots(&roots).expect("skill roots should load"); + let report = render_skills_report(&skills, &roots); assert!(report.contains("Skills")); assert!(report.contains("3 available skills")); assert!(report.contains("Project (.codex):")); + assert!(report.contains(&format!( + "Path {}", + workspace.join(".codex").join("skills").display() + ))); assert!(report.contains("plan · Project planning guidance")); assert!(report.contains("Project (.claw):")); + assert!(report.contains(&format!( + "Path {}", + workspace.join(".claw").join("commands").display() + ))); assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands")); assert!(report.contains("User (~/.codex):")); assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance")); @@ -2426,6 +2353,31 @@ mod tests { let _ = fs::remove_dir_all(cwd); } + #[test] + fn empty_skills_report_lists_checked_directories() { + let workspace = temp_dir("skills-empty"); + let nested = workspace.join("apps").join("ui"); + fs::create_dir_all(&nested).expect("nested cwd"); + fs::create_dir_all(workspace.join(".claw").join("skills")).expect("claw skills"); + fs::create_dir_all(workspace.join(".codex").join("commands")).expect("codex commands"); + + let roots = runtime::discover_skill_roots(&nested); + let report = render_skills_report(&[], &roots); + + assert!(report.contains("Skills")); + assert!(report.contains("No skills found.")); + assert!(report.contains(&workspace.join(".claw").join("skills").display().to_string())); + assert!(report.contains( + &workspace + .join(".codex") + .join("commands") + .display() + .to_string() + )); + + let _ = fs::remove_dir_all(workspace); + } + #[test] fn parses_quoted_skill_frontmatter_values() { let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n"; diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index c714f95..e5df2c8 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -15,12 +15,9 @@ mod prompt; mod remote; pub mod sandbox; mod session; +mod skills; mod usage; -pub use lsp::{ - FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig, - SymbolLocation, WorkspaceDiagnostics, -}; pub use bash::{execute_bash, BashCommandInput, BashCommandOutput}; pub use bootstrap::{BootstrapPhase, BootstrapPlan}; pub use compact::{ @@ -28,8 +25,8 @@ pub use compact::{ get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; pub use config::{ - ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig, - McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, + ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, + McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, @@ -44,12 +41,16 @@ pub use file_ops::{ WriteFileOutput, }; pub use hooks::{HookEvent, HookRunResult, HookRunner}; +pub use lsp::{ + FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig, SymbolLocation, + WorkspaceDiagnostics, +}; pub use mcp::{ mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp, scoped_mcp_config_hash, unwrap_ccr_proxy_url, }; pub use mcp_client::{ - McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport, + McpClientAuth, McpClientBootstrap, McpClientTransport, McpManagedProxyTransport, McpRemoteTransport, McpSdkTransport, McpStdioTransport, }; pub use mcp_stdio::{ @@ -81,6 +82,10 @@ pub use remote::{ DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, }; pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; +pub use skills::{ + discover_skill_roots, resolve_skill_path, SkillDiscoveryRoot, SkillDiscoverySource, + SkillRootKind, +}; pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, }; diff --git a/rust/crates/runtime/src/skills.rs b/rust/crates/runtime/src/skills.rs new file mode 100644 index 0000000..13f2b00 --- /dev/null +++ b/rust/crates/runtime/src/skills.rs @@ -0,0 +1,313 @@ +use std::env; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum SkillDiscoverySource { + ProjectCodex, + ProjectClaw, + UserCodexHome, + UserCodex, + UserClaw, +} + +impl SkillDiscoverySource { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::ProjectCodex => "Project (.codex)", + Self::ProjectClaw => "Project (.claw)", + Self::UserCodexHome => "User ($CODEX_HOME)", + Self::UserCodex => "User (~/.codex)", + Self::UserClaw => "User (~/.claw)", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillRootKind { + SkillsDir, + LegacyCommandsDir, +} + +impl SkillRootKind { + #[must_use] + pub const fn detail_label(self) -> Option<&'static str> { + match self { + Self::SkillsDir => None, + Self::LegacyCommandsDir => Some("legacy /commands"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillDiscoveryRoot { + pub source: SkillDiscoverySource, + pub path: PathBuf, + pub kind: SkillRootKind, +} + +pub fn discover_skill_roots(cwd: &Path) -> Vec { + let mut roots = Vec::new(); + + for ancestor in cwd.ancestors() { + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::ProjectCodex, + ancestor.join(".codex").join("skills"), + SkillRootKind::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::ProjectClaw, + ancestor.join(".claw").join("skills"), + SkillRootKind::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::ProjectCodex, + ancestor.join(".codex").join("commands"), + SkillRootKind::LegacyCommandsDir, + ); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::ProjectClaw, + ancestor.join(".claw").join("commands"), + SkillRootKind::LegacyCommandsDir, + ); + } + + if let Ok(codex_home) = env::var("CODEX_HOME") { + let codex_home = PathBuf::from(codex_home); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::UserCodexHome, + codex_home.join("skills"), + SkillRootKind::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::UserCodexHome, + codex_home.join("commands"), + SkillRootKind::LegacyCommandsDir, + ); + } + + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::UserCodex, + home.join(".codex").join("skills"), + SkillRootKind::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::UserCodex, + home.join(".codex").join("commands"), + SkillRootKind::LegacyCommandsDir, + ); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::UserClaw, + home.join(".claw").join("skills"), + SkillRootKind::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + SkillDiscoverySource::UserClaw, + home.join(".claw").join("commands"), + SkillRootKind::LegacyCommandsDir, + ); + } + + roots +} + +pub fn resolve_skill_path(skill: &str, cwd: &Path) -> Result { + let requested = normalize_requested_skill_name(skill)?; + + for root in discover_skill_roots(cwd) { + match root.kind { + SkillRootKind::SkillsDir => { + let direct = root.path.join(&requested).join("SKILL.md"); + if direct.is_file() { + return Ok(direct); + } + + if let Ok(entries) = std::fs::read_dir(&root.path) { + for entry in entries.flatten() { + let path = entry.path().join("SKILL.md"); + if !path.is_file() { + continue; + } + if entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(&requested) + { + return Ok(path); + } + } + } + } + SkillRootKind::LegacyCommandsDir => { + let direct_markdown = root.path.join(format!("{requested}.md")); + if direct_markdown.is_file() { + return Ok(direct_markdown); + } + + let direct_skill_dir = root.path.join(&requested).join("SKILL.md"); + if direct_skill_dir.is_file() { + return Ok(direct_skill_dir); + } + + if let Ok(entries) = std::fs::read_dir(&root.path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let skill_path = path.join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + if entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(&requested) + { + return Ok(skill_path); + } + continue; + } + + if !path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) + { + continue; + } + + let Some(stem) = path.file_stem() else { + continue; + }; + if stem.to_string_lossy().eq_ignore_ascii_case(&requested) { + return Ok(path); + } + } + } + } + } + } + + Err(format!("unknown skill: {requested}")) +} + +fn normalize_requested_skill_name(skill: &str) -> Result { + let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); + if requested.is_empty() { + return Err(String::from("skill must not be empty")); + } + Ok(requested.to_string()) +} + +fn push_unique_skill_root( + roots: &mut Vec, + source: SkillDiscoverySource, + path: PathBuf, + kind: SkillRootKind, +) { + if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { + roots.push(SkillDiscoveryRoot { source, path, kind }); + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::{ + discover_skill_roots, resolve_skill_path, SkillDiscoveryRoot, SkillDiscoverySource, + SkillRootKind, + }; + + fn temp_dir(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!("runtime-skills-{label}-{nanos}")) + } + + fn write_skill(root: &Path, name: &str) { + let skill_root = root.join(name); + fs::create_dir_all(&skill_root).expect("skill root"); + fs::write(skill_root.join("SKILL.md"), format!("# {name}\n")).expect("write skill"); + } + + fn write_legacy_markdown(root: &Path, name: &str) { + fs::create_dir_all(root).expect("legacy root"); + fs::write(root.join(format!("{name}.md")), format!("# {name}\n")).expect("write command"); + } + + #[test] + fn discovers_workspace_and_user_skill_roots() { + let _guard = crate::test_env_lock(); + let workspace = temp_dir("workspace"); + let nested = workspace.join("apps").join("ui"); + let user_home = temp_dir("home"); + + fs::create_dir_all(&nested).expect("nested cwd"); + fs::create_dir_all(workspace.join(".codex").join("skills")).expect("project codex skills"); + fs::create_dir_all(workspace.join(".claw").join("commands")) + .expect("project claw commands"); + fs::create_dir_all(user_home.join(".codex").join("skills")).expect("user codex skills"); + + std::env::set_var("HOME", &user_home); + std::env::remove_var("CODEX_HOME"); + + let roots = discover_skill_roots(&nested); + + assert!(roots.contains(&SkillDiscoveryRoot { + source: SkillDiscoverySource::ProjectCodex, + path: workspace.join(".codex").join("skills"), + kind: SkillRootKind::SkillsDir, + })); + assert!(roots.contains(&SkillDiscoveryRoot { + source: SkillDiscoverySource::ProjectClaw, + path: workspace.join(".claw").join("commands"), + kind: SkillRootKind::LegacyCommandsDir, + })); + assert!(roots.contains(&SkillDiscoveryRoot { + source: SkillDiscoverySource::UserCodex, + path: user_home.join(".codex").join("skills"), + kind: SkillRootKind::SkillsDir, + })); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(user_home); + } + + #[test] + fn resolves_workspace_skills_and_legacy_commands() { + let _guard = crate::test_env_lock(); + let workspace = temp_dir("resolve"); + let nested = workspace.join("apps").join("ui"); + let original_dir = std::env::current_dir().expect("cwd"); + + fs::create_dir_all(&nested).expect("nested cwd"); + write_skill(&workspace.join(".claw").join("skills"), "review"); + write_legacy_markdown(&workspace.join(".codex").join("commands"), "deploy"); + + std::env::set_current_dir(&nested).expect("set cwd"); + let review = resolve_skill_path("review", &nested).expect("workspace skill"); + let deploy = resolve_skill_path("/deploy", &nested).expect("legacy command"); + std::env::set_current_dir(&original_dir).expect("restore cwd"); + + assert!(review.ends_with(".claw/skills/review/SKILL.md")); + assert!(deploy.ends_with(".codex/commands/deploy.md")); + + let _ = fs::remove_dir_all(workspace); + } +} diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 4b42572..1db3f21 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -11,10 +11,11 @@ use api::{ use plugins::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, + edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, + resolve_skill_path as resolve_runtime_skill_path, write_file, ApiClient, ApiRequest, + AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, ConversationRuntime, + GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, RuntimeError, Session, + TokenUsage, ToolError, ToolExecutor, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -91,7 +92,10 @@ impl GlobalToolRegistry { Ok(Self { plugin_tools }) } - pub fn normalize_allowed_tools(&self, values: &[String]) -> Result>, String> { + pub fn normalize_allowed_tools( + &self, + values: &[String], + ) -> Result>, String> { if values.is_empty() { return Ok(None); } @@ -100,7 +104,11 @@ impl GlobalToolRegistry { let canonical_names = builtin_specs .iter() .map(|spec| spec.name.to_string()) - .chain(self.plugin_tools.iter().map(|tool| tool.definition().name.clone())) + .chain( + self.plugin_tools + .iter() + .map(|tool| tool.definition().name.clone()), + ) .collect::>(); let mut name_map = canonical_names .iter() @@ -151,7 +159,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 +183,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| { ( @@ -1455,47 +1465,8 @@ fn todo_store_path() -> Result { } fn resolve_skill_path(skill: &str) -> Result { - let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); - if requested.is_empty() { - return Err(String::from("skill must not be empty")); - } - - let 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_runtime_skill_path(skill, &cwd) } const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6"; @@ -3488,6 +3459,65 @@ mod tests { .ends_with("/help/SKILL.md")); } + #[test] + fn skill_resolves_workspace_skill_and_legacy_command() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("workspace-skills"); + let cwd = root.join("apps").join("ui"); + let original_dir = std::env::current_dir().expect("cwd"); + + std::fs::create_dir_all(root.join(".claw").join("skills").join("review")) + .expect("workspace skill dir"); + std::fs::write( + root.join(".claw") + .join("skills") + .join("review") + .join("SKILL.md"), + "---\ndescription: Workspace review guidance\n---\n# review\n", + ) + .expect("write workspace skill"); + std::fs::create_dir_all(root.join(".codex").join("commands")).expect("legacy root"); + std::fs::write( + root.join(".codex").join("commands").join("deploy.md"), + "---\ndescription: Deploy command guidance\n---\n# deploy\n", + ) + .expect("write legacy command"); + std::fs::create_dir_all(&cwd).expect("cwd"); + + std::env::set_current_dir(&cwd).expect("set cwd"); + + let workspace_skill = execute_tool("Skill", &json!({ "skill": "review" })) + .expect("workspace skill should resolve"); + let workspace_output: serde_json::Value = + serde_json::from_str(&workspace_skill).expect("valid json"); + assert_eq!( + workspace_output["description"].as_str(), + Some("Workspace review guidance") + ); + assert!(workspace_output["path"] + .as_str() + .expect("path") + .ends_with(".claw/skills/review/SKILL.md")); + + let legacy_skill = execute_tool("Skill", &json!({ "skill": "/deploy" })) + .expect("legacy command should resolve"); + let legacy_output: serde_json::Value = + serde_json::from_str(&legacy_skill).expect("valid json"); + assert_eq!( + legacy_output["description"].as_str(), + Some("Deploy command guidance") + ); + assert!(legacy_output["path"] + .as_str() + .expect("path") + .ends_with(".codex/commands/deploy.md")); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn tool_search_supports_keyword_and_select_queries() { let keyword = execute_tool(