feat(commands): slash-command validation, help formatting, CLI wiring

- Add centralized validate_slash_command_input for all slash commands
- Rich error messages and per-command help detail
- Wire validation into CLI entrypoints in main.rs
- Consistent /agents and /skills usage surface
- Verified: cargo test -p commands 22 passed, integration test passed, clippy clean
This commit is contained in:
YeonGyu-Kim
2026-04-02 18:24:47 +09:00
parent 6e4b0123a6
commit d88144d4a5
2 changed files with 612 additions and 35 deletions

View File

@@ -22,7 +22,8 @@ use api::{
use commands::{
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
validate_slash_command_input, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
@@ -422,12 +423,12 @@ fn join_optional_args(args: &[String]) -> Option<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(SlashCommand::Unknown(name)) => Err(format_unknown_direct_slash_command(&name)),
Some(command) => Err({
match validate_slash_command_input(&raw) {
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }),
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
Ok(Some(command)) => Err({
let _ = command;
format!(
"slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.",
@@ -435,7 +436,8 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
latest = LATEST_SESSION_REFERENCE,
)
}),
None => Err(format!("unknown subcommand: {}", rest[0])),
Ok(None) => Err(format!("unknown subcommand: {}", rest[0])),
Err(error) => Err(error.to_string()),
}
}
@@ -896,9 +898,16 @@ fn resume_session(session_path: &Path, commands: &[String]) {
let mut session = session;
for raw_command in commands {
let Some(command) = SlashCommand::parse(raw_command) else {
eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2);
let command = match validate_slash_command_input(raw_command) {
Ok(Some(command)) => command,
Ok(None) => {
eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2);
}
Err(error) => {
eprintln!("{error}");
std::process::exit(2);
}
};
match run_resume_command(&resolved_path, &session, &command) {
Ok(ResumeCommandOutcome {
@@ -1417,11 +1426,18 @@ fn run_repl(
cli.persist_session()?;
break;
}
if let Some(command) = SlashCommand::parse(&trimmed) {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
match validate_slash_command_input(&trimmed) {
Ok(Some(command)) => {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
continue;
}
continue;
}
editor.push_history(input);
cli.run_turn(&trimmed)?;
@@ -5246,6 +5262,23 @@ mod tests {
assert!(error.contains("claw --resume SESSION.jsonl /status"));
}
#[test]
fn direct_slash_commands_surface_shared_validation_errors() {
let compact_error = parse_args(&["/compact".to_string(), "now".to_string()])
.expect_err("invalid /compact shape should be rejected");
assert!(compact_error.contains("Unexpected arguments for /compact."));
assert!(compact_error.contains("Usage /compact"));
let plugins_error = parse_args(&[
"/plugins".to_string(),
"list".to_string(),
"extra".to_string(),
])
.expect_err("invalid /plugins list shape should be rejected");
assert!(plugins_error.contains("Usage: /plugin list"));
assert!(plugins_error.contains("Aliases /plugins, /marketplace"));
}
#[test]
fn formats_unknown_slash_command_with_suggestions() {
let report = format_unknown_slash_command_message("stats");