feat(FN-1174): migrate agent import flow to agent companies format
- Replace legacy companies.sh parser/types with the new agent companies parser and exported core types. - Update CLI agent import wiring and tests to consume the new import source handling. - Update dashboard agent import modal, API client, and import route tests to support archive-based sources. - Add release-note changesets and lockfile updates for the @gsxdsm/fusion patch release.
This commit is contained in:
@@ -5,22 +5,6 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { AgentStore } from "@fusion/core";
|
||||
import { runAgentImport } from "./agent-import.js";
|
||||
import type { CompaniesShAgent } from "@fusion/core";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function encodeManifest(agents: unknown[]): string {
|
||||
return Buffer.from(JSON.stringify(agents)).toString("base64");
|
||||
}
|
||||
|
||||
function makeScript(companyName: string, agents: unknown[], envLines?: string[]): string {
|
||||
const manifest = encodeManifest(agents);
|
||||
let script = `#!/bin/bash\n# Agent Company Manifest\nCOMPANY_NAME="${companyName}"\nAGENT_MANIFEST="${manifest}"`;
|
||||
if (envLines && envLines.length > 0) {
|
||||
script += "\n\n" + envLines.join("\n");
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
function makeAgentManifest(options: {
|
||||
name: string;
|
||||
@@ -46,7 +30,14 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
|
||||
mkdirSync(basePath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(basePath, "COMPANY.md"),
|
||||
"---\nname: Example Company\n---\nCompany description",
|
||||
"---\nname: Example Company\nslug: example-company\n---\nCompany description",
|
||||
);
|
||||
|
||||
const teamDir = join(basePath, "teams", "engineering");
|
||||
mkdirSync(teamDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(teamDir, "TEAM.md"),
|
||||
"---\nname: Engineering\nmanager: ../ceo/AGENTS.md\n---",
|
||||
);
|
||||
|
||||
const agentDir = join(basePath, "agents", "ceo");
|
||||
@@ -56,7 +47,7 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
|
||||
makeAgentManifest({
|
||||
name: agentName,
|
||||
title: "Chief Executive",
|
||||
skills: ["executor"],
|
||||
skills: ["review"],
|
||||
body: "Lead the company",
|
||||
}),
|
||||
);
|
||||
@@ -64,10 +55,8 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("agent-import", () => {
|
||||
const tmpDir = join(tmpdir(), "kb-agent-import-test-" + process.pid);
|
||||
const tmpDir = join(tmpdir(), `kb-agent-import-test-${process.pid}`);
|
||||
let createAgentMock: ReturnType<typeof vi.fn>;
|
||||
let listAgentsMock: ReturnType<typeof vi.fn>;
|
||||
let initMock: ReturnType<typeof vi.fn>;
|
||||
@@ -92,171 +81,44 @@ describe("agent-import", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reports error on invalid file path", async () => {
|
||||
it("reports error on invalid source path", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
runAgentImport(join(tmpDir, "nonexistent.sh")),
|
||||
).rejects.toThrow("process.exit");
|
||||
await expect(runAgentImport(join(tmpDir, "missing"))).rejects.toThrow("process.exit");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("File not found"),
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Path not found"));
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reports parse error on invalid manifest", async () => {
|
||||
const badFile = join(tmpDir, "bad.sh");
|
||||
writeFileSync(badFile, "#!/bin/bash\nCOMPANY_NAME=\"test\"\nAGENT_MANIFEST=\"not-valid!!!\"");
|
||||
it("reports parse error on malformed AGENTS.md", async () => {
|
||||
const manifestPath = join(tmpDir, "AGENTS.md");
|
||||
writeFileSync(manifestPath, "name: missing frontmatter delimiters");
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
runAgentImport(badFile),
|
||||
).rejects.toThrow("process.exit");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Parse error"),
|
||||
);
|
||||
await expect(runAgentImport(manifestPath)).rejects.toThrow("process.exit");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Parse error"));
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles empty manifest gracefully", async () => {
|
||||
const emptyFile = join(tmpDir, "empty.sh");
|
||||
writeFileSync(emptyFile, makeScript("empty-co", []));
|
||||
|
||||
it("handles empty directory gracefully", async () => {
|
||||
const emptyDir = join(tmpDir, "empty-company");
|
||||
mkdirSync(emptyDir, { recursive: true });
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runAgentImport(emptyFile);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("No agents found"),
|
||||
);
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows dry-run preview without creating agents", async () => {
|
||||
const agents: CompaniesShAgent[] = [
|
||||
{ name: "Preview Agent 1", role: "executor" },
|
||||
{ name: "Preview Agent 2", role: "reviewer" },
|
||||
];
|
||||
const manifestFile = join(tmpDir, "preview.sh");
|
||||
writeFileSync(manifestFile, makeScript("preview-co", agents));
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runAgentImport(manifestFile, { dryRun: true });
|
||||
|
||||
// Should show DRY RUN prefix
|
||||
const output = logSpy.mock.calls.flat().join(" ");
|
||||
expect(output).toContain("[DRY RUN]");
|
||||
expect(output).toContain("Preview Agent 1");
|
||||
expect(output).toContain("Preview Agent 2");
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("creates agents from valid manifest", async () => {
|
||||
const agents: CompaniesShAgent[] = [
|
||||
{ name: "New Agent", role: "executor", metadata: { title: "Test Executor" } },
|
||||
{ name: "Another Agent", role: "reviewer" },
|
||||
];
|
||||
const manifestFile = join(tmpDir, "create.sh");
|
||||
writeFileSync(manifestFile, makeScript("test-co", agents));
|
||||
|
||||
const createdAgents: Array<Record<string, unknown>> = [];
|
||||
createAgentMock.mockImplementation(async (input: any) => {
|
||||
createdAgents.push(input);
|
||||
return { id: `agent-${createdAgents.length}`, ...input };
|
||||
});
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runAgentImport(manifestFile);
|
||||
|
||||
expect(createAgentMock).toHaveBeenCalledTimes(2);
|
||||
expect(createdAgents[0]).toEqual(
|
||||
expect.objectContaining({ name: "New Agent", role: "executor", title: "Test Executor" }),
|
||||
);
|
||||
expect(createdAgents[1]).toEqual(
|
||||
expect.objectContaining({ name: "Another Agent", role: "reviewer" }),
|
||||
);
|
||||
|
||||
const output = logSpy.mock.calls.flat().join(" ");
|
||||
expect(output).toContain("Created: 2");
|
||||
expect(output).toContain("New Agent");
|
||||
expect(output).toContain("Another Agent");
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("skips existing agents with --skip-existing", async () => {
|
||||
const agents: CompaniesShAgent[] = [
|
||||
{ name: "Existing Agent", role: "executor" },
|
||||
{ name: "New Agent", role: "reviewer" },
|
||||
];
|
||||
const manifestFile = join(tmpDir, "skip.sh");
|
||||
writeFileSync(manifestFile, makeScript("skip-co", agents));
|
||||
|
||||
listAgentsMock.mockResolvedValue([
|
||||
{ id: "agent-1", name: "Existing Agent", role: "executor" },
|
||||
]);
|
||||
|
||||
const createdAgents: Array<Record<string, unknown>> = [];
|
||||
createAgentMock.mockImplementation(async (input: any) => {
|
||||
createdAgents.push(input);
|
||||
return { id: `agent-${createdAgents.length}`, ...input };
|
||||
});
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runAgentImport(manifestFile, { skipExisting: true });
|
||||
|
||||
// Only the new agent should be created
|
||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(createdAgents).toEqual([
|
||||
expect.objectContaining({ name: "New Agent", role: "reviewer" }),
|
||||
]);
|
||||
|
||||
const output = logSpy.mock.calls.flat().join(" ");
|
||||
expect(output).toContain("Skipped: 1");
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reports creation errors in summary", async () => {
|
||||
const agents: CompaniesShAgent[] = [
|
||||
{ name: "Good Agent", role: "executor" },
|
||||
{ name: "Bad Agent", role: "reviewer" },
|
||||
];
|
||||
const manifestFile = join(tmpDir, "mixed.sh");
|
||||
writeFileSync(manifestFile, makeScript("mixed-co", agents));
|
||||
|
||||
createAgentMock
|
||||
.mockResolvedValueOnce({ id: "agent-1", name: "Good Agent" })
|
||||
.mockRejectedValueOnce(new Error("Database error"));
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runAgentImport(manifestFile);
|
||||
|
||||
const output = logSpy.mock.calls.flat().join(" ");
|
||||
expect(output).toContain("Created: 1");
|
||||
expect(output).toContain("Errors: 1");
|
||||
expect(output).toContain("Bad Agent");
|
||||
expect(output).toContain("Database error");
|
||||
await runAgentImport(emptyDir);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No agents found"));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -267,18 +129,18 @@ describe("agent-import", () => {
|
||||
|
||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAgentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "CEO", role: "executor", title: "Chief Executive" }),
|
||||
expect.objectContaining({ name: "CEO", role: "custom", title: "Chief Executive" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("imports agents from a single .md AGENTS manifest", async () => {
|
||||
it("imports agents from a single AGENTS.md file", async () => {
|
||||
const manifestPath = join(tmpDir, "AGENTS.md");
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
makeAgentManifest({
|
||||
name: "Solo Agent",
|
||||
title: "Single File Agent",
|
||||
skills: ["reviewer"],
|
||||
skills: ["review"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -286,7 +148,7 @@ describe("agent-import", () => {
|
||||
|
||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAgentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Solo Agent", role: "reviewer" }),
|
||||
expect.objectContaining({ name: "Solo Agent", role: "custom" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -300,11 +162,11 @@ describe("agent-import", () => {
|
||||
|
||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAgentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Archive CEO", role: "executor" }),
|
||||
expect.objectContaining({ name: "Archive CEO", role: "custom" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports dry-run for directory imports", async () => {
|
||||
it("supports dry-run mode", async () => {
|
||||
const companyDir = createCompanyDirectory(join(tmpDir, "company-dry-run"));
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
@@ -313,22 +175,23 @@ describe("agent-import", () => {
|
||||
expect(createAgentMock).not.toHaveBeenCalled();
|
||||
const output = logSpy.mock.calls.flat().join(" ");
|
||||
expect(output).toContain("[DRY RUN]");
|
||||
expect(output).toContain("CEO");
|
||||
expect(output).toContain("Agents: 1");
|
||||
expect(output).toContain("Teams: 1");
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("supports skip-existing for directory imports", async () => {
|
||||
it("supports skip-existing", async () => {
|
||||
const companyDir = createCompanyDirectory(join(tmpDir, "company-skip"));
|
||||
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "CEO", role: "executor" }]);
|
||||
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "CEO", role: "custom" }]);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -343,9 +206,7 @@ describe("agent-import", () => {
|
||||
|
||||
await expect(runAgentImport(unsupportedPath)).rejects.toThrow("process.exit");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unsupported format"),
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unsupported format"));
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
|
||||
@@ -13,18 +13,15 @@ import {
|
||||
AgentStore,
|
||||
parseCompanyDirectory,
|
||||
parseCompanyArchive,
|
||||
parseAgentManifest,
|
||||
parseSingleAgentManifest,
|
||||
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.";
|
||||
"Unsupported format. Provide an Agent Companies directory, .tar.gz/.tgz/.zip archive, or AGENTS.md file.";
|
||||
|
||||
/**
|
||||
* Get the project path for agent operations.
|
||||
@@ -49,6 +46,8 @@ async function getProjectPath(projectName?: string): Promise<string> {
|
||||
*/
|
||||
function printSummary(
|
||||
companyName: string | undefined,
|
||||
agentCount: number,
|
||||
teamCount: number,
|
||||
created: string[],
|
||||
skipped: string[],
|
||||
errors: Array<{ name: string; error: string }>,
|
||||
@@ -56,7 +55,9 @@ function printSummary(
|
||||
): void {
|
||||
const prefix = dryRun ? "[DRY RUN] " : "";
|
||||
console.log();
|
||||
console.log(` ${prefix}Import from company: ${companyName ?? "Unknown"}`);
|
||||
console.log(` ${prefix}Company: ${companyName ?? "Unknown"}`);
|
||||
console.log(` ${prefix}Agents: ${agentCount}`);
|
||||
console.log(` ${prefix}Teams: ${teamCount}`);
|
||||
console.log(` ${prefix}Created: ${created.length}`);
|
||||
for (const name of created) {
|
||||
console.log(` ✓ ${name}`);
|
||||
@@ -99,7 +100,7 @@ export async function runAgentImport(
|
||||
|
||||
const sourcePath = resolve(source);
|
||||
if (!existsSync(sourcePath)) {
|
||||
console.error(`File not found: ${sourcePath}`);
|
||||
console.error(`Path not found: ${sourcePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -110,8 +111,11 @@ export async function runAgentImport(
|
||||
|
||||
const existingAgents = await agentStore.listAgents();
|
||||
const existingNames = new Set(existingAgents.map((a) => a.name));
|
||||
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
|
||||
|
||||
let companyName: string | undefined;
|
||||
let agentCount = 0;
|
||||
let teamCount = 0;
|
||||
let inputs: AgentCreateInput[] = [];
|
||||
let result: {
|
||||
created: string[];
|
||||
@@ -129,45 +133,33 @@ export async function runAgentImport(
|
||||
if (sourceStats.isDirectory()) {
|
||||
const pkg = parseCompanyDirectory(sourcePath);
|
||||
companyName = pkg.company?.name;
|
||||
({ inputs, result } = convertAgentCompanies(
|
||||
pkg,
|
||||
skipExisting ? { skipExisting: [...existingNames] } : undefined,
|
||||
));
|
||||
agentCount = pkg.agents.length;
|
||||
teamCount = pkg.teams.length;
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
} else if (isArchivePath(sourcePath)) {
|
||||
const pkg = await parseCompanyArchive(sourcePath);
|
||||
companyName = pkg.company?.name;
|
||||
({ inputs, result } = convertAgentCompanies(
|
||||
pkg,
|
||||
skipExisting ? { skipExisting: [...existingNames] } : undefined,
|
||||
));
|
||||
agentCount = pkg.agents.length;
|
||||
teamCount = pkg.teams.length;
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
} else if (sourcePath.endsWith(".md")) {
|
||||
const content = readFileSync(sourcePath, "utf-8");
|
||||
const manifest = parseAgentManifest(content);
|
||||
const { manifest } = parseSingleAgentManifest(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,
|
||||
));
|
||||
agentCount = pkg.agents.length;
|
||||
teamCount = 0;
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
} else {
|
||||
throw new Error(UNSUPPORTED_FORMAT_MESSAGE);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AgentCompaniesParseError || err instanceof CompaniesShParseError) {
|
||||
if (err instanceof AgentCompaniesParseError) {
|
||||
console.error(`Parse error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -190,7 +182,7 @@ export async function runAgentImport(
|
||||
|
||||
// Dry run: just preview
|
||||
if (dryRun) {
|
||||
printSummary(companyName, result.created, result.skipped, result.errors, true);
|
||||
printSummary(companyName, agentCount, teamCount, result.created, result.skipped, result.errors, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,5 +205,5 @@ export async function runAgentImport(
|
||||
}
|
||||
}
|
||||
|
||||
printSummary(companyName, created, result.skipped, errors, false);
|
||||
printSummary(companyName, agentCount, teamCount, created, result.skipped, errors, false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user