Add marketplace feed entries command (#96158)

Merged via squash.

Prepared head SHA: 1289a25156
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
This commit is contained in:
Gio Della-Libera
2026-06-28 14:11:29 -07:00
committed by GitHub
parent d1d0176acc
commit 9bb004359e
5 changed files with 362 additions and 1 deletions
+13 -1
View File
@@ -52,6 +52,9 @@ openclaw plugins uninstall <id>
openclaw plugins doctor
openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins marketplace entries
openclaw plugins marketplace entries --offline
openclaw plugins marketplace entries --json
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --json
openclaw plugins marketplace refresh
@@ -524,6 +527,11 @@ Use `plugins registry` to inspect whether the persisted registry is present, cur
### Marketplace
```bash
openclaw plugins marketplace entries
openclaw plugins marketplace entries --offline
openclaw plugins marketplace entries --json
openclaw plugins marketplace entries --feed-profile <name>
openclaw plugins marketplace entries --feed-url <url>
openclaw plugins marketplace list <source>
openclaw plugins marketplace list <source> --json
openclaw plugins marketplace refresh
@@ -532,7 +540,11 @@ openclaw plugins marketplace refresh --feed-url <url>
openclaw plugins marketplace refresh --expected-sha256 <sha256> --json
```
Marketplace list accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries.
`plugins marketplace entries` lists entries from the configured OpenClaw marketplace feed. By default it attempts the hosted feed and falls back to the latest accepted snapshot or bundled data. Use `--feed-profile <name>` to read a specific configured profile, `--feed-url <url>` to read an explicit hosted feed URL, and `--offline` to read the latest accepted snapshot without fetching the feed.
`plugins marketplace refresh` refreshes the configured hosted feed snapshot and reports whether OpenClaw accepted hosted data, a hosted snapshot, or bundled fallback data. Use `--expected-sha256` when a caller needs the command to fail unless a fresh hosted payload matches a pinned checksum.
Marketplace `list` accepts a local marketplace path, a `marketplace.json` path, a GitHub shorthand like `owner/repo`, a GitHub repo URL, or a git URL. `--json` prints the resolved source label plus the parsed marketplace manifest and plugin entries.
Marketplace refresh loads a hosted OpenClaw marketplace feed and persists the
validated response as the local hosted-feed snapshot. Without options, it uses
+30
View File
@@ -17,6 +17,7 @@ describe("plugins cli lazy runtime boundary", () => {
vi.doMock("./plugins-cli.runtime.js", () => {
runtimeLoaded();
return {
runPluginMarketplaceEntriesCommand: vi.fn(),
runPluginMarketplaceListCommand: vi.fn(),
runPluginMarketplaceRefreshCommand: vi.fn(),
runPluginsDisableCommand: vi.fn(),
@@ -50,6 +51,7 @@ describe("plugins cli lazy runtime boundary", () => {
vi.doMock("./plugins-cli.runtime.js", () => {
runtimeLoaded();
return {
runPluginMarketplaceEntriesCommand: vi.fn(),
runPluginMarketplaceListCommand: vi.fn(),
runPluginMarketplaceRefreshCommand: vi.fn(),
runPluginsDisableCommand: vi.fn(),
@@ -70,9 +72,37 @@ describe("plugins cli lazy runtime boundary", () => {
expect(runPluginsRegistryCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
});
it("loads the plugins runtime for marketplace entries", async () => {
const runPluginMarketplaceEntriesCommand = vi.fn().mockResolvedValue(undefined);
vi.doMock("./plugins-cli.runtime.js", () => ({
runPluginMarketplaceEntriesCommand,
runPluginMarketplaceListCommand: vi.fn(),
runPluginMarketplaceRefreshCommand: vi.fn(),
runPluginsDisableCommand: vi.fn(),
runPluginsDoctorCommand: vi.fn(),
runPluginsEnableCommand: vi.fn(),
runPluginsInstallAction: vi.fn(),
runPluginsRegistryCommand: vi.fn(),
}));
const { registerPluginsCli } = await import("./plugins-cli.js");
const program = new Command();
registerPluginsCli(program);
await program.parseAsync(
["plugins", "marketplace", "entries", "--feed-profile", "acme", "--offline", "--json"],
{ from: "user" },
);
expect(runPluginMarketplaceEntriesCommand).toHaveBeenCalledWith(
expect.objectContaining({ feedProfile: "acme", offline: true, json: true }),
);
});
it("loads the plugins runtime for marketplace refresh", async () => {
const runPluginMarketplaceRefreshCommand = vi.fn().mockResolvedValue(undefined);
vi.doMock("./plugins-cli.runtime.js", () => ({
runPluginMarketplaceEntriesCommand: vi.fn(),
runPluginMarketplaceListCommand: vi.fn(),
runPluginMarketplaceRefreshCommand,
runPluginsDisableCommand: vi.fn(),
@@ -0,0 +1,194 @@
// Covers the hosted OpenClaw marketplace feed entries command.
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const defaultRuntime = {
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}),
log: vi.fn(),
writeJson: vi.fn(),
};
return {
defaultRuntime,
getRuntimeConfig: vi.fn(),
loadConfiguredHostedOfficialExternalPluginCatalogEntries: vi.fn(),
};
});
vi.mock("../config/config.js", () => ({
assertConfigWriteAllowedInCurrentMode: vi.fn(),
getRuntimeConfig: mocks.getRuntimeConfig,
readConfigFileSnapshot: vi.fn(),
replaceConfigFile: vi.fn(),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.defaultRuntime,
}));
vi.mock("../plugins/official-external-plugin-catalog.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../plugins/official-external-plugin-catalog.js")>();
return {
...actual,
loadConfiguredHostedOfficialExternalPluginCatalogEntries:
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries,
};
});
describe("plugins marketplace entries", () => {
beforeEach(() => {
mocks.defaultRuntime.error.mockClear();
mocks.defaultRuntime.exit.mockClear();
mocks.defaultRuntime.log.mockClear();
mocks.defaultRuntime.writeJson.mockClear();
mocks.getRuntimeConfig.mockReset();
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockReset();
});
it("lists entries from the configured marketplace feed as JSON", async () => {
const config = {
marketplaces: {
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
sources: { "acme-npm": { type: "npm" as const } },
},
};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted-snapshot",
entries: [
{
name: "@acme/calendar",
version: "1.2.3",
kind: "plugin",
state: "available",
publisher: { trust: "official" },
install: {
candidates: [{ sourceRef: "acme-npm", package: "@acme/calendar", version: "1.2.3" }],
},
openclaw: {
plugin: { id: "acme-calendar", label: "Acme Calendar" },
},
},
],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
},
snapshot: {
body: "{}",
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
},
savedAt: "2026-06-23T01:02:03.000Z",
},
error: "hosted catalog feed offline mode",
});
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceEntriesCommand({ feedProfile: "acme", offline: true, json: true });
expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith(
config,
{ feedProfile: "acme", offline: true },
);
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
source: "hosted-snapshot",
entryCount: 1,
entries: [
expect.objectContaining({
id: "acme-calendar",
label: "Acme Calendar",
name: "@acme/calendar",
version: "1.2.3",
install: expect.objectContaining({ npmSpec: "@acme/calendar@1.2.3" }),
}),
],
}),
);
});
it("redacts query-bearing feed URLs from entries output", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "bundled-fallback",
entries: [],
error:
"hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
metadata: {
url: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
status: 503,
},
});
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceEntriesCommand({
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
json: true,
});
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ url: "https://clawhub.ai/v1/feeds/plugins" }),
error: "hosted catalog feed fetch failed for https://clawhub.ai/v1/feeds/plugins",
}),
);
mocks.defaultRuntime.writeJson.mockClear();
mocks.defaultRuntime.log.mockClear();
await runPluginMarketplaceEntriesCommand({
feedUrl: "https://clawhub.ai/v1/feeds/plugins?token=secret#frag",
});
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
expect(output).toContain("https://clawhub.ai/v1/feeds/plugins");
expect(output).not.toContain("token=secret");
expect(output).not.toContain("#frag");
});
it("prints bundled fallback entries without failing", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "bundled-fallback",
entries: [
{
name: "@openclaw/acpx",
openclaw: {
plugin: { id: "acpx", label: "ACP" },
install: {
clawhubSpec: "clawhub:@openclaw/acpx",
npmSpec: "@openclaw/acpx",
defaultChoice: "npm",
},
},
},
],
error: "hosted catalog feed offline mode",
});
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceEntriesCommand({ offline: true });
const output = mocks.defaultRuntime.log.mock.calls.map(([value]) => String(value)).join("\n");
expect(output).toContain("bundled fallback");
expect(output).toContain("acpx");
expect(output).toContain("@openclaw/acpx");
expect(output).not.toContain("clawhub:@openclaw/acpx");
expect(output).toContain("hosted catalog feed offline mode");
expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled();
});
});
+106
View File
@@ -18,6 +18,7 @@ import { defaultRuntime } from "../runtime.js";
import { shortenHomeInString } from "../utils.js";
import { formatMissingPluginMessage } from "./error-format.js";
import type {
PluginMarketplaceEntriesOptions,
PluginMarketplaceListOptions,
PluginMarketplaceRefreshOptions,
PluginRegistryOptions,
@@ -468,6 +469,22 @@ type MarketplaceRefreshPayload = {
error?: string;
};
type MarketplaceEntryPayload = {
id?: string;
label: string;
kind?: string;
name?: string;
version?: string;
install?: {
defaultChoice?: string;
clawhubSpec?: string;
npmSpec?: string;
localPath?: string;
expectedIntegrity?: string;
minHostVersion?: string;
};
};
function buildMarketplaceRefreshPayload(
result: Awaited<
ReturnType<
@@ -545,6 +562,21 @@ function sanitizeMarketplaceRefreshPayload(
return sanitized;
}
function formatMarketplaceEntryInstall(entry: MarketplaceEntryPayload): string | undefined {
if (entry.install?.defaultChoice === "npm") {
return entry.install.npmSpec ?? entry.install.clawhubSpec ?? entry.install.localPath;
}
return entry.install?.clawhubSpec ?? entry.install?.npmSpec ?? entry.install?.localPath;
}
function formatMarketplaceEntryLine(entry: MarketplaceEntryPayload): string {
const id = entry.id ?? entry.name ?? entry.label;
const install = formatMarketplaceEntryInstall(entry);
const suffix = install ? " " + theme.muted(install) : "";
const label = entry.label !== id ? " " + theme.muted(entry.label) : "";
return theme.command(id) + label + suffix;
}
function formatMarketplaceRefreshSource(source: MarketplaceRefreshPayload["source"]): string {
if (source === "hosted") {
return theme.success("hosted");
@@ -581,6 +613,80 @@ function formatPinnedMarketplaceRefreshFailure(payload: MarketplaceRefreshPayloa
return `Pinned marketplace feed refresh did not accept a fresh hosted payload (source: ${payload.source}).`;
}
/** List entries from the configured OpenClaw marketplace feed. */
export async function runPluginMarketplaceEntriesCommand(
opts: PluginMarketplaceEntriesOptions,
): Promise<void> {
const catalog = await import("../plugins/official-external-plugin-catalog.js");
const cfg = getRuntimeConfig();
const result = await catalog.loadConfiguredHostedOfficialExternalPluginCatalogEntries(cfg, {
...(opts.feedProfile ? { feedProfile: opts.feedProfile } : {}),
...(opts.feedUrl ? { feedUrl: opts.feedUrl } : {}),
...(opts.offline ? { offline: true } : {}),
});
const summary = sanitizeMarketplaceRefreshPayload(buildMarketplaceRefreshPayload(result), {
feedUrl: opts.feedUrl,
});
const entries: MarketplaceEntryPayload[] = result.entries.map((entry) => {
const id = catalog.resolveOfficialExternalPluginId(entry);
const install =
catalog.resolveOfficialExternalPluginInstall(entry, { catalogConfig: cfg.marketplaces }) ??
undefined;
const payload: MarketplaceEntryPayload = {
label: catalog.resolveOfficialExternalPluginLabel(entry),
};
if (id) {
payload.id = id;
}
if (entry.kind) {
payload.kind = entry.kind;
}
if (entry.name) {
payload.name = entry.name;
}
if (entry.version) {
payload.version = entry.version;
}
if (install) {
payload.install = install;
}
return payload;
});
if (opts.json) {
defaultRuntime.writeJson({ ...summary, entries, entryCount: entries.length });
return;
}
const lines = [
theme.muted("Source:") + " " + formatMarketplaceRefreshSource(summary.source),
theme.muted("Entries:") + " " + String(entries.length),
];
if (summary.feed) {
lines.push(
theme.muted("Feed:") +
" " +
summary.feed.id +
" " +
theme.muted("sequence " + String(summary.feed.sequence)),
);
}
if (summary.metadata?.url) {
lines.push(theme.muted("URL:") + " " + summary.metadata.url);
}
if (summary.snapshot?.savedAt) {
lines.push(theme.muted("Snapshot:") + " " + summary.snapshot.savedAt);
}
if (summary.error) {
lines.push(theme.muted("Fallback reason:") + " " + summary.error);
}
if (entries.length > 0) {
lines.push("");
lines.push(...entries.map(formatMarketplaceEntryLine));
}
defaultRuntime.log(lines.join("\n"));
}
/** Refresh the configured OpenClaw marketplace feed snapshot. */
export async function runPluginMarketplaceRefreshCommand(
opts: PluginMarketplaceRefreshOptions,
+19
View File
@@ -26,6 +26,13 @@ export type PluginMarketplaceListOptions = {
json?: boolean;
};
export type PluginMarketplaceEntriesOptions = {
feedProfile?: string;
feedUrl?: string;
json?: boolean;
offline?: boolean;
};
export type PluginMarketplaceRefreshOptions = {
expectedSha256?: string;
feedProfile?: string;
@@ -283,6 +290,18 @@ export function registerPluginsCli(program: Command) {
.command("marketplace")
.description("Inspect Claude-compatible plugin marketplaces");
marketplace
.command("entries")
.description("List entries from the configured OpenClaw marketplace feed")
.option("--feed-profile <name>", "Configured marketplace feed profile to list")
.option("--feed-url <url>", "Explicit hosted marketplace feed URL")
.option("--offline", "Read the latest accepted snapshot without fetching the feed", false)
.option("--json", "Print JSON")
.action(async (opts: PluginMarketplaceEntriesOptions) => {
const { runPluginMarketplaceEntriesCommand } = await loadPluginsRuntime();
await runPluginMarketplaceEntriesCommand(opts);
});
marketplace
.command("refresh")
.description("Refresh the configured OpenClaw marketplace feed snapshot")