[eric] marketplace: Get on every card and sheet, spinner, then Open; installs remembered by the backend and Open launches the app, workflow or skill

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-03 09:14:31 -07:00
co-authored by Claude Fable 5.1
parent 2afbdc5b05
commit bc1e7acac9
12 changed files with 338 additions and 50 deletions
+70
View File
@@ -0,0 +1,70 @@
"""What this machine installed from the marketplace, by listing id.
The App Store knows what is installed; without a record every package showed Install again after a
restart, and Open had nothing to open. The importer creates a fresh entity per commit and returns its
id, so the record is written at commit time and maps a listing to the thing it became. A record whose
entity has since been deleted is stale by design: the store re-checks the live lists before showing Open.
"""
import json
import os
import time
from typing import Dict, Optional
from pydantic import BaseModel, ConfigDict
from typeguard import typechecked
from backend.config.paths import DATA_ROOT
INSTALLS_PATH = os.path.join(DATA_ROOT, "marketplace_installs.json")
class InstallRecord(BaseModel):
"""One pointer per importable root, typed, so an id can never point at a kind nobody can resolve."""
model_config = ConfigDict(validate_assignment=True)
listing_id: str
root_type: str
output_id: Optional[str] = None
skill_id: Optional[str] = None
workflow_id: Optional[str] = None
dashboard_id: Optional[str] = None
session_id: Optional[str] = None
version: str = ""
installed_at: float = 0.0
def root_id(self) -> Optional[str]:
return self.output_id or self.skill_id or self.workflow_id or self.dashboard_id or self.session_id
@typechecked
def load_installs(path: Optional[str] = None) -> Dict[str, InstallRecord]:
p = path or INSTALLS_PATH
try:
with open(p, "r", encoding="utf-8") as f:
raw = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
out: Dict[str, InstallRecord] = {}
for key, value in (raw or {}).items():
try:
out[key] = InstallRecord.model_validate(value)
except Exception:
continue
return out
@typechecked
def record_install(rec: InstallRecord, path: Optional[str] = None) -> Dict[str, InstallRecord]:
p = path or INSTALLS_PATH
installs = load_installs(p)
if rec.installed_at == 0.0:
rec = rec.model_copy(update={"installed_at": time.time()})
installs[rec.listing_id] = rec
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
tmp = p + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({k: v.model_dump() for k, v in installs.items()}, f, indent=1)
os.replace(tmp, p)
return installs
+19
View File
@@ -14,6 +14,7 @@ from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict
from backend.apps.marketplace import catalog
from backend.apps.marketplace.installs import InstallRecord, load_installs, record_install
from backend.apps.marketplace.package_download import (
DownloadRefused,
download_package,
@@ -61,3 +62,21 @@ async def install_preflight(body: InstallRequest) -> ImportPreflightResponse:
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))
class InstallsResponse(BaseModel):
model_config = ConfigDict(validate_assignment=True)
installs: dict[str, InstallRecord]
@marketplace.router.get("/installed")
async def get_installed() -> InstallsResponse:
return InstallsResponse(installs=load_installs())
@marketplace.router.post("/installed")
async def post_installed(body: InstallRecord) -> InstallsResponse:
if not body.listing_id or not body.root_type or not body.root_id():
raise HTTPException(status_code=400, detail="listing_id, root_type and the id of what was installed are required")
return InstallsResponse(installs=record_install(body))
+12
View File
@@ -32,6 +32,9 @@ class EntityKind(str, Enum):
CLOUD_WORKFLOW = "cloud_workflow"
# A provider login in 9router's own db, not one of our JSON records.
PROVIDER_CONNECTION = "provider_connection"
SKILL = "skill"
# A row of the published marketplace sheet, resolved from the catalog we last fetched.
MARKETPLACE_LISTING = "marketplace_listing"
class EntityStore(BaseModel):
@@ -67,9 +70,18 @@ ENTITY_STORES: List[EntityStore] = [
# The one referent that does not live on this machine. preflight asks the cloud whether it still has the row; a miss renders as "nothing is running this", never as a silent blank.
EntityStore(kind=EntityKind.CLOUD_WORKFLOW, module="backend.apps.workflows.cloud.client", lookup="preflight"),
EntityStore(kind=EntityKind.PROVIDER_CONNECTION, module="backend.apps.nine_router.credential_store", lookup="read_credential"),
# A skill is a folder under ~/.claude/skills; its only by-id lookup is the path resolver.
EntityStore(kind=EntityKind.SKILL, module="backend.apps.skills.skills", lookup="skill_md_path"),
EntityStore(kind=EntityKind.MARKETPLACE_LISTING, module="backend.apps.marketplace.catalog", lookup="find_listing"),
]
CROSS_ENTITY_REFERENCES: List[EntityReference] = [
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="listing_id", target=EntityKind.MARKETPLACE_LISTING),
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="output_id", target=EntityKind.OUTPUT),
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="skill_id", target=EntityKind.SKILL),
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="workflow_id", target=EntityKind.WORKFLOW),
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="dashboard_id", target=EntityKind.DASHBOARD),
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="session_id", target=EntityKind.SESSION),
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="dashboard_id", target=EntityKind.DASHBOARD),
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="selected_app_output_ids", target=EntityKind.OUTPUT),
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="workflow_edit_id", target=EntityKind.WORKFLOW),
@@ -0,0 +1,31 @@
"""The store must remember what it installed across restarts, and survive a bad file."""
import json
from backend.apps.marketplace.installs import InstallRecord, load_installs, record_install
def test_a_recorded_install_round_trips(tmp_path):
p = str(tmp_path / "installs.json")
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-1", version="1.0.0"), p)
record_install(InstallRecord(listing_id="hello", root_type="skill", skill_id="sk-1", version="1.0.0"), p)
got = load_installs(p)
assert set(got) == {"git-graph", "hello"}
assert got["git-graph"].output_id == "out-1" and got["git-graph"].installed_at > 0
def test_reinstalling_replaces_the_record_for_that_listing(tmp_path):
p = str(tmp_path / "installs.json")
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-1", version="1.0.0"), p)
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-2", version="1.1.0"), p)
got = load_installs(p)
assert len(got) == 1 and got["git-graph"].output_id == "out-2" and got["git-graph"].version == "1.1.0"
def test_a_missing_or_corrupt_file_reads_as_nothing_installed(tmp_path):
p = str(tmp_path / "installs.json")
assert load_installs(p) == {}
(tmp_path / "installs.json").write_text("{not json")
assert load_installs(p) == {}
(tmp_path / "installs.json").write_text(json.dumps({"x": {"listing_id": "x"}}))
assert load_installs(p) == {}
@@ -11,6 +11,7 @@ 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 { addViewCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import ImportModal from '@/app/components/share/ImportModal';
import { importNeedsConfirm } from '@/app/components/share/importNeedsConfirm';
import { importCommit } from '@/app/components/share/shareApi';
@@ -21,6 +22,7 @@ import PackageDialog from './packages/detail/PackageDialog';
import PackageBundleCard from './packages/PackageBundleCard';
import PackageBundleDialog from './packages/detail/PackageBundleDialog';
import { stagePackageInstall } from './packages/installPackage';
import { fetchInstalls, installState, recordFor, recordInstall, type InstallRecord, type PillState } from './packages/installs';
import { KIND_LABELS, isBundle, resolveBundleMembers, type Listing } from './packages/catalog';
type Toast = { message: string; severity: 'success' | 'error' } | null;
@@ -28,25 +30,47 @@ 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 DirectoryPackagesTab: React.FC<{ onOpenSkill?: (skillId: string) => void }> = ({ onOpenSkill }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { listings, loading, loaded, source, error } = useAppSelector((s) => s.marketplaceCatalog);
const outputs = useAppSelector((s) => s.outputs.items);
const skills = useAppSelector((s) => s.skills.items);
const workflows = useAppSelector((s) => s.workflows.items);
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 [installs, setInstalls] = useState<Record<string, InstallRecord>>({});
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));
// The live lists decide whether Open still has something to open; the record alone only says Get is done.
dispatch(fetchOutputs());
dispatch(fetchSkills());
dispatch(fetchWorkflows(undefined));
let alive = true;
fetchInstalls().then((m) => { if (alive) setInstalls(m); }).catch(() => {});
return () => { alive = false; };
}, [dispatch]);
const stateFor = (listing: Listing): PillState => (
installingId === listing.id ? 'installing' : installState(listing, installs[listing.id], { outputs, skills, workflows })
);
const openInstalled = (listing: Listing) => {
const rec = installs[listing.id];
if (!rec) return;
if (rec.output_id) dispatch(addViewCard({ outputId: rec.output_id }));
else if (rec.workflow_id) dispatch(openWorkflowsApp({ workflowId: rec.workflow_id }));
else if (rec.skill_id) onOpenSkill?.(rec.skill_id);
};
const packages = useMemo(() => listings.filter((l) => !isBundle(l)), [listings]);
const bundles = useMemo(() => listings.filter(isBundle), [listings]);
const kindOptions = useMemo(
@@ -75,21 +99,26 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
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' });
// The pill turning into Open is the feedback, the way the App Store does it: no toast, no tab jump.
const finish = async (listingId: string, rootType: string, rootId: string) => {
const version = listings.find((l) => l.id === listingId)?.version ?? '';
// 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);
try {
setInstalls(await recordInstall(recordFor(listingId, rootType, rootId, version)));
} catch (e: unknown) {
setInstalls((prev) => ({ ...prev, [listingId]: { ...recordFor(listingId, rootType, rootId, version), installed_at: Date.now() / 1000 } }));
setToast({ message: e instanceof Error ? e.message : "Installed, but the store couldn't remember it.", severity: 'error' });
}
};
const commit = async (preflight: ImportPreflight, listingId: string) => {
setCommitting(true);
try {
const res = await importCommit(preflight.staging_token);
finish(preflight, listingId, res.root_type);
await finish(listingId, res.root_type, res.root_id);
setConfirm(null);
} catch (e: unknown) {
setToast({ message: e instanceof Error ? e.message : "We couldn't finish the install.", severity: 'error' });
@@ -183,7 +212,10 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
<PackageCard
key={listing.id}
listing={listing}
state={stateFor(listing)}
onOpen={() => setOpenListing(listing)}
onGet={() => { void install(listing.id); }}
onOpenInstalled={() => openInstalled(listing)}
onTag={(tag) => setQuery(tag)}
/>
))}
@@ -224,15 +256,16 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
<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)}
state={openListing ? stateFor(openListing) : 'get'}
onInstall={() => { if (openListing) void install(openListing.id); }}
onOpen={() => { if (openListing) openInstalled(openListing); }}
onClose={() => setOpenListing(null)}
/>
<PackageBundleDialog
bundle={openBundle}
members={openBundle ? resolveBundleMembers(openBundle, listings) : []}
installedIds={installedIds}
stateOf={(id) => { const l = listings.find((x) => x.id === id); return l ? stateFor(l) : 'get'; }}
onOpenInstalled={(member) => openInstalled(member)}
installing={installingId !== null}
onInstallAll={() => { if (openBundle) void installBundle(openBundle); }}
onInstallMember={(id) => { void install(id); }}
@@ -66,7 +66,7 @@ const MarketplaceBody: React.FC = () => {
const content = (): React.ReactElement => {
switch (view) {
case 'packages':
return <DirectoryPackagesTab onInstalled={(rootType) => { if (rootType === 'skill') { setFocusSkillId(null); setView('my-skills'); } }} />;
return <DirectoryPackagesTab onOpenSkill={(id) => { setFocusSkillId(id); setView('my-skills'); }} />;
case 'connectors':
return <DirectoryConnectorsTab onOpenInstalled={(id) => { setFocusToolId(id); setView('my-connectors'); }} />;
case 'my-skills':
@@ -0,0 +1,57 @@
import React from 'react';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { PillState } from './installs';
interface Props {
state: PillState;
disabled?: boolean;
onGet: () => void;
onOpen: () => void;
size?: 'sm' | 'md';
}
// The one action a package has, the way the App Store draws it: Get, a spinner while it lands, then Open. It swallows the click so a card underneath never opens its sheet.
export default function InstallPill({ state, disabled, onGet, onOpen, size = 'md' }: Props) {
const c = useClaudeTokens();
const sm = size === 'sm';
const base = {
borderRadius: `${c.radius.full}px`,
textTransform: 'none' as const,
fontWeight: 650,
fontSize: sm ? '0.75rem' : '0.8125rem',
px: sm ? 1.75 : 2.25,
py: sm ? 0.35 : 0.6,
minWidth: sm ? 58 : 72,
lineHeight: 1.5,
flexShrink: 0,
};
const stop = (e: React.MouseEvent) => { e.stopPropagation(); };
if (state === 'installed') {
return (
<Button onClick={stop} disabled variant="outlined" startIcon={<CheckRoundedIcon sx={{ fontSize: 15 }} />} sx={{ ...base, '&.Mui-disabled': { color: c.text.muted, borderColor: c.border.subtle } }}>
Installed
</Button>
);
}
if (state === 'open') {
return (
<Button onClick={(e) => { stop(e); onOpen(); }} variant="outlined" sx={{ ...base, color: c.accent.primary, borderColor: c.accent.primary, bgcolor: c.bg.surface, '&:hover': { bgcolor: c.bg.elevated, borderColor: c.accent.hover } }}>
Open
</Button>
);
}
return (
<Button
onClick={(e) => { stop(e); if (state === 'get') onGet(); }}
disabled={disabled || state === 'installing'}
variant="contained"
disableElevation
sx={{ ...base, bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.accent.primary, color: c.text.inverse, opacity: state === 'installing' ? 1 : 0.5 } }}
>
{state === 'installing' ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : 'Get'}
</Button>
);
}
@@ -6,14 +6,19 @@ import ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { parseTags, KIND_LABELS, type Listing } from './catalog';
import PackageTagRow from './detail/PackageTagRow';
import InstallPill from './InstallPill';
import type { PillState } from './installs';
interface Props {
listing: Listing;
state: PillState;
onOpen: () => void;
onGet: () => void;
onOpenInstalled: () => void;
onTag: (tag: string) => void;
}
export default function PackageCard({ listing, onOpen, onTag }: Props) {
export default function PackageCard({ listing, state, onOpen, onGet, onOpenInstalled, onTag }: Props) {
const c = useClaudeTokens();
const tags = parseTags(listing.tags).slice(0, 3);
@@ -73,6 +78,7 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) {
{listing.author ? ` · ${listing.author}` : ''}
</Typography>
</Box>
<InstallPill state={state} disabled={!listing.download_url} onGet={onGet} onOpen={onOpenInstalled} size="sm" />
</Stack>
<Typography
@@ -7,18 +7,20 @@ import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
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 ExtensionIcon from '@mui/icons-material/Extension';
import Inventory2Icon from '@mui/icons-material/Inventory2Outlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { KIND_LABELS, parseTags, type Listing } from '../catalog';
import PackageTagRow from './PackageTagRow';
import InstallPill from '../InstallPill';
import type { PillState } from '../installs';
interface Props {
bundle: Listing | null;
members: Listing[];
installedIds: string[];
stateOf: (listingId: string) => PillState;
onOpenInstalled: (listing: Listing) => void;
onClose: () => void;
onOpenMember: (member: Listing) => void;
onInstallAll: () => void;
@@ -28,11 +30,11 @@ interface Props {
// A bundle has no package of its own: the sheet lists it, the dialog installs its members. There is no
// file to download here on purpose; a store page installs, it does not hand out archives.
export default function PackageBundleDialog({ bundle, members, installedIds, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) {
export default function PackageBundleDialog({ bundle, members, stateOf, onOpenInstalled, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) {
const c = useClaudeTokens();
if (!bundle) return null;
const installable = members.filter((m) => m.download_url);
const allInstalled = installable.length > 0 && installable.every((m) => installedIds.includes(m.id));
const allInstalled = installable.length > 0 && installable.every((m) => stateOf(m.id) !== 'get');
const pill = { borderRadius: `${c.radius.full}px`, textTransform: 'none' as const, fontWeight: 600, fontSize: '0.8125rem', px: 2, py: 0.6, minWidth: 0, whiteSpace: 'nowrap' as const };
return (
@@ -75,7 +77,7 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
disableElevation
sx={{ ...pill, bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.muted } }}
>
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : allInstalled ? 'Installed' : 'Install all'}
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : allInstalled ? 'Installed' : 'Get all'}
</Button>
</Stack>
@@ -94,7 +96,6 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
) : (
<Stack spacing={0.75}>
{members.map((m) => {
const done = installedIds.includes(m.id);
return (
<Box
key={m.id}
@@ -115,22 +116,7 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
{KIND_LABELS[m.kind] || m.kind || 'Package'}{m.version ? ` · v${m.version}` : ''}
</Typography>
</Box>
{done ? (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: c.text.muted, fontSize: '0.8125rem', flexShrink: 0 }}>
<CheckRoundedIcon sx={{ fontSize: 16 }} />
<span>Installed</span>
</Stack>
) : (
<Button
onClick={(e) => { e.stopPropagation(); onInstallMember(m.id); }}
disabled={installing || !m.download_url}
variant="outlined"
size="small"
sx={{ ...pill, borderColor: c.border.strong, color: c.text.primary, '&:hover': { borderColor: c.text.primary, bgcolor: c.bg.elevated } }}
>
Install
</Button>
)}
<InstallPill state={stateOf(m.id)} disabled={!m.download_url} onGet={() => onInstallMember(m.id)} onOpen={() => onOpenInstalled(m)} size="sm" />
</Box>
);
})}
@@ -1,13 +1,10 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
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 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 ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -15,19 +12,21 @@ import { KIND_LABELS, parseTags, type Listing } from '../catalog';
import { detailsForListing } from '../notionDetails';
import PackageDetails from './PackageDetails';
import PackageTagRow from './PackageTagRow';
import InstallPill from '../InstallPill';
import type { PillState } from '../installs';
import PackageVideoSection from './PackageVideoSection';
interface Props {
listing: Listing | null;
onClose: () => void;
state: PillState;
onInstall: () => void;
installing: boolean;
installed?: boolean;
onOpen: () => void;
}
// The package sheet: one Install action, no file to download. The bundle is fetched and reviewed by the
// same import path a dropped .swarm takes, so the raw archive has no job on a store page.
export default function PackageDialog({ listing, onClose, onInstall, installing, installed }: Props) {
export default function PackageDialog({ listing, onClose, state, onInstall, onOpen }: Props) {
const c = useClaudeTokens();
if (!listing) return null;
const details = detailsForListing(listing);
@@ -70,16 +69,7 @@ export default function PackageDialog({ listing, onClose, onInstall, installing,
</Typography>
<Typography sx={{ mt: 0.4, fontSize: '0.8125rem', color: c.text.muted }}>{meta}</Typography>
</Box>
<Button
onClick={onInstall}
variant="contained"
disableElevation
disabled={installing || !!installed || !listing.download_url}
startIcon={installed ? <CheckRoundedIcon sx={{ fontSize: 16 }} /> : undefined}
sx={{ borderRadius: `${c.radius.full}px`, textTransform: 'none', fontWeight: 600, fontSize: '0.8125rem', px: 2.25, py: 0.6, minWidth: 0, whiteSpace: 'nowrap', bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.muted } }}
>
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : installed ? 'Installed' : 'Install'}
</Button>
<InstallPill state={state} disabled={!listing.download_url} onGet={onInstall} onOpen={onOpen} />
</Stack>
{listing.description && (
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installState } from './installs';
import type { Listing } from './catalog';
// The store's one button: Get until the machine remembers an install, Open while the thing it became still exists.
const listing = { id: 'git-graph', kind: 'app' } as Listing;
const rec = { listing_id: 'git-graph', root_type: 'app', output_id: 'out-1', version: '1.0.0', installed_at: 1 };
const none = { outputs: {}, skills: {}, workflows: {} };
test('no record means Get', () => {
assert.equal(installState(listing, undefined, none), 'get');
});
test('a recorded app is Open while its output exists, and Get again once it is gone', () => {
assert.equal(installState(listing, rec, { ...none, outputs: { 'out-1': {} } }), 'open');
assert.equal(installState(listing, rec, none), 'get');
});
test('skills and workflows open through their own lists; other kinds just read Installed', () => {
assert.equal(installState(listing, { listing_id: 'x', root_type: 'skill', skill_id: 'sk', version: '', installed_at: 1 }, { ...none, skills: { sk: {} } }), 'open');
assert.equal(installState(listing, { listing_id: 'x', root_type: 'workflow', workflow_id: 'wf', version: '', installed_at: 1 }, { ...none, workflows: { wf: {} } }), 'open');
assert.equal(installState(listing, { listing_id: 'x', root_type: 'mode', session_id: 'm', version: '', installed_at: 1 }, none), 'installed');
});
@@ -0,0 +1,60 @@
import { API_BASE } from '@/shared/config';
import type { Listing } from './catalog';
// One typed pointer per importable root, mirroring the backend record.
export interface InstallRecord {
listing_id: string;
root_type: string;
output_id?: string | null;
skill_id?: string | null;
workflow_id?: string | null;
dashboard_id?: string | null;
session_id?: string | null;
version: string;
installed_at: number;
}
export function recordFor(listingId: string, rootType: string, rootId: string, version: string): Omit<InstallRecord, 'installed_at'> {
const rec: Omit<InstallRecord, 'installed_at'> = { listing_id: listingId, root_type: rootType, version };
if (rootType === 'app') rec.output_id = rootId;
else if (rootType === 'skill') rec.skill_id = rootId;
else if (rootType === 'workflow') rec.workflow_id = rootId;
else if (rootType === 'dashboard') rec.dashboard_id = rootId;
else if (rootType === 'session' || rootType === 'mode') rec.session_id = rootId;
return rec;
}
export type PillState = 'get' | 'installing' | 'open' | 'installed';
interface LiveEntities {
outputs: Record<string, unknown>;
skills: Record<string, unknown>;
workflows: Record<string, unknown>;
}
// Get until the record says otherwise; Open only while the thing it became still exists, so a deleted app offers Get again instead of an Open that goes nowhere.
export function installState(listing: Listing, record: InstallRecord | undefined, live: LiveEntities): PillState {
if (!record) return 'get';
if (record.output_id) return record.output_id in live.outputs ? 'open' : 'get';
if (record.skill_id) return record.skill_id in live.skills ? 'open' : 'get';
if (record.workflow_id) return record.workflow_id in live.workflows ? 'open' : 'get';
return 'installed';
}
export async function fetchInstalls(): Promise<Record<string, InstallRecord>> {
const res = await fetch(`${API_BASE}/marketplace/installed`);
if (!res.ok) return {};
const body = (await res.json()) as { installs: Record<string, InstallRecord> };
return body.installs || {};
}
export async function recordInstall(rec: Omit<InstallRecord, 'installed_at'>): Promise<Record<string, InstallRecord>> {
const res = await fetch(`${API_BASE}/marketplace/installed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...rec, installed_at: 0 }),
});
if (!res.ok) throw new Error("Installed, but the store couldn't remember it.");
const body = (await res.json()) as { installs: Record<string, InstallRecord> };
return body.installs || {};
}