mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 16:39:04 +08:00
feat(rust): surface workspace skill discovery in /skills
The TypeScript CLI exposes a skills browser backed by workspace/user skill discovery, while the Rust port only had partial local loading and an inconsistent slash-command view. This change adds a shared runtime skill discovery path, teaches the Skill tool to resolve workspace `.codex/.claw` skills plus legacy `/commands`, and makes `/skills` report the checked local skill directories in the current workspace context. Constraint: Keep scope limited to local/workspace skill discovery without inventing bundled or remote registries yet Rejected: Add a bundled skill registry surface now | too broad for this parity increment Rejected: Leave tool resolution and /skills discovery separate | misleading output and weaker parity with TS Confidence: high Scope-risk: moderate Reversibility: clean Directive: Extend the shared runtime skill discovery path before adding new skill sources so the tool surface and /skills stay aligned Tested: cargo fmt --all; cargo test -p runtime skills:: -- --nocapture; cargo test -p commands skills -- --nocapture; cargo test -p tools skill_ -- --nocapture; cargo test -p claw-cli skills -- --nocapture; cargo test -p claw-cli init_help_mentions_direct_subcommand -- --nocapture Not-tested: Full workspace-wide cargo test sweep
This commit is contained in:
10
PARITY.md
10
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.
|
||||
|
||||
@@ -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<SlashCommandDescriptor> {
|
||||
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::<Vec<_>>();
|
||||
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};
|
||||
|
||||
@@ -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<String>,
|
||||
source: DefinitionSource,
|
||||
shadowed_by: Option<DefinitionSource>,
|
||||
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<SkillDiscoverySource>,
|
||||
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<SkillRoot> {
|
||||
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<SkillRoot>,
|
||||
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<Vec<AgentSummary>> {
|
||||
@@ -1446,16 +1335,16 @@ fn load_agents_from_roots(
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
|
||||
fn load_skills_from_roots(roots: &[SkillDiscoveryRoot]) -> std::io::Result<Vec<SkillSummary>> {
|
||||
let mut skills = Vec::new();
|
||||
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
||||
let mut active_sources = BTreeMap::<String, SkillDiscoverySource>::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<Vec<SkillSumma
|
||||
description,
|
||||
source: root.source,
|
||||
shadowed_by: None,
|
||||
origin: root.origin,
|
||||
origin: root.kind,
|
||||
});
|
||||
}
|
||||
SkillOrigin::LegacyCommandsDir => {
|
||||
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<Vec<SkillSumma
|
||||
description,
|
||||
source: root.source,
|
||||
shadowed_by: None,
|
||||
origin: root.origin,
|
||||
origin: root.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1650,9 +1539,19 @@ fn agent_detail(agent: &AgentSummary) -> 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<PathBuf> {
|
||||
roots.iter().map(|root| root.path.clone()).collect()
|
||||
}
|
||||
|
||||
fn skill_root_paths_for_source(
|
||||
roots: &[SkillDiscoveryRoot],
|
||||
source: SkillDiscoverySource,
|
||||
) -> Vec<PathBuf> {
|
||||
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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
313
rust/crates/runtime/src/skills.rs
Normal file
313
rust/crates/runtime/src/skills.rs
Normal file
@@ -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<SkillDiscoveryRoot> {
|
||||
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<PathBuf, String> {
|
||||
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<String, String> {
|
||||
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<SkillDiscoveryRoot>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<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 +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::<Vec<_>>();
|
||||
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<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_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(
|
||||
|
||||
Reference in New Issue
Block a user