Compare commits

...

6 Commits

Author SHA1 Message Date
Vincent Koc
aa130315b1 Agents: run bundle MCP tools in embedded Pi 2026-03-16 18:11:45 -07:00
Vincent Koc
3c9fb835de Docs: use placeholders for marketplace plugin examples 2026-03-16 02:06:27 -07:00
Vincent Koc
3effdf98f7 Docs: add Claude marketplace plugin install guidance 2026-03-16 02:02:39 -07:00
Vincent Koc
0cc27bf2e5 E2E: cover marketplace plugin installs in Docker 2026-03-16 01:41:56 -07:00
Vincent Koc
8241311947 Plugins: add Claude marketplace installs 2026-03-16 01:41:44 -07:00
Vincent Koc
45fb57802c Changelog: note Claude marketplace plugin support 2026-03-16 01:40:59 -07:00
30 changed files with 2629 additions and 164 deletions

View File

@ -26,6 +26,8 @@ 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.
- Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import.
### Breaking

View File

@ -284,7 +284,8 @@ Manage extensions and their config:
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
- `openclaw plugins info <id>` — show details for a plugin.
- `openclaw plugins install <path|.tgz|npm-spec>` — install a plugin (or add a plugin path to `plugins.load.paths`).
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
- `openclaw plugins doctor` — report plugin load errors.

View File

@ -1,5 +1,5 @@
---
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)"
read_when:
- You want to install or manage Gateway plugins or compatible bundles
- You want to debug plugin load failures
@ -28,6 +28,7 @@ openclaw plugins uninstall <id>
openclaw plugins doctor
openclaw plugins update <id>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
```
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
@ -46,6 +47,8 @@ capabilities.
```bash
openclaw plugins install <path-or-spec>
openclaw plugins install <npm-spec> --pin
openclaw plugins install <plugin>@<marketplace>
openclaw plugins install <plugin> --marketplace <marketplace>
```
Security note: treat plugin installs like running code. Prefer pinned versions.
@ -65,6 +68,31 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Claude marketplace installs are also supported.
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's
local registry cache at `~/.claude/plugins/known_marketplaces.json`:
```bash
openclaw plugins marketplace list <marketplace-name>
openclaw plugins install <plugin-name>@<marketplace-name>
```
Use `--marketplace` when you want to pass the marketplace source explicitly:
```bash
openclaw plugins install <plugin-name> --marketplace <marketplace-name>
openclaw plugins install <plugin-name> --marketplace <owner/repo>
openclaw plugins install <plugin-name> --marketplace ./my-marketplace
```
Marketplace sources can be:
- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json`
- a local marketplace root or `marketplace.json` path
- a GitHub repo shorthand such as `owner/repo`
- a git URL
For local paths and archives, OpenClaw auto-detects:
- native OpenClaw plugins (`openclaw.plugin.json`)
@ -114,7 +142,8 @@ openclaw plugins update --all
openclaw plugins update <id> --dry-run
```
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
Updates apply to tracked installs in `plugins.installs`, currently npm and
marketplace installs.
When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw prints a warning and asks for confirmation before proceeding. Use

View File

@ -104,11 +104,15 @@ loader. Cursor command markdown works through the same path.
- `HOOK.md`
- `handler.ts` or `handler.js`
#### MCP for CLI backends
#### MCP for Pi
- enabled bundles can contribute MCP server config
- current runtime wiring is used by the `claude-cli` backend
- OpenClaw merges bundle MCP config into the backend `--mcp-config` file
- OpenClaw merges bundle MCP config into the effective embedded Pi settings as
`mcpServers`
- OpenClaw also exposes supported bundle MCP tools during embedded Pi agent
turns by launching the declared MCP servers as subprocesses
- project-local Pi settings still apply after bundle defaults, so workspace
settings can override bundle MCP entries when needed
#### Embedded Pi settings
@ -133,7 +137,6 @@ diagnostics/info output, but OpenClaw does not run them yet:
- Cursor `.cursor/agents`
- Cursor `.cursor/hooks.json`
- Cursor `.cursor/rules`
- Cursor `mcpServers` outside the current mapped runtime paths
- Codex inline/app metadata beyond capability reporting
## Capability reporting
@ -153,7 +156,8 @@ Current exceptions:
- Claude `commands` is considered supported because it maps to skills
- Claude `settings` is considered supported because it maps to embedded Pi settings
- Cursor `commands` is considered supported because it maps to skills
- bundle MCP is considered supported where OpenClaw actually imports it
- bundle MCP is considered supported because it maps into embedded Pi settings
and exposes tools to embedded Pi
- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts
## Format differences
@ -195,6 +199,7 @@ Claude-specific notes:
- `commands/` is treated like skill content
- `settings.json` is imported into embedded Pi settings
- `.mcp.json` and manifest `mcpServers` can expose tools to embedded Pi
- `hooks/hooks.json` is detected, but not executed as Claude automation
### Cursor
@ -246,7 +251,9 @@ Current behavior:
- bundle discovery reads files inside the plugin root with boundary checks
- skills and hook-pack paths must stay inside the plugin root
- bundle settings files are read with the same boundary checks
- OpenClaw does not execute arbitrary bundle runtime code in-process
- supported bundle MCP servers may be launched as subprocesses for embedded Pi
tool calls
- OpenClaw does not load arbitrary bundle runtime modules in-process
This makes bundle support safer by default than native plugin modules, but you
should still treat third-party bundles as trusted content for the features they
@ -259,12 +266,19 @@ openclaw plugins install ./my-codex-bundle
openclaw plugins install ./my-claude-bundle
openclaw plugins install ./my-cursor-bundle
openclaw plugins install ./my-bundle.tgz
openclaw plugins marketplace list <marketplace-name>
openclaw plugins install <plugin-name>@<marketplace-name>
openclaw plugins info my-bundle
```
If the directory is a native OpenClaw plugin/package, the native install path
still wins.
For Claude marketplace names, OpenClaw reads the local Claude known-marketplace
registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries
can resolve to bundle-compatible directories/archives or to native plugin
sources; after resolution, the normal install rules still apply.
## Troubleshooting
### Bundle is detected but capabilities do not run

