From 612827eee881b23760902d92d1a0a9233a794f7e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Apr 2026 11:41:38 -0700 Subject: [PATCH] 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 --- .changeset/fn-976-companies-sh-import.md | 7 + packages/cli/src/bin.ts | 14 +- .../cli/src/commands/agent-import.test.ts | 227 ++++++++++ packages/cli/src/commands/agent-import.ts | 150 +++++++ packages/cli/src/commands/dashboard.ts | 19 +- packages/cli/src/companies-sh-parser.ts | 16 + packages/core/src/companies-sh-parser.test.ts | 314 ++++++++++++++ packages/core/src/companies-sh-parser.ts | 269 ++++++++++++ packages/core/src/companies-sh-types.test.ts | 190 +++++++++ packages/core/src/companies-sh-types.ts | 101 +++++ packages/core/src/index.ts | 22 + packages/dashboard/app/api.ts | 31 ++ .../app/components/AgentImportModal.tsx | 401 ++++++++++++++++++ .../dashboard/app/components/AgentsView.tsx | 34 +- packages/dashboard/app/styles.css | 262 ++++++++++++ .../src/__tests__/routes-agent-import.test.ts | 254 +++++++++++ packages/dashboard/src/routes.ts | 97 +++++ 17 files changed, 2388 insertions(+), 20 deletions(-) create mode 100644 .changeset/fn-976-companies-sh-import.md create mode 100644 packages/cli/src/commands/agent-import.test.ts create mode 100644 packages/cli/src/commands/agent-import.ts create mode 100644 packages/cli/src/companies-sh-parser.ts create mode 100644 packages/core/src/companies-sh-parser.test.ts create mode 100644 packages/core/src/companies-sh-parser.ts create mode 100644 packages/core/src/companies-sh-types.test.ts create mode 100644 packages/core/src/companies-sh-types.ts create mode 100644 packages/dashboard/app/components/AgentImportModal.tsx create mode 100644 packages/dashboard/src/__tests__/routes-agent-import.test.ts diff --git a/.changeset/fn-976-companies-sh-import.md b/.changeset/fn-976-companies-sh-import.md new file mode 100644 index 000000000..d2e40974a --- /dev/null +++ b/.changeset/fn-976-companies-sh-import.md @@ -0,0 +1,7 @@ +--- +"@gsxdsm/fusion": minor +--- + +Add companies.sh agent import support. Parse shell-script-based company manifests +and import agents via CLI (fn agent import --dry-run --skip-existing) +and dashboard API (POST /agents/import). diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 5aa753f80..e9f286171 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -49,6 +49,7 @@ const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runM const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js"); const { runInit } = await import("./commands/init.js"); const { runAgentStop, runAgentStart } = await import("./commands/agent.js"); +const { runAgentImport } = await import("./commands/agent-import.js"); const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js"); const HELP = ` @@ -110,6 +111,8 @@ Usage: fn git fetch [remote] Fetch from remote (default: origin) fn agent stop Stop a running agent (pause execution) fn agent start Start a stopped agent (resume execution) + fn agent import [--dry-run] [--skip-existing] + Import agents from a companies.sh manifest fn agent mailbox View an agent's mailbox fn message inbox List inbox messages fn message outbox List sent messages @@ -759,9 +762,18 @@ async function main() { await runAgentMailbox(id, projectName); break; } + case "import": { + const source = args[2]; + if (!source) { console.error("Usage: fn agent import [--dry-run] [--skip-existing]"); process.exit(1); } + const importArgs = args.slice(3); + const dryRun = importArgs.includes("--dry-run"); + const skipExisting = importArgs.includes("--skip-existing"); + await runAgentImport(source, { dryRun, skipExisting, project: projectName }); + break; + } default: console.error(`Unknown subcommand: agent ${subcommand || ""}`); - console.log("Try: fn agent stop | fn agent start | fn agent mailbox "); + console.log("Try: fn agent stop | fn agent start | fn agent mailbox | fn agent import "); process.exit(1); } break; diff --git a/packages/cli/src/commands/agent-import.test.ts b/packages/cli/src/commands/agent-import.test.ts new file mode 100644 index 000000000..0bb816899 --- /dev/null +++ b/packages/cli/src/commands/agent-import.test.ts @@ -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; + let listAgentsMock: ReturnType; + let initMock: ReturnType; + + 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> = []; + 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> = []; + 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(); + }); +}); diff --git a/packages/cli/src/commands/agent-import.ts b/packages/cli/src/commands/agent-import.ts new file mode 100644 index 000000000..04f0c500b --- /dev/null +++ b/packages/cli/src/commands/agent-import.ts @@ -0,0 +1,150 @@ +/** + * CLI command for importing agents from companies.sh manifests. + * + * Usage: + * fn agent import [--dry-run] [--skip-existing] [--project ] + * + * @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 { + 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 { + 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); +} diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 937c79e5e..11f11a091 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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 | 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, diff --git a/packages/cli/src/companies-sh-parser.ts b/packages/cli/src/companies-sh-parser.ts new file mode 100644 index 000000000..4bcc2b488 --- /dev/null +++ b/packages/cli/src/companies-sh-parser.ts @@ -0,0 +1,16 @@ +/** + * Re-export companies.sh parser from @fusion/core. + * + * The parser implementation lives in @fusion/core so it can be shared + * between CLI and dashboard. This file re-exports for backward compatibility. + * + * @module companies-sh-parser + */ + +export { + parseCompaniesShManifest, + companiesShAgentToAgentCreateInput, + convertCompaniesShAgents, + mapRoleToCapability, + CompaniesShParseError, +} from "@fusion/core"; diff --git a/packages/core/src/companies-sh-parser.test.ts b/packages/core/src/companies-sh-parser.test.ts new file mode 100644 index 000000000..9143346c0 --- /dev/null +++ b/packages/core/src/companies-sh-parser.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect } from "vitest"; +import { + parseCompaniesShManifest, + companiesShAgentToAgentCreateInput, + convertCompaniesShAgents, + mapRoleToCapability, + CompaniesShParseError, +} from "./companies-sh-parser.js"; + +// ── 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("companies-sh-parser", () => { + describe("parseCompaniesShManifest", () => { + it("parses a valid companies.sh manifest", () => { + const agents = [ + { + name: "Code Reviewer", + role: "reviewer", + capabilities: ["code-review", "security-audit"], + config: { model: "claude-sonnet-4", maxTokens: 4096, thinkingLevel: "medium" }, + metadata: { title: "Senior Code Reviewer", icon: "👁" }, + }, + ]; + const script = makeScript("test-company", agents, [ + 'export KB_AGENT_MODEL="${KB_AGENT_MODEL:-claude-sonnet-4}"', + ]); + + const manifest = parseCompaniesShManifest(script); + + expect(manifest.companyName).toBe("test-company"); + expect(manifest.agents).toHaveLength(1); + expect(manifest.agents[0].name).toBe("Code Reviewer"); + expect(manifest.agents[0].role).toBe("reviewer"); + expect(manifest.agents[0].capabilities).toEqual(["code-review", "security-audit"]); + expect(manifest.agents[0].config?.model).toBe("claude-sonnet-4"); + expect(manifest.agents[0].metadata?.title).toBe("Senior Code Reviewer"); + expect(manifest.envVars).toHaveLength(1); + expect(manifest.envVars[0].name).toBe("KB_AGENT_MODEL"); + expect(manifest.envVars[0].defaultValue).toBe("claude-sonnet-4"); + }); + + it("parses a manifest with multiple agents", () => { + const agents = [ + { name: "Agent 1", role: "executor" }, + { name: "Agent 2", role: "reviewer" }, + { name: "Agent 3", role: "triage" }, + ]; + const script = makeScript("multi-agent", agents); + + const manifest = parseCompaniesShManifest(script); + + expect(manifest.agents).toHaveLength(3); + expect(manifest.agents.map((a) => a.name)).toEqual(["Agent 1", "Agent 2", "Agent 3"]); + }); + + it("throws on empty content", () => { + expect(() => parseCompaniesShManifest("")).toThrow(CompaniesShParseError); + expect(() => parseCompaniesShManifest("")).toThrow("empty or not a string"); + }); + + it("throws on missing COMPANY_NAME", () => { + const manifest = encodeManifest([{ name: "Test", role: "executor" }]); + const script = `#!/bin/bash\nAGENT_MANIFEST="${manifest}"`; + + expect(() => parseCompaniesShManifest(script)).toThrow("Missing COMPANY_NAME"); + }); + + it("throws on missing AGENT_MANIFEST", () => { + const script = `#!/bin/bash\nCOMPANY_NAME="test"`; + + expect(() => parseCompaniesShManifest(script)).toThrow("Missing AGENT_MANIFEST"); + }); + + it("throws on invalid base64 encoding", () => { + const script = `#!/bin/bash\nCOMPANY_NAME="test"\nAGENT_MANIFEST="not-valid-base64!!!"`; + + expect(() => parseCompaniesShManifest(script)).toThrow("Invalid base64"); + }); + + it("throws on invalid JSON in manifest", () => { + const badJson = btoa("not json"); + const script = `#!/bin/bash\nCOMPANY_NAME="test"\nAGENT_MANIFEST="${badJson}"`; + + expect(() => parseCompaniesShManifest(script)).toThrow("Invalid JSON"); + }); + + it("throws when manifest decodes to non-array", () => { + const obj = btoa(JSON.stringify({ name: "not an array" })); + const script = `#!/bin/bash\nCOMPANY_NAME="test"\nAGENT_MANIFEST="${obj}"`; + + expect(() => parseCompaniesShManifest(script)).toThrow("must decode to a JSON array"); + }); + + it("throws on agent missing name", () => { + const agents = [{ role: "executor" }]; + const script = makeScript("test", agents); + + expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: name"); + }); + + it("throws on agent missing role", () => { + const agents = [{ name: "Test Agent" }]; + const script = makeScript("test", agents); + + expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: role"); + }); + + it("throws on agent with empty name", () => { + const agents = [{ name: " ", role: "executor" }]; + const script = makeScript("test", agents); + + expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: name"); + }); + + it("throws on agent with empty role", () => { + const agents = [{ name: "Test", role: "" }]; + const script = makeScript("test", agents); + + expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: role"); + }); + + it("handles empty capabilities array", () => { + const agents = [{ name: "Test", role: "executor", capabilities: [] }]; + const script = makeScript("test", agents); + + const manifest = parseCompaniesShManifest(script); + expect(manifest.agents[0].capabilities).toEqual([]); + }); + + it("handles agents with no optional fields", () => { + const agents = [{ name: "Minimal", role: "custom" }]; + const script = makeScript("test", agents); + + const manifest = parseCompaniesShManifest(script); + expect(manifest.agents[0].capabilities).toBeUndefined(); + expect(manifest.agents[0].config).toBeUndefined(); + expect(manifest.agents[0].metadata).toBeUndefined(); + }); + + it("extracts multiple environment variables", () => { + const agents = [{ name: "Test", role: "executor" }]; + const script = makeScript("test", agents, [ + 'export KB_MODEL="${KB_MODEL:-claude-sonnet-4}"', + 'export KB_THINKING="${KB_THINKING:-medium}"', + 'export KB_MAX_TOKENS="${KB_MAX_TOKENS:-4096}"', + ]); + + const manifest = parseCompaniesShManifest(script); + + expect(manifest.envVars).toHaveLength(3); + expect(manifest.envVars.map((v) => v.name)).toEqual([ + "KB_MODEL", + "KB_THINKING", + "KB_MAX_TOKENS", + ]); + }); + + it("returns empty envVars when no export statements", () => { + const agents = [{ name: "Test", role: "executor" }]; + const script = makeScript("test", agents); + + const manifest = parseCompaniesShManifest(script); + expect(manifest.envVars).toEqual([]); + }); + + it("handles non-object agent entries", () => { + const agents = ["not an object", 42, null]; + const script = makeScript("test", agents); + + expect(() => parseCompaniesShManifest(script)).toThrow("not an object"); + }); + + it("filters non-string capabilities", () => { + const agents = [{ name: "Test", role: "executor", capabilities: ["valid", 123, null, "also-valid"] }]; + const script = makeScript("test", agents); + + const manifest = parseCompaniesShManifest(script); + expect(manifest.agents[0].capabilities).toEqual(["valid", "also-valid"]); + }); + }); + + describe("mapRoleToCapability", () => { + it("maps all known roles correctly", () => { + expect(mapRoleToCapability("triage")).toBe("triage"); + expect(mapRoleToCapability("executor")).toBe("executor"); + expect(mapRoleToCapability("reviewer")).toBe("reviewer"); + expect(mapRoleToCapability("merger")).toBe("merger"); + expect(mapRoleToCapability("scheduler")).toBe("scheduler"); + expect(mapRoleToCapability("engineer")).toBe("engineer"); + expect(mapRoleToCapability("custom")).toBe("custom"); + }); + + it("maps unknown roles to custom", () => { + expect(mapRoleToCapability("analyst")).toBe("custom"); + expect(mapRoleToCapability("designer")).toBe("custom"); + expect(mapRoleToCapability("")).toBe("custom"); + }); + }); + + describe("companiesShAgentToAgentCreateInput", () => { + it("converts a minimal agent", () => { + const agent = { name: "Test", role: "executor" }; + const input = companiesShAgentToAgentCreateInput(agent); + + expect(input.name).toBe("Test"); + expect(input.role).toBe("executor"); + expect(input.metadata).toBeUndefined(); + expect(input.runtimeConfig).toBeUndefined(); + }); + + it("converts a fully populated agent", () => { + const agent = { + name: "Code Reviewer", + role: "reviewer", + capabilities: ["code-review"], + config: { + model: "claude-sonnet-4", + maxTokens: 4096, + thinkingLevel: "medium" as const, + maxTurns: 10, + }, + metadata: { + title: "Senior Reviewer", + icon: "👁", + description: "Reviews code for quality", + }, + }; + + const input = companiesShAgentToAgentCreateInput(agent); + + expect(input.name).toBe("Code Reviewer"); + expect(input.role).toBe("reviewer"); + expect(input.title).toBe("Senior Reviewer"); + expect(input.icon).toBe("👁"); + expect(input.runtimeConfig).toEqual({ + model: "claude-sonnet-4", + maxTokens: 4096, + thinkingLevel: "medium", + maxTurns: 10, + }); + expect(input.metadata).toEqual({ + capabilities: ["code-review"], + description: "Reviews code for quality", + }); + }); + + it("maps unknown roles to custom", () => { + const agent = { name: "Special", role: "analyst" }; + const input = companiesShAgentToAgentCreateInput(agent); + + expect(input.role).toBe("custom"); + }); + + it("handles agent with empty capabilities", () => { + const agent = { name: "Test", role: "executor", capabilities: [] }; + const input = companiesShAgentToAgentCreateInput(agent); + + expect(input.metadata).toBeUndefined(); + }); + }); + + describe("convertCompaniesShAgents", () => { + it("converts all agents when no duplicates", () => { + const agents = [ + { name: "Agent 1", role: "executor" }, + { name: "Agent 2", role: "reviewer" }, + ]; + + const { inputs, result } = convertCompaniesShAgents(agents); + + expect(inputs).toHaveLength(2); + expect(result.created).toEqual(["Agent 1", "Agent 2"]); + expect(result.skipped).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("skips agents with existing names", () => { + const agents = [ + { name: "Existing Agent", role: "executor" }, + { name: "New Agent", role: "reviewer" }, + ]; + + const { inputs, result } = convertCompaniesShAgents(agents, { + skipExisting: ["Existing Agent"], + }); + + expect(inputs).toHaveLength(1); + expect(inputs[0].name).toBe("New Agent"); + expect(result.skipped).toEqual(["Existing Agent"]); + }); + + it("handles empty agent list", () => { + const { inputs, result } = convertCompaniesShAgents([]); + + expect(inputs).toHaveLength(0); + expect(result.created).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/companies-sh-parser.ts b/packages/core/src/companies-sh-parser.ts new file mode 100644 index 000000000..1d4f909cb --- /dev/null +++ b/packages/core/src/companies-sh-parser.ts @@ -0,0 +1,269 @@ +/** + * Parser for companies.sh manifest files. + * + * Extracts agent definitions from shell-script-based manifests following + * the companies.sh standard. Handles base64-encoded JSON payloads, + * shell variable extraction, and environment variable defaults. + * + * @module companies-sh-parser + */ + +import type { + CompaniesShManifest, + CompaniesShAgent, + CompaniesShEnvVar, + CompaniesShImportResult, +} from "./companies-sh-types.js"; +import type { AgentCreateInput, AgentCapability } from "./types.js"; + +// ── Parsing Errors ─────────────────────────────────────────────────────── + +export class CompaniesShParseError extends Error { + constructor(message: string) { + super(message); + this.name = "CompaniesShParseError"; + } +} + +// ── Role Mapping ───────────────────────────────────────────────────────── + +const VALID_ROLES: Set = new Set([ + "triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom", +]); + +/** + * Map a companies.sh role string to a kb AgentCapability. + * Unknown roles fall back to "custom". + */ +export function mapRoleToCapability(role: string): AgentCapability { + if (VALID_ROLES.has(role)) { + return role as AgentCapability; + } + return "custom"; +} + +// ── Shell Variable Extraction ──────────────────────────────────────────── + +/** + * Extract a shell variable value from script content. + * Handles both `VAR="value"` and `VAR='value'` syntax. + * Returns null if the variable is not found. + */ +function extractShellVariable(script: string, varName: string): string | null { + // Match VAR="value" or VAR='value' — capture the value inside quotes + const regex = new RegExp(`^${varName}=["'](.*)["']\\s*$`, "m"); + const match = script.match(regex); + if (!match) return null; + return match[1]; +} + +/** + * Extract environment variable defaults from export statements. + * Matches `export VAR="${VAR:-default}"` pattern. + */ +function extractEnvVars(script: string): CompaniesShEnvVar[] { + const envVars: CompaniesShEnvVar[] = []; + const regex = /^export\s+(\w+)="\$\{(?:\w+):-(.*?)\}"\s*$/gm; + let match: RegExpExecArray | null; + + while ((match = regex.exec(script)) !== null) { + envVars.push({ + name: match[1], + defaultValue: match[2], + }); + } + + return envVars; +} + +// ── Validation ─────────────────────────────────────────────────────────── + +/** + * Validate a single parsed agent has required fields. + * Throws if name or role is missing or invalid type. + */ +function validateAgent(agent: unknown, index: number): CompaniesShAgent { + if (!agent || typeof agent !== "object") { + throw new CompaniesShParseError(`Agent at index ${index} is not an object`); + } + + const obj = agent as Record; + + if (typeof obj.name !== "string" || obj.name.trim() === "") { + throw new CompaniesShParseError(`Agent at index ${index} is missing required field: name`); + } + + if (typeof obj.role !== "string" || obj.role.trim() === "") { + throw new CompaniesShParseError(`Agent at index ${index} is missing required field: role`); + } + + return { + name: obj.name, + role: obj.role, + capabilities: Array.isArray(obj.capabilities) + ? obj.capabilities.filter((c: unknown) => typeof c === "string") + : undefined, + config: obj.config && typeof obj.config === "object" + ? { + ...(typeof (obj.config as Record).model === "string" && { model: (obj.config as Record).model as string }), + ...(typeof (obj.config as Record).maxTokens === "number" && { maxTokens: (obj.config as Record).maxTokens as number }), + ...(typeof (obj.config as Record).thinkingLevel === "string" && { thinkingLevel: (obj.config as Record).thinkingLevel as CompaniesShAgent["config"] extends { thinkingLevel?: infer T } ? T : never }), + ...(typeof (obj.config as Record).maxTurns === "number" && { maxTurns: (obj.config as Record).maxTurns as number }), + } + : undefined, + metadata: obj.metadata && typeof obj.metadata === "object" + ? { + ...(typeof (obj.metadata as Record).title === "string" && { title: (obj.metadata as Record).title as string }), + ...(typeof (obj.metadata as Record).icon === "string" && { icon: (obj.metadata as Record).icon as string }), + ...(typeof (obj.metadata as Record).description === "string" && { description: (obj.metadata as Record).description as string }), + } + : undefined, + }; +} + +// ── Main Parser ────────────────────────────────────────────────────────── + +/** + * Parse a companies.sh manifest from raw script content. + * + * Extracts: + * - COMPANY_NAME shell variable + * - AGENT_MANIFEST base64-encoded JSON array + * - Environment variable defaults from export statements + * + * @throws {CompaniesShParseError} If the manifest is malformed + */ +export function parseCompaniesShManifest(scriptContent: string): CompaniesShManifest { + if (!scriptContent || typeof scriptContent !== "string") { + throw new CompaniesShParseError("Manifest content is empty or not a string"); + } + + // Extract company name + const companyName = extractShellVariable(scriptContent, "COMPANY_NAME"); + if (!companyName) { + throw new CompaniesShParseError("Missing COMPANY_NAME variable in manifest"); + } + + // Extract and decode agent manifest + const manifestBase64 = extractShellVariable(scriptContent, "AGENT_MANIFEST"); + if (!manifestBase64) { + throw new CompaniesShParseError("Missing AGENT_MANIFEST variable in manifest"); + } + + let manifestJson: string; + try { + // Validate base64 format — atob throws on invalid base64 characters + atob(manifestBase64); + } catch { + throw new CompaniesShParseError("Invalid base64 encoding in AGENT_MANIFEST"); + } + // Decode using Buffer for proper UTF-8 support + manifestJson = Buffer.from(manifestBase64, "base64").toString("utf-8"); + + let rawAgents: unknown[]; + try { + const parsed = JSON.parse(manifestJson); + if (!Array.isArray(parsed)) { + throw new CompaniesShParseError("AGENT_MANIFEST must decode to a JSON array"); + } + rawAgents = parsed; + } catch (err) { + if (err instanceof CompaniesShParseError) throw err; + throw new CompaniesShParseError(`Invalid JSON in AGENT_MANIFEST: ${(err as Error).message}`); + } + + // Validate each agent + const agents: CompaniesShAgent[] = rawAgents.map((agent, index) => + validateAgent(agent, index) + ); + + // Extract environment variable defaults + const envVars = extractEnvVars(scriptContent); + + return { companyName, agents, envVars }; +} + +// ── Conversion ─────────────────────────────────────────────────────────── + +/** + * Convert a companies.sh agent definition to a kb AgentCreateInput. + * Maps roles and extracts relevant configuration. + */ +export function companiesShAgentToAgentCreateInput( + agent: CompaniesShAgent, +): AgentCreateInput { + const input: AgentCreateInput = { + name: agent.name, + role: mapRoleToCapability(agent.role), + }; + + if (agent.metadata?.title) { + input.title = agent.metadata.title; + } + + if (agent.metadata?.icon) { + input.icon = agent.metadata.icon; + } + + if (agent.config) { + input.runtimeConfig = {}; + if (agent.config.model) input.runtimeConfig.model = agent.config.model; + if (agent.config.maxTokens) input.runtimeConfig.maxTokens = agent.config.maxTokens; + if (agent.config.thinkingLevel) input.runtimeConfig.thinkingLevel = agent.config.thinkingLevel; + if (agent.config.maxTurns) input.runtimeConfig.maxTurns = agent.config.maxTurns; + } + + // Store capabilities and description in metadata + const metadata: Record = {}; + if (agent.capabilities && agent.capabilities.length > 0) { + metadata.capabilities = agent.capabilities; + } + if (agent.metadata?.description) { + metadata.description = agent.metadata.description; + } + if (Object.keys(metadata).length > 0) { + input.metadata = metadata; + } + + return input; +} + +/** + * Convert multiple companies.sh agents to AgentCreateInput array, + * optionally skipping agents with errors. + * + * Returns an import result with created names, skipped names, and errors. + */ +export function convertCompaniesShAgents( + agents: CompaniesShAgent[], + options?: { skipExisting?: string[] }, +): { inputs: AgentCreateInput[]; result: CompaniesShImportResult } { + const existingNames = new Set(options?.skipExisting ?? []); + const inputs: AgentCreateInput[] = []; + const importResult: CompaniesShImportResult = { + created: [], + skipped: [], + errors: [], + }; + + for (const agent of agents) { + // Skip agents that already exist by name + if (existingNames.has(agent.name)) { + importResult.skipped.push(agent.name); + continue; + } + + try { + const input = companiesShAgentToAgentCreateInput(agent); + inputs.push(input); + importResult.created.push(agent.name); + } catch (err) { + importResult.errors.push({ + name: agent.name, + error: (err as Error).message, + }); + } + } + + return { inputs, result: importResult }; +} diff --git a/packages/core/src/companies-sh-types.test.ts b/packages/core/src/companies-sh-types.test.ts new file mode 100644 index 000000000..857cf5902 --- /dev/null +++ b/packages/core/src/companies-sh-types.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import type { + CompaniesShManifest, + CompaniesShAgent, + CompaniesShConfig, + CompaniesShMetadata, + CompaniesShEnvVar, + CompaniesShImportResult, + CompaniesShRole, +} from "./companies-sh-types.js"; + +describe("companies-sh-types", () => { + describe("CompaniesShAgent", () => { + it("accepts a valid minimal agent with required fields", () => { + const agent: CompaniesShAgent = { + name: "Code Reviewer", + role: "reviewer", + }; + expect(agent.name).toBe("Code Reviewer"); + expect(agent.role).toBe("reviewer"); + }); + + it("accepts a fully populated agent", () => { + const agent: CompaniesShAgent = { + name: "Code Reviewer", + role: "reviewer", + capabilities: ["code-review", "security-audit"], + config: { + model: "claude-sonnet-4", + maxTokens: 4096, + thinkingLevel: "medium", + maxTurns: 10, + }, + metadata: { + title: "Senior Code Reviewer", + icon: "👁", + description: "Reviews code for quality and security", + }, + }; + expect(agent.name).toBe("Code Reviewer"); + expect(agent.capabilities).toHaveLength(2); + expect(agent.config?.model).toBe("claude-sonnet-4"); + expect(agent.metadata?.title).toBe("Senior Code Reviewer"); + }); + + it("accepts an agent with optional fields omitted", () => { + const agent: CompaniesShAgent = { + name: "Simple Agent", + role: "executor", + }; + expect(agent.capabilities).toBeUndefined(); + expect(agent.config).toBeUndefined(); + expect(agent.metadata).toBeUndefined(); + }); + }); + + describe("CompaniesShConfig", () => { + it("accepts a config with all optional fields", () => { + const config: CompaniesShConfig = { + model: "provider/model-id", + maxTokens: 8192, + thinkingLevel: "high", + maxTurns: 20, + }; + expect(config.model).toBe("provider/model-id"); + expect(config.maxTokens).toBe(8192); + }); + + it("accepts an empty config", () => { + const config: CompaniesShConfig = {}; + expect(config.model).toBeUndefined(); + }); + }); + + describe("CompaniesShMetadata", () => { + it("accepts metadata with all fields", () => { + const meta: CompaniesShMetadata = { + title: "Job Title", + icon: "🤖", + description: "An AI agent", + }; + expect(meta.title).toBe("Job Title"); + expect(meta.icon).toBe("🤖"); + }); + }); + + describe("CompaniesShManifest", () => { + it("accepts a valid manifest structure", () => { + const manifest: CompaniesShManifest = { + companyName: "my-company", + agents: [ + { name: "Agent 1", role: "executor" }, + { name: "Agent 2", role: "reviewer" }, + ], + envVars: [ + { name: "KB_AGENT_MODEL", defaultValue: "claude-sonnet-4" }, + ], + }; + expect(manifest.companyName).toBe("my-company"); + expect(manifest.agents).toHaveLength(2); + expect(manifest.envVars).toHaveLength(1); + }); + + it("accepts a manifest with empty agents array", () => { + const manifest: CompaniesShManifest = { + companyName: "empty-company", + agents: [], + envVars: [], + }; + expect(manifest.agents).toHaveLength(0); + }); + }); + + describe("CompaniesShEnvVar", () => { + it("accepts an env var with name and default", () => { + const envVar: CompaniesShEnvVar = { + name: "KB_MODEL", + defaultValue: "claude-sonnet-4", + }; + expect(envVar.name).toBe("KB_MODEL"); + expect(envVar.defaultValue).toBe("claude-sonnet-4"); + }); + }); + + describe("CompaniesShImportResult", () => { + it("accepts a valid import result", () => { + const result: CompaniesShImportResult = { + created: ["agent-1", "agent-2"], + skipped: ["agent-3"], + errors: [{ name: "bad-agent", error: "missing role" }], + }; + expect(result.created).toHaveLength(2); + expect(result.skipped).toHaveLength(1); + expect(result.errors).toHaveLength(1); + }); + + it("accepts an empty result", () => { + const result: CompaniesShImportResult = { + created: [], + skipped: [], + errors: [], + }; + expect(result.created).toHaveLength(0); + }); + }); + + describe("CompaniesShRole", () => { + it("accepts all defined role types", () => { + const roles: CompaniesShRole[] = [ + "triage", + "executor", + "reviewer", + "merger", + "scheduler", + "engineer", + "custom", + ]; + expect(roles).toHaveLength(7); + }); + }); + + describe("runtime validation", () => { + it("validates that a minimal object satisfies CompaniesShAgent shape", () => { + // Simulate runtime validation of parsed JSON + const parsed = JSON.parse('{"name":"Test","role":"executor"}'); + expect(typeof parsed.name).toBe("string"); + expect(typeof parsed.role).toBe("string"); + expect(parsed.name).toBe("Test"); + expect(parsed.role).toBe("executor"); + }); + + it("detects missing required fields in parsed data", () => { + const parsed = JSON.parse('{"name":"Test"}'); + expect(parsed.role).toBeUndefined(); + // This would fail validation: missing role + expect(() => { + if (!parsed.role) throw new Error("Missing required field: role"); + }).toThrow("Missing required field: role"); + }); + + it("detects malformed data types", () => { + const parsed = JSON.parse('{"name":123,"role":"executor"}'); + expect(typeof parsed.name).toBe("number"); + // This would fail validation: name should be string + expect(() => { + if (typeof parsed.name !== "string") throw new Error("name must be a string"); + }).toThrow("name must be a string"); + }); + }); +}); diff --git a/packages/core/src/companies-sh-types.ts b/packages/core/src/companies-sh-types.ts new file mode 100644 index 000000000..499aa34a3 --- /dev/null +++ b/packages/core/src/companies-sh-types.ts @@ -0,0 +1,101 @@ +/** + * TypeScript type definitions for the companies.sh agent manifest format. + * + * The companies.sh standard defines a shell-script manifest that contains + * base64-encoded agent definitions, enabling portability of agent configurations + * across different agent systems. + * + * @module companies-sh-types + */ + +// ── Agent Config ───────────────────────────────────────────────────────── + +/** Configuration options for a companies.sh agent */ +export interface CompaniesShConfig { + /** AI model identifier (e.g., "provider/model-id") */ + model?: string; + /** Maximum tokens for the agent's responses */ + maxTokens?: number; + /** Thinking effort level */ + thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high"; + /** Maximum number of conversation turns */ + maxTurns?: number; +} + +// ── Agent Metadata ─────────────────────────────────────────────────────── + +/** Display metadata for a companies.sh agent */ +export interface CompaniesShMetadata { + /** Human-readable job title */ + title?: string; + /** Emoji icon identifier */ + icon?: string; + /** Agent description */ + description?: string; +} + +// ── Agent Definition ───────────────────────────────────────────────────── + +/** Role types in the companies.sh manifest format */ +export type CompaniesShRole = + | "triage" + | "executor" + | "reviewer" + | "merger" + | "scheduler" + | "engineer" + | "custom"; + +/** + * A single agent definition in the companies.sh manifest. + * Each agent has a required name and role, plus optional configuration. + */ +export interface CompaniesShAgent { + /** Display name (required) */ + name: string; + /** Agent role (required) — maps to kb AgentCapability */ + role: CompaniesShRole | string; + /** List of capability identifiers */ + capabilities?: string[]; + /** Agent runtime configuration */ + config?: CompaniesShConfig; + /** Display metadata */ + metadata?: CompaniesShMetadata; +} + +// ── Environment Variable ───────────────────────────────────────────────── + +/** An environment variable with its default value extracted from the manifest */ +export interface CompaniesShEnvVar { + /** Variable name */ + name: string; + /** Default value (from ${VAR:-default} syntax) */ + defaultValue: string; +} + +// ── Manifest ───────────────────────────────────────────────────────────── + +/** + * Parsed companies.sh manifest representing a full shell-script agent company. + * Contains the company name, decoded agent definitions, and environment variables. + */ +export interface CompaniesShManifest { + /** Company name extracted from COMPANY_NAME variable */ + companyName: string; + /** Decoded and parsed agent definitions */ + agents: CompaniesShAgent[]; + /** Environment variables with defaults extracted from export statements */ + envVars: CompaniesShEnvVar[]; +} + +// ── Import Result ──────────────────────────────────────────────────────── + +/** Result of importing agents from a companies.sh manifest */ +export interface CompaniesShImportResult { + /** Agents successfully created */ + created: string[]; + /** Agent names that were skipped (already exist or invalid) */ + skipped: string[]; + /** Errors encountered during import */ + errors: Array<{ name: string; error: string }>; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5d0fae90a..b11c1dee5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -192,3 +192,25 @@ export { buildExecutionMemoryInstructions, readProjectMemory, } from "./project-memory.js"; + +// ── companies.sh Types ─────────────────────────────────────────────────── + +export type { + CompaniesShManifest, + CompaniesShAgent, + CompaniesShConfig, + CompaniesShMetadata, + CompaniesShEnvVar, + CompaniesShImportResult, + CompaniesShRole, +} from "./companies-sh-types.js"; + +// ── companies.sh Parser ────────────────────────────────────────────── + +export { + parseCompaniesShManifest, + companiesShAgentToAgentCreateInput, + convertCompaniesShAgents, + mapRoleToCapability, + CompaniesShParseError, +} from "./companies-sh-parser.js"; diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 4492e9c47..52af00119 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -1852,6 +1852,37 @@ export function fetchAgentChildren(agentId: string, projectId?: string): Promise }); } +// ── Agent Import API ──────────────────────────────────────────────────────── + +/** Result of importing agents from a companies.sh manifest */ +export interface AgentImportResult { + companyName: 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[]; + errors: Array<{ name: string; error: string }>; + dryRun?: boolean; +} + +/** + * Import agents from a companies.sh manifest via the API. + * Uses dryRun for preview, then actual import. + */ +export function importAgents( + manifest: string, + options?: { dryRun?: boolean; skipExisting?: boolean }, + projectId?: string, +): Promise { + return api(withProjectId("/agents/import", projectId), { + method: "POST", + body: JSON.stringify({ + manifest, + dryRun: options?.dryRun ?? false, + skipExisting: options?.skipExisting ?? true, + }), + }); +} + // ── Agent Generation API ──────────────────────────────────────────────────── /** Generated agent specification returned by the AI */ diff --git a/packages/dashboard/app/components/AgentImportModal.tsx b/packages/dashboard/app/components/AgentImportModal.tsx new file mode 100644 index 000000000..5a4f38d66 --- /dev/null +++ b/packages/dashboard/app/components/AgentImportModal.tsx @@ -0,0 +1,401 @@ +import { useState, useRef, useCallback } from "react"; +import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2 } from "lucide-react"; + +export interface AgentImportModalProps { + isOpen: boolean; + onClose: () => void; + onImported: () => void; + projectId?: string; +} + +/** Parsed agent preview item for display before import */ +interface AgentPreview { + name: string; + role: string; + icon?: string; + title?: string; + model?: string; +} + +/** Import result from the API */ +interface ImportResult { + companyName: string; + created: Array<{ id: string; name: string }>; + skipped: string[]; + errors: Array<{ name: string; error: string }>; +} + +/** API error response shape */ +interface ApiErrorResponse { + error: string; +} + +type ModalStep = "input" | "preview" | "result"; + +/** + * Modal for importing agents from a companies.sh manifest. + * + * Supports two input methods: + * - File upload (.sh files) + * - Paste raw manifest content + * + * Flow: Input → Preview parsed agents → Import → Show results + */ +export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) { + const [step, setStep] = useState("input"); + const [manifestContent, setManifestContent] = useState(""); + const [companyName, setCompanyName] = useState(""); + const [agents, setAgents] = useState([]); + const [isParsing, setIsParsing] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [parseError, setParseError] = useState(null); + const [importResult, setImportResult] = useState(null); + const [importError, setImportError] = useState(null); + const fileInputRef = useRef(null); + + const reset = useCallback(() => { + setStep("input"); + setManifestContent(""); + setCompanyName(""); + setAgents([]); + setIsParsing(false); + setIsImporting(false); + setParseError(null); + setImportResult(null); + setImportError(null); + }, []); + + const handleClose = useCallback(() => { + reset(); + onClose(); + }, [reset, onClose]); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (ev) => { + const content = ev.target?.result as string; + setManifestContent(content); + setParseError(null); + }; + reader.onerror = () => { + setParseError("Failed to read file"); + }; + reader.readAsText(file); + + // Reset file input so the same file can be re-selected + e.target.value = ""; + }, []); + + /** Build the API URL with optional projectId */ + function buildUrl(path: string): string { + if (!projectId) return `/api${path}`; + const separator = path.includes("?") ? "&" : "?"; + return `/api${path}${separator}projectId=${encodeURIComponent(projectId)}`; + } + + /** Parse the manifest content by calling the API with dryRun=true */ + const handleParse = useCallback(async () => { + if (!manifestContent.trim()) { + setParseError("Please provide manifest content"); + return; + } + + setIsParsing(true); + setParseError(null); + + try { + const res = await fetch(buildUrl("/agents/import"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ manifest: manifestContent, dryRun: true }), + }); + + if (!res.ok) { + const data = await res.json() as ApiErrorResponse; + throw new Error(data.error ?? `Parse failed (${res.status})`); + } + + const data = await res.json() as { + companyName: string; + 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>; + 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).icon ?? "") + : undefined, + title: a.metadata && typeof a.metadata === "object" + ? String((a.metadata as Record).title ?? "") + : undefined, + model: a.config && typeof a.config === "object" + ? String((a.config as Record).model ?? "") + : undefined, + })); + } + } catch { + // Fallback: just show names from dry-run result + previewAgents = data.created.map((name) => ({ name, role: "custom" })); + } + + setCompanyName(data.companyName); + setAgents(previewAgents); + setStep("preview"); + } catch (err) { + setParseError(err instanceof Error ? err.message : "Failed to parse manifest"); + } finally { + setIsParsing(false); + } + }, [manifestContent, projectId]); + + /** Execute the actual import */ + const handleImport = useCallback(async () => { + setIsImporting(true); + setImportError(null); + + try { + const res = await fetch(buildUrl("/agents/import"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ manifest: manifestContent, skipExisting: true }), + }); + + if (!res.ok) { + const data = await res.json() as ApiErrorResponse; + throw new Error(data.error ?? `Import failed (${res.status})`); + } + + const data = await res.json() as ImportResult; + setImportResult(data); + setStep("result"); + onImported(); + } catch (err) { + setImportError(err instanceof Error ? err.message : "Failed to import agents"); + } finally { + setIsImporting(false); + } + }, [manifestContent, projectId, onImported]); + + if (!isOpen) return null; + + return ( +
{ if (e.target === e.currentTarget) handleClose(); }}> +
+ {/* Header */} +
+ Import Agents + +
+ + {/* Body */} +
+ {/* Step 1: Input */} + {step === "input" && ( +
+

+ Import agents from a companies.sh manifest file. Upload a .sh file or paste the manifest content directly. +

+ + {/* File upload */} +
+ + + .sh files supported +
+ + {/* Or divider */} +
+ or paste manifest content +
+ + {/* Text area for paste */} +