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:
Yeachan-Heo
2026-04-01 23:34:38 +00:00
parent 8599bac67b
commit fdd06e814b
6 changed files with 518 additions and 215 deletions

View File

@@ -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";