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);
|
||||
}
|
||||
255
packages/core/src/agent-companies-exporter.test.ts
Normal file
255
packages/core/src/agent-companies-exporter.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
306
packages/core/src/agent-companies-exporter.ts
Normal file
306
packages/core/src/agent-companies-exporter.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
152
packages/dashboard/src/__tests__/routes-agent-export.test.ts
Normal file
152
packages/dashboard/src/__tests__/routes-agent-export.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user