Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 890a611073 fix(cli): address hub dashboard review feedback 2026-06-17 17:45:04 -07:00
Saoud Rizwan b07375d5ec feat(cli): refresh hub dashboard navigation 2026-06-17 17:37:23 -07:00
Saoud Rizwan a51b156383 feat(cli): add hub primitive catalogs (#11624)
* feat(cli): add hub marketplace

* chore(cli): clean up marketplace install code

* fix(cli): harden marketplace review issues

* feat(cli): add marketplace uninstall actions

* fix(cli): keep marketplace dialog actions in sync

* fix(cli): uninstall marketplace cards directly

* fix(cli): hide stale marketplace installed notice

* feat(cli): split hub marketplace pages

* fix(cli): show full marketplace descriptions

* fix(cli): avoid duplicate marketplace descriptions

* fix(cli): support marketplace deep refreshes

* fix(cli): hide marketplace placeholder icons

* fix(cli): remove marketplace card icons

* fix(cli): ignore featured marketplace entries

* chore(cli): clean marketplace follow-up code

* fix(cli): address marketplace review findings
2026-06-17 17:17:38 -07:00
20 changed files with 3800 additions and 681 deletions
+40 -2
View File
@@ -10,7 +10,7 @@ describe("buildSkillsArgs", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
"install",
"add",
"owner/repo",
"--agent",
"cline",
@@ -20,6 +20,17 @@ describe("buildSkillsArgs", () => {
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
});
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
"remove",
"my-skill",
"--agent",
"cline",
]);
});
it("does not inject when the user already targeted an agent", () => {
expect(
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
@@ -32,12 +43,39 @@ describe("buildSkillsArgs", () => {
).not.toContain("cline");
});
it("aliases install and uninstall when agent options come before the subcommand", () => {
expect(
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
"--agent",
"cursor",
"add",
"owner/repo",
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
});
it("does not scope non-install subcommands to cline", () => {
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
expect(buildSkillsArgs(["remove"])).not.toContain("--agent");
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
});
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
"remove",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
});
it("ignores leading flags when detecting the subcommand", () => {
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
"cline",
+45 -2
View File
@@ -16,7 +16,21 @@ const SKILLS_PACKAGE = "skills@latest";
// own agent. `use` is intentionally excluded: without --agent it prints the
// generated prompt to stdout, whereas adding --agent would launch that agent
// interactively instead — not what someone scoping to Cline would expect.
const CLINE_SCOPED_SUBCOMMANDS = new Set(["add", "install", "i", "update"]);
const CLINE_SCOPED_SUBCOMMANDS = new Set([
"add",
"install",
"i",
"update",
"remove",
"rm",
"r",
"uninstall",
]);
const SKILLS_SUBCOMMAND_ALIASES = new Map([
["install", "add"],
["uninstall", "remove"],
]);
function hasAgentFlag(args: readonly string[]): boolean {
return args.some(
@@ -24,8 +38,36 @@ function hasAgentFlag(args: readonly string[]): boolean {
);
}
function optionConsumesNextValue(arg: string): boolean {
return arg === "-a" || arg === "--agent";
}
function findSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg.startsWith("-")) {
if (optionConsumesNextValue(arg)) {
index++;
}
continue;
}
return index;
}
return -1;
}
function findSubcommand(args: readonly string[]): string | undefined {
return args.find((arg) => !arg.startsWith("-"));
const index = findSubcommandIndex(args);
return index >= 0 ? args[index] : undefined;
}
function normalizeSkillsSubcommandAliases(args: string[]): void {
const index = findSubcommandIndex(args);
if (index < 0) return;
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
if (alias) {
args[index] = alias;
}
}
/**
@@ -35,6 +77,7 @@ function findSubcommand(args: readonly string[]): string | undefined {
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
const args = [...userArgs];
const subcommand = findSubcommand(args);
normalizeSkillsSubcommandAliases(args);
if (
subcommand &&
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
+4 -2
View File
@@ -324,10 +324,12 @@ export async function runCli(): Promise<void> {
.addHelpText(
"after",
"\nForwards to the open skills CLI via npx. Examples:\n" +
" cline skill install <owner/repo> Install a skill into Cline\n" +
" cline skill add <owner/repo> Add a skill into Cline\n" +
" cline skill install <owner/repo> Alias for add\n" +
" cline skill list List installed skills\n" +
" cline skill remove Remove installed skills\n" +
"\ninstall/add default to '--agent cline' unless you pass your own --agent.\n" +
" cline skill uninstall Alias for remove\n" +
"\nadd/install and remove/uninstall default to '--agent cline' unless you pass your own --agent.\n" +
"Run 'npx skills --help' for the full command reference.",
)
.action(async () => {
+1
View File
@@ -12,6 +12,7 @@
"dev": "bun run src/dev.ts",
"start": "bun run src/server.ts",
"smoke:options": "bun run src/validate-options.ts",
"test": "bunx vitest run --config vitest.config.ts",
"typecheck": "bun tsc -p tsconfig.json --noEmit"
},
"dependencies": {
+16
View File
@@ -22,6 +22,7 @@ import {
syncHubClientsAndSessions,
syncHubHealth,
} from "./server/hub";
import { fetchMarketplaceCatalog } from "./server/marketplace";
import {
loadModels,
runProviderOAuthLogin,
@@ -99,6 +100,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
if (url.pathname === "/config.json") {
return createJsonResponse(browserConfig);
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(await fetchMarketplaceCatalog());
} catch (error) {
return createJsonResponse(
{
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
},
502,
);
}
}
return assets.serve(url.pathname);
},
websocket: {
@@ -27,6 +27,11 @@ import {
stopConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
deleteMcpServer,
ensureMcpSettingsFile,
@@ -213,6 +218,22 @@ export async function handleDesktopCommand(
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(workspaceRoot),
);
}
if (command === "install_marketplace_entry") {
const result = await installMarketplaceEntryForDesktopCommand(args);
broadcastHubState(ctx);
return result;
}
if (command === "uninstall_marketplace_entry") {
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
broadcastHubState(ctx);
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) throw new Error("tool name is required");
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
describe("isWebviewRoute", () => {
it.each([
"/",
"/chat",
"/sessions",
"/models",
"/customizations",
"/marketplace",
"/marketplace/mcp",
"/marketplace/skills",
"/marketplace/plugins",
"/channels",
"/schedules",
"/settings",
"/settings/providers",
])("matches dashboard SPA route %s", (pathname) => {
expect(isWebviewRoute(pathname)).toBe(true);
});
it("does not treat nested marketplace asset requests as SPA routes", () => {
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
});
});
describe("normalizeWebviewIndexHtml", () => {
it("rewrites relative built asset URLs so deep links can refresh", () => {
expect(
normalizeWebviewIndexHtml(
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
),
).toBe(
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
);
});
});
+66 -6
View File
@@ -15,6 +15,14 @@ export function createTextResponse(text: string, status = 200): Response {
});
}
const NO_STORE_HEADERS = {
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
pragma: "no-cache",
expires: "0",
};
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
function contentTypeFor(path: string): string {
switch (extname(path)) {
case ".html":
@@ -36,16 +44,29 @@ function contentTypeFor(path: string): string {
}
}
function isWebviewRoute(pathname: string): boolean {
export function isWebviewRoute(pathname: string): boolean {
return (
pathname === "/" ||
pathname === "/index.html" ||
pathname === "/chat" ||
pathname === "/sessions" ||
pathname === "/models" ||
pathname === "/customizations" ||
pathname === "/marketplace" ||
pathname === "/marketplace/mcp" ||
pathname === "/marketplace/skills" ||
pathname === "/marketplace/plugins" ||
pathname === "/channels" ||
pathname === "/schedules" ||
pathname === "/settings" ||
pathname.startsWith("/settings/")
);
}
export function normalizeWebviewIndexHtml(html: string): string {
return html.replaceAll('src="./', 'src="/').replaceAll('href="./', 'href="/');
}
function renderDevIndexHtml(devServerUrl: string): string {
return `<!doctype html>
<html lang="en">
@@ -74,6 +95,14 @@ function renderDevIndexHtml(devServerUrl: string): string {
export class WebviewAssets {
constructor(private readonly webviewDistDir: string) {}
private async resolveCurrentMainAssetPath(): Promise<string | undefined> {
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
if (!(await indexFile.exists())) return undefined;
const html = await indexFile.text();
const match = html.match(/src="\.\/(assets\/index-[^"]+\.js)"/);
return match?.[1] ? join(this.webviewDistDir, match[1]) : undefined;
}
private resolveStaticPath(pathname: string): string | undefined {
const decoded = decodeURIComponent(pathname);
const requested = decoded === "/" ? "/index.html" : decoded;
@@ -89,8 +118,11 @@ export class WebviewAssets {
private async serveIndex(): Promise<Response> {
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "content-type": "text/html; charset=utf-8" },
return new Response(normalizeWebviewIndexHtml(await indexFile.text()), {
headers: {
"content-type": "text/html; charset=utf-8",
...NO_STORE_HEADERS,
},
});
}
return createTextResponse(
@@ -103,7 +135,10 @@ export class WebviewAssets {
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
if (devServerUrl && isWebviewRoute(pathname)) {
return new Response(renderDevIndexHtml(devServerUrl), {
headers: { "content-type": "text/html; charset=utf-8" },
headers: {
"content-type": "text/html; charset=utf-8",
...NO_STORE_HEADERS,
},
});
}
if (isWebviewRoute(pathname)) {
@@ -112,12 +147,37 @@ export class WebviewAssets {
const filePath = this.resolveStaticPath(pathname);
if (!filePath) return createTextResponse("not found", 404);
const file = Bun.file(filePath);
let responsePath = filePath;
let file = Bun.file(responsePath);
if (
!(await file.exists()) &&
/^\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(pathname)
) {
const currentMainAssetPath = await this.resolveCurrentMainAssetPath();
if (currentMainAssetPath) {
responsePath = currentMainAssetPath;
file = Bun.file(responsePath);
}
}
if (!(await file.exists())) {
return createTextResponse("not found", 404);
}
const isHashedAsset = /^\/assets\/.+-[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(
pathname,
);
return new Response(file, {
headers: { "content-type": contentTypeFor(filePath) },
headers: {
"content-type": contentTypeFor(responsePath),
"cache-control": isHashedAsset
? IMMUTABLE_ASSET_CACHE
: NO_STORE_HEADERS["cache-control"],
...(isHashedAsset
? {}
: {
pragma: NO_STORE_HEADERS.pragma,
expires: NO_STORE_HEADERS.expires,
}),
},
});
}
}
@@ -0,0 +1,797 @@
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMarketplaceMcpInput,
fetchMarketplaceCatalog,
installMarketplaceEntry,
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallMarketplaceEntry,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
describe("marketplace installer", () => {
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalClineDir = process.env.CLINE_DIR;
const originalHome = process.env.HOME;
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
afterEach(() => {
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalClineDir === undefined) {
delete process.env.CLINE_DIR;
} else {
process.env.CLINE_DIR = originalClineDir;
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
vi.restoreAllMocks();
});
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
expect(
buildMarketplaceMcpInput([
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
]),
).toEqual({
name: "context7",
transportType: "streamableHttp",
url: "https://mcp.context7.com/mcp",
disabled: false,
});
});
it("maps stdio MCP catalog args to command and args", () => {
expect(
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
).toEqual({
name: "filesystem",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "/tmp"],
disabled: false,
});
});
it("preserves server flags after stdio MCP command args begin", () => {
expect(
buildMarketplaceMcpInput([
"search",
"npx",
"-y",
"server",
"--transport",
"stdio",
]),
).toEqual({
name: "search",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "--transport", "stdio"],
disabled: false,
});
});
it("runs skills globally for Cline without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
"---\nname: web-design-guidelines\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await installMarketplaceEntry(
{
entry: {
id: "web-design-guidelines",
type: "skill",
name: "Web Design Guidelines",
install: {
args: [
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
],
},
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"add",
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
"-g",
"-a",
"cline",
"-y",
]);
});
it("skips skill install commands when the global skill already exists", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Cline SDK is already installed.",
});
expect(spawnCommand).not.toHaveBeenCalled();
});
it("reports Cline global skills as marketplace-installed", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
process.env.CLINE_DIR = clineDir;
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
}),
).toEqual({ installedKeys: ["skill:cline-sdk"] });
});
it("accepts skill installs that create Cline global skills", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
const clineDir = join(homeDir, ".cline");
process.env.HOME = homeDir;
process.env.CLINE_DIR = clineDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Installed Cline SDK globally for Cline.",
});
});
it("removes Cline global marketplace skills without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
const spawnCommand = vi.fn(async () => {
rmSync(skillDir, { recursive: true, force: true });
return {
exitCode: 0,
stdout: "removed",
stderr: "",
};
});
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Cline SDK.",
});
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
it("does not report project-local skills as marketplace-installed globals", () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
},
{
skills: [
{
id: "cline-sdk",
name: "cline-sdk",
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("rejects skill installs that exit zero but report failure", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Failed to install 1",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("Skill install failed");
});
it("redacts common secret formats from failed install output", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout:
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
stderr:
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
}));
let message = "";
try {
await installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain("Authorization: [redacted]");
expect(message).toContain("api key [redacted]");
expect(message).toContain("OPENAI_API_KEY=[redacted]");
expect(message).toContain("TOKEN=[redacted]");
expect(message).toContain("password is [redacted]");
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
expect(message).not.toContain("stdout-token");
expect(message).not.toContain("stdout-key");
expect(message).not.toContain("compound-key");
expect(message).not.toContain("stderr-token");
expect(message).not.toContain("stderr-password");
expect(message).not.toContain("anthropic-secret");
});
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents"), { recursive: true });
writeFileSync(join(homeDir, ".agents", "skills"), "");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow(
"Cannot install skill globally because ~/.agents/skills is not writable",
);
expect(spawnCommand).not.toHaveBeenCalled();
});
it("rejects skill installs that do not create a global skill", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Installation complete",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("was not found in Cline's global skills directories");
});
it("runs official plugin installs through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntry(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("runs official plugin uninstalls through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Goal.",
});
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntryForDesktopCommand(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await uninstallMarketplaceEntryForDesktopCommand(
{
entry: {
id: "goal",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallMarketplaceEntry({
entry: {
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Context7.",
});
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
],
}),
).toEqual({ installedKeys: [] });
});
it("reports official plugin marketplace entries installed from Cline home", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("does not report plugin inventory substring matches as installed", () => {
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
},
{
plugins: [
{
name: "goal-helper",
path: "/workspace/.cline/plugins/goal-helper/index.ts",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("skips invalid marketplace entries during installed-status checks", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "broken-mcp",
type: "mcp",
name: "Broken MCP",
install: {
args: [
"broken-mcp",
"--transport",
"ws",
"https://example.com/mcp",
],
},
},
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("rejects invalid marketplace entries before spawning commands", async () => {
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "bad",
type: "skill",
install: { args: [] },
},
},
{ spawnCommand },
),
).rejects.toThrow("marketplace install args are required");
expect(spawnCommand).not.toHaveBeenCalled();
});
it("fetches the marketplace catalog through the server helper", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(JSON.stringify({ version: 1, entries: [] }), {
headers: { "content-type": "application/json" },
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
version: 1,
entries: [],
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://cline.github.io/marketplace/catalog.json",
{ headers: { Accept: "application/json" } },
);
});
it("surfaces marketplace catalog upstream failures", async () => {
const fetchImpl = vi.fn(async () => {
return new Response("nope", {
status: 503,
statusText: "Service Unavailable",
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
"Failed to fetch marketplace catalog: 503 Service Unavailable",
);
});
});
+873
View File
@@ -0,0 +1,873 @@
import { type SpawnOptions, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { resolveClineDir } from "@cline/shared/storage";
import {
deleteMcpServer,
readMcpServersResponse,
upsertMcpServer,
} from "./mcp";
import type { JsonRecord } from "./types";
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
type MarketplaceInstallInput = {
id: string;
type: MarketplacePrimitiveType;
name?: string;
install: {
args?: string[];
env?: MarketplaceEnvVar[];
command?: string;
notes?: string;
};
};
type MarketplaceInstallResult = {
id: string;
type: MarketplacePrimitiveType;
status: "installed" | "uninstalled";
message: string;
details?: JsonRecord;
output?: string;
};
type MarketplaceInstallStatusResult = {
installedKeys: string[];
};
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
type SpawnCommand = (
command: string,
args: string[],
options?: SpawnOptions,
) => Promise<SpawnResult>;
type CatalogFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
type CatalogLoader = () => Promise<unknown>;
const MAX_OUTPUT_CHARS = 12_000;
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
const MARKETPLACE_CATALOG_URL =
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
"https://cline.github.io/marketplace/catalog.json";
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
export async function fetchMarketplaceCatalog(
fetchImpl: CatalogFetch = fetch,
): Promise<unknown> {
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
);
}
return response.json();
}
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function readInstallInput(
args?: Record<string, unknown>,
): MarketplaceInstallInput {
const entry = readInstallRecord(args);
const install =
entry.install && typeof entry.install === "object"
? (entry.install as Record<string, unknown>)
: {};
const installArgs = toStringArray(install.args);
if (installArgs.length === 0) {
throw new Error("marketplace install args are required");
}
const env = Array.isArray(install.env)
? install.env
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null)
: undefined;
return {
id: entry.id.trim(),
type: entry.type,
name: typeof entry.name === "string" ? entry.name : undefined,
install: {
args: installArgs,
command:
typeof install.command === "string" ? install.command : undefined,
env,
notes: typeof install.notes === "string" ? install.notes : undefined,
},
};
}
function readInstallRecord(
args?: Record<string, unknown>,
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
const entry =
args?.entry && typeof args.entry === "object"
? (args.entry as Record<string, unknown>)
: (args ?? {});
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
throw new Error("marketplace entry id is required");
}
if (!isPrimitiveType(entry.type)) {
throw new Error("marketplace entry type must be mcp, skill, or plugin");
}
return entry as Record<string, unknown> & {
id: string;
type: MarketplacePrimitiveType;
};
}
function readInstallRequest(args?: Record<string, unknown>) {
const entry = readInstallRecord(args);
return {
id: entry.id.trim(),
type: entry.type,
};
}
function readInstallInputList(
args?: Record<string, unknown>,
): MarketplaceInstallInput[] {
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
return rawEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
const catalogEntries =
catalog && typeof catalog === "object"
? (catalog as Record<string, unknown>).entries
: undefined;
if (!Array.isArray(catalogEntries)) {
throw new Error("marketplace catalog entries are required");
}
return catalogEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function marketplaceEntryKey(
entry: Pick<MarketplaceInstallInput, "id" | "type">,
) {
return `${entry.type}:${entry.id}`;
}
function redactOutput(value: string): string {
const lines = value.split(/\r?\n/).map((line) => {
if (!SECRET_PATTERN.test(line)) return line;
return line
.replace(
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi,
"$1[redacted]",
)
.replace(/\b(Bearer)\s+\S+/gi, "$1 [redacted]")
.replace(
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
"$1[redacted]",
);
});
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
}
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
new Promise<SpawnResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
const child = spawn(command, args, {
...options,
env: options.env ?? process.env,
shell: options.shell ?? platform() === "win32",
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
const forceKillTimeout = setTimeout(() => {
if (!settled) {
child.kill("SIGKILL");
}
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
const timeout = setTimeout(() => {
timedOut = true;
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
child.kill("SIGTERM");
}, INSTALL_COMMAND_TIMEOUT_MS);
forceKillTimeout.unref?.();
timeout.unref?.();
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
}
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
}
});
child.once("error", (error) => {
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
reject(error);
});
child.once("close", (code, signal) => {
settled = true;
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
const result = {
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
stdout,
stderr,
};
resolve(result);
});
});
function normalizeTransport(value: string | undefined): string {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertUrl(value: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`Invalid MCP server URL: ${value}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid MCP server URL: ${value}`);
}
}
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
const [rawName, ...rest] = args;
const name = rawName?.trim();
if (!name) {
throw new Error("MCP marketplace install requires a server name");
}
let transportType = "stdio";
const targetArgs: string[] = [];
let parsingMarketplaceOptions = true;
for (let index = 0; index < rest.length; index++) {
const arg = rest[index];
if (parsingMarketplaceOptions && arg === "--") {
targetArgs.push(...rest.slice(index + 1));
break;
}
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
const next = rest[index + 1]?.trim();
if (!next) throw new Error("--transport requires a value");
transportType = normalizeTransport(next);
index++;
continue;
}
parsingMarketplaceOptions = false;
targetArgs.push(arg);
}
transportType = normalizeTransport(transportType);
if (transportType === "stdio") {
const [command, ...commandArgs] = targetArgs;
if (!command?.trim()) {
throw new Error("Stdio MCP install requires a command");
}
return {
name,
transportType,
command,
args: commandArgs.length > 0 ? commandArgs : undefined,
disabled: false,
};
}
if (targetArgs.length !== 1) {
throw new Error("Remote MCP install requires exactly one URL");
}
const url = targetArgs[0]?.trim() ?? "";
assertUrl(url);
return {
name,
transportType,
url,
disabled: false,
};
}
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
return { command: wrapperPath, argsPrefix: [] };
}
const entry = process.argv[1]?.trim();
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
return { command: process.execPath, argsPrefix: [entry] };
}
return { command: "cline", argsPrefix: [] };
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
function sanitizeSegment(value: string): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "plugin";
}
function sanitizeSkillSegment(value: string): string {
const sanitized = value
.toLowerCase()
.replace(/[^a-z0-9._]+/g, "-")
.replace(/^[.-]+|[.-]+$/g, "")
.slice(0, 255);
return sanitized || "skill";
}
function isOfficialPluginSlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
function getOfficialPluginInstallPath(source: string): string | undefined {
const slug = source.trim();
if (!isOfficialPluginSlug(slug)) return undefined;
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
return join(
resolveClineDir(),
"plugins",
"_installed",
"official",
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
);
}
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "plugin") return false;
const [source] = entry.install.args ?? [];
if (!source) return false;
const installPath = getOfficialPluginInstallPath(source);
return Boolean(installPath && existsSync(installPath));
}
function normalizeMatchValue(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
const candidates = new Set<string>();
const addCandidate = (value: string | undefined) => {
const normalized = sanitizeSkillSegment(value ?? "");
if (normalized && normalized !== "skill") {
candidates.add(normalized);
}
};
addCandidate(entry.id);
addCandidate(entry.name);
const installArgs = entry.install.args ?? [];
for (let index = 0; index < installArgs.length; index++) {
const arg = installArgs[index];
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
addCandidate(installArgs[index + 1]);
index++;
continue;
}
const skillFilter = arg.split("@").at(1);
if (skillFilter) {
addCandidate(skillFilter);
}
}
return [...candidates];
}
function getGlobalSkillPaths(skillName: string): string[] {
return [
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
join(homedir(), ".agents", "skills", skillName, "SKILL.md"),
].filter((path, index, paths) => paths.indexOf(path) === index);
}
function ensureGlobalSkillsDirWritable(): void {
const skillsDir = join(homedir(), ".agents", "skills");
try {
mkdirSync(skillsDir, { recursive: true });
const probePath = join(
skillsDir,
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
);
writeFileSync(probePath, "", { flag: "wx" });
unlinkSync(probePath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
);
}
}
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
return findInstalledGlobalSkillName(entry) !== undefined;
}
function findInstalledGlobalSkillName(
entry: MarketplaceInstallInput,
): string | undefined {
if (entry.type !== "skill") return undefined;
const candidates = getSkillInstallCandidates(entry);
return candidates.find((candidate) =>
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
);
}
function hasMatchingInventoryItem(
items: unknown,
entry: MarketplaceInstallInput,
): boolean {
if (!Array.isArray(items)) return false;
const candidates = new Set([
normalizeMatchValue(entry.id),
normalizeMatchValue(entry.name),
...(entry.install.args ?? []).map(normalizeMatchValue),
]);
candidates.delete("");
return items.some((item) => {
if (!item || typeof item !== "object") return false;
const record = item as JsonRecord;
const values = [
typeof record.name === "string" ? record.name : undefined,
typeof record.id === "string" ? record.id : undefined,
typeof record.path === "string" ? record.path : undefined,
]
.map(normalizeMatchValue)
.filter(Boolean);
return values.some((value) => candidates.has(value));
});
}
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "mcp") return false;
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = readMcpServersResponse();
const servers = Array.isArray(response.servers) ? response.servers : [];
return servers.some((server) => {
if (!server || typeof server !== "object") return false;
const record = server as JsonRecord;
return record.name === input.name;
});
}
function isMarketplaceEntryInstalled(
entry: MarketplaceInstallInput,
inventory?: JsonRecord,
): boolean {
try {
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
if (entry.type === "plugin") {
return (
isOfficialPluginInstalled(entry) ||
hasMatchingInventoryItem(inventory?.plugins, entry)
);
}
if (entry.type === "skill") {
return isGlobalSkillInstalled(entry);
}
return false;
} catch {
return false;
}
}
function commandOutput(result: SpawnResult): string | undefined {
const output = redactOutput(
[result.stdout, result.stderr].filter(Boolean).join("\n"),
);
return output.trim().length > 0 ? output.trim() : undefined;
}
async function installSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
if (isGlobalSkillInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
ensureGlobalSkillsDirWritable();
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"add",
...(entry.install.args ?? []),
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (/\bFailed to install\b/i.test(output ?? "")) {
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
}
if (!isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
output,
};
}
async function uninstallSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installedName = findInstalledGlobalSkillName(entry);
if (!installedName) {
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `${entry.name ?? entry.id} is not installed.`,
};
}
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"remove",
installedName,
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill uninstall completed, but ${entry.name ?? entry.id} is still present in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
if (installArgs.length !== 1) {
throw new Error(
"Plugin marketplace installs currently support exactly one source argument.",
);
}
if (isOfficialPluginInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"install",
installArgs[0] ?? "",
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
async function uninstallPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
const target = installArgs[0]?.trim() || entry.id;
if (!target) {
throw new Error("Plugin marketplace uninstalls require a plugin name.");
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"uninstall",
target,
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = upsertMcpServer(input);
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
return installSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return installPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function uninstallMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = deleteMcpServer(String(input.name ?? ""));
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
return uninstallSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return uninstallPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function installMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return installMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export async function uninstallMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return uninstallMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export function listMarketplaceInstalledEntries(
args?: Record<string, unknown>,
inventory?: JsonRecord,
): MarketplaceInstallStatusResult {
const entries = readInstallInputList(args);
const installedKeys = entries
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
.map(marketplaceEntryKey);
return { installedKeys };
}
export async function installMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return installMarketplaceEntryFromCatalog(args, options);
}
export async function uninstallMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return uninstallMarketplaceEntryFromCatalog(args, options);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,818 @@
import {
CheckCircle2,
Copy,
ExternalLink,
Plug,
Search,
Server,
Trash2,
Wrench,
} from "lucide-react";
import {
type CSSProperties,
type MouseEvent,
useEffect,
useMemo,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { desktopClient } from "@/lib/desktop-client";
import {
fetchMarketplaceCatalog,
type MarketplaceCatalog,
type MarketplaceEntry,
type MarketplacePrimitiveType,
type MarketplaceTag,
} from "@/lib/marketplace";
type EntryActionState =
| { status: "idle" }
| { status: "installing" }
| { status: "uninstalling" }
| {
status: "installed";
message: string;
output?: string;
}
| {
status: "uninstalled";
message: string;
output?: string;
}
| { status: "failed"; message: string };
type MarketplaceInstallResult = {
status: "installed" | "uninstalled";
message: string;
output?: string;
};
type MarketplaceInstallStatusResult = {
installedKeys: string[];
};
type InstalledStatusState = "loading" | "ready";
const INSTALL_TIMEOUT_MS = 300_000;
const CODE_FONT_STYLE: CSSProperties = {
fontFamily:
'ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
};
const primitivePageDetails = {
mcp: {
title: "MCP Servers",
description:
"Install Model Context Protocol servers into this CLI environment.",
emptyInstalled: "No MCP servers installed from this catalog.",
emptyCatalog: "No MCP servers match the current filters.",
icon: Server,
},
skill: {
title: "Skills",
description: "Install skills globally for Cline.",
emptyInstalled: "No skills installed from this catalog.",
emptyCatalog: "No skills match the current filters.",
icon: Wrench,
},
plugin: {
title: "Plugins",
description: "Install plugins into this CLI environment.",
emptyInstalled: "No plugins installed from this catalog.",
emptyCatalog: "No plugins match the current filters.",
icon: Plug,
},
} satisfies Record<
MarketplacePrimitiveType,
{
title: string;
description: string;
emptyInstalled: string;
emptyCatalog: string;
icon: typeof Server;
}
>;
const primitiveBadgeLabels: Record<MarketplacePrimitiveType, string> = {
mcp: "MCP Server",
skill: "Skill",
plugin: "Plugin",
};
function TypeBadge({ type }: { type: MarketplacePrimitiveType }) {
const tone = {
mcp: "border-purple-400/40 bg-purple-500/10 text-purple-700 dark:text-purple-300",
skill:
"border-emerald-400/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
plugin: "border-sky-400/40 bg-sky-500/10 text-sky-700 dark:text-sky-300",
}[type];
return (
<Badge variant="outline" className={`${tone} max-w-32`}>
<span className="truncate">{primitiveBadgeLabels[type]}</span>
</Badge>
);
}
function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
return `${entry.type}:${entry.id}`;
}
function entrySearchText(
entry: MarketplaceEntry,
tagLabels: Map<string, string>,
): string {
return [
entry.name,
entry.tagline,
entry.description,
entry.type,
...entry.tags.map((tag) => tagLabels.get(tag) ?? tag),
]
.join(" ")
.toLowerCase();
}
function actionMessage(
state: EntryActionState | undefined,
): string | undefined {
if (state?.status === "installed" || state?.status === "uninstalled") {
return state.message;
}
return undefined;
}
function actionOutput(state: EntryActionState | undefined): string | undefined {
if (state?.status === "installed" || state?.status === "uninstalled") {
return state.output;
}
return undefined;
}
function EntryDetails({
actionState,
entry,
}: {
actionState: EntryActionState | undefined;
entry: MarketplaceEntry;
}) {
const requiredEnv =
entry.install.env?.filter((env) => env.required !== false) ?? [];
const optionalEnv =
entry.install.env?.filter((env) => env.required === false) ?? [];
const statusMessage = actionMessage(actionState);
const output = actionOutput(actionState);
const copyCommand = async () => {
await navigator.clipboard?.writeText(entry.install.command);
};
return (
<div
className="grid gap-3 border-t pt-3"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
role="presentation"
>
{requiredEnv.length > 0 || optionalEnv.length > 0 ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Environment setup needed
</p>
<p className="mt-1 text-xs leading-5 text-amber-800/80 dark:text-amber-100/80">
Add these values to your Cline/plugin environment after install.
</p>
<div className="mt-3 grid gap-2">
{[...requiredEnv, ...optionalEnv].map((env) => (
<div
key={env.name}
className="rounded-md border border-amber-500/20 bg-background/60 p-2"
>
<div className="flex items-center justify-between gap-2">
<code className="font-mono text-xs font-semibold">
<span style={CODE_FONT_STYLE}>{env.name}</span>
</code>
<Badge variant="outline">
{env.required === false ? "Optional" : "Required"}
</Badge>
</div>
{env.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{env.description}
</p>
) : null}
{env.url ? (
<a
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
href={env.url}
rel="noreferrer"
target="_blank"
>
Get value
<ExternalLink className="size-3" />
</a>
) : null}
</div>
))}
</div>
</div>
) : null}
{entry.install.notes ? (
<p className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
{entry.install.notes}
</p>
) : null}
<details className="rounded-lg border bg-muted/30 p-3">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Manual CLI command
</summary>
<div className="mt-3 grid gap-2">
<p className="text-xs leading-5 text-muted-foreground">
Use this only if you prefer installing from a terminal.
</p>
<div className="flex items-start gap-2">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-md border bg-background p-2 font-mono text-xs text-muted-foreground">
<span style={CODE_FONT_STYLE}>{entry.install.command}</span>
</code>
<Button
onClick={copyCommand}
size="sm"
type="button"
variant="ghost"
>
<Copy className="size-3.5" />
Copy
</Button>
</div>
</div>
</details>
{statusMessage ? (
<p className="text-sm text-muted-foreground">{statusMessage}</p>
) : null}
{actionState?.status === "failed" ? (
<div className="max-h-44 overflow-auto rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{actionState.message}
</div>
) : null}
{output ? (
<pre
className="max-h-44 overflow-auto rounded-lg border bg-muted/30 p-3 font-mono text-xs text-muted-foreground"
style={CODE_FONT_STYLE}
>
{output}
</pre>
) : null}
</div>
);
}
function MarketplaceEntryCard({
actionState,
entry,
expanded,
installed,
installedStatusReady,
onInstall,
onToggleExpanded,
onUninstall,
tagLabels,
}: {
actionState: EntryActionState | undefined;
entry: MarketplaceEntry;
expanded: boolean;
installed: boolean;
installedStatusReady: boolean;
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
tagLabels: Map<string, string>;
}) {
const busy =
actionState?.status === "installing" ||
actionState?.status === "uninstalling";
const setupNeeded = Boolean(entry.install.env?.length);
const handleActionClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (installed) {
onUninstall(entry);
return;
}
onInstall(entry);
};
const actionLabel = !installedStatusReady
? "Checking..."
: actionState?.status === "installing"
? "Installing..."
: actionState?.status === "uninstalling"
? "Uninstalling..."
: installed
? "Uninstall"
: "Install";
return (
// biome-ignore lint/a11y/useSemanticElements: The card contains a nested action button, so the wrapper cannot be a native button.
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={() => onToggleExpanded(entry)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onToggleExpanded(entry);
}
}}
role="button"
tabIndex={0}
>
<div className="min-w-0">
<div className="flex min-w-0 items-start justify-between gap-2">
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
{entry.name}
</h2>
<TypeBadge type={entry.type} />
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
</div>
</div>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-h-5 text-xs text-muted-foreground">
{installed ? (
<span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="size-3.5" />
Installed
</span>
) : setupNeeded ? (
<span className="text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null}
</div>
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
</div>
{expanded ? (
<EntryDetails actionState={actionState} entry={entry} />
) : null}
</div>
);
}
function TagButton({
active,
count,
onClick,
tag,
}: {
active: boolean;
count: number;
onClick: () => void;
tag: MarketplaceTag;
}) {
return (
<Button
aria-pressed={active}
onClick={onClick}
size="sm"
type="button"
variant={active ? "default" : "outline"}
>
<span className="truncate">{tag.label}</span>
<span className="rounded bg-background/30 px-1.5 py-0.5 text-xs">
{count}
</span>
</Button>
);
}
function MarketplaceSection({
actionStates,
emptyMessage,
entries,
expandedEntryKey,
installedEntryKeys,
installedStatusReady,
onInstall,
onToggleExpanded,
onUninstall,
tagLabels,
title,
}: {
actionStates: Map<string, EntryActionState>;
emptyMessage: string;
entries: MarketplaceEntry[];
expandedEntryKey: string | null;
installedEntryKeys: Set<string>;
installedStatusReady: boolean;
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
tagLabels: Map<string, string>;
title: string;
}) {
return (
<section className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<span className="text-sm text-muted-foreground">{entries.length}</span>
</div>
{entries.length > 0 ? (
<div className="grid gap-3">
{entries.map((entry) => {
const key = entryKey(entry);
return (
<MarketplaceEntryCard
actionState={actionStates.get(key)}
entry={entry}
expanded={expandedEntryKey === key}
installed={installedEntryKeys.has(key)}
installedStatusReady={installedStatusReady}
key={key}
onInstall={onInstall}
onToggleExpanded={onToggleExpanded}
onUninstall={onUninstall}
tagLabels={tagLabels}
/>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed bg-card p-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)}
</section>
);
}
export function MarketplaceView({
primitive,
}: {
primitive: MarketplacePrimitiveType;
}) {
const [catalog, setCatalog] = useState<MarketplaceCatalog | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [expandedEntryKey, setExpandedEntryKey] = useState<string | null>(null);
const [installedEntryKeys, setInstalledEntryKeys] = useState<Set<string>>(
() => new Set(),
);
const [actionStates, setActionStates] = useState<
Map<string, EntryActionState>
>(() => new Map());
const [installedStatusState, setInstalledStatusState] =
useState<InstalledStatusState>("loading");
useEffect(() => {
let cancelled = false;
void (async () => {
try {
if (!cancelled) {
setInstalledStatusState("loading");
}
const nextCatalog = await fetchMarketplaceCatalog();
if (!cancelled) {
setCatalog(nextCatalog);
setErrorMessage(null);
}
try {
const response =
await desktopClient.invoke<MarketplaceInstallStatusResult>(
"list_marketplace_installed_entries",
{ entries: nextCatalog.entries },
);
if (!cancelled) {
setInstalledEntryKeys(new Set(response.installedKeys));
setInstalledStatusState("ready");
}
} catch {
if (!cancelled) {
setInstalledStatusState("ready");
}
}
} catch (error) {
if (!cancelled) {
setErrorMessage(
error instanceof Error ? error.message : String(error),
);
setInstalledStatusState("ready");
}
}
})();
return () => {
cancelled = true;
};
}, []);
const pageDetails = primitivePageDetails[primitive];
const PageIcon = pageDetails.icon;
const tagLabels = useMemo(
() => new Map(catalog?.tags.map((tag) => [tag.id, tag.label]) ?? []),
[catalog?.tags],
);
const primitiveEntries = useMemo(
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
[catalog?.entries, primitive],
);
const tagCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const entry of primitiveEntries) {
for (const tag of entry.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [primitiveEntries]);
const primitiveTags = useMemo(
() =>
(catalog?.tags ?? []).filter((tag) => (tagCounts.get(tag.id) ?? 0) > 0),
[catalog?.tags, tagCounts],
);
const filteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
const matchesQuery =
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
return matchesTag && matchesQuery;
});
}, [primitiveEntries, query, selectedTag, tagLabels]);
const installedEntries = useMemo(
() =>
filteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const catalogEntries = useMemo(
() =>
filteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const activeFilters = query.trim().length > 0 || selectedTag !== null;
const installedStatusReady = installedStatusState === "ready";
const clearFilters = () => {
setQuery("");
setSelectedTag(null);
};
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
const key = entryKey(entry);
setActionStates((current) => {
const next = new Map(current);
next.set(key, state);
return next;
});
};
const markEntryInstalled = (entry: MarketplaceEntry) => {
setInstalledEntryKeys((current) => new Set(current).add(entryKey(entry)));
};
const markEntryUninstalled = (entry: MarketplaceEntry) => {
setInstalledEntryKeys((current) => {
const next = new Set(current);
next.delete(entryKey(entry));
return next;
});
};
const toggleExpanded = (entry: MarketplaceEntry) => {
const key = entryKey(entry);
setExpandedEntryKey((current) => (current === key ? null : key));
};
const installEntry = async (entry: MarketplaceEntry) => {
const key = entryKey(entry);
const currentState = actionStates.get(key);
if (
currentState?.status === "installing" ||
currentState?.status === "uninstalling"
) {
return;
}
setExpandedEntryKey(key);
setEntryState(entry, { status: "installing" });
try {
const result = await desktopClient.invoke<MarketplaceInstallResult>(
"install_marketplace_entry",
{ entry },
{ timeoutMs: INSTALL_TIMEOUT_MS },
);
setEntryState(entry, {
status: "installed",
message: result.message,
output: result.output,
});
markEntryInstalled(entry);
} catch (error) {
setEntryState(entry, {
status: "failed",
message: error instanceof Error ? error.message : String(error),
});
}
};
const uninstallEntry = async (entry: MarketplaceEntry) => {
const key = entryKey(entry);
const currentState = actionStates.get(key);
if (
currentState?.status === "installing" ||
currentState?.status === "uninstalling"
) {
return;
}
setExpandedEntryKey(key);
setEntryState(entry, { status: "uninstalling" });
try {
const result = await desktopClient.invoke<MarketplaceInstallResult>(
"uninstall_marketplace_entry",
{ entry },
{ timeoutMs: INSTALL_TIMEOUT_MS },
);
setEntryState(entry, {
status: "uninstalled",
message: result.message,
output: result.output,
});
markEntryUninstalled(entry);
} catch (error) {
setEntryState(entry, {
status: "failed",
message: error instanceof Error ? error.message : String(error),
});
}
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-6xl px-6 py-6 max-[720px]:px-3">
<div className="mb-6 flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-normal text-foreground">
<PageIcon className="size-8 text-primary" />
<span>{pageDetails.title}</span>
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{pageDetails.description}
</p>
</div>
{catalog?.generatedAt ? (
<p className="text-xs text-muted-foreground">
Updated{" "}
{new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(catalog.generatedAt))}
</p>
) : null}
</div>
{!catalog && !errorMessage ? (
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
<Spinner className="mr-2" />
Loading marketplace...
</div>
) : null}
{catalog && !installedStatusReady ? (
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
<Spinner className="mr-2" />
Checking installed status...
</div>
) : null}
{errorMessage ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{catalog && installedStatusReady ? (
<div className="grid gap-6">
<div className="grid gap-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative block flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={`Search ${pageDetails.title}`}
className="h-10 pl-8"
onChange={(event) => setQuery(event.target.value)}
placeholder={`Search ${pageDetails.title.toLowerCase()}`}
value={query}
/>
</div>
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{filteredEntries.length}
</span>
<span>
{filteredEntries.length === 1 ? "result" : "results"}
</span>
{activeFilters ? (
<Button
onClick={clearFilters}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
{primitiveTags.length > 0 ? (
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
) : null}
</div>
<MarketplaceSection
actionStates={actionStates}
emptyMessage={pageDetails.emptyInstalled}
entries={installedEntries}
expandedEntryKey={expandedEntryKey}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
tagLabels={tagLabels}
title="Installed"
/>
<MarketplaceSection
actionStates={actionStates}
emptyMessage={pageDetails.emptyCatalog}
entries={catalogEntries}
expandedEntryKey={expandedEntryKey}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
tagLabels={tagLabels}
title="Catalog"
/>
</div>
) : null}
</div>
</ScrollArea>
);
}
@@ -1,6 +1,4 @@
"use client";
import { ChevronDown, ChevronRight, Moon, Sun, X } from "lucide-react";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -40,7 +38,6 @@ const navCategories = [
] as const;
export type SettingsSection = (typeof navCategories)[number];
type Theme = "dark" | "light";
type GlobalSettingsResponse = {
telemetryOptOut: boolean;
autoUpdateEnabled: boolean;
@@ -58,17 +55,15 @@ let providerCatalogCache: {
// -----------------------------------------------------------
export function SettingsView({
chrome = "full",
initialSection = "General",
onClose,
onNavigateSection,
onThemeChange,
theme,
}: {
chrome?: "full" | "content";
initialSection?: SettingsSection;
onClose: () => void;
onNavigateSection?: (section: SettingsSection) => void;
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
const [providersExpanded, setProvidersExpanded] = useState(true);
@@ -141,11 +136,14 @@ export function SettingsView({
}, [setProvidersWithCache]);
useEffect(() => {
if (activeNav !== "Providers") {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderCatalog();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProviderCatalog]);
}, [activeNav, loadProviderCatalog]);
const persistProviderSettings = useCallback(
async (
@@ -349,6 +347,73 @@ export function SettingsView({
setAddingProvider(false);
};
const content =
activeNav === "Providers" && selectedProvider ? (
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
provider={selectedProvider}
/>
) : activeNav === "Providers" ? (
addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading providers...</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
)
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Customizations" ? (
<RulesView />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
);
if (chrome === "content") {
return (
<div className="h-full overflow-hidden bg-background">{content}</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header bar */}
@@ -440,88 +505,13 @@ export function SettingsView({
</nav>
{/* Content area */}
<div className="flex-1 overflow-hidden">
{activeNav === "Providers" && selectedProvider ? (
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={
modelsLoadingByProvider[selectedProvider.id] ?? false
}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) =>
updateProvider(selectedProvider.id, updates)
}
provider={selectedProvider}
/>
) : activeNav === "Providers" ? (
addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading providers...
</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
)
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Customizations" ? (
<RulesView />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent
onThemeChange={onThemeChange}
theme={theme}
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">{content}</div>
</div>
</div>
);
}
function GeneralSettingsContent({
onThemeChange,
theme,
}: {
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
function GeneralSettingsContent() {
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
@@ -605,43 +595,19 @@ function GeneralSettingsContent({
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">General</h2>
<div className="px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5">
<div className="mb-12">
<h1 className="text-[32px] font-semibold leading-none tracking-normal text-foreground">
General
</h1>
</div>
<section className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<section className="max-w-[86rem]">
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-sm font-medium text-foreground">Theme</p>
<p className="mt-1 text-xs text-muted-foreground">
Use the light or dark Cline Hub interface.
<p className="text-[17px] font-semibold text-foreground">
Auto update
</p>
</div>
<div className="flex items-center gap-2 max-[720px]:justify-start">
<Button
onClick={() => onThemeChange("dark")}
type="button"
variant={theme === "dark" ? "default" : "outline"}
>
<Moon className="size-4" />
Dark
</Button>
<Button
onClick={() => onThemeChange("light")}
type="button"
variant={theme === "light" ? "default" : "outline"}
>
<Sun className="size-4" />
Light
</Button>
</div>
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Auto update</p>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[15px] text-muted-foreground">
Automatically install CLI updates on startup.
</p>
{autoUpdateError ? (
@@ -659,12 +625,12 @@ function GeneralSettingsContent({
}
/>
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-sm font-medium text-foreground">Telemetry</p>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-[17px] font-semibold text-foreground">
Telemetry
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Enable error and usage report to help us improve Cline.
</p>
{telemetryError ? (
@@ -674,10 +640,12 @@ function GeneralSettingsContent({
) : null}
</div>
<Switch
aria-label="Telemetry opt-out"
aria-label="Telemetry"
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
disabled={telemetryLoading || telemetrySaving}
onCheckedChange={(checked) => void updateTelemetryOptOut(checked)}
onCheckedChange={(checked) =>
void updateTelemetryOptOut(!checked)
}
/>
</div>
</section>
+17 -15
View File
@@ -6,24 +6,24 @@
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.998 0 0);
--foreground: oklch(0.18 0.006 255);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.18 0.006 255);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--muted: oklch(0.968 0.004 270);
--muted-foreground: oklch(0.52 0.012 270);
--accent: oklch(0.94 0.006 270);
--accent-foreground: oklch(0.18 0.006 255);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.93 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--border: oklch(0.91 0.006 270);
--input: oklch(0.91 0.006 270);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
@@ -31,19 +31,21 @@
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar: oklch(0.975 0.004 270);
--sidebar-foreground: oklch(0.18 0.006 255);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-accent: oklch(0.925 0.006 270);
--sidebar-accent-foreground: oklch(0.18 0.006 255);
--sidebar-border: oklch(0.91 0.006 270);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--font-sans: "Geist Variable", sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback";
--font-mono:
ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono",
monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import type { WebviewInboundMessage } from "../../../webview-protocol";
import { HubDesktopClient, isBrowserTransportFailure } from "./desktop-client";
function createClient() {
const postToHost = vi.fn<(message: WebviewInboundMessage) => void>();
const client = new HubDesktopClient({ postToHost, listen: false });
return { client, postToHost };
}
function lastDesktopCommand(postToHost: ReturnType<typeof vi.fn>) {
const message = postToHost.mock.lastCall?.[0] as
| Extract<WebviewInboundMessage, { type: "desktopCommand" }>
| undefined;
if (message?.type !== "desktopCommand") {
throw new Error("Expected a desktop command to be posted");
}
return message;
}
describe("HubDesktopClient", () => {
it("does not reject pending desktop commands for unrelated hub errors", async () => {
const { client, postToHost } = createClient();
const pending = client.invoke<{ installedKeys: string[] }>(
"list_marketplace_installed_entries",
);
const command = lastDesktopCommand(postToHost);
client.handleMessage({
data: { type: "error", text: "Failed to restore previous session." },
});
client.handleMessage({
data: {
type: "desktopCommandResult",
id: command.id,
ok: true,
result: { installedKeys: ["plugin:goal"] },
},
});
await expect(pending).resolves.toEqual({ installedKeys: ["plugin:goal"] });
});
it("rejects pending desktop commands for browser transport failures", async () => {
const { client } = createClient();
const pending = client.invoke("list_marketplace_installed_entries");
client.handleMessage({
data: { type: "status", text: "Disconnected from the Cline Hub server." },
});
await expect(pending).rejects.toThrow(
"Disconnected from the Cline Hub server.",
);
});
it("only treats exact browser lifecycle messages as transport failures", () => {
expect(
isBrowserTransportFailure({
type: "error",
text: "Failed to connect to the Cline Hub server.",
}),
).toBe(true);
expect(
isBrowserTransportFailure({
type: "error",
text: "Failed to restore previous session.",
}),
).toBe(false);
});
});
@@ -3,28 +3,62 @@
import type { WebviewOutboundMessage } from "../../../webview-protocol";
import { postToHost } from "../vscode";
type PostToHost = typeof postToHost;
type PendingRequest = {
command: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
};
const REQUEST_TIMEOUT_MS = 120_000;
const BROWSER_TRANSPORT_FAILURE_MESSAGES = new Set([
"Disconnected from the Cline Hub server.",
"Failed to connect to the Cline Hub server.",
"Received an invalid message from the Cline Hub server.",
]);
class HubDesktopClient {
export function isBrowserTransportFailure(
message: WebviewOutboundMessage,
): boolean {
if (message.type !== "status" && message.type !== "error") {
return false;
}
return BROWSER_TRANSPORT_FAILURE_MESSAGES.has(message.text);
}
export class HubDesktopClient {
private requestCounter = 0;
private readonly pending = new Map<string, PendingRequest>();
private readonly postToHost: PostToHost;
constructor() {
if (typeof window !== "undefined") {
constructor(options: { postToHost?: PostToHost; listen?: boolean } = {}) {
this.postToHost = options.postToHost ?? postToHost;
if ((options.listen ?? true) && typeof window !== "undefined") {
window.addEventListener("message", (event) => {
this.handleMessage(event as MessageEvent<WebviewOutboundMessage>);
});
}
}
private handleMessage(event: MessageEvent<WebviewOutboundMessage>) {
handleMessage(event: Pick<MessageEvent<WebviewOutboundMessage>, "data">) {
const message = event.data;
if (
message &&
typeof message === "object" &&
(message.type === "status" || message.type === "error")
) {
if (isBrowserTransportFailure(message) && this.pending.size > 0) {
const error = new Error(message.text);
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
pending.reject(error);
}
this.pending.clear();
}
return;
}
if (
!message ||
typeof message !== "object" ||
@@ -46,19 +80,24 @@ class HubDesktopClient {
pending.reject(new Error(message.error));
}
async invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
async invoke<T>(
command: string,
args?: Record<string, unknown>,
options?: { timeoutMs?: number },
): Promise<T> {
const id = `desktop_${Date.now()}_${this.requestCounter++}`;
return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Timed out waiting for desktop command: ${command}`));
}, REQUEST_TIMEOUT_MS);
}, options?.timeoutMs ?? REQUEST_TIMEOUT_MS);
this.pending.set(id, {
command,
resolve: (value) => resolve(value as T),
reject,
timeoutId,
});
postToHost({ type: "desktopCommand", id, command, args });
this.postToHost({ type: "desktopCommand", id, command, args });
});
}
}
@@ -0,0 +1,189 @@
export type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
export type MarketplaceTag = {
id: string;
label: string;
count: number;
};
export type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
tagline: string;
description: string;
tags: string[];
install: {
args: string[];
env?: MarketplaceEnvVar[];
notes?: string;
command: string;
};
};
export type MarketplaceCatalog = {
version: number;
generatedAt?: string;
baseUrl?: string;
counts: {
total: number;
plugins: number;
skills: number;
mcps: number;
};
tags: MarketplaceTag[];
entries: MarketplaceEntry[];
};
const MARKETPLACE_CATALOG_URL = "/api/marketplace/catalog";
const EMPTY_CATALOG: MarketplaceCatalog = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function parseCount(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function parseEnv(value: unknown): MarketplaceEnvVar[] | undefined {
if (!Array.isArray(value)) return undefined;
const env = value
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null);
return env.length > 0 ? env : undefined;
}
export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
const response = await fetch(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
const rawCounts =
typeof data?.counts === "object" && data.counts !== null ? data.counts : {};
const tags: MarketplaceTag[] = Array.isArray(data?.tags)
? data.tags
.map((tag: unknown) => {
if (!tag || typeof tag !== "object") return null;
const candidate = tag as Record<string, unknown>;
if (
typeof candidate.id !== "string" ||
typeof candidate.label !== "string"
) {
return null;
}
return {
id: candidate.id,
label: candidate.label,
count: parseCount(candidate.count),
};
})
.filter(
(tag: MarketplaceTag | null): tag is MarketplaceTag => tag !== null,
)
: [];
const entries: MarketplaceEntry[] = Array.isArray(data?.entries)
? data.entries
.map((entry: unknown) => {
if (!entry || typeof entry !== "object") return null;
const candidate = entry as Record<string, unknown>;
const install =
typeof candidate.install === "object" && candidate.install !== null
? (candidate.install as Record<string, unknown>)
: {};
if (
typeof candidate.id !== "string" ||
!isPrimitiveType(candidate.type) ||
typeof candidate.name !== "string" ||
typeof candidate.tagline !== "string" ||
typeof candidate.description !== "string" ||
typeof install.command !== "string"
) {
return null;
}
return {
id: candidate.id,
type: candidate.type,
name: candidate.name,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
install: {
args: toStringArray(install.args),
command: install.command,
env: parseEnv(install.env),
notes:
typeof install.notes === "string" ? install.notes : undefined,
},
};
})
.filter(
(entry: MarketplaceEntry | null): entry is MarketplaceEntry =>
entry !== null && entry.install.args.length > 0,
)
: [];
return {
version: parseCount(data?.version) || EMPTY_CATALOG.version,
generatedAt:
typeof data?.generatedAt === "string" ? data.generatedAt : undefined,
baseUrl,
counts: {
total: parseCount(rawCounts.total) || entries.length,
plugins: parseCount(rawCounts.plugins),
skills: parseCount(rawCounts.skills),
mcps: parseCount(rawCounts.mcps),
},
tags,
entries,
};
}
export { EMPTY_CATALOG, MARKETPLACE_CATALOG_URL };
+15 -9
View File
@@ -24,6 +24,13 @@ function dispatchHostMessage(message: WebviewOutboundMessage): void {
window.dispatchEvent(new MessageEvent("message", { data: message }));
}
function readBrowserRoomSecret(): string | undefined {
const roomSecret = new URLSearchParams(window.location.search)
.get("roomSecret")
?.trim();
return roomSecret || undefined;
}
function createBrowserSocket(): WebSocket {
if (
browserSocket &&
@@ -35,16 +42,13 @@ function createBrowserSocket(): WebSocket {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new URLSearchParams();
const roomSecret = new URLSearchParams(window.location.search)
.get("roomSecret")
?.trim();
const roomSecret = readBrowserRoomSecret();
if (roomSecret) {
params.set("roomSecret", roomSecret);
}
const query = params.toString();
browserSocket = new WebSocket(
`${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`,
);
const socketUrl = `${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`;
browserSocket = new WebSocket(socketUrl);
browserSocket.addEventListener("open", () => {
for (const message of pendingMessages.splice(0)) {
browserSocket?.send(JSON.stringify(message));
@@ -52,10 +56,10 @@ function createBrowserSocket(): WebSocket {
});
browserSocket.addEventListener("message", (event) => {
try {
dispatchHostMessage(
JSON.parse(String(event.data)) as WebviewOutboundMessage,
);
const message = JSON.parse(String(event.data)) as WebviewOutboundMessage;
dispatchHostMessage(message);
} catch {
pendingMessages.splice(0);
dispatchHostMessage({
type: "error",
text: "Received an invalid message from the Cline Hub server.",
@@ -63,12 +67,14 @@ function createBrowserSocket(): WebSocket {
}
});
browserSocket.addEventListener("close", () => {
pendingMessages.splice(0);
dispatchHostMessage({
type: "status",
text: "Disconnected from the Cline Hub server.",
});
});
browserSocket.addEventListener("error", () => {
pendingMessages.splice(0);
dispatchHostMessage({
type: "error",
text: "Failed to connect to the Cline Hub server.",
+33
View File
@@ -0,0 +1,33 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const rootDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: rootDir,
resolve: {
alias: [
{
find: /^@cline\/core$/,
replacement: resolve(rootDir, "../../sdk/packages/core/src/index.ts"),
},
{
find: /^@cline\/core\/(.+)$/,
replacement: resolve(rootDir, "../../sdk/packages/core/src/$1"),
},
{
find: /^@cline\/shared$/,
replacement: resolve(rootDir, "../../sdk/packages/shared/src/index.ts"),
},
{
find: /^@cline\/shared\/(.+)$/,
replacement: resolve(rootDir, "../../sdk/packages/shared/src/$1"),
},
],
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+3 -3
View File
@@ -22,8 +22,8 @@
"code": "bun --conditions=development -F @cline/code dev",
"clean": "bun run sdk/scripts/clean.ts",
"types": "bun --parallel -F '*' typecheck",
"test": "bun --parallel -F './sdk/packages/**' -F @cline/cli test",
"test:unit": "bash -lc 'set -euo pipefail; bun -F @cline/agents test & p1=$!; bun -F @cline/llms test & p2=$!; bun -F @cline/core test:unit & p3=$!; bun -F @cline/cli test:unit & p4=$!; wait $p1; wait $p2; wait $p3; wait $p4'",
"test": "bun --parallel -F './sdk/packages/**' -F @cline/cli -F @cline/cline-hub test",
"test:unit": "bash -lc 'set -euo pipefail; bun -F @cline/agents test & p1=$!; bun -F @cline/llms test & p2=$!; bun -F @cline/core test:unit & p3=$!; bun -F @cline/cli test:unit & p4=$!; bun -F @cline/cline-hub test & p5=$!; wait $p1; wait $p2; wait $p3; wait $p4; wait $p5'",
"test:e2e": "bun -F @cline/core test:e2e && bun -F @cline/cli test:e2e",
"test:e2e:interactive": "bun -F @cline/cli test:e2e:interactive",
"verify:routines": "zsh -lc 'cd sdk/packages/core && bunx vitest run src/cron/schedule-service.test.ts --config vitest.config.ts'",
@@ -32,7 +32,7 @@
"format": "bun biome format sdk/ apps/cli/ apps/cline-hub/ apps/examples/",
"lint": "bun biome lint sdk/ apps/cli/ apps/cline-hub/ apps/examples/",
"fix": "bun biome check --write --unsafe --diagnostic-level=error sdk/ apps/cli/ apps/cline-hub/ apps/examples/",
"check": "bun biome check --diagnostic-level=error sdk/ apps/cli/ apps/cline-hub/ apps/examples/ && bun run build:sdk && bun run -F @cline/cli build && bun --parallel -F './sdk/packages/**' -F @cline/cli typecheck && bun sdk/scripts/check-publish.ts",
"check": "bun biome check --diagnostic-level=error sdk/ apps/cli/ apps/cline-hub/ apps/examples/ && bun run build:sdk && bun run -F @cline/cli build && bun -F @cline/cline-hub build:webview && bun --parallel -F './sdk/packages/**' -F @cline/cli -F @cline/cline-hub typecheck && bun sdk/scripts/check-publish.ts",
"version": "bun run types && bun sdk/scripts/version.ts",
"release": "bun sdk/scripts/release.ts"
},