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:
gsxdsm
2026-04-08 13:31:07 -07:00
parent fd4ba8ab34
commit 8c0d30d206
9 changed files with 1029 additions and 1 deletions

View 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();
});
});

View 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);
}