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:
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user