Skip to content

Commit 4d67959

Browse files
Merge remote-tracking branch 'upstream/main' into dev
2 parents 6372965 + ab44985 commit 4d67959

5 files changed

Lines changed: 465 additions & 16 deletions

File tree

rust/crates/commands/src/lib.rs

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2371,6 +2371,40 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
23712371
let skills = load_skills_from_roots(&roots)?;
23722372
Ok(render_skills_report(&skills))
23732373
}
2374+
Some(args) if args.starts_with("list ") => {
2375+
let filter = args["list ".len()..].trim().to_lowercase();
2376+
let roots = discover_skill_roots(cwd);
2377+
let skills = load_skills_from_roots(&roots)?;
2378+
let filtered: Vec<_> = skills
2379+
.into_iter()
2380+
.filter(|s| s.name.to_lowercase().contains(&filter))
2381+
.collect();
2382+
Ok(render_skills_report(&filtered))
2383+
}
2384+
Some("show" | "info" | "describe") => {
2385+
let roots = discover_skill_roots(cwd);
2386+
let skills = load_skills_from_roots(&roots)?;
2387+
Ok(render_skills_report(&skills))
2388+
}
2389+
Some(args)
2390+
if args.starts_with("show ")
2391+
|| args.starts_with("info ")
2392+
|| args.starts_with("describe ") =>
2393+
{
2394+
let name = args
2395+
.splitn(2, ' ')
2396+
.nth(1)
2397+
.unwrap_or_default()
2398+
.trim()
2399+
.to_lowercase();
2400+
let roots = discover_skill_roots(cwd);
2401+
let skills = load_skills_from_roots(&roots)?;
2402+
let matched: Vec<_> = skills
2403+
.into_iter()
2404+
.filter(|s| s.name.to_lowercase() == name)
2405+
.collect();
2406+
Ok(render_skills_report(&matched))
2407+
}
23742408
Some("install") => Ok(render_skills_usage(Some("install"))),
23752409
Some(args) if args.starts_with("install ") => {
23762410
let target = args["install ".len()..].trim();
@@ -2402,6 +2436,40 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
24022436
let skills = load_skills_from_roots(&roots)?;
24032437
Ok(render_skills_report_json(&skills))
24042438
}
2439+
Some(args) if args.starts_with("list ") => {
2440+
let filter = args["list ".len()..].trim().to_lowercase();
2441+
let roots = discover_skill_roots(cwd);
2442+
let skills = load_skills_from_roots(&roots)?;
2443+
let filtered: Vec<_> = skills
2444+
.into_iter()
2445+
.filter(|s| s.name.to_lowercase().contains(&filter))
2446+
.collect();
2447+
Ok(render_skills_report_json(&filtered))
2448+
}
2449+
Some("show" | "info" | "describe") => {
2450+
let roots = discover_skill_roots(cwd);
2451+
let skills = load_skills_from_roots(&roots)?;
2452+
Ok(render_skills_report_json(&skills))
2453+
}
2454+
Some(args)
2455+
if args.starts_with("show ")
2456+
|| args.starts_with("info ")
2457+
|| args.starts_with("describe ") =>
2458+
{
2459+
let name = args
2460+
.splitn(2, ' ')
2461+
.nth(1)
2462+
.unwrap_or_default()
2463+
.trim()
2464+
.to_lowercase();
2465+
let roots = discover_skill_roots(cwd);
2466+
let skills = load_skills_from_roots(&roots)?;
2467+
let matched: Vec<_> = skills
2468+
.into_iter()
2469+
.filter(|s| s.name.to_lowercase() == name)
2470+
.collect();
2471+
Ok(render_skills_report_json(&matched))
2472+
}
24052473
Some("install") => Ok(render_skills_usage_json(Some("install"))),
24062474
Some(args) if args.starts_with("install ") => {
24072475
let target = args["install ".len()..].trim();
@@ -2419,10 +2487,20 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
24192487
#[must_use]
24202488
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
24212489
match normalize_optional_args(args) {
2422-
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
2490+
None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => {
2491+
SkillSlashDispatch::Local
2492+
}
24232493
Some(args) if args == "install" || args.starts_with("install ") => {
24242494
SkillSlashDispatch::Local
24252495
}
2496+
Some(args)
2497+
if args.starts_with("list ")
2498+
|| args.starts_with("show ")
2499+
|| args.starts_with("info ")
2500+
|| args.starts_with("describe ") =>
2501+
{
2502+
SkillSlashDispatch::Local
2503+
}
24262504
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
24272505
}
24282506
}
@@ -2596,10 +2674,44 @@ fn render_mcp_report_for(
25962674
)),
25972675
}
25982676
}
2677+
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
2678+
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
2679+
Ok(render_mcp_unsupported_action_text(
2680+
args,
2681+
"list accepts no filter argument; use `claw mcp list`",
2682+
))
2683+
}
2684+
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
2685+
Ok(render_mcp_unsupported_action_text(
2686+
args,
2687+
"use `claw mcp show <server>` to inspect a server",
2688+
))
2689+
}
25992690
Some(args) => Ok(render_mcp_usage(Some(args))),
26002691
}
26012692
}
26022693

