[eric] marketplace: the published .swarm catalog is the store tab, installed through the existing import door

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R
This commit is contained in:
ciregenz
2026-09-02 15:32:10 -07:00
co-authored by Claude Opus 5
parent f57e9509f8
commit 8afec84986
23 changed files with 2400 additions and 16 deletions
+178
View File
@@ -0,0 +1,178 @@
"""The marketplace catalog: a public Google Sheet read as CSV and normalized into listings.
The sheet is the publisher's surface, so a package appears by adding a row and needs no OpenSwarm
deploy and no credential. Drive share links are rewritten to direct-download form because a share
link serves an HTML page, not the bundle.
"""
import csv
import io
import os
import re
import time
import urllib.request
from typing import Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from typeguard import typechecked
# The published catalog. Overridable so a fork or a staging sheet can be pointed at without a build.
DEFAULT_SHEET_ID = "1HInP47Vj-daPYKIl02YrAbyKsX1GQ_FSPSm_YF2QIWw"
SHEET_ID_ENV = "OPENSWARM_MARKETPLACE_SHEET_ID"
CACHE_TTL_SECONDS = 600
FETCH_TIMEOUT_SECONDS = 15
# A sheet is text; anything this large is a wrong URL answering, not a catalog.
MAX_SHEET_BYTES = 4 * 1024 * 1024
P_SHEET_ID_RE = re.compile(r"/spreadsheets/d/([A-Za-z0-9_-]+)")
P_DRIVE_ID_RE = re.compile(r"(?:/d/|id=)([A-Za-z0-9_-]{20,})")
class Listing(BaseModel):
"""One row of the sheet. Every field is a string because a spreadsheet cell is a string, and a
publisher leaving one blank must never break the catalog for everyone else."""
model_config = ConfigDict(validate_assignment=True)
id: str
title: str = ""
kind: str = ""
version: str = ""
author: str = ""
description: str = ""
tags: str = ""
download_url: str = ""
icon_url: str = ""
video_url: str = ""
size: str = ""
updated_at: str = ""
# A bundle groups already-published listings; this holds their comma-separated ids.
bundle_items: str = ""
# The publisher's long-form page, converted to our block JSON at publish time.
notion_url: str = ""
details_json: str = ""
class CatalogResponse(BaseModel):
model_config = ConfigDict(validate_assignment=True)
source: str
count: int
listings: List[Listing] = Field(default_factory=list)
error: str = ""
fetched_at: float = 0.0
KNOWN_FIELDS = tuple(Listing.model_fields.keys())
p_cache: Optional[CatalogResponse] = None
@typechecked
def sheet_id() -> str:
"""The configured sheet, accepting either a raw id or a pasted sheet URL."""
raw = (os.environ.get(SHEET_ID_ENV) or "").strip() or DEFAULT_SHEET_ID
match = P_SHEET_ID_RE.search(raw)
return match.group(1) if match else raw
@typechecked
def csv_export_url(sid: str) -> str:
return f"https://docs.google.com/spreadsheets/d/{sid}/export?format=csv"
@typechecked
def normalize_download_url(url: str) -> str:
"""A Drive share link serves a viewer page; the installer needs the bytes."""
url = (url or "").strip()
if not url or "drive.google.com" not in url:
return url
match = P_DRIVE_ID_RE.search(url)
return f"https://drive.google.com/uc?export=download&id={match.group(1)}" if match else url
@typechecked
def normalize_video_url(url: str) -> str:
"""Drive video links are stored in several hand-pasted shapes; keep one embeddable form."""
url = (url or "").strip()
if not url or "drive.google.com" not in url or "/preview" in url:
return url
match = P_DRIVE_ID_RE.search(url)
return f"https://drive.google.com/file/d/{match.group(1)}/preview" if match else url
@typechecked
def normalize_video_field(raw: str) -> str:
"""One cell can carry several demo videos, one URL per line, primary first."""
lines = [normalize_video_url(line) for line in re.split(r"[\r\n]+", raw or "") if line.strip()]
return "\n".join(line for line in lines if line)
@typechecked
def slug_from_title(title: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
@typechecked
def parse_csv(text: str) -> List[Listing]:
"""Rows the sheet's own header names, loosely matched so a sloppy header still parses. Unknown
columns are ignored rather than rejected, so a publisher can add their own without a release."""
listings: List[Listing] = []
for row in csv.DictReader(io.StringIO(text)):
norm: Dict[str, str] = {
(key or "").strip().lower().replace(" ", "_"): (value or "").strip()
for key, value in row.items()
if key
}
values = {field: norm.get(field, "") for field in KNOWN_FIELDS}
if not values["id"] and not values["title"]:
continue
if not values["id"]:
values["id"] = slug_from_title(values["title"])
if not values["id"]:
continue
values["download_url"] = normalize_download_url(values["download_url"])
values["video_url"] = normalize_video_field(values["video_url"])
listings.append(Listing(**values))
return listings
@typechecked
def fetch_sheet_csv(url: str) -> str:
"""Stdlib only: this runs on end-user machines, and the sheet is public so there is no auth."""
request = urllib.request.Request(url, headers={"User-Agent": "OpenSwarm-Marketplace/1.0"})
with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT_SECONDS) as response:
raw = response.read(MAX_SHEET_BYTES + 1)
if len(raw) > MAX_SHEET_BYTES:
raise ValueError("catalog response is too large to be a sheet")
charset = response.headers.get_content_charset() or "utf-8"
return raw.decode(charset, errors="replace")
@typechecked
def load_catalog(force: bool = False) -> CatalogResponse:
"""The catalog, from cache when fresh. A failed fetch serves the last good copy rather than an
empty store, and says so, because a flaky network must not look like an empty marketplace."""
global p_cache
now = time.time()
if not force and p_cache is not None and now - p_cache.fetched_at < CACHE_TTL_SECONDS:
return p_cache
try:
listings = parse_csv(fetch_sheet_csv(csv_export_url(sheet_id())))
except Exception as e:
if p_cache is not None:
return p_cache.model_copy(update={"source": "cache", "error": f"Could not refresh the catalog: {e}"})
return CatalogResponse(source="empty", count=0, listings=[], error=f"Could not reach the catalog: {e}", fetched_at=now)
p_cache = CatalogResponse(source="sheet", count=len(listings), listings=listings, fetched_at=now)
return p_cache
@typechecked
def find_listing(listing_id: str) -> Optional[Listing]:
"""Resolve an id against OUR fetched catalog; a caller never gets to name a URL to fetch."""
wanted = (listing_id or "").strip()
for listing in load_catalog().listings:
if listing.id == wanted:
return listing
return None
+63
View File
@@ -0,0 +1,63 @@
"""Marketplace routes: browse the published catalog, and stage a package for install.
Install deliberately owns no writing of its own. It downloads the bundle and hands the bytes to the
same staging door a dropped .swarm goes through, so the conflict check, the secret review and the
"a skill never installs silently" rule are the ones already shipped, not a second copy that can
drift away from them.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict
from backend.apps.marketplace import catalog
from backend.apps.marketplace.package_download import (
DownloadRefused,
download_package,
package_filename,
)
from backend.apps.swarm.models import ImportPreflightResponse
from backend.apps.swarm.swarm import stage_bundle_for_import
from backend.config.Apps import SubApp
logger = logging.getLogger(__name__)
class InstallRequest(BaseModel):
model_config = ConfigDict(validate_assignment=True)
id: str
@asynccontextmanager
async def marketplace_lifespan():
yield
marketplace = SubApp("marketplace", marketplace_lifespan)
@marketplace.router.get("/listings")
async def get_listings(refresh: bool = False) -> catalog.CatalogResponse:
"""The published catalog. Never raises: an unreachable sheet answers with the last good copy
and an error string, because a browse surface that 500s reads as "the marketplace is gone"."""
return await asyncio.to_thread(catalog.load_catalog, refresh)
@marketplace.router.post("/install/preflight")
async def install_preflight(body: InstallRequest) -> ImportPreflightResponse:
"""Download the listing's bundle and stage it for the ordinary import confirm flow."""
listing = await asyncio.to_thread(catalog.find_listing, body.id)
if listing is None:
raise HTTPException(status_code=404, detail="that package is not in the catalog any more")
if not listing.download_url:
raise HTTPException(status_code=400, detail="this listing has no package file yet")
try:
raw = await asyncio.to_thread(download_package, listing.download_url)
except DownloadRefused as e:
logger.warning("marketplace download refused for %s: %s", listing.id, e)
raise HTTPException(status_code=400, detail=str(e))
return stage_bundle_for_import(raw, package_filename(listing.id, listing.title))
@@ -0,0 +1,76 @@
"""Fetch a published .swarm, with the host allowlist that keeps this from being an SSRF hole.
The catalog is a spreadsheet other people can edit, so a row's URL is untrusted input that would
otherwise make OUR backend fetch whatever it names, including localhost and cloud metadata. Every
hop of the redirect chain is checked, not just the first, because a permitted host may redirect.
"""
import urllib.error
import urllib.request
from urllib.parse import urlparse
from typeguard import typechecked
ALLOWED_DOWNLOAD_HOSTS = (
"drive.google.com",
"drive.usercontent.google.com",
"docs.google.com",
"github.com",
"objects.githubusercontent.com",
"raw.githubusercontent.com",
)
DOWNLOAD_TIMEOUT_SECONDS = 60
MAX_PACKAGE_BYTES = 200 * 1024 * 1024
class DownloadRefused(Exception):
"""The URL is not somewhere we are willing to fetch from."""
@typechecked
def host_allowed(url: str) -> bool:
parsed = urlparse((url or "").strip())
if parsed.scheme != "https" or not parsed.hostname:
return False
host = parsed.hostname.lower()
return any(host == allowed or host.endswith("." + allowed) for allowed in ALLOWED_DOWNLOAD_HOSTS)
class AllowlistRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Drive answers a download with a redirect, so redirects have to be followed; each new
location is re-checked so a permitted host cannot bounce us onto a private address."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
if not host_allowed(newurl):
raise DownloadRefused(f"refused a redirect to {urlparse(newurl).hostname or newurl}")
return super().redirect_request(req, fp, code, msg, headers, newurl)
@typechecked
def download_package(url: str) -> bytes:
"""The bundle's bytes, or DownloadRefused. Never returns a partial or oversized body."""
if not host_allowed(url):
raise DownloadRefused("this package is not hosted somewhere OpenSwarm will download from")
opener = urllib.request.build_opener(AllowlistRedirectHandler())
request = urllib.request.Request(url, headers={"User-Agent": "OpenSwarm-Marketplace/1.0"})
try:
with opener.open(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response:
raw = response.read(MAX_PACKAGE_BYTES + 1)
except urllib.error.HTTPError as e:
raise DownloadRefused(f"the download returned {e.code}")
except DownloadRefused:
raise
except Exception as e:
raise DownloadRefused(f"the download failed: {e}")
if len(raw) > MAX_PACKAGE_BYTES:
raise DownloadRefused("the package is too large")
if not raw:
raise DownloadRefused("the download was empty")
return raw
@typechecked
def package_filename(listing_id: str, title: str) -> str:
base = (listing_id or title or "package").strip() or "package"
return f"{base}.swarm"
+11 -4
View File
@@ -82,13 +82,14 @@ async def export_bundle(body: ExportRequest) -> Response:
)
@swarm.router.post("/import/preflight")
async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightResponse:
raw = await file.read()
def stage_bundle_for_import(raw: bytes, filename: str) -> ImportPreflightResponse:
"""Stage bytes for import and hand back the review. Every door that installs a bundle comes
through here (a dropped file, a marketplace package), so the conflict/secret review and the
one staging store can never diverge per door."""
if len(raw) > MAX_TOTAL_BYTES:
raise HTTPException(status_code=400, detail="file is too large")
try:
sandbox, manifest, warnings = closure.stage_upload(raw, file.filename or "")
sandbox, manifest, warnings = closure.stage_upload(raw, filename)
conflicts = closure.detect_conflicts(sandbox, manifest)
review = closure.review_bundle(sandbox, manifest)
except BundleError as e:
@@ -105,6 +106,12 @@ async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightRespo
)
@swarm.router.post("/import/preflight")
async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightResponse:
raw = await file.read()
return stage_bundle_for_import(raw, file.filename or "")
@swarm.router.post("/import/commit")
async def import_commit(body: ImportCommitRequest) -> ImportCommitResponse:
entry = P_STAGING.get(body.staging_token)
+2 -1
View File
@@ -43,6 +43,7 @@ from backend.apps.outputs.outputs import outputs
from backend.apps.outputs.versions_routes import output_versions
from backend.apps.dashboards.dashboards import dashboards
from backend.apps.swarm.swarm import swarm
from backend.apps.marketplace.marketplace import marketplace
from backend.apps.service.service import service
from backend.apps.subscription.router import subscription
from backend.apps.auth.router import auth
@@ -60,7 +61,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough, apps_sdk])
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, marketplace, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough, apps_sdk])
app = main_app.app
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
+130
View File
@@ -0,0 +1,130 @@
"""The marketplace catalog and its one install door.
Two things here are load-bearing and are asserted rather than assumed: the catalog is a spreadsheet
other people can edit, so a row's download URL is untrusted input, and install must go through the
SAME staging door a dropped .swarm uses, or the secret review and the never-install-a-skill-silently
rule would exist in one door and not the other.
"""
import inspect
import pytest
from backend.apps.marketplace import catalog, marketplace
from backend.apps.marketplace.package_download import (
DownloadRefused,
download_package,
host_allowed,
)
SHEET = (
"id,title,kind,version,author,description,tags,icon_url,download_url,size,updated_at,video_url,bundle_items\n"
"hello,Hello World,skill,1.0.0,Test,Says hello,demo,,https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUv/view,12 KB,2026-09-01,,\n"
",Derived Title,app,,Someone,No id column,,,https://example.com/x.swarm,,,,\n"
",,,,,,,,,,,,\n"
"pack,Starter Pack,bundle,,Team,Two things,,,,,2026-09-02,,hello, hello ,\n"
)
def p_listing(rows, listing_id):
return next(row for row in rows if row.id == listing_id)
def test_a_drive_share_link_becomes_something_an_installer_can_fetch():
rows = catalog.parse_csv(SHEET)
assert p_listing(rows, "hello").download_url == (
"https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUv"
)
def test_a_row_without_an_id_gets_one_from_its_title_and_a_blank_row_is_dropped():
rows = catalog.parse_csv(SHEET)
assert [row.id for row in rows] == ["hello", "derived-title", "pack"]
def test_an_unknown_column_is_ignored_rather_than_rejected():
rows = catalog.parse_csv("id,title,kind,publisher_notes\nx,X,skill,anything at all\n")
assert rows[0].id == "x" and rows[0].kind == "skill"
def test_several_demo_videos_in_one_cell_each_normalize_and_keep_their_order():
raw = "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUv/view\nhttps://youtu.be/abcdefghijk"
rows = catalog.parse_csv(f"id,title,video_url\nv,V,\"{raw}\"\n")
assert rows[0].video_url.split("\n") == [
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUv/preview",
"https://youtu.be/abcdefghijk",
]
@pytest.mark.parametrize(
"url,allowed",
[
("https://drive.google.com/uc?export=download&id=x", True),
("https://drive.usercontent.google.com/download?id=x", True),
("https://raw.githubusercontent.com/o/r/main/a.swarm", True),
# The sheet is editable by other people, so these are the shapes that must never resolve.
("http://drive.google.com/uc?id=x", False),
("https://drive.google.com.evil.example/a.swarm", False),
("https://127.0.0.1/a.swarm", False),
("https://169.254.169.254/latest/meta-data", False),
("file:///etc/passwd", False),
("", False),
],
)
def test_only_hosts_we_publish_from_are_fetchable(url, allowed):
assert host_allowed(url) is allowed
def test_a_refused_host_never_reaches_the_network():
with pytest.raises(DownloadRefused):
download_package("https://evil.example/package.swarm")
def test_a_redirect_off_the_allowlist_is_refused_mid_chain():
from backend.apps.marketplace.package_download import AllowlistRedirectHandler
handler = AllowlistRedirectHandler()
with pytest.raises(DownloadRefused):
handler.redirect_request(None, None, 302, "Found", {}, "https://127.0.0.1/pkg.swarm")
def test_an_unreachable_sheet_serves_the_last_good_catalog_instead_of_an_empty_store(monkeypatch):
monkeypatch.setattr(catalog, "fetch_sheet_csv", lambda url: SHEET)
good = catalog.load_catalog(force=True)
assert good.source == "sheet" and good.count == 3
def p_down(url):
raise OSError("no network")
monkeypatch.setattr(catalog, "fetch_sheet_csv", p_down)
stale = catalog.load_catalog(force=True)
assert stale.source == "cache", "a flaky network must not look like an empty marketplace"
assert [row.id for row in stale.listings] == [row.id for row in good.listings]
assert "Could not refresh" in stale.error
def test_with_no_cache_at_all_an_unreachable_sheet_is_honest_rather_than_silent(monkeypatch):
monkeypatch.setattr(catalog, "p_cache", None)
def p_down(url):
raise OSError("no network")
monkeypatch.setattr(catalog, "fetch_sheet_csv", p_down)
empty = catalog.load_catalog(force=True)
assert empty.source == "empty" and empty.count == 0 and empty.error
def test_a_caller_names_a_listing_id_never_a_url():
"""The install request model carries only an id, so no caller can make the backend fetch a URL
of their choosing; the URL is resolved from the catalog WE fetched."""
assert set(marketplace.InstallRequest.model_fields) == {"id"}
def test_install_stages_through_the_same_door_as_a_dropped_file():
from backend.apps.swarm import swarm as swarm_routes
assert marketplace.stage_bundle_for_import is swarm_routes.stage_bundle_for_import
body = inspect.getsource(marketplace.install_preflight)
assert "stage_bundle_for_import" in body
for forbidden in ("write_folder_skill", "closure.commit", "import_commit"):
assert forbidden not in body, "install must never write; it stages and lets the user confirm"
@@ -0,0 +1,233 @@
import React, { useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Alert from '@mui/material/Alert';
import Snackbar from '@mui/material/Snackbar';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchMarketplaceListings } from '@/shared/state/marketplaceCatalogSlice';
import { fetchSkills } from '@/shared/state/skillsSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
import ImportModal from '@/app/components/share/ImportModal';
import { importNeedsConfirm } from '@/app/components/share/importNeedsConfirm';
import { importCommit } from '@/app/components/share/shareApi';
import type { ImportPreflight } from '@/app/components/share/shareTypes';
import DirectoryFilterBar from './DirectoryFilterBar';
import PackageCard from './packages/PackageCard';
import PackageDialog from './packages/detail/PackageDialog';
import PackageBundleCard from './packages/PackageBundleCard';
import PackageBundleDialog from './packages/detail/PackageBundleDialog';
import { stagePackageInstall } from './packages/installPackage';
import { KIND_LABELS, isBundle, resolveBundleMembers, type Listing } from './packages/catalog';
type Toast = { message: string; severity: 'success' | 'error' } | null;
// The store tab: packages published to the marketplace sheet. Install downloads the .swarm and hands
// it to the ordinary bundle import, so what a package can do to this machine is reviewed the same way
// a dropped file is.
const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void }> = ({ onInstalled }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { listings, loading, loaded, source, error } = useAppSelector((s) => s.marketplaceCatalog);
const [query, setQuery] = useState('');
const [kinds, setKinds] = useState<string[]>([]);
const [sort, setSort] = useState('newest');
const [openListing, setOpenListing] = useState<Listing | null>(null);
const [openBundle, setOpenBundle] = useState<Listing | null>(null);
const [installingId, setInstallingId] = useState<string | null>(null);
const [installedIds, setInstalledIds] = useState<string[]>([]);
const [confirm, setConfirm] = useState<{ preflight: ImportPreflight; listingId: string } | null>(null);
const [committing, setCommitting] = useState(false);
const [toast, setToast] = useState<Toast>(null);
useEffect(() => {
dispatch(fetchMarketplaceListings(false));
}, [dispatch]);
const packages = useMemo(() => listings.filter((l) => !isBundle(l)), [listings]);
const bundles = useMemo(() => listings.filter(isBundle), [listings]);
const kindOptions = useMemo(
() => Array.from(new Set(packages.map((l) => l.kind).filter(Boolean))).sort()
.map((k) => ({ value: k, label: KIND_LABELS[k] || k })),
[packages],
);
const matchesQuery = (l: Listing, q: string): boolean =>
!q || [l.title, l.description, l.tags, l.author].some((field) => field.toLowerCase().includes(q));
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
const rows = packages.filter((l) => (kinds.length === 0 || kinds.includes(l.kind)) && matchesQuery(l, q));
return [...rows].sort((a, b) => {
if (sort === 'name') return a.title.localeCompare(b.title);
if (sort === 'kind') return a.kind.localeCompare(b.kind);
return (b.updated_at || '').localeCompare(a.updated_at || '');
});
}, [packages, query, kinds, sort]);
// A bundle has no single kind, so it leaves the view entirely once the user narrows to one.
const visibleBundles = useMemo(() => {
if (kinds.length > 0) return [];
const q = query.trim().toLowerCase();
return bundles.filter((b) => matchesQuery(b, q));
}, [bundles, query, kinds]);
const finish = (preflight: ImportPreflight, listingId: string, rootType: string) => {
setInstalledIds((prev) => (prev.includes(listingId) ? prev : [...prev, listingId]));
setToast({ message: `Added ${preflight.summary.root.name}`, severity: 'success' });
// Nothing else refetches these on import, so an installed package would otherwise stay invisible.
if (rootType === 'skill') dispatch(fetchSkills());
if (rootType === 'app') dispatch(fetchOutputs());
if (rootType === 'workflow') dispatch(fetchWorkflows(undefined));
onInstalled?.(rootType);
};
const commit = async (preflight: ImportPreflight, listingId: string) => {
setCommitting(true);
try {
const res = await importCommit(preflight.staging_token);
finish(preflight, listingId, res.root_type);
setConfirm(null);
} catch (e: unknown) {
setToast({ message: e instanceof Error ? e.message : "We couldn't finish the install.", severity: 'error' });
} finally {
setCommitting(false);
}
};
const install = async (listingId: string) => {
setInstallingId(listingId);
try {
const preflight = await stagePackageInstall(listingId);
if (importNeedsConfirm(preflight)) setConfirm({ preflight, listingId });
else await commit(preflight, listingId);
} catch (e: unknown) {
setToast({ message: e instanceof Error ? e.message : "We couldn't download this package.", severity: 'error' });
} finally {
setInstallingId(null);
}
};
const installBundle = async (bundle: Listing) => {
const members = resolveBundleMembers(bundle, listings).filter((m) => m.download_url);
if (members.length === 0) {
setToast({ message: 'This bundle has no installable packages yet.', severity: 'error' });
return;
}
// One at a time: each member gets its own review, and a bundle must not be a way to skip one.
for (const member of members) {
await install(member.id);
}
};
const body = (): React.ReactElement => {
if (loading && !loaded) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
<CircularProgress size={24} sx={{ color: c.accent.primary }} />
</Box>
);
}
if (visible.length === 0 && visibleBundles.length === 0) {
return (
<Typography sx={{ fontSize: '0.9375rem', color: c.text.tertiary, pt: 6, textAlign: 'center' }}>
{error ? error : 'No packages match that search yet.'}
</Typography>
);
}
return (
<>
{visibleBundles.length > 0 && (
<Box sx={{ mb: 2.5 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600, color: c.text.tertiary, mb: 1 }}>
Collections
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75 }}>
{visibleBundles.map((bundle) => (
<PackageBundleCard
key={bundle.id}
bundle={bundle}
members={resolveBundleMembers(bundle, listings)}
onOpen={() => setOpenBundle(bundle)}
/>
))}
</Box>
</Box>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75, alignContent: 'start' }}>
{visible.map((listing) => (
<PackageCard
key={listing.id}
listing={listing}
onOpen={() => setOpenListing(listing)}
onTag={(tag) => setQuery(tag)}
/>
))}
</Box>
</>
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 1.75 }}>
<DirectoryFilterBar
searchPlaceholder="Search packages, tags, authors"
query={query}
onQuery={setQuery}
filterSections={[{ label: 'Kind', options: kindOptions }]}
filterSelected={kinds}
onToggleFilter={(value) => setKinds((prev) => (prev.includes(value) ? prev.filter((k) => k !== value) : [...prev, value]))}
sortOptions={[
{ value: 'newest', label: 'Newest' },
{ value: 'name', label: 'Name' },
{ value: 'kind', label: 'Kind' },
]}
sortValue={sort}
onSort={setSort}
/>
{source === 'cache' && (
<Typography sx={{ fontSize: '0.8125rem', color: c.text.muted }}>
Showing the last catalog we loaded; the marketplace could not be reached just now.
</Typography>
)}
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', pr: 0.5 }}>{body()}</Box>
<PackageDialog
listing={openListing}
installing={!!openListing && installingId === openListing.id}
installed={!!openListing && installedIds.includes(openListing.id)}
onInstall={() => { if (openListing) void install(openListing.id); }}
onClose={() => setOpenListing(null)}
/>
<PackageBundleDialog
bundle={openBundle}
members={openBundle ? resolveBundleMembers(openBundle, listings) : []}
installing={installingId !== null}
onInstallAll={() => { if (openBundle) void installBundle(openBundle); }}
onInstallMember={(id) => { void install(id); }}
onOpenMember={(member) => { setOpenBundle(null); setOpenListing(member); }}
onClose={() => setOpenBundle(null)}
/>
<ImportModal
preflight={confirm?.preflight ?? null}
open={!!confirm}
committing={committing}
onConfirm={() => confirm && commit(confirm.preflight, confirm.listingId)}
onClose={() => setConfirm(null)}
/>
<Snackbar
open={!!toast}
autoHideDuration={4000}
onClose={() => setToast(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={toast?.severity || 'success'} variant="filled" onClose={() => setToast(null)}>
{toast?.message}
</Alert>
</Snackbar>
</Box>
);
};
export default DirectoryPackagesTab;
@@ -2,31 +2,31 @@ import React, { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import GridViewOutlinedIcon from '@mui/icons-material/GridViewOutlined';
import FolderSpecialOutlinedIcon from '@mui/icons-material/FolderSpecialOutlined';
import PowerOutlinedIcon from '@mui/icons-material/PowerOutlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearMarketplaceRequestedTab } from '@/shared/state/dashboardLayoutSlice';
import DirectorySkillsTab from './DirectorySkillsTab';
import DirectoryPackagesTab from './DirectoryPackagesTab';
import DirectoryConnectorsTab from './DirectoryConnectorsTab';
// Lazy: the manage views pull the full Skills/Tools pages (markdown, MCP cards) and the marketplace opens from the dock.
const SkillsManage = React.lazy(() => import('../Skills/Skills'));
const ToolsManage = React.lazy(() => import('../Tools/Tools'));
export type DirectoryTab = 'skills' | 'connectors' | 'my-skills' | 'my-connectors';
export type DirectoryTab = 'packages' | 'connectors' | 'my-skills' | 'my-connectors';
const VALID_TABS: DirectoryTab[] = ['skills', 'connectors', 'my-skills', 'my-connectors'];
const VALID_TABS: DirectoryTab[] = ['packages', 'connectors', 'my-skills', 'my-connectors'];
// The Marketplace window body: claude.ai's Directory grids land first (the store), with the
// The Marketplace window body: the published package catalog lands first (the store), with the
// installed-item manage pages as their own rail rows below the divider.
const MarketplaceBody: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const requestedTab = useAppSelector((s) => s.dashboardLayout.marketplaceRequestedTab);
const [view, setView] = useState<DirectoryTab>('skills');
const [view, setView] = useState<DirectoryTab>('packages');
const [focusSkillId, setFocusSkillId] = useState<string | null>(null);
const [focusToolId, setFocusToolId] = useState<string | null>(null);
@@ -65,12 +65,12 @@ const MarketplaceBody: React.FC = () => {
const content = (): React.ReactElement => {
switch (view) {
case 'skills':
return <DirectorySkillsTab onOpenInstalled={(id) => { setFocusSkillId(id); setView('my-skills'); }} />;
case 'packages':
return <DirectoryPackagesTab onInstalled={(rootType) => { if (rootType === 'skill') { setFocusSkillId(null); setView('my-skills'); } }} />;
case 'connectors':
return <DirectoryConnectorsTab onOpenInstalled={(id) => { setFocusToolId(id); setView('my-connectors'); }} />;
case 'my-skills':
return <SkillsManage onBrowseDirectory={() => setView('skills')} focusSkillId={focusSkillId} />;
return <SkillsManage onBrowseDirectory={() => setView('packages')} focusSkillId={focusSkillId} />;
case 'my-connectors':
return <ToolsManage onBrowseConnectors={() => setView('connectors')} expandToolId={focusToolId} />;
}
@@ -80,7 +80,7 @@ const MarketplaceBody: React.FC = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, bgcolor: c.bg.surface }}>
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, pt: 1.5 }}>
<Box sx={{ width: 210, minWidth: 210, px: 2, pt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{railRow('skills', 'Skills', <DescriptionOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
{railRow('packages', 'Packages', <Inventory2OutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
{railRow('connectors', 'Connectors', <GridViewOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
<Box sx={{ height: '1px', bgcolor: c.border.subtle, mx: 1.5, my: 1 }} />
{railRow('my-skills', 'My skills', <FolderSpecialOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
@@ -2,6 +2,6 @@ import { store } from '@/shared/state/store';
import { openMarketplaceCard } from '@/shared/state/dashboardLayoutSlice';
import type { DirectoryTab } from './MarketplaceBody';
export function openMarketplace(tab: DirectoryTab = 'skills'): void {
export function openMarketplace(tab: DirectoryTab = 'packages'): void {
store.dispatch(openMarketplaceCard({ tab }));
}
@@ -0,0 +1,147 @@
import React from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { KIND_LABELS, type Listing } from './catalog';
interface Props {
bundle: Listing;
members: Listing[];
onOpen: () => void;
}
// Leads with a stacked-icon motif of its first few members and a count, so a bundle reads as a collection at a glance rather than as another single package.
export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
const c = useClaudeTokens();
const preview = members.slice(0, 4);
const kinds = Array.from(new Set(members.map((m) => KIND_LABELS[m.kind] || m.kind).filter(Boolean)));
return (
<Box
onClick={onOpen}
sx={{
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 3,
p: 2.5,
transition: c.transition,
position: 'relative',
overflow: 'hidden',
'&:hover': {
borderColor: c.accent.primary,
boxShadow: c.shadow.md,
transform: 'translateY(-2px)',
},
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2.5,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${c.accent.primary}1A`,
overflow: 'hidden',
}}
>
{bundle.icon_url ? (
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<Inventory2Icon sx={{ fontSize: 24, color: c.accent.primary }} />
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
sx={{
fontSize: '1.05rem',
fontWeight: 660,
color: c.text.primary,
letterSpacing: '-0.01em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{bundle.title}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.accent.primary, fontWeight: 600 }}>
Bundle · {members.length} {members.length === 1 ? 'package' : 'packages'}
</Typography>
</Box>
</Stack>
<Typography
sx={{
fontSize: '0.875rem',
color: c.text.secondary,
lineHeight: 1.5,
mb: 1.75,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: '2.6em',
}}
>
{bundle.description || 'A curated collection of packages.'}
</Typography>
<Stack direction="row" spacing={-0.75} sx={{ mt: 'auto', alignItems: 'center' }}>
{preview.map((m, i) => (
<Box
key={m.id}
sx={{
width: 30,
height: 30,
borderRadius: 1.5,
flexShrink: 0,
ml: i === 0 ? 0 : '-8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: c.bg.secondary,
border: `2px solid ${c.bg.surface}`,
overflow: 'hidden',
zIndex: preview.length - i,
}}
>
{m.icon_url ? (
<Box component="img" src={m.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ExtensionIcon sx={{ fontSize: 15, color: c.text.muted }} />
)}
</Box>
))}
{members.length > preview.length && (
<Typography sx={{ ml: 1, fontSize: '0.78rem', color: c.text.muted }}>
+{members.length - preview.length} more
</Typography>
)}
{kinds.length > 0 && (
<Chip
label={kinds.slice(0, 2).join(' · ')}
size="small"
sx={{
ml: 'auto',
height: 22,
fontSize: '0.72rem',
bgcolor: c.bg.secondary,
color: c.text.tertiary,
}}
/>
)}
</Stack>
</Box>
);
}
@@ -0,0 +1,118 @@
import React from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { parseTags, KIND_LABELS, type Listing } from './catalog';
interface Props {
listing: Listing;
onOpen: () => void;
onTag: (tag: string) => void;
}
export default function PackageCard({ listing, onOpen, onTag }: Props) {
const c = useClaudeTokens();
const tags = parseTags(listing.tags).slice(0, 3);
return (
<Box
onClick={onOpen}
sx={{
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 3,
p: 2.5,
transition: c.transition,
'&:hover': {
borderColor: c.border.medium,
boxShadow: c.shadow.md,
transform: 'translateY(-2px)',
},
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${c.accent.primary}14`,
overflow: 'hidden',
}}
>
{listing.icon_url ? (
<Box component="img" src={listing.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ExtensionIcon sx={{ fontSize: 22, color: c.accent.primary }} />
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
sx={{
fontSize: '1rem',
fontWeight: 620,
color: c.text.primary,
letterSpacing: '-0.01em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{listing.title}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>
{KIND_LABELS[listing.kind] || listing.kind || 'Package'}
{listing.version ? ` · v${listing.version}` : ''}
</Typography>
</Box>
</Stack>
<Typography
sx={{
fontSize: '0.875rem',
color: c.text.secondary,
lineHeight: 1.5,
mb: 1.75,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: '2.6em',
}}
>
{listing.description || 'No description provided.'}
</Typography>
<Stack direction="row" spacing={0.75} sx={{ mt: 'auto', flexWrap: 'wrap', gap: 0.75 }}>
{tags.map((t) => (
<Chip
key={t}
label={`#${t}`}
size="small"
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
onTag(t);
}}
sx={{
height: 22,
fontSize: '0.72rem',
bgcolor: c.bg.secondary,
color: c.text.tertiary,
'&:hover': { color: c.text.primary },
}}
/>
))}
</Stack>
</Box>
);
}
@@ -0,0 +1,71 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { driveStreamUrl, isBundle, parseBundleItems, parseTags, parseVideoUrls, resolveBundleMembers, videoEmbed, type Listing } from './catalog';
function listing(over: Partial<Listing>): Listing {
return {
id: 'x', title: 'X', kind: 'skill', version: '', author: '', description: '', tags: '',
download_url: '', icon_url: '', video_url: '', size: '', updated_at: '',
bundle_items: '', notion_url: '', details_json: '', ...over,
};
}
test('a bundle resolves its members in its own order and drops ids that no longer exist', () => {
const bundle = listing({ id: 'pack', kind: 'bundle', bundle_items: 'b, a, gone, a' });
const all = [bundle, listing({ id: 'a' }), listing({ id: 'b' })];
assert.deepEqual(resolveBundleMembers(bundle, all).map((m) => m.id), ['b', 'a']);
assert.equal(isBundle(bundle), true);
assert.deepEqual(parseBundleItems('b, a, , a'), ['b', 'a'], 'a repeated id is listed once');
});
test('a bundle never lists itself, which would install it forever', () => {
const bundle = listing({ id: 'pack', kind: 'bundle', bundle_items: 'pack, a' });
assert.deepEqual(resolveBundleMembers(bundle, [bundle, listing({ id: 'a' })]).map((m) => m.id), ['a']);
});
test('tags survive sloppy spacing and empty cells', () => {
assert.deepEqual(parseTags(' notion , productivity ,,'), ['notion', 'productivity']);
assert.deepEqual(parseTags(''), []);
});
test('a Drive video link becomes a byte stream a video tag can actually play', () => {
assert.equal(
driveStreamUrl('https://drive.google.com/file/d/1AbCdEf/preview'),
'https://drive.usercontent.google.com/download?id=1AbCdEf&export=download',
);
});
test('a YouTube listing embeds the player, a Drive listing streams the file', () => {
assert.equal(videoEmbed('https://youtu.be/abcdefghijk')?.kind, 'youtube');
assert.equal(videoEmbed('https://drive.google.com/file/d/1AbCdEf/preview')?.kind, 'file');
assert.equal(videoEmbed(''), null);
});
test('several demo videos in one cell keep their order, primary first', () => {
assert.deepEqual(parseVideoUrls('https://a\n\nhttps://b\n'), ['https://a', 'https://b']);
});
// The store tab is the marketplace's front door, so it must be the row that opens by default.
test('Packages is the default marketplace tab and the old skills store is gone', () => {
const dir = path.join(process.cwd(), 'src/app/pages/Directory');
const body = fs.readFileSync(path.join(dir, 'MarketplaceBody.tsx'), 'utf8');
assert.match(body, /useState<DirectoryTab>\('packages'\)/);
assert.match(body, /railRow\('packages', 'Packages'/);
assert.doesNotMatch(body, /DirectorySkillsTab/);
assert.equal(fs.existsSync(path.join(dir, 'DirectorySkillsTab.tsx')), false);
const open = fs.readFileSync(path.join(dir, 'openMarketplace.ts'), 'utf8');
assert.match(open, /DirectoryTab = 'packages'/);
});
// Install must not grow a second write path; it stages and lets the shared confirm surface decide.
test('the packages tab installs through the shared bundle import, not its own writer', () => {
const tab = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/Directory/DirectoryPackagesTab.tsx'), 'utf8');
assert.match(tab, /importNeedsConfirm/);
assert.match(tab, /importCommit/);
assert.match(tab, /<ImportModal/);
const gate = tab.indexOf('importNeedsConfirm(preflight)');
const commit = tab.indexOf('else await commit(preflight');
assert.ok(gate > 0 && commit > gate, 'the confirm gate is asked BEFORE anything is committed');
});
@@ -0,0 +1,141 @@
import { API_BASE } from '@/shared/config';
const CATALOG_LISTINGS_URL = `${API_BASE}/catalog/listings`;
export interface Listing {
id: string;
title: string;
kind: string;
version: string;
author: string;
description: string;
tags: string;
download_url: string;
icon_url: string;
video_url: string;
size: string;
updated_at: string;
// Bundles (kind === 'bundle') list their member ids here, comma separated; empty on ordinary packages.
bundle_items: string;
notion_url: string;
// The linked Notion page converted to DetailDoc JSON at upload time; empty when there is no linked page.
details_json: string;
}
export interface ListingsResponse {
source: 'sheet' | 'sample';
count: number;
listings: Listing[];
error: string;
}
export async function fetchListings(): Promise<ListingsResponse> {
const res = await fetch(CATALOG_LISTINGS_URL);
if (!res.ok) throw new Error(`Listings request failed (${res.status})`);
return (await res.json()) as ListingsResponse;
}
export function parseTags(tags: string): string[] {
return (tags || '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
// Drive's /file/d/{id}/preview URL is a player page an HTML5 <video> cannot play (and its iframe embed fails on clips Drive never transcoded), so pull the file id back out and rebuild the Range-enabled byte-stream URL.
export function driveStreamUrl(url: string): string {
if (!url) return '';
if (url.includes('drive.usercontent.google.com/download')) return url;
const m = url.match(/\/file\/d\/([^/]+)/) || url.match(/[?&]id=([^&]+)/);
const fileId = m?.[1];
if (!fileId) return url;
return `https://drive.usercontent.google.com/download?id=${fileId}&export=download`;
}
// YouTube hands out the same 11-char id through four URL shapes, and listings carry whichever one the author copied.
export function youtubeId(url: string): string | null {
if (!url) return null;
const m =
url.match(/[?&]v=([A-Za-z0-9_-]{11})/) ||
url.match(/youtu\.be\/([A-Za-z0-9_-]{11})/) ||
url.match(/\/embed\/([A-Za-z0-9_-]{11})/) ||
url.match(/\/shorts\/([A-Za-z0-9_-]{11})/);
return m?.[1] ?? null;
}
export interface VideoEmbed {
kind: 'youtube' | 'file';
src: string;
poster?: string;
videoId?: string;
}
// Newer listings host the demo on YouTube (unlisted) and legacy ones on Drive, so the player has to be picked per URL.
export function videoEmbed(url: string): VideoEmbed | null {
if (!url) return null;
const yt = youtubeId(url);
if (yt) {
return {
kind: 'youtube',
src: `https://www.youtube.com/embed/${yt}?rel=0&playsinline=1`,
poster: `https://i.ytimg.com/vi/${yt}/maxresdefault.jpg`,
videoId: yt,
};
}
return { kind: 'file', src: driveStreamUrl(url) };
}
// One video_url cell can hold several demos, one URL per line, the primary first; a legacy single-URL cell parses to a one-element list unchanged.
export function parseVideoUrls(raw: string): string[] {
return (raw || '')
.split(/[\r\n]+/)
.map((u) => u.trim())
.filter(Boolean);
}
// Each entry keeps its original URL so callers can key and dedupe on it.
export function videoEmbeds(raw: string): { url: string; embed: VideoEmbed }[] {
const out: { url: string; embed: VideoEmbed }[] = [];
for (const url of parseVideoUrls(raw)) {
const embed = videoEmbed(url);
if (embed) out.push({ url, embed });
}
return out;
}
export const KIND_LABELS: Record<string, string> = {
skill: 'Skill',
workflow: 'Workflow',
app: 'App',
mode: 'Mode',
agent: 'Agent',
dashboard: 'Dashboard',
bundle: 'Bundle',
};
export function isBundle(listing: Listing): boolean {
return (listing.kind || '').trim().toLowerCase() === 'bundle';
}
export function parseBundleItems(raw: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of (raw || '').split(',').map((s) => s.trim()).filter(Boolean)) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
}
// Preserves bundle order, drops ids whose listing no longer exists (a member deleted after bundling), and never returns the bundle itself.
export function resolveBundleMembers(bundle: Listing, all: Listing[]): Listing[] {
const byId = new Map(all.map((l) => [l.id, l]));
const out: Listing[] = [];
for (const id of parseBundleItems(bundle.bundle_items)) {
const member = byId.get(id);
if (member && member.id !== bundle.id) out.push(member);
}
return out;
}
@@ -0,0 +1,229 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close';
import DownloadIcon from '@mui/icons-material/Download';
import ExtensionIcon from '@mui/icons-material/Extension';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { KIND_LABELS, parseTags, type Listing } from '../catalog';
interface Props {
bundle: Listing | null;
members: Listing[];
onClose: () => void;
onOpenMember: (member: Listing) => void;
onInstallAll: () => void;
onInstallMember: (id: string) => void;
installing: boolean;
}
// A bundle has no .swarm of its own, so the dialog installs every member at once and lists each one with its own install button and a click-through to its full detail.
export default function PackageBundleDialog({
bundle,
members,
onClose,
onOpenMember,
onInstallAll,
onInstallMember,
installing,
}: Props) {
const c = useClaudeTokens();
if (!bundle) return null;
const installable = members.filter((m) => m.download_url);
const tags = parseTags(bundle.tags);
return (
<Dialog
open
onClose={onClose}
fullWidth
maxWidth="md"
scroll="paper"
slotProps={{
paper: {
sx: {
borderRadius: { xs: 0, sm: 4 },
m: { xs: 0, sm: 3 },
maxHeight: { xs: '100%', sm: 'calc(100% - 48px)' },
bgcolor: c.bg.surface,
backgroundImage: 'none',
boxShadow: c.shadow.lg,
},
},
backdrop: { sx: { bgcolor: 'rgba(29, 29, 31, 0.42)', backdropFilter: 'blur(6px)' } },
}}
>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ position: 'sticky', top: 0, zIndex: 1, bgcolor: c.bg.surface, px: { xs: 2.5, sm: 4 }, py: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.accent.primary }}>
Marketplace bundle
</Typography>
<IconButton onClick={onClose} aria-label="Close bundle details" sx={{ color: c.text.tertiary }}>
<CloseIcon />
</IconButton>
</Stack>
</Box>
<Box sx={{ px: { xs: 2.5, sm: 5 }, pb: { xs: 4, sm: 5 } }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ xs: 'flex-start', sm: 'center' }} sx={{ mb: 3 }}>
<Box sx={{ width: 76, height: 76, borderRadius: 3, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.accent.primary}1A`, overflow: 'hidden' }}>
{bundle.icon_url ? (
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<Inventory2Icon sx={{ fontSize: 34, color: c.accent.primary }} />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: { xs: '1.65rem', sm: '2rem' }, lineHeight: 1.15, fontWeight: 700, letterSpacing: '-0.03em', color: c.text.primary }}>
{bundle.title}
</Typography>
<Typography sx={{ mt: 0.75, color: c.accent.primary, fontWeight: 600 }}>
Bundle · {members.length} {members.length === 1 ? 'package' : 'packages'}
{bundle.author ? (
<Box component="span" sx={{ color: c.text.tertiary, fontWeight: 400 }}>{` · ${bundle.author}`}</Box>
) : null}
</Typography>
</Box>
</Stack>
<Typography sx={{ fontSize: '0.98rem', color: c.text.secondary, lineHeight: 1.7, whiteSpace: 'pre-wrap', mb: 3 }}>
{bundle.description || 'A curated collection of packages.'}
</Typography>
{tags.length > 0 && (
<Stack direction="row" sx={{ mb: 3, flexWrap: 'wrap', gap: 0.75 }}>
{tags.map((tag) => (
<Chip key={tag} label={`#${tag}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.tertiary }} />
))}
</Stack>
)}
<Divider sx={{ borderColor: c.border.subtle, mb: 2.5 }} />
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5, gap: 1.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.muted }}>
In this bundle
</Typography>
<Button
onClick={onInstallAll}
disabled={installing || installable.length === 0}
variant="contained"
size="small"
disableElevation
startIcon={
installing ? (
<CircularProgress size={15} thickness={5} sx={{ color: 'inherit' }} />
) : (
<DownloadIcon sx={{ fontSize: 17 }} />
)
}
sx={{
borderRadius: 999,
textTransform: 'none',
fontWeight: 600,
bgcolor: c.accent.primary,
color: '#fff',
px: 2,
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(0.94)' },
}}
>
Install all ({installable.length})
</Button>
</Stack>
{members.length === 0 ? (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>
The packages in this bundle are no longer available.
</Typography>
) : (
<Stack spacing={1.25}>
{members.map((m) => (
<Box
key={m.id}
onClick={() => onOpenMember(m)}
role="button"
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.75,
cursor: 'pointer',
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2.5,
p: 1.75,
transition: c.transition,
'&:hover': { borderColor: c.border.medium, boxShadow: c.shadow.sm },
}}
>
<Box sx={{ width: 44, height: 44, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.accent.primary}14`, overflow: 'hidden' }}>
{m.icon_url ? (
<Box component="img" src={m.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ExtensionIcon sx={{ fontSize: 22, color: c.accent.primary }} />
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{m.title}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>
{KIND_LABELS[m.kind] || m.kind || 'Package'}
{m.version ? ` · v${m.version}` : ''}
</Typography>
</Box>
<Stack spacing={0.5} alignItems="flex-end" sx={{ flexShrink: 0 }}>
<Button
onClick={(e) => {
e.stopPropagation();
onInstallMember(m.id);
}}
disabled={installing || !m.download_url}
variant="outlined"
size="small"
sx={{
borderRadius: 999,
textTransform: 'none',
borderColor: c.border.medium,
color: c.text.primary,
'&:hover': { borderColor: c.accent.primary, bgcolor: `${c.accent.primary}0D` },
}}
>
Install
</Button>
{m.download_url && (
<Link
href={m.download_url}
download
rel="noopener"
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => e.stopPropagation()}
sx={{ fontSize: '0.72rem', color: c.text.muted, textDecorationColor: c.border.medium, '&:hover': { color: c.text.primary } }}
>
Download .swarm file
</Link>
)}
</Stack>
</Box>
))}
</Stack>
)}
<Typography sx={{ mt: 3, fontSize: '0.8rem', color: c.text.ghost }}>
Install all adds every package in this bundle at once, or install them one at a time. The raw .swarm files stay linked if you would rather keep a copy.
</Typography>
</Box>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import type { DetailBlock, DetailDoc } from '../notionDetails';
function Heading({ block, c }: { block: Extract<DetailBlock, { type: 'heading' }>; c: ClaudeTokens }) {
return (
<Typography
component="h3"
sx={{ mt: 3.5, mb: 1.25, fontSize: '1.05rem', fontWeight: 700, letterSpacing: '-0.02em', color: c.text.primary }}
>
{block.emoji ? `${block.emoji} ` : ''}
{block.text}
</Typography>
);
}
function Paragraph({ block, c }: { block: Extract<DetailBlock, { type: 'paragraph' }>; c: ClaudeTokens }) {
return (
<Typography sx={{ fontSize: '0.95rem', lineHeight: 1.7, color: c.text.secondary, mb: 1.5 }}>
{block.text}
</Typography>
);
}
function Callout({ block, c }: { block: Extract<DetailBlock, { type: 'callout' }>; c: ClaudeTokens }) {
const tone = block.tone ?? 'default';
const bg =
tone === 'warning' ? c.status.errorBg : tone === 'info' ? `${c.accent.primary}0F` : c.bg.secondary;
const border =
tone === 'warning' ? `${c.status.error}33` : tone === 'info' ? `${c.accent.primary}33` : c.border.subtle;
return (
<Stack
direction="row"
spacing={1.5}
sx={{ bgcolor: bg, border: `1px solid ${border}`, borderRadius: 2.5, p: 2, mb: 1.5 }}
>
{block.emoji && <Box sx={{ fontSize: '1.2rem', lineHeight: 1.4, flexShrink: 0 }}>{block.emoji}</Box>}
<Box sx={{ minWidth: 0 }}>
{block.title && (
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.text.primary, mb: 0.25 }}>
{block.title}
</Typography>
)}
<Typography sx={{ fontSize: '0.92rem', lineHeight: 1.65, color: c.text.secondary }}>
{block.text}
</Typography>
</Box>
</Stack>
);
}
function Features({ block, c }: { block: Extract<DetailBlock, { type: 'features' }>; c: ClaudeTokens }) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 1.25,
mb: 1.5,
}}
>
{block.items.map((item) => (
<Stack
key={item.title}
direction="row"
spacing={1.5}
sx={{ bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`, borderRadius: 2.5, p: 1.75 }}
>
<Box sx={{ fontSize: '1.25rem', lineHeight: 1.3, flexShrink: 0 }}>{item.emoji}</Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontSize: '0.9rem', fontWeight: 700, color: c.text.primary, mb: 0.25 }}>
{item.title}
</Typography>
<Typography sx={{ fontSize: '0.85rem', lineHeight: 1.55, color: c.text.tertiary }}>
{item.text}
</Typography>
</Box>
</Stack>
))}
</Box>
);
}
function Steps({ block, c }: { block: Extract<DetailBlock, { type: 'steps' }>; c: ClaudeTokens }) {
return (
<Stack spacing={1.25} sx={{ mb: 1.5 }}>
{block.items.map((text, i) => (
<Stack key={i} direction="row" spacing={1.5} alignItems="flex-start">
<Box
sx={{
width: 26,
height: 26,
flexShrink: 0,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${c.accent.primary}14`,
color: c.accent.primary,
fontSize: '0.82rem',
fontWeight: 700,
}}
>
{i + 1}
</Box>
<Typography sx={{ fontSize: '0.92rem', lineHeight: 1.6, color: c.text.secondary, pt: 0.25 }}>
{text}
</Typography>
</Stack>
))}
</Stack>
);
}
function Bullets({ block, c }: { block: Extract<DetailBlock, { type: 'bullets' }>; c: ClaudeTokens }) {
return (
<Stack spacing={0.75} sx={{ mb: 1.5 }}>
{block.items.map((text, i) => (
<Stack key={i} direction="row" spacing={1.25} alignItems="flex-start">
<Box sx={{ height: 'calc(0.92rem * 1.6)', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<Box sx={{ width: 5, height: 5, borderRadius: '50%', bgcolor: c.text.muted }} />
</Box>
<Typography sx={{ fontSize: '0.92rem', lineHeight: 1.6, color: c.text.secondary }}>{text}</Typography>
</Stack>
))}
</Stack>
);
}
function FaqItem({ q, a, c }: { q: string; a: string; c: ClaudeTokens }) {
const [open, setOpen] = useState(false);
return (
<Box sx={{ border: `1px solid ${c.border.subtle}`, borderRadius: 2.5, overflow: 'hidden' }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
onClick={() => setOpen((v) => !v)}
role="button"
aria-expanded={open}
sx={{ cursor: 'pointer', px: 2, py: 1.5, bgcolor: c.bg.elevated }}
>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>💡 {q}</Typography>
<ExpandMoreIcon
sx={{ color: c.text.muted, transform: open ? 'rotate(180deg)' : 'none', transition: c.transition }}
/>
</Stack>
<Collapse in={open}>
<Typography sx={{ px: 2, py: 1.75, fontSize: '0.9rem', lineHeight: 1.65, color: c.text.secondary }}>
{a}
</Typography>
</Collapse>
</Box>
);
}
function Faq({ block, c }: { block: Extract<DetailBlock, { type: 'faq' }>; c: ClaudeTokens }) {
return (
<Stack spacing={1} sx={{ mb: 1.5 }}>
{block.items.map((item, i) => (
<FaqItem key={i} q={item.q} a={item.a} c={c} />
))}
</Stack>
);
}
function renderBlock(block: DetailBlock, i: number, c: ClaudeTokens) {
switch (block.type) {
case 'heading':
return <Heading key={i} block={block} c={c} />;
case 'paragraph':
return <Paragraph key={i} block={block} c={c} />;
case 'callout':
return <Callout key={i} block={block} c={c} />;
case 'features':
return <Features key={i} block={block} c={c} />;
case 'steps':
return <Steps key={i} block={block} c={c} />;
case 'bullets':
return <Bullets key={i} block={block} c={c} />;
case 'faq':
return <Faq key={i} block={block} c={c} />;
}
}
export default function PackageDetails({ doc }: { doc: DetailDoc }) {
const c = useClaudeTokens();
return <Box>{doc.blocks.map((block, i) => renderBlock(block, i, c))}</Box>;
}
@@ -0,0 +1,160 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import CloseIcon from '@mui/icons-material/Close';
import DownloadIcon from '@mui/icons-material/Download';
import ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { KIND_LABELS, parseTags, type Listing } from '../catalog';
import { detailsForListing } from '../notionDetails';
import PackageDetails from './PackageDetails';
import PackageVideoSection from './PackageVideoSection';
interface Props {
listing: Listing | null;
onClose: () => void;
onInstall: () => void;
installing: boolean;
installed?: boolean;
}
export default function PackageDialog({ listing, onClose, onInstall, installing, installed }: Props) {
const c = useClaudeTokens();
if (!listing) return null;
const details = detailsForListing(listing);
const tags = parseTags(listing.tags);
const subMeta = [
KIND_LABELS[listing.kind] || listing.kind || 'Package',
listing.version ? `v${listing.version}` : '',
listing.author || '',
listing.size || '',
listing.updated_at ? `Updated ${listing.updated_at}` : '',
]
.filter(Boolean)
.join(' · ');
return (
<Dialog
open
onClose={onClose}
fullWidth
maxWidth="md"
scroll="paper"
slotProps={{
paper: {
sx: {
borderRadius: { xs: 0, sm: 4 },
m: { xs: 0, sm: 3 },
maxHeight: { xs: '100%', sm: 'calc(100% - 48px)' },
bgcolor: c.bg.surface,
backgroundImage: 'none',
boxShadow: c.shadow.lg,
},
},
backdrop: { sx: { bgcolor: 'rgba(29, 29, 31, 0.42)', backdropFilter: 'blur(6px)' } },
}}
>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ position: 'sticky', top: 0, zIndex: 1, bgcolor: c.bg.surface, px: { xs: 2.5, sm: 4 }, py: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.muted }}>
Marketplace package
</Typography>
<IconButton onClick={onClose} aria-label="Close package details" sx={{ color: c.text.tertiary }}>
<CloseIcon />
</IconButton>
</Stack>
</Box>
<Box sx={{ px: { xs: 2.5, sm: 5 }, pb: { xs: 4, sm: 5 } }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ xs: 'flex-start', sm: 'center' }} sx={{ mb: 2.5 }}>
<Box sx={{ width: 76, height: 76, borderRadius: 3, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.accent.primary}14`, overflow: 'hidden' }}>
{listing.icon_url ? (
<Box component="img" src={listing.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ExtensionIcon sx={{ fontSize: 34, color: c.accent.primary }} />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: { xs: '1.65rem', sm: '2rem' }, lineHeight: 1.15, fontWeight: 700, letterSpacing: '-0.03em', color: c.text.primary }}>
{listing.title}
</Typography>
<Typography sx={{ mt: 0.75, color: c.text.tertiary, fontSize: '0.9rem' }}>{subMeta}</Typography>
{tags.length > 0 && (
<Stack direction="row" sx={{ mt: 1.25, flexWrap: 'wrap', gap: 0.75 }}>
{tags.map((tag) => (
<Chip key={tag} label={`#${tag}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.tertiary }} />
))}
</Stack>
)}
</Box>
<Stack spacing={0.75} alignItems={{ xs: 'flex-start', sm: 'flex-end' }} sx={{ flexShrink: 0 }}>
<Button
onClick={onInstall}
variant="contained"
disableElevation
disabled={installing || !!installed || !listing.download_url}
startIcon={
installing ? (
<CircularProgress size={16} thickness={5} sx={{ color: 'inherit' }} />
) : installed ? (
<CheckRoundedIcon />
) : (
<DownloadIcon />
)
}
sx={{
borderRadius: 999,
px: 3,
py: 1.15,
whiteSpace: 'nowrap',
textTransform: 'none',
fontWeight: 600,
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.hover },
}}
>
{installed ? 'Installed' : 'Install'}
</Button>
{listing.download_url && (
<Link
href={listing.download_url}
download
rel="noopener"
sx={{ fontSize: '0.78rem', color: c.text.muted, textDecorationColor: c.border.medium, '&:hover': { color: c.text.primary } }}
>
Download .swarm file
</Link>
)}
</Stack>
</Stack>
{listing.description && (
<Typography sx={{ fontSize: '0.98rem', color: c.text.secondary, lineHeight: 1.7, whiteSpace: 'pre-wrap', mb: 3 }}>
{listing.description}
</Typography>
)}
<PackageVideoSection key={listing.id} raw={listing.video_url} />
{details && (
<Box sx={{ mt: 4 }}>
<PackageDetails doc={details} />
</Box>
)}
</Box>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,211 @@
import React, { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import MovieIcon from '@mui/icons-material/Movie';
import PlayArrowRounded from '@mui/icons-material/PlayArrowRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { videoEmbeds, type VideoEmbed } from '../catalog';
import YouTubePlayer from './YouTubePlayer';
// The visible band of the cropped YouTube player, and how far it is slid up to hide the letterboxed top.
const YT_WINDOW_HEIGHT = 500;
const YT_TOP_OFFSET = -160;
// maxresdefault is missing for some uploads, so fall back to the size every video has.
function fallbackThumb(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.currentTarget;
if (img.src.includes('maxresdefault')) img.src = img.src.replace('maxresdefault', 'hqdefault');
}
// A facade player: at rest a clean poster plus our own play button, so the resting state matches the rest of the UI.
function DemoVideo({ embed }: { embed: VideoEmbed }) {
const c = useClaudeTokens();
const [playing, setPlaying] = useState(false);
const frame = {
position: 'relative' as const,
width: '100%',
aspectRatio: '16 / 9',
mb: 3,
borderRadius: 3,
overflow: 'hidden',
bgcolor: '#000',
border: `1px solid ${c.border.subtle}`,
};
if (embed.kind === 'file') {
return (
<Box sx={frame}>
<Box
component="video"
src={embed.src}
controls
preload="metadata"
sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
/>
</Box>
);
}
// The overflow crop is the only thing that removes YouTube's edge-anchored chrome from the square player.
if (playing && embed.videoId) {
return (
<Box sx={{ height: YT_WINDOW_HEIGHT, borderRadius: 3, overflow: 'hidden' }}>
<Box sx={{ mt: `${YT_TOP_OFFSET}px` }}>
<YouTubePlayer videoId={embed.videoId} />
</Box>
</Box>
);
}
return (
<Box
onClick={() => setPlaying(true)}
role="button"
aria-label="Play demo video"
sx={{ ...frame, cursor: 'pointer', '&:hover .demo-play': { transform: 'translate(-50%, -50%) scale(1.06)' } }}
>
{embed.poster && (
<Box
component="img"
src={embed.poster}
alt=""
onError={fallbackThumb}
sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
<Box sx={{ position: 'absolute', inset: 0, bgcolor: 'rgba(0,0,0,0.18)' }} />
<Box
className="demo-play"
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 68,
height: 68,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(8px)',
boxShadow: c.shadow.lg,
transition: c.transition,
}}
>
<PlayArrowRounded sx={{ fontSize: 40, color: c.text.primary, ml: 0.5 }} />
</Box>
</Box>
);
}
function UpNextThumb({
item,
active,
onClick,
}: {
item: { url: string; embed: VideoEmbed };
active: boolean;
onClick: () => void;
}) {
const c = useClaudeTokens();
return (
<Box
onClick={onClick}
role="button"
aria-label="Play this video"
sx={{
position: 'relative',
width: { xs: 140, sm: '100%' },
flexShrink: 0,
aspectRatio: '16 / 9',
borderRadius: 2,
overflow: 'hidden',
cursor: 'pointer',
bgcolor: '#000',
border: `2px solid ${active ? c.accent.primary : c.border.subtle}`,
boxShadow: active ? `0 0 0 3px ${c.accent.primary}33` : 'none',
transition: c.transition,
opacity: active ? 1 : 0.82,
'&:hover': { opacity: 1 },
}}
>
{item.embed.poster ? (
<Box
component="img"
src={item.embed.poster}
alt=""
onError={fallbackThumb}
sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<MovieIcon sx={{ fontSize: 28, color: 'rgba(255,255,255,0.6)' }} />
</Box>
)}
<Box sx={{ position: 'absolute', inset: 0, bgcolor: active ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.12)' }} />
{active && (
<Stack
direction="row"
alignItems="center"
spacing={0.5}
sx={{
position: 'absolute',
bottom: 6,
left: 6,
px: 0.75,
py: 0.25,
borderRadius: 999,
bgcolor: c.accent.primary,
boxShadow: c.shadow.sm,
}}
>
<GraphicEqIcon sx={{ fontSize: 13, color: '#fff' }} />
<Typography sx={{ fontSize: '0.62rem', fontWeight: 700, color: '#fff', lineHeight: 1, letterSpacing: '0.01em' }}>
Now playing
</Typography>
</Stack>
)}
</Box>
);
}
// Keying DemoVideo on the active URL remounts it, so switching clips starts the new one cleanly.
export default function PackageVideoSection({ raw }: { raw: string }) {
const c = useClaudeTokens();
const videos = useMemo(() => videoEmbeds(raw), [raw]);
const [activeUrl, setActiveUrl] = useState(videos[0]?.url ?? '');
if (videos.length === 0) return null;
const active = videos.find((v) => v.url === activeUrl) ?? videos[0];
const hasUpNext = videos.length > 1;
return (
<Stack
direction={{ xs: 'column', sm: hasUpNext ? 'row' : 'column' }}
spacing={hasUpNext ? 2 : 0}
alignItems="flex-start"
>
<Box sx={{ flex: 1, minWidth: 0, width: '100%', '& > *': { mb: '0 !important' } }}>
<DemoVideo key={active.url} embed={active.embed} />
</Box>
{hasUpNext && (
<Box sx={{ width: { xs: '100%', sm: 118 }, flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.muted, mb: 1 }}>
Up next
</Typography>
<Stack
direction={{ xs: 'row', sm: 'column' }}
sx={{ gap: 1.25, overflowX: { xs: 'auto', sm: 'visible' }, pb: 0.5 }}
>
{videos.map((v) => (
<UpNextThumb key={v.url} item={v} active={v.url === active.url} onClick={() => setActiveUrl(v.url)} />
))}
</Stack>
</Box>
)}
</Stack>
);
}
@@ -0,0 +1,292 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import PlayArrowRounded from '@mui/icons-material/PlayArrowRounded';
import PauseRounded from '@mui/icons-material/PauseRounded';
import VolumeUpRounded from '@mui/icons-material/VolumeUpRounded';
import VolumeOffRounded from '@mui/icons-material/VolumeOffRounded';
import FullscreenRounded from '@mui/icons-material/FullscreenRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface YouTubePlayerApi {
playVideo: () => void;
pauseVideo: () => void;
mute: () => void;
unMute: () => void;
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
getCurrentTime: () => number;
getDuration: () => number;
destroy: () => void;
}
interface YouTubeReadyEvent {
target: YouTubePlayerApi;
}
interface YouTubeStateEvent {
target: YouTubePlayerApi;
data: number;
}
interface YouTubeApi {
Player: new (
host: HTMLElement,
options: {
videoId: string;
playerVars: Record<string, number>;
events: {
onReady: (e: YouTubeReadyEvent) => void;
onStateChange: (e: YouTubeStateEvent) => void;
};
},
) => YouTubePlayerApi;
PlayerState: { PLAYING: number };
}
declare global {
interface Window {
YT?: YouTubeApi;
onYouTubeIframeAPIReady?: () => void;
}
}
let apiPromise: Promise<void> | null = null;
// The iframe API script is global and answers through one window callback, so every player shares a single load.
function loadApi(): Promise<void> {
if (window.YT?.Player) return Promise.resolve();
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve) => {
const prev = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
prev?.();
resolve();
};
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
});
return apiPromise;
}
function fmt(seconds: number): string {
const t = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function YouTubePlayer({ videoId }: { videoId: string }) {
const c = useClaudeTokens();
const hostRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YouTubePlayerApi | null>(null);
const rafRef = useRef<number | null>(null);
const [ready, setReady] = useState(false);
const [playing, setPlaying] = useState(false);
const [muted, setMuted] = useState(false);
const [cur, setCur] = useState(0);
const [dur, setDur] = useState(0);
const [hover, setHover] = useState(false);
useEffect(() => {
let disposed = false;
void loadApi().then(() => {
const api = window.YT;
if (disposed || !api || !hostRef.current) return;
playerRef.current = new api.Player(hostRef.current, {
videoId,
playerVars: {
autoplay: 1,
controls: 0,
modestbranding: 1,
rel: 0,
fs: 0,
disablekb: 1,
iv_load_policy: 3,
playsinline: 1,
},
events: {
onReady: (e) => {
setReady(true);
setDur(e.target.getDuration() || 0);
e.target.playVideo();
},
onStateChange: (e) => {
const playingNow = e.data === api.PlayerState.PLAYING;
setPlaying(playingNow);
if (playingNow) setDur(e.target.getDuration() || 0);
},
},
});
});
return () => {
disposed = true;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
try {
playerRef.current?.destroy();
} catch {
// A player torn down with its iframe already gone throws on destroy; nothing left to clean up.
}
};
}, [videoId]);
useEffect(() => {
const tick = () => {
const p = playerRef.current;
if (p?.getCurrentTime) setCur(p.getCurrentTime() || 0);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
const togglePlay = () => {
const p = playerRef.current;
if (!p) return;
if (playing) p.pauseVideo();
else p.playVideo();
};
const toggleMute = () => {
const p = playerRef.current;
if (!p) return;
if (muted) {
p.unMute();
setMuted(false);
} else {
p.mute();
setMuted(true);
}
};
const seek = (e: React.MouseEvent<HTMLDivElement>) => {
const p = playerRef.current;
if (!p || !dur) return;
const rect = e.currentTarget.getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
p.seekTo(frac * dur, true);
setCur(frac * dur);
};
const goFullscreen = () => {
const el = hostRef.current?.parentElement;
el?.requestFullscreen?.();
};
// Square frame on purpose: YouTube anchors its title and control chrome to the player box, so a box taller than 16:9 letterboxes the video and lands that chrome on the black bands the caller crops away.
const frame = {
position: 'relative' as const,
width: '100%',
aspectRatio: '1 / 1',
mb: 3,
borderRadius: 3,
overflow: 'hidden',
bgcolor: '#000',
border: `1px solid ${c.border.subtle}`,
};
return (
<Box sx={frame} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<Box
ref={hostRef}
sx={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
'& iframe': { width: '100%', height: '100%', border: 0, display: 'block' },
}}
/>
{/* Swallows every hover and click so YouTube never paints its own chrome. */}
<Box onClick={togglePlay} sx={{ position: 'absolute', inset: 0, cursor: 'pointer', zIndex: 2 }} />
{/* Opaque while paused, hiding YouTube's title, share and watch-later overlay behind our own play button. */}
<Box
onClick={togglePlay}
sx={{
position: 'absolute',
inset: 0,
zIndex: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
bgcolor: '#000',
opacity: ready && playing ? 0 : 1,
pointerEvents: ready && playing ? 'none' : 'auto',
transition: 'opacity 160ms ease',
}}
>
<Box
sx={{
width: 68,
height: 68,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(255,255,255,0.92)',
boxShadow: c.shadow.lg,
}}
>
<PlayArrowRounded sx={{ fontSize: 40, color: '#111', ml: 0.5 }} />
</Box>
</Box>
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
px: 1.5,
pt: 3,
pb: 1,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 100%)',
opacity: playing && hover ? 1 : 0,
pointerEvents: playing && hover ? 'auto' : 'none',
transition: 'opacity 160ms ease',
}}
>
<Box
onClick={seek}
sx={{ position: 'relative', height: 4, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.28)', cursor: 'pointer', mb: 0.5 }}
>
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${dur ? (cur / dur) * 100 : 0}%`,
bgcolor: '#fff',
borderRadius: 2,
}}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton onClick={togglePlay} size="small" sx={{ color: '#fff' }}>
{playing ? <PauseRounded /> : <PlayArrowRounded />}
</IconButton>
<IconButton onClick={toggleMute} size="small" sx={{ color: '#fff' }}>
{muted ? <VolumeOffRounded /> : <VolumeUpRounded />}
</IconButton>
<Box sx={{ fontSize: '0.78rem', color: '#fff', fontVariantNumeric: 'tabular-nums', ml: 0.5 }}>
{fmt(cur)} / {fmt(dur)}
</Box>
<Box sx={{ flex: 1 }} />
<IconButton onClick={goFullscreen} size="small" sx={{ color: '#fff' }}>
<FullscreenRounded />
</IconButton>
</Box>
</Box>
</Box>
);
}
@@ -0,0 +1,25 @@
import { API_BASE } from '@/shared/config';
import type { ImportPreflight } from '@/app/components/share/shareTypes';
// Installing a marketplace package is the ordinary bundle import with the download done for you:
// the backend fetches the .swarm and stages it, then the SAME confirm surface and commit route a
// dropped file uses take over. One door, so the secret review and the skill-confirm rule cannot
// hold on one path and not the other.
export async function stagePackageInstall(listingId: string): Promise<ImportPreflight> {
const res = await fetch(`${API_BASE}/marketplace/install/preflight`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: listingId }),
});
if (!res.ok) {
let detail = "We couldn't download this package.";
try {
const body = (await res.json()) as { detail?: string };
if (body.detail) detail = body.detail;
} catch {
// A non-JSON error body is still an error; the default sentence stands.
}
throw new Error(detail);
}
return res.json();
}
@@ -0,0 +1,43 @@
import type { Listing } from './catalog';
// A package's long-form writeup is authored in Notion; we model it as structured blocks and render it natively rather than iframing Notion, which would drag in its own fonts, chrome and network load with no theming.
export type DetailBlock =
| { type: 'heading'; emoji?: string; text: string }
| { type: 'paragraph'; text: string }
| { type: 'callout'; emoji?: string; title?: string; text: string; tone?: 'default' | 'warning' | 'info' }
| { type: 'features'; items: { emoji: string; title: string; text: string }[] }
| { type: 'steps'; items: string[] }
| { type: 'bullets'; items: string[] }
| { type: 'faq'; items: { q: string; a: string }[] };
export interface DetailDoc {
title: string;
meta?: string;
tagline?: string;
tags?: string[];
blocks: DetailBlock[];
}
// Null when the listing has no linked page or the stored JSON is malformed, and the dialog then renders nothing below the video.
export function detailsForListing(listing: Listing): DetailDoc | null {
const raw = (listing.details_json || '').trim();
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isDetailDoc(parsed) ? parsed : null;
} catch {
return null;
}
}
// details_json arrives from a spreadsheet cell, so the shape is validated at this boundary; per-block fields are not, because PackageDetails already skips block types it does not recognize.
function isDetailDoc(v: unknown): v is DetailDoc {
if (!v || typeof v !== 'object') return false;
const doc = v as Record<string, unknown>;
if (typeof doc.title !== 'string') return false;
if (!Array.isArray(doc.blocks)) return false;
return doc.blocks.every(
(b) => b && typeof b === 'object' && typeof (b as Record<string, unknown>).type === 'string',
);
}
@@ -0,0 +1,62 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
import type { Listing } from '@/app/pages/Directory/packages/catalog';
const MARKETPLACE_API = `${API_BASE}/marketplace`;
// 'cache' means the sheet was unreachable and these are the last listings we saw, which the tab says out loud.
export type CatalogSource = 'sheet' | 'cache' | 'empty';
interface CatalogState {
listings: Listing[];
source: CatalogSource;
loading: boolean;
loaded: boolean;
error: string;
}
const initialState: CatalogState = { listings: [], source: 'empty', loading: false, loaded: false, error: '' };
interface CatalogPayload {
source: CatalogSource;
count: number;
listings: Listing[];
error: string;
}
export const fetchMarketplaceListings = createAsyncThunk(
'marketplaceCatalog/fetch',
async (refresh: boolean = false) => {
const res = await fetch(`${MARKETPLACE_API}/listings${refresh ? '?refresh=true' : ''}`);
if (!res.ok) throw new Error(`Marketplace listings failed: ${res.status}`);
return (await res.json()) as CatalogPayload;
},
{ condition: (_, { getState }) => !(getState() as { marketplaceCatalog: CatalogState }).marketplaceCatalog.loading },
);
const marketplaceCatalogSlice = createSlice({
name: 'marketplaceCatalog',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchMarketplaceListings.pending, (state) => {
state.loading = true;
})
.addCase(fetchMarketplaceListings.fulfilled, (state, action) => {
state.loading = false;
state.loaded = true;
state.listings = action.payload.listings;
state.source = action.payload.source;
state.error = action.payload.error;
})
.addCase(fetchMarketplaceListings.rejected, (state, action) => {
state.loading = false;
state.loaded = true;
// Keep whatever listings we already had: a failed refresh must not empty a populated store.
state.error = action.error.message || 'Could not reach the marketplace.';
});
},
});
export default marketplaceCatalogSlice.reducer;
+2
View File
@@ -8,6 +8,7 @@ import modesReducer from './modesSlice';
import settingsReducer from './settingsSlice';
import mcpRegistryReducer from './mcpRegistrySlice';
import skillRegistryReducer from './skillRegistrySlice';
import marketplaceCatalogReducer from './marketplaceCatalogSlice';
import outputsReducer from './outputsSlice';
import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
@@ -31,6 +32,7 @@ export const store = configureStore({
settings: settingsReducer,
mcpRegistry: mcpRegistryReducer,
skillRegistry: skillRegistryReducer,
marketplaceCatalog: marketplaceCatalogReducer,
outputs: outputsReducer,
dashboardLayout: dashboardLayoutReducer,
dashboards: dashboardsReducer,