feat(FN-1174): migrate agent import flow to agent companies format
- Replace legacy companies.sh parser/types with the new agent companies parser and exported core types. - Update CLI agent import wiring and tests to consume the new import source handling. - Update dashboard agent import modal, API client, and import route tests to support archive-based sources. - Add release-note changesets and lockfile updates for the @gsxdsm/fusion patch release.
This commit is contained in:
7
.changeset/fn-1174-agent-companies-import.md
Normal file
7
.changeset/fn-1174-agent-companies-import.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Replace the legacy `companies.sh` agent import format with the Agent Companies specification.
|
||||||
|
|
||||||
|
Agent import now supports Agent Companies package directories, `.tar.gz`/`.tgz`/`.zip` archives, and standalone `AGENTS.md` manifests with YAML frontmatter across the core parser, CLI, and dashboard import flows.
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
"@gsxdsm/fusion": minor
|
"@gsxdsm/fusion": minor
|
||||||
---
|
---
|
||||||
|
|
||||||
Add Agent Companies import support across the CLI and dashboard agent import flows. Users can now import from package directories, `.tar.gz`/`.tgz` archives, and single `.md` manifests while keeping legacy `.sh` manifest import compatibility.
|
Add Agent Companies import support across the CLI and dashboard agent import flows. Users can import from package directories, `.tar.gz`/`.tgz` archives, and single `.md` manifests with YAML frontmatter.
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Usage:
|
|||||||
fn agent stop <id> Stop a running agent (pause execution)
|
fn agent stop <id> Stop a running agent (pause execution)
|
||||||
fn agent start <id> Start a stopped agent (resume execution)
|
fn agent start <id> Start a stopped agent (resume execution)
|
||||||
fn agent import <path> [--dry-run] [--skip-existing]
|
fn agent import <path> [--dry-run] [--skip-existing]
|
||||||
Import agents from an Agent Companies package (directory, archive, or .md file)
|
Import agents from an Agent Companies package (directory, archive, or AGENTS.md file)
|
||||||
fn agent mailbox <id> View an agent's mailbox
|
fn agent mailbox <id> View an agent's mailbox
|
||||||
fn message inbox List inbox messages
|
fn message inbox List inbox messages
|
||||||
fn message outbox List sent messages
|
fn message outbox List sent messages
|
||||||
|
|||||||
@@ -5,22 +5,6 @@ import { join } from "node:path";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { AgentStore } from "@fusion/core";
|
import { AgentStore } from "@fusion/core";
|
||||||
import { runAgentImport } from "./agent-import.js";
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAgentManifest(options: {
|
function makeAgentManifest(options: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -46,7 +30,14 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
|
|||||||
mkdirSync(basePath, { recursive: true });
|
mkdirSync(basePath, { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(basePath, "COMPANY.md"),
|
join(basePath, "COMPANY.md"),
|
||||||
"---\nname: Example Company\n---\nCompany description",
|
"---\nname: Example Company\nslug: example-company\n---\nCompany description",
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamDir = join(basePath, "teams", "engineering");
|
||||||
|
mkdirSync(teamDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(teamDir, "TEAM.md"),
|
||||||
|
"---\nname: Engineering\nmanager: ../ceo/AGENTS.md\n---",
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentDir = join(basePath, "agents", "ceo");
|
const agentDir = join(basePath, "agents", "ceo");
|
||||||
@@ -56,7 +47,7 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
|
|||||||
makeAgentManifest({
|
makeAgentManifest({
|
||||||
name: agentName,
|
name: agentName,
|
||||||
title: "Chief Executive",
|
title: "Chief Executive",
|
||||||
skills: ["executor"],
|
skills: ["review"],
|
||||||
body: "Lead the company",
|
body: "Lead the company",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -64,10 +55,8 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
|
|||||||
return basePath;
|
return basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("agent-import", () => {
|
describe("agent-import", () => {
|
||||||
const tmpDir = join(tmpdir(), "kb-agent-import-test-" + process.pid);
|
const tmpDir = join(tmpdir(), `kb-agent-import-test-${process.pid}`);
|
||||||
let createAgentMock: ReturnType<typeof vi.fn>;
|
let createAgentMock: ReturnType<typeof vi.fn>;
|
||||||
let listAgentsMock: ReturnType<typeof vi.fn>;
|
let listAgentsMock: ReturnType<typeof vi.fn>;
|
||||||
let initMock: ReturnType<typeof vi.fn>;
|
let initMock: ReturnType<typeof vi.fn>;
|
||||||
@@ -92,171 +81,44 @@ describe("agent-import", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports error on invalid file path", async () => {
|
it("reports error on invalid source path", async () => {
|
||||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||||
throw new Error("process.exit");
|
throw new Error("process.exit");
|
||||||
});
|
});
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
await expect(
|
await expect(runAgentImport(join(tmpDir, "missing"))).rejects.toThrow("process.exit");
|
||||||
runAgentImport(join(tmpDir, "nonexistent.sh")),
|
|
||||||
).rejects.toThrow("process.exit");
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Path not found"));
|
||||||
expect.stringContaining("File not found"),
|
|
||||||
);
|
|
||||||
|
|
||||||
exitSpy.mockRestore();
|
exitSpy.mockRestore();
|
||||||
errorSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports parse error on invalid manifest", async () => {
|
it("reports parse error on malformed AGENTS.md", async () => {
|
||||||
const badFile = join(tmpDir, "bad.sh");
|
const manifestPath = join(tmpDir, "AGENTS.md");
|
||||||
writeFileSync(badFile, "#!/bin/bash\nCOMPANY_NAME=\"test\"\nAGENT_MANIFEST=\"not-valid!!!\"");
|
writeFileSync(manifestPath, "name: missing frontmatter delimiters");
|
||||||
|
|
||||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||||
throw new Error("process.exit");
|
throw new Error("process.exit");
|
||||||
});
|
});
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
await expect(
|
await expect(runAgentImport(manifestPath)).rejects.toThrow("process.exit");
|
||||||
runAgentImport(badFile),
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Parse error"));
|
||||||
).rejects.toThrow("process.exit");
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Parse error"),
|
|
||||||
);
|
|
||||||
|
|
||||||
exitSpy.mockRestore();
|
exitSpy.mockRestore();
|
||||||
errorSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty manifest gracefully", async () => {
|
it("handles empty directory gracefully", async () => {
|
||||||
const emptyFile = join(tmpDir, "empty.sh");
|
const emptyDir = join(tmpDir, "empty-company");
|
||||||
writeFileSync(emptyFile, makeScript("empty-co", []));
|
mkdirSync(emptyDir, { recursive: true });
|
||||||
|
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
await runAgentImport(emptyFile);
|
await runAgentImport(emptyDir);
|
||||||
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
|
|
||||||
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"));
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No agents found"));
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,18 +129,18 @@ describe("agent-import", () => {
|
|||||||
|
|
||||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||||
expect(createAgentMock).toHaveBeenCalledWith(
|
expect(createAgentMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ name: "CEO", role: "executor", title: "Chief Executive" }),
|
expect.objectContaining({ name: "CEO", role: "custom", title: "Chief Executive" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports agents from a single .md AGENTS manifest", async () => {
|
it("imports agents from a single AGENTS.md file", async () => {
|
||||||
const manifestPath = join(tmpDir, "AGENTS.md");
|
const manifestPath = join(tmpDir, "AGENTS.md");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
manifestPath,
|
manifestPath,
|
||||||
makeAgentManifest({
|
makeAgentManifest({
|
||||||
name: "Solo Agent",
|
name: "Solo Agent",
|
||||||
title: "Single File Agent",
|
title: "Single File Agent",
|
||||||
skills: ["reviewer"],
|
skills: ["review"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -286,7 +148,7 @@ describe("agent-import", () => {
|
|||||||
|
|
||||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||||
expect(createAgentMock).toHaveBeenCalledWith(
|
expect(createAgentMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ name: "Solo Agent", role: "reviewer" }),
|
expect.objectContaining({ name: "Solo Agent", role: "custom" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,11 +162,11 @@ describe("agent-import", () => {
|
|||||||
|
|
||||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
||||||
expect(createAgentMock).toHaveBeenCalledWith(
|
expect(createAgentMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ name: "Archive CEO", role: "executor" }),
|
expect.objectContaining({ name: "Archive CEO", role: "custom" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports dry-run for directory imports", async () => {
|
it("supports dry-run mode", async () => {
|
||||||
const companyDir = createCompanyDirectory(join(tmpDir, "company-dry-run"));
|
const companyDir = createCompanyDirectory(join(tmpDir, "company-dry-run"));
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -313,22 +175,23 @@ describe("agent-import", () => {
|
|||||||
expect(createAgentMock).not.toHaveBeenCalled();
|
expect(createAgentMock).not.toHaveBeenCalled();
|
||||||
const output = logSpy.mock.calls.flat().join(" ");
|
const output = logSpy.mock.calls.flat().join(" ");
|
||||||
expect(output).toContain("[DRY RUN]");
|
expect(output).toContain("[DRY RUN]");
|
||||||
expect(output).toContain("CEO");
|
expect(output).toContain("Agents: 1");
|
||||||
|
expect(output).toContain("Teams: 1");
|
||||||
|
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports skip-existing for directory imports", async () => {
|
it("supports skip-existing", async () => {
|
||||||
const companyDir = createCompanyDirectory(join(tmpDir, "company-skip"));
|
const companyDir = createCompanyDirectory(join(tmpDir, "company-skip"));
|
||||||
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "CEO", role: "executor" }]);
|
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "CEO", role: "custom" }]);
|
||||||
|
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
await runAgentImport(companyDir, { skipExisting: true });
|
await runAgentImport(companyDir, { skipExisting: true });
|
||||||
|
|
||||||
expect(createAgentMock).not.toHaveBeenCalled();
|
expect(createAgentMock).not.toHaveBeenCalled();
|
||||||
const output = logSpy.mock.calls.flat().join(" ");
|
const output = logSpy.mock.calls.flat().join(" ");
|
||||||
expect(output).toContain("Skipped: 1");
|
expect(output).toContain("Skipped: 1");
|
||||||
|
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -343,9 +206,7 @@ describe("agent-import", () => {
|
|||||||
|
|
||||||
await expect(runAgentImport(unsupportedPath)).rejects.toThrow("process.exit");
|
await expect(runAgentImport(unsupportedPath)).rejects.toThrow("process.exit");
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unsupported format"));
|
||||||
expect.stringContaining("Unsupported format"),
|
|
||||||
);
|
|
||||||
|
|
||||||
exitSpy.mockRestore();
|
exitSpy.mockRestore();
|
||||||
errorSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
|
|||||||
@@ -13,18 +13,15 @@ import {
|
|||||||
AgentStore,
|
AgentStore,
|
||||||
parseCompanyDirectory,
|
parseCompanyDirectory,
|
||||||
parseCompanyArchive,
|
parseCompanyArchive,
|
||||||
parseAgentManifest,
|
parseSingleAgentManifest,
|
||||||
convertAgentCompanies,
|
convertAgentCompanies,
|
||||||
AgentCompaniesParseError,
|
AgentCompaniesParseError,
|
||||||
parseCompaniesShManifest,
|
|
||||||
convertCompaniesShAgents,
|
|
||||||
CompaniesShParseError,
|
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { AgentCreateInput } from "@fusion/core";
|
import type { AgentCreateInput } from "@fusion/core";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
|
|
||||||
const UNSUPPORTED_FORMAT_MESSAGE =
|
const UNSUPPORTED_FORMAT_MESSAGE =
|
||||||
"Unsupported format. Provide a directory, .tar.gz/.zip archive, .md file, or .sh manifest.";
|
"Unsupported format. Provide an Agent Companies directory, .tar.gz/.tgz/.zip archive, or AGENTS.md file.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the project path for agent operations.
|
* Get the project path for agent operations.
|
||||||
@@ -49,6 +46,8 @@ async function getProjectPath(projectName?: string): Promise<string> {
|
|||||||
*/
|
*/
|
||||||
function printSummary(
|
function printSummary(
|
||||||
companyName: string | undefined,
|
companyName: string | undefined,
|
||||||
|
agentCount: number,
|
||||||
|
teamCount: number,
|
||||||
created: string[],
|
created: string[],
|
||||||
skipped: string[],
|
skipped: string[],
|
||||||
errors: Array<{ name: string; error: string }>,
|
errors: Array<{ name: string; error: string }>,
|
||||||
@@ -56,7 +55,9 @@ function printSummary(
|
|||||||
): void {
|
): void {
|
||||||
const prefix = dryRun ? "[DRY RUN] " : "";
|
const prefix = dryRun ? "[DRY RUN] " : "";
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` ${prefix}Import from company: ${companyName ?? "Unknown"}`);
|
console.log(` ${prefix}Company: ${companyName ?? "Unknown"}`);
|
||||||
|
console.log(` ${prefix}Agents: ${agentCount}`);
|
||||||
|
console.log(` ${prefix}Teams: ${teamCount}`);
|
||||||
console.log(` ${prefix}Created: ${created.length}`);
|
console.log(` ${prefix}Created: ${created.length}`);
|
||||||
for (const name of created) {
|
for (const name of created) {
|
||||||
console.log(` ✓ ${name}`);
|
console.log(` ✓ ${name}`);
|
||||||
@@ -99,7 +100,7 @@ export async function runAgentImport(
|
|||||||
|
|
||||||
const sourcePath = resolve(source);
|
const sourcePath = resolve(source);
|
||||||
if (!existsSync(sourcePath)) {
|
if (!existsSync(sourcePath)) {
|
||||||
console.error(`File not found: ${sourcePath}`);
|
console.error(`Path not found: ${sourcePath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +111,11 @@ export async function runAgentImport(
|
|||||||
|
|
||||||
const existingAgents = await agentStore.listAgents();
|
const existingAgents = await agentStore.listAgents();
|
||||||
const existingNames = new Set(existingAgents.map((a) => a.name));
|
const existingNames = new Set(existingAgents.map((a) => a.name));
|
||||||
|
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
|
||||||
|
|
||||||
let companyName: string | undefined;
|
let companyName: string | undefined;
|
||||||
|
let agentCount = 0;
|
||||||
|
let teamCount = 0;
|
||||||
let inputs: AgentCreateInput[] = [];
|
let inputs: AgentCreateInput[] = [];
|
||||||
let result: {
|
let result: {
|
||||||
created: string[];
|
created: string[];
|
||||||
@@ -129,45 +133,33 @@ export async function runAgentImport(
|
|||||||
if (sourceStats.isDirectory()) {
|
if (sourceStats.isDirectory()) {
|
||||||
const pkg = parseCompanyDirectory(sourcePath);
|
const pkg = parseCompanyDirectory(sourcePath);
|
||||||
companyName = pkg.company?.name;
|
companyName = pkg.company?.name;
|
||||||
({ inputs, result } = convertAgentCompanies(
|
agentCount = pkg.agents.length;
|
||||||
pkg,
|
teamCount = pkg.teams.length;
|
||||||
skipExisting ? { skipExisting: [...existingNames] } : undefined,
|
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||||
));
|
|
||||||
} else if (isArchivePath(sourcePath)) {
|
} else if (isArchivePath(sourcePath)) {
|
||||||
const pkg = await parseCompanyArchive(sourcePath);
|
const pkg = await parseCompanyArchive(sourcePath);
|
||||||
companyName = pkg.company?.name;
|
companyName = pkg.company?.name;
|
||||||
({ inputs, result } = convertAgentCompanies(
|
agentCount = pkg.agents.length;
|
||||||
pkg,
|
teamCount = pkg.teams.length;
|
||||||
skipExisting ? { skipExisting: [...existingNames] } : undefined,
|
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||||
));
|
|
||||||
} else if (sourcePath.endsWith(".md")) {
|
} else if (sourcePath.endsWith(".md")) {
|
||||||
const content = readFileSync(sourcePath, "utf-8");
|
const content = readFileSync(sourcePath, "utf-8");
|
||||||
const manifest = parseAgentManifest(content);
|
const { manifest } = parseSingleAgentManifest(content);
|
||||||
const pkg = {
|
const pkg = {
|
||||||
company: undefined,
|
company: undefined,
|
||||||
agents: [manifest],
|
agents: [manifest],
|
||||||
teams: [],
|
teams: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
skills: [],
|
|
||||||
};
|
};
|
||||||
({ inputs, result } = convertAgentCompanies(
|
agentCount = pkg.agents.length;
|
||||||
pkg,
|
teamCount = 0;
|
||||||
skipExisting ? { skipExisting: [...existingNames] } : undefined,
|
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
||||||
));
|
|
||||||
} else if (sourcePath.endsWith(".sh")) {
|
|
||||||
const content = readFileSync(sourcePath, "utf-8");
|
|
||||||
const manifest = parseCompaniesShManifest(content);
|
|
||||||
companyName = manifest.companyName;
|
|
||||||
({ inputs, result } = convertCompaniesShAgents(
|
|
||||||
manifest.agents,
|
|
||||||
skipExisting ? { skipExisting: [...existingNames] } : undefined,
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(UNSUPPORTED_FORMAT_MESSAGE);
|
throw new Error(UNSUPPORTED_FORMAT_MESSAGE);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AgentCompaniesParseError || err instanceof CompaniesShParseError) {
|
if (err instanceof AgentCompaniesParseError) {
|
||||||
console.error(`Parse error: ${err.message}`);
|
console.error(`Parse error: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -190,7 +182,7 @@ export async function runAgentImport(
|
|||||||
|
|
||||||
// Dry run: just preview
|
// Dry run: just preview
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
printSummary(companyName, result.created, result.skipped, result.errors, true);
|
printSummary(companyName, agentCount, teamCount, result.created, result.skipped, result.errors, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,5 +205,5 @@ export async function runAgentImport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
printSummary(companyName, created, result.skipped, errors, false);
|
printSummary(companyName, agentCount, teamCount, created, result.skipped, errors, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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";
|
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"extract-zip": "^2.0.1",
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
AgentCompaniesParseError,
|
AgentCompaniesParseError,
|
||||||
agentManifestToAgentCreateInput,
|
agentManifestToAgentCreateInput,
|
||||||
convertAgentCompanies,
|
convertAgentCompanies,
|
||||||
|
mapRoleToCapability,
|
||||||
parseAgentManifest,
|
parseAgentManifest,
|
||||||
parseCompanyArchive,
|
parseCompanyArchive,
|
||||||
parseCompanyDirectory,
|
parseCompanyDirectory,
|
||||||
parseCompanyManifest,
|
parseCompanyManifest,
|
||||||
parseProjectManifest,
|
parseProjectManifest,
|
||||||
parseSkillManifest,
|
parseSingleAgentManifest,
|
||||||
parseTaskManifest,
|
parseTaskManifest,
|
||||||
parseTeamManifest,
|
parseTeamManifest,
|
||||||
parseYamlFrontmatter,
|
parseYamlFrontmatter,
|
||||||
@@ -33,6 +34,15 @@ function writeTextFile(path: string, content: string): void {
|
|||||||
writeFileSync(path, content, "utf-8");
|
writeFileSync(path, content, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCommand(command: string): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`command -v ${command}`, { stdio: "pipe" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
while (tempDirs.length > 0) {
|
while (tempDirs.length > 0) {
|
||||||
const dir = tempDirs.pop();
|
const dir = tempDirs.pop();
|
||||||
@@ -44,96 +54,58 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("agent-companies-parser", () => {
|
describe("agent-companies-parser", () => {
|
||||||
describe("parseYamlFrontmatter", () => {
|
describe("parseYamlFrontmatter", () => {
|
||||||
it("parses YAML frontmatter with markdown body", () => {
|
it("parses valid YAML frontmatter and body", () => {
|
||||||
const content = `---
|
const content = `---
|
||||||
name: CEO
|
name: CEO
|
||||||
skills:
|
skills:
|
||||||
- review
|
- review
|
||||||
---
|
---
|
||||||
You are the CEO agent.`;
|
Lead code review.`;
|
||||||
|
|
||||||
const { frontmatter, body } = parseYamlFrontmatter(content);
|
const parsed = parseYamlFrontmatter(content);
|
||||||
|
expect(parsed.frontmatter.name).toBe("CEO");
|
||||||
expect(frontmatter.name).toBe("CEO");
|
expect(parsed.frontmatter.skills).toEqual(["review"]);
|
||||||
expect(frontmatter.skills).toEqual(["review"]);
|
expect(parsed.body).toBe("Lead code review.");
|
||||||
expect(body).toBe("You are the CEO agent.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses frontmatter with no body", () => {
|
it("throws when frontmatter is missing", () => {
|
||||||
|
expect(() => parseYamlFrontmatter("name: CEO")).toThrow(AgentCompaniesParseError);
|
||||||
|
expect(() => parseYamlFrontmatter("name: CEO")).toThrow("Missing YAML frontmatter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when YAML is malformed", () => {
|
||||||
const content = `---
|
const content = `---
|
||||||
name: Lean Dev Shop
|
|
||||||
---`;
|
|
||||||
const { frontmatter, body } = parseYamlFrontmatter(content);
|
|
||||||
|
|
||||||
expect(frontmatter.name).toBe("Lean Dev Shop");
|
|
||||||
expect(body).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on missing frontmatter delimiters", () => {
|
|
||||||
expect(() => parseYamlFrontmatter("name: no delimiters")).toThrow(
|
|
||||||
AgentCompaniesParseError,
|
|
||||||
);
|
|
||||||
expect(() => parseYamlFrontmatter("name: no delimiters")).toThrow(
|
|
||||||
"Missing YAML frontmatter delimiters",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on malformed YAML", () => {
|
|
||||||
const malformed = `---
|
|
||||||
name: CEO
|
name: CEO
|
||||||
skills: [review
|
skills: [review
|
||||||
---
|
---
|
||||||
body`;
|
Body`;
|
||||||
expect(() => parseYamlFrontmatter(malformed)).toThrow(AgentCompaniesParseError);
|
|
||||||
expect(() => parseYamlFrontmatter(malformed)).toThrow("Malformed YAML frontmatter");
|
expect(() => parseYamlFrontmatter(content)).toThrow("Malformed YAML frontmatter");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when YAML parses to null", () => {
|
it("supports empty body", () => {
|
||||||
const content = `---
|
const parsed = parseYamlFrontmatter(`---
|
||||||
null
|
name: CEO
|
||||||
---`;
|
---`);
|
||||||
expect(() => parseYamlFrontmatter(content)).toThrow("must parse to an object");
|
expect(parsed.body).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when YAML parses to an array", () => {
|
it("parses multiline fields", () => {
|
||||||
const content = `---
|
const parsed = parseYamlFrontmatter(`---
|
||||||
- one
|
|
||||||
- two
|
|
||||||
---`;
|
|
||||||
expect(() => parseYamlFrontmatter(content)).toThrow("must parse to an object");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles multiline frontmatter fields", () => {
|
|
||||||
const content = `---
|
|
||||||
name: CEO
|
name: CEO
|
||||||
description: |
|
description: |
|
||||||
Leads strategy
|
First line
|
||||||
Reviews direction
|
Second line
|
||||||
---
|
---
|
||||||
Body`;
|
Body`);
|
||||||
const { frontmatter } = parseYamlFrontmatter(content);
|
|
||||||
|
|
||||||
expect(String(frontmatter.description)).toContain("Leads strategy");
|
expect(parsed.frontmatter.description).toBe("First line\nSecond line\n");
|
||||||
expect(String(frontmatter.description)).toContain("Reviews direction");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles array fields in frontmatter", () => {
|
|
||||||
const content = `---
|
|
||||||
name: Reviewer
|
|
||||||
skills:
|
|
||||||
- review
|
|
||||||
- security-review
|
|
||||||
---
|
|
||||||
Body`;
|
|
||||||
const { frontmatter } = parseYamlFrontmatter(content);
|
|
||||||
|
|
||||||
expect(frontmatter.skills).toEqual(["review", "security-review"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("individual manifest parsing", () => {
|
describe("individual manifests", () => {
|
||||||
it("parses AGENTS.md with full frontmatter and body", () => {
|
it("parses full AGENTS.md", () => {
|
||||||
const content = `---
|
const manifest = parseAgentManifest(`---
|
||||||
name: CEO
|
name: CEO
|
||||||
title: Chief Executive Officer
|
title: Chief Executive Officer
|
||||||
reportsTo: null
|
reportsTo: null
|
||||||
@@ -141,25 +113,32 @@ skills:
|
|||||||
- plan-ceo-review
|
- plan-ceo-review
|
||||||
- review
|
- review
|
||||||
---
|
---
|
||||||
You are the CEO agent. Your job is to lead.`;
|
Agent instructions.`);
|
||||||
|
|
||||||
const manifest = parseAgentManifest(content);
|
|
||||||
|
|
||||||
expect(manifest.name).toBe("CEO");
|
expect(manifest.name).toBe("CEO");
|
||||||
expect(manifest.title).toBe("Chief Executive Officer");
|
expect(manifest.title).toBe("Chief Executive Officer");
|
||||||
expect(manifest.reportsTo).toBeNull();
|
expect(manifest.reportsTo).toBeNull();
|
||||||
expect(manifest.skills).toEqual(["plan-ceo-review", "review"]);
|
expect(manifest.skills).toEqual(["plan-ceo-review", "review"]);
|
||||||
expect(manifest.instructionBody).toContain("You are the CEO agent");
|
expect(manifest.instructionBody).toBe("Agent instructions.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses AGENTS.md with minimal fields", () => {
|
it("parses minimal AGENTS.md", () => {
|
||||||
const manifest = parseAgentManifest(`---
|
const manifest = parseAgentManifest(`---
|
||||||
name: Minimal Agent
|
name: Solo Agent
|
||||||
---`);
|
---`);
|
||||||
expect(manifest.name).toBe("Minimal Agent");
|
expect(manifest.name).toBe("Solo Agent");
|
||||||
expect(manifest.instructionBody).toBe("");
|
expect(manifest.instructionBody).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses standalone AGENTS.md wrapper", () => {
|
||||||
|
const parsed = parseSingleAgentManifest(`---
|
||||||
|
name: Solo Agent
|
||||||
|
---
|
||||||
|
Be helpful.`);
|
||||||
|
expect(parsed.manifest.name).toBe("Solo Agent");
|
||||||
|
expect(parsed.manifest.instructionBody).toBe("Be helpful.");
|
||||||
|
});
|
||||||
|
|
||||||
it("parses COMPANY.md with schema and slug", () => {
|
it("parses COMPANY.md with schema and slug", () => {
|
||||||
const manifest = parseCompanyManifest(`---
|
const manifest = parseCompanyManifest(`---
|
||||||
name: Lean Dev Shop
|
name: Lean Dev Shop
|
||||||
@@ -177,79 +156,44 @@ schema: agentcompanies/v1
|
|||||||
name: Engineering
|
name: Engineering
|
||||||
manager: ../cto/AGENTS.md
|
manager: ../cto/AGENTS.md
|
||||||
includes:
|
includes:
|
||||||
- ../platform-lead/AGENTS.md
|
- ../platform/TEAM.md
|
||||||
- ../../skills/review/SKILL.md
|
|
||||||
---`);
|
---`);
|
||||||
|
|
||||||
expect(manifest.manager).toBe("../cto/AGENTS.md");
|
expect(manifest.manager).toBe("../cto/AGENTS.md");
|
||||||
expect(manifest.includes).toEqual([
|
expect(manifest.includes).toEqual(["../platform/TEAM.md"]);
|
||||||
"../platform-lead/AGENTS.md",
|
|
||||||
"../../skills/review/SKILL.md",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses PROJECT.md", () => {
|
it("parses PROJECT.md", () => {
|
||||||
const manifest = parseProjectManifest(`---
|
const manifest = parseProjectManifest(`---
|
||||||
name: Q2 Launch
|
name: Q2 Launch
|
||||||
description: Launch execution project
|
|
||||||
slug: q2-launch
|
slug: q2-launch
|
||||||
---`);
|
---`);
|
||||||
|
|
||||||
expect(manifest.name).toBe("Q2 Launch");
|
|
||||||
expect(manifest.slug).toBe("q2-launch");
|
expect(manifest.slug).toBe("q2-launch");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses TASK.md with assignee, project, and schedule", () => {
|
it("parses TASK.md schedule", () => {
|
||||||
const manifest = parseTaskManifest(`---
|
const manifest = parseTaskManifest(`---
|
||||||
name: Monday Review
|
name: Monday Review
|
||||||
slug: monday-review
|
|
||||||
description: Weekly code review
|
|
||||||
assignee: ./agents/ceo/AGENTS.md
|
assignee: ./agents/ceo/AGENTS.md
|
||||||
project: ./projects/q2-launch/PROJECT.md
|
project: ./projects/q2-launch/PROJECT.md
|
||||||
schedule:
|
schedule:
|
||||||
timezone: America/New_York
|
timezone: America/New_York
|
||||||
startsAt: "2025-01-06T09:00:00"
|
startsAt: "2026-04-14T09:00:00"
|
||||||
---`);
|
---`);
|
||||||
|
|
||||||
expect(manifest.assignee).toBe("./agents/ceo/AGENTS.md");
|
expect(manifest.assignee).toBe("./agents/ceo/AGENTS.md");
|
||||||
expect(manifest.project).toBe("./projects/q2-launch/PROJECT.md");
|
expect(manifest.schedule?.timezone).toBe("America/New_York");
|
||||||
expect(manifest.schedule).toEqual({
|
|
||||||
timezone: "America/New_York",
|
|
||||||
startsAt: "2025-01-06T09:00:00",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses SKILL.md with provides and requirements", () => {
|
|
||||||
const manifest = parseSkillManifest(`---
|
|
||||||
name: Code Review
|
|
||||||
provides:
|
|
||||||
- code-review
|
|
||||||
requirements:
|
|
||||||
- typescript
|
|
||||||
---`);
|
|
||||||
|
|
||||||
expect(manifest.provides).toEqual(["code-review"]);
|
|
||||||
expect(manifest.requirements).toEqual(["typescript"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on missing required name field", () => {
|
|
||||||
const invalid = `---
|
|
||||||
description: Missing required field
|
|
||||||
---`;
|
|
||||||
expect(() => parseTeamManifest(invalid)).toThrow(AgentCompaniesParseError);
|
|
||||||
expect(() => parseTeamManifest(invalid)).toThrow("team manifest is missing required field: name");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseCompanyDirectory", () => {
|
describe("directory parsing", () => {
|
||||||
it("parses a full directory structure", () => {
|
it("parses a full company directory", () => {
|
||||||
const root = createTempDir();
|
const root = createTempDir();
|
||||||
|
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
join(root, "COMPANY.md"),
|
join(root, "COMPANY.md"),
|
||||||
`---
|
`---
|
||||||
name: Lean Dev Shop
|
name: Lean Dev Shop
|
||||||
description: Small engineering-focused AI company
|
slug: lean-dev-shop
|
||||||
schema: agentcompanies/v1
|
schema: agentcompanies/v1
|
||||||
---`,
|
---`,
|
||||||
);
|
);
|
||||||
@@ -261,28 +205,25 @@ title: Chief Executive Officer
|
|||||||
skills:
|
skills:
|
||||||
- review
|
- review
|
||||||
---
|
---
|
||||||
You are the CEO agent.`,
|
Lead reviews.`,
|
||||||
);
|
);
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
join(root, "teams", "engineering", "TEAM.md"),
|
join(root, "teams", "engineering", "TEAM.md"),
|
||||||
`---
|
`---
|
||||||
name: Engineering
|
name: Engineering
|
||||||
manager: ../cto/AGENTS.md
|
manager: ../ceo/AGENTS.md
|
||||||
---`,
|
---`,
|
||||||
);
|
);
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
join(root, "tasks", "review", "TASK.md"),
|
join(root, "projects", "q2-launch", "PROJECT.md"),
|
||||||
|
`---
|
||||||
|
name: Q2 Launch
|
||||||
|
---`,
|
||||||
|
);
|
||||||
|
writeTextFile(
|
||||||
|
join(root, "tasks", "monday-review", "TASK.md"),
|
||||||
`---
|
`---
|
||||||
name: Monday Review
|
name: Monday Review
|
||||||
assignee: ./agents/ceo/AGENTS.md
|
|
||||||
---`,
|
|
||||||
);
|
|
||||||
writeTextFile(
|
|
||||||
join(root, "skills", "code-review", "SKILL.md"),
|
|
||||||
`---
|
|
||||||
name: Code Review
|
|
||||||
provides:
|
|
||||||
- code-review
|
|
||||||
---`,
|
---`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -291,235 +232,171 @@ provides:
|
|||||||
expect(pkg.company?.name).toBe("Lean Dev Shop");
|
expect(pkg.company?.name).toBe("Lean Dev Shop");
|
||||||
expect(pkg.agents).toHaveLength(1);
|
expect(pkg.agents).toHaveLength(1);
|
||||||
expect(pkg.teams).toHaveLength(1);
|
expect(pkg.teams).toHaveLength(1);
|
||||||
|
expect(pkg.projects).toHaveLength(1);
|
||||||
expect(pkg.tasks).toHaveLength(1);
|
expect(pkg.tasks).toHaveLength(1);
|
||||||
expect(pkg.skills).toHaveLength(1);
|
|
||||||
expect(pkg.projects).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles directory with only agents and no COMPANY.md", () => {
|
it("parses agents-only directory without COMPANY.md", () => {
|
||||||
const root = createTempDir();
|
const root = createTempDir();
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
join(root, "agents", "ceo", "AGENTS.md"),
|
join(root, "agents", "solo", "AGENTS.md"),
|
||||||
`---
|
`---
|
||||||
name: CEO
|
name: Solo Agent
|
||||||
---`,
|
---`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pkg = parseCompanyDirectory(root);
|
const pkg = parseCompanyDirectory(root);
|
||||||
expect(pkg.company).toBeUndefined();
|
expect(pkg.company).toBeUndefined();
|
||||||
expect(pkg.agents).toHaveLength(1);
|
expect(pkg.agents).toHaveLength(1);
|
||||||
|
expect(pkg.teams).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty directory", () => {
|
it("parses empty directory", () => {
|
||||||
const root = createTempDir();
|
const root = createTempDir();
|
||||||
const pkg = parseCompanyDirectory(root);
|
const pkg = parseCompanyDirectory(root);
|
||||||
|
|
||||||
expect(pkg).toEqual({
|
expect(pkg).toEqual({
|
||||||
company: undefined,
|
company: undefined,
|
||||||
agents: [],
|
agents: [],
|
||||||
teams: [],
|
teams: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
skills: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on non-existent directory", () => {
|
it("handles circular team includes without recursion issues", () => {
|
||||||
const root = createTempDir();
|
const root = createTempDir();
|
||||||
const missingPath = join(root, "missing");
|
|
||||||
expect(() => parseCompanyDirectory(missingPath)).toThrow("does not exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when path is not a directory", () => {
|
|
||||||
const root = createTempDir();
|
|
||||||
const filePath = join(root, "not-a-directory.md");
|
|
||||||
writeTextFile(filePath, "hello");
|
|
||||||
|
|
||||||
expect(() => parseCompanyDirectory(filePath)).toThrow("is not a directory");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores non-directory entries in section folders", () => {
|
|
||||||
const root = createTempDir();
|
|
||||||
writeTextFile(join(root, "agents", "README.md"), "not a manifest folder");
|
|
||||||
writeTextFile(join(root, "agents", "ceo", "AGENTS.md"), `---
|
|
||||||
name: CEO
|
|
||||||
---`);
|
|
||||||
|
|
||||||
const pkg = parseCompanyDirectory(root);
|
|
||||||
expect(pkg.agents).toHaveLength(1);
|
|
||||||
expect(pkg.agents[0].name).toBe("CEO");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes file path context in parse errors", () => {
|
|
||||||
const root = createTempDir();
|
|
||||||
writeTextFile(join(root, "agents", "ceo", "AGENTS.md"), `---
|
|
||||||
description: missing name
|
|
||||||
---`);
|
|
||||||
|
|
||||||
expect(() => parseCompanyDirectory(root)).toThrow("AGENTS.md");
|
|
||||||
expect(() => parseCompanyDirectory(root)).toThrow("missing required field: name");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseCompanyArchive", () => {
|
|
||||||
it("throws a clear error for zip archives", async () => {
|
|
||||||
const zipPath = join(createTempDir(), "company.zip");
|
|
||||||
await expect(parseCompanyArchive(zipPath)).rejects.toThrow(
|
|
||||||
"Zip archives are not yet supported",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a tar.gz archive", async () => {
|
|
||||||
const temp = createTempDir();
|
|
||||||
const packageDirName = "company-package";
|
|
||||||
const packageDir = join(temp, packageDirName);
|
|
||||||
|
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
join(packageDir, "COMPANY.md"),
|
join(root, "teams", "a", "TEAM.md"),
|
||||||
`---
|
`---
|
||||||
name: Lean Dev Shop
|
name: a
|
||||||
schema: agentcompanies/v1
|
slug: a
|
||||||
|
includes:
|
||||||
|
- ../b/TEAM.md
|
||||||
---`,
|
---`,
|
||||||
);
|
);
|
||||||
writeTextFile(
|
writeTextFile(
|
||||||
join(packageDir, "agents", "ceo", "AGENTS.md"),
|
join(root, "teams", "b", "TEAM.md"),
|
||||||
`---
|
`---
|
||||||
name: CEO
|
name: b
|
||||||
skills:
|
slug: b
|
||||||
- review
|
includes:
|
||||||
---
|
- ../a/TEAM.md
|
||||||
You are the CEO agent.`,
|
---`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const archivePath = join(temp, "company.tgz");
|
const pkg = parseCompanyDirectory(root);
|
||||||
execSync(
|
expect(pkg.teams).toHaveLength(2);
|
||||||
`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(temp)} ${JSON.stringify(packageDirName)}`,
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
|
describe("archive parsing", () => {
|
||||||
|
it("parses a .tgz archive", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const packageDir = join(root, "company-package");
|
||||||
|
writeTextFile(join(packageDir, "COMPANY.md"), `---
|
||||||
|
name: Archive Company
|
||||||
|
schema: agentcompanies/v1
|
||||||
|
---`);
|
||||||
|
writeTextFile(join(packageDir, "agents", "ceo", "AGENTS.md"), `---
|
||||||
|
name: Archive CEO
|
||||||
|
---`);
|
||||||
|
|
||||||
|
const archivePath = join(root, "company.tgz");
|
||||||
|
execSync(`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(root)} company-package`);
|
||||||
|
|
||||||
const pkg = await parseCompanyArchive(archivePath);
|
const pkg = await parseCompanyArchive(archivePath);
|
||||||
expect(pkg.company?.name).toBe("Lean Dev Shop");
|
expect(pkg.company?.name).toBe("Archive Company");
|
||||||
|
expect(pkg.agents[0]?.name).toBe("Archive CEO");
|
||||||
|
});
|
||||||
|
|
||||||
|
const zipIt = hasCommand("zip") ? it : it.skip;
|
||||||
|
zipIt("parses a .zip archive", async () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const packageDir = join(root, "zip-company");
|
||||||
|
writeTextFile(join(packageDir, "COMPANY.md"), `---
|
||||||
|
name: Zip Company
|
||||||
|
schema: agentcompanies/v1
|
||||||
|
---`);
|
||||||
|
writeTextFile(join(packageDir, "agents", "ceo", "AGENTS.md"), `---
|
||||||
|
name: Zip CEO
|
||||||
|
---`);
|
||||||
|
|
||||||
|
const archivePath = join(root, "company.zip");
|
||||||
|
execSync(`zip -qr ${JSON.stringify(archivePath)} zip-company`, { cwd: root });
|
||||||
|
|
||||||
|
const pkg = await parseCompanyArchive(archivePath);
|
||||||
|
expect(pkg.company?.name).toBe("Zip Company");
|
||||||
expect(pkg.agents).toHaveLength(1);
|
expect(pkg.agents).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles archive with a single file entry", async () => {
|
it("throws for unsupported archive extension", async () => {
|
||||||
const temp = createTempDir();
|
const root = createTempDir();
|
||||||
writeTextFile(join(temp, "README.md"), "hello");
|
const archivePath = join(root, "company.rar");
|
||||||
|
writeTextFile(archivePath, "not a real archive");
|
||||||
|
|
||||||
const archivePath = join(temp, "single-file.tgz");
|
await expect(parseCompanyArchive(archivePath)).rejects.toThrow(
|
||||||
execSync(
|
"Unsupported archive format",
|
||||||
`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(temp)} README.md`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pkg = await parseCompanyArchive(archivePath);
|
|
||||||
expect(pkg.company).toBeUndefined();
|
|
||||||
expect(pkg.agents).toEqual([]);
|
|
||||||
expect(pkg.teams).toEqual([]);
|
|
||||||
expect(pkg.projects).toEqual([]);
|
|
||||||
expect(pkg.tasks).toEqual([]);
|
|
||||||
expect(pkg.skills).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("conversion", () => {
|
describe("conversion", () => {
|
||||||
it("converts AgentManifest to AgentCreateInput", () => {
|
it("maps AgentManifest to AgentCreateInput", () => {
|
||||||
const input = agentManifestToAgentCreateInput({
|
const input = agentManifestToAgentCreateInput({
|
||||||
name: "CEO",
|
name: "CEO",
|
||||||
title: "Chief Executive Officer",
|
title: "Chief Executive Officer",
|
||||||
instructionBody: "Lead the company",
|
instructionBody: "Lead strategy",
|
||||||
skills: ["review"],
|
skills: ["review"],
|
||||||
reportsTo: "../founder/AGENTS.md",
|
reportsTo: null,
|
||||||
|
metadata: {
|
||||||
|
sources: [{ kind: "git", repo: "acme/repo" }],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(input.name).toBe("CEO");
|
expect(input).toEqual({
|
||||||
expect(input.title).toBe("Chief Executive Officer");
|
name: "CEO",
|
||||||
expect(input.instructionsText).toBe("Lead the company");
|
role: "custom",
|
||||||
expect(input.metadata).toEqual({ skills: ["review"] });
|
title: "Chief Executive Officer",
|
||||||
expect(input.reportsTo).toBe("../founder/AGENTS.md");
|
metadata: {
|
||||||
expect(input.role).toBe("reviewer");
|
instructions: "Lead strategy",
|
||||||
});
|
skills: ["review"],
|
||||||
|
reportsTo: null,
|
||||||
it("defaults to custom role when no skills are provided", () => {
|
sources: [{ kind: "git", repo: "acme/repo" }],
|
||||||
const input = agentManifestToAgentCreateInput({
|
},
|
||||||
name: "Unknown",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(input.role).toBe("custom");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("infers role from reportsTo when skills are absent", () => {
|
it("converts package agents with skipExisting", () => {
|
||||||
const input = agentManifestToAgentCreateInput({
|
|
||||||
name: "Planner",
|
|
||||||
reportsTo: "../triage-lead/AGENTS.md",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(input.role).toBe("triage");
|
|
||||||
expect(input.reportsTo).toBe("../triage-lead/AGENTS.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers skills over reportsTo for role inference", () => {
|
|
||||||
const input = agentManifestToAgentCreateInput({
|
|
||||||
name: "Mixed",
|
|
||||||
skills: ["review"],
|
|
||||||
reportsTo: "../executor-lead/AGENTS.md",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(input.role).toBe("reviewer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("infers role from skills containing role hints", () => {
|
|
||||||
const input = agentManifestToAgentCreateInput({
|
|
||||||
name: "Triager",
|
|
||||||
skills: ["plan-triage-review"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(input.role).toBe("triage");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps instructionBody to instructionsText", () => {
|
|
||||||
const input = agentManifestToAgentCreateInput({
|
|
||||||
name: "Writer",
|
|
||||||
instructionBody: "Write docs",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(input.instructionsText).toBe("Write docs");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts package agents with skipExisting logic", () => {
|
|
||||||
const { inputs, result } = convertAgentCompanies(
|
const { inputs, result } = convertAgentCompanies(
|
||||||
{
|
{
|
||||||
company: { name: "Lean Dev Shop" },
|
company: { name: "Example" },
|
||||||
agents: [
|
agents: [{ name: "Existing" }, { name: "New Agent", title: "New" }],
|
||||||
{ name: "Existing", skills: ["review"] },
|
|
||||||
{ name: "New Agent", skills: ["executor"] },
|
|
||||||
],
|
|
||||||
teams: [],
|
teams: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
skills: [],
|
|
||||||
},
|
},
|
||||||
{ skipExisting: ["Existing"] },
|
{ skipExisting: ["Existing"] },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(inputs).toHaveLength(1);
|
expect(inputs).toHaveLength(1);
|
||||||
expect(inputs[0].name).toBe("New Agent");
|
expect(inputs[0]?.name).toBe("New Agent");
|
||||||
expect(inputs[0].role).toBe("executor");
|
expect(result).toEqual({
|
||||||
expect(result.created).toEqual(["New Agent"]);
|
created: ["New Agent"],
|
||||||
expect(result.skipped).toEqual(["Existing"]);
|
skipped: ["Existing"],
|
||||||
expect(result.errors).toEqual([]);
|
errors: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to custom role when no skills are present", () => {
|
||||||
|
const input = agentManifestToAgentCreateInput({ name: "Generalist" });
|
||||||
|
expect(input.role).toBe("custom");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error handling", () => {
|
describe("mapRoleToCapability", () => {
|
||||||
it("AgentCompaniesParseError has correct name", () => {
|
it("maps known roles and defaults unknowns to custom", () => {
|
||||||
const err = new AgentCompaniesParseError("boom");
|
expect(mapRoleToCapability("reviewer")).toBe("reviewer");
|
||||||
expect(err.name).toBe("AgentCompaniesParseError");
|
expect(mapRoleToCapability("unknown-role")).toBe("custom");
|
||||||
expect(err.message).toBe("boom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("error messages include parsing context", () => {
|
|
||||||
expect(() => parseCompanyManifest("---\ndescription: missing name\n---")).toThrow(
|
|
||||||
"company manifest is missing required field: name",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Parser for Agent Companies markdown manifests.
|
* Parser for Agent Companies markdown manifests.
|
||||||
*
|
*
|
||||||
* Supports YAML frontmatter extraction, per-manifest parsing,
|
|
||||||
* directory/package parsing, archive parsing, and conversion into
|
|
||||||
* Fusion `AgentCreateInput` payloads.
|
|
||||||
*
|
|
||||||
* @module agent-companies-parser
|
* @module agent-companies-parser
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||||
import { join, resolve } from "node:path";
|
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import extractZip from "extract-zip";
|
||||||
import { parse as parseYaml } from "yaml";
|
import { parse as parseYaml } from "yaml";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -20,17 +18,11 @@ import type {
|
|||||||
AgentManifest,
|
AgentManifest,
|
||||||
CompanyManifest,
|
CompanyManifest,
|
||||||
ProjectManifest,
|
ProjectManifest,
|
||||||
SkillManifest,
|
|
||||||
TaskManifest,
|
TaskManifest,
|
||||||
TeamManifest,
|
TeamManifest,
|
||||||
} from "./agent-companies-types.js";
|
} from "./agent-companies-types.js";
|
||||||
import { mapRoleToCapability } from "./companies-sh-parser.js";
|
|
||||||
import type { AgentCapability, AgentCreateInput } from "./types.js";
|
import type { AgentCapability, AgentCreateInput } from "./types.js";
|
||||||
|
|
||||||
export { mapRoleToCapability } from "./companies-sh-parser.js";
|
|
||||||
|
|
||||||
// ── Parsing Errors ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class AgentCompaniesParseError extends Error {
|
export class AgentCompaniesParseError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -38,18 +30,32 @@ export class AgentCompaniesParseError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Frontmatter Parsing ──────────────────────────────────────────────────
|
const VALID_ROLES: Set<string> = new Set([
|
||||||
|
"triage",
|
||||||
|
"executor",
|
||||||
|
"reviewer",
|
||||||
|
"merger",
|
||||||
|
"scheduler",
|
||||||
|
"engineer",
|
||||||
|
"custom",
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract YAML frontmatter and markdown body from a manifest file.
|
* Map a role string to a Fusion agent capability.
|
||||||
*
|
* Unknown roles fall back to "custom".
|
||||||
* @throws {AgentCompaniesParseError} On missing or malformed frontmatter.
|
|
||||||
*/
|
*/
|
||||||
|
export function mapRoleToCapability(role: string): AgentCapability {
|
||||||
|
if (VALID_ROLES.has(role)) {
|
||||||
|
return role as AgentCapability;
|
||||||
|
}
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
export function parseYamlFrontmatter(content: string): {
|
export function parseYamlFrontmatter(content: string): {
|
||||||
frontmatter: Record<string, unknown>;
|
frontmatter: Record<string, unknown>;
|
||||||
body: string;
|
body: string;
|
||||||
} {
|
} {
|
||||||
if (typeof content !== "string" || content.length === 0) {
|
if (typeof content !== "string" || content.trim().length === 0) {
|
||||||
throw new AgentCompaniesParseError("Manifest content is empty or not a string");
|
throw new AgentCompaniesParseError("Manifest content is empty or not a string");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,15 +64,12 @@ export function parseYamlFrontmatter(content: string): {
|
|||||||
throw new AgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
|
throw new AgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const yamlContent = match[1];
|
|
||||||
const body = match[2] ?? "";
|
|
||||||
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = parseYaml(yamlContent);
|
parsed = parseYaml(match[1]);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
throw new AgentCompaniesParseError(
|
throw new AgentCompaniesParseError(
|
||||||
`Malformed YAML frontmatter: ${(err as Error).message}`,
|
`Malformed YAML frontmatter: ${(error as Error).message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,79 +79,59 @@ export function parseYamlFrontmatter(content: string): {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
frontmatter: parsed as Record<string, unknown>,
|
frontmatter: parsed as Record<string, unknown>,
|
||||||
body,
|
body: match[2] ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRequiredFields(
|
function requireName(frontmatter: Record<string, unknown>, kind: string): void {
|
||||||
frontmatter: Record<string, unknown>,
|
if (typeof frontmatter.name !== "string" || frontmatter.name.trim().length === 0) {
|
||||||
kind: string,
|
throw new AgentCompaniesParseError(`${kind} manifest is missing required field: name`);
|
||||||
requiredFields: string[],
|
|
||||||
): void {
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
const value = frontmatter[field];
|
|
||||||
if (typeof value !== "string" || value.trim() === "") {
|
|
||||||
throw new AgentCompaniesParseError(
|
|
||||||
`${kind} manifest is missing required field: ${field}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function parseTypedManifest<T>(content: string, kind: string): T {
|
||||||
* Parse and validate a manifest frontmatter shape.
|
|
||||||
*/
|
|
||||||
function parseManifest<T>(content: string, kind: string, requiredFields: string[]): T {
|
|
||||||
const { frontmatter } = parseYamlFrontmatter(content);
|
const { frontmatter } = parseYamlFrontmatter(content);
|
||||||
validateRequiredFields(frontmatter, kind, requiredFields);
|
requireName(frontmatter, kind);
|
||||||
return frontmatter as T;
|
return frontmatter as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Individual Manifest Parsers ─────────────────────────────────────────
|
|
||||||
|
|
||||||
export function parseCompanyManifest(content: string): CompanyManifest {
|
|
||||||
return parseManifest<CompanyManifest>(content, "company", ["name"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTeamManifest(content: string): TeamManifest {
|
|
||||||
return parseManifest<TeamManifest>(content, "team", ["name"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseAgentManifest(content: string): AgentManifest {
|
export function parseAgentManifest(content: string): AgentManifest {
|
||||||
const manifest = parseManifest<AgentManifest>(content, "agent", ["name"]);
|
const { frontmatter, body } = parseYamlFrontmatter(content);
|
||||||
const { body } = parseYamlFrontmatter(content);
|
requireName(frontmatter, "agent");
|
||||||
return {
|
return {
|
||||||
...manifest,
|
...(frontmatter as unknown as AgentManifest),
|
||||||
instructionBody: body,
|
instructionBody: body,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseSingleAgentManifest(content: string): { manifest: AgentManifest } {
|
||||||
|
return { manifest: parseAgentManifest(content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCompanyManifest(content: string): CompanyManifest {
|
||||||
|
return parseTypedManifest<CompanyManifest>(content, "company");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTeamManifest(content: string): TeamManifest {
|
||||||
|
return parseTypedManifest<TeamManifest>(content, "team");
|
||||||
|
}
|
||||||
|
|
||||||
export function parseProjectManifest(content: string): ProjectManifest {
|
export function parseProjectManifest(content: string): ProjectManifest {
|
||||||
return parseManifest<ProjectManifest>(content, "project", ["name"]);
|
return parseTypedManifest<ProjectManifest>(content, "project");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTaskManifest(content: string): TaskManifest {
|
export function parseTaskManifest(content: string): TaskManifest {
|
||||||
return parseManifest<TaskManifest>(content, "task", ["name"]);
|
return parseTypedManifest<TaskManifest>(content, "task");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSkillManifest(content: string): SkillManifest {
|
function parseManifestFile<T>(filePath: string, parser: (content: string) => T): T {
|
||||||
return parseManifest<SkillManifest>(content, "skill", ["name"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Directory + Archive Parsing ─────────────────────────────────────────
|
|
||||||
|
|
||||||
function parseManifestFile<T>(
|
|
||||||
filePath: string,
|
|
||||||
parser: (content: string) => T,
|
|
||||||
): T {
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(filePath, "utf-8");
|
return parser(readFileSync(filePath, "utf-8"));
|
||||||
return parser(content);
|
} catch (error) {
|
||||||
} catch (err) {
|
if (error instanceof AgentCompaniesParseError) {
|
||||||
if (err instanceof AgentCompaniesParseError) {
|
throw new AgentCompaniesParseError(`${filePath}: ${error.message}`);
|
||||||
throw new AgentCompaniesParseError(`${filePath}: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
throw err;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,68 +146,104 @@ function parseManifestSubdirectories<T>(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = readdirSync(sectionPath, { withFileTypes: true });
|
const manifests: T[] = [];
|
||||||
const parsed: T[] = [];
|
const entries = readdirSync(sectionPath, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifestPath = join(sectionPath, entry.name, filename);
|
const manifestPath = join(sectionPath, entry.name, filename);
|
||||||
if (!existsSync(manifestPath)) {
|
if (!existsSync(manifestPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
manifests.push(parseManifestFile(manifestPath, parser));
|
||||||
parsed.push(parseManifestFile(manifestPath, parser));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed;
|
return manifests;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkTeamIncludes(teams: TeamManifest[]): void {
|
||||||
|
const byKey = new Map<string, TeamManifest>();
|
||||||
|
for (const team of teams) {
|
||||||
|
const key = team.slug ?? team.name;
|
||||||
|
byKey.set(key, team);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const visiting = new Set<string>();
|
||||||
|
|
||||||
|
const visit = (key: string, depth = 0): void => {
|
||||||
|
if (depth > 64 || visited.has(key) || visiting.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.add(key);
|
||||||
|
const team = byKey.get(key);
|
||||||
|
if (team?.includes) {
|
||||||
|
for (const includeRef of team.includes) {
|
||||||
|
const includeKey = includeRef.replace(/\.md$/i, "").split("/").pop();
|
||||||
|
if (includeKey) {
|
||||||
|
visit(includeKey, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.delete(key);
|
||||||
|
visited.add(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of byKey.keys()) {
|
||||||
|
visit(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCompanyDirectory(dirPath: string): AgentCompaniesPackage {
|
export function parseCompanyDirectory(dirPath: string): AgentCompaniesPackage {
|
||||||
const resolvedDir = resolve(dirPath);
|
const resolvedPath = resolve(dirPath);
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
if (!existsSync(resolvedDir)) {
|
throw new AgentCompaniesParseError(`Company directory does not exist: ${resolvedPath}`);
|
||||||
throw new AgentCompaniesParseError(`Company directory does not exist: ${resolvedDir}`);
|
|
||||||
}
|
}
|
||||||
if (!statSync(resolvedDir).isDirectory()) {
|
if (!statSync(resolvedPath).isDirectory()) {
|
||||||
throw new AgentCompaniesParseError(`Company path is not a directory: ${resolvedDir}`);
|
throw new AgentCompaniesParseError(`Company path is not a directory: ${resolvedPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyPath = join(resolvedDir, "COMPANY.md");
|
const companyPath = join(resolvedPath, "COMPANY.md");
|
||||||
|
const teams = parseManifestSubdirectories(resolvedPath, "teams", "TEAM.md", parseTeamManifest);
|
||||||
|
walkTeamIncludes(teams);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company: existsSync(companyPath)
|
company: existsSync(companyPath)
|
||||||
? parseManifestFile(companyPath, parseCompanyManifest)
|
? parseManifestFile(companyPath, parseCompanyManifest)
|
||||||
: undefined,
|
: undefined,
|
||||||
agents: parseManifestSubdirectories(resolvedDir, "agents", "AGENTS.md", parseAgentManifest),
|
agents: parseManifestSubdirectories(resolvedPath, "agents", "AGENTS.md", parseAgentManifest),
|
||||||
teams: parseManifestSubdirectories(resolvedDir, "teams", "TEAM.md", parseTeamManifest),
|
teams,
|
||||||
projects: parseManifestSubdirectories(resolvedDir, "projects", "PROJECT.md", parseProjectManifest),
|
projects: parseManifestSubdirectories(
|
||||||
tasks: parseManifestSubdirectories(resolvedDir, "tasks", "TASK.md", parseTaskManifest),
|
resolvedPath,
|
||||||
skills: parseManifestSubdirectories(resolvedDir, "skills", "SKILL.md", parseSkillManifest),
|
"projects",
|
||||||
|
"PROJECT.md",
|
||||||
|
parseProjectManifest,
|
||||||
|
),
|
||||||
|
tasks: parseManifestSubdirectories(resolvedPath, "tasks", "TASK.md", parseTaskManifest),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveArchiveRoot(tempDir: string): string {
|
function resolveExtractionRoot(tempDir: string): string {
|
||||||
if (existsSync(join(tempDir, "COMPANY.md"))) {
|
if (existsSync(join(tempDir, "COMPANY.md"))) {
|
||||||
return tempDir;
|
return tempDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = readdirSync(tempDir, { withFileTypes: true });
|
const directories = readdirSync(tempDir, { withFileTypes: true }).filter((entry) =>
|
||||||
|
entry.isDirectory(),
|
||||||
|
);
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const directory of directories) {
|
||||||
if (!entry.isDirectory()) {
|
const candidate = join(tempDir, directory.name);
|
||||||
continue;
|
if (existsSync(join(candidate, "COMPANY.md"))) {
|
||||||
}
|
return candidate;
|
||||||
const childPath = join(tempDir, entry.name);
|
|
||||||
if (existsSync(join(childPath, "COMPANY.md"))) {
|
|
||||||
return childPath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 1 && entries[0].isDirectory()) {
|
if (directories.length === 1) {
|
||||||
return join(tempDir, entries[0].name);
|
return join(tempDir, directories[0].name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tempDir;
|
return tempDir;
|
||||||
@@ -232,124 +251,60 @@ function resolveArchiveRoot(tempDir: string): string {
|
|||||||
|
|
||||||
export async function parseCompanyArchive(archivePath: string): Promise<AgentCompaniesPackage> {
|
export async function parseCompanyArchive(archivePath: string): Promise<AgentCompaniesPackage> {
|
||||||
const resolvedArchivePath = resolve(archivePath);
|
const resolvedArchivePath = resolve(archivePath);
|
||||||
|
|
||||||
if (resolvedArchivePath.endsWith(".zip")) {
|
|
||||||
throw new AgentCompaniesParseError(
|
|
||||||
"Zip archives are not yet supported for Agent Companies imports. Please use .tar.gz or .tgz.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedArchivePath.endsWith(".tar.gz") && !resolvedArchivePath.endsWith(".tgz")) {
|
|
||||||
throw new AgentCompaniesParseError(
|
|
||||||
"Unsupported archive format. Expected .tar.gz or .tgz.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), "agent-companies-"));
|
const tempDir = mkdtempSync(join(tmpdir(), "agent-companies-"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(
|
if (resolvedArchivePath.endsWith(".tar.gz") || resolvedArchivePath.endsWith(".tgz")) {
|
||||||
`tar xzf ${JSON.stringify(resolvedArchivePath)} -C ${JSON.stringify(tempDir)}`,
|
execSync(
|
||||||
{ stdio: "pipe" },
|
`tar xzf ${JSON.stringify(resolvedArchivePath)} -C ${JSON.stringify(tempDir)}`,
|
||||||
);
|
{ stdio: "pipe" },
|
||||||
|
);
|
||||||
|
} else if (resolvedArchivePath.endsWith(".zip")) {
|
||||||
|
await extractZip(resolvedArchivePath, { dir: tempDir });
|
||||||
|
} else {
|
||||||
|
throw new AgentCompaniesParseError(
|
||||||
|
"Unsupported archive format. Expected .tar.gz, .tgz, or .zip",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const extractionRoot = resolveArchiveRoot(tempDir);
|
return parseCompanyDirectory(resolveExtractionRoot(tempDir));
|
||||||
return parseCompanyDirectory(extractionRoot);
|
} catch (error) {
|
||||||
} catch (err) {
|
if (error instanceof AgentCompaniesParseError) {
|
||||||
if (err instanceof AgentCompaniesParseError) {
|
throw error;
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new AgentCompaniesParseError(
|
throw new AgentCompaniesParseError(
|
||||||
`Failed to parse Agent Companies archive: ${(err as Error).message}`,
|
`Failed to parse Agent Companies archive: ${(error as Error).message}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Conversion to Fusion Agent Inputs ───────────────────────────────────
|
|
||||||
|
|
||||||
const ROLE_HINT_ALIASES: Record<string, AgentCapability> = {
|
|
||||||
triage: "triage",
|
|
||||||
planner: "triage",
|
|
||||||
planning: "triage",
|
|
||||||
executor: "executor",
|
|
||||||
execute: "executor",
|
|
||||||
reviewer: "reviewer",
|
|
||||||
review: "reviewer",
|
|
||||||
merger: "merger",
|
|
||||||
merge: "merger",
|
|
||||||
scheduler: "scheduler",
|
|
||||||
schedule: "scheduler",
|
|
||||||
engineer: "engineer",
|
|
||||||
engineering: "engineer",
|
|
||||||
custom: "custom",
|
|
||||||
};
|
|
||||||
|
|
||||||
function extractRoleFromHint(hint: string): AgentCapability {
|
|
||||||
const normalized = hint.toLowerCase();
|
|
||||||
const tokens = normalized.split(/[^a-z]+/g).filter(Boolean);
|
|
||||||
|
|
||||||
for (const token of tokens) {
|
|
||||||
const mapped = ROLE_HINT_ALIASES[token];
|
|
||||||
if (mapped) {
|
|
||||||
return mapRoleToCapability(mapped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapRoleToCapability("custom");
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferRole(agent: AgentManifest): AgentCapability {
|
|
||||||
if (agent.skills && agent.skills.length > 0) {
|
|
||||||
for (const skill of agent.skills) {
|
|
||||||
const role = extractRoleFromHint(skill);
|
|
||||||
if (role !== "custom") {
|
|
||||||
return role;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof agent.reportsTo === "string" && agent.reportsTo.trim() !== "") {
|
|
||||||
const role = extractRoleFromHint(agent.reportsTo);
|
|
||||||
if (role !== "custom") {
|
|
||||||
return role;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapRoleToCapability("custom");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCreateInput {
|
export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCreateInput {
|
||||||
const metadata: Record<string, unknown> = {};
|
const metadata: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (agent.skills && agent.skills.length > 0) {
|
if (typeof agent.instructionBody === "string") {
|
||||||
|
metadata.instructions = agent.instructionBody;
|
||||||
|
}
|
||||||
|
if (Array.isArray(agent.skills) && agent.skills.length > 0) {
|
||||||
metadata.skills = agent.skills;
|
metadata.skills = agent.skills;
|
||||||
}
|
}
|
||||||
|
if (agent.reportsTo !== undefined) {
|
||||||
|
metadata.reportsTo = agent.reportsTo;
|
||||||
|
}
|
||||||
|
if (Array.isArray(agent.metadata?.sources) && agent.metadata.sources.length > 0) {
|
||||||
|
metadata.sources = agent.metadata.sources;
|
||||||
|
}
|
||||||
|
|
||||||
const input: AgentCreateInput = {
|
return {
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
role: inferRole(agent),
|
role: mapRoleToCapability("custom"),
|
||||||
|
...(typeof agent.title === "string" && agent.title.trim().length > 0
|
||||||
|
? { title: agent.title }
|
||||||
|
: {}),
|
||||||
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (agent.title) {
|
|
||||||
input.title = agent.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.instructionBody !== undefined) {
|
|
||||||
input.instructionsText = agent.instructionBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.reportsTo !== null && agent.reportsTo !== undefined) {
|
|
||||||
input.reportsTo = agent.reportsTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(metadata).length > 0) {
|
|
||||||
input.metadata = metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
return input;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertAgentCompanies(
|
export function convertAgentCompanies(
|
||||||
@@ -371,13 +326,12 @@ export function convertAgentCompanies(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const input = agentManifestToAgentCreateInput(agent);
|
inputs.push(agentManifestToAgentCreateInput(agent));
|
||||||
inputs.push(input);
|
|
||||||
result.created.push(agent.name);
|
result.created.push(agent.name);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
result.errors.push({
|
result.errors.push({
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
error: (err as Error).message,
|
error: (error as Error).message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
AgentCompaniesFrontmatter,
|
AgentCompaniesFrontmatter,
|
||||||
AgentCompaniesImportResult,
|
AgentCompaniesImportResult,
|
||||||
@@ -8,31 +8,31 @@ import type {
|
|||||||
AgentManifest,
|
AgentManifest,
|
||||||
CompanyManifest,
|
CompanyManifest,
|
||||||
ProjectManifest,
|
ProjectManifest,
|
||||||
SkillManifest,
|
|
||||||
SourceReference,
|
SourceReference,
|
||||||
TaskManifest,
|
TaskManifest,
|
||||||
TeamManifest,
|
TeamManifest,
|
||||||
} from "./agent-companies-types.js";
|
} from "./agent-companies-types.js";
|
||||||
|
|
||||||
describe("agent-companies-types", () => {
|
describe("agent-companies-types", () => {
|
||||||
it("accepts AgentCompaniesSchema literal", () => {
|
it("supports schema and kind literals", () => {
|
||||||
const schema: AgentCompaniesSchema = "agentcompanies/v1";
|
const schema: AgentCompaniesSchema = "agentcompanies/v1";
|
||||||
expect(schema).toBe("agentcompanies/v1");
|
const kinds: AgentCompaniesKind[] = ["company", "team", "agent", "project", "task", "skill"];
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts all AgentCompaniesKind variants", () => {
|
expect(schema).toBe("agentcompanies/v1");
|
||||||
const kinds: AgentCompaniesKind[] = [
|
|
||||||
"company",
|
|
||||||
"team",
|
|
||||||
"agent",
|
|
||||||
"project",
|
|
||||||
"task",
|
|
||||||
"skill",
|
|
||||||
];
|
|
||||||
expect(kinds).toHaveLength(6);
|
expect(kinds).toHaveLength(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts AgentCompaniesFrontmatter base fields", () => {
|
it("supports shared frontmatter with source metadata", () => {
|
||||||
|
const source: SourceReference = {
|
||||||
|
kind: "git",
|
||||||
|
repo: "acme/agent-company",
|
||||||
|
path: "agents/ceo/AGENTS.md",
|
||||||
|
commit: "abc123",
|
||||||
|
hash: "sha256:def456",
|
||||||
|
url: "https://example.com/repo",
|
||||||
|
trackingRef: "main",
|
||||||
|
};
|
||||||
|
|
||||||
const frontmatter: AgentCompaniesFrontmatter = {
|
const frontmatter: AgentCompaniesFrontmatter = {
|
||||||
name: "Lean Dev Shop",
|
name: "Lean Dev Shop",
|
||||||
description: "Small engineering-focused AI company",
|
description: "Small engineering-focused AI company",
|
||||||
@@ -41,150 +41,75 @@ describe("agent-companies-types", () => {
|
|||||||
kind: "company",
|
kind: "company",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
license: "MIT",
|
license: "MIT",
|
||||||
authors: ["Team"],
|
authors: ["Fusion Team"],
|
||||||
tags: ["engineering", "ai"],
|
tags: ["ai", "engineering"],
|
||||||
metadata: {
|
metadata: {
|
||||||
sources: [{ kind: "git", repo: "acme/repo" }],
|
sources: [source],
|
||||||
customField: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(frontmatter.name).toBe("Lean Dev Shop");
|
expect(frontmatter.metadata?.sources?.[0]?.repo).toBe("acme/agent-company");
|
||||||
expect(frontmatter.metadata?.sources).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts minimal AgentManifest", () => {
|
it("supports company/team/agent/project/task manifests", () => {
|
||||||
const manifest: AgentManifest = {
|
const company: CompanyManifest = {
|
||||||
name: "CEO",
|
name: "Lean Dev Shop",
|
||||||
|
goals: ["Ship high-quality software"],
|
||||||
|
requirements: ["Use review workflow"],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(manifest.name).toBe("CEO");
|
const team: TeamManifest = {
|
||||||
expect(manifest.skills).toBeUndefined();
|
name: "Engineering",
|
||||||
});
|
manager: "../cto/AGENTS.md",
|
||||||
|
includes: ["../platform/AGENTS.md"],
|
||||||
|
};
|
||||||
|
|
||||||
it("accepts fully populated AgentManifest", () => {
|
const agent: AgentManifest = {
|
||||||
const manifest: AgentManifest = {
|
|
||||||
name: "CEO",
|
name: "CEO",
|
||||||
description: "Runs strategy",
|
|
||||||
slug: "ceo",
|
|
||||||
kind: "agent",
|
|
||||||
title: "Chief Executive Officer",
|
title: "Chief Executive Officer",
|
||||||
reportsTo: null,
|
reportsTo: null,
|
||||||
skills: ["plan-ceo-review", "review"],
|
skills: ["plan-ceo-review", "review"],
|
||||||
instructionBody: "You are the CEO.",
|
instructionBody: "Lead strategy and review architecture.",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(manifest.title).toBe("Chief Executive Officer");
|
const project: ProjectManifest = {
|
||||||
expect(manifest.reportsTo).toBeNull();
|
|
||||||
expect(manifest.skills).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts CompanyManifest with schema and slug", () => {
|
|
||||||
const manifest: CompanyManifest = {
|
|
||||||
name: "Lean Dev Shop",
|
|
||||||
description: "Small engineering-focused AI company",
|
|
||||||
slug: "lean-dev-shop",
|
|
||||||
schema: "agentcompanies/v1",
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(manifest.schema).toBe("agentcompanies/v1");
|
|
||||||
expect(manifest.slug).toBe("lean-dev-shop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts TeamManifest with manager and includes", () => {
|
|
||||||
const manifest: TeamManifest = {
|
|
||||||
name: "Engineering",
|
|
||||||
manager: "../cto/AGENTS.md",
|
|
||||||
includes: ["../platform-lead/AGENTS.md", "../../skills/review/SKILL.md"],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(manifest.manager).toContain("AGENTS.md");
|
|
||||||
expect(manifest.includes).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts ProjectManifest", () => {
|
|
||||||
const manifest: ProjectManifest = {
|
|
||||||
name: "Q2 Launch",
|
name: "Q2 Launch",
|
||||||
description: "Launch execution project",
|
|
||||||
slug: "q2-launch",
|
slug: "q2-launch",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(manifest.name).toBe("Q2 Launch");
|
const task: TaskManifest = {
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts TaskManifest with assignee, project, and schedule", () => {
|
|
||||||
const manifest: TaskManifest = {
|
|
||||||
name: "Monday Review",
|
name: "Monday Review",
|
||||||
slug: "monday-review",
|
|
||||||
description: "Weekly code review",
|
|
||||||
assignee: "./agents/ceo/AGENTS.md",
|
assignee: "./agents/ceo/AGENTS.md",
|
||||||
project: "./projects/q2-launch/PROJECT.md",
|
project: "./projects/q2-launch/PROJECT.md",
|
||||||
schedule: {
|
schedule: {
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
startsAt: "2025-01-06T09:00:00",
|
startsAt: "2026-04-14T09:00:00",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(manifest.assignee).toContain("AGENTS.md");
|
expect(company.goals).toHaveLength(1);
|
||||||
expect(manifest.schedule?.timezone).toBe("America/New_York");
|
expect(team.includes).toEqual(["../platform/AGENTS.md"]);
|
||||||
|
expect(agent.reportsTo).toBeNull();
|
||||||
|
expect(project.slug).toBe("q2-launch");
|
||||||
|
expect(task.schedule?.timezone).toBe("America/New_York");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts SkillManifest with provides and requirements", () => {
|
it("supports package and import result shapes", () => {
|
||||||
const manifest: SkillManifest = {
|
|
||||||
name: "Code Review",
|
|
||||||
provides: ["code-review", "security-review"],
|
|
||||||
requirements: ["typescript"],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(manifest.provides).toHaveLength(2);
|
|
||||||
expect(manifest.requirements).toEqual(["typescript"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts SourceReference with optional fields", () => {
|
|
||||||
const source: SourceReference = {
|
|
||||||
kind: "git",
|
|
||||||
repo: "acme/repo",
|
|
||||||
path: "skills/review",
|
|
||||||
commit: "abc123",
|
|
||||||
hash: "sha256:xyz",
|
|
||||||
url: "https://example.com/spec",
|
|
||||||
trackingRef: "v1",
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(source.repo).toBe("acme/repo");
|
|
||||||
expect(source.trackingRef).toBe("v1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts AgentCompaniesPackage with nested manifests", () => {
|
|
||||||
const pkg: AgentCompaniesPackage = {
|
const pkg: AgentCompaniesPackage = {
|
||||||
company: {
|
company: { name: "Lean Dev Shop" },
|
||||||
name: "Lean Dev Shop",
|
|
||||||
schema: "agentcompanies/v1",
|
|
||||||
},
|
|
||||||
agents: [{ name: "CEO" }],
|
agents: [{ name: "CEO" }],
|
||||||
teams: [{ name: "Engineering" }],
|
teams: [{ name: "Engineering" }],
|
||||||
projects: [{ name: "Q2 Launch" }],
|
projects: [{ name: "Q2 Launch" }],
|
||||||
tasks: [{ name: "Monday Review" }],
|
tasks: [{ name: "Monday Review" }],
|
||||||
skills: [{ name: "Code Review" }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(pkg.company?.name).toBe("Lean Dev Shop");
|
|
||||||
expect(pkg.agents).toHaveLength(1);
|
|
||||||
expect(pkg.teams).toHaveLength(1);
|
|
||||||
expect(pkg.projects).toHaveLength(1);
|
|
||||||
expect(pkg.tasks).toHaveLength(1);
|
|
||||||
expect(pkg.skills).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts AgentCompaniesImportResult", () => {
|
|
||||||
const result: AgentCompaniesImportResult = {
|
const result: AgentCompaniesImportResult = {
|
||||||
created: ["CEO", "CTO"],
|
created: ["CEO"],
|
||||||
skipped: ["Reviewer"],
|
skipped: ["CTO"],
|
||||||
errors: [{ name: "Broken", error: "missing name" }],
|
errors: [{ name: "Reviewer", error: "invalid manifest" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(result.created).toHaveLength(2);
|
expect(pkg.agents[0].name).toBe("CEO");
|
||||||
expect(result.skipped).toEqual(["Reviewer"]);
|
expect(result.errors[0]?.name).toBe("Reviewer");
|
||||||
expect(result.errors[0].name).toBe("Broken");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* TypeScript type definitions for the Agent Companies markdown manifest format.
|
* Type definitions for Agent Companies package manifests.
|
||||||
*
|
|
||||||
* Agent Companies packages are directory-based and use YAML frontmatter inside
|
|
||||||
* markdown files (COMPANY.md, TEAM.md, AGENTS.md, PROJECT.md, TASK.md, SKILL.md).
|
|
||||||
*
|
*
|
||||||
* @module agent-companies-types
|
* @module agent-companies-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Schema + Kinds ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Current Agent Companies schema literal. */
|
|
||||||
export type AgentCompaniesSchema = "agentcompanies/v1";
|
export type AgentCompaniesSchema = "agentcompanies/v1";
|
||||||
|
|
||||||
/** Supported manifest kinds in Agent Companies packages. */
|
|
||||||
export type AgentCompaniesKind =
|
export type AgentCompaniesKind =
|
||||||
| "company"
|
| "company"
|
||||||
| "team"
|
| "team"
|
||||||
@@ -21,12 +14,6 @@ export type AgentCompaniesKind =
|
|||||||
| "task"
|
| "task"
|
||||||
| "skill";
|
| "skill";
|
||||||
|
|
||||||
// ── Provenance Metadata ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source/provenance reference for imported or pinned external artifacts.
|
|
||||||
* Stored under `metadata.sources` when present.
|
|
||||||
*/
|
|
||||||
export interface SourceReference {
|
export interface SourceReference {
|
||||||
kind: string;
|
kind: string;
|
||||||
repo?: string;
|
repo?: string;
|
||||||
@@ -37,72 +24,41 @@ export interface SourceReference {
|
|||||||
trackingRef?: string;
|
trackingRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Common Frontmatter ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared frontmatter fields across Agent Companies manifest files.
|
|
||||||
*/
|
|
||||||
export interface AgentCompaniesFrontmatter {
|
export interface AgentCompaniesFrontmatter {
|
||||||
/** Human-readable name (required). */
|
|
||||||
name: string;
|
name: string;
|
||||||
/** Optional short discovery description. */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Stable portable identifier. */
|
|
||||||
slug?: string;
|
slug?: string;
|
||||||
/** Schema identifier (typically set at package roots). */
|
|
||||||
schema?: AgentCompaniesSchema;
|
schema?: AgentCompaniesSchema;
|
||||||
/** Explicit kind override (often implied by filename). */
|
|
||||||
kind?: AgentCompaniesKind;
|
kind?: AgentCompaniesKind;
|
||||||
/** Optional semantic version for package/manifests. */
|
|
||||||
version?: string;
|
version?: string;
|
||||||
/** License identifier. */
|
|
||||||
license?: string;
|
license?: string;
|
||||||
/** Attribution metadata. */
|
|
||||||
authors?: string[];
|
authors?: string[];
|
||||||
/** Search and classification tags. */
|
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
/** Tool-specific extension metadata. */
|
|
||||||
metadata?: {
|
metadata?: {
|
||||||
/** Optional source/provenance references. */
|
|
||||||
sources?: SourceReference[];
|
sources?: SourceReference[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Manifest Types ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Root COMPANY.md manifest. */
|
|
||||||
export interface CompanyManifest extends AgentCompaniesFrontmatter {
|
export interface CompanyManifest extends AgentCompaniesFrontmatter {
|
||||||
// Company currently uses common frontmatter fields only.
|
goals?: string[];
|
||||||
|
requirements?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TEAM.md manifest. */
|
|
||||||
export interface TeamManifest extends AgentCompaniesFrontmatter {
|
export interface TeamManifest extends AgentCompaniesFrontmatter {
|
||||||
manager?: string;
|
manager?: string;
|
||||||
includes?: string[];
|
includes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AGENTS.md manifest. */
|
|
||||||
export interface AgentManifest extends AgentCompaniesFrontmatter {
|
export interface AgentManifest extends AgentCompaniesFrontmatter {
|
||||||
title?: string;
|
title?: string;
|
||||||
reportsTo?: string | null;
|
reportsTo?: string | null;
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
/** Markdown content after YAML frontmatter. */
|
|
||||||
instructionBody?: string;
|
instructionBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PROJECT.md manifest. */
|
export interface ProjectManifest extends AgentCompaniesFrontmatter {}
|
||||||
export interface ProjectManifest extends AgentCompaniesFrontmatter {
|
|
||||||
// Project currently uses common frontmatter fields only.
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SKILL.md manifest. */
|
|
||||||
export interface SkillManifest extends AgentCompaniesFrontmatter {
|
|
||||||
provides?: string[];
|
|
||||||
requirements?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** TASK.md manifest. */
|
|
||||||
export interface TaskManifest extends AgentCompaniesFrontmatter {
|
export interface TaskManifest extends AgentCompaniesFrontmatter {
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
@@ -112,19 +68,14 @@ export interface TaskManifest extends AgentCompaniesFrontmatter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Package + Import Result ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Parsed Agent Companies package from a directory or archive. */
|
|
||||||
export interface AgentCompaniesPackage {
|
export interface AgentCompaniesPackage {
|
||||||
company?: CompanyManifest;
|
company?: CompanyManifest;
|
||||||
agents: AgentManifest[];
|
agents: AgentManifest[];
|
||||||
teams: TeamManifest[];
|
teams: TeamManifest[];
|
||||||
projects: ProjectManifest[];
|
projects: ProjectManifest[];
|
||||||
tasks: TaskManifest[];
|
tasks: TaskManifest[];
|
||||||
skills: SkillManifest[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of converting/importing Agent Companies agents into Fusion agent inputs. */
|
|
||||||
export interface AgentCompaniesImportResult {
|
export interface AgentCompaniesImportResult {
|
||||||
created: string[];
|
created: string[];
|
||||||
skipped: string[];
|
skipped: string[];
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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<string> = 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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>).model === "string" && { model: (obj.config as Record<string, unknown>).model as string }),
|
|
||||||
...(typeof (obj.config as Record<string, unknown>).maxTokens === "number" && { maxTokens: (obj.config as Record<string, unknown>).maxTokens as number }),
|
|
||||||
...(typeof (obj.config as Record<string, unknown>).thinkingLevel === "string" && { thinkingLevel: (obj.config as Record<string, unknown>).thinkingLevel as CompaniesShAgent["config"] extends { thinkingLevel?: infer T } ? T : never }),
|
|
||||||
...(typeof (obj.config as Record<string, unknown>).maxTurns === "number" && { maxTurns: (obj.config as Record<string, unknown>).maxTurns as number }),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
metadata: obj.metadata && typeof obj.metadata === "object"
|
|
||||||
? {
|
|
||||||
...(typeof (obj.metadata as Record<string, unknown>).title === "string" && { title: (obj.metadata as Record<string, unknown>).title as string }),
|
|
||||||
...(typeof (obj.metadata as Record<string, unknown>).icon === "string" && { icon: (obj.metadata as Record<string, unknown>).icon as string }),
|
|
||||||
...(typeof (obj.metadata as Record<string, unknown>).description === "string" && { description: (obj.metadata as Record<string, unknown>).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<string, unknown> = {};
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 }>;
|
|
||||||
}
|
|
||||||
@@ -221,28 +221,6 @@ export {
|
|||||||
readProjectMemory,
|
readProjectMemory,
|
||||||
} from "./project-memory.js";
|
} 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";
|
|
||||||
|
|
||||||
// ── Agent Companies Types ──────────────────────────────────
|
// ── Agent Companies Types ──────────────────────────────────
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -255,7 +233,6 @@ export type {
|
|||||||
TeamManifest,
|
TeamManifest,
|
||||||
AgentManifest,
|
AgentManifest,
|
||||||
ProjectManifest,
|
ProjectManifest,
|
||||||
SkillManifest,
|
|
||||||
TaskManifest,
|
TaskManifest,
|
||||||
SourceReference,
|
SourceReference,
|
||||||
} from "./agent-companies-types.js";
|
} from "./agent-companies-types.js";
|
||||||
@@ -267,11 +244,12 @@ export {
|
|||||||
parseCompanyManifest,
|
parseCompanyManifest,
|
||||||
parseTeamManifest,
|
parseTeamManifest,
|
||||||
parseAgentManifest,
|
parseAgentManifest,
|
||||||
|
parseSingleAgentManifest,
|
||||||
parseProjectManifest,
|
parseProjectManifest,
|
||||||
parseTaskManifest,
|
parseTaskManifest,
|
||||||
parseSkillManifest,
|
|
||||||
parseCompanyDirectory,
|
parseCompanyDirectory,
|
||||||
parseCompanyArchive,
|
parseCompanyArchive,
|
||||||
|
mapRoleToCapability,
|
||||||
agentManifestToAgentCreateInput,
|
agentManifestToAgentCreateInput,
|
||||||
convertAgentCompanies,
|
convertAgentCompanies,
|
||||||
AgentCompaniesParseError,
|
AgentCompaniesParseError,
|
||||||
|
|||||||
@@ -2100,6 +2100,7 @@ export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Ta
|
|||||||
/** Result of importing agents from an Agent Companies source */
|
/** Result of importing agents from an Agent Companies source */
|
||||||
export interface AgentImportResult {
|
export interface AgentImportResult {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
companySlug?: string;
|
||||||
agents?: Array<{ name: string; role: string; title?: string; skills?: string[] }>;
|
agents?: Array<{ name: string; role: string; title?: string; skills?: string[] }>;
|
||||||
/** In dry-run mode: agent name strings. In live mode: agent objects with id and name. */
|
/** In dry-run mode: agent name strings. In live mode: agent objects with id and name. */
|
||||||
created: string[] | Array<{ id: string; name: string }>;
|
created: string[] | Array<{ id: string; name: string }>;
|
||||||
@@ -2113,7 +2114,7 @@ export interface AgentImportResult {
|
|||||||
* Uses dryRun for preview, then actual import.
|
* Uses dryRun for preview, then actual import.
|
||||||
*/
|
*/
|
||||||
export function importAgents(
|
export function importAgents(
|
||||||
input: { manifest?: string; source?: string; agents?: unknown[] },
|
input: { manifest: string } | { source: string } | { agents: unknown[] },
|
||||||
options?: { dryRun?: boolean; skipExisting?: boolean },
|
options?: { dryRun?: boolean; skipExisting?: boolean },
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
): Promise<AgentImportResult> {
|
): Promise<AgentImportResult> {
|
||||||
|
|||||||
@@ -19,11 +19,19 @@ interface AgentPreview {
|
|||||||
/** Import result from the API */
|
/** Import result from the API */
|
||||||
interface ImportResult {
|
interface ImportResult {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
companySlug?: string;
|
||||||
created: Array<{ id: string; name: string }>;
|
created: Array<{ id: string; name: string }>;
|
||||||
skipped: string[];
|
skipped: string[];
|
||||||
errors: Array<{ name: string; error: string }>;
|
errors: Array<{ name: string; error: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DirectoryAgentInput {
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
skills?: string[];
|
||||||
|
instructionBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** API error response shape */
|
/** API error response shape */
|
||||||
interface ApiErrorResponse {
|
interface ApiErrorResponse {
|
||||||
error: string;
|
error: string;
|
||||||
@@ -32,11 +40,62 @@ interface ApiErrorResponse {
|
|||||||
type ModalStep = "input" | "preview" | "result";
|
type ModalStep = "input" | "preview" | "result";
|
||||||
type InputMethod = "paste" | "file" | "directory";
|
type InputMethod = "paste" | "file" | "directory";
|
||||||
|
|
||||||
|
function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
||||||
|
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Missing YAML frontmatter delimiters (---)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatterLines = match[1].split(/\r?\n/);
|
||||||
|
const body = match[2] ?? "";
|
||||||
|
const result: DirectoryAgentInput = { name: "" };
|
||||||
|
const skills: string[] = [];
|
||||||
|
let inSkills = false;
|
||||||
|
|
||||||
|
for (const rawLine of frontmatterLines) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
if (trimmed.startsWith("skills:")) {
|
||||||
|
inSkills = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inSkills && trimmed.startsWith("- ")) {
|
||||||
|
skills.push(trimmed.slice(2).trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inSkills = false;
|
||||||
|
|
||||||
|
const [key, ...valueParts] = trimmed.split(":");
|
||||||
|
const value = valueParts.join(":").trim();
|
||||||
|
const normalizedValue = value.replace(/^['"]|['"]$/g, "");
|
||||||
|
|
||||||
|
if (key === "name") result.name = normalizedValue;
|
||||||
|
if (key === "title") result.title = normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.name) {
|
||||||
|
throw new Error("Missing required field: name");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skills.length > 0) {
|
||||||
|
result.skills = skills;
|
||||||
|
}
|
||||||
|
if (body.trim().length > 0) {
|
||||||
|
result.instructionBody = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal for importing agents from Agent Companies manifests.
|
* Modal for importing agents from Agent Companies manifests.
|
||||||
*
|
*
|
||||||
* Supports three input methods:
|
* Supports three input methods:
|
||||||
* - File upload (.md/.txt/.sh files)
|
* - File upload (.md/.txt files)
|
||||||
* - Directory upload (webkitdirectory)
|
* - Directory upload (webkitdirectory)
|
||||||
* - Paste raw manifest content
|
* - Paste raw manifest content
|
||||||
*
|
*
|
||||||
@@ -46,6 +105,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
const [step, setStep] = useState<ModalStep>("input");
|
const [step, setStep] = useState<ModalStep>("input");
|
||||||
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
|
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
|
||||||
const [manifestContent, setManifestContent] = useState("");
|
const [manifestContent, setManifestContent] = useState("");
|
||||||
|
const [directoryAgents, setDirectoryAgents] = useState<DirectoryAgentInput[]>([]);
|
||||||
const [companyName, setCompanyName] = useState("Unknown");
|
const [companyName, setCompanyName] = useState("Unknown");
|
||||||
const [agents, setAgents] = useState<AgentPreview[]>([]);
|
const [agents, setAgents] = useState<AgentPreview[]>([]);
|
||||||
const [isParsing, setIsParsing] = useState(false);
|
const [isParsing, setIsParsing] = useState(false);
|
||||||
@@ -60,6 +120,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
setStep("input");
|
setStep("input");
|
||||||
setInputMethod("paste");
|
setInputMethod("paste");
|
||||||
setManifestContent("");
|
setManifestContent("");
|
||||||
|
setDirectoryAgents([]);
|
||||||
setCompanyName("Unknown");
|
setCompanyName("Unknown");
|
||||||
setAgents([]);
|
setAgents([]);
|
||||||
setIsParsing(false);
|
setIsParsing(false);
|
||||||
@@ -82,6 +143,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
reader.onload = (ev) => {
|
reader.onload = (ev) => {
|
||||||
const content = ev.target?.result as string;
|
const content = ev.target?.result as string;
|
||||||
setInputMethod("file");
|
setInputMethod("file");
|
||||||
|
setDirectoryAgents([]);
|
||||||
setManifestContent(content);
|
setManifestContent(content);
|
||||||
setParseError(null);
|
setParseError(null);
|
||||||
};
|
};
|
||||||
@@ -99,31 +161,31 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const textFiles = files
|
const agentFiles = files
|
||||||
.filter((file) => /\.(md|txt|sh)$/i.test(file.name))
|
.filter((file) => (file.webkitRelativePath || file.name).toLowerCase().endsWith("agents.md"))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aPath = a.webkitRelativePath || a.name;
|
const aPath = a.webkitRelativePath || a.name;
|
||||||
const bPath = b.webkitRelativePath || b.name;
|
const bPath = b.webkitRelativePath || b.name;
|
||||||
return aPath.localeCompare(bPath);
|
return aPath.localeCompare(bPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (textFiles.length === 0) {
|
if (agentFiles.length === 0) {
|
||||||
setParseError("Selected directory has no .md, .txt, or .sh files");
|
setParseError("Selected directory has no AGENTS.md files");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks: string[] = [];
|
const parsedAgents: DirectoryAgentInput[] = [];
|
||||||
for (const file of textFiles) {
|
for (const file of agentFiles) {
|
||||||
const relativePath = file.webkitRelativePath || file.name;
|
|
||||||
const content = await file.text();
|
const content = await file.text();
|
||||||
chunks.push(`--- FILE: ${relativePath} ---\n${content}`);
|
parsedAgents.push(parseDirectoryAgentManifest(content));
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputMethod("directory");
|
setInputMethod("directory");
|
||||||
setManifestContent(chunks.join("\n\n"));
|
setDirectoryAgents(parsedAgents);
|
||||||
|
setManifestContent("");
|
||||||
setParseError(null);
|
setParseError(null);
|
||||||
} catch {
|
} catch {
|
||||||
setParseError("Failed to read selected directory");
|
setParseError("Failed to parse AGENTS.md files from selected directory");
|
||||||
} finally {
|
} finally {
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}
|
}
|
||||||
@@ -138,7 +200,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
|
|
||||||
/** Parse the manifest content by calling the API with dryRun=true */
|
/** Parse the manifest content by calling the API with dryRun=true */
|
||||||
const handleParse = useCallback(async () => {
|
const handleParse = useCallback(async () => {
|
||||||
if (!manifestContent.trim()) {
|
if (inputMethod === "directory" && directoryAgents.length === 0) {
|
||||||
|
setParseError("Please select a directory containing AGENTS.md files");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inputMethod !== "directory" && !manifestContent.trim()) {
|
||||||
setParseError("Please provide manifest content");
|
setParseError("Please provide manifest content");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,10 +213,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
setParseError(null);
|
setParseError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const body = inputMethod === "directory"
|
||||||
|
? { agents: directoryAgents, dryRun: true }
|
||||||
|
: { manifest: manifestContent, dryRun: true };
|
||||||
|
|
||||||
const res = await fetch(buildUrl("/agents/import"), {
|
const res = await fetch(buildUrl("/agents/import"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ manifest: manifestContent, dryRun: true }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -178,7 +248,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
} finally {
|
} finally {
|
||||||
setIsParsing(false);
|
setIsParsing(false);
|
||||||
}
|
}
|
||||||
}, [manifestContent, projectId]);
|
}, [inputMethod, directoryAgents, manifestContent, projectId]);
|
||||||
|
|
||||||
/** Execute the actual import */
|
/** Execute the actual import */
|
||||||
const handleImport = useCallback(async () => {
|
const handleImport = useCallback(async () => {
|
||||||
@@ -186,10 +256,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const body = inputMethod === "directory"
|
||||||
|
? { agents: directoryAgents, skipExisting: true }
|
||||||
|
: { manifest: manifestContent, skipExisting: true };
|
||||||
|
|
||||||
const res = await fetch(buildUrl("/agents/import"), {
|
const res = await fetch(buildUrl("/agents/import"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ manifest: manifestContent, skipExisting: true }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -206,7 +280,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
}
|
}
|
||||||
}, [manifestContent, projectId, onImported]);
|
}, [inputMethod, directoryAgents, manifestContent, projectId, onImported]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -235,7 +309,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".md,.txt,.sh"
|
accept=".md,.txt"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="agent-import-file-input"
|
className="agent-import-file-input"
|
||||||
aria-label="Upload agent manifest file"
|
aria-label="Upload agent manifest file"
|
||||||
@@ -266,7 +340,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
<FolderOpen size={16} />
|
<FolderOpen size={16} />
|
||||||
Select Directory
|
Select Directory
|
||||||
</button>
|
</button>
|
||||||
<span className="agent-import-file-hint">.md, .txt, and .sh files supported</span>
|
<span className="agent-import-file-hint">.md and .txt files supported</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Or divider */}
|
{/* Or divider */}
|
||||||
@@ -277,10 +351,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
{/* Text area for paste */}
|
{/* Text area for paste */}
|
||||||
<textarea
|
<textarea
|
||||||
className="agent-import-textarea"
|
className="agent-import-textarea"
|
||||||
placeholder={"---\nname: Agent Name\ntitle: Agent Title\nskills:\n - review\n---\nAgent instructions go here..."}
|
placeholder={"---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here..."}
|
||||||
value={manifestContent}
|
value={manifestContent}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInputMethod("paste");
|
setInputMethod("paste");
|
||||||
|
setDirectoryAgents([]);
|
||||||
setManifestContent(e.target.value);
|
setManifestContent(e.target.value);
|
||||||
setParseError(null);
|
setParseError(null);
|
||||||
}}
|
}}
|
||||||
@@ -414,7 +489,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
<button
|
<button
|
||||||
className="btn btn--primary"
|
className="btn btn--primary"
|
||||||
onClick={() => void handleParse()}
|
onClick={() => void handleParse()}
|
||||||
disabled={isParsing || !manifestContent.trim()}
|
disabled={isParsing || (inputMethod === "directory" ? directoryAgents.length === 0 : !manifestContent.trim())}
|
||||||
>
|
>
|
||||||
{isParsing ? (
|
{isParsing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,26 +5,15 @@ import { join } from "node:path";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { request } from "../test-request.js";
|
import { request } from "../test-request.js";
|
||||||
|
|
||||||
// ── Mock @fusion/core for agent import ────────────────────────────────
|
|
||||||
|
|
||||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||||
const mockListAgents = vi.fn().mockResolvedValue([]);
|
const mockListAgents = vi.fn().mockResolvedValue([]);
|
||||||
const mockCreateAgent = vi.fn();
|
const mockCreateAgent = vi.fn();
|
||||||
|
|
||||||
const mockParseCompaniesShManifest = vi.fn();
|
|
||||||
const mockConvertCompaniesShAgents = vi.fn();
|
|
||||||
const mockParseCompanyDirectory = vi.fn();
|
const mockParseCompanyDirectory = vi.fn();
|
||||||
const mockParseCompanyArchive = vi.fn();
|
const mockParseCompanyArchive = vi.fn();
|
||||||
const mockParseAgentManifest = vi.fn();
|
const mockParseSingleAgentManifest = vi.fn();
|
||||||
const mockConvertAgentCompanies = vi.fn();
|
const mockConvertAgentCompanies = vi.fn();
|
||||||
|
|
||||||
class MockCompaniesShParseError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = "CompaniesShParseError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockAgentCompaniesParseError extends Error {
|
class MockAgentCompaniesParseError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -39,26 +28,21 @@ vi.mock("@fusion/core", () => {
|
|||||||
listAgents = mockListAgents;
|
listAgents = mockListAgents;
|
||||||
createAgent = mockCreateAgent;
|
createAgent = mockCreateAgent;
|
||||||
},
|
},
|
||||||
parseCompaniesShManifest: (...args: unknown[]) => mockParseCompaniesShManifest(...args),
|
|
||||||
convertCompaniesShAgents: (...args: unknown[]) => mockConvertCompaniesShAgents(...args),
|
|
||||||
parseCompanyDirectory: (...args: unknown[]) => mockParseCompanyDirectory(...args),
|
parseCompanyDirectory: (...args: unknown[]) => mockParseCompanyDirectory(...args),
|
||||||
parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args),
|
parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args),
|
||||||
parseAgentManifest: (...args: unknown[]) => mockParseAgentManifest(...args),
|
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
|
||||||
convertAgentCompanies: (...args: unknown[]) => mockConvertAgentCompanies(...args),
|
convertAgentCompanies: (...args: unknown[]) => mockConvertAgentCompanies(...args),
|
||||||
CompaniesShParseError: MockCompaniesShParseError,
|
|
||||||
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Mock Store ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class MockStore extends EventEmitter {
|
class MockStore extends EventEmitter {
|
||||||
getRootDir(): string {
|
getRootDir(): string {
|
||||||
return "/tmp/fn-1189-test";
|
return "/tmp/fn-1174-test";
|
||||||
}
|
}
|
||||||
|
|
||||||
getFusionDir(): string {
|
getFusionDir(): string {
|
||||||
return "/tmp/fn-1189-test/.fusion";
|
return "/tmp/fn-1174-test/.fusion";
|
||||||
}
|
}
|
||||||
|
|
||||||
getDatabase() {
|
getDatabase() {
|
||||||
@@ -73,25 +57,12 @@ class MockStore extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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) {
|
async function postImport(app: Parameters<typeof request>[0], body: unknown) {
|
||||||
return request(app, "POST", "/api/agents/import", JSON.stringify(body), {
|
return request(app, "POST", "/api/agents/import", JSON.stringify(body), {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("POST /api/agents/import", () => {
|
describe("POST /api/agents/import", () => {
|
||||||
let store: MockStore;
|
let store: MockStore;
|
||||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||||
@@ -106,44 +77,33 @@ describe("POST /api/agents/import", () => {
|
|||||||
mockCreateAgent.mockReset();
|
mockCreateAgent.mockReset();
|
||||||
mockCreateAgent.mockImplementation(async (input: any) => ({ id: `agent-${input.name}`, ...input }));
|
mockCreateAgent.mockImplementation(async (input: any) => ({ id: `agent-${input.name}`, ...input }));
|
||||||
|
|
||||||
mockParseCompaniesShManifest.mockReturnValue({
|
mockParseCompanyDirectory.mockReturnValue({
|
||||||
companyName: "test-co",
|
company: { name: "Directory Co", slug: "directory-co" },
|
||||||
agents: [{ name: "Test Agent", role: "executor" }],
|
agents: [{ name: "Dir Agent", skills: ["review"] }],
|
||||||
envVars: [],
|
teams: [{ name: "Engineering" }],
|
||||||
|
projects: [],
|
||||||
|
tasks: [],
|
||||||
});
|
});
|
||||||
mockConvertCompaniesShAgents.mockReturnValue({
|
|
||||||
inputs: [{ name: "Test Agent", role: "executor" }],
|
mockParseCompanyArchive.mockResolvedValue({
|
||||||
result: {
|
company: { name: "Archive Co", slug: "archive-co" },
|
||||||
created: ["Test Agent"],
|
agents: [{ name: "Archive Agent", skills: ["review"] }],
|
||||||
skipped: [],
|
teams: [{ name: "Ops" }],
|
||||||
errors: [],
|
projects: [],
|
||||||
|
tasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockParseSingleAgentManifest.mockReturnValue({
|
||||||
|
manifest: {
|
||||||
|
name: "YAML Agent",
|
||||||
|
title: "Chief Executive",
|
||||||
|
skills: ["review"],
|
||||||
|
instructionBody: "Instructions",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mockParseCompanyDirectory.mockReturnValue({
|
|
||||||
company: { name: "Directory Co" },
|
|
||||||
agents: [{ name: "Dir Agent", skills: ["executor"] }],
|
|
||||||
teams: [],
|
|
||||||
projects: [],
|
|
||||||
tasks: [],
|
|
||||||
skills: [],
|
|
||||||
});
|
|
||||||
mockParseCompanyArchive.mockResolvedValue({
|
|
||||||
company: { name: "Archive Co" },
|
|
||||||
agents: [{ name: "Archive Agent", skills: ["executor"] }],
|
|
||||||
teams: [],
|
|
||||||
projects: [],
|
|
||||||
tasks: [],
|
|
||||||
skills: [],
|
|
||||||
});
|
|
||||||
mockParseAgentManifest.mockReturnValue({
|
|
||||||
name: "YAML Agent",
|
|
||||||
title: "Chief Executive",
|
|
||||||
skills: ["review"],
|
|
||||||
instructionBody: "Instructions",
|
|
||||||
});
|
|
||||||
mockConvertAgentCompanies.mockReturnValue({
|
mockConvertAgentCompanies.mockReturnValue({
|
||||||
inputs: [{ name: "YAML Agent", role: "reviewer", title: "Chief Executive", metadata: { skills: ["review"] } }],
|
inputs: [{ name: "YAML Agent", role: "custom", title: "Chief Executive", metadata: { skills: ["review"] } }],
|
||||||
result: {
|
result: {
|
||||||
created: ["YAML Agent"],
|
created: ["YAML Agent"],
|
||||||
skipped: [],
|
skipped: [],
|
||||||
@@ -168,7 +128,7 @@ describe("POST /api/agents/import", () => {
|
|||||||
expect((response.body as any).error).toContain("Provide one of");
|
expect((response.body as any).error).toContain("Provide one of");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports agents via Mode 1 (agents array)", async () => {
|
it("imports agents via { agents } mode", async () => {
|
||||||
const response = await postImport(app, {
|
const response = await postImport(app, {
|
||||||
agents: [{ name: "Test Agent", skills: ["executor"] }],
|
agents: [{ name: "Test Agent", skills: ["executor"] }],
|
||||||
});
|
});
|
||||||
@@ -178,9 +138,11 @@ describe("POST /api/agents/import", () => {
|
|||||||
const body = response.body as any;
|
const body = response.body as any;
|
||||||
expect(body.created).toHaveLength(1);
|
expect(body.created).toHaveLength(1);
|
||||||
expect(body.created[0].name).toBe("YAML Agent");
|
expect(body.created[0].name).toBe("YAML Agent");
|
||||||
|
expect(body.companyName).toBe("Unknown");
|
||||||
|
expect(body.companySlug).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports agents via Mode 2 (source directory)", async () => {
|
it("imports agents via { source } directory mode", async () => {
|
||||||
const sourceDir = join(testDir, "company");
|
const sourceDir = join(testDir, "company");
|
||||||
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
|
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
|
||||||
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
|
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
|
||||||
@@ -190,61 +152,59 @@ describe("POST /api/agents/import", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(mockParseCompanyDirectory).toHaveBeenCalledWith(sourceDir);
|
expect(mockParseCompanyDirectory).toHaveBeenCalledWith(sourceDir);
|
||||||
const body = response.body as any;
|
const body = response.body as any;
|
||||||
|
expect(body.companyName).toBe("Directory Co");
|
||||||
|
expect(body.companySlug).toBe("directory-co");
|
||||||
expect(body.created).toHaveLength(1);
|
expect(body.created).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports agents via Mode 3 manifest string (YAML frontmatter)", async () => {
|
it("imports agents via { source } archive mode", async () => {
|
||||||
const response = await postImport(app, {
|
const archivePath = join(testDir, "company.tgz");
|
||||||
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
|
writeFileSync(archivePath, "archive");
|
||||||
});
|
|
||||||
|
const response = await postImport(app, { source: archivePath });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(mockParseAgentManifest).toHaveBeenCalled();
|
expect(mockParseCompanyArchive).toHaveBeenCalledWith(archivePath);
|
||||||
expect(mockParseCompaniesShManifest).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to legacy .sh parsing when YAML parse fails", async () => {
|
|
||||||
mockParseAgentManifest.mockImplementation(() => {
|
|
||||||
throw new MockAgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await postImport(app, {
|
|
||||||
manifest: makeScript("fallback-co", [{ name: "Legacy Agent", role: "executor" }]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(mockParseCompaniesShManifest).toHaveBeenCalledTimes(1);
|
|
||||||
const body = response.body as any;
|
const body = response.body as any;
|
||||||
expect(body.created).toHaveLength(1);
|
expect(body.companyName).toBe("Archive Co");
|
||||||
|
expect(body.companySlug).toBe("archive-co");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns dry-run previews with agents array and does not create agents", async () => {
|
it("rejects non-directory source paths", async () => {
|
||||||
|
const filePath = join(testDir, "manifest.md");
|
||||||
|
writeFileSync(filePath, "---\nname: Agent\n---");
|
||||||
|
|
||||||
|
const response = await postImport(app, { source: filePath });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((response.body as any).error).toContain("directory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports agents via { manifest } AGENTS.md mode", async () => {
|
||||||
const response = await postImport(app, {
|
const response = await postImport(app, {
|
||||||
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
|
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockParseSingleAgentManifest).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns dry-run preview and does not create agents", async () => {
|
||||||
|
const response = await postImport(app, {
|
||||||
|
manifest: "---\nname: YAML Agent\n---\nInstructions",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const body = response.body as any;
|
const body = response.body as any;
|
||||||
expect(body.dryRun).toBe(true);
|
expect(body.dryRun).toBe(true);
|
||||||
|
expect(body.created).toEqual(["YAML Agent"]);
|
||||||
expect(body.agents).toEqual([
|
expect(body.agents).toEqual([
|
||||||
expect.objectContaining({ name: "YAML Agent", role: "reviewer", title: "Chief Executive" }),
|
expect.objectContaining({ name: "YAML Agent", role: "custom", title: "Chief Executive" }),
|
||||||
]);
|
]);
|
||||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for unsupported source paths", async () => {
|
|
||||||
const unsupportedPath = join(testDir, "manifest.json");
|
|
||||||
writeFileSync(unsupportedPath, "{}");
|
|
||||||
|
|
||||||
const response = await postImport(app, {
|
|
||||||
source: unsupportedPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect((response.body as any).error).toContain("Unsupported source format");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors skipExisting and returns skipped agents", async () => {
|
it("honors skipExisting and returns skipped agents", async () => {
|
||||||
mockListAgents.mockResolvedValue([{ id: "agent-existing", name: "YAML Agent" }]);
|
mockListAgents.mockResolvedValue([{ id: "agent-existing", name: "YAML Agent" }]);
|
||||||
mockConvertAgentCompanies.mockReturnValue({
|
mockConvertAgentCompanies.mockReturnValue({
|
||||||
@@ -266,4 +226,17 @@ describe("POST /api/agents/import", () => {
|
|||||||
expect(body.skipped).toEqual(["YAML Agent"]);
|
expect(body.skipped).toEqual(["YAML Agent"]);
|
||||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 400 for parser errors", async () => {
|
||||||
|
mockParseSingleAgentManifest.mockImplementation(() => {
|
||||||
|
throw new MockAgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await postImport(app, {
|
||||||
|
manifest: "invalid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((response.body as any).error).toContain("Missing YAML frontmatter");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7270,12 +7270,12 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/agents/import
|
* POST /api/agents/import
|
||||||
* Import agents from Agent Companies packages or legacy companies.sh manifests.
|
* Import agents from Agent Companies sources.
|
||||||
*
|
*
|
||||||
* Body modes (checked in order):
|
* Body modes (checked in order):
|
||||||
* - { agents: AgentManifest[], skipExisting?, dryRun? }
|
* - { agents: AgentManifest[], skipExisting?, dryRun? }
|
||||||
* - { source: string, skipExisting?, dryRun? }
|
* - { source: string, skipExisting?, dryRun? } // server directory path
|
||||||
* - { manifest: string, skipExisting?, dryRun? }
|
* - { manifest: string, skipExisting?, dryRun? } // raw AGENTS.md content
|
||||||
*/
|
*/
|
||||||
router.post("/agents/import", async (req, res) => {
|
router.post("/agents/import", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -7284,12 +7284,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
AgentStore,
|
AgentStore,
|
||||||
parseCompanyDirectory,
|
parseCompanyDirectory,
|
||||||
parseCompanyArchive,
|
parseCompanyArchive,
|
||||||
parseAgentManifest,
|
parseSingleAgentManifest,
|
||||||
convertAgentCompanies,
|
convertAgentCompanies,
|
||||||
AgentCompaniesParseError,
|
AgentCompaniesParseError,
|
||||||
parseCompaniesShManifest,
|
|
||||||
convertCompaniesShAgents,
|
|
||||||
CompaniesShParseError,
|
|
||||||
} = await import("@fusion/core");
|
} = await import("@fusion/core");
|
||||||
|
|
||||||
const scopedStore = await getScopedStore(req);
|
const scopedStore = await getScopedStore(req);
|
||||||
@@ -7300,27 +7297,22 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
const existingNames = new Set(existingAgents.map((a: any) => a.name));
|
const existingNames = new Set(existingAgents.map((a: any) => a.name));
|
||||||
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
|
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
|
||||||
|
|
||||||
let companyName: string | undefined;
|
let pkg: {
|
||||||
let inputs: any[] = [];
|
company?: { name?: string; slug?: string };
|
||||||
let result: {
|
agents: unknown[];
|
||||||
created: string[];
|
teams: unknown[];
|
||||||
skipped: string[];
|
projects: unknown[];
|
||||||
errors: Array<{ name: string; error: string }>;
|
tasks: unknown[];
|
||||||
} = {
|
|
||||||
created: [],
|
|
||||||
skipped: [],
|
|
||||||
errors: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(agents)) {
|
if (Array.isArray(agents)) {
|
||||||
const pkg = {
|
pkg = {
|
||||||
|
company: undefined,
|
||||||
agents,
|
agents,
|
||||||
teams: [],
|
teams: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
skills: [],
|
|
||||||
};
|
};
|
||||||
({ inputs, result } = convertAgentCompanies(pkg as any, conversionOptions));
|
|
||||||
} else if (typeof source === "string" && source.trim()) {
|
} else if (typeof source === "string" && source.trim()) {
|
||||||
const sourcePath = resolve(source);
|
const sourcePath = resolve(source);
|
||||||
if (!existsSync(sourcePath)) {
|
if (!existsSync(sourcePath)) {
|
||||||
@@ -7328,77 +7320,36 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isArchive =
|
const isArchive = sourcePath.endsWith(".tar.gz")
|
||||||
sourcePath.endsWith(".tar.gz") || sourcePath.endsWith(".tgz") || sourcePath.endsWith(".zip");
|
|| sourcePath.endsWith(".tgz")
|
||||||
|
|| sourcePath.endsWith(".zip");
|
||||||
|
|
||||||
let pkg;
|
if (isArchive) {
|
||||||
if (nodeFs.statSync(sourcePath).isDirectory()) {
|
|
||||||
pkg = parseCompanyDirectory(sourcePath);
|
|
||||||
} else if (isArchive) {
|
|
||||||
pkg = await parseCompanyArchive(sourcePath);
|
pkg = await parseCompanyArchive(sourcePath);
|
||||||
|
} else if (nodeFs.statSync(sourcePath).isDirectory()) {
|
||||||
|
pkg = parseCompanyDirectory(sourcePath);
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ error: "Unsupported source format. Provide a directory or .tar.gz/.zip archive path." });
|
res.status(400).json({ error: "Source must be a server-side directory or archive path" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
companyName = pkg.company?.name;
|
|
||||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
|
||||||
} else if (typeof manifest === "string") {
|
} else if (typeof manifest === "string") {
|
||||||
try {
|
const { manifest: singleAgent } = parseSingleAgentManifest(manifest);
|
||||||
const segmentedMatches = [...manifest.matchAll(
|
pkg = {
|
||||||
/--- FILE:\s*([^\n]+)\s*---\n([\s\S]*?)(?=(?:\n--- FILE:\s*[^\n]+\s*---\n)|$)/g,
|
company: undefined,
|
||||||
)];
|
agents: [singleAgent],
|
||||||
|
teams: [],
|
||||||
if (segmentedMatches.length > 0) {
|
projects: [],
|
||||||
const parsedAgents = segmentedMatches
|
tasks: [],
|
||||||
.map(([, relativePath, content]) => ({
|
};
|
||||||
relativePath: relativePath.trim(),
|
|
||||||
content,
|
|
||||||
}))
|
|
||||||
.filter(({ relativePath }) => relativePath.toLowerCase().endsWith("agents.md"))
|
|
||||||
.map(({ content }) => parseAgentManifest(content));
|
|
||||||
|
|
||||||
const pkg = {
|
|
||||||
agents: parsedAgents,
|
|
||||||
teams: [],
|
|
||||||
projects: [],
|
|
||||||
tasks: [],
|
|
||||||
skills: [],
|
|
||||||
};
|
|
||||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
|
||||||
} else {
|
|
||||||
const parsedAgentManifest = parseAgentManifest(manifest);
|
|
||||||
const pkg = {
|
|
||||||
agents: [parsedAgentManifest],
|
|
||||||
teams: [],
|
|
||||||
projects: [],
|
|
||||||
tasks: [],
|
|
||||||
skills: [],
|
|
||||||
};
|
|
||||||
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!(err instanceof AgentCompaniesParseError)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedCompaniesSh = parseCompaniesShManifest(manifest);
|
|
||||||
companyName = parsedCompaniesSh.companyName;
|
|
||||||
({ inputs, result } = convertCompaniesShAgents(parsedCompaniesSh.agents as any[], conversionOptions));
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
if (fallbackErr instanceof CompaniesShParseError) {
|
|
||||||
res.status(400).json({ error: fallbackErr.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw fallbackErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ error: "Provide one of: agents (array), source (path), or manifest (string)" });
|
res.status(400).json({ error: "Provide one of: agents (array), source (path), or manifest (string)" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { inputs, result } = convertAgentCompanies(pkg as any, conversionOptions);
|
||||||
|
const companyName = pkg.company?.name ?? "Unknown";
|
||||||
|
const companySlug = typeof pkg.company?.slug === "string" ? pkg.company.slug : undefined;
|
||||||
|
|
||||||
if (inputs.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
|
if (inputs.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
|
||||||
res.status(400).json({ error: "No agents found in manifest" });
|
res.status(400).json({ error: "No agents found in manifest" });
|
||||||
return;
|
return;
|
||||||
@@ -7417,6 +7368,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
res.json({
|
res.json({
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
companyName,
|
companyName,
|
||||||
|
...(companySlug ? { companySlug } : {}),
|
||||||
agents: agentPreview,
|
agents: agentPreview,
|
||||||
created: result.created,
|
created: result.created,
|
||||||
skipped: result.skipped,
|
skipped: result.skipped,
|
||||||
@@ -7425,7 +7377,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const created: any[] = [];
|
const created: Array<{ id: string; name: string }> = [];
|
||||||
const errors: Array<{ name: string; error: string }> = [...result.errors];
|
const errors: Array<{ name: string; error: string }> = [...result.errors];
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
@@ -7436,7 +7388,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await agentStore.createAgent(input);
|
const agent = await agentStore.createAgent(input);
|
||||||
created.push(agent);
|
created.push({ id: agent.id, name: agent.name });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errors.push({ name: input.name, error: err.message });
|
errors.push({ name: input.name, error: err.message });
|
||||||
}
|
}
|
||||||
@@ -7444,12 +7396,13 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
companyName,
|
companyName,
|
||||||
|
...(companySlug ? { companySlug } : {}),
|
||||||
created,
|
created,
|
||||||
skipped: result.skipped,
|
skipped: result.skipped,
|
||||||
errors,
|
errors,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AgentCompaniesParseError" || err?.name === "CompaniesShParseError") {
|
if (err?.name === "AgentCompaniesParseError") {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -75,6 +75,9 @@ importers:
|
|||||||
cron-parser:
|
cron-parser:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
|
extract-zip:
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
yaml:
|
yaml:
|
||||||
specifier: ^2.8.3
|
specifier: ^2.8.3
|
||||||
version: 2.8.3
|
version: 2.8.3
|
||||||
|
|||||||
Reference in New Issue
Block a user