diff --git a/.gitmodules b/.gitmodules index 99ab3006..7a52a439 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,4 +5,4 @@ [submodule "resources/skills"] path = resources/skills url = https://github.com/opencue/skills.git - branch = soul-main + branch = main diff --git a/resources/skills b/resources/skills index a452e5d2..3c6b2569 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit a452e5d2892d59d766b1927c9d35a6f806bce79e +Subproject commit 3c6b256909d3856a8cc3be78cacf466344cc34e6 diff --git a/src/lib/dashboard-server.test.ts b/src/lib/dashboard-server.test.ts index 9602aadf..3c645545 100644 --- a/src/lib/dashboard-server.test.ts +++ b/src/lib/dashboard-server.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; -import { handleProfileDetail, handleMcpCatalog, handleMcpAdd, handleMarket, createHandler, semverGt, computeVersionInfo, buildTimeline, handleHooks, handleHookSource } from "./dashboard-server"; +import { handleProfileDetail, handleMcpCatalog, handleMcpAdd, handleMarket, handleMarketInstall, createHandler, semverGt, computeVersionInfo, buildTimeline, handleHooks, handleHookSource } from "./dashboard-server"; function detail(profile: string) { return handleProfileDetail(new URLSearchParams({ profile })); @@ -285,6 +285,36 @@ describe("handleMcpAdd validation", () => { }); }); +describe("handleMarketInstall validation", () => { + test("rejects a missing id", async () => { + const res = await handleMarketInstall({ addKind: "skill", profile: "core" }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("missing-id"); + }); + + test("rejects an unknown add kind", async () => { + const res = await handleMarketInstall({ id: "skill:x", addKind: "bogus", profile: "core" }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("invalid-add-kind"); + }); + + test("rejects a missing profile", async () => { + const res = await handleMarketInstall({ id: "skill:x", addKind: "skill" }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("missing-profile"); + }); + + test("returns a manual command for a CLI without touching a profile", async () => { + const res = await handleMarketInstall({ id: "cli:ripgrep", addKind: "cli", profile: "core" }); + expect(res.ok).toBe(true); + if (res.ok) { + const data = res.data as { manual?: { command: string }; changed: boolean }; + expect(data.changed).toBe(false); + expect(data.manual?.command).toBeTruthy(); + } + }); +}); + describe("buildTimeline", () => { test("returns gap-filled daily buckets spanning the window", () => { const t = buildTimeline(7); diff --git a/src/lib/dashboard-server.ts b/src/lib/dashboard-server.ts index d543a7e5..4a272328 100644 --- a/src/lib/dashboard-server.ts +++ b/src/lib/dashboard-server.ts @@ -37,6 +37,7 @@ import { } from "./profile-merge"; import { validateProfileName } from "./profile-generator"; import { loadMcpCatalog, addMcpToProfile } from "./mcp-catalog"; +import { installMarketItem, type MarketAddKind } from "./market-install"; import { aggregateProfileClis, type ProfileCli } from "./skill-clis"; import { collectPermissions } from "./permissions"; import { reposForProfile, resolveRepoStars } from "./repos"; @@ -1653,6 +1654,34 @@ export async function handleMarket(): Promise> { return { ok: true, data }; } +const MARKET_ADD_KINDS = new Set(["mcp", "skill", "profile", "cli", "workflow", "plugin"]); + +/** + * Install a marketplace item into a profile (the studio's "Add to profile" + * picker). Validates the body, edits the target profile.yaml via + * installMarketItem, and busts the market cache so the next browse reflects any + * new profile membership. Returns `manual` for kinds with no profile.yaml home + * (a bare CLI) so the studio can show the command instead of claiming success. + */ +export async function handleMarketInstall( + body: { id?: unknown; addKind?: unknown; profile?: unknown; add?: unknown } | null, +): Promise> { + const id = typeof body?.id === "string" ? body.id : ""; + const addKind = typeof body?.addKind === "string" ? (body.addKind as MarketAddKind) : ""; + const profile = typeof body?.profile === "string" ? body.profile : ""; + const add = typeof body?.add === "string" ? body.add : undefined; + if (!id) return { ok: false, error: "missing-id" }; + if (!addKind || !MARKET_ADD_KINDS.has(addKind as MarketAddKind)) return { ok: false, error: "invalid-add-kind" }; + if (!profile) return { ok: false, error: "missing-profile" }; + try { + const result = await installMarketItem({ id, addKind: addKind as MarketAddKind, profile, add }); + if (result.changed) marketCache = null; // counts/membership may have shifted + return { ok: true, data: result }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} + // ── Workflows: the n8n-style canvas's saved DAGs (resources/workflows/*.json) ── export async function handleWorkflows(): Promise> { return { ok: true, data: listWorkflows() }; @@ -1871,6 +1900,13 @@ export function createHandler(): (req: Request) => Promise { return Response.json(result, { status: result.ok ? 200 : 400 }); } + if (req.method === "POST" && url.pathname === "/api/v1/market/install") { + let body: unknown = null; + try { body = await req.json(); } catch { /* malformed */ } + const result = await handleMarketInstall(body as Parameters[0]); + return Response.json(result, { status: result.ok ? 200 : 400 }); + } + if (req.method === "POST" && url.pathname === "/api/v1/workflows/save") { let body: unknown = null; try { body = await req.json(); } catch { /* malformed */ } diff --git a/src/lib/market-install.test.ts b/src/lib/market-install.test.ts new file mode 100644 index 00000000..8a271eec --- /dev/null +++ b/src/lib/market-install.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for installMarketItem — the profile.yaml editor that backs the studio + * Market page's "Add to profile". Runs against a throwaway CUE_PROFILES_DIR + * fixture so it never touches the real `profiles/` tree, and asserts both the + * write and its idempotency for every addKind that has a profile.yaml home. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { installMarketItem } from "./market-install"; + +let dir: string; +let prevProfilesDir: string | undefined; + +function writeProfile(name: string, yaml: string) { + mkdirSync(join(dir, name), { recursive: true }); + writeFileSync(join(dir, name, "profile.yaml"), yaml); +} +function readProfile(name: string): string { + return readFileSync(join(dir, name, "profile.yaml"), "utf8"); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "cue-market-install-")); + prevProfilesDir = process.env.CUE_PROFILES_DIR; + process.env.CUE_PROFILES_DIR = dir; +}); +afterEach(() => { + if (prevProfilesDir === undefined) delete process.env.CUE_PROFILES_DIR; + else process.env.CUE_PROFILES_DIR = prevProfilesDir; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("installMarketItem", () => { + test("adds a skill under skills.npx, parsing the repo from the add command", async () => { + writeProfile("dev", "name: dev\ninherits: core\nskills:\n local:\n - github/github\n"); + const r = await installMarketItem({ + id: "skill:graphql-mcp-bridge", + addKind: "skill", + profile: "dev", + add: "cue marketplace install-skill zcebupelka/graphql-mcp-bridge", + }); + expect(r.changed).toBe(true); + expect(r.alreadyPresent).toBe(false); + const yaml = readProfile("dev"); + expect(yaml).toContain("npx:"); + expect(yaml).toContain("- repo: zcebupelka/graphql-mcp-bridge"); + expect(yaml).toContain("skills: [graphql-mcp-bridge]"); + + // Idempotent on the repo. + const again = await installMarketItem({ + id: "skill:graphql-mcp-bridge", addKind: "skill", profile: "dev", + add: "cue marketplace install-skill zcebupelka/graphql-mcp-bridge", + }); + expect(again.alreadyPresent).toBe(true); + expect(again.changed).toBe(false); + }); + + test("creates the skills scaffold when the profile has none", async () => { + writeProfile("bare", "name: bare\ninherits: core\n"); + const r = await installMarketItem({ + id: "skill:foo", addKind: "skill", profile: "bare", + add: "cue marketplace install-skill owner/foo", + }); + expect(r.changed).toBe(true); + const yaml = readProfile("bare"); + expect(yaml).toContain("skills:"); + expect(yaml).toContain(" npx:"); + expect(yaml).toContain("- repo: owner/foo"); + }); + + test("appends a plugin to the plugins list", async () => { + writeProfile("dev", "name: dev\nplugins:\n - resend@claude-plugins-official\n"); + const r = await installMarketItem({ id: "plugin:foo@bar", addKind: "plugin", profile: "dev" }); + expect(r.changed).toBe(true); + expect(readProfile("dev")).toContain("- foo@bar"); + const again = await installMarketItem({ id: "plugin:foo@bar", addKind: "plugin", profile: "dev" }); + expect(again.alreadyPresent).toBe(true); + }); + + test("expands a scalar inherits into a list when adding a companion profile", async () => { + writeProfile("dev", "name: dev\ninherits: core\n"); + const r = await installMarketItem({ id: "profile:backend", addKind: "profile", profile: "dev" }); + expect(r.changed).toBe(true); + const yaml = readProfile("dev"); + expect(yaml).toContain("inherits:"); + expect(yaml).toContain(" - core"); + expect(yaml).toContain(" - backend"); + }); + + test("refuses to make a profile inherit itself", async () => { + writeProfile("dev", "name: dev\ninherits: core\n"); + const r = await installMarketItem({ id: "profile:dev", addKind: "profile", profile: "dev" }); + expect(r.changed).toBe(false); + expect(r.detail).toMatch(/itself/); + }); + + test("adds a workflow to the playbooks list", async () => { + writeProfile("dev", "name: dev\n"); + const r = await installMarketItem({ id: "workflow:ship-it", addKind: "workflow", profile: "dev" }); + expect(r.changed).toBe(true); + expect(readProfile("dev")).toContain("playbooks:"); + expect(readProfile("dev")).toContain("- ship-it"); + }); + + test("returns a manual command for a bare CLI (no profile write)", async () => { + writeProfile("dev", "name: dev\n"); + const r = await installMarketItem({ id: "cli:ripgrep", addKind: "cli", profile: "dev" }); + expect(r.changed).toBe(false); + expect(r.manual?.command).toBeTruthy(); + expect(readProfile("dev")).toBe("name: dev\n"); // untouched + }); + + test("rejects an unknown / traversing profile name", async () => { + await expect( + installMarketItem({ id: "plugin:x@y", addKind: "plugin", profile: "../escape" }), + ).rejects.toThrow(/invalid-profile/); + }); + + test("errors when the profile has no profile.yaml", async () => { + await expect( + installMarketItem({ id: "plugin:x@y", addKind: "plugin", profile: "ghost" }), + ).rejects.toThrow(/not-a-physical-profile/); + }); +}); diff --git a/src/lib/market-install.ts b/src/lib/market-install.ts new file mode 100644 index 00000000..a151658e --- /dev/null +++ b/src/lib/market-install.ts @@ -0,0 +1,247 @@ +/** + * Marketplace install — wire a `/market` item into a real profile.yaml. + * + * The studio's Market page lists everything addable to a profile (skills, MCPs, + * plugins, companion profiles, workflows). This module is what makes the + * "Install → Add to profile" picker actually *do* something: it edits the + * target `profiles//profile.yaml` in place, idempotently, so the item + * shows up in the profile (and the dashboard's counts) on the next resolve. + * + * It deliberately mirrors `addMcpToProfile` in mcp-catalog.ts — same profile + * validation, same path-traversal guard, same line-based YAML editing (we keep + * the file human-authored, so we splice list items rather than round-tripping + * through a YAML serializer that would reflow comments and quoting). + * + * Paths resolve per-call from env so tests can point CUE_PROFILES_DIR at a + * fixture tree without writing to the real `profiles/`. + */ + +import { readFile, writeFile, access } from "node:fs/promises"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { addMcpToProfile } from "./mcp-catalog"; + +function repoRoot(): string { + return ( + process.env.CUE_REPO_ROOT ?? + process.env.SOUL_REPO_ROOT ?? + resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") + ); +} +function profilesDir(): string { + return process.env.CUE_PROFILES_DIR ?? join(repoRoot(), "profiles"); +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** The kinds the studio's Market page can route to "Add to profile". */ +export type MarketAddKind = "mcp" | "skill" | "profile" | "cli" | "workflow" | "plugin"; + +export interface MarketInstallInput { + /** The MarketItem id, e.g. "skill:foo", "mcp:github", "profile:backend". */ + id: string; + /** Which install path to take. */ + addKind: MarketAddKind; + /** Target physical profile (a `profiles//` directory). */ + profile: string; + /** The item's copy-paste install command — used to recover a skill's repo. */ + add?: string; +} + +export interface MarketInstallResult { + id: string; + profile: string; + addKind: MarketAddKind; + /** True when the profile.yaml was edited (false when already present). */ + changed: boolean; + /** True when the item was already wired into the profile. */ + alreadyPresent: boolean; + /** Human-readable summary of what happened. */ + detail: string; + /** + * Set for kinds that can't be wired into a profile.yaml (a bare CLI tool). + * The studio shows the command for the user to run instead. + */ + manual?: { command: string }; +} + +/** Resolve + validate a physical profile.yaml path, guarding traversal. */ +async function profileYamlPath(profile: string): Promise { + if (!/^[a-z0-9][a-z0-9_-]*$/i.test(profile)) { + throw new Error(`invalid-profile: "${profile}"`); + } + const dir = profilesDir(); + const yamlPath = join(dir, profile, "profile.yaml"); + if (!resolve(yamlPath).startsWith(resolve(dir))) { + throw new Error(`invalid-profile: "${profile}"`); + } + try { + await access(yamlPath); + } catch { + throw new Error( + `not-a-physical-profile: "${profile}" has no profile.yaml (composite profiles can't be written directly)`, + ); + } + return yamlPath; +} + +/** + * Add `item` to a top-level YAML list `key` (e.g. plugins, playbooks, inherits). + * Handles all three on-disk shapes: + * - key absent → append `key:\n - item` + * - key is a scalar → expand to a list keeping the existing value + * - key is a block list → splice in after the last existing entry + * Idempotent: a no-op when `item` is already present under the key. + */ +function addToplevelListItem(content: string, key: string, item: string): { content: string; alreadyPresent: boolean } { + const lines = content.split("\n"); + const keyIdx = lines.findIndex((l) => new RegExp(`^${escapeRe(key)}:`).test(l)); + + if (keyIdx === -1) { + return { content: content.trimEnd() + `\n${key}:\n - ${item}\n`, alreadyPresent: false }; + } + + // Scalar form: `key: value` (anything non-empty, non-comment after the colon). + const scalar = lines[keyIdx]!.match(new RegExp(`^${escapeRe(key)}:\\s*(\\S[^#]*?)\\s*(#.*)?$`)); + if (scalar?.[1]) { + const existing = scalar[1].replace(/^["']|["']$/g, ""); + if (existing === item) return { content, alreadyPresent: true }; + lines.splice(keyIdx, 1, `${key}:`, ` - ${existing}`, ` - ${item}`); + return { content: lines.join("\n"), alreadyPresent: false }; + } + + // Block list: scan the contiguous ` - ...` entries under the key. + let insertIdx = keyIdx + 1; + while (insertIdx < lines.length && /^\s+-\s/.test(lines[insertIdx] ?? "")) { + if (new RegExp(`^\\s*-\\s+${escapeRe(item)}\\s*(#.*)?$`).test(lines[insertIdx]!)) { + return { content, alreadyPresent: true }; + } + insertIdx++; + } + lines.splice(insertIdx, 0, ` - ${item}`); + return { content: lines.join("\n"), alreadyPresent: false }; +} + +/** + * Add an npx skill entry (`{ repo, skills: [name] }`) under `skills.npx:`. + * Creates the `skills:` / `npx:` scaffold when absent. Idempotent on `repo`. + */ +function addSkillNpx(content: string, repo: string, skillName: string): { content: string; alreadyPresent: boolean } { + const lines = content.split("\n"); + if (lines.some((l) => new RegExp(`^\\s*-\\s+repo:\\s+${escapeRe(repo)}\\s*(#.*)?$`).test(l))) { + return { content, alreadyPresent: true }; + } + + const entry = [` - repo: ${repo}`, ` skills: [${skillName}]`]; + const skillsIdx = lines.findIndex((l) => /^skills:/.test(l)); + if (skillsIdx === -1) { + return { content: content.trimEnd() + `\nskills:\n npx:\n${entry.join("\n")}\n`, alreadyPresent: false }; + } + + // Find ` npx:` inside the skills block (stop at the next top-level key). + let npxIdx = -1; + for (let i = skillsIdx + 1; i < lines.length; i++) { + if (/^\S/.test(lines[i] ?? "")) break; + if (/^\s{2}npx:/.test(lines[i] ?? "")) { npxIdx = i; break; } + } + if (npxIdx === -1) { + lines.splice(skillsIdx + 1, 0, " npx:", ...entry); + return { content: lines.join("\n"), alreadyPresent: false }; + } + // Insert after the existing npx children (indented 4+). + let insertIdx = npxIdx + 1; + while (insertIdx < lines.length && /^\s{4,}\S/.test(lines[insertIdx] ?? "")) insertIdx++; + lines.splice(insertIdx, 0, ...entry); + return { content: lines.join("\n"), alreadyPresent: false }; +} + +/** "cue marketplace install-skill owner/repo" → "owner/repo" (best effort). */ +function repoFromAddCommand(add: string | undefined): string | null { + if (!add) return null; + const m = add.match(/install-skill\s+(\S+)/); + return m?.[1] ?? null; +} + +/** + * Install a marketplace item into a profile by editing its profile.yaml. + * + * Returns `manual` (rather than throwing) for a bare CLI, which has no + * profile.yaml home — the studio surfaces the command for the user to run. + */ +export async function installMarketItem(input: MarketInstallInput): Promise { + const { addKind, profile } = input; + const ref = input.id.includes(":") ? input.id.slice(input.id.indexOf(":") + 1) : input.id; + + // MCPs already have a dedicated, validated writer — reuse it verbatim. + if (addKind === "mcp") { + const r = await addMcpToProfile(ref, profile); + return { + id: input.id, profile, addKind, + changed: !r.alreadyPresent, alreadyPresent: r.alreadyPresent, + detail: r.alreadyPresent ? `${ref} already in ${profile}` : `added mcp ${ref} to ${profile}`, + }; + } + + // A bare CLI tool isn't a profile member — hand back the install command. + if (addKind === "cli") { + return { + id: input.id, profile, addKind, + changed: false, alreadyPresent: false, + detail: `${ref} is a CLI tool — run its install command`, + manual: { command: input.add && input.add !== "(see recipe)" ? input.add : `cue cli install ${ref}` }, + }; + } + + const yamlPath = await profileYamlPath(profile); + const before = await readFile(yamlPath, "utf8"); + + let edited: { content: string; alreadyPresent: boolean }; + let label: string; + switch (addKind) { + case "skill": { + const repo = repoFromAddCommand(input.add); + if (!repo) { + return { + id: input.id, profile, addKind, changed: false, alreadyPresent: false, + detail: `couldn't resolve a repo for ${ref}`, + manual: { command: input.add ?? `cue marketplace install-skill ${ref}` }, + }; + } + edited = addSkillNpx(before, repo, ref); + label = `skill ${ref}`; + break; + } + case "plugin": + edited = addToplevelListItem(before, "plugins", ref); + label = `plugin ${ref}`; + break; + case "profile": + if (ref === profile) { + return { + id: input.id, profile, addKind, changed: false, alreadyPresent: true, + detail: `${profile} can't inherit itself`, + }; + } + edited = addToplevelListItem(before, "inherits", ref); + label = `profile ${ref} (inherited)`; + break; + case "workflow": + edited = addToplevelListItem(before, "playbooks", ref); + label = `workflow ${ref}`; + break; + default: + throw new Error(`unknown-add-kind: "${addKind}"`); + } + + if (!edited.alreadyPresent && edited.content !== before) { + await writeFile(yamlPath, edited.content); + } + return { + id: input.id, profile, addKind, + changed: !edited.alreadyPresent, alreadyPresent: edited.alreadyPresent, + detail: edited.alreadyPresent ? `${label} already in ${profile}` : `added ${label} to ${profile}`, + }; +} diff --git a/src/lib/pair-suggestions.ts b/src/lib/pair-suggestions.ts index b995e05a..79ab0b8e 100644 --- a/src/lib/pair-suggestions.ts +++ b/src/lib/pair-suggestions.ts @@ -199,7 +199,7 @@ export interface BuildUniversalOptions { } const UNIVERSAL_DEFAULTS: Required> = - { maxFeatured: 5, maxFrequent: 2, minFrequentPicks: 3 }; + { pinnedCompanions: UNIVERSAL_COMPANIONS, maxFeatured: 5, maxFrequent: 2, minFrequentPicks: 3 }; /** * Cross-profile combine suggestions surfaced under *every* primary: the curated diff --git a/web/src/studio/api.ts b/web/src/studio/api.ts index ba1e1365..1286b0b3 100644 --- a/web/src/studio/api.ts +++ b/web/src/studio/api.ts @@ -207,6 +207,35 @@ export interface MarketData { counts: Record; } +/** Result of POST /market/install — the profile.yaml edit that backs "Add to profile". */ +export interface MarketInstallResult { + id: string; + profile: string; + addKind: MarketItem["addKind"]; + /** True when the profile.yaml was actually edited. */ + changed: boolean; + /** True when the item was already wired into the profile. */ + alreadyPresent: boolean; + detail: string; + /** Present for a bare CLI (no profile.yaml home) — the command to run instead. */ + manual?: { command: string }; +} + +/** + * Install a marketplace item into one of the user's profiles. Edits the target + * profile.yaml server-side (skill → skills.npx, mcp → mcps, plugin → plugins, + * profile → inherits, workflow → playbooks). A bare CLI comes back with + * `manual` set so the caller can show the command instead. + */ +export function installMarketItem(item: Pick, profile: string) { + return postJson("/market/install", { + id: item.id, + addKind: item.addKind, + add: item.add, + profile, + }); +} + export interface SkillUsageRow { id: string; hits: number; diff --git a/web/src/studio/views/Market.tsx b/web/src/studio/views/Market.tsx index 29aea373..69381815 100644 --- a/web/src/studio/views/Market.tsx +++ b/web/src/studio/views/Market.tsx @@ -14,7 +14,7 @@ import { useEffect, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { useMarket, useProfilesFull, type MarketItem } from "../api"; +import { useMarket, useProfilesFull, installMarketItem, type MarketItem } from "../api"; import { useCommunityMarket, publishCommunity } from "../../lib/market-client"; import { useSession } from "../../lib/auth-client"; @@ -63,6 +63,7 @@ function daysAgo(when: string): number { } export function MarketView() { + const qc = useQueryClient(); const { data } = useMarket(); const community = useCommunityMarket(); const { data: session } = useSession(); @@ -77,6 +78,7 @@ export function MarketView() { const [pubOpen, setPubOpen] = useState(false); const [toast, setToast] = useState(""); const [addFor, setAddFor] = useState(null); + const [installing, setInstalling] = useState(null); const [sortOpen, setSortOpen] = useState(false); useEffect(() => { try { localStorage.setItem(STARS_KEY, JSON.stringify(stars)); } catch { /* ignore */ } }, [stars]); @@ -96,7 +98,34 @@ export function MarketView() { const starred = new Set(stars); const toggleStar = (id: string) => setStars((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); - const flash = (m: string) => { setToast(m); setTimeout(() => setToast(""), 1800); }; + const flash = (m: string) => { setToast(m); setTimeout(() => setToast(""), 2600); }; + + // Install an item into a real profile (edits profile.yaml server-side). A + // bare CLI has no profile.yaml home — its `manual` command is copied instead. + async function install(i: LocalMarketItem, profile: string) { + if (i.mine) { flash("Local drafts can't be installed yet — publish opens a registry PR"); return; } + const key = i.id + "→" + profile; + setInstalling(key); + try { + const r = await installMarketItem(i, profile); + if (r.manual) { + try { await navigator.clipboard.writeText(r.manual.command); } catch { /* ignore */ } + flash(`${i.name}: ${r.manual.command} — copied`); + } else if (r.alreadyPresent) { + flash(`${i.name} already in ${profile}`); + } else { + flash(`${i.name} → added to ${profile} · relaunch cue to load`); + } + // Reflect the new membership across the studio. + qc.invalidateQueries({ queryKey: ["profiles-full"] }); + qc.invalidateQueries({ queryKey: ["profile-detail"] }); + qc.invalidateQueries({ queryKey: ["market"] }); + } catch (err) { + flash(`Install failed: ${(err as Error).message}`); + } finally { + setInstalling(null); + } + } // Browse list: the user's local drafts on top, then the hosted community // submissions (what everyone pushed), then this checkout's live catalog. @@ -171,17 +200,21 @@ export function MarketView() {
e.stopPropagation()}>
Add to profile choose one of yours
- {myProfiles.map((p) => ( - - ))} + {myProfiles.map((p) => { + const busy = installing === i.id + "→" + p.name; + return ( + + ); + })} {myProfiles.length === 0 &&
no profiles loaded
}
{