View File

@ -57,6 +57,18 @@ openclaw plugins install ./my-bundle
openclaw plugins install ./my-bundle.tgz
```
For Claude marketplace installs, list the marketplace first, then install by
marketplace entry name:
```bash
openclaw plugins marketplace list <marketplace-name>
openclaw plugins install <plugin-name>@<marketplace-name>
```
OpenClaw resolves known Claude marketplace names from
`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit
marketplace source with `--marketplace`.
## Architecture
OpenClaw's plugin system has four layers:
@ -94,6 +106,10 @@ OpenClaw also recognizes two compatible external bundle layouts:
component layout without a manifest
- Cursor-style bundles: `.cursor-plugin/plugin.json`
Claude marketplace entries can point at any of these compatible bundles, or at
native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first,
then runs the normal install path for the resolved source.
They are shown in the plugin list as `format=bundle`, with a subtype of
`codex` or `claude` in verbose/info output.
@ -108,18 +124,22 @@ plugins:
OpenClaw skill loader
- supported now: Claude bundle `settings.json` defaults for embedded Pi agent
settings (with shell override keys sanitized)
- supported now: bundle MCP config, merged into embedded Pi agent settings as
`mcpServers`, with supported bundle MCP tools exposed during embedded Pi
agent turns
- supported now: Cursor `.cursor/commands/*.md` roots, mapped into the normal
OpenClaw skill loader
- supported now: Codex bundle hook directories that use the OpenClaw hook-pack
layout (`HOOK.md` + `handler.ts`/`handler.js`)
- detected but not wired yet: other declared bundle capabilities such as
agents, Claude hook automation, Cursor rules/hooks/MCP metadata, MCP/app/LSP
agents, Claude hook automation, Cursor rules/hooks metadata, app/LSP
metadata, output styles
That means bundle install/discovery/list/info/enablement all work, and bundle
skills, Claude command-skills, Claude bundle settings defaults, and compatible
Codex hook directories load when the bundle is enabled, but bundle runtime code
is not executed in-process.
Codex hook directories load when the bundle is enabled. Supported bundle MCP
servers may also run as subprocesses for embedded Pi tool calls, but bundle
runtime modules are not loaded in-process.
Bundle hook support is limited to the normal OpenClaw hook directory format
(`HOOK.md` plus `handler.ts`/`handler.js` under the declared hook roots).

