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

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": minor
---
Add Agent Companies import support across the CLI and dashboard agent import flows. Users can now import from package directories, `.tar.gz`/`.tgz` archives, and single `.md` manifests while keeping legacy `.sh` manifest import compatibility.

View File

@@ -123,8 +123,8 @@ Usage:
fn git fetch [remote] Fetch from remote (default: origin)
fn agent stop <id> Stop a running agent (pause execution)
fn agent start <id> Start a stopped agent (resume execution)
fn agent import <file> [--dry-run] [--skip-existing]
Import agents from a companies.sh manifest
fn agent import <path> [--dry-run] [--skip-existing]
Import agents from an Agent Companies package (directory, archive, or .md file)
fn agent mailbox <id> View an agent's mailbox
fn message inbox List inbox messages
fn message outbox List sent messages
@@ -848,7 +848,7 @@ async function main() {
}
case "import": {
const source = args[2];
if (!source) { console.error("Usage: fn agent import <file> [--dry-run] [--skip-existing]"); process.exit(1); }
if (!source) { console.error("Usage: fn agent import <path> [--dry-run] [--skip-existing]"); process.exit(1); }
const importArgs = args.slice(3);
const dryRun = importArgs.includes("--dry-run");
const skipExisting = importArgs.includes("--skip-existing");
@@ -857,7 +857,7 @@ async function main() {
}
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 <file>");
console.log("Try: fn agent stop <id> | fn agent start <id> | fn agent mailbox <id> | fn agent import <path>");
process.exit(1);
}
break;

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);
}

View File

