From d5c93dad2b9f9fc21d2061150262c51164f31caf Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 24 Aug 2026 20:42:55 -0600 Subject: [PATCH] feat: unify skills, MCP and integrations into one Marketplace tab Extending Roxy meant knowing which mechanism your wish was implemented by: a Skill (/skills), an MCP server (/mcp), an Integration (/integrations), or the Remote Workspace dialog. Four pages, four vocabularies, every one named after the plumbing rather than the outcome. To connect a database you had to already know the words "MCP server". Collapse all four into one noun -- an Add-on -- with a `kind` badge that explains the mechanism without being how the list is organized. The UX decisions, in order of how much they matter: 1. One noun. You search for "postgres", not for "an MCP server". 2. Two tabs (Installed / Discover), not seven. Labs is third only because it answers a different question: where is this going? 3. Search is the primary control, and it indexes the jargon we removed from the surface -- typing "mcp" or "SKILL.md" still finds the right cards. 4. Permissions on the card, before install. An add-on's risk is DERIVED from its capabilities (addonRisk) rather than self-reported, so nothing can advertise itself as harmless while asking to drive your keyboard. 5. One switch means one thing. Live toggle for anything real, an explicit "Preview" for shells, an interest signal for ideas. A control that looks live but is inert would poison every other control on the page. Also lands SuperUser: the first `critical` add-on, shipped deliberately as a shell. It is listed, describable and consent-gated, and it does nothing. It exists in this state so the permission model is designed against a real critical entry rather than a hypothetical one, and so the conversation about whether Roxy should operate the whole machine happens over a manifest, now, instead of over an implementation, later. And Labs: 12 candidate implementations published before they are built, each with its capabilities, what it builds on, and the honest objection to it. An idea with a manifest can be argued with; the same idea in a backlog can only be seen by its author. No new IPC and no migration -- installed rows are merged from the existing skills.list/mcp.list/integrations.list, and preview/lab flags persist through the integrations table under namespaced ids (`addon:` / `lab:`). Removes routes/Integrations.tsx: fully covered by the Marketplace and unreachable from the UI. /skills and /mcp are kept as the deep-dive editors the Marketplace links into. Adds ~45 checks to smoke:shared (935 passing). --- src/renderer/src/App.tsx | 6 +- src/renderer/src/components/PageShell.tsx | 6 +- src/renderer/src/components/Sidebar.tsx | 54 +- src/renderer/src/lib/icons.tsx | 33 +- src/renderer/src/routes/Integrations.tsx | 56 - src/renderer/src/routes/Marketplace.tsx | 1242 +++++++++++++++++++++ src/renderer/src/routes/Settings.tsx | 25 +- src/shared/marketplace.ts | 958 ++++++++++++++++ test/shared.ts | 188 ++++ 9 files changed, 2479 insertions(+), 89 deletions(-) delete mode 100644 src/renderer/src/routes/Integrations.tsx create mode 100644 src/renderer/src/routes/Marketplace.tsx create mode 100644 src/shared/marketplace.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2120137..7cddaee 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,9 +4,9 @@ import { useRoxyStore } from './lib/store' import roxy from './assets/roxy.png' import Onboarding from './routes/Onboarding' import Chat from './routes/Chat' -import Integrations from './routes/Integrations' import Skills from './routes/Skills' import Mcp from './routes/Mcp' +import Marketplace from './routes/Marketplace' import Settings from './routes/Settings' function Splash(): JSX.Element { @@ -39,7 +39,9 @@ export default function App(): JSX.Element { } /> : } /> - } /> + } /> + {/* Kept as the deep-dive surfaces the Marketplace links into: the skill + editor and the raw MCP JSON editor. The Marketplace is the front door. */} } /> } /> } /> diff --git a/src/renderer/src/components/PageShell.tsx b/src/renderer/src/components/PageShell.tsx index 77aa736..e1a37df 100644 --- a/src/renderer/src/components/PageShell.tsx +++ b/src/renderer/src/components/PageShell.tsx @@ -1,17 +1,21 @@ import { type ReactNode } from 'react' import { ArrowLeft } from 'lucide-react' +import { cn } from '../lib/cn' export function PageShell({ title, subtitle, onBack, actions, + wide = false, children }: { title: string subtitle?: string onBack: () => void actions?: ReactNode + /** Widen the column for grid/browse pages (Marketplace) instead of prose. */ + wide?: boolean children: ReactNode }): JSX.Element { return ( @@ -28,7 +32,7 @@ export function PageShell({ {actions &&
{actions}
}
-
+
{subtitle &&

{subtitle}

} {children}
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index c0a5e70..9a2f774 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -8,16 +8,15 @@ import { } from 'react' import { useNavigate } from 'react-router-dom' import { + Blocks, ChevronRight, FolderOpen, GitBranch, GitFork, Hammer, - Lightbulb, MonitorSmartphone, PanelLeftClose, PanelLeftOpen, - Plug, Plus, Repeat, Settings as SettingsIcon, @@ -439,18 +438,11 @@ export function Sidebar(): JSX.Element { )} - + } + > +
+
+ + {/* Search first: the primary control, not a filter tucked in a corner. */} +
+
+ + setQuery(e.target.value)} + placeholder="Search add-ons — try “postgres”, “review”, “phone”, or “mcp”" + className="h-10 pl-9" + spellCheck={false} + autoComplete="off" + /> + {query && ( + + )} +
+ + { + setTab(t) + setCategory(null) + setKind(null) + }} + /> + + {tab !== 'labs' && ( + + )} +
+ + {tab === 'labs' ? ( + void setFlag(labFlagId(idea.id), value)} + /> + ) : loading ? ( +

Reading your add-ons…

+ ) : visible.length === 0 ? ( + { + setQuery('') + setCategory(null) + setKind(null) + }} + onDiscover={() => setTab('discover')} + /> + ) : ( +
+ {visible.map((row) => ( + setSelected(row)} + onInstalled={reload} + onBusy={setBusy} + onFlag={setFlag} + /> + ))} +
+ )} + + {tab !== 'labs' && setManual((v) => !v)} />} +
+ + {selected && ( + setSelected(null)} onChanged={reload} /> + )} + + ) +} + +/** The one-paragraph frame: what an add-on is, before any list of them. */ +function Header(): JSX.Element { + return ( +
+

+ Add-ons are how Roxy gets new abilities — new tools, reusable playbooks, other places to + reach it from, other devices to run it on. Every one says up front what it is allowed to do, + and can be switched off without being removed. +

+
+ ) +} + +function Tabs({ + tab, + counts, + onChange +}: { + tab: Tab + counts: Record + onChange: (tab: Tab) => void +}): JSX.Element { + const items: { id: Tab; label: string; hint: string }[] = [ + { id: 'installed', label: 'Installed', hint: 'What Roxy can do right now' }, + { id: 'discover', label: 'Discover', hint: 'One-click add-ons' }, + { id: 'labs', label: 'Labs', hint: 'Ideas being explored' } + ] + return ( +
+ {items.map((it) => ( + + ))} +
+ ) +} + +/** + * Secondary filters. Kinds are listed *after* categories and worded as outcomes + * ("Tool server", not "MCP") — the mechanism is available to filter by, but it is + * not the first thing asked of you. + */ +function FilterRow({ + category, + kind, + onCategory, + onKind +}: { + category: AddonCategory | null + kind: AddonKind | null + onCategory: (c: AddonCategory | null) => void + onKind: (k: AddonKind | null) => void +}): JSX.Element { + return ( +
+ (onCategory(null), onKind(null))}> + All + + + {ADDON_CATEGORIES.map((c) => ( + onCategory(category === c ? null : c)}> + {c} + + ))} + + {(['skill', 'mcp'] as AddonKind[]).map((k) => ( + onKind(kind === k ? null : k)}> + {getAddonKind(k)?.label} + + ))} +
+ ) +} + +function Chip({ + active, + onClick, + children +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}): JSX.Element { + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Card +// --------------------------------------------------------------------------- + +function AddonCard({ + row, + busy, + onOpen, + onInstalled, + onBusy, + onFlag +}: { + row: AddonRow + busy: boolean + onOpen: () => void + onInstalled: () => Promise + onBusy: (id: string | null) => void + onFlag: (flag: string, value: boolean) => Promise +}): JSX.Element { + const { manifest, state } = row + const risk = addonRisk(manifest.capabilities) + const kindDef = getAddonKind(manifest.kind) + + return ( +
+
+ + + + + + +
+ +
+
+ +
+ + +
+
+ ) +} + +/** + * The single control on a card, chosen by state. The whole point is that its + * *shape* tells you what will happen: a switch for something live, a button for + * something to fetch, and flat text for a shell that isn't wired up. + */ +function CardAction({ + row, + busy, + onOpen, + onInstalled, + onBusy, + onFlag +}: { + row: AddonRow + busy: boolean + onOpen: () => void + onInstalled: () => Promise + onBusy: (id: string | null) => void + onFlag: (flag: string, value: boolean) => Promise +}): JSX.Element { + const { manifest, state } = row + + if (busy) return + + // A preview ships as a shell. It must read as "not built yet", never as "off": + // an inert switch that looks live is the one thing this page cannot do. + if (state === 'preview') { + return ( + + ) + } + + if (state === 'available') { + const needs = installNeeds(manifest.install) + return ( + + ) + } + + // Installed and real: one switch, and it means exactly what it says. + const flag = addonFlagId(manifest.id) + return ( + { + if (manifest.kind === 'mcp') { + onBusy(manifest.id) + void api.mcp + .setEnabled(manifest.id, v) + .then(onInstalled) + .finally(() => onBusy(null)) + } else if (manifest.kind === 'integration') { + void onFlag(manifest.id, v) + } else { + void onFlag(flag, v) + } + }} + /> + ) +} + +function StateBadge({ state }: { state: AddonState }): JSX.Element | null { + if (state === 'enabled' || state === 'available') return null + const styles: Partial> = { + disabled: 'text-text-subtle', + broken: 'border-danger/30 bg-danger/10 text-danger', + preview: 'border-warning/30 bg-warning/10 text-warning', + planned: 'text-text-subtle' + } + return {STATE_LABEL[state]} +} + +/** Permission chips, worst-first, so nothing alarming hides behind a "+2". */ +function CapabilityChips({ + capabilities, + max +}: { + capabilities: CapabilityId[] + max?: number +}): JSX.Element { + const caps = sortCapabilities(capabilities) + if (!caps.length) { + return No extra permissions + } + const shown = max ? caps.slice(0, max) : caps + return ( +
+ {shown.map((c) => ( + + + {c.label} + + ))} + {max && caps.length > max && ( + +{caps.length - max} + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Detail panel +// --------------------------------------------------------------------------- + +/** + * The "extension page": prose, the full permission list, and the install/consent + * control. A modal rather than a route so browsing state (query, tab, scroll) + * survives — reading about one add-on should not cost you your search. + */ +function DetailPanel({ + row, + onClose, + onChanged +}: { + row: AddonRow + onClose: () => void + onChanged: () => Promise +}): JSX.Element { + const { manifest, state } = row + const risk = addonRisk(manifest.capabilities) + const kindDef = getAddonKind(manifest.kind) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [consent, setConsent] = useState('') + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + const gated = needsConsent(manifest.capabilities) + + return ( +
+
e.stopPropagation()} + > +
+ + + +
+
+

{manifest.name}

+ {kindDef && {kindDef.label}} + +
+

+ {manifest.author ?? 'Community'} · {manifest.category} +

+
+ +
+ +
+

{manifest.tagline}

+ + {manifest.about?.map((para, i) => ( +

+ {para} +

+ ))} + + {kindDef && ( +

+ How it works: {kindDef.detail} +

+ )} + + {/* Permissions in full — the reason this page can be trusted. */} +
+

+ What it can do +

+ {manifest.capabilities.length === 0 ? ( +

+ Nothing beyond what Roxy already does. This add-on only changes how it behaves. +

+ ) : ( +
    + {sortCapabilities(manifest.capabilities).map((c) => ( +
  • + + + + + {c.label} + — {c.detail} + +
  • + ))} +
+ )} +
+ + {row.detail && ( +

{row.detail}

+ )} + {row.error && ( +

+ Last error: {row.error} +

+ )} + + {state === 'available' && installNeeds(manifest.install).length > 0 && ( +
+

+ Needs a value before it will run: {installNeeds(manifest.install).join(', ')}. Add + it, then fill it in with the {'{ }'} editor under + “Add manually”. +

+
+ )} + + {manifest.homepage && ( + + )} + + {/* Consent gate: for `critical` add-ons a switch is too cheap a gesture. */} + {gated && state !== 'preview' && ( +
+

+ + This add-on can act outside Roxy, as you. Type{' '} + I understand to enable it. +

+ setConsent(e.target.value)} + placeholder="I understand" + className="mt-2" + /> +
+ )} + + {error &&

{error}

} +
+ +
+ + {state === 'preview' + ? 'Shipped as a shell — the switch grants nothing yet.' + : 'Switching an add-on off never deletes it.'} + +
+ {state === 'available' && manifest.install && ( + + )} + {(state === 'enabled' || state === 'disabled' || state === 'broken') && + manifest.kind === 'mcp' && ( + + )} + +
+
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Labs +// --------------------------------------------------------------------------- + +const EFFORT_LABEL: Record = { + small: 'Small', + medium: 'Medium', + large: 'Large' +} + +/** + * Labs — the repo's idea list, in the product. + * + * Publishing candidate implementations next to shipped ones is the point: an idea + * with a name, a capability list and a stated tradeoff is something a user can + * argue with, and a maintainer can be held to. The section is only honest if the + * controls are: `preview` gets a real flag, `planned` gets an interest signal that + * is *labelled* as one. + */ +function LabsTab({ + ideas, + flags, + busy, + onToggle +}: { + ideas: LabIdea[] + flags: IntegrationConnection[] + busy: string | null + onToggle: (idea: LabIdea, value: boolean) => void +}): JSX.Element { + return ( +
+
+

+ + Ideas we're exploring +

+

+ Roxy is an open repo, and this is where its direction is argued in the open rather than in + a backlog. Each idea lists what it would let Roxy do, what it would build on, and the + honest objection to it. Preview means a shell exists + behind the switch; Idea means nothing is built and the + switch only records that you want it. Nothing here is a promise. +

+
+ + {ideas.length === 0 ? ( +

No ideas match that search.

+ ) : ( +
+ {ideas.map((idea) => { + const flag = labFlagId(idea.id) + const on = readFlag(flags, flag) + return ( +
+
+ + + +
+
+ {idea.name} + + {EFFORT_LABEL[idea.effort]} +
+

{idea.tagline}

+
+
+ {busy === flag ? ( + + ) : ( + onToggle(idea, v)} /> + )} + + {idea.state === 'preview' ? 'Try it' : 'I want this'} + +
+
+ +

{idea.rationale}

+ + {idea.buildsOn && ( +

+ Builds on: {idea.buildsOn} +

+ )} + {idea.tradeoff && ( +

+ Catch: {idea.tradeoff} +

+ )} + + +
+ ) + })} +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Manual / advanced +// --------------------------------------------------------------------------- + +/** + * The escape hatch, collapsed by default. The catalog is a shortcut, not a + * boundary: any MCP server and any skill repo still works, and the existing + * raw-JSON editor is the right tool for that — it just should not be the first + * thing a newcomer sees. + */ +function ManualSection({ open, onToggle }: { open: boolean; onToggle: () => void }): JSX.Element { + const navigate = useNavigate() + return ( +
+ + {open && ( +
+

+ Anything not in the catalog: paste an MCP server config, or install a skill from a + GitHub repo. Same result — it shows up under Installed like everything else. +

+
+

+ Tool servers +

+ +
+
+ + + Opens the skill editor (SKILL.md files under ~/.roxy/skills). + +
+
+ )} +
+ ) +} + +function EmptyResult({ + tab, + query, + onClear, + onDiscover +}: { + tab: Tab + query: string + onClear: () => void + onDiscover: () => void +}): JSX.Element { + return ( +
+
+ +
+ {query ? ( + <> +

Nothing matches “{query}”

+

+ Try a broader word, or add it yourself — any MCP server or skill repo works. +

+ + + ) : tab === 'installed' ? ( + <> +

No add-ons yet

+

+ Roxy already reads and writes files, runs commands and browses the web. Add-ons are for + everything past that — your database, your issue tracker, your own playbooks. +

+ + + ) : ( + <> +

You have everything in the catalog

+

+ Use “Add manually” below for anything else. +

+ + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +/** + * Merge the three existing backends into one list of rows. + * + * This is the whole trick of the page: no migration, no new service. An MCP + * server, a discovered SKILL.md and an integration flag are already + * enable/disable-able things with a status — they just never shared a shape. Here + * they get one, and their backend status collapses into the six-value + * {@link AddonState} the user actually reasons about. + */ +function useInstalledAddons(): { + rows: AddonRow[] + flags: IntegrationConnection[] + loading: boolean + reload: () => Promise +} { + const [rows, setRows] = useState([]) + const [flags, setFlags] = useState([]) + const [loading, setLoading] = useState(true) + + const reload = useCallback(async (): Promise => { + const [skills, servers, integrations] = await Promise.all([ + api.skills.list(), + api.mcp.list(), + api.integrations.list() + ]) + setFlags(integrations) + setRows([ + ...servers.map(mcpRow), + ...skills.map(skillRow), + ...integrationRows(integrations), + remoteRow(), + superUserRow() + ]) + setLoading(false) + }, []) + + useEffect(() => { + void reload() + // The agent can add servers/skills itself mid-session, and the raw editor + // lives on another surface — so re-read whenever the window comes back. + const onFocus = (): void => void reload() + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [reload]) + + return { rows, flags, loading, reload } +} + +/** An MCP server → a row. A catalog manifest supplies the prose when we have one. */ +function mcpRow(s: McpServerView): AddonRow { + const known = getCatalogAddon(s.id) + const manifest: AddonManifest = known ?? { + id: s.id, + name: s.id, + tagline: + s.status === 'connected' + ? `Adds ${s.tools.length} tool${s.tools.length === 1 ? '' : 's'} to Roxy.` + : 'An external tool server.', + kind: 'mcp', + category: 'Coding', + // Unknown servers are described by their transport, which is all we honestly + // know: a local one runs a process here, a remote one talks to someone else. + capabilities: + s.config.type === 'local' ? ['shell', 'files-read', 'network'] : ['network', 'credentials'], + icon: 'plug', + keywords: ['mcp', 'tool server'] + } + return { + manifest, + state: !s.enabled ? 'disabled' : s.status === 'error' ? 'broken' : 'enabled', + detail: s.config.type === 'remote' ? s.config.url : s.config.command.join(' '), + error: s.error, + tools: s.tools.length + } +} + +/** A discovered SKILL.md → a row. Skills are files: present means on. */ +function skillRow(s: SkillView): AddonRow { + const known = getCatalogAddon(s.name) + return { + manifest: known ?? { + id: s.name, + name: s.name, + tagline: s.description || 'A playbook Roxy loads when a task matches it.', + kind: 'skill', + category: 'Workflow', + capabilities: [], + icon: 'file-text', + author: s.source === 'workspace' ? 'This project' : 'You', + keywords: ['skill', 'skill.md', 'playbook', s.source] + }, + state: 'enabled', + detail: s.location + } +} + +/** Messaging channels: catalog defs joined with their persisted flag. */ +function integrationRows(rows: IntegrationConnection[]): AddonRow[] { + return INTEGRATIONS.map((it) => ({ + manifest: { + id: it.id, + name: it.name, + tagline: it.description, + kind: 'integration' as const, + category: 'Communication' as const, + capabilities: ['messaging', 'network', 'credentials'] as CapabilityId[], + state: it.status === 'coming-soon' ? ('preview' as const) : undefined, + icon: it.icon, + author: 'Roxy', + keywords: ['integration', 'chat', 'channel', 'messenger'] + }, + state: (it.status === 'coming-soon' + ? 'preview' + : readFlag(rows, it.id) + ? 'enabled' + : 'disabled') as AddonState + })) +} + +/** + * Remote Workspace was a sidebar dialog, which meant it was only discoverable if + * you happened to hover the right icon. It is an add-on like any other, so it is + * listed here too — the card explains it, the dialog still runs it. + */ +function remoteRow(): AddonRow { + return { + manifest: { + id: 'remote-workspace', + name: 'Remote Workspace', + tagline: 'Drive this session from your phone, over an encrypted link.', + kind: 'bridge', + category: 'Devices', + capabilities: ['network', 'always-on'], + icon: 'monitor-smartphone', + author: 'Roxy', + keywords: ['remote', 'phone', 'mobile', 'qr', 'share', 'pair'], + about: [ + 'Shares the running session to a phone: scan a QR, enter the PIN, and prompt Roxy from anywhere.', + 'Your code and files never leave this computer — only the chat is relayed. Start it from the Remote Workspace button in the sidebar; stopping it revokes the link and the PIN immediately.' + ] + }, + state: 'enabled', + detail: 'Start it from the sidebar — it shares on demand, not continuously.' + } +} + +/** The SuperUser shell (see `SUPERUSER_ADDON` for why it ships unwired). */ +function superUserRow(): AddonRow { + return { manifest: SUPERUSER_ADDON, state: 'preview' } +} + +/** + * Run an install. Returns an error message, or null on success — installs fail for + * ordinary reasons (no network, a repo with no SKILL.md) and the reason has to + * reach the card that asked. + */ +async function installAddon( + manifest: AddonManifest, + onBusy: (id: string | null) => void, + onDone: () => Promise +): Promise { + const install = manifest.install + if (!install) return 'This add-on has nothing to install.' + onBusy(manifest.id) + try { + if (install.via === 'mcp') { + await api.mcp.upsert({ id: manifest.id, config: install.config, enabled: true }) + // Connect right away so a bad config surfaces here rather than mid-turn. + await api.mcp.reconnect(manifest.id) + } else if (install.via === 'skill') { + const res = await api.skills.install(install.source) + if (!res.ok) return res.error ?? 'Nothing was installed.' + } else { + await api.integrations.setEnabled(manifest.id, true) + } + await onDone() + return null + } catch (e) { + return e instanceof Error ? e.message : 'Install failed.' + } finally { + onBusy(null) + } +} diff --git a/src/renderer/src/routes/Settings.tsx b/src/renderer/src/routes/Settings.tsx index 14e2be7..9f507e2 100644 --- a/src/renderer/src/routes/Settings.tsx +++ b/src/renderer/src/routes/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { GripVertical, Globe, Plus, Trash2 } from 'lucide-react' +import { Blocks, ChevronRight, GripVertical, Globe, Plus, Trash2 } from 'lucide-react' import type { AppVersions, ConnectedProvider } from '@shared/types' import type { UpdateInfo } from '@shared/api' import { AUTH_LABELS } from '@shared/providers' @@ -16,7 +16,6 @@ import { } from '@shared/branch' import { randomSlug, slugToBranchSegment } from '@shared/slugs' import { PageShell } from '../components/PageShell' -import { McpServers } from '../components/McpServers' import { CookiePanel } from '../components/CookiePanel' import { ConfigBackup } from '../components/ConfigBackup' import { ActivitySection } from '../components/ActivitySection' @@ -353,11 +352,29 @@ export default function Settings(): JSX.Element { + {/* Add-ons have their own front door now (the Marketplace). Settings keeps a + pointer rather than a second copy of the list: two places to manage the + same servers is how the two drift apart. */}

- MCP servers + Add-ons

- +
diff --git a/src/shared/marketplace.ts b/src/shared/marketplace.ts new file mode 100644 index 0000000..c7a9c17 --- /dev/null +++ b/src/shared/marketplace.ts @@ -0,0 +1,958 @@ +/** + * The Marketplace: one catalog, one noun, one place. + * + * ## Why this module exists + * + * Roxy grew four *separate* ways to extend it, each with its own page and its own + * vocabulary: + * + * - **Skills** (`/skills`) — `SKILL.md` playbooks discovered on disk. + * - **MCP servers** (`/mcp`) — external tool servers over stdio/HTTP. + * - **Integrations** (`/integrations`) — messaging surfaces (Telegram, Slack…). + * - **Remote Workspace** (a dialog in the sidebar) — bridge the session to a phone. + * + * Four doors to the same question — *"what else can Roxy do?"* — is three doors too + * many. A user who wants Roxy to read their Postgres has to already know the words + * "MCP server"; a user who wants a reusable release-notes workflow has to already + * know the words "SKILL.md frontmatter". That is a *mechanism-first* UI, and + * mechanism-first UIs are only intuitive to the person who built them. + * + * ## The model + * + * Everything above is the same shape: **a capability you can turn on, that grants + * Roxy some power, that you can turn off again.** So there is exactly one noun — + * an **Add-on** — with a `kind` that says which machinery implements it. The kind + * is a *badge*, not a section: it explains, it doesn't organize. + * + * What we keep from VS Code's marketplace: + * search-first, Installed vs Discover, one-click install, a detail view, + * enable/disable without uninstalling, categories as filters. + * + * What we deliberately drop: + * publisher accounts, a hosted registry, version pinning + update campaigns, + * ratings/reviews, per-extension sidebar views. None of that helps a single user + * answer "what else can Roxy do?", and all of it is surface to maintain. + * + * What we keep from Claude Code / agent CLIs: + * the artifact is a plain file you own and can read, capabilities are legible, + * and the agent itself can install/toggle add-ons through the same API the UI uses. + * + * ## The rule that makes it trustworthy + * + * Every add-on declares its {@link CapabilityId}s up front, and an add-on's risk is + * *derived* from them (see {@link addonRisk}) rather than self-reported — so nothing + * can advertise itself as harmless while asking for the keys to the machine. The UI + * shows those chips on the card, before install, which is the whole reason + * something like {@link SUPERUSER_ADDON} can exist in the list at all. + * + * Pure + dependency-free (no Node, no Electron), so it's unit-tested in + * `npm run smoke:shared` alongside the other shared catalogs. + */ + +import type { McpServerConfig } from './mcp' + +// =========================================================================== +// Capabilities — the permission vocabulary +// =========================================================================== + +/** + * A single power an add-on can ask for. This is the *user-facing* permission + * vocabulary: deliberately coarse (11 buckets, not 60 scopes) because a list of + * permissions nobody reads is worse than four chips everybody does. + */ +export type CapabilityId = + | 'files-read' + | 'files-write' + | 'shell' + | 'network' + | 'browser' + | 'screen' + | 'input-control' + | 'apps' + | 'credentials' + | 'messaging' + | 'always-on' + +/** + * How much damage a capability can do if the add-on behind it is buggy or hostile. + * Ordered; {@link addonRisk} takes the max over an add-on's capabilities. + */ +export type RiskLevel = 'safe' | 'moderate' | 'elevated' | 'critical' + +/** Ascending severity, so risk can be compared/maxed numerically. */ +export const RISK_ORDER: RiskLevel[] = ['safe', 'moderate', 'elevated', 'critical'] + +export interface CapabilityDef { + id: CapabilityId + /** Chip text. Plain language, no jargon — a verb phrase where possible. */ + label: string + /** One line explaining the real-world consequence, shown on hover/detail. */ + detail: string + risk: RiskLevel + /** lucide-react icon name, resolved by the renderer's icon map. */ + icon: string +} + +export const CAPABILITIES: CapabilityDef[] = [ + { + id: 'files-read', + label: 'Read files', + detail: 'Can read files in the workspace it was given.', + risk: 'safe', + icon: 'file-text' + }, + { + id: 'files-write', + label: 'Change files', + detail: 'Can create, edit and delete files in the workspace.', + risk: 'moderate', + icon: 'file-pen' + }, + { + id: 'network', + label: 'Network access', + detail: 'Can send data to, and fetch data from, the internet.', + risk: 'moderate', + icon: 'globe' + }, + { + id: 'browser', + label: 'Drive a browser', + detail: 'Can open pages, click and type in a real browser session.', + risk: 'moderate', + icon: 'globe' + }, + { + id: 'messaging', + label: 'Send messages', + detail: 'Can read and send messages on your behalf in a chat app.', + risk: 'elevated', + icon: 'message-circle' + }, + { + id: 'credentials', + label: 'Use secrets', + detail: 'Holds API keys or tokens you provide; they are stored on this machine.', + risk: 'elevated', + icon: 'shield' + }, + { + id: 'always-on', + label: 'Runs in background', + detail: 'Keeps running between turns, even when you are not looking at Roxy.', + risk: 'elevated', + icon: 'timer' + }, + { + id: 'shell', + label: 'Run commands', + detail: 'Can execute shell commands on this computer.', + risk: 'elevated', + icon: 'terminal' + }, + { + id: 'screen', + label: 'See your screen', + detail: 'Can capture what is on your display, including other apps.', + risk: 'critical', + icon: 'monitor' + }, + { + id: 'input-control', + label: 'Control mouse & keyboard', + detail: 'Can move the pointer and type as if it were you. Nothing is off-limits.', + risk: 'critical', + icon: 'mouse-pointer' + }, + { + id: 'apps', + label: 'Control other apps', + detail: 'Can launch, script and quit applications outside Roxy.', + risk: 'critical', + icon: 'layout-grid' + } +] + +const CAPABILITY_BY_ID = new Map(CAPABILITIES.map((c) => [c.id, c])) + +export function getCapability(id: CapabilityId): CapabilityDef | undefined { + return CAPABILITY_BY_ID.get(id) +} + +/** Compare two risk levels the way `Array#sort` wants (ascending severity). */ +export function compareRisk(a: RiskLevel, b: RiskLevel): number { + return RISK_ORDER.indexOf(a) - RISK_ORDER.indexOf(b) +} + +/** + * An add-on's risk is the **worst** of the capabilities it asks for — computed, + * never declared. An add-on cannot claim to be safe while asking to drive your + * keyboard, which is precisely the lie a marketplace has to make impossible. + */ +export function addonRisk(capabilities: CapabilityId[]): RiskLevel { + let worst: RiskLevel = 'safe' + for (const id of capabilities) { + const cap = CAPABILITY_BY_ID.get(id) + if (cap && compareRisk(cap.risk, worst) > 0) worst = cap.risk + } + return worst +} + +/** + * Whether turning this on should demand an explicit, typed confirmation rather + * than a switch flip. `critical` means "can act as you, outside Roxy" — a toggle + * is too cheap a gesture for that. + */ +export function needsConsent(capabilities: CapabilityId[]): boolean { + return addonRisk(capabilities) === 'critical' +} + +/** Capability chips, worst-first — the scary ones must not be below the fold. */ +export function sortCapabilities(capabilities: CapabilityId[]): CapabilityDef[] { + return capabilities + .map((id) => CAPABILITY_BY_ID.get(id)) + .filter((c): c is CapabilityDef => !!c) + .sort((a, b) => compareRisk(b.risk, a.risk) || a.label.localeCompare(b.label)) +} + +// =========================================================================== +// Add-ons — the single noun +// =========================================================================== + +/** + * Which machinery implements an add-on. Shown as a badge so the mechanism stays + * discoverable ("ah, that one is an MCP server") without being the way the list + * is organized. + */ +export type AddonKind = 'skill' | 'mcp' | 'integration' | 'bridge' | 'system' | 'lab' + +export interface AddonKindDef { + id: AddonKind + /** Badge text. */ + label: string + /** One line for the detail panel: what this kind of add-on *is*. */ + detail: string + icon: string +} + +export const ADDON_KINDS: AddonKindDef[] = [ + { + id: 'skill', + label: 'Skill', + detail: 'A SKILL.md playbook. Roxy loads its instructions when a task matches.', + icon: 'file-text' + }, + { + id: 'mcp', + label: 'Tool server', + detail: 'An MCP server that hands Roxy new tools, over stdio or HTTP.', + icon: 'plug' + }, + { + id: 'integration', + label: 'Channel', + detail: 'A place you can talk to Roxy from, outside this app.', + icon: 'message-circle' + }, + { + id: 'bridge', + label: 'Bridge', + detail: 'Connects this workspace to another device you own.', + icon: 'monitor-smartphone' + }, + { + id: 'system', + label: 'System', + detail: 'Reaches outside the workspace and operates this computer directly.', + icon: 'cpu' + }, + { + id: 'lab', + label: 'Lab', + detail: 'An idea being explored in the repo. Flip it on to try it early.', + icon: 'flask-conical' + } +] + +const KIND_BY_ID = new Map(ADDON_KINDS.map((k) => [k.id, k])) + +export function getAddonKind(id: AddonKind): AddonKindDef | undefined { + return KIND_BY_ID.get(id) +} + +/** + * Categories are the *only* taxonomy in the UI besides search. Kept to seven so + * the filter row never wraps and never needs a "More…" — a category list you + * scroll is a category list that has stopped helping. + */ +export type AddonCategory = + | 'Coding' + | 'Data' + | 'Web' + | 'Communication' + | 'Devices' + | 'System' + | 'Workflow' + +export const ADDON_CATEGORIES: AddonCategory[] = [ + 'Coding', + 'Data', + 'Web', + 'Communication', + 'Devices', + 'System', + 'Workflow' +] + +/** + * Lifecycle of an add-on *as the user experiences it*. Deliberately not the union + * of every backend status: an MCP connection error and a skill whose file vanished + * are both `broken` here, because the user's next move is the same either way. + */ +export type AddonState = + | 'enabled' // installed and on + | 'disabled' // installed but off + | 'available' // in the catalog, not installed + | 'preview' // shipped as a shell — visible, honest, does nothing yet + | 'planned' // an idea in Labs, not built + | 'broken' // installed but failing (bad config, missing file, dead server) + +/** + * A catalog entry: everything needed to render a card and a detail panel, with no + * knowledge of whether it happens to be installed. Live state is merged in at the + * renderer (see `AddonRow` in the Marketplace route). + */ +export interface AddonManifest { + /** Stable id. For catalog entries this is also the on-disk / server name. */ + id: string + name: string + /** One line, capability-first: what Roxy can *do* with this. No jargon. */ + tagline: string + kind: AddonKind + category: AddonCategory + capabilities: CapabilityId[] + /** Only for entries that ship as a shell (`preview`) or an idea (`planned`). */ + state?: Extract + icon: string + /** Who publishes it — "Roxy" for built-ins, else an upstream owner. */ + author?: string + /** Docs/homepage. Opened externally; never rendered as HTML. */ + homepage?: string + /** Search keywords beyond name/tagline (aliases, the jargon we hid). */ + keywords?: string[] + /** Recipe for one-click install. Absent = nothing to install (shells, ideas). */ + install?: AddonInstall + /** + * Longer prose for the detail panel: what it does, what it costs you, what it + * cannot do. Rendered as paragraphs, not markdown. + */ + about?: string[] +} + +/** How "Install" is carried out — one branch per backing subsystem. */ +export type AddonInstall = + /** Write an MCP server entry, then connect it. */ + | { + via: 'mcp' + config: McpServerConfig + /** Env/header/arg keys the user must fill in before it will run. */ + needs?: string[] + } + /** Fetch SKILL.md(s) from a GitHub repo / URL, exactly like `npx skills add`. */ + | { via: 'skill'; source: string } + /** Flip a persisted flag (integrations table). Nothing to download. */ + | { via: 'flag' } + +/** + * Values the user must supply before an add-on can work (a token, a path, a + * connection string). Only MCP installs can have them; this narrows the union in + * one place so the UI can just ask "does this need setup?". + * + * Driving the install button off this is what stops one-click from meaning + * "one click, then a broken server and a debugging session". + */ +export function installNeeds(install: AddonInstall | undefined): string[] { + return install?.via === 'mcp' ? (install.needs ?? []) : [] +} + +// =========================================================================== +// Persisted flags — how `preview` and `lab` add-ons remember their switch +// =========================================================================== + +/** + * Labs flags and shell add-ons persist through the existing `integrations` table + * (a plain `id → enabled + config` store) under a namespaced id, so this whole + * feature needs no migration and no new IPC channel: `integrations.list()` and + * `integrations.setEnabled()` already do exactly this job. The Integrations page + * renders only ids from its own catalog, so the namespaced rows stay invisible + * there instead of leaking in as ghost entries. + */ +export const LAB_FLAG_PREFIX = 'lab:' +export const ADDON_FLAG_PREFIX = 'addon:' + +export function labFlagId(id: string): string { + return LAB_FLAG_PREFIX + id +} + +export function addonFlagId(id: string): string { + return ADDON_FLAG_PREFIX + id +} + +/** Read one namespaced flag out of the rows `integrations.list()` returned. */ +export function readFlag(rows: { id: string; enabled: boolean }[], flagId: string): boolean { + return rows.some((r) => r.id === flagId && r.enabled) +} + +// =========================================================================== +// The SuperUser add-on — a shell, on purpose +// =========================================================================== + +/** + * **SuperUser** — the first add-on of its kind, and a deliberate shell: it is + * listed, it is describable, it asks for consent, and it does *nothing*. + * + * It exists in this state for two reasons. + * + * 1. It is the honest way to design the dangerous end of the marketplace. Full + * machine control is the capability everything else is measured against; if the + * permission model, the consent gate and the detail panel can hold *this* + * without lying to the user, they can hold anything milder. Building the UI + * against a real `critical` entry — rather than a hypothetical one — is what + * keeps {@link addonRisk} and {@link needsConsent} from being decoration. + * + * 2. Shipping the shell before the engine means the scary part is reviewed in + * daylight. Every capability it will eventually need is already written down + * below, so the conversation about whether Roxy should be allowed to do this + * happens *now*, over a manifest, instead of later, over an implementation. + * + * `state: 'preview'` is load-bearing: the UI must render it as "not implemented", + * never as "off". A switch that looks live but is inert is the one failure mode + * this add-on cannot have. + */ +export const SUPERUSER_ADDON: AddonManifest = { + id: 'superuser', + name: 'SuperUser', + tagline: 'Let Roxy operate this whole computer, not just the workspace.', + kind: 'system', + category: 'System', + capabilities: [ + 'input-control', + 'screen', + 'apps', + 'shell', + 'files-read', + 'files-write', + 'network' + ], + state: 'preview', + icon: 'cpu', + author: 'Roxy', + keywords: ['computer use', 'automation', 'os', 'desktop', 'root', 'admin', 'accessibility'], + about: [ + 'Today Roxy acts inside a workspace: it reads and writes files in a folder, runs commands there, and drives its own browser. SuperUser is the proposal to lift that boundary — one add-on that lets Roxy use the machine the way you do.', + 'That means moving the real pointer, typing into whatever app has focus, reading the screen to see the result, and launching or quitting programs. Not a bigger sandbox: no sandbox.', + 'It is shipped here as a shell. Nothing is wired to the agent, the switch grants no permission, and turning it on changes no behaviour. What you are looking at is the manifest — the exact list of powers it would need — published before the code exists, so the trade is reviewable while it is still cheap to change.', + 'When it is built it will stay opt-in per session, ask before each new class of action, keep a reviewable log of everything it did, and be revocable from one place. If it cannot do all four, it should not ship.' + ] +} + +// =========================================================================== +// Discover — the curated catalog +// =========================================================================== + +/** + * The one-click catalog. Small on purpose: a curated dozen that covers the common + * asks beats a searchable thousand nobody can judge, and every entry here is + * something a maintainer has actually run. Growth belongs in "Add manually", + * which accepts any MCP server or any skill repo — the catalog is a shortcut, + * never the boundary of what is possible. + */ +export const MARKETPLACE_CATALOG: AddonManifest[] = [ + // ---- Coding ------------------------------------------------------------- + { + id: 'github', + name: 'GitHub', + tagline: 'Read and manage repos, issues and pull requests.', + kind: 'mcp', + category: 'Coding', + capabilities: ['network', 'credentials'], + icon: 'git-branch', + author: 'GitHub', + homepage: 'https://github.com/github/github-mcp-server', + keywords: ['mcp', 'git', 'pr', 'issues', 'review'], + install: { + via: 'mcp', + config: { + type: 'local', + command: ['npx', '-y', '@modelcontextprotocol/server-github'], + environment: { GITHUB_PERSONAL_ACCESS_TOKEN: '' } + }, + needs: ['GITHUB_PERSONAL_ACCESS_TOKEN'] + }, + about: [ + 'Gives Roxy the GitHub API as tools: search code, open and review pull requests, triage issues, read Actions runs.', + 'Needs a personal access token. It is stored on this machine and sent only to GitHub.' + ] + }, + { + id: 'filesystem', + name: 'Filesystem (extra folders)', + tagline: 'Let Roxy reach a folder outside the current workspace.', + kind: 'mcp', + category: 'Coding', + capabilities: ['files-read', 'files-write'], + icon: 'folder', + author: 'Model Context Protocol', + homepage: 'https://github.com/modelcontextprotocol/servers', + keywords: ['mcp', 'files', 'directory', 'notes'], + install: { + via: 'mcp', + config: { + type: 'local', + command: ['npx', '-y', '@modelcontextprotocol/server-filesystem', ''], + environment: {} + }, + needs: ['path'] + }, + about: [ + 'Roxy already has full access to the workspace it is pointed at. This is for the second folder — a notes vault, a design export, a sibling repo you keep referring to.', + 'Scoped to the paths you list, and nothing above them.' + ] + }, + { + id: 'code-review', + name: 'Code Review', + tagline: 'A review pass with your team’s standards, not generic advice.', + kind: 'skill', + category: 'Coding', + capabilities: ['files-read'], + icon: 'file-text', + author: 'Roxy', + keywords: ['skill', 'review', 'lint', 'standards', 'pr'], + install: { via: 'skill', source: 'roxy-gg/skills/code-review' }, + about: [ + 'A SKILL.md that tells Roxy how to review a diff: what to flag, what to leave alone, and in what order to report.', + 'It is a text file under ~/.roxy/skills — edit it until the reviews sound like your team.' + ] + }, + // ---- Data --------------------------------------------------------------- + { + id: 'postgres', + name: 'Postgres', + tagline: 'Explore a database and answer questions with real SQL.', + kind: 'mcp', + category: 'Data', + capabilities: ['network', 'credentials'], + icon: 'database', + author: 'Model Context Protocol', + homepage: 'https://github.com/modelcontextprotocol/servers', + keywords: ['mcp', 'sql', 'database', 'schema', 'query'], + install: { + via: 'mcp', + config: { + type: 'local', + command: ['npx', '-y', '@modelcontextprotocol/server-postgres', ''], + environment: {} + }, + needs: ['connection string'] + }, + about: [ + 'Roxy can read the schema and run queries, so "why is this migration slow" gets answered against your data instead of guessed from the code.', + 'Point it at a read-only replica or a read-only role. It will run whatever SQL it decides it needs.' + ] + }, + { + id: 'sqlite', + name: 'SQLite', + tagline: 'Query a local .db file directly.', + kind: 'mcp', + category: 'Data', + capabilities: ['files-read', 'files-write'], + icon: 'database', + author: 'Model Context Protocol', + homepage: 'https://github.com/modelcontextprotocol/servers', + keywords: ['mcp', 'sql', 'database', 'local'], + install: { + via: 'mcp', + config: { + type: 'local', + command: ['npx', '-y', '@modelcontextprotocol/server-sqlite', '--db-path', ''], + environment: {} + }, + needs: ['--db-path'] + } + }, + // ---- Web ---------------------------------------------------------------- + { + id: 'playwright', + name: 'Playwright', + tagline: 'Test and script a real browser, headless.', + kind: 'mcp', + category: 'Web', + capabilities: ['browser', 'network', 'shell'], + icon: 'globe', + author: 'Microsoft', + homepage: 'https://github.com/microsoft/playwright-mcp', + keywords: ['mcp', 'browser', 'e2e', 'test', 'scrape', 'automation'], + install: { + via: 'mcp', + config: { type: 'local', command: ['npx', '-y', '@playwright/mcp@latest'] } + }, + about: [ + 'Roxy has a built-in browser for looking at pages. This is the other job: driving a scripted, headless browser to write and run end-to-end tests.', + 'It downloads browser binaries on first run.' + ] + }, + { + id: 'fetch', + name: 'Fetch', + tagline: 'Pull any URL down as clean, readable text.', + kind: 'mcp', + category: 'Web', + capabilities: ['network'], + icon: 'globe', + author: 'Model Context Protocol', + homepage: 'https://github.com/modelcontextprotocol/servers', + keywords: ['mcp', 'http', 'scrape', 'markdown', 'docs'], + install: { + via: 'mcp', + config: { type: 'local', command: ['npx', '-y', '@modelcontextprotocol/server-fetch'] } + } + }, + // ---- Workflow ----------------------------------------------------------- + { + id: 'memory', + name: 'Memory', + tagline: 'Remember decisions and preferences across sessions.', + kind: 'mcp', + category: 'Workflow', + capabilities: ['files-read', 'files-write'], + icon: 'brain', + author: 'Model Context Protocol', + homepage: 'https://github.com/modelcontextprotocol/servers', + keywords: ['mcp', 'memory', 'knowledge graph', 'context', 'notes'], + install: { + via: 'mcp', + config: { type: 'local', command: ['npx', '-y', '@modelcontextprotocol/server-memory'] } + }, + about: [ + 'A small knowledge graph on disk that survives between sessions, so "we decided against that in March" stays true without you retyping it.' + ] + }, + { + id: 'release-notes', + name: 'Release Notes', + tagline: 'Turn a range of commits into a changelog people read.', + kind: 'skill', + category: 'Workflow', + capabilities: ['files-read', 'shell'], + icon: 'file-text', + author: 'Roxy', + keywords: ['skill', 'changelog', 'git', 'release'], + install: { via: 'skill', source: 'roxy-gg/skills/release-notes' } + }, + { + id: 'agent-skills', + name: 'Agent Skills pack', + tagline: 'A starter set of community playbooks, installed in one go.', + kind: 'skill', + category: 'Workflow', + capabilities: ['files-read'], + icon: 'library', + author: 'vercel-labs', + homepage: 'https://github.com/vercel-labs/agent-skills', + keywords: ['skill', 'pack', 'bundle', 'community', 'starter'], + install: { via: 'skill', source: 'vercel-labs/agent-skills' }, + about: [ + 'Installs every SKILL.md it finds in the repo. They land as editable files under ~/.roxy/skills — keep the ones that fit, delete the rest.' + ] + } +] + +// =========================================================================== +// Labs — ideas in the open, behind a switch +// =========================================================================== + +/** + * A candidate implementation, published *before* it is built. + * + * This is the mechanism the repo uses to argue about direction in public. An idea + * with a manifest — name, capabilities, effort, what would have to be true — is + * something you can agree or disagree with. The same idea in a backlog ticket is + * something only the author can see. So they live here, in the product, in the + * same list as the things that already work, with an honest `state`. + * + * The switch is real for `preview` entries (a persisted flag; the feature reads + * it) and inert for `planned` ones (an interest signal, and nothing more). The UI + * has to say which is which, every time — the moment a `planned` toggle *looks* + * functional, this whole section becomes a lie. + */ +export interface LabIdea { + id: string + name: string + /** One line: the outcome, not the implementation. */ + tagline: string + category: AddonCategory + capabilities: CapabilityId[] + /** `preview` = shell exists behind the flag. `planned` = idea only. */ + state: Extract + /** Rough build cost, for reading the list as a roadmap. */ + effort: 'small' | 'medium' | 'large' + icon: string + /** Why it is worth doing — the argument, in one or two sentences. */ + rationale: string + /** What already exists in this repo that it would build on. */ + buildsOn?: string + /** The honest objection: what makes it hard, risky, or possibly wrong. */ + tradeoff?: string +} + +export const LAB_IDEAS: LabIdea[] = [ + { + id: 'skill-from-session', + name: 'Save this session as a skill', + tagline: 'Turn a session that went well into a reusable playbook.', + category: 'Workflow', + capabilities: ['files-read', 'files-write'], + state: 'planned', + effort: 'small', + icon: 'file-text', + rationale: + 'The best skills are transcripts of work that already succeeded. Writing them by hand afterwards is the step everyone skips, so the knowledge stays in one closed session.', + buildsOn: 'The `skill_manage` tool and the SKILL.md serializer already write skills.', + tradeoff: + 'A summarized transcript is verbose and workspace-specific; it needs real editing before it generalizes.' + }, + { + id: 'voice-mode', + name: 'Voice mode', + tagline: 'Hold a key, talk, let Roxy work while you keep reading.', + category: 'Workflow', + capabilities: ['network'], + state: 'planned', + effort: 'medium', + icon: 'mic', + rationale: + 'Prompts are long and typing them is the slow part. Dictation fits how people actually describe a bug — rambling, with corrections.', + tradeoff: + 'Local transcription is heavy; a cloud one sends your voice off the machine, which contradicts the privacy line everywhere else.' + }, + { + id: 'cost-guardrails', + name: 'Cost guardrails', + tagline: 'A hard budget per session, with an automatic downshift instead of a stop.', + category: 'Workflow', + capabilities: [], + state: 'planned', + effort: 'small', + icon: 'timer', + rationale: + 'Roxy already meters tokens and cost precisely. The missing half is acting on it: cap a session, drop to a cheaper model at 80%, and never surprise anyone with the bill.', + buildsOn: 'The usage meter + per-turn cost accounting already track every request.' + }, + { + id: 'night-shift', + name: 'Night shift', + tagline: 'Queue the boring work, review a report in the morning.', + category: 'Workflow', + capabilities: ['files-write', 'shell', 'always-on'], + state: 'planned', + effort: 'medium', + icon: 'timer', + rationale: + 'Dependency bumps, flaky-test triage and lint sweeps do not need supervision — they need a diff to review. Loops can already run on a schedule; what is missing is a digest worth reading.', + buildsOn: 'Scheduled loops + workstreams (each run isolated in its own worktree/branch).', + tradeoff: + 'Unsupervised writes on a schedule is exactly how you wake up to a broken main branch. Needs worktree isolation to be mandatory, not default.' + }, + { + id: 'checkpoint-rewind', + name: 'Rewind', + tagline: 'Step back to how the repo looked before any turn.', + category: 'Coding', + capabilities: ['files-read', 'files-write'], + state: 'planned', + effort: 'medium', + icon: 'history', + rationale: + 'Undo is the feature that makes an agent safe to let run. A per-turn checkpoint turns "it broke something four steps ago" from an investigation into a click.', + buildsOn: 'Every session already runs in a git worktree on its own branch.', + tradeoff: + 'Cheap for tracked files; untracked artifacts and anything the agent did outside git are not covered, and a half-honest undo is worse than none.' + }, + { + id: 'semantic-index', + name: 'Semantic code index', + tagline: 'Find code by what it does, not by the words in it.', + category: 'Coding', + capabilities: ['files-read'], + state: 'planned', + effort: 'large', + icon: 'search', + rationale: + 'grep needs the right identifier. On an unfamiliar repo the first question is "where does auth happen", and that has no keyword.', + tradeoff: + 'A local index means embedding the whole repo and keeping it fresh; a remote one means uploading the source. Both are real costs for a win grep often already gets.' + }, + { + id: 'self-healing-tests', + name: 'Self-healing tests', + tagline: 'Watch the suite, and fix what it breaks.', + category: 'Coding', + capabilities: ['files-write', 'shell', 'always-on'], + state: 'planned', + effort: 'medium', + icon: 'flask-conical', + rationale: + 'The tightest useful agent loop is edit → run → read failure → repeat, and it is the one thing an agent does better than a person: it never gets bored on attempt nine.', + tradeoff: + 'Optimizing for a green suite teaches an agent to delete assertions. Needs the diff reviewed as the deliverable, not the passing run.' + }, + { + id: 'bash-jail', + name: 'Sandboxed shell', + tagline: 'Run commands with the blast radius spelled out.', + category: 'System', + capabilities: ['shell'], + state: 'planned', + effort: 'large', + icon: 'shield', + rationale: + '`bash` is the tool people hesitate over, and the hesitation is correct. A declared profile — these paths, this network, no more — is what makes approving it a decision instead of a gamble.', + tradeoff: + 'Three separate implementations (seatbelt, namespaces, a Windows story) and a hard truth: a jail tight enough to be safe breaks half the commands people need.' + }, + { + id: 'team-relay', + name: 'Team relay', + tagline: 'Hand a live session to a teammate, not a screenshot.', + category: 'Devices', + capabilities: ['network', 'always-on'], + state: 'planned', + effort: 'medium', + icon: 'monitor-smartphone', + rationale: + 'Remote Workspace already relays a session to another device over an encrypted link with a PIN. The same primitive, pointed at a colleague, replaces the pasted terminal log.', + buildsOn: 'The Remote Workspace relay (room + guest token + PIN pairing).', + tradeoff: + 'A teammate is not you: it needs per-guest read-only vs. can-prompt roles, which the phone flow never had to answer.' + }, + { + id: 'browser-to-skill', + name: 'Record a browser flow as a skill', + tagline: 'Click through it once; get a repeatable playbook.', + category: 'Web', + capabilities: ['browser', 'files-write'], + state: 'planned', + effort: 'medium', + icon: 'mouse-pointer', + rationale: + 'Describing a deploy dashboard in prose is slower and less accurate than doing it once with a recorder on.', + buildsOn: 'The built-in browser tools (open, click, type, read) already produce a usable trace.' + }, + { + id: 'menubar-agent', + name: 'Menu bar agent', + tagline: 'Ask from anywhere, without finding the window.', + category: 'Devices', + capabilities: ['always-on'], + state: 'planned', + effort: 'small', + icon: 'layout-grid', + rationale: + 'Most prompts are one line. A global shortcut and a small input beat switching apps to a full IDE window.' + }, + { + id: 'local-models', + name: 'Local model autodetect', + tagline: 'Notice Ollama or LM Studio and offer it, without setup.', + category: 'System', + capabilities: ['network'], + state: 'planned', + effort: 'small', + icon: 'cpu', + rationale: + 'Both providers are already in the catalog, but only if you go and configure them. Probing the default port turns a config chore into a one-click offer — and makes the offline story real.', + buildsOn: 'The Ollama + LM Studio provider entries already exist.' + } +] + +// =========================================================================== +// Search + filtering (pure, so the whole browse experience is testable) +// =========================================================================== + +/** Anything renderable as a card. `installed`/`state` are merged in by the caller. */ +export interface AddonSearchable { + id: string + name: string + tagline: string + kind: AddonKind + category: AddonCategory + keywords?: string[] +} + +/** + * Match a card against a query. Searches name, tagline, id, kind label and + * keywords, so the jargon we removed from the UI still *finds* things: typing + * "mcp" or "SKILL.md" works even though no card leads with either. + * + * All terms must match (AND), each anywhere in the haystack — the behaviour + * people expect from an extension search box. + */ +export function matchesQuery(addon: AddonSearchable, query: string): boolean { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean) + if (!terms.length) return true + const haystack = [ + addon.name, + addon.tagline, + addon.id, + addon.category, + getAddonKind(addon.kind)?.label ?? '', + ...(addon.keywords ?? []) + ] + .join(' ') + .toLowerCase() + return terms.every((t) => haystack.includes(t)) +} + +export interface AddonFilter { + query?: string + /** null/undefined = every category. */ + category?: AddonCategory | null + /** null/undefined = every kind. */ + kind?: AddonKind | null +} + +/** Whether one add-on survives the current search + chips. */ +export function matchesFilter(addon: AddonSearchable, filter: AddonFilter): boolean { + return ( + (!filter.category || addon.category === filter.category) && + (!filter.kind || addon.kind === filter.kind) && + matchesQuery(addon, filter.query ?? '') + ) +} + +export function filterAddons(list: T[], filter: AddonFilter): T[] { + return list.filter((a) => matchesFilter(a, filter)) +} + +/** Which categories are actually represented — so the filter row hides dead chips. */ +export function categoriesPresent(list: AddonSearchable[]): AddonCategory[] { + const present = new Set(list.map((a) => a.category)) + return ADDON_CATEGORIES.filter((c) => present.has(c)) +} + +/** "3 tools · reads files" style summary for a collapsed card. */ +export function summarizeCapabilities(capabilities: CapabilityId[]): string { + const caps = sortCapabilities(capabilities) + if (!caps.length) return 'No extra permissions' + if (caps.length <= 2) return caps.map((c) => c.label).join(' · ') + return `${caps[0].label} · ${caps[1].label} +${caps.length - 2}` +} + +const CATALOG_BY_ID = new Map(MARKETPLACE_CATALOG.map((a) => [a.id, a])) + +/** Catalog lookup, used to enrich an installed row with its published manifest. */ +export function getCatalogAddon(id: string): AddonManifest | undefined { + return CATALOG_BY_ID.get(id) +} diff --git a/test/shared.ts b/test/shared.ts index fd438f2..77fa880 100644 --- a/test/shared.ts +++ b/test/shared.ts @@ -294,6 +294,26 @@ import { toResponsesTools, type ResponsesEvent } from '../src/main/services/responses' +import { + ADDON_CATEGORIES, + ADDON_KINDS, + CAPABILITIES, + LAB_IDEAS, + MARKETPLACE_CATALOG, + SUPERUSER_ADDON, + addonFlagId, + addonRisk, + filterAddons, + getCapability, + installNeeds, + labFlagId, + matchesQuery, + needsConsent, + readFlag, + sortCapabilities, + summarizeCapabilities, + type AddonSearchable +} from '../src/shared/marketplace' let pass = 0 const fails: string[] = [] @@ -5141,6 +5161,174 @@ async function main(): Promise { !applyResponsesEvent({ type: 'response.in_progress' }, { onText: () => {} }) ) + // ---- Marketplace ---------------------------------------------------------- + check('marketplace: catalog non-empty', MARKETPLACE_CATALOG.length > 0) + check( + 'marketplace: add-on ids unique', + new Set(MARKETPLACE_CATALOG.map((a) => a.id)).size === MARKETPLACE_CATALOG.length + ) + check( + 'marketplace: lab ids unique', + new Set(LAB_IDEAS.map((i) => i.id)).size === LAB_IDEAS.length + ) + check( + 'marketplace: capability ids unique', + new Set(CAPABILITIES.map((c) => c.id)).size === CAPABILITIES.length + ) + check( + 'marketplace: every add-on capability resolves', + [...MARKETPLACE_CATALOG, SUPERUSER_ADDON].every((a) => + a.capabilities.every((c) => !!getCapability(c)) + ) + ) + check( + 'marketplace: every lab capability resolves', + LAB_IDEAS.every((i) => i.capabilities.every((c) => !!getCapability(c))) + ) + check( + 'marketplace: every category is a declared one', + [...MARKETPLACE_CATALOG, SUPERUSER_ADDON, ...LAB_IDEAS].every((a) => + ADDON_CATEGORIES.includes(a.category) + ) + ) + check( + 'marketplace: every kind is a declared one', + MARKETPLACE_CATALOG.every((a) => ADDON_KINDS.some((k) => k.id === a.kind)) + ) + + // Risk is DERIVED, never declared -- an add-on must not be able to advertise + // itself as harmless while asking to drive the keyboard. + check( + 'marketplace: risk is the worst capability', + addonRisk(['files-read', 'shell']) === 'elevated' + ) + check('marketplace: risk of nothing is safe', addonRisk([]) === 'safe') + check( + 'marketplace: input-control is critical', + addonRisk(['files-read', 'input-control']) === 'critical' + ) + check('marketplace: unknown capabilities do not raise risk', addonRisk([]) === 'safe') + check( + 'marketplace: SuperUser is critical', + addonRisk(SUPERUSER_ADDON.capabilities) === 'critical' + ) + check('marketplace: SuperUser demands consent', needsConsent(SUPERUSER_ADDON.capabilities)) + check('marketplace: SuperUser ships as a shell', SUPERUSER_ADDON.state === 'preview') + check('marketplace: SuperUser is not installable', !SUPERUSER_ADDON.install) + check('marketplace: elevated alone does not demand consent', !needsConsent(['shell'])) + + // Chips must be worst-first so nothing alarming hides behind a "+2". + const sorted = sortCapabilities(['files-read', 'input-control', 'network']) + check( + 'marketplace: capabilities sort worst-first', + sorted[0]?.id === 'input-control', + sorted[0]?.id + ) + check('marketplace: sortCapabilities drops unknowns', sortCapabilities([]).length === 0) + check( + 'marketplace: summary names the worst first', + summarizeCapabilities(['files-read', 'input-control']).startsWith('Control mouse') + ) + check( + 'marketplace: empty summary is explicit', + summarizeCapabilities([]) === 'No extra permissions' + ) + check( + 'marketplace: long summaries overflow with a count', + summarizeCapabilities(['files-read', 'files-write', 'shell', 'network']).endsWith('+2') + ) + + // Search must still find things by the jargon the UI deliberately hides. + const searchable: AddonSearchable[] = MARKETPLACE_CATALOG.map((a) => ({ + id: a.id, + name: a.name, + tagline: a.tagline, + kind: a.kind, + category: a.category, + keywords: a.keywords + })) + check( + 'marketplace: empty query matches everything', + filterAddons(searchable, {}).length === searchable.length + ) + check( + 'marketplace: "mcp" still finds tool servers', + filterAddons(searchable, { query: 'mcp' }).length > 0 + ) + check( + 'marketplace: search finds by name', + filterAddons(searchable, { query: 'postgres' }).some((a) => a.id === 'postgres') + ) + check( + 'marketplace: search is case-insensitive', + filterAddons(searchable, { query: 'POSTGRES' }).some((a) => a.id === 'postgres') + ) + check( + 'marketplace: multi-term search ANDs', + filterAddons(searchable, { query: 'postgres playwright' }).length === 0 + ) + check( + 'marketplace: category filter narrows', + filterAddons(searchable, { category: 'Data' }).every((a) => a.category === 'Data') + ) + check( + 'marketplace: kind filter narrows', + filterAddons(searchable, { kind: 'skill' }).every((a) => a.kind === 'skill') + ) + check( + 'marketplace: filters compose', + filterAddons(searchable, { query: 'browser', category: 'Web', kind: 'mcp' }).every( + (a) => a.category === 'Web' && a.kind === 'mcp' + ) + ) + check( + 'marketplace: nonsense query matches nothing', + filterAddons(searchable, { query: 'zzzzqqq' }).length === 0 + ) + check( + 'marketplace: whitespace query matches everything', + filterAddons(searchable, { query: ' ' }).length === searchable.length + ) + check( + 'marketplace: matchesQuery searches the kind label', + matchesQuery({ ...searchable[0], keywords: [] }, 'server') || + matchesQuery({ ...searchable[0], keywords: [] }, 'skill') + ) + + // Only MCP installs can require setup -- this is what the "Add" vs "Set up" + // button branches on. + check( + 'marketplace: skill installs need nothing', + installNeeds({ via: 'skill', source: 'x' }).length === 0 + ) + check('marketplace: flag installs need nothing', installNeeds({ via: 'flag' }).length === 0) + check('marketplace: missing install needs nothing', installNeeds(undefined).length === 0) + check( + 'marketplace: github needs a token', + installNeeds(MARKETPLACE_CATALOG.find((a) => a.id === 'github')?.install).length === 1 + ) + check( + 'marketplace: everything needing setup is an MCP install', + MARKETPLACE_CATALOG.every( + (a) => installNeeds(a.install).length === 0 || a.install?.via === 'mcp' + ) + ) + + // Flags are namespaced so they cannot collide with real integration rows. + check('marketplace: lab flags are namespaced', labFlagId('voice') === 'lab:voice') + check('marketplace: addon flags are namespaced', addonFlagId('superuser') === 'addon:superuser') + check( + 'marketplace: flag namespaces never collide', + labFlagId('x') !== addonFlagId('x') && !labFlagId('x').includes(addonFlagId('x')) + ) + const flagRows = [ + { id: 'lab:voice-mode', enabled: true }, + { id: 'lab:night-shift', enabled: false } + ] + check('marketplace: readFlag reads an on flag', readFlag(flagRows, 'lab:voice-mode')) + check('marketplace: readFlag reads an off flag', !readFlag(flagRows, 'lab:night-shift')) + check('marketplace: readFlag defaults to off', !readFlag(flagRows, 'lab:missing')) + if (fails.length) { console.error(`\nSHARED FAILED \u2014 ${fails.length} failing: ${fails.join(', ')}`) process.exit(1)