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:
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import extractZip from "extract-zip";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type {
|
||||
@@ -20,17 +18,11 @@ import type {
|
||||
AgentManifest,
|
||||
CompanyManifest,
|
||||
ProjectManifest,
|
||||
SkillManifest,
|
||||
TaskManifest,
|
||||
TeamManifest,
|
||||
} from "./agent-companies-types.js";
|
||||
import { mapRoleToCapability } from "./companies-sh-parser.js";
|
||||
import type { AgentCapability, AgentCreateInput } from "./types.js";
|
||||
|
||||
export { mapRoleToCapability } from "./companies-sh-parser.js";
|
||||
|
||||
// ── Parsing Errors ───────────────────────────────────────────────────────
|
||||
|
||||
export class AgentCompaniesParseError extends Error {
|
||||
constructor(message: string) {
|
||||
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.
|
||||
*
|
||||
* @throws {AgentCompaniesParseError} On missing or malformed frontmatter.
|
||||
* Map a role string to a Fusion agent capability.
|
||||
* Unknown roles fall back to "custom".
|
||||
*/
|
||||
export function mapRoleToCapability(role: string): AgentCapability {
|
||||
if (VALID_ROLES.has(role)) {
|
||||
return role as AgentCapability;
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function parseYamlFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -58,15 +64,12 @@ export function parseYamlFrontmatter(content: string): {
|
||||
throw new AgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
|
||||
}
|
||||
|
||||
const yamlContent = match[1];
|
||||
const body = match[2] ?? "";
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(yamlContent);
|
||||
} catch (err) {
|
||||
parsed = parseYaml(match[1]);
|
||||
} catch (error) {
|
||||
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 {
|
||||
frontmatter: parsed as Record<string, unknown>,
|
||||
body,
|
||||
body: match[2] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function validateRequiredFields(
|
||||
frontmatter: Record<string, unknown>,
|
||||
kind: string,
|
||||
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 requireName(frontmatter: Record<string, unknown>, kind: string): void {
|
||||
if (typeof frontmatter.name !== "string" || frontmatter.name.trim().length === 0) {
|
||||
throw new AgentCompaniesParseError(`${kind} manifest is missing required field: name`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a manifest frontmatter shape.
|
||||
*/
|
||||
function parseManifest<T>(content: string, kind: string, requiredFields: string[]): T {
|
||||
function parseTypedManifest<T>(content: string, kind: string): T {
|
||||
const { frontmatter } = parseYamlFrontmatter(content);
|
||||
validateRequiredFields(frontmatter, kind, requiredFields);
|
||||
requireName(frontmatter, kind);
|
||||
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 {
|
||||
const manifest = parseManifest<AgentManifest>(content, "agent", ["name"]);
|
||||
const { body } = parseYamlFrontmatter(content);
|
||||
const { frontmatter, body } = parseYamlFrontmatter(content);
|
||||
requireName(frontmatter, "agent");
|
||||
return {
|
||||
...manifest,
|
||||
...(frontmatter as unknown as AgentManifest),
|
||||
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 {
|
||||
return parseManifest<ProjectManifest>(content, "project", ["name"]);
|
||||
return parseTypedManifest<ProjectManifest>(content, "project");
|
||||
}
|
||||
|
||||
export function parseTaskManifest(content: string): TaskManifest {
|
||||
return parseManifest<TaskManifest>(content, "task", ["name"]);
|
||||
return parseTypedManifest<TaskManifest>(content, "task");
|
||||
}
|
||||
|
||||
export function parseSkillManifest(content: string): SkillManifest {
|
||||
return parseManifest<SkillManifest>(content, "skill", ["name"]);
|
||||
}
|
||||
|
||||
// ── Directory + Archive Parsing ─────────────────────────────────────────
|
||||
|
||||
function parseManifestFile<T>(
|
||||
filePath: string,
|
||||
parser: (content: string) => T,
|
||||
): T {
|
||||
function parseManifestFile<T>(filePath: string, parser: (content: string) => T): T {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return parser(content);
|
||||
} catch (err) {
|
||||
if (err instanceof AgentCompaniesParseError) {
|
||||
throw new AgentCompaniesParseError(`${filePath}: ${err.message}`);
|
||||
return parser(readFileSync(filePath, "utf-8"));
|
||||
} catch (error) {
|
||||
if (error instanceof AgentCompaniesParseError) {
|
||||
throw new AgentCompaniesParseError(`${filePath}: ${error.message}`);
|
||||
}
|
||||
throw err;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,68 +146,104 @@ function parseManifestSubdirectories<T>(
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = readdirSync(sectionPath, { withFileTypes: true });
|
||||
const parsed: T[] = [];
|
||||
const manifests: T[] = [];
|
||||
const entries = readdirSync(sectionPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifestPath = join(sectionPath, entry.name, filename);
|
||||
if (!existsSync(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed.push(parseManifestFile(manifestPath, parser));
|
||||
manifests.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 {
|
||||
const resolvedDir = resolve(dirPath);
|
||||
|
||||
if (!existsSync(resolvedDir)) {
|
||||
throw new AgentCompaniesParseError(`Company directory does not exist: ${resolvedDir}`);
|
||||
const resolvedPath = resolve(dirPath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new AgentCompaniesParseError(`Company directory does not exist: ${resolvedPath}`);
|
||||
}
|
||||
if (!statSync(resolvedDir).isDirectory()) {
|
||||
throw new AgentCompaniesParseError(`Company path is not a directory: ${resolvedDir}`);
|
||||
if (!statSync(resolvedPath).isDirectory()) {
|
||||
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 {
|
||||
company: existsSync(companyPath)
|
||||
? parseManifestFile(companyPath, parseCompanyManifest)
|
||||
: undefined,
|
||||
agents: parseManifestSubdirectories(resolvedDir, "agents", "AGENTS.md", parseAgentManifest),
|
||||
teams: parseManifestSubdirectories(resolvedDir, "teams", "TEAM.md", parseTeamManifest),
|
||||
projects: parseManifestSubdirectories(resolvedDir, "projects", "PROJECT.md", parseProjectManifest),
|
||||
tasks: parseManifestSubdirectories(resolvedDir, "tasks", "TASK.md", parseTaskManifest),
|
||||
skills: parseManifestSubdirectories(resolvedDir, "skills", "SKILL.md", parseSkillManifest),
|
||||
agents: parseManifestSubdirectories(resolvedPath, "agents", "AGENTS.md", parseAgentManifest),
|
||||
teams,
|
||||
projects: parseManifestSubdirectories(
|
||||
resolvedPath,
|
||||
"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"))) {
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
const entries = readdirSync(tempDir, { withFileTypes: true });
|
||||
const directories = readdirSync(tempDir, { withFileTypes: true }).filter((entry) =>
|
||||
entry.isDirectory(),
|
||||
);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const childPath = join(tempDir, entry.name);
|
||||
if (existsSync(join(childPath, "COMPANY.md"))) {
|
||||
return childPath;
|
||||
for (const directory of directories) {
|
||||
const candidate = join(tempDir, directory.name);
|
||||
if (existsSync(join(candidate, "COMPANY.md"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 1 && entries[0].isDirectory()) {
|
||||
return join(tempDir, entries[0].name);
|
||||
if (directories.length === 1) {
|
||||
return join(tempDir, directories[0].name);
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
@@ -232,124 +251,60 @@ function resolveArchiveRoot(tempDir: string): string {
|
||||
|
||||
export async function parseCompanyArchive(archivePath: string): Promise<AgentCompaniesPackage> {
|
||||
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-"));
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`tar xzf ${JSON.stringify(resolvedArchivePath)} -C ${JSON.stringify(tempDir)}`,
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
if (resolvedArchivePath.endsWith(".tar.gz") || resolvedArchivePath.endsWith(".tgz")) {
|
||||
execSync(
|
||||
`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(extractionRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof AgentCompaniesParseError) {
|
||||
throw err;
|
||||
return parseCompanyDirectory(resolveExtractionRoot(tempDir));
|
||||
} catch (error) {
|
||||
if (error instanceof AgentCompaniesParseError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AgentCompaniesParseError(
|
||||
`Failed to parse Agent Companies archive: ${(err as Error).message}`,
|
||||
`Failed to parse Agent Companies archive: ${(error as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
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,
|
||||
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(
|
||||
@@ -371,13 +326,12 @@ export function convertAgentCompanies(
|
||||
}
|
||||
|
||||
try {
|
||||
const input = agentManifestToAgentCreateInput(agent);
|
||||
inputs.push(input);
|
||||
inputs.push(agentManifestToAgentCreateInput(agent));
|
||||
result.created.push(agent.name);
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
name: agent.name,
|
||||
error: (err as Error).message,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user