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:
@@ -91,6 +91,8 @@ const commandMocks = vi.hoisted(() => ({
|
||||
runPluginUninstall: vi.fn(),
|
||||
runPluginEnable: vi.fn(),
|
||||
runPluginDisable: vi.fn(),
|
||||
runPluginSetupStatus: vi.fn(),
|
||||
runPluginSetup: vi.fn(),
|
||||
runPluginCreate: vi.fn(),
|
||||
|
||||
runResearchCreate: vi.fn(),
|
||||
@@ -211,6 +213,8 @@ vi.mock("../commands/plugin.js", () => ({
|
||||
runPluginUninstall: commandMocks.runPluginUninstall,
|
||||
runPluginEnable: commandMocks.runPluginEnable,
|
||||
runPluginDisable: commandMocks.runPluginDisable,
|
||||
runPluginSetupStatus: commandMocks.runPluginSetupStatus,
|
||||
runPluginSetup: commandMocks.runPluginSetup,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/plugin-scaffold.js", () => ({
|
||||
@@ -432,7 +436,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 | create",
|
||||
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | 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 } = await import("./commands/plugin.js");
|
||||
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup } = 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");
|
||||
@@ -214,6 +214,8 @@ async function loadCommandHandlers() {
|
||||
runPluginUninstall,
|
||||
runPluginEnable,
|
||||
runPluginDisable,
|
||||
runPluginSetupStatus,
|
||||
runPluginSetup,
|
||||
runPluginCreate,
|
||||
runSkillsSearch,
|
||||
runSkillsInstall,
|
||||
@@ -340,6 +342,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 setup-status <id> Check plugin setup binary/runtime status
|
||||
fn plugin setup <id> [--action install|uninstall]
|
||||
Install or uninstall plugin setup binaries/runtimes
|
||||
fn plugin create <name> Scaffold a new plugin project
|
||||
fn skills search <query> Search skills.sh for agent skills
|
||||
fn skills search <query> --limit 5 Limit results
|
||||
@@ -553,6 +558,8 @@ async function main() {
|
||||
runPluginUninstall,
|
||||
runPluginEnable,
|
||||
runPluginDisable,
|
||||
runPluginSetupStatus,
|
||||
runPluginSetup,
|
||||
runPluginCreate,
|
||||
runSkillsSearch,
|
||||
runSkillsInstall,
|
||||
@@ -1443,6 +1450,24 @@ async function main() {
|
||||
await runPluginDisable(id, { projectName });
|
||||
break;
|
||||
}
|
||||
case "setup-status": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn plugin setup-status <id>"); process.exit(1); }
|
||||
await runPluginSetupStatus(id, { projectName });
|
||||
break;
|
||||
}
|
||||
case "setup": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn plugin setup <id> [--action install|uninstall]"); process.exit(1); }
|
||||
const actionIndex = args.indexOf("--action");
|
||||
const action = actionIndex >= 0 ? args[actionIndex + 1] : "install";
|
||||
if (action !== "install" && action !== "uninstall") {
|
||||
console.error("--action must be install or uninstall");
|
||||
process.exit(1);
|
||||
}
|
||||
await runPluginSetup(id, { action, projectName });
|
||||
break;
|
||||
}
|
||||
case "create": {
|
||||
const pluginName = args[2];
|
||||
if (!pluginName) { console.error("Usage: fn plugin create <name>"); process.exit(1); }
|
||||
@@ -1451,7 +1476,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 | create");
|
||||
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | setup-status | setup | create");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -327,3 +327,82 @@ export async function runPluginDisable(
|
||||
console.log(` ✓ ${plugin.name} disabled 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user