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

@@ -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;

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

View File

@@ -0,0 +1,255 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseYamlFrontmatter } from "./agent-companies-parser.js";
import {
agentToCompaniesManifest,
exportAgentsToDirectory,
generateAgentMd,
generateCompanyMd,
slugify,
} from "./agent-companies-exporter.js";
import type { Agent } from "./types.js";
const tempDirs: string[] = [];
function createTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "agent-companies-exporter-test-"));
tempDirs.push(dir);
return dir;
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date().toISOString();
return {
id: overrides.id ?? "agent-1",
name: overrides.name ?? "CEO",
role: overrides.role ?? "executor",
state: overrides.state ?? "idle",
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
metadata: overrides.metadata ?? {},
...(overrides.title !== undefined ? { title: overrides.title } : {}),
...(overrides.icon !== undefined ? { icon: overrides.icon } : {}),
...(overrides.reportsTo !== undefined ? { reportsTo: overrides.reportsTo } : {}),
...(overrides.instructionsText !== undefined
? { instructionsText: overrides.instructionsText }
: {}),
};
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe("agent-companies-exporter", () => {
it("maps Agent fields to AgentCompanies manifest", () => {
const agent = makeAgent({
id: "agent-ceo",
name: "CEO",
title: "Chief Executive Officer",
icon: "crown",
role: "reviewer",
reportsTo: "agent-root",
instructionsText: "Lead strategy and review architecture.",
metadata: {
description: "Company lead",
skills: ["review", { name: "architecture" }],
},
});
const manifest = agentToCompaniesManifest(agent);
expect(manifest).toEqual({
name: "CEO",
title: "Chief Executive Officer",
icon: "crown",
role: "reviewer",
reportsTo: "agent-root",
skills: ["review", "architecture"],
description: "Company lead",
schema: "agentcompanies/v1",
instructionBody: "Lead strategy and review architecture.",
});
});
it("generates COMPANY.md with valid YAML frontmatter", () => {
const content = generateCompanyMd([makeAgent({ name: "Leadership" })], {
name: "Acme Agents",
description: "Autonomous engineering org",
slug: "acme-agents",
});
const parsed = parseYamlFrontmatter(content);
expect(parsed.frontmatter).toMatchObject({
name: "Acme Agents",
description: "Autonomous engineering org",
slug: "acme-agents",
schema: "agentcompanies/v1",
});
expect(parsed.body).toContain("Autonomous engineering org");
});
it("generates AGENTS.md with frontmatter and markdown body", () => {
const content = generateAgentMd(
makeAgent({
name: "Reviewer",
title: "Code Reviewer",
icon: "shield",
role: "reviewer",
instructionsText: "Always verify tests and edge-cases.",
metadata: {
description: "Ensures quality",
skills: ["qa"],
},
}),
);
const parsed = parseYamlFrontmatter(content);
expect(parsed.frontmatter).toMatchObject({
name: "Reviewer",
title: "Code Reviewer",
icon: "shield",
role: "reviewer",
reportsTo: null,
skills: ["qa"],
description: "Ensures quality",
schema: "agentcompanies/v1",
});
expect(parsed.body).toBe("Always verify tests and edge-cases.");
});
it("exports agents and skills to Agent Companies directory layout", async () => {
const outputDir = createTempDir();
const ceo = makeAgent({
id: "agent-ceo",
name: "CEO",
role: "executor",
metadata: {
description: "Company lead",
skills: ["strategy"],
},
instructionsText: "Lead the company.",
});
const reviewer = makeAgent({
id: "agent-reviewer",
name: "Code Reviewer",
role: "reviewer",
reportsTo: "agent-ceo",
metadata: {
description: "Reviews code",
skills: ["review"],
},
instructionsText: "Review every pull request.",
});
const result = await exportAgentsToDirectory([ceo, reviewer], outputDir, {
companyName: "Acme AI",
companySlug: "acme-ai",
});
expect(result.agentsExported).toBe(2);
expect(result.skillsExported).toBe(2);
expect(result.errors).toEqual([]);
const companyPath = join(outputDir, "COMPANY.md");
const reviewerPath = join(outputDir, "agents", "code-reviewer", "AGENTS.md");
const strategySkillPath = join(outputDir, "skills", "strategy", "SKILL.md");
expect(readFileSync(companyPath, "utf-8")).toContain("schema: agentcompanies/v1");
const reviewerManifest = parseYamlFrontmatter(readFileSync(reviewerPath, "utf-8"));
expect(reviewerManifest.frontmatter.reportsTo).toBe("../ceo/AGENTS.md");
expect(readFileSync(strategySkillPath, "utf-8")).toContain("kind: skill");
expect(result.filesWritten).toEqual(
expect.arrayContaining([companyPath, reviewerPath, strategySkillPath]),
);
});
it("slugifies names for directories", async () => {
const outputDir = createTempDir();
const agent = makeAgent({
id: "agent-qa",
name: "Lead QA / Ops!",
});
const result = await exportAgentsToDirectory([agent], outputDir);
expect(result.agentsExported).toBe(1);
expect(readFileSync(join(outputDir, "agents", "lead-qa-ops", "AGENTS.md"), "utf-8")).toContain(
"name: Lead QA / Ops!",
);
expect(slugify("Lead QA / Ops!")).toBe("lead-qa-ops");
});
it("handles optional fields when reportsTo, instructionsText, and skills are absent", async () => {
const outputDir = createTempDir();
const agent = makeAgent({
id: "agent-solo",
name: "Solo",
reportsTo: undefined,
instructionsText: undefined,
metadata: {},
});
const result = await exportAgentsToDirectory([agent], outputDir, { includeSkills: false });
expect(result.skillsExported).toBe(0);
const parsed = parseYamlFrontmatter(
readFileSync(join(outputDir, "agents", "solo", "AGENTS.md"), "utf-8"),
);
expect(parsed.frontmatter.reportsTo).toBeNull();
expect(parsed.frontmatter.skills).toEqual([]);
expect(parsed.body).toBe("");
});
it("collects errors for invalid agents and continues export", async () => {
const outputDir = createTempDir();
const valid = makeAgent({ id: "agent-valid", name: "Valid Agent" });
const invalid = makeAgent({ id: "agent-invalid", name: " " });
const result = await exportAgentsToDirectory([invalid, valid], outputDir);
expect(result.agentsExported).toBe(1);
expect(result.errors).toEqual([
{
agentId: "agent-invalid",
error: "Agent name is required for export",
},
]);
expect(readFileSync(join(outputDir, "agents", "valid-agent", "AGENTS.md"), "utf-8")).toContain(
"name: Valid Agent",
);
});
it("captures per-agent write errors", async () => {
const outputDir = createTempDir();
const conflictPath = join(outputDir, "agents", "ceo");
mkdirSync(dirname(conflictPath), { recursive: true });
writeFileSync(conflictPath, "not-a-directory", "utf-8");
const result = await exportAgentsToDirectory(
[
makeAgent({ id: "agent-ceo", name: "CEO" }),
makeAgent({ id: "agent-cto", name: "CTO" }),
],
outputDir,
);
expect(result.agentsExported).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.agentId).toBe("agent-ceo");
expect(readFileSync(join(outputDir, "agents", "cto", "AGENTS.md"), "utf-8")).toContain(
"name: CTO",
);
});
});

