feat(FN-976): add companies.sh agent import for CLI and dashboard

- Add companies.sh type definitions and parser in @fusion/core with comprehensive tests
- Create CLI `kb agent-import` command to import agents from companies.sh
- Add POST /api/agents/import dashboard endpoint with dry-run support
- Build AgentImportModal component with search, preview, and batch import UI
- Add dashboard route tests for the agent import API endpoint
- Create changeset for @gsxdsm/fusion minor bump
This commit is contained in:
gsxdsm
2026-04-07 11:41:38 -07:00
parent 43b1f6e772
commit 39f4f4929f
17 changed files with 2388 additions and 20 deletions

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
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();
vi.mock("@fusion/core", () => {
return {
AgentStore: class MockAgentStore {
init = mockInit;
listAgents = mockListAgents;
createAgent = mockCreateAgent;
},
parseCompaniesShManifest: (...args: unknown[]) => mockParseCompaniesShManifest(...args),
convertCompaniesShAgents: (...args: unknown[]) => mockConvertCompaniesShAgents(...args),
CompaniesShParseError: class CompaniesShParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CompaniesShParseError";
}
},
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-976-test";
}
getFusionDir(): string {
return "/tmp/fn-976-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
// ── 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>;
beforeEach(async () => {
// Reset mock implementations (don't reassign variables — class fields capture by reference)
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
mockCreateAgent.mockReset();
mockParseCompaniesShManifest.mockReturnValue({
companyName: "test-co",
agents: [{ name: "Test Agent", role: "executor" }],
envVars: [],
});
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [{ name: "Test Agent", role: "executor" }],
result: {
created: ["Test Agent"],
skipped: [],
errors: [],
},
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns 400 when manifest is missing", async () => {
const response = await postImport(app, {});
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("manifest is required");
});
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");
});
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);
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);
});
it("returns dry-run preview without creating agents", async () => {
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [{ name: "Preview Agent", role: "executor" }],
result: {
created: ["Preview Agent"],
skipped: [],
errors: [],
},
});
const response = await postImport(app, {
manifest: makeScript("preview-co", [{ name: "Preview Agent", role: "executor" }]),
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(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" }],
result: {
created: ["New Agent"],
skipped: ["Existing 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" },
]),
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");
});
});

View File

@@ -6847,6 +6847,103 @@ 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 }
*/
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 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));
// Convert agents to AgentCreateInput
const { inputs, result } = convertCompaniesShAgents(
parsed.agents as any[],
skipExisting ? { skipExisting: [...existingNames] } : undefined,
);
// Dry run: return preview without creating
if (dryRun) {
res.json({
dryRun: true,
companyName: parsed.companyName,
created: result.created,
skipped: result.skipped,
errors: result.errors,
});
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;
}
try {
const agent = await agentStore.createAgent(input);
created.push(agent);
} catch (err: any) {
errors.push({ name: input.name, error: err.message });
}
}
res.json({
companyName: parsed.companyName,
created,
skipped: result.skipped,
errors,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/agents/stats
* Return aggregate stats across all agents.