mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(onboarding): surface official plugin installs
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc.
|
||||
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
|
||||
- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists.
|
||||
- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.
|
||||
|
||||
@@ -300,6 +300,24 @@ describe("codex provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a setup auth choice for installing Codex as an external provider", async () => {
|
||||
const provider = buildCodexProvider();
|
||||
|
||||
expect(provider.auth[0]).toMatchObject({
|
||||
id: "app-server",
|
||||
kind: "custom",
|
||||
wizard: {
|
||||
choiceId: "codex",
|
||||
choiceLabel: "Codex app-server",
|
||||
onboardingScopes: ["text-inference"],
|
||||
},
|
||||
});
|
||||
await expect(provider.auth[0].run({} as never)).resolves.toMatchObject({
|
||||
profiles: [],
|
||||
defaultModel: "codex/gpt-5.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a lightweight provider-discovery entry for model list/status", async () => {
|
||||
expect(codexProviderDiscovery.id).toBe("codex");
|
||||
expect(codexProviderDiscovery.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
|
||||
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
|
||||
const MODEL_DISCOVERY_PAGE_LIMIT = 100;
|
||||
const CODEX_APP_SERVER_SETUP_METHOD_ID = "app-server";
|
||||
const CODEX_DEFAULT_MODEL_REF = `${CODEX_PROVIDER_ID}/${FALLBACK_CODEX_MODELS[0].id}`;
|
||||
const codexCatalogLog = createSubsystemLogger("codex/catalog");
|
||||
|
||||
type CodexModelLister = (options: {
|
||||
@@ -55,7 +57,25 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
|
||||
id: CODEX_PROVIDER_ID,
|
||||
label: "Codex",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
auth: [
|
||||
{
|
||||
id: CODEX_APP_SERVER_SETUP_METHOD_ID,
|
||||
label: "Codex app-server",
|
||||
hint: "Use the Codex app-server runtime and managed model catalog.",
|
||||
kind: "custom",
|
||||
wizard: {
|
||||
choiceId: CODEX_PROVIDER_ID,
|
||||
choiceLabel: "Codex app-server",
|
||||
choiceHint: "Use the Codex app-server runtime and managed model catalog.",
|
||||
assistantPriority: -40,
|
||||
groupId: CODEX_PROVIDER_ID,
|
||||
groupLabel: "Codex",
|
||||
groupHint: "Codex app-server model provider",
|
||||
onboardingScopes: ["text-inference"],
|
||||
},
|
||||
run: async () => ({ profiles: [], defaultModel: CODEX_DEFAULT_MODEL_REF }),
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
order: "late",
|
||||
run: async (ctx) => {
|
||||
|
||||
@@ -16,7 +16,19 @@
|
||||
"name": "Codex",
|
||||
"docs": "/providers/models",
|
||||
"categories": ["cloud", "llm"],
|
||||
"authChoices": []
|
||||
"authChoices": [
|
||||
{
|
||||
"method": "app-server",
|
||||
"choiceId": "codex",
|
||||
"choiceLabel": "Codex app-server",
|
||||
"choiceHint": "Use the Codex app-server runtime and managed model catalog.",
|
||||
"assistantPriority": -40,
|
||||
"groupId": "codex",
|
||||
"groupLabel": "Codex",
|
||||
"groupHint": "Codex app-server model provider",
|
||||
"onboardingScopes": ["text-inference"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"install": {
|
||||
|
||||
@@ -5,6 +5,8 @@ type LoadOpenClawProviderIndex =
|
||||
type LoadPluginRegistrySnapshot = typeof import("./plugin-registry.js").loadPluginRegistrySnapshot;
|
||||
type ResolveManifestProviderAuthChoices =
|
||||
typeof import("./provider-auth-choices.js").resolveManifestProviderAuthChoices;
|
||||
type ListOfficialExternalProviderCatalogEntries =
|
||||
typeof import("./official-external-plugin-catalog.js").listOfficialExternalProviderCatalogEntries;
|
||||
|
||||
const loadOpenClawProviderIndex = vi.hoisted(() =>
|
||||
vi.fn<LoadOpenClawProviderIndex>(() => ({ version: 1, providers: {} })),
|
||||
@@ -43,6 +45,19 @@ vi.mock("./provider-auth-choices.js", () => ({
|
||||
resolveManifestProviderAuthChoices,
|
||||
}));
|
||||
|
||||
const listOfficialExternalProviderCatalogEntries = vi.hoisted(() =>
|
||||
vi.fn<ListOfficialExternalProviderCatalogEntries>(() => []),
|
||||
);
|
||||
vi.mock("./official-external-plugin-catalog.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./official-external-plugin-catalog.js")>(
|
||||
"./official-external-plugin-catalog.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listOfficialExternalProviderCatalogEntries,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
resolveProviderInstallCatalogEntries,
|
||||
resolveProviderInstallCatalogEntry,
|
||||
@@ -64,6 +79,7 @@ describe("provider install catalog", () => {
|
||||
diagnostics: [],
|
||||
});
|
||||
resolveManifestProviderAuthChoices.mockReturnValue([]);
|
||||
listOfficialExternalProviderCatalogEntries.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("merges manifest auth-choice metadata with registry install metadata", () => {
|
||||
@@ -498,6 +514,57 @@ describe("provider install catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces official external provider install metadata when the provider plugin is not installed", () => {
|
||||
listOfficialExternalProviderCatalogEntries.mockReturnValue([
|
||||
{
|
||||
name: "@openclaw/codex",
|
||||
source: "official",
|
||||
kind: "provider",
|
||||
openclaw: {
|
||||
plugin: { id: "codex", label: "Codex" },
|
||||
providers: [
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
authChoices: [
|
||||
{
|
||||
method: "app-server",
|
||||
choiceId: "codex",
|
||||
choiceLabel: "Codex app-server",
|
||||
choiceHint: "Use the Codex app-server runtime.",
|
||||
groupId: "codex",
|
||||
groupLabel: "Codex",
|
||||
onboardingScopes: ["text-inference"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
install: {
|
||||
npmSpec: "@openclaw/codex",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(resolveProviderInstallCatalogEntry("codex")).toMatchObject({
|
||||
pluginId: "codex",
|
||||
providerId: "codex",
|
||||
methodId: "app-server",
|
||||
choiceId: "codex",
|
||||
choiceLabel: "Codex app-server",
|
||||
choiceHint: "Use the Codex app-server runtime.",
|
||||
groupId: "codex",
|
||||
groupLabel: "Codex",
|
||||
onboardingScopes: ["text-inference"],
|
||||
label: "Codex",
|
||||
install: {
|
||||
npmSpec: "@openclaw/codex",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces provider-index ClawHub install metadata as the preferred source", () => {
|
||||
loadOpenClawProviderIndex.mockReturnValue({
|
||||
version: 1,
|
||||
|
||||
140
src/wizard/setup.official-plugins.test.ts
Normal file
140
src/wizard/setup.official-plugins.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
|
||||
import { createNonExitingRuntime } from "../runtime.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
|
||||
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
|
||||
vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({
|
||||
cfg,
|
||||
installed: true,
|
||||
status: "installed",
|
||||
})),
|
||||
);
|
||||
vi.mock("../commands/onboarding-plugin-install.js", () => ({
|
||||
ensureOnboardingPluginInstalled,
|
||||
}));
|
||||
|
||||
import {
|
||||
__testing,
|
||||
resolveOfficialPluginOnboardingInstallEntries,
|
||||
setupOfficialPluginInstalls,
|
||||
} from "./setup.official-plugins.js";
|
||||
|
||||
describe("resolveOfficialPluginOnboardingInstallEntries", () => {
|
||||
it("lists optional generic official plugins without channel, provider, or search-owned entries", () => {
|
||||
const entries = resolveOfficialPluginOnboardingInstallEntries({ config: {} });
|
||||
const pluginIds = entries.map((entry) => entry.pluginId);
|
||||
|
||||
expect(pluginIds).toContain("diagnostics-otel");
|
||||
expect(pluginIds).toContain("diagnostics-prometheus");
|
||||
expect(pluginIds).toContain("acpx");
|
||||
expect(pluginIds).not.toContain("brave");
|
||||
expect(pluginIds).not.toContain("codex");
|
||||
expect(pluginIds).not.toContain("discord");
|
||||
});
|
||||
|
||||
it("hides already configured official plugins", () => {
|
||||
const entries = resolveOfficialPluginOnboardingInstallEntries({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
acpx: { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
"diagnostics-otel": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/diagnostics-otel",
|
||||
installPath: "/tmp/diagnostics-otel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const pluginIds = entries.map((entry) => entry.pluginId);
|
||||
|
||||
expect(pluginIds).not.toContain("acpx");
|
||||
expect(pluginIds).not.toContain("diagnostics-otel");
|
||||
expect(pluginIds).toContain("diagnostics-prometheus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInstallHint", () => {
|
||||
it("describes dual-source npm-default installs as npm first", () => {
|
||||
expect(
|
||||
__testing.formatInstallHint({
|
||||
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
|
||||
npmSpec: "@openclaw/diagnostics-otel",
|
||||
defaultChoice: "npm",
|
||||
}),
|
||||
).toBe("npm, with ClawHub fallback");
|
||||
});
|
||||
|
||||
it("keeps dual-source clawhub-default installs ClawHub first", () => {
|
||||
expect(
|
||||
__testing.formatInstallHint({
|
||||
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
|
||||
npmSpec: "@openclaw/diagnostics-otel",
|
||||
defaultChoice: "clawhub",
|
||||
}),
|
||||
).toBe("ClawHub, with npm fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupOfficialPluginInstalls", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ensureOnboardingPluginInstalled.mockImplementation(async ({ cfg }) => ({
|
||||
cfg,
|
||||
installed: true,
|
||||
status: "installed",
|
||||
}));
|
||||
});
|
||||
|
||||
it("installs selected optional official plugins through the shared onboarding installer", async () => {
|
||||
const multiselect = vi.fn(async () => ["diagnostics-otel"]);
|
||||
const prompter = createWizardPrompter({
|
||||
multiselect: multiselect as WizardPrompter["multiselect"],
|
||||
});
|
||||
|
||||
await setupOfficialPluginInstalls({
|
||||
config: {},
|
||||
prompter,
|
||||
runtime: createNonExitingRuntime(),
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(multiselect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Install optional plugins",
|
||||
}),
|
||||
);
|
||||
expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entry: expect.objectContaining({
|
||||
pluginId: "diagnostics-otel",
|
||||
install: expect.objectContaining({
|
||||
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
|
||||
npmSpec: "@openclaw/diagnostics-otel",
|
||||
defaultChoice: "npm",
|
||||
}),
|
||||
}),
|
||||
promptInstall: false,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not install when the user skips optional plugins", async () => {
|
||||
const prompter = createWizardPrompter({
|
||||
multiselect: vi.fn(async () => ["__skip__"]) as WizardPrompter["multiselect"],
|
||||
});
|
||||
|
||||
await setupOfficialPluginInstalls({
|
||||
config: {},
|
||||
prompter,
|
||||
runtime: createNonExitingRuntime(),
|
||||
});
|
||||
|
||||
expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
130
src/wizard/setup.official-plugins.ts
Normal file
130
src/wizard/setup.official-plugins.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { ensureOnboardingPluginInstalled } from "../commands/onboarding-plugin-install.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginPackageInstall } from "../plugins/manifest.js";
|
||||
import {
|
||||
getOfficialExternalPluginCatalogManifest,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginInstall,
|
||||
resolveOfficialExternalPluginLabel,
|
||||
} from "../plugins/official-external-plugin-catalog.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
|
||||
const SKIP_VALUE = "__skip__";
|
||||
|
||||
export type OfficialPluginOnboardingInstallEntry = {
|
||||
pluginId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
install: PluginPackageInstall;
|
||||
};
|
||||
|
||||
function isInstalledOrConfigured(config: OpenClawConfig, pluginId: string): boolean {
|
||||
return Boolean(config.plugins?.entries?.[pluginId] || config.plugins?.installs?.[pluginId]);
|
||||
}
|
||||
|
||||
function isGenericOfficialPluginEntry(entry: { source?: string; kind?: string }): boolean {
|
||||
const manifest = getOfficialExternalPluginCatalogManifest(entry);
|
||||
return (
|
||||
entry.source === "official" &&
|
||||
entry.kind === "plugin" &&
|
||||
Boolean(manifest?.plugin?.id) &&
|
||||
!manifest?.channel &&
|
||||
(manifest?.providers?.length ?? 0) === 0 &&
|
||||
(manifest?.webSearchProviders?.length ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function formatInstallHint(install: PluginPackageInstall): string {
|
||||
if (install.clawhubSpec && install.npmSpec) {
|
||||
return install.defaultChoice === "clawhub"
|
||||
? "ClawHub, with npm fallback"
|
||||
: "npm, with ClawHub fallback";
|
||||
}
|
||||
if (install.clawhubSpec) {
|
||||
return "ClawHub";
|
||||
}
|
||||
if (install.npmSpec) {
|
||||
return "npm";
|
||||
}
|
||||
if (install.localPath) {
|
||||
return "local path";
|
||||
}
|
||||
return "install source";
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
formatInstallHint,
|
||||
};
|
||||
|
||||
export function resolveOfficialPluginOnboardingInstallEntries(params: {
|
||||
config: OpenClawConfig;
|
||||
}): OfficialPluginOnboardingInstallEntry[] {
|
||||
const entries: OfficialPluginOnboardingInstallEntry[] = [];
|
||||
for (const entry of listOfficialExternalPluginCatalogEntries()) {
|
||||
if (!isGenericOfficialPluginEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
const pluginId = resolveOfficialExternalPluginId(entry);
|
||||
const install = resolveOfficialExternalPluginInstall(entry);
|
||||
if (!pluginId || !install || isInstalledOrConfigured(params.config, pluginId)) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
pluginId,
|
||||
label: resolveOfficialExternalPluginLabel(entry),
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
install,
|
||||
});
|
||||
}
|
||||
return entries.toSorted((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
|
||||
export async function setupOfficialPluginInstalls(params: {
|
||||
config: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const installEntries = resolveOfficialPluginOnboardingInstallEntries({
|
||||
config: params.config,
|
||||
});
|
||||
if (installEntries.length === 0) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
const selected = await params.prompter.multiselect({
|
||||
message: "Install optional plugins",
|
||||
options: [
|
||||
{
|
||||
value: SKIP_VALUE,
|
||||
label: "Skip for now",
|
||||
hint: "Continue without installing optional plugins",
|
||||
},
|
||||
...installEntries.map((entry) => ({
|
||||
value: entry.pluginId,
|
||||
label: entry.label,
|
||||
hint: entry.description ?? formatInstallHint(entry.install),
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
let next = params.config;
|
||||
for (const pluginId of selected.filter((value) => value !== SKIP_VALUE)) {
|
||||
const entry = installEntries.find((candidate) => candidate.pluginId === pluginId);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: next,
|
||||
entry,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
workspaceDir: params.workspaceDir,
|
||||
promptInstall: false,
|
||||
});
|
||||
next = result.cfg;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -762,6 +762,13 @@ export async function runSetupWizard(
|
||||
|
||||
// Plugin configuration (sandbox backends, tool plugins, etc.)
|
||||
if (flow !== "quickstart") {
|
||||
const { setupOfficialPluginInstalls } = await import("./setup.official-plugins.js");
|
||||
nextConfig = await setupOfficialPluginInstalls({
|
||||
config: nextConfig,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
const { setupPluginConfig } = await import("./setup.plugin-config.js");
|
||||
nextConfig = await setupPluginConfig({
|
||||
config: nextConfig,
|
||||
|
||||
Reference in New Issue
Block a user