mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 16:44:50 +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
|
### Rust exists
|
||||||
Evidence:
|
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`.
|
- 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`.
|
- 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
|
### Missing or broken in Rust
|
||||||
- No bundled skill registry equivalent.
|
- No bundled skill registry equivalent.
|
||||||
- No `/skills` command.
|
|
||||||
- No MCP skill-builder pipeline.
|
- No MCP skill-builder pipeline.
|
||||||
- No TS-style live skill discovery/reload/change handling.
|
- No TS-style live skill discovery/reload/change handling.
|
||||||
- No comparable session-memory / team-memory integration around skills.
|
- 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
|
### Rust exists
|
||||||
Evidence:
|
Evidence:
|
||||||
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
|
- 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`.
|
- Main CLI/repl/prompt handling lives in `rust/crates/claw-cli/src/main.rs`.
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### 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 Rust equivalent to TS structured IO / remote transport layers.
|
||||||
- No TS-style handler decomposition for auth/plugins/MCP/agents.
|
- 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.
|
- 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"
|
"/init · then type / to browse commands"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
" Autocomplete Type / for command suggestions · Tab accepts or cycles"
|
" Autocomplete Type / for command suggestions · Tab accepts or cycles".to_string(),
|
||||||
.to_string(),
|
|
||||||
" Editor /vim toggles modal editing · Esc clears menus first".to_string(),
|
" Editor /vim toggles modal editing · Esc clears menus first".to_string(),
|
||||||
" Multiline Shift+Enter or Ctrl+J inserts a newline".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),
|
command: format!("/{}", spec.name),
|
||||||
description: Some(spec.summary.to_string()),
|
description: Some(spec.summary.to_string()),
|
||||||
argument_hint: spec.argument_hint.map(ToOwned::to_owned),
|
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<_>>();
|
.collect::<Vec<_>>();
|
||||||
descriptors.extend([
|
descriptors.extend([
|
||||||
@@ -4056,7 +4059,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" claw skills List installed skills"
|
" claw skills List discoverable local skills"
|
||||||
)?;
|
)?;
|
||||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
@@ -4146,9 +4149,9 @@ mod tests {
|
|||||||
print_help_to, push_output_block, render_config_report, render_memory_report,
|
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,
|
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
||||||
resume_supported_slash_commands, slash_command_completion_candidates,
|
resume_supported_slash_commands, slash_command_completion_candidates,
|
||||||
slash_command_descriptors, status_context,
|
slash_command_descriptors, status_context, CliAction, CliOutputFormat,
|
||||||
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
|
InternalPromptProgressEvent, InternalPromptProgressState, SlashCommand, StatusUsage,
|
||||||
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use std::process::Command;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use plugins::{PluginError, PluginManager, PluginSummary};
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CommandManifestEntry {
|
pub struct CommandManifestEntry {
|
||||||
@@ -659,31 +662,9 @@ struct AgentSummary {
|
|||||||
struct SkillSummary {
|
struct SkillSummary {
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
source: DefinitionSource,
|
source: SkillDiscoverySource,
|
||||||
shadowed_by: Option<DefinitionSource>,
|
shadowed_by: Option<SkillDiscoverySource>,
|
||||||
origin: SkillOrigin,
|
origin: SkillRootKind,
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[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") => {
|
None | Some("list") => {
|
||||||
let roots = discover_skill_roots(cwd);
|
let roots = discover_skill_roots(cwd);
|
||||||
let skills = load_skills_from_roots(&roots)?;
|
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("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
|
||||||
Some(args) => Ok(render_skills_usage(Some(args))),
|
Some(args) => Ok(render_skills_usage(Some(args))),
|
||||||
@@ -1301,83 +1282,6 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
|
|||||||
roots
|
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(
|
fn push_unique_root(
|
||||||
roots: &mut Vec<(DefinitionSource, PathBuf)>,
|
roots: &mut Vec<(DefinitionSource, PathBuf)>,
|
||||||
source: DefinitionSource,
|
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(
|
fn load_agents_from_roots(
|
||||||
roots: &[(DefinitionSource, PathBuf)],
|
roots: &[(DefinitionSource, PathBuf)],
|
||||||
) -> std::io::Result<Vec<AgentSummary>> {
|
) -> std::io::Result<Vec<AgentSummary>> {
|
||||||
@@ -1446,16 +1335,16 @@ fn load_agents_from_roots(
|
|||||||
Ok(agents)
|
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 skills = Vec::new();
|
||||||
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
let mut active_sources = BTreeMap::<String, SkillDiscoverySource>::new();
|
||||||
|
|
||||||
for root in roots {
|
for root in roots {
|
||||||
let mut root_skills = Vec::new();
|
let mut root_skills = Vec::new();
|
||||||
for entry in fs::read_dir(&root.path)? {
|
for entry in fs::read_dir(&root.path)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
match root.origin {
|
match root.kind {
|
||||||
SkillOrigin::SkillsDir => {
|
SkillRootKind::SkillsDir => {
|
||||||
if !entry.path().is_dir() {
|
if !entry.path().is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1471,10 +1360,10 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
|||||||
description,
|
description,
|
||||||
source: root.source,
|
source: root.source,
|
||||||
shadowed_by: None,
|
shadowed_by: None,
|
||||||
origin: root.origin,
|
origin: root.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
SkillOrigin::LegacyCommandsDir => {
|
SkillRootKind::LegacyCommandsDir => {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let markdown_path = if path.is_dir() {
|
let markdown_path = if path.is_dir() {
|
||||||
let skill_path = path.join("SKILL.md");
|
let skill_path = path.join("SKILL.md");
|
||||||
@@ -1502,7 +1391,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
|||||||
description,
|
description,
|
||||||
source: root.source,
|
source: root.source,
|
||||||
shadowed_by: None,
|
shadowed_by: None,
|
||||||
origin: root.origin,
|
origin: root.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1650,9 +1539,19 @@ fn agent_detail(agent: &AgentSummary) -> String {
|
|||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_skills_report(skills: &[SkillSummary]) -> String {
|
fn render_skills_report(skills: &[SkillSummary], roots: &[SkillDiscoveryRoot]) -> String {
|
||||||
if skills.is_empty() {
|
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
|
let total_active = skills
|
||||||
@@ -1666,11 +1565,11 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for source in [
|
for source in [
|
||||||
DefinitionSource::ProjectCodex,
|
SkillDiscoverySource::ProjectCodex,
|
||||||
DefinitionSource::ProjectClaw,
|
SkillDiscoverySource::ProjectClaw,
|
||||||
DefinitionSource::UserCodexHome,
|
SkillDiscoverySource::UserCodexHome,
|
||||||
DefinitionSource::UserCodex,
|
SkillDiscoverySource::UserCodex,
|
||||||
DefinitionSource::UserClaw,
|
SkillDiscoverySource::UserClaw,
|
||||||
] {
|
] {
|
||||||
let group = skills
|
let group = skills
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1681,6 +1580,9 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(format!("{}:", source.label()));
|
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 {
|
for skill in group {
|
||||||
let mut parts = vec![skill.name.clone()];
|
let mut parts = vec![skill.name.clone()];
|
||||||
if let Some(description) = &skill.description {
|
if let Some(description) = &skill.description {
|
||||||
@@ -1701,6 +1603,21 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
lines.join("\n").trim_end().to_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> {
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
||||||
args.map(str::trim).filter(|value| !value.is_empty())
|
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,
|
handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots,
|
||||||
render_agents_report, render_plugins_report, render_skills_report,
|
render_agents_report, render_plugins_report, render_skills_report,
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||||
suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot,
|
suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SlashCommand,
|
||||||
SlashCommand,
|
|
||||||
};
|
};
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
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::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -2368,30 +2287,38 @@ mod tests {
|
|||||||
write_skill(&user_skills, "help", "Help guidance");
|
write_skill(&user_skills, "help", "Help guidance");
|
||||||
|
|
||||||
let roots = vec![
|
let roots = vec![
|
||||||
SkillRoot {
|
SkillDiscoveryRoot {
|
||||||
source: DefinitionSource::ProjectCodex,
|
source: SkillDiscoverySource::ProjectCodex,
|
||||||
path: project_skills,
|
path: project_skills,
|
||||||
origin: SkillOrigin::SkillsDir,
|
kind: SkillRootKind::SkillsDir,
|
||||||
},
|
},
|
||||||
SkillRoot {
|
SkillDiscoveryRoot {
|
||||||
source: DefinitionSource::ProjectClaw,
|
source: SkillDiscoverySource::ProjectClaw,
|
||||||
path: project_commands,
|
path: project_commands,
|
||||||
origin: SkillOrigin::LegacyCommandsDir,
|
kind: SkillRootKind::LegacyCommandsDir,
|
||||||
},
|
},
|
||||||
SkillRoot {
|
SkillDiscoveryRoot {
|
||||||
source: DefinitionSource::UserCodex,
|
source: SkillDiscoverySource::UserCodex,
|
||||||
path: user_skills,
|
path: user_skills,
|
||||||
origin: SkillOrigin::SkillsDir,
|
kind: SkillRootKind::SkillsDir,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let report =
|
let skills = load_skills_from_roots(&roots).expect("skill roots should load");
|
||||||
render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
|
let report = render_skills_report(&skills, &roots);
|
||||||
|
|
||||||
assert!(report.contains("Skills"));
|
assert!(report.contains("Skills"));
|
||||||
assert!(report.contains("3 available skills"));
|
assert!(report.contains("3 available skills"));
|
||||||
assert!(report.contains("Project (.codex):"));
|
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("plan · Project planning guidance"));
|
||||||
assert!(report.contains("Project (.claw):"));
|
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("deploy · Legacy deployment guidance · legacy /commands"));
|
||||||
assert!(report.contains("User (~/.codex):"));
|
assert!(report.contains("User (~/.codex):"));
|
||||||
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
|
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
|
||||||
@@ -2426,6 +2353,31 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(cwd);
|
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]
|
#[test]
|
||||||
fn parses_quoted_skill_frontmatter_values() {
|
fn parses_quoted_skill_frontmatter_values() {
|
||||||
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
||||||
|
|||||||
@@ -15,12 +15,9 @@ mod prompt;
|
|||||||
mod remote;
|
mod remote;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod skills;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use lsp::{
|
|
||||||
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig,
|
|
||||||
SymbolLocation, WorkspaceDiagnostics,
|
|
||||||
};
|
|
||||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||||
pub use compact::{
|
pub use compact::{
|
||||||
@@ -28,8 +25,8 @@ pub use compact::{
|
|||||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
|
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||||
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||||
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
||||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
@@ -44,12 +41,16 @@ pub use file_ops::{
|
|||||||
WriteFileOutput,
|
WriteFileOutput,
|
||||||
};
|
};
|
||||||
pub use hooks::{HookEvent, HookRunResult, HookRunner};
|
pub use hooks::{HookEvent, HookRunResult, HookRunner};
|
||||||
|
pub use lsp::{
|
||||||
|
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig, SymbolLocation,
|
||||||
|
WorkspaceDiagnostics,
|
||||||
|
};
|
||||||
pub use mcp::{
|
pub use mcp::{
|
||||||
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
|
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
|
||||||
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
|
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
|
||||||
};
|
};
|
||||||
pub use mcp_client::{
|
pub use mcp_client::{
|
||||||
McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
|
McpClientAuth, McpClientBootstrap, McpClientTransport, McpManagedProxyTransport,
|
||||||
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
||||||
};
|
};
|
||||||
pub use mcp_stdio::{
|
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,
|
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 session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
|
||||||
|
pub use skills::{
|
||||||
|
discover_skill_roots, resolve_skill_path, SkillDiscoveryRoot, SkillDiscoverySource,
|
||||||
|
SkillRootKind,
|
||||||
|
};
|
||||||
pub use usage::{
|
pub use usage::{
|
||||||
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
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 plugins::PluginTool;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
|
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file,
|
||||||
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
|
resolve_skill_path as resolve_runtime_skill_path, write_file, ApiClient, ApiRequest,
|
||||||
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
|
AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, ConversationRuntime,
|
||||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, RuntimeError, Session,
|
||||||
|
TokenUsage, ToolError, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -91,7 +92,10 @@ impl GlobalToolRegistry {
|
|||||||
Ok(Self { plugin_tools })
|
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() {
|
if values.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -100,7 +104,11 @@ impl GlobalToolRegistry {
|
|||||||
let canonical_names = builtin_specs
|
let canonical_names = builtin_specs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|spec| spec.name.to_string())
|
.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<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut name_map = canonical_names
|
let mut name_map = canonical_names
|
||||||
.iter()
|
.iter()
|
||||||
@@ -151,7 +159,8 @@ impl GlobalToolRegistry {
|
|||||||
.plugin_tools
|
.plugin_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| {
|
.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 {
|
.map(|tool| ToolDefinition {
|
||||||
name: tool.definition().name.clone(),
|
name: tool.definition().name.clone(),
|
||||||
@@ -174,7 +183,8 @@ impl GlobalToolRegistry {
|
|||||||
.plugin_tools
|
.plugin_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| {
|
.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| {
|
.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> {
|
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
if requested.is_empty() {
|
resolve_runtime_skill_path(skill, &cwd)
|
||||||
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}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||||
@@ -3488,6 +3459,65 @@ mod tests {
|
|||||||
.ends_with("/help/SKILL.md"));
|
.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]
|
#[test]
|
||||||
fn tool_search_supports_keyword_and_select_queries() {
|
fn tool_search_supports_keyword_and_select_queries() {
|
||||||
let keyword = execute_tool(
|
let keyword = execute_tool(
|
||||||
|
|||||||
Reference in New Issue
Block a user