Compare commits
6 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa130315b1 | ||
|
|
3c9fb835de | ||
|
|
3effdf98f7 | ||
|
|
0cc27bf2e5 | ||
|
|
8241311947 | ||
|
|
45fb57802c |
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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 . .
|
||||
|
||||
@ -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"
|
||||
|
||||
141
src/agents/pi-bundle-mcp-tools.test.ts
Normal file
141
src/agents/pi-bundle-mcp-tools.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
278
src/agents/pi-bundle-mcp-tools.ts
Normal file
278
src/agents/pi-bundle-mcp-tools.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
302
src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts
Normal file
302
src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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-");
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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).",
|
||||
|
||||
@ -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",
|
||||
};
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -1060,6 +1060,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
|
||||
(capability) =>
|
||||
capability !== "skills" &&
|
||||
capability !== "mcpServers" &&
|
||||
capability !== "settings" &&
|
||||
!(
|
||||
capability === "commands" &&
|
||||
|
||||
141
src/plugins/marketplace.test.ts
Normal file
141
src/plugins/marketplace.test.ts
Normal 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
832
src/plugins/marketplace.ts
Normal 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?.();
|
||||
}
|
||||
}
|
||||
@ -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", () => {
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user