feat(FN-3565): add plugin management CLI, loader, runner, and dashboard rou

This merge adds a complete plugin management system to Fusion: a new `fn plugin` CLI command for installing/removing plugins, a plugin loader in core, a plugin runner in engine, and dashboard routes for plugin management UI, along with a plugin management guide in docs. It also documents task evalua

Fusion-Task-Id: FN-3565
This commit is contained in:
Fusion
2026-05-06 04:15:13 -07:00
committed by gsxdsm
parent 27973a8345
commit 8f812e2f89
15 changed files with 994 additions and 7 deletions

View File

@@ -41,6 +41,9 @@ describe("PluginRunner", () => {
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
getPluginPromptContributions: ReturnType<typeof vi.fn>;
getPluginSetupInfo: ReturnType<typeof vi.fn>;
checkPluginSetup: ReturnType<typeof vi.fn>;
installPluginSetup: ReturnType<typeof vi.fn>;
uninstallPluginSetup: ReturnType<typeof vi.fn>;
getLoadedPlugins: ReturnType<typeof vi.fn>;
getPlugin: ReturnType<typeof vi.fn>;
loadPlugin: ReturnType<typeof vi.fn>;
@@ -102,6 +105,9 @@ describe("PluginRunner", () => {
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
getPluginPromptContributions: vi.fn().mockReturnValue([]),
getPluginSetupInfo: vi.fn().mockReturnValue([]),
checkPluginSetup: vi.fn().mockResolvedValue({ status: "installed" }),
installPluginSetup: vi.fn().mockResolvedValue(undefined),
uninstallPluginSetup: vi.fn().mockResolvedValue(undefined),
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
loadPlugin: vi.fn().mockResolvedValue({}),
@@ -909,6 +915,64 @@ describe("PluginRunner", () => {
expect(mockPluginLoader.getPluginPromptContributions).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginSetupInfo).toHaveBeenCalledTimes(3);
});
it("checkPluginSetup delegates to loader and returns result", async () => {
const result = { status: "installed" as const, version: "1.2.3" };
mockPluginLoader.checkPluginSetup.mockResolvedValue(result);
await expect(pluginRunner.checkPluginSetup("test-plugin")).resolves.toEqual(result);
expect(mockPluginLoader.checkPluginSetup).toHaveBeenCalledWith("test-plugin");
});
it("checkPluginSetup returns error status when loader throws", async () => {
mockPluginLoader.checkPluginSetup.mockRejectedValue(new Error("check failed"));
await expect(pluginRunner.checkPluginSetup("test-plugin")).resolves.toEqual({ status: "error", error: "check failed" });
});
it("installPluginSetup returns success true on success", async () => {
await expect(pluginRunner.installPluginSetup("test-plugin")).resolves.toEqual({ success: true });
expect(mockPluginLoader.installPluginSetup).toHaveBeenCalledWith("test-plugin");
});
it("installPluginSetup returns success false on failure", async () => {
mockPluginLoader.installPluginSetup.mockRejectedValue(new Error("install failed"));
await expect(pluginRunner.installPluginSetup("test-plugin")).resolves.toEqual({ success: false, error: "install failed" });
});
it("uninstallPluginSetup returns success and failure results", async () => {
await expect(pluginRunner.uninstallPluginSetup("test-plugin")).resolves.toEqual({ success: true });
mockPluginLoader.uninstallPluginSetup.mockRejectedValueOnce(new Error("uninstall failed"));
await expect(pluginRunner.uninstallPluginSetup("test-plugin")).resolves.toEqual({ success: false, error: "uninstall failed" });
});
it("getSetupStatuses returns statuses for all plugins with setup", async () => {
mockPluginLoader.getPluginSetupInfo.mockReturnValue([
{ pluginId: "a", manifest: { binaryName: "a-bin", description: "A" }, hooks: { checkSetup: vi.fn() } },
{ pluginId: "b", manifest: { binaryName: "b-bin", description: "B" }, hooks: { checkSetup: vi.fn() } },
]);
mockPluginLoader.checkPluginSetup
.mockResolvedValueOnce({ status: "installed", version: "1.0.0" })
.mockResolvedValueOnce({ status: "not-installed" });
await expect(pluginRunner.getSetupStatuses()).resolves.toEqual([
{ pluginId: "a", manifest: { binaryName: "a-bin", description: "A" }, status: { status: "installed", version: "1.0.0" } },
{ pluginId: "b", manifest: { binaryName: "b-bin", description: "B" }, status: { status: "not-installed" } },
]);
});
it("getSetupStatuses handles setup check failures gracefully", async () => {
mockPluginLoader.getPluginSetupInfo.mockReturnValue([
{ pluginId: "offline", manifest: { binaryName: "off-bin", description: "Offline" }, hooks: { checkSetup: vi.fn() } },
]);
mockPluginLoader.checkPluginSetup.mockRejectedValue(new Error("Plugin \"offline\" is not loaded"));
await expect(pluginRunner.getSetupStatuses()).resolves.toEqual([
{
pluginId: "offline",
manifest: { binaryName: "off-bin", description: "Offline" },
status: { status: "error", error: 'Plugin "offline" is not loaded' },
},
]);
});
});
describe("getRuntimeById()", () => {

View File

@@ -25,6 +25,7 @@ import type {
PluginPromptSurface,
PluginSetupManifest,
PluginSetupHooks,
PluginSetupCheckResult,
} from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type } from "@mariozechner/pi-ai";
@@ -371,6 +372,55 @@ export class PluginRunner {
return this.cachedSetupInfo.setups;
}
async checkPluginSetup(pluginId: string): Promise<PluginSetupCheckResult> {
try {
return await this.withTimeout(
this.options.pluginLoader.checkPluginSetup(pluginId),
this.hookTimeoutMs,
`Setup check for plugin ${pluginId} timed out`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log.warn(`Setup check failed for plugin ${pluginId}: ${message}`);
return { status: "error", error: message };
}
}
async installPluginSetup(pluginId: string): Promise<{ success: boolean; error?: string }> {
try {
await this.options.pluginLoader.installPluginSetup(pluginId);
this.invalidateSetupCache();
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log.warn(`Setup install failed for plugin ${pluginId}: ${message}`);
return { success: false, error: message };
}
}
async uninstallPluginSetup(pluginId: string): Promise<{ success: boolean; error?: string }> {
try {
await this.options.pluginLoader.uninstallPluginSetup(pluginId);
this.invalidateSetupCache();
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log.warn(`Setup uninstall failed for plugin ${pluginId}: ${message}`);
return { success: false, error: message };
}
}
async getSetupStatuses(): Promise<Array<{ pluginId: string; manifest: PluginSetupManifest; status?: PluginSetupCheckResult }>> {
const setupInfo = this.getPluginSetupInfo();
return Promise.all(
setupInfo.map(async ({ pluginId, manifest }) => ({
pluginId,
manifest,
status: await this.checkPluginSetup(pluginId),
})),
);
}
getPromptContributionsForSurface(surface: PluginPromptSurface): Array<{
pluginId: string;
contribution: PluginPromptContribution;