Plugins: add Claude marketplace installs

This commit is contained in:
Vincent Koc 2026-03-16 01:41:44 -07:00
parent 45fb57802c
commit 8241311947
10 changed files with 1358 additions and 61 deletions

View File

@ -11,6 +11,11 @@ import { enablePluginInConfig } from "../plugins/enable.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import {
installPluginFromMarketplace,
listMarketplacePlugins,
resolveMarketplaceInstallShortcut,
} from "../plugins/marketplace.js";
import type { PluginRecord } from "../plugins/registry.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
@ -46,6 +51,10 @@ export type PluginUpdateOptions = {
dryRun?: boolean;
};
export type PluginMarketplaceListOptions = {
json?: boolean;
};
export type PluginUninstallOptions = {
keepFiles?: boolean;
keepConfig?: boolean;
@ -203,9 +212,65 @@ async function installBundledPluginSource(params: {
async function runPluginInstallCommand(params: {
raw: string;
opts: { link?: boolean; pin?: boolean };
opts: { link?: boolean; pin?: boolean; marketplace?: string };
}) {
const { raw, opts } = params;
const shorthand = !params.opts.marketplace
? await resolveMarketplaceInstallShortcut(params.raw)
: null;
if (shorthand?.ok === false) {
defaultRuntime.error(shorthand.error);
process.exit(1);
}
const raw = shorthand?.ok ? shorthand.plugin : params.raw;
const opts = {
...params.opts,
marketplace:
params.opts.marketplace ?? (shorthand?.ok ? shorthand.marketplaceSource : undefined),
};
if (opts.marketplace) {
if (opts.link) {
defaultRuntime.error("`--link` is not supported with `--marketplace`.");
process.exit(1);
}
if (opts.pin) {
defaultRuntime.error("`--pin` is not supported with `--marketplace`.");
process.exit(1);
}
const cfg = loadConfig();
const result = await installPluginFromMarketplace({
marketplace: opts.marketplace,
plugin: raw,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
defaultRuntime.error(result.error);
process.exit(1);
}
clearPluginManifestRegistryCache();
let next = enablePluginInConfig(cfg, result.pluginId).config;
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "marketplace",
installPath: result.targetDir,
version: result.version,
marketplaceName: result.marketplaceName,
marketplaceSource: result.marketplaceSource,
marketplacePlugin: result.marketplacePlugin,
});
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
}
const fileSpec = resolveFileNpmSpecToLocalPath(raw);
if (fileSpec && !fileSpec.ok) {
defaultRuntime.error(fileSpec.error);
@ -734,17 +799,24 @@ export function registerPluginsCli(program: Command) {
plugins
.command("install")
.description("Install a plugin (path, archive, or npm spec)")
.argument("<path-or-spec>", "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec")
.description("Install a plugin (path, archive, npm spec, or marketplace entry)")
.argument(
"<path-or-spec-or-plugin>",
"Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name",
)
.option("-l, --link", "Link a local path instead of copying", false)
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
.option(
"--marketplace <source>",
"Install a Claude marketplace plugin from a local repo/path or git/GitHub source",
)
.action(async (raw: string, opts: { link?: boolean; pin?: boolean; marketplace?: string }) => {
await runPluginInstallCommand({ raw, opts });
});
plugins
.command("update")
.description("Update installed plugins (npm installs only)")
.description("Update installed plugins (npm and marketplace installs)")
.argument("[id]", "Plugin id (omit with --all)")
.option("--all", "Update all tracked plugins", false)
.option("--dry-run", "Show what would change without writing", false)
@ -755,7 +827,7 @@ export function registerPluginsCli(program: Command) {
if (targets.length === 0) {
if (opts.all) {
defaultRuntime.log("No npm-installed plugins to update.");
defaultRuntime.log("No tracked plugins to update.");
return;
}
defaultRuntime.error("Provide a plugin id or use --all.");
@ -839,4 +911,54 @@ export function registerPluginsCli(program: Command) {
lines.push(`${theme.muted("Docs:")} ${docs}`);
defaultRuntime.log(lines.join("\n"));
});
const marketplace = plugins
.command("marketplace")
.description("Inspect Claude-compatible plugin marketplaces");
marketplace
.command("list")
.description("List plugins published by a marketplace source")
.argument("<source>", "Local marketplace path/repo or git/GitHub source")
.option("--json", "Print JSON")
.action(async (source: string, opts: PluginMarketplaceListOptions) => {
const result = await listMarketplacePlugins({
marketplace: source,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
defaultRuntime.error(result.error);
process.exit(1);
}
if (opts.json) {
defaultRuntime.log(
JSON.stringify(
{
source: result.sourceLabel,
name: result.manifest.name,
version: result.manifest.version,
plugins: result.manifest.plugins,
},
null,
2,
),
);
return;
}
if (result.manifest.plugins.length === 0) {
defaultRuntime.log(`No plugins found in marketplace ${result.sourceLabel}.`);
return;
}
defaultRuntime.log(
`${theme.heading("Marketplace")} ${theme.muted(result.manifest.name ?? result.sourceLabel)}`,
);
for (const plugin of result.manifest.plugins) {
const suffix = plugin.version ? theme.muted(` v${plugin.version}`) : "";
const desc = plugin.description ? ` - ${theme.muted(plugin.description)}` : "";
defaultRuntime.log(`${theme.command(plugin.name)}${suffix}${desc}`);
}
});
}

View File

@ -1003,6 +1003,12 @@ export const FIELD_HELP: Record<string, string> = {
"plugins.installs.*.resolvedAt":
"ISO timestamp when npm package metadata was last resolved for this install record.",
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
"plugins.installs.*.marketplaceName":
"Marketplace display name recorded for marketplace-backed plugin installs (if available).",
"plugins.installs.*.marketplaceSource":
"Original marketplace source used to resolve the install (for example a repo path or Git URL).",
"plugins.installs.*.marketplacePlugin":
"Plugin entry name inside the source marketplace, used for later updates.",
"agents.list.*.identity.avatar":
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
"agents.defaults.model.primary": "Primary model (provider/model).",

View File

@ -871,4 +871,7 @@ export const FIELD_LABELS: Record<string, string> = {
"plugins.installs.*.shasum": "Plugin Resolved Shasum",
"plugins.installs.*.resolvedAt": "Plugin Resolution Time",
"plugins.installs.*.installedAt": "Plugin Install Time",
"plugins.installs.*.marketplaceName": "Plugin Marketplace Name",
"plugins.installs.*.marketplaceSource": "Plugin Marketplace Source",
"plugins.installs.*.marketplacePlugin": "Plugin Marketplace Plugin",
};

View File

@ -19,7 +19,12 @@ export type PluginsLoadConfig = {
paths?: string[];
};
export type PluginInstallRecord = InstallRecordBase;
export type PluginInstallRecord = Omit<InstallRecordBase, "source"> & {
source: InstallRecordBase["source"] | "marketplace";
marketplaceName?: string;
marketplaceSource?: string;
marketplacePlugin?: string;
};
export type PluginsConfig = {
/** Enable or disable plugin loading. */

View File

@ -6,6 +6,8 @@ export const InstallSourceSchema = z.union([
z.literal("path"),
]);
export const PluginInstallSourceSchema = z.union([InstallSourceSchema, z.literal("marketplace")]);
export const InstallRecordShape = {
source: InstallSourceSchema,
spec: z.string().optional(),
@ -20,3 +22,11 @@ export const InstallRecordShape = {
resolvedAt: z.string().optional(),
installedAt: z.string().optional(),
} as const;
export const PluginInstallRecordShape = {
...InstallRecordShape,
source: PluginInstallSourceSchema,
marketplaceName: z.string().optional(),
marketplaceSource: z.string().optional(),
marketplacePlugin: z.string().optional(),
} as const;

View File

@ -11,7 +11,7 @@ import {
SecretsConfigSchema,
} from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
import { InstallRecordShape } from "./zod-schema.installs.js";
import { PluginInstallRecordShape } from "./zod-schema.installs.js";
import { ChannelsSchema } from "./zod-schema.providers.js";
import { sensitive } from "./zod-schema.sensitive.js";
import {
@ -905,7 +905,7 @@ export const OpenClawSchema = z
z.string(),
z
.object({
...InstallRecordShape,
...PluginInstallRecordShape,
})
.strict(),
)

View File

@ -0,0 +1,141 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
const installPluginFromPathMock = vi.fn();
vi.mock("./install.js", () => ({
installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args),
}));
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-"));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
describe("marketplace plugins", () => {
afterEach(() => {
installPluginFromPathMock.mockReset();
});
it("lists plugins from a local marketplace root", async () => {
await withTempDir(async (rootDir) => {
await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(rootDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: "./plugins/frontend-design",
},
],
}),
);
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: rootDir });
expect(result).toEqual({
ok: true,
sourceLabel: expect.stringContaining(".claude-plugin/marketplace.json"),
manifest: {
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: { kind: "path", path: "./plugins/frontend-design" },
},
],
},
});
});
});
it("resolves relative plugin paths against the marketplace root", async () => {
await withTempDir(async (rootDir) => {
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(
path.join(rootDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
}),
);
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const { installPluginFromMarketplace } = await import("./marketplace.js");
const result = await installPluginFromMarketplace({
marketplace: path.join(rootDir, ".claude-plugin", "marketplace.json"),
plugin: "frontend-design",
});
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: pluginDir,
}),
);
expect(result).toMatchObject({
ok: true,
pluginId: "frontend-design",
marketplacePlugin: "frontend-design",
marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"),
});
});
});
it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => {
await withTempDir(async (homeDir) => {
await fs.mkdir(path.join(homeDir, ".claude", "plugins"), { recursive: true });
await fs.writeFile(
path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"),
JSON.stringify({
"claude-plugins-official": {
source: {
source: "github",
repo: "anthropics/claude-plugins-official",
},
installLocation: path.join(homeDir, ".claude", "plugins", "marketplaces", "official"),
},
}),
);
const { resolveMarketplaceInstallShortcut } = await import("./marketplace.js");
const shortcut = await withEnvAsync(
{ HOME: homeDir },
async () => await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"),
);
expect(shortcut).toEqual({
ok: true,
plugin: "superpowers",
marketplaceName: "claude-plugins-official",
marketplaceSource: "claude-plugins-official",
});
});
});
});

