mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 08:34:49 +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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user