mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-04 03:44:49 +08:00
Make agents and skills commands usable beyond placeholder parsing
Wire /agents and /skills through the Rust command stack so they can run as direct CLI subcommands, direct slash invocations, and resume-safe slash commands. The handlers now provide structured usage output, skills discovery also covers legacy /commands markdown entries, and the reporting/tests line up more closely with the original TypeScript behavior where feasible. Constraint: The Rust port does not yet have the original TypeScript TUI menus or plugin/MCP skill registry, so text reports approximate those views Rejected: Rebuild the original interactive React menus in Rust now | too large for the current CLI parity slice Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep /skills discovery and the Skill tool aligned if command/skill registry parity expands later Tested: cargo test --workspace Tested: cargo clippy --workspace --all-targets -- -D warnings Tested: cargo run -q -p rusty-claude-cli -- agents --help Tested: cargo run -q -p rusty-claude-cli -- /agents Not-tested: Live Anthropic-backed REPL execution of /agents or /skills
This commit is contained in:
@@ -212,16 +212,16 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "agents",
|
name: "agents",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
summary: "Manage agent configurations",
|
summary: "List configured agents",
|
||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
resume_supported: false,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "skills",
|
name: "skills",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
summary: "List available skills",
|
summary: "List available skills",
|
||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
resume_supported: false,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -470,6 +470,29 @@ struct SkillSummary {
|
|||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
source: DefinitionSource,
|
source: DefinitionSource,
|
||||||
shadowed_by: Option<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,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -585,23 +608,27 @@ pub fn handle_plugins_slash_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||||
if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
|
match normalize_optional_args(args) {
|
||||||
return Ok(format!("Usage: /agents\nUnexpected arguments: {args}"));
|
None | Some("list") => {
|
||||||
|
let roots = discover_definition_roots(cwd, "agents");
|
||||||
|
let agents = load_agents_from_roots(&roots)?;
|
||||||
|
Ok(render_agents_report(&agents))
|
||||||
|
}
|
||||||
|
Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
|
||||||
|
Some(args) => Ok(render_agents_usage(Some(args))),
|
||||||
}
|
}
|
||||||
|
|
||||||
let roots = discover_definition_roots(cwd, "agents");
|
|
||||||
let agents = load_agents_from_roots(&roots)?;
|
|
||||||
Ok(render_agents_report(&agents))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||||
if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
|
match normalize_optional_args(args) {
|
||||||
return Ok(format!("Usage: /skills\nUnexpected arguments: {args}"));
|
None | Some("list") => {
|
||||||
|
let roots = discover_skill_roots(cwd);
|
||||||
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
|
Ok(render_skills_report(&skills))
|
||||||
|
}
|
||||||
|
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
|
||||||
|
Some(args) => Ok(render_skills_usage(Some(args))),
|
||||||
}
|
}
|
||||||
|
|
||||||
let roots = discover_definition_roots(cwd, "skills");
|
|
||||||
let skills = load_skills_from_roots(&roots)?;
|
|
||||||
Ok(render_skills_report(&skills))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -697,6 +724,83 @@ 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::ProjectClaude,
|
||||||
|
ancestor.join(".claude").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::ProjectClaude,
|
||||||
|
ancestor.join(".claude").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::UserClaude,
|
||||||
|
home.join(".claude").join("skills"),
|
||||||
|
SkillOrigin::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClaude,
|
||||||
|
home.join(".claude").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,
|
||||||
@@ -707,6 +811,21 @@ 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>> {
|
||||||
@@ -750,31 +869,66 @@ fn load_agents_from_roots(
|
|||||||
Ok(agents)
|
Ok(agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_skills_from_roots(
|
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
|
||||||
roots: &[(DefinitionSource, PathBuf)],
|
|
||||||
) -> 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, DefinitionSource>::new();
|
||||||
|
|
||||||
for (source, 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)? {
|
for entry in fs::read_dir(&root.path)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
if !entry.path().is_dir() {
|
match root.origin {
|
||||||
continue;
|
SkillOrigin::SkillsDir => {
|
||||||
|
if !entry.path().is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let skill_path = entry.path().join("SKILL.md");
|
||||||
|
if !skill_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(skill_path)?;
|
||||||
|
let (name, description) = parse_skill_frontmatter(&contents);
|
||||||
|
root_skills.push(SkillSummary {
|
||||||
|
name: name
|
||||||
|
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
|
||||||
|
description,
|
||||||
|
source: root.source,
|
||||||
|
shadowed_by: None,
|
||||||
|
origin: root.origin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SkillOrigin::LegacyCommandsDir => {
|
||||||
|
let path = entry.path();
|
||||||
|
let markdown_path = if path.is_dir() {
|
||||||
|
let skill_path = path.join("SKILL.md");
|
||||||
|
if !skill_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
skill_path
|
||||||
|
} else if path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
|
||||||
|
{
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&markdown_path)?;
|
||||||
|
let fallback_name = markdown_path.file_stem().map_or_else(
|
||||||
|
|| entry.file_name().to_string_lossy().to_string(),
|
||||||
|
|stem| stem.to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
|
let (name, description) = parse_skill_frontmatter(&contents);
|
||||||
|
root_skills.push(SkillSummary {
|
||||||
|
name: name.unwrap_or(fallback_name),
|
||||||
|
description,
|
||||||
|
source: root.source,
|
||||||
|
shadowed_by: None,
|
||||||
|
origin: root.origin,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let skill_path = entry.path().join("SKILL.md");
|
|
||||||
if !skill_path.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let contents = fs::read_to_string(skill_path)?;
|
|
||||||
let (name, description) = parse_skill_frontmatter(&contents);
|
|
||||||
root_skills.push(SkillSummary {
|
|
||||||
name: name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
|
|
||||||
description,
|
|
||||||
source: *source,
|
|
||||||
shadowed_by: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
root_skills.sort_by(|left, right| left.name.cmp(&right.name));
|
root_skills.sort_by(|left, right| left.name.cmp(&right.name));
|
||||||
|
|
||||||
@@ -830,16 +984,16 @@ fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(value) = trimmed.strip_prefix("name:") {
|
if let Some(value) = trimmed.strip_prefix("name:") {
|
||||||
let value = value.trim();
|
let value = unquote_frontmatter_value(value.trim());
|
||||||
if !value.is_empty() {
|
if !value.is_empty() {
|
||||||
name = Some(value.to_string());
|
name = Some(value);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(value) = trimmed.strip_prefix("description:") {
|
if let Some(value) = trimmed.strip_prefix("description:") {
|
||||||
let value = value.trim();
|
let value = unquote_frontmatter_value(value.trim());
|
||||||
if !value.is_empty() {
|
if !value.is_empty() {
|
||||||
description = Some(value.to_string());
|
description = Some(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -847,6 +1001,20 @@ fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
|
|||||||
(name, description)
|
(name, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unquote_frontmatter_value(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|trimmed| trimmed.strip_suffix('"'))
|
||||||
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.strip_prefix('\'')
|
||||||
|
.and_then(|trimmed| trimmed.strip_suffix('\''))
|
||||||
|
})
|
||||||
|
.unwrap_or(value)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn render_agents_report(agents: &[AgentSummary]) -> String {
|
fn render_agents_report(agents: &[AgentSummary]) -> String {
|
||||||
if agents.is_empty() {
|
if agents.is_empty() {
|
||||||
return "No agents found.".to_string();
|
return "No agents found.".to_string();
|
||||||
@@ -937,10 +1105,14 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
|
|
||||||
lines.push(format!("{}:", source.label()));
|
lines.push(format!("{}:", source.label()));
|
||||||
for skill in group {
|
for skill in group {
|
||||||
let detail = match &skill.description {
|
let mut parts = vec![skill.name.clone()];
|
||||||
Some(description) => format!("{} · {}", skill.name, description),
|
if let Some(description) = &skill.description {
|
||||||
None => skill.name.clone(),
|
parts.push(description.clone());
|
||||||
};
|
}
|
||||||
|
if let Some(detail) = skill.origin.detail_label() {
|
||||||
|
parts.push(detail.to_string());
|
||||||
|
}
|
||||||
|
let detail = parts.join(" · ");
|
||||||
match skill.shadowed_by {
|
match skill.shadowed_by {
|
||||||
Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
|
Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
|
||||||
None => lines.push(format!(" {detail}")),
|
None => lines.push(format!(" {detail}")),
|
||||||
@@ -952,6 +1124,36 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
lines.join("\n").trim_end().to_string()
|
lines.join("\n").trim_end().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
||||||
|
args.map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_agents_usage(unexpected: Option<&str>) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"Agents".to_string(),
|
||||||
|
" Usage /agents".to_string(),
|
||||||
|
" Direct CLI claw agents".to_string(),
|
||||||
|
" Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(args) = unexpected {
|
||||||
|
lines.push(format!(" Unexpected {args}"));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"Skills".to_string(),
|
||||||
|
" Usage /skills".to_string(),
|
||||||
|
" Direct CLI claw skills".to_string(),
|
||||||
|
" Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(args) = unexpected {
|
||||||
|
lines.push(format!(" Unexpected {args}"));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn handle_slash_command(
|
pub fn handle_slash_command(
|
||||||
input: &str,
|
input: &str,
|
||||||
@@ -1011,7 +1213,7 @@ mod tests {
|
|||||||
handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
|
handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
|
||||||
load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
|
load_skills_from_roots, 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,
|
||||||
DefinitionSource, SlashCommand,
|
DefinitionSource, SkillOrigin, SkillRoot, 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};
|
||||||
@@ -1071,6 +1273,15 @@ mod tests {
|
|||||||
.expect("write skill");
|
.expect("write skill");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_legacy_command(root: &Path, name: &str, description: &str) {
|
||||||
|
fs::create_dir_all(root).expect("commands root");
|
||||||
|
fs::write(
|
||||||
|
root.join(format!("{name}.md")),
|
||||||
|
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
|
||||||
|
)
|
||||||
|
.expect("write command");
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_supported_slash_commands() {
|
fn parses_supported_slash_commands() {
|
||||||
@@ -1232,7 +1443,7 @@ mod tests {
|
|||||||
assert!(help.contains("/agents"));
|
assert!(help.contains("/agents"));
|
||||||
assert!(help.contains("/skills"));
|
assert!(help.contains("/skills"));
|
||||||
assert_eq!(slash_command_specs().len(), 25);
|
assert_eq!(slash_command_specs().len(), 25);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 11);
|
assert_eq!(resume_supported_slash_commands().len(), 13);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1425,24 +1636,41 @@ mod tests {
|
|||||||
fn lists_skills_from_project_and_user_roots() {
|
fn lists_skills_from_project_and_user_roots() {
|
||||||
let workspace = temp_dir("skills-workspace");
|
let workspace = temp_dir("skills-workspace");
|
||||||
let project_skills = workspace.join(".codex").join("skills");
|
let project_skills = workspace.join(".codex").join("skills");
|
||||||
|
let project_commands = workspace.join(".claude").join("commands");
|
||||||
let user_home = temp_dir("skills-home");
|
let user_home = temp_dir("skills-home");
|
||||||
let user_skills = user_home.join(".codex").join("skills");
|
let user_skills = user_home.join(".codex").join("skills");
|
||||||
|
|
||||||
write_skill(&project_skills, "plan", "Project planning guidance");
|
write_skill(&project_skills, "plan", "Project planning guidance");
|
||||||
|
write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
|
||||||
write_skill(&user_skills, "plan", "User planning guidance");
|
write_skill(&user_skills, "plan", "User planning guidance");
|
||||||
write_skill(&user_skills, "help", "Help guidance");
|
write_skill(&user_skills, "help", "Help guidance");
|
||||||
|
|
||||||
let roots = vec![
|
let roots = vec![
|
||||||
(DefinitionSource::ProjectCodex, project_skills),
|
SkillRoot {
|
||||||
(DefinitionSource::UserCodex, user_skills),
|
source: DefinitionSource::ProjectCodex,
|
||||||
|
path: project_skills,
|
||||||
|
origin: SkillOrigin::SkillsDir,
|
||||||
|
},
|
||||||
|
SkillRoot {
|
||||||
|
source: DefinitionSource::ProjectClaude,
|
||||||
|
path: project_commands,
|
||||||
|
origin: SkillOrigin::LegacyCommandsDir,
|
||||||
|
},
|
||||||
|
SkillRoot {
|
||||||
|
source: DefinitionSource::UserCodex,
|
||||||
|
path: user_skills,
|
||||||
|
origin: SkillOrigin::SkillsDir,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
let report =
|
let report =
|
||||||
render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
|
render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
|
||||||
|
|
||||||
assert!(report.contains("Skills"));
|
assert!(report.contains("Skills"));
|
||||||
assert!(report.contains("2 available skills"));
|
assert!(report.contains("3 available skills"));
|
||||||
assert!(report.contains("Project (.codex):"));
|
assert!(report.contains("Project (.codex):"));
|
||||||
assert!(report.contains("plan · Project planning guidance"));
|
assert!(report.contains("plan · Project planning guidance"));
|
||||||
|
assert!(report.contains("Project (.claude):"));
|
||||||
|
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"));
|
||||||
assert!(report.contains("help · Help guidance"));
|
assert!(report.contains("help · Help guidance"));
|
||||||
@@ -1451,6 +1679,39 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(user_home);
|
let _ = fs::remove_dir_all(user_home);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agents_and_skills_usage_support_help_and_unexpected_args() {
|
||||||
|
let cwd = temp_dir("slash-usage");
|
||||||
|
|
||||||
|
let agents_help =
|
||||||
|
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
|
||||||
|
assert!(agents_help.contains("Usage /agents"));
|
||||||
|
assert!(agents_help.contains("Direct CLI claw agents"));
|
||||||
|
|
||||||
|
let agents_unexpected =
|
||||||
|
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
||||||
|
assert!(agents_unexpected.contains("Unexpected show planner"));
|
||||||
|
|
||||||
|
let skills_help =
|
||||||
|
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
||||||
|
assert!(skills_help.contains("Usage /skills"));
|
||||||
|
assert!(skills_help.contains("legacy /commands"));
|
||||||
|
|
||||||
|
let skills_unexpected =
|
||||||
|
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
|
||||||
|
assert!(skills_unexpected.contains("Unexpected show help"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_quoted_skill_frontmatter_values() {
|
||||||
|
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
||||||
|
let (name, description) = super::parse_skill_frontmatter(contents);
|
||||||
|
assert_eq!(name.as_deref(), Some("hud"));
|
||||||
|
assert_eq!(description.as_deref(), Some("Quoted description"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn installs_plugin_from_path_and_lists_it() {
|
fn installs_plugin_from_path_and_lists_it() {
|
||||||
let config_home = temp_dir("home");
|
let config_home = temp_dir("home");
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
match parse_args(&args)? {
|
match parse_args(&args)? {
|
||||||
CliAction::DumpManifests => dump_manifests(),
|
CliAction::DumpManifests => dump_manifests(),
|
||||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||||
|
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
|
||||||
|
CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?,
|
||||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||||
CliAction::Version => print_version(),
|
CliAction::Version => print_version(),
|
||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
@@ -104,6 +106,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
enum CliAction {
|
enum CliAction {
|
||||||
DumpManifests,
|
DumpManifests,
|
||||||
BootstrapPlan,
|
BootstrapPlan,
|
||||||
|
Agents {
|
||||||
|
args: Option<String>,
|
||||||
|
},
|
||||||
|
Skills {
|
||||||
|
args: Option<String>,
|
||||||
|
},
|
||||||
PrintSystemPrompt {
|
PrintSystemPrompt {
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
date: String,
|
date: String,
|
||||||
@@ -267,6 +275,12 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
match rest[0].as_str() {
|
match rest[0].as_str() {
|
||||||
"dump-manifests" => Ok(CliAction::DumpManifests),
|
"dump-manifests" => Ok(CliAction::DumpManifests),
|
||||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
||||||
|
"agents" => Ok(CliAction::Agents {
|
||||||
|
args: join_optional_args(&rest[1..]),
|
||||||
|
}),
|
||||||
|
"skills" => Ok(CliAction::Skills {
|
||||||
|
args: join_optional_args(&rest[1..]),
|
||||||
|
}),
|
||||||
"system-prompt" => parse_system_prompt_args(&rest[1..]),
|
"system-prompt" => parse_system_prompt_args(&rest[1..]),
|
||||||
"login" => Ok(CliAction::Login),
|
"login" => Ok(CliAction::Login),
|
||||||
"logout" => Ok(CliAction::Logout),
|
"logout" => Ok(CliAction::Logout),
|
||||||
@@ -284,14 +298,37 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
permission_mode,
|
permission_mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
other if !other.starts_with('/') => Ok(CliAction::Prompt {
|
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
|
||||||
|
_other => Ok(CliAction::Prompt {
|
||||||
prompt: rest.join(" "),
|
prompt: rest.join(" "),
|
||||||
model,
|
model,
|
||||||
output_format,
|
output_format,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
}),
|
}),
|
||||||
other => Err(format!("unknown subcommand: {other}")),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_optional_args(args: &[String]) -> Option<String> {
|
||||||
|
let joined = args.join(" ");
|
||||||
|
let trimmed = joined.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
||||||
|
let raw = rest.join(" ");
|
||||||
|
match SlashCommand::parse(&raw) {
|
||||||
|
Some(SlashCommand::Help) => Ok(CliAction::Help),
|
||||||
|
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
|
||||||
|
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
||||||
|
Some(command) => Err(format!(
|
||||||
|
"unsupported direct slash command outside the REPL: {command_name}",
|
||||||
|
command_name = match command {
|
||||||
|
SlashCommand::Unknown(name) => format!("/{name}"),
|
||||||
|
_ => rest[0].clone(),
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
None => Err(format!("unknown subcommand: {}", rest[0])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,6 +928,20 @@ fn run_resume_command(
|
|||||||
)),
|
)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
SlashCommand::Agents { args } => {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
Ok(ResumeCommandOutcome {
|
||||||
|
session: session.clone(),
|
||||||
|
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
SlashCommand::Skills { args } => {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
Ok(ResumeCommandOutcome {
|
||||||
|
session: session.clone(),
|
||||||
|
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
|
||||||
|
})
|
||||||
|
}
|
||||||
SlashCommand::Bughunter { .. }
|
SlashCommand::Bughunter { .. }
|
||||||
| SlashCommand::Commit
|
| SlashCommand::Commit
|
||||||
| SlashCommand::Pr { .. }
|
| SlashCommand::Pr { .. }
|
||||||
@@ -903,8 +954,6 @@ fn run_resume_command(
|
|||||||
| SlashCommand::Permissions { .. }
|
| SlashCommand::Permissions { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
| SlashCommand::Plugins { .. }
|
| SlashCommand::Plugins { .. }
|
||||||
| SlashCommand::Agents { .. }
|
|
||||||
| SlashCommand::Skills { .. }
|
|
||||||
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3718,6 +3767,8 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
writeln!(out, " claw dump-manifests")?;
|
writeln!(out, " claw dump-manifests")?;
|
||||||
writeln!(out, " claw bootstrap-plan")?;
|
writeln!(out, " claw bootstrap-plan")?;
|
||||||
|
writeln!(out, " claw agents")?;
|
||||||
|
writeln!(out, " claw skills")?;
|
||||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
||||||
writeln!(out, " claw login")?;
|
writeln!(out, " claw login")?;
|
||||||
writeln!(out, " claw logout")?;
|
writeln!(out, " claw logout")?;
|
||||||
@@ -3772,6 +3823,8 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
out,
|
out,
|
||||||
" claw --resume session.json /status /diff /export notes.txt"
|
" claw --resume session.json /status /diff /export notes.txt"
|
||||||
)?;
|
)?;
|
||||||
|
writeln!(out, " claw agents")?;
|
||||||
|
writeln!(out, " claw /skills")?;
|
||||||
writeln!(out, " claw login")?;
|
writeln!(out, " claw login")?;
|
||||||
writeln!(out, " claw init")?;
|
writeln!(out, " claw init")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3992,6 +4045,43 @@ mod tests {
|
|||||||
parse_args(&["init".to_string()]).expect("init should parse"),
|
parse_args(&["init".to_string()]).expect("init should parse"),
|
||||||
CliAction::Init
|
CliAction::Init
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["agents".to_string()]).expect("agents should parse"),
|
||||||
|
CliAction::Agents { args: None }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["skills".to_string()]).expect("skills should parse"),
|
||||||
|
CliAction::Skills { args: None }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["agents".to_string(), "--help".to_string()])
|
||||||
|
.expect("agents help should parse"),
|
||||||
|
CliAction::Agents {
|
||||||
|
args: Some("--help".to_string())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_direct_agents_and_skills_slash_commands() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
|
||||||
|
CliAction::Agents { args: None }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["/skills".to_string()]).expect("/skills should parse"),
|
||||||
|
CliAction::Skills { args: None }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["/skills".to_string(), "help".to_string()])
|
||||||
|
.expect("/skills help should parse"),
|
||||||
|
CliAction::Skills {
|
||||||
|
args: Some("help".to_string())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let error = parse_args(&["/status".to_string()])
|
||||||
|
.expect_err("/status should remain REPL-only when invoked directly");
|
||||||
|
assert!(error.contains("unsupported direct slash command"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4108,7 +4198,7 @@ mod tests {
|
|||||||
names,
|
names,
|
||||||
vec![
|
vec![
|
||||||
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
|
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
|
||||||
"version", "export",
|
"version", "export", "agents", "skills",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4175,6 +4265,9 @@ mod tests {
|
|||||||
print_help_to(&mut help).expect("help should render");
|
print_help_to(&mut help).expect("help should render");
|
||||||
let help = String::from_utf8(help).expect("help should be utf8");
|
let help = String::from_utf8(help).expect("help should be utf8");
|
||||||
assert!(help.contains("claw init"));
|
assert!(help.contains("claw init"));
|
||||||
|
assert!(help.contains("claw agents"));
|
||||||
|
assert!(help.contains("claw skills"));
|
||||||
|
assert!(help.contains("claw /skills"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user