From 2445b38463b59200482b59e2ff922b4e7d4f9e48 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 09:38:24 +0000 Subject: [PATCH 1/4] feat(studio): make marketplace Install actually install into a profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The studio Market page listed everything addable to a profile but the "Add to profile" picker only flashed a toast — it never wrote anything. This wires it end-to-end so installing a marketplace item edits the target profile.yaml for real. - src/lib/market-install.ts: installMarketItem() edits profile.yaml in place, idempotently, mirroring addMcpToProfile's validation + path guard. Routes each addKind to its home — skill → skills.npx, mcp → mcps (reuses addMcpToProfile), plugin → plugins, profile → inherits (expands a scalar into a list), workflow → playbooks. A bare CLI has no profile.yaml home, so it returns a `manual` command for the studio to surface instead. - dashboard-server: handleMarketInstall + POST /api/v1/market/install, on the existing write-side allowlist; busts the market cache on a real edit. - web: installMarketItem() api helper + Market.tsx wires the picker to call it, shows pending/success/already-present/manual/error in the toast, and invalidates the profiles/detail/market queries so the rest of the studio reflects the new membership. - Tests for every addKind (write + idempotency + traversal guard) and the handler's request validation. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0178QDX7Jk7CkdGgH7uKfamv --- src/lib/dashboard-server.test.ts | 32 +++- src/lib/dashboard-server.ts | 36 +++++ src/lib/market-install.test.ts | 128 ++++++++++++++++ src/lib/market-install.ts | 247 +++++++++++++++++++++++++++++++ web/src/studio/api.ts | 29 ++++ web/src/studio/views/Market.tsx | 60 ++++++-- 6 files changed, 518 insertions(+), 14 deletions(-) create mode 100644 src/lib/market-install.test.ts create mode 100644 src/lib/market-install.ts 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/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 b11ea3ea..71a8bac4 100644 --- a/web/src/studio/views/Market.tsx +++ b/web/src/studio/views/Market.tsx @@ -12,8 +12,9 @@ */ 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"; // A locally-published draft is a MarketItem with the extra "yours" marker. Kept // in localStorage and prepended to the browse list before the live catalog. @@ -60,6 +61,7 @@ function daysAgo(when: string): number { } export function MarketView() { + const qc = useQueryClient(); const { data } = useMarket(); const profilesQ = useProfilesFull(); @@ -71,6 +73,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]); @@ -90,7 +93,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 live catalog. Never // the prototype SEED — a fresh checkout shows exactly what /market returns. @@ -159,17 +189,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
}
{ From 9d9d8daa98f028dc34136aeaf0276fc2922f6171 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 09:42:37 +0000 Subject: [PATCH 2/4] fix(ci): repoint resources/skills submodule to a live commit CI was failing at the actions/checkout submodule step for every PR (and on main since #71): the resources/skills gitlink pinned 4274beaece3a38a4a7a85142b2f09f2b23e3ce89, which no longer exists on opencue/skills.git (force-pushed / GC'd), so `submodule update` aborted before lint/test/e2e could run. Bump the gitlink to a452e5d2892d59d766b1927c9d35a6f806bce79e, the current tip of the tracked `soul-main` branch, so checkout succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0178QDX7Jk7CkdGgH7uKfamv --- resources/skills | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/skills b/resources/skills index 4274beae..a452e5d2 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit 4274beaece3a38a4a7a85142b2f09f2b23e3ce89 +Subproject commit a452e5d2892d59d766b1927c9d35a6f806bce79e From f842f800b6b0b6a3d45c89b9677538ed4fe48fdb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 09:48:33 +0000 Subject: [PATCH 3/4] fix(types): give UNIVERSAL_DEFAULTS its pinnedCompanions default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildUniversalSuggestions spreads UNIVERSAL_DEFAULTS over the caller's opts, but the defaults object was missing pinnedCompanions, which became a required key of `Required>` once that field was added — so `tsc --noEmit` failed. Default it to UNIVERSAL_COMPANIONS (the documented default), so the spread actually supplies it and the type is satisfied. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0178QDX7Jk7CkdGgH7uKfamv --- src/lib/pair-suggestions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 1073f8030386768d0998ab41d94007d099467979 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 09:48:33 +0000 Subject: [PATCH 4/4] fix(ci): track the skills submodule's main branch (has the referenced skills) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous bump pointed resources/skills at soul-main's tip, but that branch is mid-vault-migration and no longer contains skills the catalog references — e.g. core/profile.yaml uses tools/ccusage and tools/headroom, which exist only on the skills repo's `main` branch. That made every profile fail the e2e resolver dry-run (E3 missing reference). Repoint the gitlink to opencue/skills main (3c6b2569) and update .gitmodules to track `branch = main` so `--remote` refreshes stay on the canonical catalog. `cue validate --all` now passes (exit 0, warnings only). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0178QDX7Jk7CkdGgH7uKfamv --- .gitmodules | 2 +- resources/skills | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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