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,227 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
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;
}
// ── Tests ────────────────────────────────────────────────────────────────
describe("agent-import", () => {
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>;
beforeEach(() => {
mkdirSync(tmpDir, { recursive: true });
createAgentMock = vi.fn();
listAgentsMock = vi.fn().mockResolvedValue([]);
initMock = vi.fn().mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it("reports error on invalid file 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");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("File 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!!!\"");
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"),
);
exitSpy.mockRestore();
errorSpy.mockRestore();
});
it("handles empty manifest gracefully", async () => {
const emptyFile = join(tmpDir, "empty.sh");
writeFileSync(emptyFile, makeScript("empty-co", []));
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 };
});
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);
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 };
});
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 });
// 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"));
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);
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");
logSpy.mockRestore();
});
});

View File

@@ -0,0 +1,150 @@
/**
* CLI command for importing agents from companies.sh manifests.
*
* Usage:
* fn agent import <source> [--dry-run] [--skip-existing] [--project <name>]
*
* @module agent-import
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { AgentStore, parseCompaniesShManifest, convertCompaniesShAgents, CompaniesShParseError } from "@fusion/core";
import { resolveProject } from "../project-context.js";
/**
* Get the project path for agent operations.
* Falls back to process.cwd() if no project is specified.
*/
async function getProjectPath(projectName?: string): Promise<string> {
if (projectName) {
const context = await resolveProject(projectName);
return context.projectPath;
}
try {
const context = await resolveProject(undefined);
return context.projectPath;
} catch {
return process.cwd();
}
}
/**
* Print a summary of the import result.
*/
function printSummary(
companyName: string,
created: string[],
skipped: string[],
errors: Array<{ name: string; error: string }>,
dryRun: boolean,
): void {
const prefix = dryRun ? "[DRY RUN] " : "";
console.log();
console.log(` ${prefix}Import from company: ${companyName}`);
console.log(` ${prefix}Created: ${created.length}`);
for (const name of created) {
console.log(`${name}`);
}
if (skipped.length > 0) {
console.log(` ${prefix}Skipped: ${skipped.length}`);
for (const name of skipped) {
console.log(`${name}`);
}
}
if (errors.length > 0) {
console.log(` ${prefix}Errors: ${errors.length}`);
for (const err of errors) {
console.log(`${err.name}: ${err.error}`);
}
}
console.log();
}
/**
* Run the agent import command.
*
* @param source - File path to a companies.sh manifest
* @param options - Command options
*/
export async function runAgentImport(
source: string,
options?: {
dryRun?: boolean;
skipExisting?: boolean;
project?: string;
},
): Promise<void> {
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}`);
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" });
await agentStore.init();
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,
);
// Dry run: just preview
if (dryRun) {
printSummary(manifest.companyName, result.created, result.skipped, result.errors, true);
return;
}
// Create agents
const created: string[] = [];
const errors: Array<{ name: string; error: string }> = [...result.errors];
for (const input of inputs) {
try {
// Double-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;
}
await agentStore.createAgent(input);
created.push(input.name);
} catch (err) {
errors.push({ name: input.name, error: (err as Error).message });
}
}
printSummary(manifest.companyName, created, result.skipped, errors, false);
}

View File

@@ -565,11 +565,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// ── MissionAutopilot: autonomous mission progression ─────────────
//
// Declared before createServer so it can be passed to both the server
// and the Scheduler. Assigned inside the engine block below (dev mode
// skips the engine entirely, so missionAutopilot stays undefined).
// Created before createServer so it can be passed to both the server
// and the Scheduler. The scheduler reference is set after Scheduler
// construction via setScheduler() to break the circular dependency.
// In dev mode the autopilot is created but never started.
//
let missionAutopilot: InstanceType<typeof MissionAutopilot> | undefined;
const missionAutopilot = new MissionAutopilot(store, store.getMissionStore());
// Start the web server with AI merge, auth, and model registry wired in
const app = createServer(store, { onMerge, authStorage, modelRegistry, automationStore, missionAutopilot });
@@ -629,13 +630,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
prCommentHandler.handleNewComments(taskId, prInfo, comments),
);
// ── MissionAutopilot: autonomous mission progression ─────────────
//
// Created before the Scheduler since Scheduler's constructor accepts
// missionAutopilot. The scheduler reference is set after construction
// via setScheduler() to break the circular dependency.
//
missionAutopilot = new MissionAutopilot(store, store.getMissionStore());
// ── MissionAutopilot is already created above (before createServer) ──
// The scheduler reference is set after construction via setScheduler()
// to break the circular dependency.
const scheduler = new Scheduler(store, {
semaphore,