feat(FN-1189): add Agent Companies import support

- Expand fn agent import to accept directories, .tar.gz/.tgz/.zip archives, single .md manifests, and legacy .sh manifests
- Update /api/agents/import to handle agents array, source path, or manifest string inputs with dry-run previews and legacy fallback parsing
- Refresh AgentImportModal and dashboard API typing for new preview metadata, directory uploads, and improved error handling
- Add coverage for CLI import paths, dashboard import routes, modal behavior, and assignment route timeout stability
- Add a minor @gsxdsm/fusion changeset documenting Agent Companies import support
This commit is contained in:
gsxdsm
2026-04-08 07:13:25 -07:00
parent 872369faed
commit cb24d6ccca
10 changed files with 803 additions and 277 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
import { execSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
@@ -21,6 +22,48 @@ function makeScript(companyName: string, agents: unknown[], envLines?: string[])
return script;
}
function makeAgentManifest(options: {
name: string;
title?: string;
skills?: string[];
body?: string;
}): string {
const lines = ["---", `name: ${options.name}`];
if (options.title) {
lines.push(`title: ${options.title}`);
}
if (options.skills && options.skills.length > 0) {
lines.push("skills:");
for (const skill of options.skills) {
lines.push(` - ${skill}`);
}
}
lines.push("---", options.body ?? `${options.name} instructions`);
return lines.join("\n");
}
function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
mkdirSync(basePath, { recursive: true });
writeFileSync(
join(basePath, "COMPANY.md"),
"---\nname: Example Company\n---\nCompany description",
);
const agentDir = join(basePath, "agents", "ceo");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, "AGENTS.md"),
makeAgentManifest({
name: agentName,
title: "Chief Executive",
skills: ["executor"],
body: "Lead the company",
}),
);
return basePath;
}
// ── Tests ────────────────────────────────────────────────────────────────
describe("agent-import", () => {
@@ -34,6 +77,10 @@ describe("agent-import", () => {
createAgentMock = vi.fn();
listAgentsMock = vi.fn().mockResolvedValue([]);
initMock = vi.fn().mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "init").mockImplementation(initMock);
vi.spyOn(AgentStore.prototype, "listAgents").mockImplementation(listAgentsMock);
vi.spyOn(AgentStore.prototype, "createAgent").mockImplementation(createAgentMock);
});
afterEach(() => {
@@ -134,10 +181,6 @@ describe("agent-import", () => {
return { id: `agent-${createdAgents.length}`, ...input };
});
vi.spyOn(AgentStore.prototype, "init").mockImplementation(initMock);
vi.spyOn(AgentStore.prototype, "listAgents").mockImplementation(listAgentsMock);
vi.spyOn(AgentStore.prototype, "createAgent").mockImplementation(createAgentMock);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(manifestFile);
@@ -176,10 +219,6 @@ describe("agent-import", () => {
return { id: `agent-${createdAgents.length}`, ...input };
});
vi.spyOn(AgentStore.prototype, "init").mockImplementation(initMock);
vi.spyOn(AgentStore.prototype, "listAgents").mockImplementation(listAgentsMock);
vi.spyOn(AgentStore.prototype, "createAgent").mockImplementation(createAgentMock);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(manifestFile, { skipExisting: true });
@@ -208,10 +247,6 @@ describe("agent-import", () => {
.mockResolvedValueOnce({ id: "agent-1", name: "Good Agent" })
.mockRejectedValueOnce(new Error("Database error"));
vi.spyOn(AgentStore.prototype, "init").mockImplementation(initMock);
vi.spyOn(AgentStore.prototype, "listAgents").mockImplementation(listAgentsMock);
vi.spyOn(AgentStore.prototype, "createAgent").mockImplementation(createAgentMock);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(manifestFile);
@@ -224,4 +259,95 @@ describe("agent-import", () => {
logSpy.mockRestore();
});
it("imports agents from an Agent Companies directory", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-dir"));
await runAgentImport(companyDir);
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "CEO", role: "executor", title: "Chief Executive" }),
);
});
it("imports agents from a single .md AGENTS manifest", async () => {
const manifestPath = join(tmpDir, "AGENTS.md");
writeFileSync(
manifestPath,
makeAgentManifest({
name: "Solo Agent",
title: "Single File Agent",
skills: ["reviewer"],
}),
);
await runAgentImport(manifestPath);
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "Solo Agent", role: "reviewer" }),
);
});
it("imports agents from a .tar.gz archive", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-archive-src"), "Archive CEO");
const archivePath = join(tmpDir, "company.tar.gz");
execSync(`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(companyDir)} .`);
await runAgentImport(archivePath);
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "Archive CEO", role: "executor" }),
);
});
it("supports dry-run for directory imports", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-dry-run"));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(companyDir, { dryRun: true });
expect(createAgentMock).not.toHaveBeenCalled();
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("[DRY RUN]");
expect(output).toContain("CEO");
logSpy.mockRestore();
});
it("supports skip-existing for directory imports", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-skip"));
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "CEO", role: "executor" }]);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(companyDir, { skipExisting: true });
expect(createAgentMock).not.toHaveBeenCalled();
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("Skipped: 1");
logSpy.mockRestore();
});
it("reports unsupported file formats", async () => {
const unsupportedPath = join(tmpDir, "manifest.json");
writeFileSync(unsupportedPath, JSON.stringify({ name: "Not a manifest" }));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runAgentImport(unsupportedPath)).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Unsupported format"),
);
exitSpy.mockRestore();
errorSpy.mockRestore();
});
});

