feat(FN-1190): add agent export for Agent Companies packages
- Add a core exporter that generates COMPANY.md, per-agent AGENTS.md files, and optional SKILL.md stubs with stable slug/link handling - Export new Agent Companies exporter APIs from @fusion/core and extend manifest kinds to include skill entries - Add fn agent export with project-aware agent loading, optional company metadata flags, and export summary/error output - Add POST /api/agents/export route with request validation, agent ID filtering, custom/default output directory support, and exporter integration - Add focused tests for core exporter behavior, CLI export command, and dashboard route handling
This commit is contained in:
@@ -53,6 +53,7 @@ const { runNodeList, runNodeAdd, runNodeRemove, runNodeShow, runNodeHealth } = a
|
||||
const { runInit } = await import("./commands/init.js");
|
||||
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
|
||||
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 HELP = `
|
||||
@@ -128,6 +129,8 @@ Usage:
|
||||
fn agent start <id> Start a stopped agent (resume execution)
|
||||
fn agent import <path> [--dry-run] [--skip-existing]
|
||||
Import agents from an Agent Companies package (directory, archive, or AGENTS.md file)
|
||||
fn agent export <dir> [--company-name <name>] [--company-slug <slug>]
|
||||
Export Fusion agents to an Agent Companies package directory
|
||||
fn agent mailbox <id> View an agent's mailbox
|
||||
fn message inbox List inbox messages
|
||||
fn message outbox List sent messages
|
||||
@@ -872,9 +875,22 @@ async function main() {
|
||||
await runAgentImport(source, { dryRun, skipExisting, project: projectName });
|
||||
break;
|
||||
}
|
||||
case "export": {
|
||||
const outputDir = args[2];
|
||||
if (!outputDir) {
|
||||
console.error("Usage: fn agent export <dir> [--company-name <name>] [--company-slug <slug>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const exportArgs = args.slice(3);
|
||||
const companyName = getFlagValue(exportArgs, "--company-name");
|
||||
const companySlug = getFlagValue(exportArgs, "--company-slug");
|
||||
await runAgentExport(outputDir, { project: projectName, companyName, companySlug });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: agent ${subcommand || ""}`);
|
||||
console.log("Try: fn agent stop <id> | fn agent start <id> | fn agent mailbox <id> | fn agent import <path>");
|
||||
console.log("Try: fn agent stop <id> | fn agent start <id> | fn agent mailbox <id> | fn agent import <path> | fn agent export <dir>");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
122
packages/cli/src/commands/agent-export.test.ts
Normal file
122
packages/cli/src/commands/agent-export.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { AgentStore } from "@fusion/core";
|
||||
|
||||
const mockResolveProject = vi.fn();
|
||||
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
||||
}));
|
||||
|
||||
import { runAgentExport } from "./agent-export.js";
|
||||
|
||||
describe("agent-export", () => {
|
||||
const tmpRoot = join(tmpdir(), `kb-agent-export-test-${process.pid}`);
|
||||
let projectDir: string;
|
||||
let outputDir: string;
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
projectDir = join(tmpRoot, `project-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
outputDir = join(projectDir, "exports", "company");
|
||||
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-test",
|
||||
projectPath: projectDir,
|
||||
projectName: "proj-test",
|
||||
isRegistered: true,
|
||||
store: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
async function seedAgents(): Promise<void> {
|
||||
const store = new AgentStore({ rootDir: join(projectDir, ".fusion") });
|
||||
await store.init();
|
||||
|
||||
const ceo = await store.createAgent({
|
||||
name: "CEO",
|
||||
role: "executor",
|
||||
title: "Chief Executive",
|
||||
metadata: {
|
||||
description: "Company lead",
|
||||
skills: ["strategy"],
|
||||
},
|
||||
instructionsText: "Lead company operations.",
|
||||
});
|
||||
|
||||
await store.createAgent({
|
||||
name: "Reviewer",
|
||||
role: "reviewer",
|
||||
reportsTo: ceo.id,
|
||||
metadata: {
|
||||
description: "Code reviewer",
|
||||
skills: ["review"],
|
||||
},
|
||||
instructionsText: "Review all changes.",
|
||||
});
|
||||
}
|
||||
|
||||
it("exports agents and creates package files", async () => {
|
||||
await seedAgents();
|
||||
|
||||
await runAgentExport(outputDir, {
|
||||
companyName: "Acme Export",
|
||||
companySlug: "acme-export",
|
||||
});
|
||||
|
||||
expect(existsSync(join(outputDir, "COMPANY.md"))).toBe(true);
|
||||
expect(existsSync(join(outputDir, "agents", "ceo", "AGENTS.md"))).toBe(true);
|
||||
expect(existsSync(join(outputDir, "agents", "reviewer", "AGENTS.md"))).toBe(true);
|
||||
expect(existsSync(join(outputDir, "skills", "strategy", "SKILL.md"))).toBe(true);
|
||||
expect(existsSync(join(outputDir, "skills", "review", "SKILL.md"))).toBe(true);
|
||||
|
||||
const output = logSpy.mock.calls.flat().join(" ");
|
||||
expect(output).toContain("Agents exported: 2");
|
||||
});
|
||||
|
||||
it("resolves project path when --project is provided", async () => {
|
||||
await seedAgents();
|
||||
|
||||
await runAgentExport(outputDir, {
|
||||
project: "my-project",
|
||||
});
|
||||
|
||||
expect(mockResolveProject).toHaveBeenCalledWith("my-project");
|
||||
expect(existsSync(join(outputDir, "COMPANY.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("exits with an error when there are no agents to export", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit");
|
||||
}) as any);
|
||||
|
||||
await expect(
|
||||
runAgentExport(outputDir, {
|
||||
project: "empty-project",
|
||||
}),
|
||||
).rejects.toThrow("process.exit");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("No agents found to export");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
90
packages/cli/src/commands/agent-export.ts
Normal file
90
packages/cli/src/commands/agent-export.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* CLI command for exporting agents to Agent Companies packages.
|
||||
*
|
||||
* Usage:
|
||||
* fn agent export <dir> [--company-name <name>] [--company-slug <slug>] [--project <name>]
|
||||
*
|
||||
* @module agent-export
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { AgentStore, exportAgentsToDirectory } from "@fusion/core";
|
||||
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Get the project path for agent operations.
|
||||
* Falls back to process.cwd() if no project is specified.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(result: {
|
||||
outputDir: string;
|
||||
agentsExported: number;
|
||||
skillsExported: number;
|
||||
filesWritten: string[];
|
||||
errors: Array<{ agentId: string; error: string }>;
|
||||
}): void {
|
||||
console.log();
|
||||
console.log(` Output directory: ${result.outputDir}`);
|
||||
console.log(` Agents exported: ${result.agentsExported}`);
|
||||
console.log(` Skills exported: ${result.skillsExported}`);
|
||||
console.log(` Files written: ${result.filesWritten.length}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log(` Errors: ${result.errors.length}`);
|
||||
for (const err of result.errors) {
|
||||
console.log(` ✗ ${err.agentId}: ${err.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the agent export command.
|
||||
*/
|
||||
export async function runAgentExport(
|
||||
outputDir: string,
|
||||
options?: {
|
||||
project?: string;
|
||||
companyName?: string;
|
||||
companySlug?: string;
|
||||
agentIds?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
const projectPath = await getProjectPath(options?.project);
|
||||
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
|
||||
await agentStore.init();
|
||||
|
||||
const allAgents = await agentStore.listAgents();
|
||||
const filterIds = options?.agentIds?.filter((id) => id.trim().length > 0);
|
||||
const agents = filterIds && filterIds.length > 0
|
||||
? allAgents.filter((agent) => filterIds.includes(agent.id))
|
||||
: allAgents;
|
||||
|
||||
if (agents.length === 0) {
|
||||
console.error("No agents found to export");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await exportAgentsToDirectory(agents, resolve(outputDir), {
|
||||
companyName: options?.companyName,
|
||||
companySlug: options?.companySlug,
|
||||
});
|
||||
|
||||
printSummary(result);
|
||||
}
|
||||
Reference in New Issue
Block a user