View File

@ -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"]

View File

@ -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 . .

View File

@ -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" <<JSON
{
"name": "@openclaw/$id",
"version": "$version",
"openclaw": { "extensions": ["./index.js"] }
}
JSON
cat > "$dir/index.js" <<JS
module.exports = {
id: "$id",
name: "$name",
register(api) {
api.registerGatewayMethod("$method", async () => ({ 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" <<JSON
{
"claude-fixtures": {
"installLocation": "$marketplace_root",
"source": {
"type": "github",
"repo": "openclaw/fixture-marketplace"
}
}
}
JSON
node "$OPENCLAW_ENTRY" plugins marketplace list claude-fixtures --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"

View File

@ -0,0 +1,141 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const tempDirs: string[] = [];
async function makeTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("createBundleMcpToolRuntime", () => {
it("loads bundle MCP tools and executes them", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
},
});
try {
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]);
const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined);
expect(result.content[0]).toMatchObject({
type: "text",
text: "FROM-BUNDLE",
});
expect(result.details).toEqual({
mcpServer: "bundleProbe",
mcpTool: "bundle_probe",
});
} finally {
await runtime.dispose();
}
});
it("skips bundle MCP tools that collide with existing tool names", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
},
reservedToolNames: ["bundle_probe"],
});
try {
expect(runtime.tools).toEqual([]);
} finally {
await runtime.dispose();
}
});
});

View File