View File

@@ -1,5 +1,5 @@
/**
* CLI command for importing agents from companies.sh manifests.
* CLI command for importing agents from Agent Companies packages.
*
* Usage:
* fn agent import <source> [--dry-run] [--skip-existing] [--project <name>]
@@ -7,11 +7,25 @@
* @module agent-import
*/
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { AgentStore, parseCompaniesShManifest, convertCompaniesShAgents, CompaniesShParseError } from "@fusion/core";
import {
AgentStore,
parseCompanyDirectory,
parseCompanyArchive,
parseAgentManifest,
convertAgentCompanies,
AgentCompaniesParseError,
parseCompaniesShManifest,
convertCompaniesShAgents,
CompaniesShParseError,
} from "@fusion/core";
import type { AgentCreateInput } from "@fusion/core";
import { resolveProject } from "../project-context.js";
const UNSUPPORTED_FORMAT_MESSAGE =
"Unsupported format. Provide a directory, .tar.gz/.zip archive, .md file, or .sh manifest.";
/**
* Get the project path for agent operations.
* Falls back to process.cwd() if no project is specified.
@@ -34,7 +48,7 @@ async function getProjectPath(projectName?: string): Promise<string> {
* Print a summary of the import result.
*/
function printSummary(
companyName: string,
companyName: string | undefined,
created: string[],
skipped: string[],
errors: Array<{ name: string; error: string }>,
@@ -42,7 +56,7 @@ function printSummary(
): void {
const prefix = dryRun ? "[DRY RUN] " : "";
console.log();
console.log(` ${prefix}Import from company: ${companyName}`);
console.log(` ${prefix}Import from company: ${companyName ?? "Unknown"}`);
console.log(` ${prefix}Created: ${created.length}`);
for (const name of created) {
console.log(`${name}`);
@@ -62,10 +76,14 @@ function printSummary(
console.log();
}
function isArchivePath(path: string): boolean {
return path.endsWith(".tar.gz") || path.endsWith(".tgz") || path.endsWith(".zip");
}
/**
* Run the agent import command.
*
* @param source - File path to a companies.sh manifest
* @param source - Path to an Agent Companies directory/archive/manifest source
* @param options - Command options
*/
export async function runAgentImport(
@@ -79,34 +97,12 @@ export async function runAgentImport(
const dryRun = options?.dryRun ?? false;
const skipExisting = options?.skipExisting ?? false;
// Resolve file path
const filePath = resolve(source);
if (!existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
const sourcePath = resolve(source);
if (!existsSync(sourcePath)) {
console.error(`File not found: ${sourcePath}`);
process.exit(1);
}
// Read and parse manifest
let manifest;
try {
const content = readFileSync(filePath, "utf-8");
manifest = parseCompaniesShManifest(content);
} catch (err) {
if (err instanceof CompaniesShParseError) {
console.error(`Parse error: ${err.message}`);
process.exit(1);
}
console.error(`Error reading file: ${(err as Error).message}`);
process.exit(1);
}
if (manifest.agents.length === 0) {
console.log();
console.log(" No agents found in manifest");
console.log();
return;
}
// Get existing agent names for skip logic
const projectPath = await getProjectPath(options?.project);
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
@@ -115,15 +111,86 @@ export async function runAgentImport(
const existingAgents = await agentStore.listAgents();
const existingNames = new Set(existingAgents.map((a) => a.name));
// Convert agents
const { inputs, result } = convertCompaniesShAgents(
manifest.agents,
skipExisting ? { skipExisting: [...existingNames] } : undefined,
);
let companyName: string | undefined;
let inputs: AgentCreateInput[] = [];
let result: {
created: string[];
skipped: string[];
errors: Array<{ name: string; error: string }>;
} = {
created: [],
skipped: [],
errors: [],
};
try {
const sourceStats = statSync(sourcePath);
if (sourceStats.isDirectory()) {
const pkg = parseCompanyDirectory(sourcePath);
companyName = pkg.company?.name;
({ inputs, result } = convertAgentCompanies(
pkg,
skipExisting ? { skipExisting: [...existingNames] } : undefined,
));
} else if (isArchivePath(sourcePath)) {
const pkg = await parseCompanyArchive(sourcePath);
companyName = pkg.company?.name;
({ inputs, result } = convertAgentCompanies(
pkg,
skipExisting ? { skipExisting: [...existingNames] } : undefined,
));
} else if (sourcePath.endsWith(".md")) {
const content = readFileSync(sourcePath, "utf-8");
const manifest = parseAgentManifest(content);
const pkg = {
company: undefined,
agents: [manifest],
teams: [],
projects: [],
tasks: [],
skills: [],
};
({ inputs, result } = convertAgentCompanies(
pkg,
skipExisting ? { skipExisting: [...existingNames] } : undefined,
));
} else if (sourcePath.endsWith(".sh")) {
const content = readFileSync(sourcePath, "utf-8");
const manifest = parseCompaniesShManifest(content);
companyName = manifest.companyName;
({ inputs, result } = convertCompaniesShAgents(
manifest.agents,
skipExisting ? { skipExisting: [...existingNames] } : undefined,
));
} else {
throw new Error(UNSUPPORTED_FORMAT_MESSAGE);
}
} catch (err) {
if (err instanceof AgentCompaniesParseError || err instanceof CompaniesShParseError) {
console.error(`Parse error: ${err.message}`);
process.exit(1);
}
if (err instanceof Error && err.message === UNSUPPORTED_FORMAT_MESSAGE) {
console.error(err.message);
process.exit(1);
}
console.error(`Error reading source: ${(err as Error).message}`);
process.exit(1);
}
if (result.created.length === 0 && result.skipped.length === 0 && result.errors.length === 0) {
console.log();
console.log(" No agents found in manifest");
console.log();
return;
}
// Dry run: just preview
if (dryRun) {
printSummary(manifest.companyName, result.created, result.skipped, result.errors, true);
printSummary(companyName, result.created, result.skipped, result.errors, true);
return;
}
@@ -146,5 +213,5 @@ export async function runAgentImport(
}
}
printSummary(manifest.companyName, created, result.skipped, errors, false);
printSummary(companyName, created, result.skipped, errors, false);
}