feat(FN-976): add companies.sh agent import for CLI and dashboard
- Add companies.sh type definitions and parser in @fusion/core with comprehensive tests - Create CLI `kb agent-import` command to import agents from companies.sh - Add POST /api/agents/import dashboard endpoint with dry-run support - Build AgentImportModal component with search, preview, and batch import UI - Add dashboard route tests for the agent import API endpoint - Create changeset for @gsxdsm/fusion minor bump
This commit is contained in:
314
packages/core/src/companies-sh-parser.test.ts
Normal file
314
packages/core/src/companies-sh-parser.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseCompaniesShManifest,
|
||||
companiesShAgentToAgentCreateInput,
|
||||
convertCompaniesShAgents,
|
||||
mapRoleToCapability,
|
||||
CompaniesShParseError,
|
||||
} from "./companies-sh-parser.js";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function encodeManifest(agents: unknown[]): string {
|
||||
return Buffer.from(JSON.stringify(agents)).toString("base64");
|
||||
}
|
||||
|
||||
function makeScript(companyName: string, agents: unknown[], envLines?: string[]): string {
|
||||
const manifest = encodeManifest(agents);
|
||||
let script = `#!/bin/bash\n# Agent Company Manifest\nCOMPANY_NAME="${companyName}"\nAGENT_MANIFEST="${manifest}"`;
|
||||
if (envLines && envLines.length > 0) {
|
||||
script += "\n\n" + envLines.join("\n");
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("companies-sh-parser", () => {
|
||||
describe("parseCompaniesShManifest", () => {
|
||||
it("parses a valid companies.sh manifest", () => {
|
||||
const agents = [
|
||||
{
|
||||
name: "Code Reviewer",
|
||||
role: "reviewer",
|
||||
capabilities: ["code-review", "security-audit"],
|
||||
config: { model: "claude-sonnet-4", maxTokens: 4096, thinkingLevel: "medium" },
|
||||
metadata: { title: "Senior Code Reviewer", icon: "👁" },
|
||||
},
|
||||
];
|
||||
const script = makeScript("test-company", agents, [
|
||||
'export KB_AGENT_MODEL="${KB_AGENT_MODEL:-claude-sonnet-4}"',
|
||||
]);
|
||||
|
||||
const manifest = parseCompaniesShManifest(script);
|
||||
|
||||
expect(manifest.companyName).toBe("test-company");
|
||||
expect(manifest.agents).toHaveLength(1);
|
||||
expect(manifest.agents[0].name).toBe("Code Reviewer");
|
||||
expect(manifest.agents[0].role).toBe("reviewer");
|
||||
expect(manifest.agents[0].capabilities).toEqual(["code-review", "security-audit"]);
|
||||
expect(manifest.agents[0].config?.model).toBe("claude-sonnet-4");
|
||||
expect(manifest.agents[0].metadata?.title).toBe("Senior Code Reviewer");
|
||||
expect(manifest.envVars).toHaveLength(1);
|
||||
expect(manifest.envVars[0].name).toBe("KB_AGENT_MODEL");
|
||||
expect(manifest.envVars[0].defaultValue).toBe("claude-sonnet-4");
|
||||
});
|
||||
|
||||
it("parses a manifest with multiple agents", () => {
|
||||
const agents = [
|
||||
{ name: "Agent 1", role: "executor" },
|
||||
{ name: "Agent 2", role: "reviewer" },
|
||||
{ name: "Agent 3", role: "triage" },
|
||||
];
|
||||
const script = makeScript("multi-agent", agents);
|
||||
|
||||
const manifest = parseCompaniesShManifest(script);
|
||||
|
||||
expect(manifest.agents).toHaveLength(3);
|
||||
expect(manifest.agents.map((a) => a.name)).toEqual(["Agent 1", "Agent 2", "Agent 3"]);
|
||||
});
|
||||
|
||||
it("throws on empty content", () => {
|
||||
expect(() => parseCompaniesShManifest("")).toThrow(CompaniesShParseError);
|
||||
expect(() => parseCompaniesShManifest("")).toThrow("empty or not a string");
|
||||
});
|
||||
|
||||
it("throws on missing COMPANY_NAME", () => {
|
||||
const manifest = encodeManifest([{ name: "Test", role: "executor" }]);
|
||||
const script = `#!/bin/bash\nAGENT_MANIFEST="${manifest}"`;
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("Missing COMPANY_NAME");
|
||||
});
|
||||
|
||||
it("throws on missing AGENT_MANIFEST", () => {
|
||||
const script = `#!/bin/bash\nCOMPANY_NAME="test"`;
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("Missing AGENT_MANIFEST");
|
||||
});
|
||||
|
||||
it("throws on invalid base64 encoding", () => {
|
||||
const script = `#!/bin/bash\nCOMPANY_NAME="test"\nAGENT_MANIFEST="not-valid-base64!!!"`;
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("Invalid base64");
|
||||
});
|
||||
|
||||
it("throws on invalid JSON in manifest", () => {
|
||||
const badJson = btoa("not json");
|
||||
const script = `#!/bin/bash\nCOMPANY_NAME="test"\nAGENT_MANIFEST="${badJson}"`;
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("Invalid JSON");
|
||||
});
|
||||
|
||||
it("throws when manifest decodes to non-array", () => {
|
||||
const obj = btoa(JSON.stringify({ name: "not an array" }));
|
||||
const script = `#!/bin/bash\nCOMPANY_NAME="test"\nAGENT_MANIFEST="${obj}"`;
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("must decode to a JSON array");
|
||||
});
|
||||
|
||||
it("throws on agent missing name", () => {
|
||||
const agents = [{ role: "executor" }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: name");
|
||||
});
|
||||
|
||||
it("throws on agent missing role", () => {
|
||||
const agents = [{ name: "Test Agent" }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: role");
|
||||
});
|
||||
|
||||
it("throws on agent with empty name", () => {
|
||||
const agents = [{ name: " ", role: "executor" }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: name");
|
||||
});
|
||||
|
||||
it("throws on agent with empty role", () => {
|
||||
const agents = [{ name: "Test", role: "" }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("missing required field: role");
|
||||
});
|
||||
|
||||
it("handles empty capabilities array", () => {
|
||||
const agents = [{ name: "Test", role: "executor", capabilities: [] }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
const manifest = parseCompaniesShManifest(script);
|
||||
expect(manifest.agents[0].capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles agents with no optional fields", () => {
|
||||
const agents = [{ name: "Minimal", role: "custom" }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
const manifest = parseCompaniesShManifest(script);
|
||||
expect(manifest.agents[0].capabilities).toBeUndefined();
|
||||
expect(manifest.agents[0].config).toBeUndefined();
|
||||
expect(manifest.agents[0].metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts multiple environment variables", () => {
|
||||
const agents = [{ name: "Test", role: "executor" }];
|
||||
const script = makeScript("test", agents, [
|
||||
'export KB_MODEL="${KB_MODEL:-claude-sonnet-4}"',
|
||||
'export KB_THINKING="${KB_THINKING:-medium}"',
|
||||
'export KB_MAX_TOKENS="${KB_MAX_TOKENS:-4096}"',
|
||||
]);
|
||||
|
||||
const manifest = parseCompaniesShManifest(script);
|
||||
|
||||
expect(manifest.envVars).toHaveLength(3);
|
||||
expect(manifest.envVars.map((v) => v.name)).toEqual([
|
||||
"KB_MODEL",
|
||||
"KB_THINKING",
|
||||
"KB_MAX_TOKENS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty envVars when no export statements", () => {
|
||||
const agents = [{ name: "Test", role: "executor" }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
const manifest = parseCompaniesShManifest(script);
|
||||
expect(manifest.envVars).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles non-object agent entries", () => {
|
||||
const agents = ["not an object", 42, null];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
expect(() => parseCompaniesShManifest(script)).toThrow("not an object");
|
||||
});
|
||||
|
||||
it("filters non-string capabilities", () => {
|
||||
const agents = [{ name: "Test", role: "executor", capabilities: ["valid", 123, null, "also-valid"] }];
|
||||
const script = makeScript("test", agents);
|
||||
|
||||
const manifest = parseCompaniesShManifest(script);
|
||||
expect(manifest.agents[0].capabilities).toEqual(["valid", "also-valid"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRoleToCapability", () => {
|
||||
it("maps all known roles correctly", () => {
|
||||
expect(mapRoleToCapability("triage")).toBe("triage");
|
||||
expect(mapRoleToCapability("executor")).toBe("executor");
|
||||
expect(mapRoleToCapability("reviewer")).toBe("reviewer");
|
||||
expect(mapRoleToCapability("merger")).toBe("merger");
|
||||
expect(mapRoleToCapability("scheduler")).toBe("scheduler");
|
||||
expect(mapRoleToCapability("engineer")).toBe("engineer");
|
||||
expect(mapRoleToCapability("custom")).toBe("custom");
|
||||
});
|
||||
|
||||
it("maps unknown roles to custom", () => {
|
||||
expect(mapRoleToCapability("analyst")).toBe("custom");
|
||||
expect(mapRoleToCapability("designer")).toBe("custom");
|
||||
expect(mapRoleToCapability("")).toBe("custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("companiesShAgentToAgentCreateInput", () => {
|
||||
it("converts a minimal agent", () => {
|
||||
const agent = { name: "Test", role: "executor" };
|
||||
const input = companiesShAgentToAgentCreateInput(agent);
|
||||
|
||||
expect(input.name).toBe("Test");
|
||||
expect(input.role).toBe("executor");
|
||||
expect(input.metadata).toBeUndefined();
|
||||
expect(input.runtimeConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("converts a fully populated agent", () => {
|
||||
const agent = {
|
||||
name: "Code Reviewer",
|
||||
role: "reviewer",
|
||||
capabilities: ["code-review"],
|
||||
config: {
|
||||
model: "claude-sonnet-4",
|
||||
maxTokens: 4096,
|
||||
thinkingLevel: "medium" as const,
|
||||
maxTurns: 10,
|
||||
},
|
||||
metadata: {
|
||||
title: "Senior Reviewer",
|
||||
icon: "👁",
|
||||
description: "Reviews code for quality",
|
||||
},
|
||||
};
|
||||
|
||||
const input = companiesShAgentToAgentCreateInput(agent);
|
||||
|
||||
expect(input.name).toBe("Code Reviewer");
|
||||
expect(input.role).toBe("reviewer");
|
||||
expect(input.title).toBe("Senior Reviewer");
|
||||
expect(input.icon).toBe("👁");
|
||||
expect(input.runtimeConfig).toEqual({
|
||||
model: "claude-sonnet-4",
|
||||
maxTokens: 4096,
|
||||
thinkingLevel: "medium",
|
||||
maxTurns: 10,
|
||||
});
|
||||
expect(input.metadata).toEqual({
|
||||
capabilities: ["code-review"],
|
||||
description: "Reviews code for quality",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unknown roles to custom", () => {
|
||||
const agent = { name: "Special", role: "analyst" };
|
||||
const input = companiesShAgentToAgentCreateInput(agent);
|
||||
|
||||
expect(input.role).toBe("custom");
|
||||
});
|
||||
|
||||
it("handles agent with empty capabilities", () => {
|
||||
const agent = { name: "Test", role: "executor", capabilities: [] };
|
||||
const input = companiesShAgentToAgentCreateInput(agent);
|
||||
|
||||
expect(input.metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertCompaniesShAgents", () => {
|
||||
it("converts all agents when no duplicates", () => {
|
||||
const agents = [
|
||||
{ name: "Agent 1", role: "executor" },
|
||||
{ name: "Agent 2", role: "reviewer" },
|
||||
];
|
||||
|
||||
const { inputs, result } = convertCompaniesShAgents(agents);
|
||||
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(result.created).toEqual(["Agent 1", "Agent 2"]);
|
||||
expect(result.skipped).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips agents with existing names", () => {
|
||||
const agents = [
|
||||
{ name: "Existing Agent", role: "executor" },
|
||||
{ name: "New Agent", role: "reviewer" },
|
||||
];
|
||||
|
||||
const { inputs, result } = convertCompaniesShAgents(agents, {
|
||||
skipExisting: ["Existing Agent"],
|
||||
});
|
||||
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].name).toBe("New Agent");
|
||||
expect(result.skipped).toEqual(["Existing Agent"]);
|
||||
});
|
||||
|
||||
it("handles empty agent list", () => {
|
||||
const { inputs, result } = convertCompaniesShAgents([]);
|
||||
|
||||
expect(inputs).toHaveLength(0);
|
||||
expect(result.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
269
packages/core/src/companies-sh-parser.ts
Normal file
269
packages/core/src/companies-sh-parser.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Parser for companies.sh manifest files.
|
||||
*
|
||||
* Extracts agent definitions from shell-script-based manifests following
|
||||
* the companies.sh standard. Handles base64-encoded JSON payloads,
|
||||
* shell variable extraction, and environment variable defaults.
|
||||
*
|
||||
* @module companies-sh-parser
|
||||
*/
|
||||
|
||||
import type {
|
||||
CompaniesShManifest,
|
||||
CompaniesShAgent,
|
||||
CompaniesShEnvVar,
|
||||
CompaniesShImportResult,
|
||||
} from "./companies-sh-types.js";
|
||||
import type { AgentCreateInput, AgentCapability } from "./types.js";
|
||||
|
||||
// ── Parsing Errors ───────────────────────────────────────────────────────
|
||||
|
||||
export class CompaniesShParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CompaniesShParseError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Role Mapping ─────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_ROLES: Set<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 };
|
||||
}
|
||||
190
packages/core/src/companies-sh-types.test.ts
Normal file
190
packages/core/src/companies-sh-types.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
CompaniesShManifest,
|
||||
CompaniesShAgent,
|
||||
CompaniesShConfig,
|
||||
CompaniesShMetadata,
|
||||
CompaniesShEnvVar,
|
||||
CompaniesShImportResult,
|
||||
CompaniesShRole,
|
||||
} from "./companies-sh-types.js";
|
||||
|
||||
describe("companies-sh-types", () => {
|
||||
describe("CompaniesShAgent", () => {
|
||||
it("accepts a valid minimal agent with required fields", () => {
|
||||
const agent: CompaniesShAgent = {
|
||||
name: "Code Reviewer",
|
||||
role: "reviewer",
|
||||
};
|
||||
expect(agent.name).toBe("Code Reviewer");
|
||||
expect(agent.role).toBe("reviewer");
|
||||
});
|
||||
|
||||
it("accepts a fully populated agent", () => {
|
||||
const agent: CompaniesShAgent = {
|
||||
name: "Code Reviewer",
|
||||
role: "reviewer",
|
||||
capabilities: ["code-review", "security-audit"],
|
||||
config: {
|
||||
model: "claude-sonnet-4",
|
||||
maxTokens: 4096,
|
||||
thinkingLevel: "medium",
|
||||
maxTurns: 10,
|
||||
},
|
||||
metadata: {
|
||||
title: "Senior Code Reviewer",
|
||||
icon: "👁",
|
||||
description: "Reviews code for quality and security",
|
||||
},
|
||||
};
|
||||
expect(agent.name).toBe("Code Reviewer");
|
||||
expect(agent.capabilities).toHaveLength(2);
|
||||
expect(agent.config?.model).toBe("claude-sonnet-4");
|
||||
expect(agent.metadata?.title).toBe("Senior Code Reviewer");
|
||||
});
|
||||
|
||||
it("accepts an agent with optional fields omitted", () => {
|
||||
const agent: CompaniesShAgent = {
|
||||
name: "Simple Agent",
|
||||
role: "executor",
|
||||
};
|
||||
expect(agent.capabilities).toBeUndefined();
|
||||
expect(agent.config).toBeUndefined();
|
||||
expect(agent.metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompaniesShConfig", () => {
|
||||
it("accepts a config with all optional fields", () => {
|
||||
const config: CompaniesShConfig = {
|
||||
model: "provider/model-id",
|
||||
maxTokens: 8192,
|
||||
thinkingLevel: "high",
|
||||
maxTurns: 20,
|
||||
};
|
||||
expect(config.model).toBe("provider/model-id");
|
||||
expect(config.maxTokens).toBe(8192);
|
||||
});
|
||||
|
||||
it("accepts an empty config", () => {
|
||||
const config: CompaniesShConfig = {};
|
||||
expect(config.model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompaniesShMetadata", () => {
|
||||
it("accepts metadata with all fields", () => {
|
||||
const meta: CompaniesShMetadata = {
|
||||
title: "Job Title",
|
||||
icon: "🤖",
|
||||
description: "An AI agent",
|
||||
};
|
||||
expect(meta.title).toBe("Job Title");
|
||||
expect(meta.icon).toBe("🤖");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompaniesShManifest", () => {
|
||||
it("accepts a valid manifest structure", () => {
|
||||
const manifest: CompaniesShManifest = {
|
||||
companyName: "my-company",
|
||||
agents: [
|
||||
{ name: "Agent 1", role: "executor" },
|
||||
{ name: "Agent 2", role: "reviewer" },
|
||||
],
|
||||
envVars: [
|
||||
{ name: "KB_AGENT_MODEL", defaultValue: "claude-sonnet-4" },
|
||||
],
|
||||
};
|
||||
expect(manifest.companyName).toBe("my-company");
|
||||
expect(manifest.agents).toHaveLength(2);
|
||||
expect(manifest.envVars).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a manifest with empty agents array", () => {
|
||||
const manifest: CompaniesShManifest = {
|
||||
companyName: "empty-company",
|
||||
agents: [],
|
||||
envVars: [],
|
||||
};
|
||||
expect(manifest.agents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompaniesShEnvVar", () => {
|
||||
it("accepts an env var with name and default", () => {
|
||||
const envVar: CompaniesShEnvVar = {
|
||||
name: "KB_MODEL",
|
||||
defaultValue: "claude-sonnet-4",
|
||||
};
|
||||
expect(envVar.name).toBe("KB_MODEL");
|
||||
expect(envVar.defaultValue).toBe("claude-sonnet-4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompaniesShImportResult", () => {
|
||||
it("accepts a valid import result", () => {
|
||||
const result: CompaniesShImportResult = {
|
||||
created: ["agent-1", "agent-2"],
|
||||
skipped: ["agent-3"],
|
||||
errors: [{ name: "bad-agent", error: "missing role" }],
|
||||
};
|
||||
expect(result.created).toHaveLength(2);
|
||||
expect(result.skipped).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts an empty result", () => {
|
||||
const result: CompaniesShImportResult = {
|
||||
created: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
};
|
||||
expect(result.created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompaniesShRole", () => {
|
||||
it("accepts all defined role types", () => {
|
||||
const roles: CompaniesShRole[] = [
|
||||
"triage",
|
||||
"executor",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"scheduler",
|
||||
"engineer",
|
||||
"custom",
|
||||
];
|
||||
expect(roles).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime validation", () => {
|
||||
it("validates that a minimal object satisfies CompaniesShAgent shape", () => {
|
||||
// Simulate runtime validation of parsed JSON
|
||||
const parsed = JSON.parse('{"name":"Test","role":"executor"}');
|
||||
expect(typeof parsed.name).toBe("string");
|
||||
expect(typeof parsed.role).toBe("string");
|
||||
expect(parsed.name).toBe("Test");
|
||||
expect(parsed.role).toBe("executor");
|
||||
});
|
||||
|
||||
it("detects missing required fields in parsed data", () => {
|
||||
const parsed = JSON.parse('{"name":"Test"}');
|
||||
expect(parsed.role).toBeUndefined();
|
||||
// This would fail validation: missing role
|
||||
expect(() => {
|
||||
if (!parsed.role) throw new Error("Missing required field: role");
|
||||
}).toThrow("Missing required field: role");
|
||||
});
|
||||
|
||||
it("detects malformed data types", () => {
|
||||
const parsed = JSON.parse('{"name":123,"role":"executor"}');
|
||||
expect(typeof parsed.name).toBe("number");
|
||||
// This would fail validation: name should be string
|
||||
expect(() => {
|
||||
if (typeof parsed.name !== "string") throw new Error("name must be a string");
|
||||
}).toThrow("name must be a string");
|
||||
});
|
||||
});
|
||||
});
|
||||
101
packages/core/src/companies-sh-types.ts
Normal file
101
packages/core/src/companies-sh-types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* TypeScript type definitions for the companies.sh agent manifest format.
|
||||
*
|
||||
* The companies.sh standard defines a shell-script manifest that contains
|
||||
* base64-encoded agent definitions, enabling portability of agent configurations
|
||||
* across different agent systems.
|
||||
*
|
||||
* @module companies-sh-types
|
||||
*/
|
||||
|
||||
// ── Agent Config ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Configuration options for a companies.sh agent */
|
||||
export interface CompaniesShConfig {
|
||||
/** AI model identifier (e.g., "provider/model-id") */
|
||||
model?: string;
|
||||
/** Maximum tokens for the agent's responses */
|
||||
maxTokens?: number;
|
||||
/** Thinking effort level */
|
||||
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
/** Maximum number of conversation turns */
|
||||
maxTurns?: number;
|
||||
}
|
||||
|
||||
// ── Agent Metadata ───────────────────────────────────────────────────────
|
||||
|
||||
/** Display metadata for a companies.sh agent */
|
||||
export interface CompaniesShMetadata {
|
||||
/** Human-readable job title */
|
||||
title?: string;
|
||||
/** Emoji icon identifier */
|
||||
icon?: string;
|
||||
/** Agent description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Agent Definition ─────────────────────────────────────────────────────
|
||||
|
||||
/** Role types in the companies.sh manifest format */
|
||||
export type CompaniesShRole =
|
||||
| "triage"
|
||||
| "executor"
|
||||
| "reviewer"
|
||||
| "merger"
|
||||
| "scheduler"
|
||||
| "engineer"
|
||||
| "custom";
|
||||
|
||||
/**
|
||||
* A single agent definition in the companies.sh manifest.
|
||||
* Each agent has a required name and role, plus optional configuration.
|
||||
*/
|
||||
export interface CompaniesShAgent {
|
||||
/** Display name (required) */
|
||||
name: string;
|
||||
/** Agent role (required) — maps to kb AgentCapability */
|
||||
role: CompaniesShRole | string;
|
||||
/** List of capability identifiers */
|
||||
capabilities?: string[];
|
||||
/** Agent runtime configuration */
|
||||
config?: CompaniesShConfig;
|
||||
/** Display metadata */
|
||||
metadata?: CompaniesShMetadata;
|
||||
}
|
||||
|
||||
// ── Environment Variable ─────────────────────────────────────────────────
|
||||
|
||||
/** An environment variable with its default value extracted from the manifest */
|
||||
export interface CompaniesShEnvVar {
|
||||
/** Variable name */
|
||||
name: string;
|
||||
/** Default value (from ${VAR:-default} syntax) */
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
// ── Manifest ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed companies.sh manifest representing a full shell-script agent company.
|
||||
* Contains the company name, decoded agent definitions, and environment variables.
|
||||
*/
|
||||
export interface CompaniesShManifest {
|
||||
/** Company name extracted from COMPANY_NAME variable */
|
||||
companyName: string;
|
||||
/** Decoded and parsed agent definitions */
|
||||
agents: CompaniesShAgent[];
|
||||
/** Environment variables with defaults extracted from export statements */
|
||||
envVars: CompaniesShEnvVar[];
|
||||
}
|
||||
|
||||
// ── Import Result ────────────────────────────────────────────────────────
|
||||
|
||||
/** Result of importing agents from a companies.sh manifest */
|
||||
export interface CompaniesShImportResult {
|
||||
/** Agents successfully created */
|
||||
created: string[];
|
||||
/** Agent names that were skipped (already exist or invalid) */
|
||||
skipped: string[];
|
||||
/** Errors encountered during import */
|
||||
errors: Array<{ name: string; error: string }>;
|
||||
}
|
||||
@@ -192,3 +192,25 @@ export {
|
||||
buildExecutionMemoryInstructions,
|
||||
readProjectMemory,
|
||||
} from "./project-memory.js";
|
||||
|
||||
// ── companies.sh Types ───────────────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
CompaniesShManifest,
|
||||
CompaniesShAgent,
|
||||
CompaniesShConfig,
|
||||
CompaniesShMetadata,
|
||||
CompaniesShEnvVar,
|
||||
CompaniesShImportResult,
|
||||
CompaniesShRole,
|
||||
} from "./companies-sh-types.js";
|
||||
|
||||
// ── companies.sh Parser ──────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
parseCompaniesShManifest,
|
||||
companiesShAgentToAgentCreateInput,
|
||||
convertCompaniesShAgents,
|
||||
mapRoleToCapability,
|
||||
CompaniesShParseError,
|
||||
} from "./companies-sh-parser.js";
|
||||
|
||||
Reference in New Issue
Block a user