diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index de6244db63d..cbe1f899ad8 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -52,6 +52,9 @@ openclaw plugins uninstall openclaw plugins doctor openclaw plugins update openclaw plugins update --all +openclaw plugins marketplace entries +openclaw plugins marketplace entries --offline +openclaw plugins marketplace entries --json openclaw plugins marketplace list openclaw plugins marketplace list --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 +openclaw plugins marketplace entries --feed-url openclaw plugins marketplace list openclaw plugins marketplace list --json openclaw plugins marketplace refresh @@ -532,7 +540,11 @@ openclaw plugins marketplace refresh --feed-url openclaw plugins marketplace refresh --expected-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 ` to read a specific configured profile, `--feed-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 diff --git a/src/cli/plugins-cli.lazy.test.ts b/src/cli/plugins-cli.lazy.test.ts index deb8cfde6b7..ce511424a5b 100644 --- a/src/cli/plugins-cli.lazy.test.ts +++ b/src/cli/plugins-cli.lazy.test.ts @@ -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(), diff --git a/src/cli/plugins-cli.marketplace-entries.test.ts b/src/cli/plugins-cli.marketplace-entries.test.ts new file mode 100644 index 00000000000..cbc4f879430 --- /dev/null +++ b/src/cli/plugins-cli.marketplace-entries.test.ts @@ -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(); + 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(); + }); +}); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 7ffbe28ebef..58f0f1f198c 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -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 { + 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, diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 5db79183ca3..e8f5e2e1e4f 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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 ", "Configured marketplace feed profile to list") + .option("--feed-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")