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,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";