2694+
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
2695+
format!(
2696+
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
2697+
)
2698+
}
2699+
2700+
fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
2701+
json!({
2702+
"kind": "mcp",
2703+
"action": "error",
2704+
"ok": false,
2705+
"error_kind": "unsupported_action",
2706+
"requested_action": action,
2707+
"hint": hint,
2708+
"usage": {
2709+
"slash_command": "/mcp [list|show <server>|help]",
2710+
"direct_cli": "claw mcp [list|show <server>|help]",
2711+
},
2712+
})
2713+
}
2714+
26032715
fn render_mcp_report_json_for(
26042716
loader: &ConfigLoader,
26052717
cwd: &Path,
@@ -2680,6 +2792,18 @@ fn render_mcp_report_json_for(
26802792
})),
26812793
}
26822794
}
2795+
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
2796+
Ok(render_mcp_unsupported_action_json(
2797+
args,
2798+
"list accepts no filter argument; use `claw mcp list`",
2799+
))
2800+
}
2801+
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
2802+
Ok(render_mcp_unsupported_action_json(
2803+
args,
2804+
"use `claw mcp show <server>` to inspect a server",
2805+
))
2806+
}
26832807
Some(args) => Ok(render_mcp_usage_json(Some(args))),
26842808
}
26852809
}
@@ -4619,6 +4743,32 @@ mod tests {
46194743
assert!(agents_error.contains(" Usage /agents [list|help]"));
46204744
}
46214745

4746+
#[test]
4747+
fn skills_show_and_list_filter_do_not_invoke_model() {
4748+
// `show`, `info`, `list <filter>` must route to Local, not Invoke.
4749+
// Regression for: `claw skills show plan` unexpectedly spawned a model session.
4750+
for token in &["show", "info", "describe"] {
4751+
assert_eq!(
4752+
classify_skills_slash_command(Some(token)),
4753+
SkillSlashDispatch::Local,
4754+
"`skills {token}` alone must be Local"
4755+
);
4756+
}
4757+
for prefix in &["show ", "info ", "list ", "describe "] {
4758+
let arg = format!("{prefix}plan");
4759+
assert_eq!(
4760+
classify_skills_slash_command(Some(&arg)),
4761+
SkillSlashDispatch::Local,
4762+
"`skills {arg}` must be Local, not Invoke"
4763+
);
4764+
}
4765+
// Bare invocable tokens still dispatch to Invoke.
4766+
assert_eq!(
4767+
classify_skills_slash_command(Some("plan")),
4768+
SkillSlashDispatch::Invoke("$plan".to_string()),
4769+
);
4770+
}
4771+
46224772
#[test]
46234773
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
46244774
assert_eq!(
@@ -4641,6 +4791,38 @@ mod tests {
46414791
);
46424792
}
46434793

4794+
#[test]
4795+
fn mcp_unsupported_actions_return_typed_error_not_generic_help() {
4796+
// `mcp info <name>` and `mcp list <filter>` must return typed errors, not raw help.
4797+
// Regression for #504: these previously fell through to render_mcp_usage with
4798+
// unexpected=arg, giving no machine-readable error_kind.
4799+
use crate::handle_mcp_slash_command_json;
4800+
use std::path::PathBuf;
4801+
let cwd = PathBuf::from("/tmp");
4802+
4803+
let info_json = handle_mcp_slash_command_json(Some("info nonexistent"), &cwd)
4804+
.expect("info nonexistent should not error at IO level");
4805+
assert_eq!(info_json["kind"], "mcp");
4806+
assert_eq!(info_json["ok"], false);
4807+
assert_eq!(info_json["error_kind"], "unsupported_action");
4808+
assert!(info_json["hint"]
4809+
.as_str()
4810+
.unwrap_or_default()
4811+
.contains("show"));
4812+
4813+
let list_filter_json = handle_mcp_slash_command_json(Some("list nonexistent"), &cwd)
4814+
.expect("list nonexistent should not error at IO level");
4815+
assert_eq!(list_filter_json["kind"], "mcp");
4816+
assert_eq!(list_filter_json["ok"], false);
4817+
assert_eq!(list_filter_json["error_kind"], "unsupported_action");
4818+
4819+
let describe_json = handle_mcp_slash_command_json(Some("describe myserver"), &cwd)
4820+
.expect("describe myserver should not error at IO level");
4821+
assert_eq!(describe_json["kind"], "mcp");
4822+
assert_eq!(describe_json["ok"], false);
4823+
assert_eq!(describe_json["error_kind"], "unsupported_action");
4824+
}
4825+
46444826
#[test]
46454827
fn rejects_invalid_mcp_arguments() {
46464828
let show_error = parse_error_message("/mcp show alpha beta");

0 commit comments

Comments
 (0)