diff --git a/ROADMAP.md b/ROADMAP.md index a9f87c6..4f1e379 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5520,3 +5520,61 @@ Exit 0. Full envelope with error surfaced. **Future phase (joins #143 Phase 2).** When typed-error taxonomy lands (ยง4.44), promote `config_load_error` from string to typed object across `doctor`, `status`, and `mcp` in one pass. **Source.** Jobdori dogfood 2026-04-21 18:59 KST on main HEAD `e2a43fc`. Joins **partial-success** cluster (#143, Principle #5) and **surface consistency** cluster. Session tally: ROADMAP #144. + +## Pinpoint #145. `claw plugins` subcommand not wired to CLI parser โ€” word gets treated as a prompt, hits Anthropic API + +**Gap.** `claw plugins` (and `claw plugins list`, `claw plugins --help`, `claw plugins info `, etc.) fall through the top-level subcommand match and get routed into the prompt-execution path. Result: a purely local introspection command triggers an Anthropic API call and surfaces `missing Anthropic credentials` to the user. With valid credentials, it would actually send the string `"plugins"` as a prompt to Claude, burning tokens for a local query. + +**Verified on main HEAD `faeaa1d` (2026-04-21 19:32 KST):** + +``` +$ claw plugins +error: missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API + +$ claw plugins --output-format json +{"error":"missing Anthropic credentials; ...","type":"error"} + +$ claw plugins --help +error: missing Anthropic credentials; ... + +$ claw plugins list +error: missing Anthropic credentials; ... + +$ ANTHROPIC_API_KEY=dummy claw plugins +โ ‹ ๐Ÿฆ€ Thinking... +โœ˜ โŒ Request failed +error: api returned 401 Unauthorized (authentication_error) +``` + +Compare `agents`, `mcp`, `skills` โ€” all recognized, all local, all exit 0: + +``` +$ claw agents +No agents found. +$ claw mcp +MCP + Working directory ... + Configured servers 0 +``` + +**Root cause.** In `rusty-claude-cli/src/main.rs`, the top-level `match rest[0].as_str()` parser has arms for `agents`, `mcp`, `skills`, `status`, `doctor`, `init`, `export`, `prompt`, etc., but **no arm for `plugins`**. The `CliAction::Plugins` variant exists, has a dispatcher (`print_plugins`), and is produced by `SlashCommand::Plugins` inside the REPL โ€” but the top-level CLI path was never wired. Result: `plugins` matches neither a known subcommand nor a slash path, so it falls through to the default "run as prompt" behavior. + +**Why this is a clawability gap.** +1. **Prompt misdelivery (explicit Clawhip category)**: the command string is sent to the LLM instead of dispatched locally. Real risk: without the credentials guard, `claw plugins` would send `"plugins"` as a user prompt to Claude, burning tokens. +2. **Surface asymmetry**: `plugins` is the only diagnostic-adjacent command that isn't wired. Documentation, slash command, and dispatcher all exist; parser wiring was missed. +3. **`--help` should never hit the network**. Anywhere. +4. **Misleading error**: user running `claw plugins` sees an Anthropic credential error. No hint that `plugins` wasn't a recognized subcommand. + +**Fix shape (~20 lines).** Add a `"plugins"` arm to the top-level parser in `main.rs` that produces `CliAction::Plugins { action, target, output_format }`, following the same positional convention as `mcp` (`action` = first positional, `target` = second). The existing `CliAction::Plugins` handler (`LiveCli::print_plugins`) already covers text and JSON. + +**Acceptance.** +- `claw plugins` exits 0 with plugins list (empty in a clean workspace, which is the honest state). +- `claw plugins --output-format json` emits `{"kind":"plugin","action":"list",...}` with exit 0. +- `claw plugins list` exits 0 and matches `claw plugins`. +- `claw plugins info ` resolves through the existing handler. +- No Anthropic network call occurs for any `plugins` invocation. +- Regression test: parse `["claw", "plugins"]`, assert `CliAction::Plugins { action: None, target: None, .. }`. + +**Blocker.** None. `CliAction::Plugins` already exists with a working dispatcher. + +**Source.** Jobdori dogfood 2026-04-21 19:30 KST on main HEAD `faeaa1d` in response to Clawhip nudge. Joins **prompt misdelivery** cluster. Session tally: ROADMAP #145. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 49905fd..90f9c58 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -661,6 +661,30 @@ fn parse_args(args: &[String]) -> Result { args: join_optional_args(&rest[1..]), output_format, }), + // #145: `plugins` was routed through the prompt fallback because no + // top-level parser arm produced CliAction::Plugins. That made `claw + // plugins` (and `claw plugins --help`, `claw plugins list`, ...) + // attempt an Anthropic network call, surfacing the misleading error + // `missing Anthropic credentials` even though the command is purely + // local introspection. Mirror `agents`/`mcp`/`skills`: action is the + // first positional arg, target is the second. + "plugins" => { + let tail = &rest[1..]; + let action = tail.first().cloned(); + let target = tail.get(1).cloned(); + if tail.len() > 2 { + return Err(format!( + "unexpected extra arguments after `claw plugins {}`: {}", + tail[..2].join(" "), + tail[2..].join(" ") + )); + } + Ok(CliAction::Plugins { + action, + target, + output_format, + }) + } "skills" => { let args = join_optional_args(&rest[1..]); match classify_skills_slash_command(args.as_deref()) { @@ -9549,6 +9573,52 @@ mod tests { output_format: CliOutputFormat::Text, } ); + // #145: `plugins` must parse as CliAction::Plugins (not fall through + // to the prompt path, which would hit the Anthropic API for a purely + // local introspection command). + assert_eq!( + parse_args(&["plugins".to_string()]).expect("plugins should parse"), + CliAction::Plugins { + action: None, + target: None, + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["plugins".to_string(), "list".to_string()]) + .expect("plugins list should parse"), + CliAction::Plugins { + action: Some("list".to_string()), + target: None, + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&[ + "plugins".to_string(), + "enable".to_string(), + "example-bundled".to_string(), + ]) + .expect("plugins enable should parse"), + CliAction::Plugins { + action: Some("enable".to_string()), + target: Some("example-bundled".to_string()), + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&[ + "plugins".to_string(), + "--output-format".to_string(), + "json".to_string(), + ]) + .expect("plugins --output-format json should parse"), + CliAction::Plugins { + action: None, + target: None, + output_format: CliOutputFormat::Json, + } + ); } #[test]