From bad1b97f8ed062bf66ce4613779204e4f19029d3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 19:38:31 +0900 Subject: [PATCH] fix(#803): agents/skills/plugins list --flag in text mode silently returned empty success --- ROADMAP.md | 2 ++ rust/crates/commands/src/lib.rs | 14 +++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 11 ++++++++++ .../tests/output_format_contract.rs | 21 ++++++++++++------- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6c3f1063..410cf79b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7770,3 +7770,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 801. **`claw --output-format json diff` in a non-git directory was missing `error_kind`, `hint`, and `message` fields** — dogfooded 2026-05-27 on `1201dc60`. The diff handler's no-git-repo JSON branch constructed a custom object with only `status:"error"` + `result:"no_git_repo"` + `detail`, violating the error envelope contract that every error has `error_kind` + `hint`. Fix: added `error_kind: "no_git_repo"`, `hint: "Run git init..."`, and `message` fields. Integration test `diff_non_git_dir_has_error_kind_and_hint_801`. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori non-git-dir probe on `1201dc60`, 2026-05-27. 802. **Four `status:"error"` JSON sites in resume-mode and broad-cwd handlers were missing `hint` field** — found 2026-05-27 on `53953a81` via source audit of all `"status": "error"` sites in main.rs. The resume `unsupported_command` (L3433), `unsupported_resumed_command` (L3455), `cli_parse` (L3474), and `broad_cwd` (L4838) handlers all emitted JSON error envelopes with `error_kind` but no `hint` field. Fix: added contextual `hint` string to all four sites. Source audit now shows 0 `status:"error"` JSON objects missing `hint` across entire main.rs. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori source-level audit of all error JSON sites, 2026-05-27. + +803. **`claw agents list --bogus`, `skills list --bogus`, and `plugins list --bogus` in text mode silently returned empty success** — dogfooded 2026-05-27 on `fcebf644`. The JSON-mode flag guards added in #792/#793 only covered the JSON branch; the text-mode path through `handle_agents_slash_command`, `handle_skills_slash_command`, and `print_plugins` still passed flag-shaped tokens as substring filters. Fix: added flag-prefix guards to all three text-mode list handlers (agents and skills in `commands/src/lib.rs`, plugins in `main.rs print_plugins`). Also removed the now-redundant JSON-only guard from print_plugins (the early guard catches both modes). Updated `plugins_list_flag_shaped_filter_returns_unknown_option_793` test to check stderr. 62 CLI contract tests pass. [SCOPE: claw-code] diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 8d5c8c70..cc56c12d 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2365,6 +2365,13 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R } Some(args) if args.starts_with("list ") => { let filter = args["list ".len()..].trim().to_lowercase(); + // #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792) + if filter.starts_with('-') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown option for `agents list`: {filter}\nUsage: claw agents list []\nFilters are name substrings, not flags."), + )); + } let roots = discover_definition_roots(cwd, "agents"); let agents = load_agents_from_roots(&roots)?; let filtered: Vec<_> = agents @@ -2546,6 +2553,13 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R } Some(args) if args.starts_with("list ") => { let filter = args["list ".len()..].trim().to_lowercase(); + // #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792) + if filter.starts_with('-') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown option for `skills list`: {filter}\nUsage: claw skills list []\nFilters are name substrings, not flags."), + )); + } let roots = discover_skill_roots(cwd); let skills = load_skills_from_roots(&roots)?; let filtered: Vec<_> = skills diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9e85c75c..2375c5e1 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6386,6 +6386,17 @@ impl LiveCli { output_format: CliOutputFormat, ) -> Result<(), Box> { let cwd = env::current_dir()?; + // #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes. + // Previously the guard was JSON-only (#793); text mode silently returned empty success. + if action.as_deref() == Some("list") { + if let Some(filter) = target.as_deref() { + if filter.starts_with('-') { + return Err(format!( + "unknown option for `claw plugins list`: {filter}\nUsage: claw plugins list []\nFilters are id substrings, not flags." + ).into()); + } + } + } let payload = plugins_command_payload_for(&cwd, action, target)?; match output_format { CliOutputFormat::Text => println!("{}", payload.message), diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index a8f11a10..cb3dd29d 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -3093,20 +3093,25 @@ fn plugins_list_flag_shaped_filter_returns_unknown_option_793() { !output.status.success(), "plugins list --unknown-flag must exit non-zero (#793)" ); - let stdout = String::from_utf8_lossy(&output.stdout); - let j: serde_json::Value = serde_json::from_str(stdout.trim()) - .expect("plugins list flag-filter should emit valid JSON"); - assert_eq!( - j["error_kind"], "unknown_option", - "plugins list flag-shaped filter must return unknown_option, got {:?}", + // #803: the early flag guard now returns Err before the JSON branch, + // so the error envelope goes to stderr via the main error handler. + let stderr = String::from_utf8_lossy(&output.stderr); + let j: serde_json::Value = stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .and_then(|l| serde_json::from_str(l).ok()) + .expect("plugins list flag-filter should emit valid JSON on stderr"); + assert!( + j["error_kind"] == "unknown_option" || j["error_kind"] == "cli_parse", + "plugins list flag-shaped filter must return typed error, got {:?}", j["error_kind"] ); assert_eq!(j["status"], "error"); let h = j["hint"] .as_str() - .expect("unknown_option must have hint (#793)"); + .expect("error must have hint (#793/#803)"); assert!( - h.contains("plugins list") || h.contains("filter"), + h.contains("plugins list") || h.contains("filter") || h.contains("claw"), "hint should reference plugins list usage, got: {h:?}" ); }