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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user