@ -0,0 +1,278 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { logDebug, logWarn } from "../logger.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
import type { AnyAgentTool } from "./tools/common.js";
type BundleMcpServerLaunchConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
};
type BundleMcpToolRuntime = {
tools: AnyAgentTool[];
dispose: () => Promise<void>;
};
type BundleMcpSession = {
serverName: string;
client: Client;
transport: StdioClientTransport;
detachStderr?: () => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const entries = Object.entries(value)
.map(([key, entry]) => {
if (typeof entry === "string") {
return [key, entry] as const;
}
if (typeof entry === "number" || typeof entry === "boolean") {
return [key, String(entry)] as const;
}
return null;
})
.filter((entry): entry is readonly [string, string] => entry !== null);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length > 0 ? entries : [];
}
function resolveLaunchConfig(raw: unknown): BundleMcpServerLaunchConfig | null {
if (!isRecord(raw) || typeof raw.command !== "string" || raw.command.trim().length === 0) {
return null;
}
const cwd =
typeof raw.cwd === "string" && raw.cwd.trim().length > 0
? raw.cwd
: typeof raw.workingDirectory === "string" && raw.workingDirectory.trim().length > 0
? raw.workingDirectory
: undefined;
return {
command: raw.command,
args: toStringArray(raw.args),
env: toStringRecord(raw.env),
cwd,
};
}
function describeServerLaunchConfig(config: BundleMcpServerLaunchConfig): string {
const args =
Array.isArray(config.args) && config.args.length > 0 ? ` ${config.args.join(" ")}` : "";
const cwd = config.cwd ? ` (cwd=${config.cwd})` : "";
return `${config.command}${args}${cwd}`;
}
async function listAllTools(client: Client) {
const tools: Awaited<ReturnType<Client["listTools"]>>["tools"] = [];
let cursor: string | undefined;
do {
const page = await client.listTools(cursor ? { cursor } : undefined);
tools.push(...page.tools);
cursor = page.nextCursor;
} while (cursor);
return tools;
}
function toAgentToolResult(params: {
serverName: string;
toolName: string;
result: CallToolResult;
}): AgentToolResult<unknown> {
const content = Array.isArray(params.result.content)
? (params.result.content as AgentToolResult<unknown>["content"])
: [];
const normalizedContent: AgentToolResult<unknown>["content"] =
content.length > 0
? content
: params.result.structuredContent !== undefined
? [
{
type: "text",
text: JSON.stringify(params.result.structuredContent, null, 2),
},
]
: ([
{
type: "text",
text: JSON.stringify(
{
status: params.result.isError === true ? "error" : "ok",
server: params.serverName,
tool: params.toolName,
},
null,
2,
),
},
] as AgentToolResult<unknown>["content"]);
const details: Record<string, unknown> = {
mcpServer: params.serverName,
mcpTool: params.toolName,
};
if (params.result.structuredContent !== undefined) {
details.structuredContent = params.result.structuredContent;
}
if (params.result.isError === true) {
details.status = "error";
}
return {
content: normalizedContent,
details,
};
}
function attachStderrLogging(serverName: string, transport: StdioClientTransport) {
const stderr = transport.stderr;
if (!stderr || typeof stderr.on !== "function") {
return undefined;
}
const onData = (chunk: Buffer | string) => {
const message = String(chunk).trim();
if (!message) {
return;
}
for (const line of message.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed) {
logDebug(`bundle-mcp:${serverName}: ${trimmed}`);
}
}
};
stderr.on("data", onData);
return () => {
if (typeof stderr.off === "function") {
stderr.off("data", onData);
} else if (typeof stderr.removeListener === "function") {
stderr.removeListener("data", onData);
}
};
}
async function disposeSession(session: BundleMcpSession) {
session.detachStderr?.();
await session.client.close().catch(() => {});
await session.transport.close().catch(() => {});
}
export async function createBundleMcpToolRuntime(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
reservedToolNames?: Iterable<string>;
}): Promise<BundleMcpToolRuntime> {
const loaded = loadEnabledBundleMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of loaded.diagnostics) {
logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
const reservedNames = new Set(
Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean),
);
const sessions: BundleMcpSession[] = [];
const tools: AnyAgentTool[] = [];
try {
for (const [serverName, rawServer] of Object.entries(loaded.config.mcpServers)) {
const launchConfig = resolveLaunchConfig(rawServer);
if (!launchConfig) {
logWarn(`bundle-mcp: skipped server "${serverName}" because its command is missing.`);
continue;
}
const transport = new StdioClientTransport({
command: launchConfig.command,
args: launchConfig.args,
env: launchConfig.env,
cwd: launchConfig.cwd,
stderr: "pipe",
});
const client = new Client(
{
name: "openclaw-bundle-mcp",
version: "0.0.0",
},
{},
);
const session: BundleMcpSession = {
serverName,
client,
transport,
detachStderr: attachStderrLogging(serverName, transport),
};
try {
await client.connect(transport);
const listedTools = await listAllTools(client);
sessions.push(session);
for (const tool of listedTools) {
const normalizedName = tool.name.trim().toLowerCase();
if (!normalizedName) {
continue;
}
if (reservedNames.has(normalizedName)) {
logWarn(
`bundle-mcp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`,
);
continue;
}
reservedNames.add(normalizedName);
tools.push({
name: tool.name,
label: tool.title ?? tool.name,
description:
tool.description?.trim() ||
`Provided by bundle MCP server "${serverName}" (${describeServerLaunchConfig(launchConfig)}).`,
parameters: tool.inputSchema,
execute: async (_toolCallId, input) => {
const result = (await client.callTool({
name: tool.name,
arguments: isRecord(input) ? input : {},
})) as CallToolResult;
return toAgentToolResult({
serverName,
toolName: tool.name,
result,
});
},
});
}
} catch (error) {
logWarn(
`bundle-mcp: failed to start server "${serverName}" (${describeServerLaunchConfig(launchConfig)}): ${String(error)}`,
);
await disposeSession(session);
}
}
return {
tools,
dispose: async () => {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
},
};
} catch (error) {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
throw error;
}
}

View File