832
src/plugins/marketplace.ts Normal file
View File

@ -0,0 +1,832 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveArchiveKind } from "../infra/archive.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { installPluginFromPath, type InstallPluginResult } from "./install.js";
const DEFAULT_GIT_TIMEOUT_MS = 120_000;
const MARKETPLACE_MANIFEST_CANDIDATES = [
path.join(".claude-plugin", "marketplace.json"),
"marketplace.json",
] as const;
const CLAUDE_KNOWN_MARKETPLACES_PATH = path.join(
"~",
".claude",
"plugins",
"known_marketplaces.json",
);
type MarketplaceLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
type MarketplaceEntrySource =
| { kind: "path"; path: string }
| { kind: "github"; repo: string; path?: string; ref?: string }
| { kind: "git"; url: string; path?: string; ref?: string }
| { kind: "git-subdir"; url: string; path: string; ref?: string }
| { kind: "url"; url: string };
export type MarketplacePluginEntry = {
name: string;
version?: string;
description?: string;
source: MarketplaceEntrySource;
};
export type MarketplaceManifest = {
name?: string;
version?: string;
plugins: MarketplacePluginEntry[];
};
type LoadedMarketplace = {
manifest: MarketplaceManifest;
rootDir: string;
sourceLabel: string;
cleanup?: () => Promise<void>;
};
type KnownMarketplaceRecord = {
installLocation?: string;
source?: unknown;
};
export type MarketplacePluginListResult =
| {
ok: true;
manifest: MarketplaceManifest;
sourceLabel: string;
}
| {
ok: false;
error: string;
};
export type MarketplaceInstallResult =
| ({
ok: true;
marketplaceName?: string;
marketplaceVersion?: string;
marketplacePlugin: string;
marketplaceSource: string;
marketplaceEntryVersion?: string;
} & Extract<InstallPluginResult, { ok: true }>)
| Extract<InstallPluginResult, { ok: false }>;
export type MarketplaceShortcutResolution =
| {
ok: true;
plugin: string;
marketplaceName: string;
marketplaceSource: string;
}
| {
ok: false;
error: string;
}
| null;
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function isGitUrl(value: string): boolean {
return (
/^git@/i.test(value) || /^ssh:\/\//i.test(value) || /^https?:\/\/.+\.git(?:#.*)?$/i.test(value)
);
}
function looksLikeGitHubRepoShorthand(value: string): boolean {
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.+)?$/.test(value.trim());
}
function splitRef(value: string): { base: string; ref?: string } {
const trimmed = value.trim();
const hashIndex = trimmed.lastIndexOf("#");
if (hashIndex <= 0 || hashIndex >= trimmed.length - 1) {
return { base: trimmed };
}
return {
base: trimmed.slice(0, hashIndex),
ref: trimmed.slice(hashIndex + 1).trim() || undefined,
};
}
function toOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeEntrySource(
raw: unknown,
): { ok: true; source: MarketplaceEntrySource } | { ok: false; error: string } {
if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) {
return { ok: false, error: "empty plugin source" };
}
if (isHttpUrl(trimmed)) {
return { ok: true, source: { kind: "url", url: trimmed } };
}
return { ok: true, source: { kind: "path", path: trimmed } };
}
if (!raw || typeof raw !== "object") {
return { ok: false, error: "plugin source must be a string or object" };
}
const rec = raw as Record<string, unknown>;
const kind = toOptionalString(rec.type) ?? toOptionalString(rec.source);
if (!kind) {
return { ok: false, error: 'plugin source object missing "type" or "source"' };
}
if (kind === "path") {
const sourcePath = toOptionalString(rec.path);
if (!sourcePath) {
return { ok: false, error: 'path source missing "path"' };
}
return { ok: true, source: { kind: "path", path: sourcePath } };
}
if (kind === "github") {
const repo = toOptionalString(rec.repo) ?? toOptionalString(rec.url);
if (!repo) {
return { ok: false, error: 'github source missing "repo"' };
}
return {
ok: true,
source: {
kind: "github",
repo,
path: toOptionalString(rec.path),
ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag),
},
};
}
if (kind === "git") {
const url = toOptionalString(rec.url) ?? toOptionalString(rec.repo);
if (!url) {
return { ok: false, error: 'git source missing "url"' };
}
return {
ok: true,
source: {
kind: "git",
url,
path: toOptionalString(rec.path),
ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag),
},
};
}
if (kind === "git-subdir") {
const url = toOptionalString(rec.url) ?? toOptionalString(rec.repo);
const sourcePath = toOptionalString(rec.path) ?? toOptionalString(rec.subdir);
if (!url) {
return { ok: false, error: 'git-subdir source missing "url"' };
}
if (!sourcePath) {
return { ok: false, error: 'git-subdir source missing "path"' };
}
return {
ok: true,
source: {
kind: "git-subdir",
url,
path: sourcePath,
ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag),
},
};
}
if (kind === "url") {
const url = toOptionalString(rec.url);
if (!url) {
return { ok: false, error: 'url source missing "url"' };
}
return { ok: true, source: { kind: "url", url } };
}
return { ok: false, error: `unsupported plugin source kind: ${kind}` };
}
function marketplaceEntrySourceToInput(source: MarketplaceEntrySource): string {
switch (source.kind) {
case "path":
return source.path;
case "github":
return `${source.repo}${source.ref ? `#${source.ref}` : ""}`;
case "git":
return `${source.url}${source.ref ? `#${source.ref}` : ""}`;
case "git-subdir":
return `${source.url}${source.ref ? `#${source.ref}` : ""}`;
case "url":
return source.url;
}
}
function parseMarketplaceManifest(
raw: string,
sourceLabel: string,
): { ok: true; manifest: MarketplaceManifest } | { ok: false; error: string } {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: ${String(err)}` };
}
if (!parsed || typeof parsed !== "object") {
return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: expected object` };
}
const rec = parsed as Record<string, unknown>;
if (!Array.isArray(rec.plugins)) {
return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: missing plugins[]` };
}
const plugins: MarketplacePluginEntry[] = [];
for (const entry of rec.plugins) {
if (!entry || typeof entry !== "object") {
return { ok: false, error: `invalid marketplace entry in ${sourceLabel}: expected object` };
}
const plugin = entry as Record<string, unknown>;
const name = toOptionalString(plugin.name);
if (!name) {
return { ok: false, error: `invalid marketplace entry in ${sourceLabel}: missing name` };
}
const normalizedSource = normalizeEntrySource(plugin.source);
if (!normalizedSource.ok) {
return {
ok: false,
error: `invalid marketplace entry "${name}" in ${sourceLabel}: ${normalizedSource.error}`,
};
}
plugins.push({
name,
version: toOptionalString(plugin.version),
description: toOptionalString(plugin.description),
source: normalizedSource.source,
});
}
return {
ok: true,
manifest: {
name: toOptionalString(rec.name),
version: toOptionalString(rec.version),
plugins,
},
};
}
async function pathExists(target: string): Promise<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function readClaudeKnownMarketplaces(): Promise<Record<string, KnownMarketplaceRecord>> {
const knownPath = resolveUserPath(CLAUDE_KNOWN_MARKETPLACES_PATH);
if (!(await pathExists(knownPath))) {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(await fs.readFile(knownPath, "utf-8"));
} catch {
return {};
}
if (!parsed || typeof parsed !== "object") {
return {};
}
const entries = parsed as Record<string, unknown>;
const result: Record<string, KnownMarketplaceRecord> = {};
for (const [name, value] of Object.entries(entries)) {
if (!value || typeof value !== "object") {
continue;
}
const record = value as Record<string, unknown>;
result[name] = {
installLocation: toOptionalString(record.installLocation),
source: record.source,
};
}
return result;
}
function deriveMarketplaceRootFromManifestPath(manifestPath: string): string {
const manifestDir = path.dirname(manifestPath);
return path.basename(manifestDir) === ".claude-plugin" ? path.dirname(manifestDir) : manifestDir;
}
async function resolveLocalMarketplaceSource(
input: string,
): Promise<
{ ok: true; rootDir: string; manifestPath: string } | { ok: false; error: string } | null
> {
const resolved = resolveUserPath(input);
if (!(await pathExists(resolved))) {
return null;
}
const stat = await fs.stat(resolved);
if (stat.isFile()) {
return {
ok: true,
rootDir: deriveMarketplaceRootFromManifestPath(resolved),
manifestPath: resolved,
};
}
if (!stat.isDirectory()) {
return { ok: false, error: `unsupported marketplace source: ${resolved}` };
}
const rootDir = path.basename(resolved) === ".claude-plugin" ? path.dirname(resolved) : resolved;
for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) {
const manifestPath = path.join(rootDir, candidate);
if (await pathExists(manifestPath)) {
return { ok: true, rootDir, manifestPath };
}
}
return { ok: false, error: `marketplace manifest not found under ${resolved}` };
}
function normalizeGitCloneSource(
source: string,
): { url: string; ref?: string; label: string } | null {
const split = splitRef(source);
if (looksLikeGitHubRepoShorthand(split.base)) {
return {
url: `https://github.com/${split.base}.git`,
ref: split.ref,
label: split.base,
};
}
if (isGitUrl(source)) {
return {
url: split.base,
ref: split.ref,
label: split.base,
};
}
if (isHttpUrl(source)) {
try {
const url = new URL(split.base);
if (url.hostname !== "github.com") {
return null;
}
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length < 2) {
return null;
}
const repo = `${parts[0]}/${parts[1]?.replace(/\.git$/i, "")}`;
return {
url: `https://github.com/${repo}.git`,
ref: split.ref,
label: repo,
};
} catch {
return null;
}
}
return null;
}
async function cloneMarketplaceRepo(params: {
source: string;
timeoutMs?: number;
logger?: MarketplaceLogger;
}): Promise<
| { ok: true; rootDir: string; cleanup: () => Promise<void>; label: string }
| { ok: false; error: string }
> {
const normalized = normalizeGitCloneSource(params.source);
if (!normalized) {
return { ok: false, error: `unsupported marketplace source: ${params.source}` };
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-"));
const repoDir = path.join(tmpDir, "repo");
const argv = ["git", "clone", "--depth", "1"];
if (normalized.ref) {
argv.push("--branch", normalized.ref);
}
argv.push(normalized.url, repoDir);
params.logger?.info?.(`Cloning marketplace source ${normalized.label}...`);
const res = await runCommandWithTimeout(argv, {
timeoutMs: params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
});
if (res.code !== 0) {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
const detail = res.stderr.trim() || res.stdout.trim() || "git clone failed";
return {
ok: false,
error: `failed to clone marketplace source ${normalized.label}: ${detail}`,
};
}
return {
ok: true,
rootDir: repoDir,
label: normalized.label,
cleanup: async () => {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
async function loadMarketplace(params: {
source: string;
logger?: MarketplaceLogger;
timeoutMs?: number;
}): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> {
const knownMarketplaces = await readClaudeKnownMarketplaces();
const known = knownMarketplaces[params.source];
if (known) {
if (known.installLocation) {
const local = await resolveLocalMarketplaceSource(known.installLocation);
if (local?.ok) {
const raw = await fs.readFile(local.manifestPath, "utf-8");
const parsed = parseMarketplaceManifest(raw, local.manifestPath);
if (!parsed.ok) {
return parsed;
}
return {
ok: true,
marketplace: {
manifest: parsed.manifest,
rootDir: local.rootDir,
sourceLabel: params.source,
},
};
}
}
const normalizedSource = normalizeEntrySource(known.source);
if (normalizedSource.ok) {
return await loadMarketplace({
source: marketplaceEntrySourceToInput(normalizedSource.source),
logger: params.logger,
timeoutMs: params.timeoutMs,
});
}
}
const local = await resolveLocalMarketplaceSource(params.source);
if (local?.ok === false) {
return local;
}
if (local?.ok) {
const raw = await fs.readFile(local.manifestPath, "utf-8");
const parsed = parseMarketplaceManifest(raw, local.manifestPath);
if (!parsed.ok) {
return parsed;
}
return {
ok: true,
marketplace: {
manifest: parsed.manifest,
rootDir: local.rootDir,
sourceLabel: local.manifestPath,
},
};
}
const cloned = await cloneMarketplaceRepo({
source: params.source,
timeoutMs: params.timeoutMs,
logger: params.logger,
});
if (!cloned.ok) {
return cloned;
}
let manifestPath: string | undefined;
for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) {
const next = path.join(cloned.rootDir, candidate);
if (await pathExists(next)) {
manifestPath = next;
break;
}
}
if (!manifestPath) {
await cloned.cleanup();
return { ok: false, error: `marketplace manifest not found in ${cloned.label}` };
}
const raw = await fs.readFile(manifestPath, "utf-8");
const parsed = parseMarketplaceManifest(raw, manifestPath);
if (!parsed.ok) {
await cloned.cleanup();
return parsed;
}
return {
ok: true,
marketplace: {
manifest: parsed.manifest,
rootDir: cloned.rootDir,
sourceLabel: cloned.label,
cleanup: cloned.cleanup,
},
};
}
async function downloadUrlToTempFile(url: string): Promise<
| {
ok: true;
path: string;
cleanup: () => Promise<void>;
}
| {
ok: false;
error: string;
}
> {
const response = await fetch(url);
if (!response.ok) {
return { ok: false, error: `failed to download ${url}: HTTP ${response.status}` };
}
const pathname = new URL(url).pathname;
const fileName = path.basename(pathname) || "plugin.tgz";
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-download-"));
const targetPath = path.join(tmpDir, fileName);
await fs.writeFile(targetPath, Buffer.from(await response.arrayBuffer()));
return {
ok: true,
path: targetPath,
cleanup: async () => {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
function ensureInsideMarketplaceRoot(
rootDir: string,
candidate: string,
): { ok: true; path: string } | { ok: false; error: string } {
const resolved = path.resolve(rootDir, candidate);
const relative = path.relative(rootDir, resolved);
if (relative === ".." || relative.startsWith(`..${path.sep}`)) {
return {
ok: false,
error: `plugin source escapes marketplace root: ${candidate}`,
};
}
return { ok: true, path: resolved };
}
async function resolveMarketplaceEntryInstallPath(params: {
source: MarketplaceEntrySource;
marketplaceRootDir: string;
logger?: MarketplaceLogger;
timeoutMs?: number;
}): Promise<
| {
ok: true;
path: string;
cleanup?: () => Promise<void>;
}
| {
ok: false;
error: string;
}
> {
if (params.source.kind === "path") {
if (isHttpUrl(params.source.path)) {
if (resolveArchiveKind(params.source.path)) {
return await downloadUrlToTempFile(params.source.path);
}
return {
ok: false,
error: `unsupported remote plugin path source: ${params.source.path}`,
};
}
const resolved = path.isAbsolute(params.source.path)
? { ok: true as const, path: params.source.path }
: ensureInsideMarketplaceRoot(params.marketplaceRootDir, params.source.path);
if (!resolved.ok) {
return resolved;
}
return { ok: true, path: resolved.path };
}
if (
params.source.kind === "github" ||
params.source.kind === "git" ||
params.source.kind === "git-subdir"
) {
const sourceSpec =
params.source.kind === "github"
? `${params.source.repo}${params.source.ref ? `#${params.source.ref}` : ""}`
: `${params.source.url}${params.source.ref ? `#${params.source.ref}` : ""}`;
const cloned = await cloneMarketplaceRepo({
source: sourceSpec,
timeoutMs: params.timeoutMs,
logger: params.logger,
});
if (!cloned.ok) {
return cloned;
}
const subPath =
params.source.kind === "github" || params.source.kind === "git"
? params.source.path?.trim() || "."
: params.source.path.trim();
const target = ensureInsideMarketplaceRoot(cloned.rootDir, subPath);
if (!target.ok) {
await cloned.cleanup();
return target;
}
return {
ok: true,
path: target.path,
cleanup: cloned.cleanup,
};
}
if (resolveArchiveKind(params.source.url)) {
return await downloadUrlToTempFile(params.source.url);
}
if (!normalizeGitCloneSource(params.source.url)) {
return {
ok: false,
error: `unsupported URL plugin source: ${params.source.url}`,
};
}
const cloned = await cloneMarketplaceRepo({
source: params.source.url,
timeoutMs: params.timeoutMs,
logger: params.logger,
});
if (!cloned.ok) {
return cloned;
}
return {
ok: true,
path: cloned.rootDir,
cleanup: cloned.cleanup,
};
}
export async function listMarketplacePlugins(params: {
marketplace: string;
logger?: MarketplaceLogger;
timeoutMs?: number;
}): Promise<MarketplacePluginListResult> {
const loaded = await loadMarketplace({
source: params.marketplace,
logger: params.logger,
timeoutMs: params.timeoutMs,
});
if (!loaded.ok) {
return loaded;
}
try {
return {
ok: true,
manifest: loaded.marketplace.manifest,
sourceLabel: loaded.marketplace.sourceLabel,
};
} finally {
await loaded.marketplace.cleanup?.();
}
}
export async function resolveMarketplaceInstallShortcut(
raw: string,
): Promise<MarketplaceShortcutResolution> {
const trimmed = raw.trim();
const atIndex = trimmed.lastIndexOf("@");
if (atIndex <= 0 || atIndex >= trimmed.length - 1) {
return null;
}
const plugin = trimmed.slice(0, atIndex).trim();
const marketplaceName = trimmed.slice(atIndex + 1).trim();
if (!plugin || !marketplaceName || plugin.includes("/")) {
return null;
}
const knownMarketplaces = await readClaudeKnownMarketplaces();
const known = knownMarketplaces[marketplaceName];
if (!known) {
return null;
}
if (known.installLocation) {
return {
ok: true,
plugin,
marketplaceName,
marketplaceSource: marketplaceName,
};
}
const normalizedSource = normalizeEntrySource(known.source);
if (!normalizedSource.ok) {
return {
ok: false,
error: `known Claude marketplace "${marketplaceName}" has an invalid source: ${normalizedSource.error}`,
};
}
return {
ok: true,
plugin,
marketplaceName,
marketplaceSource: marketplaceName,
};
}
export async function installPluginFromMarketplace(params: {
marketplace: string;
plugin: string;
logger?: MarketplaceLogger;
timeoutMs?: number;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
}): Promise<MarketplaceInstallResult> {
const loaded = await loadMarketplace({
source: params.marketplace,
logger: params.logger,
timeoutMs: params.timeoutMs,
});
if (!loaded.ok) {
return loaded;
}
let installCleanup: (() => Promise<void>) | undefined;
try {
const entry = loaded.marketplace.manifest.plugins.find(
(plugin) => plugin.name === params.plugin,
);
if (!entry) {
const known = loaded.marketplace.manifest.plugins.map((plugin) => plugin.name).toSorted();
return {
ok: false,
error:
`plugin "${params.plugin}" not found in marketplace ${loaded.marketplace.sourceLabel}` +
(known.length > 0 ? ` (available: ${known.join(", ")})` : ""),
};
}
const resolved = await resolveMarketplaceEntryInstallPath({
source: entry.source,
marketplaceRootDir: loaded.marketplace.rootDir,
logger: params.logger,
timeoutMs: params.timeoutMs,
});
if (!resolved.ok) {
return resolved;
}
installCleanup = resolved.cleanup;
const result = await installPluginFromPath({
path: resolved.path,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
});
if (!result.ok) {
return result;
}
return {
...result,
marketplaceName: loaded.marketplace.manifest.name,
marketplaceVersion: loaded.marketplace.manifest.version,
marketplacePlugin: entry.name,
marketplaceSource: params.marketplace,
marketplaceEntryVersion: entry.version,
};
} finally {
await installCleanup?.();
await loaded.marketplace.cleanup?.();
}
}

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const installPluginFromNpmSpecMock = vi.fn();
const installPluginFromMarketplaceMock = vi.fn();
const resolveBundledPluginSourcesMock = vi.fn();
vi.mock("./install.js", () => ({
@ -11,6 +12,10 @@ vi.mock("./install.js", () => ({
},
}));
vi.mock("./marketplace.js", () => ({
installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplaceMock(...args),
}));
vi.mock("./bundled-sources.js", () => ({
resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args),
}));
@ -18,6 +23,7 @@ vi.mock("./bundled-sources.js", () => ({
describe("updateNpmInstalledPlugins", () => {
beforeEach(() => {
installPluginFromNpmSpecMock.mockReset();
installPluginFromMarketplaceMock.mockReset();
resolveBundledPluginSourcesMock.mockReset();
});
@ -213,6 +219,95 @@ describe("updateNpmInstalledPlugins", () => {
});
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
});
it("checks marketplace installs during dry-run updates", async () => {
installPluginFromMarketplaceMock.mockResolvedValue({
ok: true,
pluginId: "claude-bundle",
targetDir: "/tmp/claude-bundle",
version: "1.2.0",
extensions: ["index.ts"],
marketplaceSource: "vincentkoc/claude-marketplace",
marketplacePlugin: "claude-bundle",
});
const { updateNpmInstalledPlugins } = await import("./update.js");
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"claude-bundle": {
source: "marketplace",
marketplaceSource: "vincentkoc/claude-marketplace",
marketplacePlugin: "claude-bundle",
installPath: "/tmp/claude-bundle",
},
},
},
},
pluginIds: ["claude-bundle"],
dryRun: true,
});
expect(installPluginFromMarketplaceMock).toHaveBeenCalledWith(
expect.objectContaining({
marketplace: "vincentkoc/claude-marketplace",
plugin: "claude-bundle",
expectedPluginId: "claude-bundle",
dryRun: true,
}),
);
expect(result.outcomes).toEqual([
{
pluginId: "claude-bundle",
status: "updated",
currentVersion: undefined,
nextVersion: "1.2.0",
message: "Would update claude-bundle: unknown -> 1.2.0.",
},
]);
});
it("updates marketplace installs and preserves source metadata", async () => {
installPluginFromMarketplaceMock.mockResolvedValue({
ok: true,
pluginId: "claude-bundle",
targetDir: "/tmp/claude-bundle",
version: "1.3.0",
extensions: ["index.ts"],
marketplaceName: "Vincent's Claude Plugins",
marketplaceSource: "vincentkoc/claude-marketplace",
marketplacePlugin: "claude-bundle",
});
const { updateNpmInstalledPlugins } = await import("./update.js");
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"claude-bundle": {
source: "marketplace",
marketplaceName: "Vincent's Claude Plugins",
marketplaceSource: "vincentkoc/claude-marketplace",
marketplacePlugin: "claude-bundle",
installPath: "/tmp/claude-bundle",
},
},
},
},
pluginIds: ["claude-bundle"],
});
expect(result.changed).toBe(true);
expect(result.config.plugins?.installs?.["claude-bundle"]).toMatchObject({
source: "marketplace",
installPath: "/tmp/claude-bundle",
version: "1.3.0",
marketplaceName: "Vincent's Claude Plugins",
marketplaceSource: "vincentkoc/claude-marketplace",
marketplacePlugin: "claude-bundle",
});
});
});
describe("syncPluginsForUpdateChannel", () => {

View File

@ -12,6 +12,7 @@ import {
resolvePluginInstallDir,
} from "./install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
import { installPluginFromMarketplace } from "./marketplace.js";
export type PluginUpdateLogger = {
info?: (message: string) => void;
@ -70,6 +71,19 @@ function formatNpmInstallFailure(params: {
return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`;
}
function formatMarketplaceInstallFailure(params: {
pluginId: string;
marketplaceSource: string;
marketplacePlugin: string;
phase: "check" | "update";
error: string;
}): string {
return (
`Failed to ${params.phase} ${params.pluginId}: ` +
`${params.error} (marketplace plugin ${params.marketplacePlugin} from ${params.marketplaceSource}).`
);
}
type InstallIntegrityDrift = {
spec: string;
expectedIntegrity: string;
@ -306,7 +320,7 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
if (record.source !== "npm") {
if (record.source !== "npm" && record.source !== "marketplace") {
outcomes.push({
pluginId,
status: "skipped",
@ -315,7 +329,7 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
if (!record.spec) {
if (record.source === "npm" && !record.spec) {
outcomes.push({
pluginId,
status: "skipped",
@ -324,6 +338,18 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
if (
record.source === "marketplace" &&
(!record.marketplaceSource || !record.marketplacePlugin)
) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (missing marketplace source metadata).`,
});
continue;
}
let installPath: string;
try {
installPath = record.installPath ?? resolvePluginInstallDir(pluginId);
@ -338,22 +364,34 @@ export async function updateNpmInstalledPlugins(params: {
const currentVersion = await readInstalledPackageVersion(installPath);
if (params.dryRun) {
let probe: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
let probe:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
try {
probe = await installPluginFromNpmSpec({
spec: record.spec,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: true,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
});
probe =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: record.spec!,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: true,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
logger,
});
} catch (err) {
outcomes.push({
pluginId,
@ -366,12 +404,21 @@ export async function updateNpmInstalledPlugins(params: {
outcomes.push({
pluginId,
status: "error",
message: formatNpmInstallFailure({
pluginId,
spec: record.spec,
phase: "check",
result: probe,
}),
message:
record.source === "npm"
? formatNpmInstallFailure({
pluginId,
spec: record.spec!,
phase: "check",
result: probe,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "check",
error: probe.error,
}),
});
continue;
}
@ -398,21 +445,32 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
let result: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
let result:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
try {
result = await installPluginFromNpmSpec({
spec: record.spec,
mode: "update",
expectedPluginId: pluginId,
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: false,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
});
result =
record.source === "npm"
? await installPluginFromNpmSpec({
spec: record.spec!,
mode: "update",
expectedPluginId: pluginId,
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
pluginId,
dryRun: false,
logger,
onIntegrityDrift: params.onIntegrityDrift,
}),
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
expectedPluginId: pluginId,
logger,
});
} catch (err) {
outcomes.push({
pluginId,
@ -425,12 +483,21 @@ export async function updateNpmInstalledPlugins(params: {
outcomes.push({
pluginId,
status: "error",
message: formatNpmInstallFailure({
pluginId,
spec: record.spec,
phase: "update",
result: result,
}),
message:
record.source === "npm"
? formatNpmInstallFailure({
pluginId,
spec: record.spec!,
phase: "update",
result: result,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "update",
error: result.error,
}),
});
continue;
}
@ -441,14 +508,30 @@ export async function updateNpmInstalledPlugins(params: {
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "npm",
spec: record.spec,
installPath: result.targetDir,
version: nextVersion,
...buildNpmResolutionInstallFields(result.npmResolution),
});
if (record.source === "npm") {
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "npm",
spec: record.spec,
installPath: result.targetDir,
version: nextVersion,
...buildNpmResolutionInstallFields(result.npmResolution),
});
} else {
const marketplaceResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromMarketplace>>,
{ ok: true }
>;
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "marketplace",
installPath: result.targetDir,
version: nextVersion,
marketplaceName: marketplaceResult.marketplaceName ?? record.marketplaceName,
marketplaceSource: record.marketplaceSource,
marketplacePlugin: record.marketplacePlugin,
});
}
changed = true;
const currentLabel = currentVersion ?? "unknown";