feat(FN-3575): rebuild plugin system with catalog, settings, and CLI comman

Restores plugin management features across the CLI and dashboard, including a new `plugin available/settings` commands and a refactored PluginManager component that groups built-in plugins (agent-browser, fusion) separately from custom ones, with updated documentation on the plugin authoring guide.

Fusion-Task-Id: FN-3575
This commit is contained in:
Fusion
2026-05-06 22:06:19 -07:00
committed by gsxdsm
parent 2b9f279d9a
commit 07b286ba7a
9 changed files with 322 additions and 162 deletions

View File

@@ -93,6 +93,8 @@ const commandMocks = vi.hoisted(() => ({
runPluginDisable: vi.fn(),
runPluginSetupStatus: vi.fn(),
runPluginSetup: vi.fn(),
runPluginAvailable: vi.fn(),
runPluginSettings: vi.fn(),
runPluginCreate: vi.fn(),
runResearchCreate: vi.fn(),
@@ -215,6 +217,8 @@ vi.mock("../commands/plugin.js", () => ({
runPluginDisable: commandMocks.runPluginDisable,
runPluginSetupStatus: commandMocks.runPluginSetupStatus,
runPluginSetup: commandMocks.runPluginSetup,
runPluginAvailable: commandMocks.runPluginAvailable,
runPluginSettings: commandMocks.runPluginSettings,
}));
vi.mock("../commands/plugin-scaffold.js", () => ({
@@ -425,6 +429,19 @@ describe("bin command routing and fallbacks", () => {
});
});
it("routes plugin available and settings", async () => {
await runBin(["plugin", "available"]);
await runBin(["plugin", "settings", "fusion-plugin-hermes-runtime", "enabled", "true", "-P", "demo"]);
expect(commandMocks.runPluginAvailable).toHaveBeenCalledWith();
expect(commandMocks.runPluginSettings).toHaveBeenCalledWith(
"fusion-plugin-hermes-runtime",
"enabled",
"true",
{ projectName: "demo" },
);
});
it("errors when plugin install source is missing", async () => {
await expect(runBin(["plugin", "add"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(
@@ -436,7 +453,7 @@ describe("bin command routing and fallbacks", () => {
await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
expect(logSpy).toHaveBeenCalledWith(
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | setup-status | setup | create",
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | setup-status | setup | create",
);
});

View File

@@ -132,7 +132,7 @@ async function loadCommandHandlers() {
const { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup } = await import("./commands/plugin.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings } = await import("./commands/plugin.js");
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
@@ -216,6 +216,8 @@ async function loadCommandHandlers() {
runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginAvailable,
runPluginSettings,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
@@ -342,6 +344,9 @@ Usage:
fn plugin uninstall <id> [--force] Uninstall a plugin
fn plugin enable <id> Enable a plugin
fn plugin disable <id> Disable a plugin
fn plugin available List built-in plugin catalog entries
fn plugin settings <id> [key] [value]
Read/update installed plugin settings
fn plugin setup-status <id> Check plugin setup binary/runtime status
fn plugin setup <id> [--action install|uninstall]
Install or uninstall plugin setup binaries/runtimes
@@ -560,6 +565,8 @@ async function main() {
runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginAvailable,
runPluginSettings,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
@@ -1450,6 +1457,16 @@ async function main() {
await runPluginDisable(id, { projectName });
break;
}
case "available": {
await runPluginAvailable();
break;
}
case "settings": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin settings <id> [key] [value]"); process.exit(1); }
await runPluginSettings(id, args[3], args[4], { projectName });
break;
}
case "setup-status": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin setup-status <id>"); process.exit(1); }
@@ -1476,7 +1493,7 @@ async function main() {
}
default:
console.error(`Unknown subcommand: plugin ${sub || ""}`);
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | setup-status | setup | create");
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | setup-status | setup | create");
process.exit(1);
}
break;

View File

@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => {
registerPlugin: ReturnType<typeof vi.fn>;
listPlugins: ReturnType<typeof vi.fn>;
getPlugin: ReturnType<typeof vi.fn>;
updatePluginSettings: ReturnType<typeof vi.fn>;
}> = [];
let loaderTaskStore: { getRootDir?: () => string } | undefined;
@@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => {
}),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
pluginStoreInstances.push(instance);
return instance;
@@ -74,11 +76,13 @@ vi.mock("node:fs/promises", () => ({
),
}));
import { runPluginInstall } from "../plugin.js";
import { runPluginAvailable, runPluginInstall, runPluginSettings } from "../plugin.js";
import { resolveProject } from "../../project-context.js";
describe("runPluginInstall", () => {
describe("plugin commands", () => {
beforeEach(() => {
mocks.reset();
vi.mocked(resolveProject).mockResolvedValue({ projectPath: "/tmp/fn-project" } as never);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
@@ -96,4 +100,34 @@ describe("runPluginInstall", () => {
expect(taskStore?.getRootDir?.()).toBe("/tmp/fn-project");
expect(mocks.getLoaderRootDir()).toBe("/tmp/fn-project");
});
it("prints built-in plugin catalog", async () => {
await expect(runPluginAvailable()).resolves.toBeUndefined();
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Installable"));
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("fusion-plugin-agent-browser"));
});
it("reads and updates plugin settings", async () => {
const storeInstance = {
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn(),
listPlugins: vi.fn(),
getPlugin: vi.fn().mockResolvedValue({
id: "paperclip-runtime",
settings: { enabled: true, retries: 2 },
}),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
await runPluginSettings("paperclip-runtime", undefined, undefined, { projectName: "demo" });
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
await runPluginSettings("paperclip-runtime", "enabled", undefined, { projectName: "demo" });
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
await runPluginSettings("paperclip-runtime", "enabled", "false", { projectName: "demo" });
expect(storeInstance.getPlugin).toHaveBeenCalledTimes(3);
expect(storeInstance.updatePluginSettings).toHaveBeenCalledWith("paperclip-runtime", { enabled: false });
});
});

View File

@@ -16,6 +16,62 @@ import * as readline from "node:readline";
import { PluginStore, PluginLoader, validatePluginManifest } 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-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.
*/
@@ -360,6 +416,52 @@ export async function runPluginSetupStatus(
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 runPluginSetup(
id: string,
options?: { action?: "install" | "uninstall"; projectName?: string },