@ -0,0 +1,302 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import "./test-helpers/fast-coding-tools.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
cleanupEmbeddedPiRunnerTestWorkspace,
createEmbeddedPiRunnerOpenAiConfig,
createEmbeddedPiRunnerTestWorkspace,
type EmbeddedPiRunnerTestWorkspace,
immediateEnqueue,
} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js";
const E2E_TIMEOUT_MS = 20_000;
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
function createMockUsage(input: number, output: number) {
return {
input,
output,
cacheRead: 0,
cacheWrite: 0,
totalTokens: input + output,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
}
let streamCallCount = 0;
let observedContexts: Array<Array<{ role?: string; content?: unknown }>> = [];
async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
vi.mock("@mariozechner/pi-coding-agent", async () => {
return await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
"@mariozechner/pi-coding-agent",
);
});
vi.mock("@mariozechner/pi-ai", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
const buildToolUseMessage = (model: { api: string; provider: string; id: string }) => ({
role: "assistant" as const,
content: [
{
type: "toolCall" as const,
id: "tc-bundle-mcp-1",
name: "bundle_probe",
arguments: {},
},
],
stopReason: "toolUse" as const,
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 1),
timestamp: Date.now(),
});
const buildStopMessage = (
model: { api: string; provider: string; id: string },
text: string,
) => ({
role: "assistant" as const,
content: [{ type: "text" as const, text }],
stopReason: "stop" as const,
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 1),
timestamp: Date.now(),
});
return {
...actual,
complete: async (model: { api: string; provider: string; id: string }) => {
streamCallCount += 1;
return streamCallCount === 1
? buildToolUseMessage(model)
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
},
completeSimple: async (model: { api: string; provider: string; id: string }) => {
streamCallCount += 1;
return streamCallCount === 1
? buildToolUseMessage(model)
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
},
streamSimple: (
model: { api: string; provider: string; id: string },
context: { messages?: Array<{ role?: string; content?: unknown }> },
) => {
streamCallCount += 1;
const messages = (context.messages ?? []).map((message) => ({ ...message }));
observedContexts.push(messages);
const stream = actual.createAssistantMessageEventStream();
queueMicrotask(() => {
if (streamCallCount === 1) {
stream.push({
type: "done",
reason: "toolUse",
message: buildToolUseMessage(model),
});
stream.end();
return;
}
const toolResultText = messages.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
const sawBundleResult = toolResultText.some((text) => text.includes("FROM-BUNDLE"));
if (!sawBundleResult) {
stream.push({
type: "done",
reason: "error",
message: {
role: "assistant" as const,
content: [],
stopReason: "error" as const,
errorMessage: "bundle MCP tool result missing from context",
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 0),
timestamp: Date.now(),
},
});
stream.end();
return;
}
stream.push({
type: "done",
reason: "stop",
message: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE"),
});
stream.end();
});
return stream;
},
};
});
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
let e2eWorkspace: EmbeddedPiRunnerTestWorkspace | undefined;
let agentDir: string;
let workspaceDir: string;
beforeAll(async () => {
vi.useRealTimers();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
e2eWorkspace = await createEmbeddedPiRunnerTestWorkspace("openclaw-bundle-mcp-pi-");
({ agentDir, workspaceDir } = e2eWorkspace);
}, 180_000);
afterAll(async () => {
await cleanupEmbeddedPiRunnerTestWorkspace(e2eWorkspace);
e2eWorkspace = undefined;
});
const readSessionMessages = async (sessionFile: string) => {
const raw = await fs.readFile(sessionFile, "utf-8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map(
(line) =>
JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } },
)
.filter((entry) => entry.type === "message")
.map((entry) => entry.message) as Array<{ role?: string; content?: unknown }>;
};
describe("runEmbeddedPiAgent bundle MCP e2e", () => {
it(
"loads bundle MCP into Pi, executes the MCP tool, and includes the result in the follow-up turn",
{ timeout: E2E_TIMEOUT_MS },
async () => {
streamCallCount = 0;
observedContexts = [];
const sessionFile = path.join(workspaceDir, "session-bundle-mcp-e2e.jsonl");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const cfg = {
...createEmbeddedPiRunnerOpenAiConfig(["mock-bundle-mcp"]),
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
};
const result = await runEmbeddedPiAgent({
sessionId: "bundle-mcp-e2e",
sessionKey: "agent:test:bundle-mcp-e2e",
sessionFile,
workspaceDir,
config: cfg,
prompt: "Use the bundle MCP tool and report its result.",
provider: "openai",
model: "mock-bundle-mcp",
timeoutMs: 10_000,
agentDir,
runId: "run-bundle-mcp-e2e",
enqueue: immediateEnqueue,
});
expect(result.meta.stopReason).toBe("stop");
expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE");
expect(streamCallCount).toBe(2);
const followUpContext = observedContexts[1] ?? [];
const followUpTexts = followUpContext.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
expect(followUpTexts.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
const messages = await readSessionMessages(sessionFile);
const toolResults = messages.filter((message) => message?.role === "toolResult");
const toolResultText = toolResults.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
expect(toolResultText.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
},
);
});

View File

@ -51,6 +51,7 @@ import { supportsModelTools } from "../model-tool-support.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import { createConfiguredOllamaStreamFn } from "../ollama-stream.js";
import { resolveOwnerDisplaySetting } from "../owner-display.js";
import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js";
import {
ensureSessionHeader,
validateAnthropicTurns,
@ -577,12 +578,24 @@ export async function compactEmbeddedPiSessionDirect(
modelContextWindowTokens: ctxInfo.tokens,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const toolsEnabled = supportsModelTools(runtimeModel);
const tools = sanitizeToolsForGoogle({
tools: supportsModelTools(runtimeModel) ? toolsRaw : [],
tools: toolsEnabled ? toolsRaw : [],
provider,
});
const allowedToolNames = collectAllowedToolNames({ tools });
logToolSchemasForGoogle({ tools, provider });
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: tools.map((tool) => tool.name),
})
: undefined;
const effectiveTools =
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
? [...tools, ...bundleMcpRuntime.tools]
: tools;
const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
logToolSchemasForGoogle({ tools: effectiveTools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
let runtimeCapabilities = runtimeChannel
@ -699,7 +712,7 @@ export async function compactEmbeddedPiSessionDirect(
reactionGuidance,
messageToolHints,
sandboxInfo,
tools,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
@ -762,7 +775,7 @@ export async function compactEmbeddedPiSessionDirect(
}
const { builtInTools, customTools } = splitSdkTools({
tools,
tools: effectiveTools,
sandboxEnabled: !!sandbox?.enabled,
});
@ -1054,6 +1067,7 @@ export async function compactEmbeddedPiSessionDirect(
clearPendingOnTimeout: true,
});
session.dispose();
await bundleMcpRuntime?.dispose();
}
} finally {
await sessionLock.release();

View File

@ -57,6 +57,7 @@ import { supportsModelTools } from "../../model-tool-support.js";
import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js";
import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js";
import { resolveOwnerDisplaySetting } from "../../owner-display.js";
import { createBundleMcpToolRuntime } from "../../pi-bundle-mcp-tools.js";
import {
downgradeOpenAIFunctionCallReasoningPairs,
isCloudCodeAssistFormatError,
@ -1544,11 +1545,25 @@ export async function runEmbeddedAttempt(
provider: params.provider,
});
const clientTools = toolsEnabled ? params.clientTools : undefined;
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: [
...tools.map((tool) => tool.name),
...(clientTools?.map((tool) => tool.function.name) ?? []),
],
})
: undefined;
const effectiveTools =
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
? [...tools, ...bundleMcpRuntime.tools]
: tools;
const allowedToolNames = collectAllowedToolNames({
tools,
tools: effectiveTools,
clientTools,
});
logToolSchemasForGoogle({ tools, provider: params.provider });
logToolSchemasForGoogle({ tools: effectiveTools, provider: params.provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
@ -1670,7 +1685,7 @@ export async function runEmbeddedAttempt(
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
@ -1705,7 +1720,7 @@ export async function runEmbeddedAttempt(
bootstrapFiles: hookAdjustedBootstrapFiles,
injectedFiles: contextFiles,
skillsPrompt,
tools,
tools: effectiveTools,
});
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
let systemPromptText = systemPromptOverride();
@ -1805,7 +1820,7 @@ export async function runEmbeddedAttempt(
const hookRunner = getGlobalHookRunner();
const { builtInTools, customTools } = splitSdkTools({
tools,
tools: effectiveTools,
sandboxEnabled: !!sandbox?.enabled,
});
@ -2865,6 +2880,7 @@ export async function runEmbeddedAttempt(
});
session?.dispose();
releaseWsSession(params.sessionId);
await bundleMcpRuntime?.dispose();
await sessionLock.release();
}
} finally {

View File

@ -79,6 +79,54 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => {
expect(snapshot.compaction?.keepRecentTokens).toBe(64_000);
});
it("loads enabled bundle MCP servers into the Pi settings snapshot", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginRoot, "servers"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "claude-bundle",
}),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, ".mcp.json"),
JSON.stringify({
mcpServers: {
bundleProbe: {
command: "node",
args: ["./servers/probe.mjs"],
},
},
}),
"utf-8",
);
hoisted.loadPluginManifestRegistry.mockReturnValue(
buildRegistry({ pluginRoot, settingsFiles: [] }),
);
const snapshot = loadEnabledBundlePiSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.mcpServers).toEqual({
bundleProbe: {
command: "node",
args: [path.join(pluginRoot, "servers", "probe.mjs")],
cwd: pluginRoot,
},
});
});
it("ignores disabled bundle plugins", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");

View File

@ -93,4 +93,34 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
expect(snapshot.compaction?.reserveTokens).toBe(32_000);
expect(snapshot.hideThinkingBlock).toBe(true);
});
it("lets project Pi settings override bundle MCP defaults", () => {
const snapshot = buildEmbeddedPiSettingsSnapshot({
globalSettings,
pluginSettings: {
mcpServers: {
bundleProbe: {
command: "node",
args: ["/plugins/probe.mjs"],
},
},
},
projectSettings: {
mcpServers: {
bundleProbe: {
command: "deno",
args: ["/workspace/probe.ts"],
},
},
},
policy: "sanitize",
});
expect(snapshot.mcpServers).toEqual({
bundleProbe: {
command: "deno",
args: ["/workspace/probe.ts"],
},
});
});
});

