2 Commits

Author SHA1 Message Date
Yeachan-Heo
ec09efa81a 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
2026-04-01 08:30:02 +00:00
Yeachan-Heo
b402b1c6b6 Implement upstream slash command parity for plugin metadata surfaces
Wire the Rust slash-command surface to expose the upstream-style /plugin entry and add /agents and /skills handling. The plugin command keeps the existing management actions while help, completion, REPL dispatch, and tests now acknowledge the upstream aliases and inventory views.\n\nConstraint: Match original TypeScript command names without regressing existing /plugins management flows\nRejected: Add placeholder commands only | users would still lack practical slash-command output\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep /plugin as the canonical help entry while preserving /plugins and /marketplace aliases unless upstream naming changes again\nTested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace\nNot-tested: Manual interactive REPL execution of /agents and /skills against a live user configuration
2026-04-01 08:19:25 +00:00
2 changed files with 448 additions and 61 deletions

View File

@@ -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>> {
@@ -721,11 +840,10 @@ fn load_agents_from_roots(
continue; continue;
} }
let contents = fs::read_to_string(entry.path())?; let contents = fs::read_to_string(entry.path())?;
let fallback_name = entry let fallback_name = entry.path().file_stem().map_or_else(
.path() || entry.file_name().to_string_lossy().to_string(),
.file_stem() |stem| stem.to_string_lossy().to_string(),
.map(|stem| stem.to_string_lossy().to_string()) );
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
root_agents.push(AgentSummary { root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name), name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"), description: parse_toml_string(&contents, "description"),
@@ -751,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));
@@ -831,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);
} }
} }
} }
@@ -848,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();
@@ -938,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}")),
@@ -953,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,
@@ -1012,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};
@@ -1072,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() {
@@ -1227,10 +1437,13 @@ mod tests {
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
assert!(help.contains( assert!(help.contains(
"/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]" "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
)); ));
assert_eq!(slash_command_specs().len(), 23); assert!(help.contains("aliases: /plugins, /marketplace"));
assert_eq!(resume_supported_slash_commands().len(), 11); assert!(help.contains("/agents"));
assert!(help.contains("/skills"));
assert_eq!(slash_command_specs().len(), 25);
assert_eq!(resume_supported_slash_commands().len(), 13);
} }
#[test] #[test]
@@ -1423,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"));
@@ -1449,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");

View File

@@ -22,8 +22,8 @@ use api::{
}; };
use commands::{ use commands::{
handle_plugins_slash_command, render_slash_command_help, resume_supported_slash_commands, handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
slash_command_specs, SlashCommand, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo; use init::initialize_repo;
@@ -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 { .. }
@@ -1197,6 +1248,14 @@ impl LiveCli {
SlashCommand::Plugins { action, target } => { SlashCommand::Plugins { action, target } => {
self.handle_plugins_command(action.as_deref(), target.as_deref())? self.handle_plugins_command(action.as_deref(), target.as_deref())?
} }
SlashCommand::Agents { args } => {
Self::print_agents(args.as_deref())?;
false
}
SlashCommand::Skills { args } => {
Self::print_skills(args.as_deref())?;
false
}
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}"); eprintln!("unknown slash command: /{name}");
false false
@@ -1397,6 +1456,18 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn print_agents(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("{}", handle_agents_slash_command(args, &cwd)?);
Ok(())
}
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("{}", handle_skills_slash_command(args, &cwd)?);
Ok(())
}
fn print_diff() -> Result<(), Box<dyn std::error::Error>> { fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?); println!("{}", render_diff_report()?);
Ok(()) Ok(())
@@ -2734,6 +2805,7 @@ fn describe_tool_progress(name: &str, input: &str) -> String {
} }
#[allow(clippy::needless_pass_by_value)] #[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_arguments)]
fn build_runtime( fn build_runtime(
session: Session, session: Session,
model: String, model: String,
@@ -3058,7 +3130,12 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value
fn slash_command_completion_candidates() -> Vec<String> { fn slash_command_completion_candidates() -> Vec<String> {
slash_command_specs() slash_command_specs()
.iter() .iter()
.map(|spec| format!("/{}", spec.name)) .flat_map(|spec| {
std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(|name| format!("/{name}"))
.collect::<Vec<_>>()
})
.collect() .collect()
} }
@@ -3690,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")?;
@@ -3744,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(())
@@ -3964,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]
@@ -4062,8 +4180,11 @@ mod tests {
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
assert!(help.contains( assert!(help.contains(
"/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]" "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
)); ));
assert!(help.contains("aliases: /plugins, /marketplace"));
assert!(help.contains("/agents"));
assert!(help.contains("/skills"));
assert!(help.contains("/exit")); assert!(help.contains("/exit"));
} }
@@ -4077,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",
] ]
); );
} }
@@ -4144,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]