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:
227
packages/cli/src/commands/agent-import.test.ts
Normal file
227
packages/cli/src/commands/agent-import.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
150
packages/cli/src/commands/agent-import.ts
Normal file
150
packages/cli/src/commands/agent-import.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user