View File

@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isRecord } from "../utils.js";
@ -107,6 +108,19 @@ export function loadEnabledBundlePiSettingsSnapshot(params: {
}
}
const bundleMcp = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of bundleMcp.diagnostics) {
log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
if (Object.keys(bundleMcp.config.mcpServers).length > 0) {
snapshot = applyMergePatch(snapshot, {
mcpServers: bundleMcp.config.mcpServers,
}) as PiSettingsSnapshot;
}
return snapshot;
}

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

@ -73,10 +73,12 @@ describe("loadEnabledBundleMcpConfig", () => {
cfg: config,
});
const resolvedServerPath = await fs.realpath(serverPath);
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
expect(loaded.config.mcpServers.bundleProbe?.command).toBe("node");
expect(loaded.config.mcpServers.bundleProbe?.args).toEqual([resolvedServerPath]);
expect(loaded.config.mcpServers.bundleProbe?.cwd).toBe(resolvedPluginRoot);
} finally {
env.restore();
}

View File

@ -137,6 +137,10 @@ function absolutizeBundleMcpServer(params: {
}): BundleMcpServerConfig {
const next: BundleMcpServerConfig = { ...params.server };
if (typeof next.cwd !== "string" && typeof next.workingDirectory !== "string") {
next.cwd = params.baseDir;
}
const command = next.command;
if (typeof command === "string" && isExplicitRelativePath(command)) {
next.command = path.resolve(params.baseDir, command);

View File

@ -426,6 +426,57 @@ describe("bundle plugins", () => {
).toBe(false);
});
it("treats bundle MCP as a supported bundle surface", () => {
const workspaceDir = makeTempDir();
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp");
mkdirSafe(path.join(bundleRoot, ".claude-plugin"));
fs.writeFileSync(
path.join(bundleRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "Claude MCP",
}),
"utf-8",
);
fs.writeFileSync(
path.join(bundleRoot, ".mcp.json"),
JSON.stringify({
mcpServers: {
probe: {
command: "node",
args: ["./probe.mjs"],
},
},
}),
"utf-8",
);
const registry = loadOpenClawPlugins({
workspaceDir,
config: {
plugins: {
entries: {
"claude-mcp": {
enabled: true,
},
},
},
},
cache: false,
});
const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp");
expect(plugin?.status).toBe("loaded");
expect(plugin?.bundleFormat).toBe("claude");
expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"]));
expect(
registry.diagnostics.some(
(diag) =>
diag.pluginId === "claude-mcp" &&
diag.message.includes("bundle capability detected but not wired"),
),
).toBe(false);
});
it("treats Cursor command roots as supported bundle skill surfaces", () => {
const workspaceDir = makeTempDir();
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "cursor-skills");

View File

@ -1060,6 +1060,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
(capability) =>
capability !== "skills" &&
capability !== "mcpServers" &&
capability !== "settings" &&
!(
capability === "commands" &&

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";