Triple-C marketplace: agents, skills, commands, hooks and plugins for all or individual containers #65
@@ -5,6 +5,7 @@ import WorkspaceSection from "./config/WorkspaceSection";
|
|||||||
import ModelSection from "./config/ModelSection";
|
import ModelSection from "./config/ModelSection";
|
||||||
import AccessSection from "./config/AccessSection";
|
import AccessSection from "./config/AccessSection";
|
||||||
import RuntimeSection from "./config/RuntimeSection";
|
import RuntimeSection from "./config/RuntimeSection";
|
||||||
|
import MarketplaceSection from "./config/MarketplaceSection";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -52,6 +53,7 @@ export default function ConfigTab({ project, save, saveState }: Props) {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
disabledReason={STOPPED_ONLY}
|
disabledReason={STOPPED_ONLY}
|
||||||
/>
|
/>
|
||||||
|
<MarketplaceSection project={project} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { useAppState, MARKETPLACE_TAB_KEY } from "../../../../store/appState";
|
||||||
|
import type { AppSettings, Project } from "../../../../lib/types";
|
||||||
|
|
||||||
|
const setGlobalItemDisabled = vi.fn();
|
||||||
|
const getMarketplaceSyncReport = vi.fn();
|
||||||
|
vi.mock("../../../../lib/tauri-commands", () => ({
|
||||||
|
setGlobalItemDisabled: (...a: unknown[]) => setGlobalItemDisabled(...a),
|
||||||
|
getMarketplaceSyncReport: (id: string) => getMarketplaceSyncReport(id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let syncFinishedHandler: ((event: { payload: { project_id: string; report: unknown } }) => void) | null = null;
|
||||||
|
const listenMock = vi.fn(async (_name: string, cb: (event: { payload: { project_id: string; report: unknown } }) => void) => {
|
||||||
|
syncFinishedHandler = cb;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: (...a: Parameters<typeof listenMock>) => listenMock(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MarketplaceSection from "./MarketplaceSection";
|
||||||
|
|
||||||
|
const A = "a".repeat(40);
|
||||||
|
const project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "api",
|
||||||
|
status: "running",
|
||||||
|
marketplace_installs: [{ marketplace_id: "m1", kind: "command", key: "cmd", commit: A }],
|
||||||
|
marketplace_disabled: [{ marketplace_id: "m1", kind: "hook", key: "noisy" }],
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
describe("MarketplaceSection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
syncFinishedHandler = null;
|
||||||
|
useAppState.setState({
|
||||||
|
tabOrder: [],
|
||||||
|
activeTabKey: null,
|
||||||
|
projects: [project],
|
||||||
|
toasts: [],
|
||||||
|
appSettings: {
|
||||||
|
marketplaces: [{ id: "m1", name: "Starter", url: "https://x/y.git", branch: null, account_id: null }],
|
||||||
|
marketplace_accounts: [],
|
||||||
|
global_marketplace_installs: [
|
||||||
|
{ marketplace_id: "m1", kind: "agent", key: "rev", commit: A },
|
||||||
|
{ marketplace_id: "m1", kind: "hook", key: "noisy", commit: A },
|
||||||
|
],
|
||||||
|
} as unknown as AppSettings,
|
||||||
|
});
|
||||||
|
getMarketplaceSyncReport.mockResolvedValue({
|
||||||
|
installed: ["agent:rev"],
|
||||||
|
updated: [],
|
||||||
|
removed: [],
|
||||||
|
skipped: [{ item: "command:cmd", reason: "a file you created has the same name" }],
|
||||||
|
errors: [],
|
||||||
|
finished_at: "2026-09-27T12:00:00Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows effective items with their source and the opted-out global item", async () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
const rev = screen.getByTestId("mp-global-agent-rev");
|
||||||
|
expect(within(rev).getByRole("switch")).toBeChecked();
|
||||||
|
const noisy = screen.getByTestId("mp-global-hook-noisy");
|
||||||
|
expect(within(noisy).getByRole("switch")).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId("mp-project-command-cmd")).toHaveTextContent("This project only");
|
||||||
|
expect(await screen.findByText(/a file you created has the same name/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opts out of a global item", async () => {
|
||||||
|
setGlobalItemDisabled.mockResolvedValue({ ...project, marketplace_disabled: [] });
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
fireEvent.click(within(screen.getByTestId("mp-global-agent-rev")).getByRole("switch"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setGlobalItemDisabled).toHaveBeenCalledWith("p1", { marketplace_id: "m1", kind: "agent", key: "rev" }, true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the Marketplace filtered to this project", () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open in Marketplace" }));
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(MARKETPLACE_TAB_KEY);
|
||||||
|
expect(useAppState.getState().marketplaceFilterProjectId).toBe("p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches the sync report when marketplace-sync-finished fires for this project", async () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
await screen.findByText(/a file you created has the same name/);
|
||||||
|
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
getMarketplaceSyncReport.mockResolvedValue({
|
||||||
|
installed: [],
|
||||||
|
updated: [],
|
||||||
|
removed: [],
|
||||||
|
skipped: [],
|
||||||
|
errors: ["boom"],
|
||||||
|
finished_at: "2026-09-27T13:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(syncFinishedHandler).not.toBeNull();
|
||||||
|
syncFinishedHandler?.({ payload: { project_id: "p1", report: {} } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(2));
|
||||||
|
expect(await screen.findByText("boom")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores marketplace-sync-finished events for other projects", async () => {
|
||||||
|
render(<MarketplaceSection project={project} />);
|
||||||
|
await screen.findByText(/a file you created has the same name/);
|
||||||
|
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(syncFinishedHandler).not.toBeNull();
|
||||||
|
syncFinishedHandler?.({ payload: { project_id: "p2", report: {} } });
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(getMarketplaceSyncReport).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { ConfigGroup } from "../../../ui/Field";
|
||||||
|
import Toggle from "../../../ui/Toggle";
|
||||||
|
import Button from "../../../ui/Button";
|
||||||
|
import { useAppState } from "../../../../store/appState";
|
||||||
|
import { KIND_LABELS } from "../../../../lib/marketplace";
|
||||||
|
import { getMarketplaceSyncReport, setGlobalItemDisabled } from "../../../../lib/tauri-commands";
|
||||||
|
import type { MarketplaceItemRef, Project, SyncReport } from "../../../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncFinishedEvent {
|
||||||
|
project_id: string;
|
||||||
|
report: SyncReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindWord = (k: MarketplaceItemRef["kind"]) => KIND_LABELS[k].replace(/s$/, "").toLowerCase();
|
||||||
|
const same = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
|
||||||
|
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
|
||||||
|
|
||||||
|
export default function MarketplaceSection({ project }: Props) {
|
||||||
|
const appSettings = useAppState((s) => s.appSettings);
|
||||||
|
const openMarketplace = useAppState((s) => s.openMarketplace);
|
||||||
|
const updateProjectInList = useAppState((s) => s.updateProjectInList);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [report, setReport] = useState<SyncReport | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
|
||||||
|
const nameOf = (id: string) => appSettings?.marketplaces.find((m) => m.id === id)?.name ?? "removed marketplace";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchReport = () => {
|
||||||
|
getMarketplaceSyncReport(project.id)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setReport(r);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setReport(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReport();
|
||||||
|
|
||||||
|
// N7 (preflight): a sync also runs outside this component's own actions
|
||||||
|
// (container start, "Apply now" from the Marketplace tab), so without
|
||||||
|
// this the report shown here goes stale as soon as one finishes.
|
||||||
|
let unlisten: UnlistenFn | null = null;
|
||||||
|
listen<SyncFinishedEvent>("marketplace-sync-finished", (event) => {
|
||||||
|
if (event.payload.project_id === project.id) fetchReport();
|
||||||
|
})
|
||||||
|
.then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Not running inside Tauri (e.g. tests) — nothing to listen to.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [project.id, project.status]);
|
||||||
|
|
||||||
|
const toggleGlobal = async (ref: MarketplaceItemRef, enabled: boolean) => {
|
||||||
|
const id = `${ref.kind}-${ref.key}`;
|
||||||
|
setBusy(id);
|
||||||
|
try {
|
||||||
|
updateProjectInList(await setGlobalItemDisabled(project.id, ref, !enabled));
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: `Could not change ${ref.key} for “${project.name}”`, detail: String(e) });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigGroup
|
||||||
|
title="Marketplace"
|
||||||
|
description="Items this project gets from marketplaces. Changes apply on the next container start or with Apply now, in new Claude sessions."
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{globalInstalls.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">From “All projects”</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{globalInstalls.map((g) => {
|
||||||
|
const shadowed = project.marketplace_installs.some((p) => same(p, g));
|
||||||
|
const enabled = !project.marketplace_disabled.some((d) => same(d, g));
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${g.marketplace_id}/${g.kind}/${g.key}`}
|
||||||
|
data-testid={`mp-global-${g.kind}-${g.key}`}
|
||||||
|
className="flex items-center justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-medium">{g.key}</span>{" "}
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{kindWord(g.kind)} · {nameOf(g.marketplace_id)}
|
||||||
|
{shadowed ? " · overridden by this project's own install" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Toggle
|
||||||
|
label={`Use ${g.key} in ${project.name}`}
|
||||||
|
checked={enabled}
|
||||||
|
disabled={busy === `${g.kind}-${g.key}`}
|
||||||
|
onChange={(v) => void toggleGlobal({ marketplace_id: g.marketplace_id, kind: g.kind, key: g.key }, v)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.marketplace_installs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">This project only</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{project.marketplace_installs.map((i) => (
|
||||||
|
<li
|
||||||
|
key={`${i.marketplace_id}/${i.kind}/${i.key}`}
|
||||||
|
data-testid={`mp-project-${i.kind}-${i.key}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{i.key}</span>{" "}
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{kindWord(i.kind)} · {nameOf(i.marketplace_id)} · This project only
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{globalInstalls.length === 0 && project.marketplace_installs.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Nothing installed from a marketplace.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
Last sync {report.finished_at ? new Date(report.finished_at).toLocaleString() : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
{report.installed.length} installed · {report.updated.length} updated · {report.removed.length} removed
|
||||||
|
</p>
|
||||||
|
{report.skipped.map((s) => (
|
||||||
|
<p key={s.item} className="text-[var(--warning)]">
|
||||||
|
Skipped {s.item}: {s.reason}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{report.errors.map((e) => (
|
||||||
|
<p key={e} className="text-[var(--error)] whitespace-pre-wrap">
|
||||||
|
{e}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => openMarketplace(project.id)}>
|
||||||
|
Open in Marketplace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ConfigGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user