diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9fb7a653d..821451053bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. - Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) - Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) +- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. Thanks @vincentkoc. ### Breaking diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index fb390c1190b..4669e762c4a 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -1,38 +1,44 @@ # syntax=docker/dockerfile:1.7 -FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df +FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* RUN corepack enable -WORKDIR /app +RUN useradd --create-home --shell /bin/bash appuser \ + && mkdir -p /app \ + && chown appuser:appuser /app +ENV HOME="/home/appuser" ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning" -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY ui/package.json ./ui/package.json -COPY extensions/memory-core/package.json ./extensions/memory-core/package.json -COPY patches ./patches +USER appuser +WORKDIR /app -RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ +COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --chown=appuser:appuser ui/package.json ./ui/package.json +COPY --chown=appuser:appuser extensions/memory-core/package.json ./extensions/memory-core/package.json +COPY --chown=appuser:appuser patches ./patches + +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile -COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ -COPY src ./src -COPY test ./test -COPY scripts ./scripts -COPY docs ./docs -COPY skills ./skills -COPY ui ./ui -COPY extensions/memory-core ./extensions/memory-core -COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit -COPY apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources -COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI +COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ +COPY --chown=appuser:appuser src ./src +COPY --chown=appuser:appuser test ./test +COPY --chown=appuser:appuser scripts ./scripts +COPY --chown=appuser:appuser docs ./docs +COPY --chown=appuser:appuser skills ./skills +COPY --chown=appuser:appuser ui ./ui +COPY --chown=appuser:appuser extensions ./extensions +COPY --chown=appuser:appuser vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit +COPY --chown=appuser:appuser apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources +COPY --chown=appuser:appuser apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI RUN pnpm build RUN pnpm ui:build -RUN useradd --create-home --shell /bin/bash appuser \ - && chown -R appuser:appuser /app -USER appuser - CMD ["bash"] diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import index a8c611a9516..4b572a705b3 100644 --- a/scripts/e2e/Dockerfile.qr-import +++ b/scripts/e2e/Dockerfile.qr-import @@ -1,23 +1,26 @@ # syntax=docker/dockerfile:1.7 -FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df +FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b RUN corepack enable +RUN useradd --create-home --shell /bin/bash appuser \ + && mkdir -p /app \ + && chown appuser:appuser /app + +ENV HOME="/home/appuser" + +USER appuser WORKDIR /app -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY ui/package.json ./ui/package.json -COPY patches ./patches +COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --chown=appuser:appuser ui/package.json ./ui/package.json +COPY --chown=appuser:appuser patches ./patches # This image only exercises the root qrcode-terminal dependency path. # Keep the pre-install copy set limited to the manifests needed for root # workspace resolution so unrelated extension edits do not bust the layer. -RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile -COPY . . - -RUN useradd --create-home --shell /bin/bash appuser \ - && chown -R appuser:appuser /app -USER appuser +COPY --chown=appuser:appuser . . diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index 854a92606ed..587840ec93a 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -8,24 +8,69 @@ echo "Building Docker image..." docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" echo "Running plugins Docker E2E..." - docker run --rm -t "$IMAGE_NAME" bash -lc ' - set -euo pipefail - if [ -f dist/index.mjs ]; then - OPENCLAW_ENTRY="dist/index.mjs" - elif [ -f dist/index.js ]; then - OPENCLAW_ENTRY="dist/index.js" - else - echo "Missing dist/index.(m)js (build output):" - ls -la dist || true - exit 1 - fi - export OPENCLAW_ENTRY +docker run --rm -i "$IMAGE_NAME" bash -s <<'EOF' +set -euo pipefail - home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") - export HOME="$home_dir" - mkdir -p "$HOME/.openclaw/extensions/demo-plugin" +if [ -f dist/index.mjs ]; then + OPENCLAW_ENTRY="dist/index.mjs" +elif [ -f dist/index.js ]; then + OPENCLAW_ENTRY="dist/index.js" +else + echo "Missing dist/index.(m)js (build output):" + ls -la dist || true + exit 1 +fi +export OPENCLAW_ENTRY - cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'"'"'JS'"'"' +home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") +export HOME="$home_dir" + +write_fixture_plugin() { + local dir="$1" + local id="$2" + local version="$3" + local method="$4" + local name="$5" + + mkdir -p "$dir" + cat > "$dir/package.json" < "$dir/index.js" < ({ ok: true })); + }, +}; +JS + cat > "$dir/openclaw.plugin.json" <<'JSON' +{ + "id": "placeholder", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON + node - <<'NODE' "$dir/openclaw.plugin.json" "$id" +const fs = require("node:fs"); +const file = process.argv[2]; +const id = process.argv[3]; +const parsed = JSON.parse(fs.readFileSync(file, "utf8")); +parsed.id = id; +fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`); +NODE +} + +mkdir -p "$HOME/.openclaw/extensions/demo-plugin" + +cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'JS' module.exports = { id: "demo-plugin", name: "Demo Plugin", @@ -38,7 +83,7 @@ module.exports = { }, }; JS - cat > "$HOME/.openclaw/extensions/demo-plugin/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$HOME/.openclaw/extensions/demo-plugin/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin", "configSchema": { @@ -48,9 +93,9 @@ JS } JSON - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8")); @@ -79,17 +124,17 @@ if (diagErrors.length > 0) { console.log("ok"); NODE - echo "Testing tgz install flow..." - pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" - mkdir -p "$pack_dir/package" - cat > "$pack_dir/package/package.json" <<'"'"'JSON'"'"' +echo "Testing tgz install flow..." +pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" +mkdir -p "$pack_dir/package" +cat > "$pack_dir/package/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-tgz", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON - cat > "$pack_dir/package/index.js" <<'"'"'JS'"'"' +cat > "$pack_dir/package/index.js" <<'JS' module.exports = { id: "demo-plugin-tgz", name: "Demo Plugin TGZ", @@ -98,7 +143,7 @@ module.exports = { }, }; JS - cat > "$pack_dir/package/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$pack_dir/package/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-tgz", "configSchema": { @@ -107,12 +152,12 @@ JS } } JSON - tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package +tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package - node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json +node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); @@ -127,16 +172,16 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de console.log("ok"); NODE - echo "Testing install from local folder (plugins.load.paths)..." - dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" - cat > "$dir_plugin/package.json" <<'"'"'JSON'"'"' +echo "Testing install from local folder (plugins.load.paths)..." +dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" +cat > "$dir_plugin/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-dir", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON - cat > "$dir_plugin/index.js" <<'"'"'JS'"'"' +cat > "$dir_plugin/index.js" <<'JS' module.exports = { id: "demo-plugin-dir", name: "Demo Plugin DIR", @@ -145,7 +190,7 @@ module.exports = { }, }; JS - cat > "$dir_plugin/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$dir_plugin/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-dir", "configSchema": { @@ -155,10 +200,10 @@ JS } JSON - node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json +node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8")); @@ -173,17 +218,17 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de console.log("ok"); NODE - echo "Testing install from npm spec (file:)..." - file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" - mkdir -p "$file_pack_dir/package" - cat > "$file_pack_dir/package/package.json" <<'"'"'JSON'"'"' +echo "Testing install from npm spec (file:)..." +file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" +mkdir -p "$file_pack_dir/package" +cat > "$file_pack_dir/package/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-file", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON - cat > "$file_pack_dir/package/index.js" <<'"'"'JS'"'"' +cat > "$file_pack_dir/package/index.js" <<'JS' module.exports = { id: "demo-plugin-file", name: "Demo Plugin FILE", @@ -192,7 +237,7 @@ module.exports = { }, }; JS - cat > "$file_pack_dir/package/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$file_pack_dir/package/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-file", "configSchema": { @@ -202,10 +247,10 @@ JS } JSON - node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json +node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8")); @@ -220,8 +265,155 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de console.log("ok"); NODE - echo "Running bundle MCP CLI-agent e2e..." - pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts -' +echo "Testing marketplace install and update flows..." +marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace" +mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin" +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-shortcut" \ + "marketplace-shortcut" \ + "0.0.1" \ + "demo.marketplace.shortcut.v1" \ + "Marketplace Shortcut" +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-direct" \ + "marketplace-direct" \ + "0.0.1" \ + "demo.marketplace.direct.v1" \ + "Marketplace Direct" +cat > "$marketplace_root/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "Fixture Marketplace", + "version": "1.0.0", + "plugins": [ + { + "name": "marketplace-shortcut", + "version": "0.0.1", + "description": "Shortcut install fixture", + "source": "./plugins/marketplace-shortcut" + }, + { + "name": "marketplace-direct", + "version": "0.0.1", + "description": "Explicit marketplace fixture", + "source": { + "type": "path", + "path": "./plugins/marketplace-direct" + } + } + ] +} +JSON +cat > "$HOME/.claude/plugins/known_marketplaces.json" < /tmp/marketplace-list.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/marketplace-list.json", "utf8")); +const names = (data.plugins || []).map((entry) => entry.name).sort(); +if (data.name !== "Fixture Marketplace") { + throw new Error(`unexpected marketplace name: ${data.name}`); +} +if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) { + throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`); +} +console.log("ok"); +NODE + +node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures +node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); +const getPlugin = (id) => { + const plugin = (data.plugins || []).find((entry) => entry.id === id); + if (!plugin) throw new Error(`plugin not found: ${id}`); + if (plugin.status !== "loaded") { + throw new Error(`unexpected status for ${id}: ${plugin.status}`); + } + return plugin; +}; + +const shortcut = getPlugin("marketplace-shortcut"); +const direct = getPlugin("marketplace-direct"); +if (shortcut.version !== "0.0.1") { + throw new Error(`unexpected shortcut version: ${shortcut.version}`); +} +if (direct.version !== "0.0.1") { + throw new Error(`unexpected direct version: ${direct.version}`); +} +if (!shortcut.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { + throw new Error("expected marketplace shortcut gateway method"); +} +if (!direct.gatewayMethods.includes("demo.marketplace.direct.v1")) { + throw new Error("expected marketplace direct gateway method"); +} +console.log("ok"); +NODE + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +for (const id of ["marketplace-shortcut", "marketplace-direct"]) { + const record = config.plugins?.installs?.[id]; + if (!record) throw new Error(`missing install record for ${id}`); + if (record.source !== "marketplace") { + throw new Error(`unexpected source for ${id}: ${record.source}`); + } + if (record.marketplaceSource !== "claude-fixtures") { + throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`); + } + if (record.marketplacePlugin !== id) { + throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`); + } +} +console.log("ok"); +NODE + +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-shortcut" \ + "marketplace-shortcut" \ + "0.0.2" \ + "demo.marketplace.shortcut.v2" \ + "Marketplace Shortcut" +node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run +node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace-updated.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut"); +if (!plugin) throw new Error("updated marketplace plugin not found"); +if (plugin.version !== "0.0.2") { + throw new Error(`unexpected updated version: ${plugin.version}`); +} +if (!plugin.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { + throw new Error(`expected updated gateway method, got ${plugin.gatewayMethods.join(", ")}`); +} +console.log("ok"); +NODE + +echo "Running bundle MCP CLI-agent e2e..." +pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts +EOF echo "OK" diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index d090fe7d83d..b4b197bf96c 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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 (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec") + .description("Install a plugin (path, archive, npm spec, or marketplace entry)") + .argument( + "", + "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 @", false) - .action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => { + .option( + "--marketplace ", + "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("", "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}`); + } + }); } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index b008b8bf869..627dccb5049 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1003,6 +1003,12 @@ export const FIELD_HELP: Record = { "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).", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6843b8f410f..9541ad3b10a 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -871,4 +871,7 @@ export const FIELD_LABELS: Record = { "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", }; diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 323946dd541..62d750b0470 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -19,7 +19,12 @@ export type PluginsLoadConfig = { paths?: string[]; }; -export type PluginInstallRecord = InstallRecordBase; +export type PluginInstallRecord = Omit & { + source: InstallRecordBase["source"] | "marketplace"; + marketplaceName?: string; + marketplaceSource?: string; + marketplacePlugin?: string; +}; export type PluginsConfig = { /** Enable or disable plugin loading. */ diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index 7853948a10c..7270e5c5d28 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -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; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 345c86b3097..d1bce17b575 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), ) diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts new file mode 100644 index 00000000000..14d3bda0323 --- /dev/null +++ b/src/plugins/marketplace.test.ts @@ -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(fn: (dir: string) => Promise): Promise { + 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", + }); + }); + }); +}); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts new file mode 100644 index 00000000000..4999c3c8828 --- /dev/null +++ b/src/plugins/marketplace.ts @@ -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; +}; + +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) + | Extract; + +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; + 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; + 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; + 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 { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +async function readClaudeKnownMarketplaces(): Promise> { + 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; + const result: Record = {}; + for (const [name, value] of Object.entries(entries)) { + if (!value || typeof value !== "object") { + continue; + } + const record = value as Record; + 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; 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; + } + | { + 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; + } + | { + 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 { + 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 { + 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 { + const loaded = await loadMarketplace({ + source: params.marketplace, + logger: params.logger, + timeoutMs: params.timeoutMs, + }); + if (!loaded.ok) { + return loaded; + } + + let installCleanup: (() => Promise) | 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?.(); + } +} diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 4d3b72ed65d..e3c21e8d7ef 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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", () => { diff --git a/src/plugins/update.ts b/src/plugins/update.ts index af6434e84cc..83733159cac 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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>; + let probe: + | Awaited> + | Awaited>; 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>; + let result: + | Awaited> + | Awaited>; 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>, + { 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";