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:
@@ -2100,6 +2100,7 @@ export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Ta
|
||||
/** Result of importing agents from an Agent Companies source */
|
||||
export interface AgentImportResult {
|
||||
companyName?: string;
|
||||
companySlug?: string;
|
||||
agents?: Array<{ name: string; role: string; title?: string; skills?: string[] }>;
|
||||
/** In dry-run mode: agent name strings. In live mode: agent objects with id and name. */
|
||||
created: string[] | Array<{ id: string; name: string }>;
|
||||
@@ -2113,7 +2114,7 @@ export interface AgentImportResult {
|
||||
* Uses dryRun for preview, then actual import.
|
||||
*/
|
||||
export function importAgents(
|
||||
input: { manifest?: string; source?: string; agents?: unknown[] },
|
||||
input: { manifest: string } | { source: string } | { agents: unknown[] },
|
||||
options?: { dryRun?: boolean; skipExisting?: boolean },
|
||||
projectId?: string,
|
||||
): Promise<AgentImportResult> {
|
||||
|
||||
@@ -19,11 +19,19 @@ interface AgentPreview {
|
||||
/** Import result from the API */
|
||||
interface ImportResult {
|
||||
companyName?: string;
|
||||
companySlug?: string;
|
||||
created: Array<{ id: string; name: string }>;
|
||||
skipped: string[];
|
||||
errors: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
interface DirectoryAgentInput {
|
||||
name: string;
|
||||
title?: string;
|
||||
skills?: string[];
|
||||
instructionBody?: string;
|
||||
}
|
||||
|
||||
/** API error response shape */
|
||||
interface ApiErrorResponse {
|
||||
error: string;
|
||||
@@ -32,11 +40,62 @@ interface ApiErrorResponse {
|
||||
type ModalStep = "input" | "preview" | "result";
|
||||
type InputMethod = "paste" | "file" | "directory";
|
||||
|
||||
function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
||||
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
||||
if (!match) {
|
||||
throw new Error("Missing YAML frontmatter delimiters (---)");
|
||||
}
|
||||
|
||||
const frontmatterLines = match[1].split(/\r?\n/);
|
||||
const body = match[2] ?? "";
|
||||
const result: DirectoryAgentInput = { name: "" };
|
||||
const skills: string[] = [];
|
||||
let inSkills = false;
|
||||
|
||||
for (const rawLine of frontmatterLines) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.startsWith("skills:")) {
|
||||
inSkills = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSkills && trimmed.startsWith("- ")) {
|
||||
skills.push(trimmed.slice(2).trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
inSkills = false;
|
||||
|
||||
const [key, ...valueParts] = trimmed.split(":");
|
||||
const value = valueParts.join(":").trim();
|
||||
const normalizedValue = value.replace(/^['"]|['"]$/g, "");
|
||||
|
||||
if (key === "name") result.name = normalizedValue;
|
||||
if (key === "title") result.title = normalizedValue;
|
||||
}
|
||||
|
||||
if (!result.name) {
|
||||
throw new Error("Missing required field: name");
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
result.skills = skills;
|
||||
}
|
||||
if (body.trim().length > 0) {
|
||||
result.instructionBody = body;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for importing agents from Agent Companies manifests.
|
||||
*
|
||||
* Supports three input methods:
|
||||
* - File upload (.md/.txt/.sh files)
|
||||
* - File upload (.md/.txt files)
|
||||
* - Directory upload (webkitdirectory)
|
||||
* - Paste raw manifest content
|
||||
*
|
||||
@@ -46,6 +105,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
const [step, setStep] = useState<ModalStep>("input");
|
||||
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
|
||||
const [manifestContent, setManifestContent] = useState("");
|
||||
const [directoryAgents, setDirectoryAgents] = useState<DirectoryAgentInput[]>([]);
|
||||
const [companyName, setCompanyName] = useState("Unknown");
|
||||
const [agents, setAgents] = useState<AgentPreview[]>([]);
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
@@ -60,6 +120,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setStep("input");
|
||||
setInputMethod("paste");
|
||||
setManifestContent("");
|
||||
setDirectoryAgents([]);
|
||||
setCompanyName("Unknown");
|
||||
setAgents([]);
|
||||
setIsParsing(false);
|
||||
@@ -82,6 +143,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
reader.onload = (ev) => {
|
||||
const content = ev.target?.result as string;
|
||||
setInputMethod("file");
|
||||
setDirectoryAgents([]);
|
||||
setManifestContent(content);
|
||||
setParseError(null);
|
||||
};
|
||||
@@ -99,31 +161,31 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
if (files.length === 0) return;
|
||||
|
||||
try {
|
||||
const textFiles = files
|
||||
.filter((file) => /\.(md|txt|sh)$/i.test(file.name))
|
||||
const agentFiles = files
|
||||
.filter((file) => (file.webkitRelativePath || file.name).toLowerCase().endsWith("agents.md"))
|
||||
.sort((a, b) => {
|
||||
const aPath = a.webkitRelativePath || a.name;
|
||||
const bPath = b.webkitRelativePath || b.name;
|
||||
return aPath.localeCompare(bPath);
|
||||
});
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
setParseError("Selected directory has no .md, .txt, or .sh files");
|
||||
if (agentFiles.length === 0) {
|
||||
setParseError("Selected directory has no AGENTS.md files");
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const file of textFiles) {
|
||||
const relativePath = file.webkitRelativePath || file.name;
|
||||
const parsedAgents: DirectoryAgentInput[] = [];
|
||||
for (const file of agentFiles) {
|
||||
const content = await file.text();
|
||||
chunks.push(`--- FILE: ${relativePath} ---\n${content}`);
|
||||
parsedAgents.push(parseDirectoryAgentManifest(content));
|
||||
}
|
||||
|
||||
setInputMethod("directory");
|
||||
setManifestContent(chunks.join("\n\n"));
|
||||
setDirectoryAgents(parsedAgents);
|
||||
setManifestContent("");
|
||||
setParseError(null);
|
||||
} catch {
|
||||
setParseError("Failed to read selected directory");
|
||||
setParseError("Failed to parse AGENTS.md files from selected directory");
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
@@ -138,7 +200,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
|
||||
/** Parse the manifest content by calling the API with dryRun=true */
|
||||
const handleParse = useCallback(async () => {
|
||||
if (!manifestContent.trim()) {
|
||||
if (inputMethod === "directory" && directoryAgents.length === 0) {
|
||||
setParseError("Please select a directory containing AGENTS.md files");
|
||||
return;
|
||||
}
|
||||
if (inputMethod !== "directory" && !manifestContent.trim()) {
|
||||
setParseError("Please provide manifest content");
|
||||
return;
|
||||
}
|
||||
@@ -147,10 +213,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setParseError(null);
|
||||
|
||||
try {
|
||||
const body = inputMethod === "directory"
|
||||
? { agents: directoryAgents, dryRun: true }
|
||||
: { manifest: manifestContent, dryRun: true };
|
||||
|
||||
const res = await fetch(buildUrl("/agents/import"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ manifest: manifestContent, dryRun: true }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -178,7 +248,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
}, [manifestContent, projectId]);
|
||||
}, [inputMethod, directoryAgents, manifestContent, projectId]);
|
||||
|
||||
/** Execute the actual import */
|
||||
const handleImport = useCallback(async () => {
|
||||
@@ -186,10 +256,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setImportError(null);
|
||||
|
||||
try {
|
||||
const body = inputMethod === "directory"
|
||||
? { agents: directoryAgents, skipExisting: true }
|
||||
: { manifest: manifestContent, skipExisting: true };
|
||||
|
||||
const res = await fetch(buildUrl("/agents/import"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ manifest: manifestContent, skipExisting: true }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -206,7 +280,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [manifestContent, projectId, onImported]);
|
||||
}, [inputMethod, directoryAgents, manifestContent, projectId, onImported]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -235,7 +309,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.txt,.sh"
|
||||
accept=".md,.txt"
|
||||
onChange={handleFileChange}
|
||||
className="agent-import-file-input"
|
||||
aria-label="Upload agent manifest file"
|
||||
@@ -266,7 +340,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
<FolderOpen size={16} />
|
||||
Select Directory
|
||||
</button>
|
||||
<span className="agent-import-file-hint">.md, .txt, and .sh files supported</span>
|
||||
<span className="agent-import-file-hint">.md and .txt files supported</span>
|
||||
</div>
|
||||
|
||||
{/* Or divider */}
|
||||
@@ -277,10 +351,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
{/* Text area for paste */}
|
||||
<textarea
|
||||
className="agent-import-textarea"
|
||||
placeholder={"---\nname: Agent Name\ntitle: Agent Title\nskills:\n - review\n---\nAgent instructions go here..."}
|
||||
placeholder={"---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here..."}
|
||||
value={manifestContent}
|
||||
onChange={(e) => {
|
||||
setInputMethod("paste");
|
||||
setDirectoryAgents([]);
|
||||
setManifestContent(e.target.value);
|
||||
setParseError(null);
|
||||
}}
|
||||
@@ -414,7 +489,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleParse()}
|
||||
disabled={isParsing || !manifestContent.trim()}
|
||||
disabled={isParsing || (inputMethod === "directory" ? directoryAgents.length === 0 : !manifestContent.trim())}
|
||||
>
|
||||
{isParsing ? (
|
||||
<>
|
||||
|
||||
@@ -5,26 +5,15 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
// ── Mock @fusion/core for agent import ────────────────────────────────
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockListAgents = vi.fn().mockResolvedValue([]);
|
||||
const mockCreateAgent = vi.fn();
|
||||
|
||||
const mockParseCompaniesShManifest = vi.fn();
|
||||
const mockConvertCompaniesShAgents = vi.fn();
|
||||
const mockParseCompanyDirectory = vi.fn();
|
||||
const mockParseCompanyArchive = vi.fn();
|
||||
const mockParseAgentManifest = vi.fn();
|
||||
const mockParseSingleAgentManifest = vi.fn();
|
||||
const mockConvertAgentCompanies = vi.fn();
|
||||
|
||||
class MockCompaniesShParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CompaniesShParseError";
|
||||
}
|
||||
}
|
||||
|
||||
class MockAgentCompaniesParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -39,26 +28,21 @@ vi.mock("@fusion/core", () => {
|
||||
listAgents = mockListAgents;
|
||||
createAgent = mockCreateAgent;
|
||||
},
|
||||
parseCompaniesShManifest: (...args: unknown[]) => mockParseCompaniesShManifest(...args),
|
||||
convertCompaniesShAgents: (...args: unknown[]) => mockConvertCompaniesShAgents(...args),
|
||||
parseCompanyDirectory: (...args: unknown[]) => mockParseCompanyDirectory(...args),
|
||||
parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args),
|
||||
parseAgentManifest: (...args: unknown[]) => mockParseAgentManifest(...args),
|
||||
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
|
||||
convertAgentCompanies: (...args: unknown[]) => mockConvertAgentCompanies(...args),
|
||||
CompaniesShParseError: MockCompaniesShParseError,
|
||||
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock Store ────────────────────────────────────────────────────────
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1189-test";
|
||||
return "/tmp/fn-1174-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1189-test/.fusion";
|
||||
return "/tmp/fn-1174-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
@@ -73,25 +57,12 @@ class MockStore extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function encodeManifest(agents: unknown[]): string {
|
||||
return Buffer.from(JSON.stringify(agents)).toString("base64");
|
||||
}
|
||||
|
||||
function makeScript(companyName: string, agents: unknown[]): string {
|
||||
const manifest = encodeManifest(agents);
|
||||
return `#!/bin/bash\nCOMPANY_NAME="${companyName}"\nAGENT_MANIFEST="${manifest}"`;
|
||||
}
|
||||
|
||||
async function postImport(app: Parameters<typeof request>[0], body: unknown) {
|
||||
return request(app, "POST", "/api/agents/import", JSON.stringify(body), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/agents/import", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
@@ -106,44 +77,33 @@ describe("POST /api/agents/import", () => {
|
||||
mockCreateAgent.mockReset();
|
||||
mockCreateAgent.mockImplementation(async (input: any) => ({ id: `agent-${input.name}`, ...input }));
|
||||
|
||||
mockParseCompaniesShManifest.mockReturnValue({
|
||||
companyName: "test-co",
|
||||
agents: [{ name: "Test Agent", role: "executor" }],
|
||||
envVars: [],
|
||||
mockParseCompanyDirectory.mockReturnValue({
|
||||
company: { name: "Directory Co", slug: "directory-co" },
|
||||
agents: [{ name: "Dir Agent", skills: ["review"] }],
|
||||
teams: [{ name: "Engineering" }],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
});
|
||||
mockConvertCompaniesShAgents.mockReturnValue({
|
||||
inputs: [{ name: "Test Agent", role: "executor" }],
|
||||
result: {
|
||||
created: ["Test Agent"],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
|
||||
mockParseCompanyArchive.mockResolvedValue({
|
||||
company: { name: "Archive Co", slug: "archive-co" },
|
||||
agents: [{ name: "Archive Agent", skills: ["review"] }],
|
||||
teams: [{ name: "Ops" }],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
});
|
||||
|
||||
mockParseSingleAgentManifest.mockReturnValue({
|
||||
manifest: {
|
||||
name: "YAML Agent",
|
||||
title: "Chief Executive",
|
||||
skills: ["review"],
|
||||
instructionBody: "Instructions",
|
||||
},
|
||||
});
|
||||
|
||||
mockParseCompanyDirectory.mockReturnValue({
|
||||
company: { name: "Directory Co" },
|
||||
agents: [{ name: "Dir Agent", skills: ["executor"] }],
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
skills: [],
|
||||
});
|
||||
mockParseCompanyArchive.mockResolvedValue({
|
||||
company: { name: "Archive Co" },
|
||||
agents: [{ name: "Archive Agent", skills: ["executor"] }],
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
skills: [],
|
||||
});
|
||||
mockParseAgentManifest.mockReturnValue({
|
||||
name: "YAML Agent",
|
||||
title: "Chief Executive",
|
||||
skills: ["review"],
|
||||
instructionBody: "Instructions",
|
||||
});
|
||||
mockConvertAgentCompanies.mockReturnValue({
|
||||
inputs: [{ name: "YAML Agent", role: "reviewer", title: "Chief Executive", metadata: { skills: ["review"] } }],
|
||||
inputs: [{ name: "YAML Agent", role: "custom", title: "Chief Executive", metadata: { skills: ["review"] } }],
|
||||
result: {
|
||||
created: ["YAML Agent"],
|
||||
skipped: [],
|
||||
@@ -168,7 +128,7 @@ describe("POST /api/agents/import", () => {
|
||||
expect((response.body as any).error).toContain("Provide one of");
|
||||
});
|
||||
|
||||
it("imports agents via Mode 1 (agents array)", async () => {
|
||||
it("imports agents via { agents } mode", async () => {
|
||||
const response = await postImport(app, {
|
||||
agents: [{ name: "Test Agent", skills: ["executor"] }],
|
||||
});
|
||||
@@ -178,9 +138,11 @@ describe("POST /api/agents/import", () => {
|
||||
const body = response.body as any;
|
||||
expect(body.created).toHaveLength(1);
|
||||
expect(body.created[0].name).toBe("YAML Agent");
|
||||
expect(body.companyName).toBe("Unknown");
|
||||
expect(body.companySlug).toBeUndefined();
|
||||
});
|
||||
|
||||
it("imports agents via Mode 2 (source directory)", async () => {
|
||||
it("imports agents via { source } directory mode", async () => {
|
||||
const sourceDir = join(testDir, "company");
|
||||
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
|
||||
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
|
||||
@@ -190,61 +152,59 @@ describe("POST /api/agents/import", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockParseCompanyDirectory).toHaveBeenCalledWith(sourceDir);
|
||||
const body = response.body as any;
|
||||
expect(body.companyName).toBe("Directory Co");
|
||||
expect(body.companySlug).toBe("directory-co");
|
||||
expect(body.created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("imports agents via Mode 3 manifest string (YAML frontmatter)", async () => {
|
||||
const response = await postImport(app, {
|
||||
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
|
||||
});
|
||||
it("imports agents via { source } archive mode", async () => {
|
||||
const archivePath = join(testDir, "company.tgz");
|
||||
writeFileSync(archivePath, "archive");
|
||||
|
||||
const response = await postImport(app, { source: archivePath });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockParseAgentManifest).toHaveBeenCalled();
|
||||
expect(mockParseCompaniesShManifest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to legacy .sh parsing when YAML parse fails", async () => {
|
||||
mockParseAgentManifest.mockImplementation(() => {
|
||||
throw new MockAgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
|
||||
});
|
||||
|
||||
const response = await postImport(app, {
|
||||
manifest: makeScript("fallback-co", [{ name: "Legacy Agent", role: "executor" }]),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockParseCompaniesShManifest).toHaveBeenCalledTimes(1);
|
||||
expect(mockParseCompanyArchive).toHaveBeenCalledWith(archivePath);
|
||||
const body = response.body as any;
|
||||
expect(body.created).toHaveLength(1);
|
||||
expect(body.companyName).toBe("Archive Co");
|
||||
expect(body.companySlug).toBe("archive-co");
|
||||
});
|
||||
|
||||
it("returns dry-run previews with agents array and does not create agents", async () => {
|
||||
it("rejects non-directory source paths", async () => {
|
||||
const filePath = join(testDir, "manifest.md");
|
||||
writeFileSync(filePath, "---\nname: Agent\n---");
|
||||
|
||||
const response = await postImport(app, { source: filePath });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("directory");
|
||||
});
|
||||
|
||||
it("imports agents via { manifest } AGENTS.md mode", async () => {
|
||||
const response = await postImport(app, {
|
||||
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockParseSingleAgentManifest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns dry-run preview and does not create agents", async () => {
|
||||
const response = await postImport(app, {
|
||||
manifest: "---\nname: YAML Agent\n---\nInstructions",
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = response.body as any;
|
||||
expect(body.dryRun).toBe(true);
|
||||
expect(body.created).toEqual(["YAML Agent"]);
|
||||
expect(body.agents).toEqual([
|
||||
expect.objectContaining({ name: "YAML Agent", role: "reviewer", title: "Chief Executive" }),
|
||||
expect.objectContaining({ name: "YAML Agent", role: "custom", title: "Chief Executive" }),
|
||||
]);
|
||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for unsupported source paths", async () => {
|
||||
const unsupportedPath = join(testDir, "manifest.json");
|
||||
writeFileSync(unsupportedPath, "{}");
|
||||
|
||||
const response = await postImport(app, {
|
||||
source: unsupportedPath,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("Unsupported source format");
|
||||
});
|
||||
|
||||
it("honors skipExisting and returns skipped agents", async () => {
|
||||
mockListAgents.mockResolvedValue([{ id: "agent-existing", name: "YAML Agent" }]);
|
||||
mockConvertAgentCompanies.mockReturnValue({
|
||||
@@ -266,4 +226,17 @@ describe("POST /api/agents/import", () => {
|
||||
expect(body.skipped).toEqual(["YAML Agent"]);
|
||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for parser errors", async () => {
|
||||
mockParseSingleAgentManifest.mockImplementation(() => {
|
||||
throw new MockAgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
|
||||
});
|
||||
|
||||
const response = await postImport(app, {
|
||||
manifest: "invalid",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("Missing YAML frontmatter");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7270,12 +7270,12 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
/**
|
||||
* POST /api/agents/import
|
||||
* Import agents from Agent Companies packages or legacy companies.sh manifests.
|
||||
* Import agents from Agent Companies sources.
|
||||
*
|
||||
* Body modes (checked in order):
|
||||
* - { agents: AgentManifest[], skipExisting?, dryRun? }
|
||||
* - { source: string, skipExisting?, dryRun? }
|
||||
* - { manifest: string, skipExisting?, dryRun? }
|
||||
* - { source: string, skipExisting?, dryRun? } // server directory path
|
||||
* - { manifest: string, skipExisting?, dryRun? } // raw AGENTS.md content
|
||||
*/
|
||||
router.post("/agents/import", async (req, res) => {
|
||||
try {
|
||||
@@ -7284,12 +7284,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
AgentStore,
|
||||
parseCompanyDirectory,
|
||||
parseCompanyArchive,
|
||||
parseAgentManifest,
|
||||
parseSingleAgentManifest,
|
||||
convertAgentCompanies,
|
||||
AgentCompaniesParseError,
|
||||
parseCompaniesShManifest,
|
||||
convertCompaniesShAgents,
|
||||
CompaniesShParseError,
|
||||
} = await import("@fusion/core");
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
@@ -7300,27 +7297,22 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
const existingNames = new Set(existingAgents.map((a: any) => a.name));
|
||||
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
|
||||
|
||||
let companyName: string | undefined;
|
||||
let inputs: any[] = [];
|
||||
let result: {
|
||||
created: string[];
|
||||
skipped: string[];
|
||||
errors: Array<{ name: string; error: string }>;
|
||||
} = {
|
||||
created: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
let pkg: {
|
||||
company?: { name?: string; slug?: string };
|
||||
agents: unknown[];
|
||||
teams: unknown[];
|
||||
projects: unknown[];
|
||||
tasks: unknown[];
|
||||
};
|
||||
|
||||
if (Array.isArray(agents)) {
|
||||
const pkg = {
|
||||
pkg = {
|
||||
company: undefined,
|
||||
agents,
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
skills: [],
|
||||
};
|
||||
({ inputs, result } = convertAgentCompanies(pkg as any, conversionOptions));
|
||||
} else if (typeof source === "string" && source.trim()) {
|
||||
const sourcePath = resolve(source);
|
||||
if (!existsSync(sourcePath)) {
|
||||
@@ -7328,77 +7320,36 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
const isArchive =
|
||||
sourcePath.endsWith(".tar.gz") || sourcePath.endsWith(".tgz") || sourcePath.endsWith(".zip");
|
||||
const isArchive = sourcePath.endsWith(".tar.gz")
|
||||
|| sourcePath.endsWith(".tgz")
|
||||
|| sourcePath.endsWith(".zip");
|
||||
|
||||
let pkg;
|
||||
if (nodeFs.statSync(sourcePath).isDirectory()) {
|
||||
pkg = parseCompanyDirectory(sourcePath);
|
||||
} else if (isArchive) {
|
||||
if (isArchive) {
|
||||
pkg = await parseCompanyArchive(sourcePath);
|
||||
} else if (nodeFs.statSync(sourcePath).isDirectory()) {
|
||||
pkg = parseCompanyDirectory(sourcePath);
|
||||
} else {
|
||||
res.status(400).json({ error: "Unsupported source format. Provide a directory or .tar.gz/.zip archive path." });
|
||||
res.status(400).json({ error: "Source must be a server-side directory or archive path" });
|
||||
return;
|
||||
}
|
||||
|
||||
companyName = pkg.company?.name;
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
} else if (typeof manifest === "string") {
|
||||
try {
|
||||
const segmentedMatches = [...manifest.matchAll(
|
||||
/--- FILE:\s*([^\n]+)\s*---\n([\s\S]*?)(?=(?:\n--- FILE:\s*[^\n]+\s*---\n)|$)/g,
|
||||
)];
|
||||
|
||||
if (segmentedMatches.length > 0) {
|
||||
const parsedAgents = segmentedMatches
|
||||
.map(([, relativePath, content]) => ({
|
||||
relativePath: relativePath.trim(),
|
||||
content,
|
||||
}))
|
||||
.filter(({ relativePath }) => relativePath.toLowerCase().endsWith("agents.md"))
|
||||
.map(({ content }) => parseAgentManifest(content));
|
||||
|
||||
const pkg = {
|
||||
agents: parsedAgents,
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
skills: [],
|
||||
};
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
} else {
|
||||
const parsedAgentManifest = parseAgentManifest(manifest);
|
||||
const pkg = {
|
||||
agents: [parsedAgentManifest],
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
skills: [],
|
||||
};
|
||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof AgentCompaniesParseError)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedCompaniesSh = parseCompaniesShManifest(manifest);
|
||||
companyName = parsedCompaniesSh.companyName;
|
||||
({ inputs, result } = convertCompaniesShAgents(parsedCompaniesSh.agents as any[], conversionOptions));
|
||||
} catch (fallbackErr) {
|
||||
if (fallbackErr instanceof CompaniesShParseError) {
|
||||
res.status(400).json({ error: fallbackErr.message });
|
||||
return;
|
||||
}
|
||||
throw fallbackErr;
|
||||
}
|
||||
}
|
||||
const { manifest: singleAgent } = parseSingleAgentManifest(manifest);
|
||||
pkg = {
|
||||
company: undefined,
|
||||
agents: [singleAgent],
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
};
|
||||
} else {
|
||||
res.status(400).json({ error: "Provide one of: agents (array), source (path), or manifest (string)" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { inputs, result } = convertAgentCompanies(pkg as any, conversionOptions);
|
||||
const companyName = pkg.company?.name ?? "Unknown";
|
||||
const companySlug = typeof pkg.company?.slug === "string" ? pkg.company.slug : undefined;
|
||||
|
||||
if (inputs.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
|
||||
res.status(400).json({ error: "No agents found in manifest" });
|
||||
return;
|
||||
@@ -7417,6 +7368,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
res.json({
|
||||
dryRun: true,
|
||||
companyName,
|
||||
...(companySlug ? { companySlug } : {}),
|
||||
agents: agentPreview,
|
||||
created: result.created,
|
||||
skipped: result.skipped,
|
||||
@@ -7425,7 +7377,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
const created: any[] = [];
|
||||
const created: Array<{ id: string; name: string }> = [];
|
||||
const errors: Array<{ name: string; error: string }> = [...result.errors];
|
||||
|
||||
for (const input of inputs) {
|
||||
@@ -7436,7 +7388,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
try {
|
||||
const agent = await agentStore.createAgent(input);
|
||||
created.push(agent);
|
||||
created.push({ id: agent.id, name: agent.name });
|
||||
} catch (err: any) {
|
||||
errors.push({ name: input.name, error: err.message });
|
||||
}
|
||||
@@ -7444,12 +7396,13 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
res.json({
|
||||
companyName,
|
||||
...(companySlug ? { companySlug } : {}),
|
||||
created,
|
||||
skipped: result.skipped,
|
||||
errors,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AgentCompaniesParseError" || err?.name === "CompaniesShParseError") {
|
||||
if (err?.name === "AgentCompaniesParseError") {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user