Files
clawdbot/src/commands/onboarding/plugin-install.ts
2026-01-16 05:55:05 +00:00

213 lines
6.1 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { recordPluginInstall } from "../../plugins/installs.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { installPluginFromNpmSpec } from "../../plugins/install.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
type InstallChoice = "npm" | "local" | "skip";
type InstallResult = {
cfg: ClawdbotConfig;
installed: boolean;
};
function hasGitWorkspace(workspaceDir?: string): boolean {
const candidates = new Set<string>();
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<string>();
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 ensurePluginEnabled(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const entries = {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
};
const next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
...(cfg.plugins?.enabled === false ? { enabled: true } : {}),
entries,
},
};
return ensurePluginAllowlist(next, pluginId);
}
function ensurePluginAllowlist(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const allow = cfg.plugins?.allow;
if (!allow || allow.includes(pluginId)) return cfg;
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId],
},
};
}
function addPluginLoadPath(cfg: ClawdbotConfig, pluginPath: string): ClawdbotConfig {
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;
prompter: WizardPrompter;
}): Promise<InstallChoice> {
const { entry, localPath, prompter } = 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 = localPath ? "local" : "npm";
return await prompter.select<InstallChoice>({
message: `Install ${entry.meta.label} plugin?`,
options,
initialValue,
});
}
export async function ensureOnboardingPluginInstalled(params: {
cfg: ClawdbotConfig;
entry: ChannelPluginCatalogEntry;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir?: string;
}): Promise<InstallResult> {
const { entry, prompter, runtime, workspaceDir } = params;
let next = params.cfg;
const allowLocal = hasGitWorkspace(workspaceDir);
const localPath = resolveLocalPath(entry, workspaceDir, allowLocal);
const choice = await promptInstallChoice({
entry,
localPath,
prompter,
});
if (choice === "skip") {
return { cfg: next, installed: false };
}
if (choice === "local" && localPath) {
next = addPluginLoadPath(next, localPath);
next = ensurePluginEnabled(next, entry.id);
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 = ensurePluginEnabled(next, result.pluginId);
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 = ensurePluginEnabled(next, entry.id);
return { cfg: next, installed: true };
}
}
runtime.error?.(`Plugin install failed: ${result.error}`);
return { cfg: next, installed: false };
}
export function reloadOnboardingPluginRegistry(params: {
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
workspaceDir?: string;
}): void {
const workspaceDir =
params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
const log = createSubsystemLogger("plugins");
loadClawdbotPlugins({
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),
},
});
}