View File

@@ -0,0 +1,306 @@
import { mkdir, writeFile } from "node:fs/promises";
import { resolve, join } from "node:path";
import { stringify as stringifyYaml } from "yaml";
import type { Agent } from "./types.js";
import type { AgentManifest } from "./agent-companies-types.js";
export interface ExportOptions {
companyName?: string;
companyDescription?: string;
companySlug?: string;
includeSkills?: boolean;
}
export interface ExportResult {
outputDir: string;
agentsExported: number;
skillsExported: number;
filesWritten: string[];
errors: Array<{ agentId: string; error: string }>;
}
interface AgentManifestOverrides {
reportsTo?: string | null;
skills?: string[];
}
interface SkillInfo {
name: string;
slug: string;
}
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function slugify(value: string, fallback = "item"): string {
const normalized = value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return normalized || fallback;
}
function ensureUniqueSlug(base: string, used: Set<string>): string {
if (!used.has(base)) {
used.add(base);
return base;
}
let counter = 2;
let candidate = `${base}-${counter}`;
while (used.has(candidate)) {
counter += 1;
candidate = `${base}-${counter}`;
}
used.add(candidate);
return candidate;
}
function toFrontmatterMarkdown(frontmatter: Record<string, unknown>, body: string): string {
const yaml = stringifyYaml(frontmatter, { lineWidth: 0 }).trimEnd();
return `---\n${yaml}\n---\n${body}`;
}
function extractSkills(agent: Agent): string[] {
const rawSkills = (agent.metadata as Record<string, unknown> | undefined)?.skills;
if (!Array.isArray(rawSkills)) {
return [];
}
const names = rawSkills
.map((entry) => {
if (typeof entry === "string") {
return trimToUndefined(entry);
}
if (entry && typeof entry === "object") {
const namedEntry = (entry as Record<string, unknown>).name;
return trimToUndefined(namedEntry);
}
return undefined;
})
.filter((entry): entry is string => typeof entry === "string");
return [...new Set(names)];
}
export function agentToCompaniesManifest(
agent: Agent,
overrides?: AgentManifestOverrides,
): AgentManifest {
const metadata = (agent.metadata ?? {}) as Record<string, unknown>;
const description = trimToUndefined(metadata.description);
return {
name: agent.name,
title: trimToUndefined(agent.title),
icon: trimToUndefined(agent.icon),
role: agent.role,
reportsTo:
overrides?.reportsTo !== undefined
? overrides.reportsTo
: agent.reportsTo
? agent.reportsTo
: null,
skills: overrides?.skills ?? extractSkills(agent),
description,
schema: "agentcompanies/v1",
instructionBody: trimToUndefined(agent.instructionsText) ?? "",
};
}
export function generateCompanyMd(
agents: Agent[],
options?: { name?: string; description?: string; slug?: string },
): string {
const topLevelAgent = agents.find((agent) => !trimToUndefined(agent.reportsTo)) ?? agents[0];
const topLevelMetadata = (topLevelAgent?.metadata ?? {}) as Record<string, unknown>;
const name =
trimToUndefined(options?.name)
?? trimToUndefined(topLevelMetadata.companyName)
?? trimToUndefined(topLevelAgent?.name)
?? "Fusion Agent Company";
const description =
trimToUndefined(options?.description)
?? trimToUndefined(topLevelMetadata.companyDescription)
?? "Exported from Fusion";
const slug = trimToUndefined(options?.slug) ?? slugify(name, "company");
const frontmatter = {
name,
description,
slug,
schema: "agentcompanies/v1",
};
return toFrontmatterMarkdown(frontmatter, description);
}
export function generateAgentMd(agent: Agent): string {
const manifest = agentToCompaniesManifest(agent);
const frontmatter: Record<string, unknown> = {
name: manifest.name,
title: manifest.title,
icon: manifest.icon,
role: manifest.role,
reportsTo: manifest.reportsTo,
skills: manifest.skills,
description: manifest.description,
schema: manifest.schema,
};
return toFrontmatterMarkdown(frontmatter, manifest.instructionBody ?? "");
}
function generateSkillMd(skillName: string): string {
return toFrontmatterMarkdown(
{
name: skillName,
schema: "agentcompanies/v1",
kind: "skill",
},
`# ${skillName}\n\n<!-- Add skill instructions here. -->`,
);
}
export async function exportAgentsToDirectory(
agents: Agent[],
outputDir: string,
options?: ExportOptions,
): Promise<ExportResult> {
const resolvedOutputDir = resolve(outputDir);
const includeSkills = options?.includeSkills ?? true;
const result: ExportResult = {
outputDir: resolvedOutputDir,
agentsExported: 0,
skillsExported: 0,
filesWritten: [],
errors: [],
};
await mkdir(resolvedOutputDir, { recursive: true });
await mkdir(join(resolvedOutputDir, "agents"), { recursive: true });
const companyMdPath = join(resolvedOutputDir, "COMPANY.md");
const companyMd = generateCompanyMd(agents, {
name: options?.companyName,
description: options?.companyDescription,
slug: options?.companySlug,
});
await writeFile(companyMdPath, companyMd, "utf-8");
result.filesWritten.push(companyMdPath);
const validAgents = agents.filter((agent) => {
if (!trimToUndefined(agent.name)) {
result.errors.push({
agentId: agent.id || "unknown",
error: "Agent name is required for export",
});
return false;
}
return true;
});
const usedAgentSlugs = new Set<string>();
const agentSlugById = new Map<string, string>();
for (const agent of validAgents) {
const baseSlug = slugify(agent.name, "agent");
const uniqueSlug = ensureUniqueSlug(baseSlug, usedAgentSlugs);
agentSlugById.set(agent.id, uniqueSlug);
}
const skillByName = new Map<string, SkillInfo>();
const usedSkillSlugs = new Set<string>();
for (const agent of validAgents) {
const agentSlug = agentSlugById.get(agent.id) ?? slugify(agent.name, "agent");
const skillNames = extractSkills(agent);
const skillRefs: string[] = [];
for (const skillName of skillNames) {
const existing = skillByName.get(skillName);
if (existing) {
skillRefs.push(existing.slug);
continue;
}
const skillSlug = ensureUniqueSlug(slugify(skillName, "skill"), usedSkillSlugs);
skillByName.set(skillName, { name: skillName, slug: skillSlug });
skillRefs.push(skillSlug);
}
let reportsTo: string | null = null;
const parentId = trimToUndefined(agent.reportsTo);
if (parentId) {
const parentSlug = agentSlugById.get(parentId);
reportsTo = parentSlug ? `../${parentSlug}/AGENTS.md` : parentId;
}
const manifest = agentToCompaniesManifest(agent, {
reportsTo,
skills: skillRefs,
});
try {
const agentDir = join(resolvedOutputDir, "agents", agentSlug);
const agentMdPath = join(agentDir, "AGENTS.md");
await mkdir(agentDir, { recursive: true });
const frontmatter: Record<string, unknown> = {
name: manifest.name,
title: manifest.title,
icon: manifest.icon,
role: manifest.role,
reportsTo: manifest.reportsTo,
skills: manifest.skills,
description: manifest.description,
schema: manifest.schema,
};
const content = toFrontmatterMarkdown(frontmatter, manifest.instructionBody ?? "");
await writeFile(agentMdPath, content, "utf-8");
result.agentsExported += 1;
result.filesWritten.push(agentMdPath);
} catch (error) {
result.errors.push({
agentId: agent.id,
error: (error as Error).message,
});
}
}
if (includeSkills && skillByName.size > 0) {
const skillsDir = join(resolvedOutputDir, "skills");
await mkdir(skillsDir, { recursive: true });
for (const skill of skillByName.values()) {
const skillDir = join(skillsDir, skill.slug);
const skillPath = join(skillDir, "SKILL.md");
await mkdir(skillDir, { recursive: true });
await writeFile(skillPath, generateSkillMd(skill.name), "utf-8");
result.skillsExported += 1;
result.filesWritten.push(skillPath);
}
}
return result;
}