@@ -2043,9 +2043,10 @@ export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Ta
// ── Agent Import API ────────────────────────────────────────────────────────
/** Result of importing agents from a companies.sh manifest */
/** Result of importing agents from an Agent Companies source */
export interface AgentImportResult {
companyName: string;
companyName?: 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 }>;
skipped: string[];
@@ -2054,18 +2055,18 @@ export interface AgentImportResult {
}
/**
* Import agents from a companies.sh manifest via the API.
* Import agents from an Agent Companies source via the API.
* Uses dryRun for preview, then actual import.
*/
export function importAgents(
manifest: string,
input: { manifest?: string; source?: string; agents?: unknown[] },
options?: { dryRun?: boolean; skipExisting?: boolean },
projectId?: string,
): Promise<AgentImportResult> {
return api<AgentImportResult>(withProjectId("/agents/import", projectId), {
method: "POST",
body: JSON.stringify({
manifest,
...input,
dryRun: options?.dryRun ?? false,
skipExisting: options?.skipExisting ?? true,
}),

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AgentImportModal } from "./AgentImportModal";
interface MockResponse {
ok: boolean;
status: number;
body: unknown;
}
function mockFetchResponse({ ok, status, body }: MockResponse): Promise<Response> {
return Promise.resolve({
ok,
status,
json: async () => body,
} as Response);
}
describe("AgentImportModal", () => {
const onClose = vi.fn();
const onImported = vi.fn();
const originalFileReader = globalThis.FileReader;
beforeEach(() => {
vi.clearAllMocks();
class MockFileReader {
onload: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
readAsText(file: Blob): void {
const content = (file as any).__content ?? "";
this.onload?.call(this as unknown as FileReader, {
target: { result: content },
} as ProgressEvent<FileReader>);
}
}
globalThis.FileReader = MockFileReader as unknown as typeof FileReader;
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.FileReader = originalFileReader;
});
it("renders the input step with file upload, directory button, and textarea", () => {
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
expect(screen.getByText("Import Agents")).toBeTruthy();
expect(screen.getByRole("button", { name: "Choose File" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Select Directory" })).toBeTruthy();
expect(screen.getByLabelText("Manifest content")).toBeTruthy();
});
it("loads selected .md file content into the manifest textarea", async () => {
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
const fileInput = screen.getByLabelText("Upload agent manifest file") as HTMLInputElement;
const file = new File(["---\nname: CEO\n---\nLead"], "AGENTS.md", { type: "text/markdown" });
(file as any).__content = "---\nname: CEO\n---\nLead";
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
const textarea = screen.getByLabelText("Manifest content") as HTMLTextAreaElement;
expect(textarea.value).toContain("name: CEO");
});
});
it("shows parse preview using API-provided agents array", async () => {
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
ok: true,
status: 200,
body: {
dryRun: true,
companyName: "Acme Co",
agents: [
{
name: "CEO",
role: "executor",
title: "Chief Executive",
skills: ["review"],
},
],
created: ["CEO"],
skipped: [],
errors: [],
},
}));
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Manifest content"), {
target: { value: "---\nname: CEO\n---\nLead" },
});
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
await waitFor(() => {
expect(screen.getByText("CEO")).toBeTruthy();
expect(screen.getByText(/executor/)).toBeTruthy();
expect(screen.getByText(/Chief Executive/)).toBeTruthy();
});
});
it("imports agents from preview step and shows result summary", async () => {
vi.mocked(globalThis.fetch)
.mockImplementationOnce(() => mockFetchResponse({
ok: true,
status: 200,
body: {
dryRun: true,
companyName: "Acme Co",
agents: [{ name: "CEO", role: "executor", title: "Chief Executive", skills: ["review"] }],
created: ["CEO"],
skipped: [],
errors: [],
},
}))
.mockImplementationOnce(() => mockFetchResponse({
ok: true,
status: 200,
body: {
companyName: "Acme Co",
created: [{ id: "agent-1", name: "CEO" }],
skipped: [],
errors: [],
},
}));
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Manifest content"), {
target: { value: "---\nname: CEO\n---\nLead" },
});
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /Import 1 Agent/i })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Import 1 Agent/i }));
await waitFor(() => {
expect(screen.getByText("Import Complete")).toBeTruthy();
expect(screen.getByText(/1 created/)).toBeTruthy();
});
expect(onImported).toHaveBeenCalledTimes(1);
});
it("shows API errors to the user", async () => {
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
ok: false,
status: 400,
body: { error: "No agents found" },
}));
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Manifest content"), {
target: { value: "invalid content" },
});
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
await waitFor(() => {
expect(screen.getByText("No agents found")).toBeTruthy();
});
});
});

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from "react";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2 } from "lucide-react";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen } from "lucide-react";
export interface AgentImportModalProps {
isOpen: boolean;
@@ -12,14 +12,13 @@ export interface AgentImportModalProps {
interface AgentPreview {
name: string;
role: string;
icon?: string;
title?: string;
model?: string;
skills?: string[];
}
/** Import result from the API */
interface ImportResult {
companyName: string;
companyName?: string;
created: Array<{ id: string; name: string }>;
skipped: string[];
errors: Array<{ name: string; error: string }>;
@@ -31,20 +30,23 @@ interface ApiErrorResponse {
}
type ModalStep = "input" | "preview" | "result";
type InputMethod = "paste" | "file" | "directory";
/**
* Modal for importing agents from a companies.sh manifest.
* Modal for importing agents from Agent Companies manifests.
*
* Supports two input methods:
* - File upload (.sh files)
* Supports three input methods:
* - File upload (.md/.txt/.sh files)
* - Directory upload (webkitdirectory)
* - Paste raw manifest content
*
* Flow: Input → Preview parsed agents → Import → Show results
*/
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
const [step, setStep] = useState<ModalStep>("input");
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
const [manifestContent, setManifestContent] = useState("");
const [companyName, setCompanyName] = useState("");
const [companyName, setCompanyName] = useState("Unknown");
const [agents, setAgents] = useState<AgentPreview[]>([]);
const [isParsing, setIsParsing] = useState(false);
const [isImporting, setIsImporting] = useState(false);
@@ -52,11 +54,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const directoryInputRef = useRef<HTMLInputElement>(null);
const reset = useCallback(() => {
setStep("input");
setInputMethod("paste");
setManifestContent("");
setCompanyName("");
setCompanyName("Unknown");
setAgents([]);
setIsParsing(false);
setIsImporting(false);
@@ -77,6 +81,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const reader = new FileReader();
reader.onload = (ev) => {
const content = ev.target?.result as string;
setInputMethod("file");
setManifestContent(content);
setParseError(null);
};
@@ -89,6 +94,41 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
e.target.value = "";
}, []);
const handleDirectoryChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length === 0) return;
try {
const textFiles = files
.filter((file) => /\.(md|txt|sh)$/i.test(file.name))
.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");
return;
}
const chunks: string[] = [];
for (const file of textFiles) {
const relativePath = file.webkitRelativePath || file.name;
const content = await file.text();
chunks.push(`--- FILE: ${relativePath} ---\n${content}`);
}
setInputMethod("directory");
setManifestContent(chunks.join("\n\n"));
setParseError(null);
} catch {
setParseError("Failed to read selected directory");
} finally {
e.target.value = "";
}
}, []);
/** Build the API URL with optional projectId */
function buildUrl(path: string): string {
if (!projectId) return `/api${path}`;
@@ -119,41 +159,18 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
}
const data = await res.json() as {
companyName: string;
companyName?: string;
agents?: AgentPreview[];
created: string[];
skipped: string[];
errors: Array<{ name: string; error: string }>;
};
// Build agent previews from the created names in the dry-run result
// We need to extract more info from the manifest for the preview
let previewAgents: AgentPreview[] = [];
try {
// Parse the base64 manifest directly to get agent details for preview
const manifestMatch = manifestContent.match(/AGENT_MANIFEST=["'](.*)["']/);
if (manifestMatch) {
const decoded = atob(manifestMatch[1]);
const parsed = JSON.parse(decoded) as Array<Record<string, unknown>>;
previewAgents = parsed.map((a) => ({
name: String(a.name ?? ""),
role: String(a.role ?? "custom"),
icon: a.metadata && typeof a.metadata === "object"
? String((a.metadata as Record<string, unknown>).icon ?? "")
: undefined,
title: a.metadata && typeof a.metadata === "object"
? String((a.metadata as Record<string, unknown>).title ?? "")
: undefined,
model: a.config && typeof a.config === "object"
? String((a.config as Record<string, unknown>).model ?? "")
: undefined,
}));
}
} catch {
// Fallback: just show names from dry-run result
previewAgents = data.created.map((name) => ({ name, role: "custom" }));
}
const previewAgents = (data.agents && data.agents.length > 0)
? data.agents
: data.created.map((name) => ({ name, role: "custom" }));
setCompanyName(data.companyName);
setCompanyName(data.companyName ?? "Unknown");
setAgents(previewAgents);
setStep("preview");
} catch (err) {
@@ -210,7 +227,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
{step === "input" && (
<div className="agent-import-input">
<p className="agent-import-description">
Import agents from a companies.sh manifest file. Upload a <code>.sh</code> file or paste the manifest content directly.
Import agents from an Agent Companies package. Upload an AGENTS.md file, select a directory, or paste manifest content.
</p>
{/* File upload */}
@@ -218,10 +235,20 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<input
ref={fileInputRef}
type="file"
accept=".sh,.txt"
accept=".md,.txt,.sh"
onChange={handleFileChange}
className="agent-import-file-input"
aria-label="Upload companies.sh file"
aria-label="Upload agent manifest file"
/>
<input
ref={directoryInputRef}
type="file"
// @ts-expect-error webkitdirectory is non-standard but supported by Chromium browsers
webkitdirectory=""
multiple
onChange={handleDirectoryChange}
className="agent-import-file-input"
aria-label="Select directory"
/>
<button
type="button"
@@ -231,7 +258,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<Upload size={16} />
Choose File
</button>
<span className="agent-import-file-hint">.sh files supported</span>
<button
type="button"
className="btn agent-import-upload-btn"
onClick={() => directoryInputRef.current?.click()}
>
<FolderOpen size={16} />
Select Directory
</button>
<span className="agent-import-file-hint">.md, .txt, and .sh files supported</span>
</div>
{/* Or divider */}
@@ -242,13 +277,19 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
{/* Text area for paste */}
<textarea
className="agent-import-textarea"
placeholder={"#!/bin/bash\n# Agent Company Manifest\nCOMPANY_NAME=\"my-company\"\nAGENT_MANIFEST=\"base64...\""}
placeholder={"---\nname: Agent Name\ntitle: Agent Title\nskills:\n - review\n---\nAgent instructions go here..."}
value={manifestContent}
onChange={(e) => { setManifestContent(e.target.value); setParseError(null); }}
onChange={(e) => {
setInputMethod("paste");
setManifestContent(e.target.value);
setParseError(null);
}}
rows={8}
aria-label="Manifest content"
/>
<p className="agent-import-file-hint">Current input: {inputMethod}</p>
{parseError && (
<p className="agent-dialog-error">
<AlertTriangle size={14} />
@@ -275,15 +316,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<div className="agent-import-agent-list">
{agents.map((agent, idx) => (
<div key={idx} className="agent-import-agent-item">
<span className="agent-import-agent-icon">
{agent.icon || "🤖"}
</span>
<span className="agent-import-agent-icon">🤖</span>
<div className="agent-import-agent-details">
<span className="agent-import-agent-name">{agent.name}</span>
<span className="agent-import-agent-meta">
{agent.title && <span className="agent-import-agent-title">{agent.title} · </span>}
<span className="agent-import-agent-role">{agent.role}</span>
{agent.model && <span className="agent-import-agent-model"> · {agent.model}</span>}
{agent.skills && agent.skills.length > 0 && (
<span className="agent-import-agent-model"> · {agent.skills.join(", ")}</span>
)}
</span>
</div>
</div>
@@ -292,6 +333,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
) : (
<p className="agent-import-empty">No agents found in the manifest.</p>
)}
{importError && (
<p className="agent-dialog-error">
<AlertTriangle size={14} />
{importError}
</p>
)}
</div>
)}
@@ -303,7 +351,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
</div>
<h3 className="agent-import-result-title">Import Complete</h3>
<p className="agent-import-result-company">
From <strong>{importResult.companyName}</strong>
From <strong>{importResult.companyName ?? "Unknown"}</strong>
</p>
<div className="agent-import-result-stats">

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
// ── Mock @fusion/core for agent import ────────────────────────────────
@@ -10,6 +13,24 @@ 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 mockConvertAgentCompanies = vi.fn();
class MockCompaniesShParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CompaniesShParseError";
}
}
class MockAgentCompaniesParseError extends Error {
constructor(message: string) {
super(message);
this.name = "AgentCompaniesParseError";
}
}
vi.mock("@fusion/core", () => {
return {
@@ -20,12 +41,12 @@ vi.mock("@fusion/core", () => {
},
parseCompaniesShManifest: (...args: unknown[]) => mockParseCompaniesShManifest(...args),
convertCompaniesShAgents: (...args: unknown[]) => mockConvertCompaniesShAgents(...args),
CompaniesShParseError: class CompaniesShParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CompaniesShParseError";
}
},
parseCompanyDirectory: (...args: unknown[]) => mockParseCompanyDirectory(...args),
parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args),
parseAgentManifest: (...args: unknown[]) => mockParseAgentManifest(...args),
convertAgentCompanies: (...args: unknown[]) => mockConvertAgentCompanies(...args),
CompaniesShParseError: MockCompaniesShParseError,
AgentCompaniesParseError: MockAgentCompaniesParseError,
};
});
@@ -33,11 +54,11 @@ vi.mock("@fusion/core", () => {
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-976-test";
return "/tmp/fn-1189-test";
}
getFusionDir(): string {
return "/tmp/fn-976-test/.fusion";
return "/tmp/fn-1189-test/.fusion";
}
getDatabase() {
@@ -74,13 +95,16 @@ async function postImport(app: Parameters<typeof request>[0], body: unknown) {
describe("POST /api/agents/import", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let testDir: string;
beforeEach(async () => {
// Reset mock implementations (don't reassign variables — class fields capture by reference)
vi.clearAllMocks();
testDir = mkdtempSync(join(tmpdir(), "kb-agent-import-route-"));
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
mockCreateAgent.mockReset();
mockCreateAgent.mockImplementation(async (input: any) => ({ id: `agent-${input.name}`, ...input }));
mockParseCompaniesShManifest.mockReturnValue({
companyName: "test-co",
@@ -96,6 +120,37 @@ describe("POST /api/agents/import", () => {
},
});
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"] } }],
result: {
created: ["YAML Agent"],
skipped: [],
errors: [],
},
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
@@ -103,152 +158,112 @@ describe("POST /api/agents/import", () => {
afterEach(() => {
vi.restoreAllMocks();
rmSync(testDir, { recursive: true, force: true });
});
it("returns 400 when manifest is missing", async () => {
it("returns 400 when no supported input mode is provided", async () => {
const response = await postImport(app, {});
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("manifest is required");
expect((response.body as any).error).toContain("Provide one of");
});
it("returns 400 when manifest is not a string or valid object", async () => {
const response = await postImport(app, { manifest: 12345 });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("manifest must be");
});
it("returns 400 on invalid manifest content", async () => {
const { CompaniesShParseError } = await import("@fusion/core");
mockParseCompaniesShManifest.mockImplementation(() => {
throw new CompaniesShParseError("Missing COMPANY_NAME variable in manifest");
it("imports agents via Mode 1 (agents array)", async () => {
const response = await postImport(app, {
agents: [{ name: "Test Agent", skills: ["executor"] }],
});
const response = await postImport(app, { manifest: "not a valid manifest" });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("Missing COMPANY_NAME");
});
it("returns 400 when manifest has no agents", async () => {
mockParseCompaniesShManifest.mockReturnValue({
companyName: "empty-co",
agents: [],
envVars: [],
});
const response = await postImport(app, { manifest: makeScript("empty-co", []) });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("No agents found");
});
it("imports agents successfully", async () => {
mockCreateAgent.mockResolvedValue({ id: "agent-1", name: "Test Agent" });
const response = await postImport(app, { manifest: makeScript("test-co", [{ name: "Test Agent", role: "executor" }]) });
expect(response.status).toBe(200);
expect(mockConvertAgentCompanies).toHaveBeenCalledTimes(1);
const body = response.body as any;
expect(body.companyName).toBe("test-co");
expect(body.created).toHaveLength(1);
expect(body.created[0].name).toBe("Test Agent");
expect(body.errors).toHaveLength(0);
expect(body.created[0].name).toBe("YAML Agent");
});
it("returns dry-run preview without creating agents", async () => {
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [{ name: "Preview Agent", role: "executor" }],
result: {
created: ["Preview Agent"],
skipped: [],
errors: [],
},
it("imports agents via Mode 2 (source directory)", async () => {
const sourceDir = join(testDir, "company");
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
const response = await postImport(app, { source: sourceDir });
expect(response.status).toBe(200);
expect(mockParseCompanyDirectory).toHaveBeenCalledWith(sourceDir);
const body = response.body as any;
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",
});
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("preview-co", [{ name: "Preview Agent", role: "executor" }]),
manifest: makeScript("fallback-co", [{ name: "Legacy Agent", role: "executor" }]),
});
expect(response.status).toBe(200);
expect(mockParseCompaniesShManifest).toHaveBeenCalledTimes(1);
const body = response.body as any;
expect(body.created).toHaveLength(1);
});
it("returns dry-run previews with agents array and does not create agents", async () => {
const response = await postImport(app, {
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
dryRun: true,
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.dryRun).toBe(true);
expect(body.companyName).toBe("test-co");
expect(body.created).toContain("Preview Agent");
// Should NOT call createAgent in dry-run mode
expect(body.agents).toEqual([
expect.objectContaining({ name: "YAML Agent", role: "reviewer", title: "Chief Executive" }),
]);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("skips existing agents when skipExisting is true", async () => {
mockListAgents.mockResolvedValue([{ id: "existing-1", name: "Existing Agent" }]);
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [{ name: "New Agent", role: "reviewer" }],
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({
inputs: [],
result: {
created: ["New Agent"],
skipped: ["Existing Agent"],
created: [],
skipped: ["YAML Agent"],
errors: [],
},
});
mockCreateAgent.mockResolvedValue({ id: "agent-2", name: "New Agent" });
const response = await postImport(app, {
manifest: makeScript("skip-co", [
{ name: "Existing Agent", role: "executor" },
{ name: "New Agent", role: "reviewer" },
]),
manifest: "---\nname: YAML Agent\n---\nInstructions",
skipExisting: true,
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.skipped).toContain("Existing Agent");
expect(mockCreateAgent).toHaveBeenCalledTimes(1);
});
it("reports per-agent creation errors", async () => {
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [
{ name: "Good Agent", role: "executor" },
{ name: "Bad Agent", role: "reviewer" },
],
result: {
created: ["Good Agent", "Bad Agent"],
skipped: [],
errors: [],
},
});
mockCreateAgent
.mockResolvedValueOnce({ id: "agent-1", name: "Good Agent" })
.mockRejectedValueOnce(new Error("Database error"));
const response = await postImport(app, {
manifest: makeScript("mixed-co", [
{ name: "Good Agent", role: "executor" },
{ name: "Bad Agent", role: "reviewer" },
]),
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.created).toHaveLength(1);
expect(body.errors).toHaveLength(1);
expect(body.errors[0].name).toBe("Bad Agent");
expect(body.errors[0].error).toContain("Database error");
});
it("accepts pre-parsed manifest object with agents array", async () => {
const response = await postImport(app, {
manifest: {
companyName: "parsed-co",
agents: [{ name: "Parsed Agent", role: "executor" }],
envVars: [],
},
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.companyName).toBe("parsed-co");
expect(body.skipped).toEqual(["YAML Agent"]);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
});

View File

@@ -2056,7 +2056,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: agentId });
expect(res.body.assignedAgentId).toBe(agentId);
});
}, 20000);
it("returns 404 when assigning to a non-existent agent", async () => {
const res = await REQUEST(
@@ -2070,7 +2070,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(404);
expect(res.body.error).toBe("Agent not found");
expect(store.updateTask).not.toHaveBeenCalled();
});
}, 20000);
it("unassigns a task when agentId is null", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -2090,7 +2090,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: null });
expect(res.body.assignedAgentId).toBeUndefined();
});
}, 20000);
it("returns tasks assigned to the specified agent", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
@@ -2103,7 +2103,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(200);
expect(res.body.map((task: { id: string }) => task.id)).toEqual(["FN-001"]);
});
}, 20000);
it("returns 404 for /api/agents/:id/tasks when agent does not exist", async () => {
const res = await GET(buildApp(), "/api/agents/agent-missing/tasks");

View File

@@ -7270,64 +7270,154 @@ Output ONLY the prompt text (no markdown, no explanations).`;
/**
* POST /api/agents/import
* Import agents from a companies.sh manifest.
* Body: { manifest: string (raw script content) | { companyName, agents, envVars }, skipExisting?: boolean, dryRun?: boolean }
* Import agents from Agent Companies packages or legacy companies.sh manifests.
*
* Body modes (checked in order):
* - { agents: AgentManifest[], skipExisting?, dryRun? }
* - { source: string, skipExisting?, dryRun? }
* - { manifest: string, skipExisting?, dryRun? }
*/
router.post("/agents/import", async (req, res) => {
try {
const { manifest, skipExisting, dryRun } = req.body;
if (!manifest) {
res.status(400).json({ error: "manifest is required" });
return;
}
const { AgentStore, parseCompaniesShManifest, convertCompaniesShAgents, CompaniesShParseError } = await import("@fusion/core");
let parsed;
if (typeof manifest === "string") {
// Raw script content — parse it
try {
parsed = parseCompaniesShManifest(manifest);
} catch (err) {
if (err instanceof CompaniesShParseError) {
res.status(400).json({ error: err.message });
return;
}
throw err;
}
} else if (typeof manifest === "object" && Array.isArray((manifest as Record<string, unknown>).agents)) {
// Pre-parsed object
parsed = manifest as { companyName: string; agents: unknown[]; envVars?: unknown[] };
} else {
res.status(400).json({ error: "manifest must be a string (script content) or parsed object with agents array" });
return;
}
if (!parsed.agents || parsed.agents.length === 0) {
res.status(400).json({ error: "No agents found in manifest" });
return;
}
const { agents, source, manifest, skipExisting, dryRun } = req.body ?? {};
const {
AgentStore,
parseCompanyDirectory,
parseCompanyArchive,
parseAgentManifest,
convertAgentCompanies,
AgentCompaniesParseError,
parseCompaniesShManifest,
convertCompaniesShAgents,
CompaniesShParseError,
} = await import("@fusion/core");
const scopedStore = await getScopedStore(req);
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
// Get existing agent names for skip logic
const existingAgents = await agentStore.listAgents();
const existingNames = new Set(existingAgents.map((a: any) => a.name));
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
// Convert agents to AgentCreateInput
const { inputs, result } = convertCompaniesShAgents(
parsed.agents as any[],
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: [],
};
if (Array.isArray(agents)) {
const pkg = {
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)) {
res.status(400).json({ error: `source does not exist: ${sourcePath}` });
return;
}
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) {
pkg = await parseCompanyArchive(sourcePath);
} else {
res.status(400).json({ error: "Unsupported source format. Provide a directory or .tar.gz/.zip 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;
}
}
} else {
res.status(400).json({ error: "Provide one of: agents (array), source (path), or manifest (string)" });
return;
}
if (inputs.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
res.status(400).json({ error: "No agents found in manifest" });
return;
}
// Dry run: return preview without creating
if (dryRun) {
const agentPreview = inputs.map((input: any) => ({
name: input.name,
role: input.role,
title: typeof input.title === "string" ? input.title : undefined,
skills: Array.isArray(input.metadata?.skills)
? input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
: undefined,
}));
res.json({
dryRun: true,
companyName: parsed.companyName,
companyName,
agents: agentPreview,
created: result.created,
skipped: result.skipped,
errors: result.errors,
@@ -7335,12 +7425,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
return;
}
// Create agents
const created: any[] = [];
const errors: Array<{ name: string; error: string }> = [...result.errors];
for (const input of inputs) {
// Check for duplicates if not using skipExisting
if (!skipExisting && existingNames.has(input.name)) {
errors.push({ name: input.name, error: "Agent with this name already exists" });
continue;
@@ -7355,12 +7443,16 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
res.json({
companyName: parsed.companyName,
companyName,
created,
skipped: result.skipped,
errors,
});
} catch (err: any) {
if (err?.name === "AgentCompaniesParseError" || err?.name === "CompaniesShParseError") {
res.status(400).json({ error: err.message });
return;
}
res.status(500).json({ error: err.message });
}
});