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:
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