import fs from "node:fs"; import path from "node:path"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { enablePluginInConfig } from "../../plugins/enable.js"; import { installPluginFromNpmSpec } from "../../plugins/install.js"; import { recordPluginInstall } from "../../plugins/installs.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; type InstallChoice = "npm" | "local" | "skip"; type InstallResult = { cfg: OpenClawConfig; installed: boolean; }; function hasGitWorkspace(workspaceDir?: string): boolean { const candidates = new Set(); candidates.add(path.join(process.cwd(), ".git")); if (workspaceDir && workspaceDir !== process.cwd()) { candidates.add(path.join(workspaceDir, ".git")); } for (const candidate of candidates) { if (fs.existsSync(candidate)) { return true; } } return false; } function resolveLocalPath( entry: ChannelPluginCatalogEntry, workspaceDir: string | undefined, allowLocal: boolean, ): string | null { if (!allowLocal) { return null; } const raw = entry.install.localPath?.trim(); if (!raw) { return null; } const candidates = new Set(); candidates.add(path.resolve(process.cwd(), raw)); if (workspaceDir && workspaceDir !== process.cwd()) { candidates.add(path.resolve(workspaceDir, raw)); } for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return null; } function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawConfig { const existing = cfg.plugins?.load?.paths ?? []; const merged = Array.from(new Set([...existing, pluginPath])); return { ...cfg, plugins: { ...cfg.plugins, load: { ...cfg.plugins?.load, paths: merged, }, }, }; } async function promptInstallChoice(params: { entry: ChannelPluginCatalogEntry; localPath?: string | null; defaultChoice: InstallChoice; prompter: WizardPrompter; }): Promise { const { entry, localPath, prompter, defaultChoice } = params; const localOptions: Array<{ value: InstallChoice; label: string; hint?: string }> = localPath ? [ { value: "local", label: "Use local plugin path", hint: localPath, }, ] : []; const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [ { value: "npm", label: `Download from npm (${entry.install.npmSpec})` }, ...localOptions, { value: "skip", label: "Skip for now" }, ]; const initialValue: InstallChoice = defaultChoice === "local" && !localPath ? "npm" : defaultChoice; return await prompter.select({ message: `Install ${entry.meta.label} plugin?`, options, initialValue, }); } function resolveInstallDefaultChoice(params: { cfg: OpenClawConfig; entry: ChannelPluginCatalogEntry; localPath?: string | null; }): InstallChoice { const { cfg, entry, localPath } = params; const updateChannel = cfg.update?.channel; if (updateChannel === "dev") { return localPath ? "local" : "npm"; } if (updateChannel === "stable" || updateChannel === "beta") { return "npm"; } const entryDefault = entry.install.defaultChoice; if (entryDefault === "local") { return localPath ? "local" : "npm"; } if (entryDefault === "npm") { return "npm"; } return localPath ? "local" : "npm"; } export async function ensureOnboardingPluginInstalled(params: { cfg: OpenClawConfig; entry: ChannelPluginCatalogEntry; prompter: WizardPrompter; runtime: RuntimeEnv; workspaceDir?: string; }): Promise { const { entry, prompter, runtime, workspaceDir } = params; let next = params.cfg; const allowLocal = hasGitWorkspace(workspaceDir); const localPath = resolveLocalPath(entry, workspaceDir, allowLocal); const defaultChoice = resolveInstallDefaultChoice({ cfg: next, entry, localPath, }); const choice = await promptInstallChoice({ entry, localPath, defaultChoice, prompter, }); if (choice === "skip") { return { cfg: next, installed: false }; } if (choice === "local" && localPath) { next = addPluginLoadPath(next, localPath); next = enablePluginInConfig(next, entry.id).config; return { cfg: next, installed: true }; } const result = await installPluginFromNpmSpec({ spec: entry.install.npmSpec, logger: { info: (msg) => runtime.log?.(msg), warn: (msg) => runtime.log?.(msg), }, }); if (result.ok) { next = enablePluginInConfig(next, result.pluginId).config; next = recordPluginInstall(next, { pluginId: result.pluginId, source: "npm", spec: entry.install.npmSpec, installPath: result.targetDir, version: result.version, }); return { cfg: next, installed: true }; } await prompter.note( `Failed to install ${entry.install.npmSpec}: ${result.error}`, "Plugin install", ); if (localPath) { const fallback = await prompter.confirm({ message: `Use local plugin path instead? (${localPath})`, initialValue: true, }); if (fallback) { next = addPluginLoadPath(next, localPath); next = enablePluginInConfig(next, entry.id).config; return { cfg: next, installed: true }; } } runtime.error?.(`Plugin install failed: ${result.error}`); return { cfg: next, installed: false }; } export function reloadOnboardingPluginRegistry(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; workspaceDir?: string; }): void { const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); const log = createSubsystemLogger("plugins"); loadOpenClawPlugins({ config: params.cfg, workspaceDir, cache: false, logger: { info: (msg) => log.info(msg), warn: (msg) => log.warn(msg), error: (msg) => log.error(msg), debug: (msg) => log.debug(msg), }, }); }