Triple-C marketplace: agents, skills, commands, hooks and plugins for all or individual containers (#65)
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m55s
Build App / build-windows (push) Successful in 7m11s
Build App / build-linux (push) Successful in 9m53s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 56s

This commit was merged in pull request #65.
This commit is contained in:
2026-09-27 23:43:27 +00:00
parent 292fc907fb
commit ee1bb691e6
91 changed files with 27575 additions and 69 deletions
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import {
effectiveInstalls,
formatItemRef,
isStale,
itemRefKey,
projectItemState,
STALE_AFTER_MS,
} from "./marketplace";
import type { MarketplaceInstall, MarketplaceSnapshot, Project } from "./types";
const A = "a".repeat(40);
const B = "b".repeat(40);
const inst = (key: string, commit = A, kind: MarketplaceInstall["kind"] = "agent"): MarketplaceInstall => ({
marketplace_id: "m1",
kind,
key,
commit,
});
const project = (patch: Partial<Project> = {}): Project =>
({
id: "p1",
name: "api",
marketplace_installs: [],
marketplace_disabled: [],
...patch,
}) as unknown as Project;
describe("itemRefKey / formatItemRef", () => {
it("keys and formats a ref", () => {
const r = { marketplace_id: "m1", kind: "hook" as const, key: "notify" };
expect(itemRefKey(r)).toBe("m1/hook/notify");
expect(formatItemRef(r)).toBe("hook:notify");
});
});
describe("projectItemState", () => {
const ref = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
it("is none when nothing installs it", () => {
expect(projectItemState(ref, [], project())).toBe("none");
});
it("is inherited from a global install", () => {
expect(projectItemState(ref, [inst("rev")], project())).toBe("inherited");
});
it("is opted_out when the project disabled the global install", () => {
const p = project({ marketplace_disabled: [ref] });
expect(projectItemState(ref, [inst("rev")], p)).toBe("opted_out");
});
it("is project for a project-only install", () => {
const p = project({ marketplace_installs: [inst("rev")] });
expect(projectItemState(ref, [], p)).toBe("project");
});
it("is project when project and global share the pin", () => {
const p = project({ marketplace_installs: [inst("rev", A)] });
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project");
});
it("flags a project pin that differs from the global pin", () => {
const p = project({ marketplace_installs: [inst("rev", B)] });
expect(projectItemState(ref, [inst("rev", A)], p)).toBe("project_pinned_differently");
});
it("does not confuse kinds with the same key", () => {
expect(projectItemState(ref, [inst("rev", A, "skill")], project())).toBe("none");
});
});
describe("effectiveInstalls", () => {
it("merges global minus disabled plus project, project winning", () => {
const disabledRef = { marketplace_id: "m1", kind: "agent" as const, key: "off" };
const p = project({
marketplace_disabled: [disabledRef],
marketplace_installs: [inst("both", B), inst("mine")],
});
const out = effectiveInstalls([inst("glob"), inst("off"), inst("both", A)], p);
expect(out.map((i) => [i.key, i.commit, i.source])).toEqual([
["both", B, "project"],
["glob", A, "global"],
["mine", A, "project"],
]);
});
});
describe("isStale", () => {
const snap = (fetched_at: string | null): MarketplaceSnapshot => ({
marketplace_id: "m1",
head_commit: null,
fetched_at,
fetch_error: null,
items: [],
});
const now = Date.parse("2026-09-27T12:00:00Z");
it("treats a never-fetched snapshot as stale", () => {
expect(isStale(snap(null), now)).toBe(true);
});
it("is fresh within 15 minutes and stale after", () => {
expect(isStale(snap(new Date(now - STALE_AFTER_MS + 1000).toISOString()), now)).toBe(false);
expect(isStale(snap(new Date(now - STALE_AFTER_MS - 1000).toISOString()), now)).toBe(true);
});
it("treats an unparsable timestamp as stale", () => {
expect(isStale(snap("not a date"), now)).toBe(true);
});
});
+79
View File
@@ -0,0 +1,79 @@
import type {
ItemKind,
ItemUpdate,
MarketplaceInstall,
MarketplaceItemRef,
MarketplaceSnapshot,
Project,
} from "./types";
/** How a project relates to one marketplace item. */
export type ProjectItemState =
| "none"
| "inherited"
| "opted_out"
| "project"
| "project_pinned_differently";
export const KIND_ORDER: ItemKind[] = ["agent", "skill", "command", "hook", "plugin"];
export const KIND_LABELS: Record<ItemKind, string> = {
agent: "Agents",
skill: "Skills",
command: "Commands",
hook: "Hooks",
plugin: "Plugins",
};
/** A marketplace is refreshed when its tab opens if the last fetch is older than this. */
export const STALE_AFTER_MS = 15 * 60 * 1000;
/** Updates that can actually be applied (not refused as invalid at head). */
export const applicableUpdates = (updates: ItemUpdate[]) => updates.filter((u) => u.invalid_at_head === null);
export const itemRefKey = (r: MarketplaceItemRef) => `${r.marketplace_id}/${r.kind}/${r.key}`;
/** Same shape as the item strings in a `SyncReport`. */
export const formatItemRef = (r: MarketplaceItemRef) => `${r.kind}:${r.key}`;
const sameItem = (a: MarketplaceItemRef, b: MarketplaceItemRef) =>
a.marketplace_id === b.marketplace_id && a.kind === b.kind && a.key === b.key;
export function projectItemState(
item: MarketplaceItemRef,
globalInstalls: MarketplaceInstall[],
project: Project,
): ProjectItemState {
const own = project.marketplace_installs.find((i) => sameItem(i, item));
const global = globalInstalls.find((i) => sameItem(i, item));
if (own) {
return global && global.commit !== own.commit ? "project_pinned_differently" : "project";
}
if (!global) return "none";
return project.marketplace_disabled.some((d) => sameItem(d, item)) ? "opted_out" : "inherited";
}
/** Mirror of the backend's `effective_installs`, tagged with where each install comes from. */
export function effectiveInstalls(
globalInstalls: MarketplaceInstall[],
project: Project,
): (MarketplaceInstall & { source: "global" | "project" })[] {
const byKey = new Map<string, MarketplaceInstall & { source: "global" | "project" }>();
for (const g of globalInstalls) {
if (project.marketplace_disabled.some((d) => sameItem(d, g))) continue;
byKey.set(itemRefKey(g), { ...g, source: "global" });
}
for (const p of project.marketplace_installs) {
byKey.set(itemRefKey(p), { ...p, source: "project" });
}
return [...byKey.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([, v]) => v);
}
export function isStale(snapshot: MarketplaceSnapshot, now: number): boolean {
if (!snapshot.fetched_at) return true;
const at = Date.parse(snapshot.fetched_at);
if (Number.isNaN(at)) return true;
return now - at > STALE_AFTER_MS;
}
+29
View File
@@ -20,6 +20,10 @@ function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImport
gateway_api_base: null,
image_source: "registry",
custom_image_name: null,
marketplace_count: 0,
global_hook_install_count: 0,
global_plugin_install_count: 0,
marketplace_account_token_count: 0,
...overrides,
};
}
@@ -79,6 +83,13 @@ describe("describeImport", () => {
expect(items.some((i) => i.includes("OpenAI-compatible"))).toBe(false);
});
it("names marketplaces and marketplace account tokens, with counts", () => {
const items = describeImport(preview({ marketplace_count: 1, marketplace_account_token_count: 2 }));
expect(items).toContain("1 marketplace");
expect(items).toContain("2 marketplace account tokens");
expect(describeImport(preview()).some((i) => i.includes("marketplace"))).toBe(false);
});
it("names a custom Docker image when set, falling back to a placeholder if unnamed", () => {
expect(
describeImport(preview({ image_source: "custom", custom_image_name: "ghcr.io/me/triple-c" })),
@@ -116,6 +127,24 @@ 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 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\./,
);
});
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, 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 })),
).toHaveLength(2);
});
it("warns about a custom Docker image every time, not only when it changes", () => {
expect(
describeImportWarnings(preview({ image_source: "custom", custom_image_name: "evil:latest" })),
+24
View File
@@ -28,6 +28,13 @@ export function describeImport(preview: SettingsImportPreview): string[] {
if (preview.image_source === "custom") {
items.push(`Docker image: ${preview.custom_image_name ?? "(no image name set)"}`);
}
if (preview.marketplace_count > 0) {
items.push(`${preview.marketplace_count} marketplace${preview.marketplace_count === 1 ? "" : "s"}`);
}
if (preview.marketplace_account_token_count > 0) {
const n = preview.marketplace_account_token_count;
items.push(`${n} marketplace account token${n === 1 ? "" : "s"}`);
}
return items;
}
@@ -45,6 +52,11 @@ export function describeImport(preview: SettingsImportPreview): string[] {
* through the UI, with no import-time signal that it wasn't freshly
* generated.
*
* Global marketplace hooks get one too: a hook runs commands in every
* project container, and an imported install never passed the hook-confirm
* step an install from the Marketplace tab shows. Global plugins likewise:
* a plugin can carry its own hooks and MCP servers.
*
* A custom Docker image gets a warning every time, not just on change: it's
* the image every project container is created from, so it's worth calling
* out regardless of what was configured before the import.
@@ -58,6 +70,18 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
);
}
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 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, 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") {
warnings.push(
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
+55 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState } from "./types";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -432,3 +432,57 @@ export const viewerWriteFile = (contentsBase64: string, baseHash: string) =>
invoke<ViewerSaved>("viewer_write_file", { contentsBase64, baseHash });
export const viewerChooseFile = (index: number) =>
invoke<ViewerState>("viewer_choose_file", { index });
// ---- Marketplace ----
export const listMarketplaceSnapshots = () =>
invoke<MarketplaceSnapshot[]>("list_marketplace_snapshots");
export const refreshMarketplaces = (marketplaceId?: string) =>
invoke<MarketplaceSnapshot[]>("refresh_marketplaces", { marketplaceId: marketplaceId ?? null });
export const addMarketplace = (
name: string,
url: string,
branch: string | null,
accountId: string | null,
) => invoke<MarketplaceSnapshot>("add_marketplace", { name, url, branch, accountId });
export const updateMarketplace = (marketplace: Marketplace) =>
invoke<AppSettings>("update_marketplace", { marketplace });
export const removeMarketplace = (marketplaceId: string) =>
invoke<AppSettings>("remove_marketplace", { marketplaceId });
/** `expectedCommit`: the head the user reviewed; the backend refuses if it moved. */
export const installMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
invoke<AppSettings>("install_marketplace_item", { item, scope, expectedCommit });
export const uninstallMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope) =>
invoke<void>("uninstall_marketplace_item", { item, scope });
export const setGlobalItemDisabled = (
projectId: string,
item: MarketplaceItemRef,
disabled: boolean,
) => invoke<Project>("set_global_item_disabled", { projectId, item, disabled });
export const forgetMarketplaceInstalls = (marketplaceId: string) =>
invoke<void>("forget_marketplace_installs", { marketplaceId });
export const listMarketplaceUpdates = () => invoke<ItemUpdate[]>("list_marketplace_updates");
export const marketplaceItemDiff = (
item: MarketplaceItemRef,
fromCommit: string,
toCommit: string,
) => invoke<FileDiff[]>("marketplace_item_diff", { item, fromCommit, toCommit });
/** `expectedCommit`: the head whose diff the user accepted; the backend refuses if it moved. */
export const updateMarketplaceItem = (item: MarketplaceItemRef, scope: InstallScope, expectedCommit: string) =>
invoke<void>("update_marketplace_item", { item, scope, expectedCommit });
export const applyMarketplaceNow = (projectId?: string) =>
invoke<ProjectSyncResult[]>("apply_marketplace_now", { projectId: projectId ?? null });
export const getMarketplaceSyncReport = (projectId: string) =>
invoke<SyncReport | null>("get_marketplace_sync_report", { projectId });
export const addMarketplaceTokenAccount = (label: string, host: string, token: string) =>
invoke<MarketplaceAccount>("add_marketplace_token_account", { label, host, token });
export const addMarketplaceGhHostAccount = (label: string, host: string) =>
invoke<MarketplaceAccount>("add_marketplace_gh_host_account", { label, host });
export const startMarketplaceGhContainerLogin = (label: string, host: string, projectId: string) =>
invoke<MarketplaceAccount>("start_marketplace_gh_container_login", { label, host, projectId });
export const cancelMarketplaceGhLogin = () => invoke<void>("cancel_marketplace_gh_login");
export const testMarketplaceAccount = (accountId: string) =>
invoke<string>("test_marketplace_account", { accountId });
export const removeMarketplaceAccount = (accountId: string) =>
invoke<AppSettings>("remove_marketplace_account", { accountId });
export const marketplaceGhHostAvailable = () => invoke<boolean>("marketplace_gh_host_available");
+97
View File
@@ -66,6 +66,8 @@ export interface Project {
claude_instructions: string | null;
claude_code_settings: ClaudeCodeSettings | null;
renamed_session_names: Record<string, string>;
marketplace_installs: MarketplaceInstall[];
marketplace_disabled: MarketplaceItemRef[];
created_at: string;
updated_at: string;
}
@@ -296,6 +298,90 @@ export interface AppSettings {
* canvas renderer it would otherwise fall back to. See
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
terminal_gpu_rendering: boolean | null;
marketplace_accounts: MarketplaceAccount[];
marketplaces: Marketplace[];
global_marketplace_installs: MarketplaceInstall[];
}
// ── Marketplace (mirrors src-tauri/src/models/marketplace.rs) ───────────────
export type ItemKind = "agent" | "skill" | "command" | "hook" | "plugin";
export type AccountMethod = "gh_host" | "gh_container" | "token";
export interface MarketplaceAccount {
id: string;
label: string;
host: string;
method: AccountMethod;
username: string | null;
}
export interface Marketplace {
id: string;
name: string;
url: string;
branch: string | null;
account_id: string | null;
}
export interface MarketplaceItemRef {
marketplace_id: string;
kind: ItemKind;
key: string;
}
export interface MarketplaceInstall extends MarketplaceItemRef {
commit: string;
}
export interface CatalogItem {
kind: ItemKind;
key: string;
name: string;
description: string;
path: string;
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;
head_commit: string | null;
fetched_at: string | null;
fetch_error: string | null;
items: CatalogItem[];
}
export interface ItemUpdate {
item: MarketplaceItemRef;
pinned: string;
head: string;
/** Why the item cannot be installed at `head`, so the update would be refused; null when it applies. */
invalid_at_head: string | null;
}
export type FileChange = "added" | "removed" | "modified";
export interface FileDiff {
path: string;
change: FileChange;
unified: string | null;
}
export interface SkippedItem {
item: string;
reason: string;
}
export interface SyncReport {
installed: string[];
updated: string[];
removed: string[];
skipped: SkippedItem[];
errors: string[];
finished_at: string;
}
export type InstallScope = { type: "global" } | { type: "project"; project_id: string };
export interface ProjectSyncResult {
project_id: string;
report: SyncReport;
}
/** What `preview_settings_import` returns before anything is applied —
@@ -327,6 +413,17 @@ export interface SettingsImportPreview {
* more attention than an ordinary setting. */
image_source: ImageSource;
custom_image_name: string | null;
/** Marketplaces the import configures. */
marketplace_count: number;
/** Hooks the import installs for all projects — each runs commands in
* every project container, without the confirm step a Marketplace-tab
* install shows, so the preview warns about them. */
global_hook_install_count: number;
/** Plugins the import installs for all projects — a plugin can bring its
* own hooks and MCP servers, and skips the same confirm step. */
global_plugin_install_count: number;
/** Marketplace account tokens the import restores to the keychain. */
marketplace_account_token_count: number;
}
/** What `apply_settings_import` returns: the settings that were actually