Files
fusion/packages/cli/src/commands/plugin.ts
gsxdsm 489a287d6f feat(acp): bundle into CLI, on-demand install, S1 safety + evidence (U8)
Wires the ACP runtime plugin into the published CLI (RUNTIME_PLUGIN_IDS in
tsup.config) and the on-demand BUILTIN_PLUGINS catalog (experimental), matching
the untrusted-subprocess security posture. Adds the Risk S1 default-policy
safety: an acpAllowUnrestricted acknowledgement (default false) — without it, a
blanket allow on a sensitive category is escalated to approval rather than
auto-approved under the allow-all default policy, applied in both the permission
floor and fs write gating. Adds docs/acp-contract.md (launch/readiness +
failure taxonomy), a README with the AGENTS.md-required upstream evidence
(SDK repo/docs/release/integrity), a bundle-output test for the staged plugin,
and a @runfusion/fusion minor changeset. Package green at 179 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:25:56 -07:00

673 lines
20 KiB
TypeScript

/**
* Plugin Management CLI Commands
*
* Provides CLI commands for plugin management:
* - fn plugin list - List installed plugins
* - fn plugin install <path> - Install a plugin from local path
* - fn plugin uninstall <id> - Uninstall a plugin
* - fn plugin enable <id> - Enable a plugin
* - fn plugin disable <id> - Disable a plugin
*/
import { existsSync } from "node:fs";
import { dirname, extname, join, resolve } from "node:path";
import { readFile, stat } from "node:fs/promises";
import * as readline from "node:readline";
import { PluginStore, PluginLoader, validatePluginManifest, resolveGlobalDir } from "@fusion/core";
import { resolveProject } from "../project-context.js";
export interface BuiltinPluginCatalogEntry {
id: string;
name: string;
description: string;
category: "runtime" | "integration";
path?: string;
experimental?: boolean;
}
export const BUILTIN_PLUGINS: BuiltinPluginCatalogEntry[] = [
{
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime",
description: "Runtime provider for Hermes CLI-backed execution.",
category: "runtime",
path: "./plugins/fusion-plugin-hermes-runtime",
experimental: true,
},
{
id: "fusion-plugin-paperclip-runtime",
name: "Paperclip Runtime",
description: "Runtime provider for Paperclip agent connections.",
category: "runtime",
path: "./plugins/fusion-plugin-paperclip-runtime",
},
{
id: "fusion-plugin-openclaw-runtime",
name: "OpenClaw Runtime",
description: "Runtime provider for OpenClaw execution.",
category: "runtime",
path: "./plugins/fusion-plugin-openclaw-runtime",
experimental: true,
},
{
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime",
description: "Runtime provider for Droid CLI execution.",
category: "runtime",
path: "./plugins/fusion-plugin-droid-runtime",
experimental: true,
},
{
id: "fusion-plugin-acp-runtime",
name: "ACP Runtime",
description: "Runtime provider that drives any external Agent Client Protocol agent over JSON-RPC/stdio.",
category: "runtime",
path: "./plugins/fusion-plugin-acp-runtime",
experimental: true,
},
{
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
description: "Dashboard plugin for task dependency graph visualization.",
category: "integration",
path: "./plugins/fusion-plugin-dependency-graph",
},
{
id: "fusion-plugin-agent-browser",
name: "Agent Browser",
description: "Built-in integration metadata. Package install support lands in FN-3101.",
category: "integration",
},
];
/**
* Get the project path for plugin operations.
*/
async function getProjectPath(projectName?: string): Promise<string> {
if (projectName) {
const context = await resolveProject(projectName);
return context.projectPath;
}
try {
const context = await resolveProject(undefined);
return context.projectPath;
} catch {
return process.cwd();
}
}
/**
* Create a PluginStore for the given project.
*/
export async function createPluginStore(
projectName?: string,
options?: { centralGlobalDir?: string },
): Promise<PluginStore> {
try {
const context = await resolveProject(projectName, process.cwd(), options?.centralGlobalDir);
const pluginStore = context.store.getPluginStore();
await pluginStore.init();
return pluginStore;
} catch {
const projectPath = await getProjectPath(projectName);
const pluginStore = new PluginStore(projectPath, {
centralGlobalDir: options?.centralGlobalDir ?? resolveGlobalDir(),
});
await pluginStore.init();
return pluginStore;
}
}
/**
* Create a PluginLoader for the given project.
*/
export async function createPluginLoader(
pluginStore: PluginStore,
projectName?: string,
): Promise<{ store: PluginStore; loader: PluginLoader }> {
const projectPath = await getProjectPath(projectName);
// Create a mock TaskStore for the loader (plugins don't need full task store access)
const mockTaskStore = {
getRootDir: () => projectPath,
getFusionDir: () => projectPath + "/.fusion",
on: () => {},
off: () => {},
} as unknown as ConstructorParameters<typeof PluginLoader>[0]["taskStore"];
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
return { store: pluginStore, loader };
}
const JS_ENTRY_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]);
const TS_SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
function isJsEntryFile(path: string): boolean {
return JS_ENTRY_EXTENSIONS.has(extname(path).toLowerCase());
}
function isTypeScriptSource(path: string): boolean {
return TS_SOURCE_EXTENSIONS.has(extname(path).toLowerCase());
}
async function statPath(path: string): Promise<import("node:fs").Stats | undefined> {
try {
return await stat(path);
} catch {
return undefined;
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
/**
* Resolve plugin installation source to a compiled JavaScript entry file.
*/
export async function resolvePluginEntryFile(pluginDir: string): Promise<string> {
const absoluteInputPath = resolve(pluginDir);
const inputStats = await statPath(absoluteInputPath);
if (inputStats?.isFile()) {
if (isTypeScriptSource(absoluteInputPath)) {
throw new Error(
`Plugin entry must be compiled JavaScript, but got TypeScript source: ${absoluteInputPath}. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
if (isJsEntryFile(absoluteInputPath)) {
return absoluteInputPath;
}
throw new Error(`Plugin entry file must end with .js, .mjs, or .cjs: ${absoluteInputPath}`);
}
const packageJsonPath = join(absoluteInputPath, "package.json");
let selectedCandidate: string | undefined;
if (existsSync(packageJsonPath)) {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8")) as Record<string, unknown>;
const exportsRecord = asRecord(packageJson.exports);
const dotExport = exportsRecord?.["."];
const dotExportRecord = asRecord(dotExport);
if (typeof dotExportRecord?.import === "string") {
selectedCandidate = dotExportRecord.import;
} else if (typeof dotExportRecord?.default === "string") {
selectedCandidate = dotExportRecord.default;
} else if (typeof dotExport === "string") {
selectedCandidate = dotExport;
} else if (typeof packageJson.main === "string") {
selectedCandidate = packageJson.main;
}
}
if (selectedCandidate) {
const absoluteCandidate = resolve(absoluteInputPath, selectedCandidate);
if (isTypeScriptSource(absoluteCandidate)) {
throw new Error(
`Plugin entry resolves to TypeScript source (${absoluteCandidate}). Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
const candidateStats = await statPath(absoluteCandidate);
if (!candidateStats?.isFile()) {
throw new Error(
`Plugin entry file not found: ${absoluteCandidate}. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
return absoluteCandidate;
}
const distIndexPath = resolve(absoluteInputPath, "dist/index.js");
const distStats = await statPath(distIndexPath);
if (distStats?.isFile()) {
return distIndexPath;
}
const indexPath = resolve(absoluteInputPath, "index.js");
const indexStats = await statPath(indexPath);
if (indexStats?.isFile()) {
return indexPath;
}
throw new Error(
`Could not resolve a plugin JavaScript entry file in ${absoluteInputPath}. Tried package.json exports/main, dist/index.js, and index.js. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
/**
* Load plugin manifest from a local path.
*/
export async function loadManifestFromPath(
pluginPath: string,
): Promise<{ manifest: import("@fusion/core").PluginManifest; path: string }> {
const absoluteInputPath = resolve(pluginPath);
const inputStats = await statPath(absoluteInputPath);
const manifestDir = inputStats?.isFile() ? dirname(absoluteInputPath) : absoluteInputPath;
const manifestPath = join(manifestDir, "manifest.json");
if (!existsSync(manifestPath)) {
throw new Error(`Plugin manifest not found at: ${manifestPath}`);
}
const content = await readFile(manifestPath, "utf-8");
const manifest = JSON.parse(content);
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return { manifest, path: manifestDir };
}
/**
* Colorize plugin state for display.
*/
function colorizeState(state: string): string {
const colors: Record<string, string> = {
started: "\x1b[32m", // green
loaded: "\x1b[33m", // yellow
error: "\x1b[31m", // red
stopped: "\x1b[2m", // dim
installed: "\x1b[34m", // blue
};
const reset = "\x1b[0m";
const color = colors[state] || colors.installed;
return `${color}${state}${reset}`;
}
/**
* List all installed plugins.
*/
export async function runPluginList(projectName?: string): Promise<void> {
const pluginStore = await createPluginStore(projectName);
const plugins = await pluginStore.listPlugins();
if (plugins.length === 0) {
console.log();
console.log(" No plugins installed");
console.log();
return;
}
console.log();
console.log(" ID Name Version State Project Enabled");
console.log(" ─────────────────────────────────────────────────────────────────────");
for (const plugin of plugins) {
const id = plugin.id.padEnd(19);
const name = plugin.name.substring(0, 23).padEnd(24);
const version = plugin.version.padEnd(8);
const state = colorizeState(plugin.state).padEnd(10);
const enabled = plugin.enabled ? "yes" : "no";
console.log(` ${id} ${name} ${version} ${state} ${enabled}`);
}
console.log();
}
/**
* Install a plugin from a local path.
*/
export async function runPluginInstall(
source: string,
options?: { projectName?: string; aiScan?: boolean },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
// Determine if source is a local path or npm package
if (source.startsWith("@")) {
// npm package
console.error("Installing plugins from npm packages is not yet implemented");
console.error("Please provide a local path to the plugin directory.");
process.exit(1);
}
// Local path
if (!existsSync(source)) {
console.error(`Plugin path does not exist: ${source}`);
process.exit(1);
}
try {
const entryPath = await resolvePluginEntryFile(source);
const { manifest } = await loadManifestFromPath(source);
console.log();
console.log(` Installing ${manifest.name} v${manifest.version} globally...`);
// Register the plugin
const plugin = await store.registerPlugin({
manifest,
path: entryPath,
aiScanOnLoad: options?.aiScan ?? false,
});
// Try to load it
if (plugin.enabled) {
try {
await loader.loadPlugin(plugin.id);
console.log(` ✓ ${manifest.name} installed globally and enabled for this project`);
} catch (loadErr) {
console.log(` ⚠ ${manifest.name} installed but failed to load: ${loadErr instanceof Error ? loadErr.message : String(loadErr)}`);
}
} else {
console.log(` ✓ ${manifest.name} installed globally (disabled for this project)`);
}
console.log();
} catch (err) {
console.error();
console.error(` Failed to install plugin: ${err instanceof Error ? err.message : String(err)}`);
console.error();
process.exit(1);
}
}
/**
* Uninstall a plugin.
*/
export async function runPluginUninstall(
id: string,
options?: { force?: boolean; projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
// Check if plugin exists
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
// Confirm unless force
if (!options?.force) {
console.log();
console.log(` Uninstall "${plugin.name}" globally?`);
console.log(" This removes it for all projects.");
console.log();
const response = await new Promise<string>((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(" Continue? [y/N] ", (answer: string) => {
rl.close();
resolve(answer.toLowerCase());
});
});
if (response !== "y" && response !== "yes") {
console.log(" Cancelled");
return;
}
}
// Stop the plugin
try {
await loader.stopPlugin(id);
} catch {
// Ignore - might not be loaded
}
// Unregister
await store.unregisterPlugin(id);
console.log();
console.log(` ✓ ${plugin.name} uninstalled globally`);
console.log();
}
/**
* Enable a plugin.
*/
export async function runPluginEnable(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
// Check if plugin exists
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (plugin.enabled) {
console.log();
console.log(` ${plugin.name} is already enabled`);
console.log();
return;
}
// Enable and start
await store.enablePlugin(id);
try {
await loader.loadPlugin(id);
} catch (loadErr) {
console.log(` ⚠ ${plugin.name} enabled but failed to load: ${loadErr instanceof Error ? loadErr.message : String(loadErr)}`);
console.log();
return;
}
console.log();
console.log(` ✓ ${plugin.name} enabled for this project and started`);
console.log();
}
/**
* Disable a plugin.
*/
export async function runPluginDisable(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
// Check if plugin exists
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (!plugin.enabled) {
console.log();
console.log(` ${plugin.name} is already disabled`);
console.log();
return;
}
// Stop and disable
await loader.stopPlugin(id);
await store.disablePlugin(id);
console.log();
console.log(` ✓ ${plugin.name} disabled for this project and stopped`);
console.log();
}
export async function runPluginSetupStatus(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
try {
await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (!loader.isPluginLoaded(id)) {
console.error(`Plugin "${id}" is not loaded. Enable the plugin first.`);
process.exit(1);
}
const loadedPlugin = loader.getPlugin(id);
if (!loadedPlugin?.setup) {
console.log("Plugin has no setup requirements");
return;
}
const result = await loader.checkPluginSetup(id);
console.log(`status: ${result.status}`);
if (result.version) console.log(`version: ${result.version}`);
if (result.binaryPath) console.log(`binaryPath: ${result.binaryPath}`);
if (result.error) console.log(`error: ${result.error}`);
}
export async function runPluginAvailable(): Promise<void> {
console.log();
console.log(" ID Name Category Installable");
console.log(" ──────────────────────────────────────────────────────────────────────────────");
for (const plugin of BUILTIN_PLUGINS) {
const id = plugin.id.padEnd(30);
const name = plugin.name.padEnd(20);
const category = plugin.category.padEnd(13);
const installable = plugin.path ? "yes" : "metadata-only";
console.log(` ${id} ${name} ${category} ${installable}`);
}
console.log();
}
export async function runPluginSettings(
id: string,
key?: string,
value?: string,
options?: { projectName?: string },
): Promise<void> {
const pluginStore = await createPluginStore(options?.projectName);
const plugin = await pluginStore.getPlugin(id);
if (!key) {
console.log(JSON.stringify(plugin.settings ?? {}, null, 2));
return;
}
if (value === undefined) {
const currentValue = (plugin.settings ?? {})[key];
console.log(currentValue === undefined ? "undefined" : JSON.stringify(currentValue, null, 2));
return;
}
const parsedValue = (() => {
try {
return JSON.parse(value);
} catch {
return value;
}
})();
await pluginStore.updatePluginSettings(id, { [key]: parsedValue });
console.log(`✓ Updated ${id}.${key}`);
}
export async function runPluginRescan(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
try {
if (plugin.state === "started" && typeof loader.reloadPlugin === "function") {
await loader.reloadPlugin(id);
} else if (plugin.enabled) {
await loader.loadPlugin(id);
}
} catch (error) {
// keep going to show persisted scan verdict/state
console.error(`Rescan/load failed: ${error instanceof Error ? error.message : String(error)}`);
}
const refreshed = await store.getPlugin(id);
const scan = refreshed.lastSecurityScan;
const verdict = scan?.verdict ?? "unavailable";
const summary = scan?.summary ?? refreshed.error ?? "No scan result available";
const findingCount = scan?.findings?.length ?? 0;
console.log(`${refreshed.name}`);
console.log(`verdict: ${verdict}`);
console.log(`summary: ${summary}`);
console.log(`findings: ${findingCount}`);
if (verdict === "blocked" || verdict === "error" || verdict === "unavailable") {
process.exit(1);
}
}
export async function runPluginSetup(
id: string,
options?: { action?: "install" | "uninstall"; projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const action = options?.action ?? "install";
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (!loader.isPluginLoaded(id)) {
console.error(`Plugin "${id}" is not loaded. Enable the plugin first.`);
process.exit(1);
}
const loadedPlugin = loader.getPlugin(id);
if (!loadedPlugin?.setup) {
console.log("Plugin has no setup requirements");
return;
}
try {
if (action === "uninstall") {
await loader.uninstallPluginSetup(id);
console.log(`✓ ${plugin.name} setup uninstalled`);
return;
}
if (!loadedPlugin.setup.hooks.install) {
console.error("Plugin has no install hook");
process.exit(1);
}
await loader.installPluginSetup(id);
console.log(`✓ ${plugin.name} setup installed`);
} catch (error) {
console.error(`Failed to ${action} setup for "${id}": ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}