feat: #145 — wire claw plugins subcommand to CLI parser (prompt misdelivery fix)

## Problem

`claw plugins` (and `claw plugins list`, `claw plugins --help`,
`claw plugins info <name>`, etc.) fell through the top-level subcommand
match and got routed into the prompt-execution path. Result: a purely
local introspection command triggered an Anthropic API call and surfaced
`missing Anthropic credentials` to the user. With valid credentials, it
would actually send the literal string "plugins" as a user prompt to
Claude, burning tokens for a local query.

$ claw plugins
error: missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API

$ ANTHROPIC_API_KEY=dummy claw plugins
⠋ 🦀 Thinking...
✘  Request failed
error: api returned 401 Unauthorized

Meanwhile siblings (`agents`, `mcp`, `skills`) all worked correctly:

$ claw agents
No agents found.
$ claw mcp
MCP
  Working directory ...
  Configured servers 0

## Root cause

`CliAction::Plugins` exists, has a working dispatcher
(`LiveCli::print_plugins`), and is produced inside the REPL via
`SlashCommand::Plugins`. But the top-level CLI parser in
`parse_subcommand()` had arms for `agents`, `mcp`, `skills`, `status`,
`doctor`, `init`, `export`, `prompt`, etc., and **no arm for
`plugins`**. The dispatch never ran from the CLI entry point.

## Changes

### rust/crates/rusty-claude-cli/src/main.rs

Added a `"plugins"` arm to the top-level match in `parse_subcommand()`
that produces `CliAction::Plugins { action, target, output_format }`,
following the same positional convention as `mcp` (`action` = first
positional, `target` = second). Rejects >2 positional args with a clear
error.

Added four regression assertions in the existing `parse_args` test:
- `plugins` alone → `CliAction::Plugins { action: None, target: None }`
- `plugins list` → action: Some("list"), target: None
- `plugins enable <name>` → action: Some("enable"), target: Some(...)
- `plugins --output-format json` → action: None, output_format: Json

### ROADMAP.md

Added Pinpoint #145 documenting the gap, verification, root cause,
fix shape, and acceptance.

## Live verification

$ claw plugins   # no credentials set
Plugins
  example-bundled      v0.1.0      disabled
  sample-hooks         v0.1.0      disabled

$ claw plugins --output-format json   # no credentials set
{
  "action": "list",
  "kind": "plugin",
  "message": "Plugins\n  example-bundled ...\n  sample-hooks ...",
  "reload_runtime": false,
  "target": null
}

Exit 0 in all modes. No network call. No "missing credentials" error.

## Tests

- rusty-claude-cli bin: 177 tests pass (new plugin assertions included)
- Full workspace green except pre-existing resume_latest flake (unrelated)

Closes ROADMAP #145.
This commit is contained in:
YeonGyu-Kim
2026-04-21 19:36:49 +09:00
parent faeaa1d30c
commit 7d63699f9f
2 changed files with 128 additions and 0 deletions

View File

@@ -661,6 +661,30 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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 <target> 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]