View File

@@ -52,6 +52,8 @@ export interface TeamManifest extends AgentCompaniesFrontmatter {
export interface AgentManifest extends AgentCompaniesFrontmatter {
title?: string;
icon?: string;
role?: string;
reportsTo?: string | null;
skills?: string[];
instructionBody?: string;

View File

@@ -259,3 +259,17 @@ export {
convertAgentCompanies,
AgentCompaniesParseError,
} from "./agent-companies-parser.js";
// ── Agent Companies Exporter ──────────────────────────────
export {
slugify,
agentToCompaniesManifest,
generateCompanyMd,
generateAgentMd,
exportAgentsToDirectory,
} from "./agent-companies-exporter.js";
export type {
ExportOptions,
ExportResult,
} from "./agent-companies-exporter.js";

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockListAgents = vi.fn().mockResolvedValue([]);
const mockExportAgentsToDirectory = vi.fn();
vi.mock("@fusion/core", () => {
return {
AgentStore: class MockAgentStore {
init = mockInit;
listAgents = mockListAgents;
},
exportAgentsToDirectory: (...args: unknown[]) => mockExportAgentsToDirectory(...args),
};
});
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1190-test";
}
getFusionDir(): string {
return "/tmp/fn-1190-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
async function postExport(app: Parameters<typeof request>[0], body: unknown) {
return request(app, "POST", "/api/agents/export", JSON.stringify(body), {
"content-type": "application/json",
});
}
describe("POST /api/agents/export", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let testDir: string;
beforeEach(async () => {
vi.clearAllMocks();
testDir = mkdtempSync(join(tmpdir(), "kb-agent-export-route-"));
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([
{
id: "agent-1",
name: "CEO",
role: "executor",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
{
id: "agent-2",
name: "Reviewer",
role: "reviewer",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
]);
mockExportAgentsToDirectory.mockResolvedValue({
outputDir: join(testDir, "export"),
agentsExported: 2,
skillsExported: 1,
filesWritten: [join(testDir, "export", "COMPANY.md")],
errors: [],
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
rmSync(testDir, { recursive: true, force: true });
});
it("exports all agents when agentIds is omitted", async () => {
const response = await postExport(app, {});
expect(response.status).toBe(200);
expect(mockExportAgentsToDirectory).toHaveBeenCalledTimes(1);
const [agentsArg, outputDirArg] = mockExportAgentsToDirectory.mock.calls[0] ?? [];
expect(agentsArg).toHaveLength(2);
expect(typeof outputDirArg).toBe("string");
const body = response.body as any;
expect(body.agentsExported).toBe(2);
expect(body.skillsExported).toBe(1);
});
it("exports only requested agent IDs", async () => {
const response = await postExport(app, { agentIds: ["agent-2"] });
expect(response.status).toBe(200);
const [agentsArg] = mockExportAgentsToDirectory.mock.calls[0] ?? [];
expect(agentsArg).toHaveLength(1);
expect(agentsArg[0]?.id).toBe("agent-2");
});
it("passes custom company options and output directory", async () => {
const customOutputDir = join(testDir, "custom-output");
const response = await postExport(app, {
companyName: "Acme AI",
companySlug: "acme-ai",
outputDir: customOutputDir,
});
expect(response.status).toBe(200);
const [, outputDirArg, optionsArg] = mockExportAgentsToDirectory.mock.calls[0] ?? [];
expect(outputDirArg).toBe(customOutputDir);
expect(optionsArg).toEqual({ companyName: "Acme AI", companySlug: "acme-ai" });
});
it("returns 400 when no agents are available", async () => {
mockListAgents.mockResolvedValue([]);
const response = await postExport(app, {});
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("No agents found to export");
});
it("returns 400 for invalid outputDir type", async () => {
const response = await postExport(app, { outputDir: 123 });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("outputDir must be a string");
});
});

View File

@@ -1,8 +1,10 @@
import { Router, type Request, type Response, type NextFunction } from "express";
import multer from "multer";
import { createReadStream, existsSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { execSync } from "node:child_process";
import { resolve, sep, join } from "node:path";
import { tmpdir } from "node:os";
import * as nodeFs from "node:fs";
import * as nodeChildProcess from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep, MessageType, ParticipantType, MessageCreateInput } from "@fusion/core";
@@ -7527,6 +7529,75 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* POST /api/agents/export
* Export agents to an Agent Companies package directory.
*
* Body:
* - { agentIds?: string[]; companyName?: string; companySlug?: string; outputDir?: string }
*/
router.post("/agents/export", async (req, res) => {
try {
const { agentIds, companyName, companySlug, outputDir } = req.body ?? {};
if (agentIds !== undefined) {
if (!Array.isArray(agentIds)) {
throw badRequest("agentIds must be an array of strings");
}
if (agentIds.some((id: unknown) => typeof id !== "string" || id.trim().length === 0)) {
throw badRequest("agentIds must contain non-empty strings");
}
}
if (companyName !== undefined && typeof companyName !== "string") {
throw badRequest("companyName must be a string");
}
if (companySlug !== undefined && typeof companySlug !== "string") {
throw badRequest("companySlug must be a string");
}
if (outputDir !== undefined && typeof outputDir !== "string") {
throw badRequest("outputDir must be a string");
}
const scopedStore = await getScopedStore(req);
const { AgentStore, exportAgentsToDirectory } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const allAgents = await agentStore.listAgents();
const requestedIds = Array.isArray(agentIds) ? [...new Set(agentIds.map((id) => id.trim()))] : [];
const agentsToExport = requestedIds.length > 0
? allAgents.filter((agent: any) => requestedIds.includes(agent.id))
: allAgents;
if (agentsToExport.length === 0) {
throw badRequest("No agents found to export");
}
let resolvedOutputDir: string;
if (typeof outputDir === "string" && outputDir.trim().length > 0) {
resolvedOutputDir = resolve(outputDir.trim());
} else if (typeof outputDir === "string") {
throw badRequest("outputDir cannot be empty");
} else {
resolvedOutputDir = await mkdtemp(join(tmpdir(), "fusion-agent-export-"));
}
const result = await exportAgentsToDirectory(agentsToExport, resolvedOutputDir, {
companyName: typeof companyName === "string" ? companyName : undefined,
companySlug: typeof companySlug === "string" ? companySlug : undefined,
});
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/agents/import
* Import agents from Agent Companies sources.