Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
[submodule "resources/skills"]
path = resources/skills
url = https://github.com/opencue/skills.git
branch = soul-main
branch = main
2 changes: 1 addition & 1 deletion resources/skills
Submodule skills updated 119 files
32 changes: 31 additions & 1 deletion src/lib/dashboard-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions src/lib/dashboard-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1653,6 +1654,34 @@ export async function handleMarket(): Promise<ApiResult<unknown>> {
return { ok: true, data };
}

const MARKET_ADD_KINDS = new Set<MarketAddKind>(["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<ApiResult<unknown>> {
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<ApiResult<unknown>> {
return { ok: true, data: listWorkflows() };
Expand Down Expand Up @@ -1871,6 +1900,13 @@ export function createHandler(): (req: Request) => Promise<Response> {
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<typeof handleMarketInstall>[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 */ }
Expand Down
128 changes: 128 additions & 0 deletions src/lib/market-install.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading