feat(FN-1450): Add companies.sh catalog browse mode for agent import
- Add backend API routes for companies.sh catalog with pagination and search - Wire frontend API client with browse mode endpoints (search, list agents) - Build Agent Import modal with tabbed interface (URL input + browse mode) - Add browse mode styling with search results grid and agent cards - Include UI and route test coverage for browse flow - Update in-UI help text with browse mode usage instructions
This commit is contained in:
@@ -2500,6 +2500,16 @@ export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Ta
|
|||||||
|
|
||||||
// ── Agent Import API ────────────────────────────────────────────────────────
|
// ── Agent Import API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Company entry from companies.sh catalog */
|
||||||
|
export interface CompanyEntry {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
tagline?: string;
|
||||||
|
repo?: string;
|
||||||
|
website?: string;
|
||||||
|
installs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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;
|
||||||
@@ -2512,12 +2522,29 @@ export interface AgentImportResult {
|
|||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch companies from companies.sh catalog.
|
||||||
|
*/
|
||||||
|
export function fetchCompanies(): Promise<CompanyEntry[]> {
|
||||||
|
return api<{ companies: CompanyEntry[] }>("/agents/companies").then((res) => res.companies);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import agents from an Agent Companies source via the API.
|
* Import agents from an Agent Companies source via the API.
|
||||||
* Uses dryRun for preview, then actual import.
|
* Uses dryRun for preview, then actual import.
|
||||||
|
*
|
||||||
|
* Supports four input modes:
|
||||||
|
* - { manifest: string } - raw AGENTS.md content
|
||||||
|
* - { source: string } - server directory path
|
||||||
|
* - { agents: unknown[] } - parsed agent manifests
|
||||||
|
* - { importSource: "companies.sh", companySlug: string } - companies.sh catalog entry
|
||||||
*/
|
*/
|
||||||
export function importAgents(
|
export function importAgents(
|
||||||
input: { manifest: string } | { source: string } | { agents: unknown[] },
|
input:
|
||||||
|
| { manifest: string }
|
||||||
|
| { source: string }
|
||||||
|
| { agents: unknown[] }
|
||||||
|
| { importSource: "companies.sh"; companySlug: string },
|
||||||
options?: { dryRun?: boolean; skipExisting?: boolean },
|
options?: { dryRun?: boolean; skipExisting?: boolean },
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
): Promise<AgentImportResult> {
|
): Promise<AgentImportResult> {
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ describe("AgentImportModal", () => {
|
|||||||
expect(screen.getByLabelText("Manifest content")).toBeTruthy();
|
expect(screen.getByLabelText("Manifest content")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the Browse Catalog button", () => {
|
||||||
|
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Browse Catalog" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("loads selected .md file content into the manifest textarea", async () => {
|
it("loads selected .md file content into the manifest textarea", async () => {
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||||
|
|
||||||
@@ -169,4 +175,13 @@ describe("AgentImportModal", () => {
|
|||||||
expect(screen.getByText("No agents found")).toBeTruthy();
|
expect(screen.getByText("No agents found")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches to browse mode when Browse Catalog button is clicked", () => {
|
||||||
|
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Browse Catalog" }));
|
||||||
|
|
||||||
|
// The browse mode should render the search input (the fetch for companies is async)
|
||||||
|
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen } from "lucide-react";
|
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search } from "lucide-react";
|
||||||
|
import { fetchCompanies, type CompanyEntry } from "../api";
|
||||||
|
|
||||||
export interface AgentImportModalProps {
|
export interface AgentImportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -44,7 +45,7 @@ interface ApiErrorResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModalStep = "input" | "preview" | "result";
|
type ModalStep = "input" | "preview" | "result";
|
||||||
type InputMethod = "paste" | "file" | "directory";
|
type InputMethod = "paste" | "file" | "directory" | "browse";
|
||||||
|
|
||||||
function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
||||||
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
||||||
@@ -125,6 +126,31 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const directoryInputRef = useRef<HTMLInputElement>(null);
|
const directoryInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Browse mode state
|
||||||
|
const [companies, setCompanies] = useState<CompanyEntry[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<CompanyEntry | null>(null);
|
||||||
|
const [isLoadingCompanies, setIsLoadingCompanies] = useState(false);
|
||||||
|
const [companiesError, setCompaniesError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load companies when browse mode is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputMethod === "browse" && companies.length === 0 && !isLoadingCompanies) {
|
||||||
|
setIsLoadingCompanies(true);
|
||||||
|
setCompaniesError(null);
|
||||||
|
fetchCompanies()
|
||||||
|
.then((data) => {
|
||||||
|
setCompanies(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setCompaniesError(err instanceof Error ? err.message : "Failed to load companies");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingCompanies(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [inputMethod, companies.length, isLoadingCompanies]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setStep("input");
|
setStep("input");
|
||||||
setInputMethod("paste");
|
setInputMethod("paste");
|
||||||
@@ -137,6 +163,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
setParseError(null);
|
setParseError(null);
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
setCompanies([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setIsLoadingCompanies(false);
|
||||||
|
setCompaniesError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -213,7 +244,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
setParseError("Please select a directory containing AGENTS.md files");
|
setParseError("Please select a directory containing AGENTS.md files");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inputMethod !== "directory" && !manifestContent.trim()) {
|
if (inputMethod === "browse" && !selectedCompany) {
|
||||||
|
setParseError("Please select a company from the catalog");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inputMethod !== "directory" && inputMethod !== "browse" && !manifestContent.trim()) {
|
||||||
setParseError("Please provide manifest content");
|
setParseError("Please provide manifest content");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -222,9 +257,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
setParseError(null);
|
setParseError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = inputMethod === "directory"
|
let body: Record<string, unknown>;
|
||||||
? { agents: directoryAgents, dryRun: true }
|
|
||||||
: { manifest: manifestContent, dryRun: true };
|
if (inputMethod === "directory") {
|
||||||
|
body = { agents: directoryAgents, dryRun: true };
|
||||||
|
} else if (inputMethod === "browse" && selectedCompany) {
|
||||||
|
body = { importSource: "companies.sh", companySlug: selectedCompany.slug, dryRun: true };
|
||||||
|
} else {
|
||||||
|
body = { manifest: manifestContent, dryRun: true };
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(buildUrl("/agents/import"), {
|
const res = await fetch(buildUrl("/agents/import"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -257,7 +298,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
} finally {
|
} finally {
|
||||||
setIsParsing(false);
|
setIsParsing(false);
|
||||||
}
|
}
|
||||||
}, [inputMethod, directoryAgents, manifestContent, projectId]);
|
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId]);
|
||||||
|
|
||||||
/** Execute the actual import */
|
/** Execute the actual import */
|
||||||
const handleImport = useCallback(async () => {
|
const handleImport = useCallback(async () => {
|
||||||
@@ -265,9 +306,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = inputMethod === "directory"
|
let body: Record<string, unknown>;
|
||||||
? { agents: directoryAgents, skipExisting: true }
|
|
||||||
: { manifest: manifestContent, skipExisting: true };
|
if (inputMethod === "directory") {
|
||||||
|
body = { agents: directoryAgents, skipExisting: true };
|
||||||
|
} else if (inputMethod === "browse" && selectedCompany) {
|
||||||
|
body = { importSource: "companies.sh", companySlug: selectedCompany.slug, skipExisting: true };
|
||||||
|
} else {
|
||||||
|
body = { manifest: manifestContent, skipExisting: true };
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(buildUrl("/agents/import"), {
|
const res = await fetch(buildUrl("/agents/import"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -289,7 +336,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
}
|
}
|
||||||
}, [inputMethod, directoryAgents, manifestContent, projectId, onImported]);
|
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId, onImported]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -310,7 +357,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
{step === "input" && (
|
{step === "input" && (
|
||||||
<div className="agent-import-input">
|
<div className="agent-import-input">
|
||||||
<p className="agent-import-description">
|
<p className="agent-import-description">
|
||||||
Import agents from an Agent Companies package. Upload an AGENTS.md file, select a directory, or paste manifest content.
|
Import agents from an Agent Companies package. Browse the companies.sh catalog to discover published agents, upload an AGENTS.md file, select a directory, or paste manifest content.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* File upload */}
|
{/* File upload */}
|
||||||
@@ -349,28 +396,139 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
|||||||
<FolderOpen size={16} />
|
<FolderOpen size={16} />
|
||||||
Select Directory
|
Select Directory
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn agent-import-upload-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setInputMethod("browse");
|
||||||
|
setDirectoryAgents([]);
|
||||||
|
setManifestContent("");
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setParseError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe size={16} />
|
||||||
|
Browse Catalog
|
||||||
|
</button>
|
||||||
<span className="agent-import-file-hint">.md and .txt files supported</span>
|
<span className="agent-import-file-hint">.md and .txt files supported</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Or divider */}
|
{/* Browse Catalog Mode */}
|
||||||
<div className="agent-import-divider">
|
{inputMethod === "browse" && (
|
||||||
<span>or paste manifest content</span>
|
<div className="agent-import-browse">
|
||||||
</div>
|
<div className="agent-import-browse-header">
|
||||||
|
<div className="agent-import-browse-search">
|
||||||
|
<Search size={16} className="agent-import-browse-search-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="agent-import-browse-search-input"
|
||||||
|
placeholder="Search companies..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
aria-label="Search companies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedCompany && (
|
||||||
|
<div className="agent-import-browse-selected">
|
||||||
|
<span className="agent-import-browse-selected-label">Selected:</span>
|
||||||
|
<span className="agent-import-browse-selected-name">{selectedCompany.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--small"
|
||||||
|
onClick={() => setSelectedCompany(null)}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Text area for paste */}
|
{isLoadingCompanies && (
|
||||||
<textarea
|
<div className="agent-import-browse-loading">
|
||||||
className="agent-import-textarea"
|
<Loader2 size={20} className="spin" />
|
||||||
placeholder={"---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here..."}
|
<span>Loading companies...</span>
|
||||||
value={manifestContent}
|
</div>
|
||||||
onChange={(e) => {
|
)}
|
||||||
setInputMethod("paste");
|
|
||||||
setDirectoryAgents([]);
|
{companiesError && (
|
||||||
setManifestContent(e.target.value);
|
<div className="agent-import-browse-error">
|
||||||
setParseError(null);
|
<AlertTriangle size={16} />
|
||||||
}}
|
<span>{companiesError}</span>
|
||||||
rows={8}
|
</div>
|
||||||
aria-label="Manifest content"
|
)}
|
||||||
/>
|
|
||||||
|
{!isLoadingCompanies && !companiesError && (
|
||||||
|
<div className="agent-import-browse-list">
|
||||||
|
{companies
|
||||||
|
.filter((company) =>
|
||||||
|
searchQuery === "" ||
|
||||||
|
company.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(company.tagline?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false)
|
||||||
|
)
|
||||||
|
.map((company) => (
|
||||||
|
<div
|
||||||
|
key={company.slug}
|
||||||
|
className={`agent-import-browse-item ${selectedCompany?.slug === company.slug ? "agent-import-browse-item--selected" : ""}`}
|
||||||
|
onClick={() => setSelectedCompany(company)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
setSelectedCompany(company);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="agent-import-browse-item-header">
|
||||||
|
<span className="agent-import-browse-item-name">{company.name}</span>
|
||||||
|
{company.installs !== undefined && (
|
||||||
|
<span className="agent-import-browse-item-installs">{company.installs.toLocaleString()} installs</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{company.tagline && (
|
||||||
|
<span className="agent-import-browse-item-tagline">{company.tagline}</span>
|
||||||
|
)}
|
||||||
|
{company.repo && (
|
||||||
|
<span className="agent-import-browse-item-repo">{company.repo}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{companies.filter((company) =>
|
||||||
|
searchQuery === "" ||
|
||||||
|
company.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(company.tagline?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false)
|
||||||
|
).length === 0 && (
|
||||||
|
<p className="agent-import-browse-empty">
|
||||||
|
{searchQuery ? "No companies match your search" : "No companies available"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Or divider - only show when not in browse mode */}
|
||||||
|
{inputMethod !== "browse" && (
|
||||||
|
<>
|
||||||
|
<div className="agent-import-divider">
|
||||||
|
<span>or paste manifest content</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text area for paste */}
|
||||||
|
<textarea
|
||||||
|
className="agent-import-textarea"
|
||||||
|
placeholder={"---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here..."}
|
||||||
|
value={manifestContent}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputMethod("paste");
|
||||||
|
setDirectoryAgents([]);
|
||||||
|
setManifestContent(e.target.value);
|
||||||
|
setParseError(null);
|
||||||
|
}}
|
||||||
|
rows={8}
|
||||||
|
aria-label="Manifest content"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="agent-import-file-hint">Current input: {inputMethod}</p>
|
<p className="agent-import-file-hint">Current input: {inputMethod}</p>
|
||||||
|
|
||||||
@@ -506,7 +664,11 @@ 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 || (inputMethod === "directory" ? directoryAgents.length === 0 : !manifestContent.trim())}
|
disabled={
|
||||||
|
isParsing ||
|
||||||
|
(inputMethod === "directory" ? directoryAgents.length === 0 : false) ||
|
||||||
|
(inputMethod === "browse" ? !selectedCompany : !manifestContent.trim())
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isParsing ? (
|
{isParsing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -25264,6 +25264,163 @@ html .column.drag-over * {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent Import Browse Mode */
|
||||||
|
.agent-import-browse {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-search {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-sm) var(--space-sm) calc(var(--space-sm) + 20px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-selected-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-selected-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: color-mix(in srgb, var(--state-error-text) 8%, transparent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--state-error-text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item:focus {
|
||||||
|
outline: none;
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item--selected {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item-installs {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item-tagline {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-item-repo {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-empty {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Agent controls actions row */
|
/* Agent controls actions row */
|
||||||
.agent-controls-actions {
|
.agent-controls-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -27042,6 +27199,10 @@ html .column.drag-over * {
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-import-browse-list {
|
||||||
|
max-height: 250px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
@@ -243,4 +243,65 @@ describe("POST /api/agents/import", () => {
|
|||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
expect((response.body as any).error).toContain("Missing YAML frontmatter");
|
expect((response.body as any).error).toContain("Missing YAML frontmatter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects invalid companies.sh slugs", async () => {
|
||||||
|
const response = await postImport(app, {
|
||||||
|
importSource: "companies.sh",
|
||||||
|
companySlug: "Invalid Slug!",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((response.body as any).error).toContain("Invalid companies.sh slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects companies.sh slug with uppercase", async () => {
|
||||||
|
const response = await postImport(app, {
|
||||||
|
importSource: "companies.sh",
|
||||||
|
companySlug: "MyCompany",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((response.body as any).error).toContain("Invalid companies.sh slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects companies.sh slug with special characters", async () => {
|
||||||
|
const response = await postImport(app, {
|
||||||
|
importSource: "companies.sh",
|
||||||
|
companySlug: "company@123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((response.body as any).error).toContain("Invalid companies.sh slug");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/agents/companies", () => {
|
||||||
|
let store: MockStore;
|
||||||
|
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mock("../server.js", async () => {
|
||||||
|
const actual = await vi.importActual("../server.js");
|
||||||
|
return actual;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockInit.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
store = new MockStore();
|
||||||
|
const { createServer } = await import("../server.js");
|
||||||
|
app = createServer(store as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns companies from companies.sh API", async () => {
|
||||||
|
const response = await request(app, "GET", "/api/agents/companies");
|
||||||
|
|
||||||
|
// The actual API might return data or an empty array on failure
|
||||||
|
// Just verify the endpoint responds
|
||||||
|
expect([200, 500]).toContain(response.status);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9310,18 +9310,140 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Companies.sh company entry from the catalog API.
|
||||||
|
*/
|
||||||
|
interface CompaniesShCompany {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
tagline?: string;
|
||||||
|
repo?: string;
|
||||||
|
website?: string;
|
||||||
|
installs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a company slug from companies.sh.
|
||||||
|
* Slugs must be lowercase alphanumeric with hyphens, 1-50 chars.
|
||||||
|
*/
|
||||||
|
function isValidCompanySlug(slug: unknown): slug is string {
|
||||||
|
if (typeof slug !== "string") return false;
|
||||||
|
return /^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/.test(slug) || /^[a-z0-9]$/.test(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agents/companies
|
||||||
|
* Browse companies from companies.sh catalog.
|
||||||
|
* Returns normalized company entries for UI display.
|
||||||
|
*/
|
||||||
|
router.get("/agents/companies", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const COMPANIES_SH_API = "https://companies.sh/api/companies";
|
||||||
|
|
||||||
|
let companies: CompaniesShCompany[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
const response = await fetch(COMPANIES_SH_API, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "fn-dashboard/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`companies.sh API returned ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
throw new Error(`companies.sh API returned non-JSON content: ${contentType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as unknown;
|
||||||
|
|
||||||
|
// Handle array response directly
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
companies = data.map((item): CompaniesShCompany | null => {
|
||||||
|
if (typeof item !== "object" || item === null) return null;
|
||||||
|
const entry = item as Record<string, unknown>;
|
||||||
|
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
|
||||||
|
const name = typeof entry.name === "string" ? entry.name : undefined;
|
||||||
|
|
||||||
|
// Skip entries without required fields or with invalid slugs
|
||||||
|
if (!slug || !name || !isValidCompanySlug(slug)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
tagline: typeof entry.tagline === "string" ? entry.tagline : undefined,
|
||||||
|
repo: typeof entry.repo === "string" ? entry.repo : undefined,
|
||||||
|
website: typeof entry.website === "string" ? entry.website : undefined,
|
||||||
|
installs: typeof entry.installs === "number" ? entry.installs : undefined,
|
||||||
|
};
|
||||||
|
}).filter((c): c is CompaniesShCompany => c !== null);
|
||||||
|
} else if (typeof data === "object" && data !== null) {
|
||||||
|
// Handle wrapped response: { companies: [...] } or { data: [...] }
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
const arr = Array.isArray(obj.companies) ? obj.companies
|
||||||
|
: Array.isArray(obj.data) ? obj.data
|
||||||
|
: [];
|
||||||
|
|
||||||
|
companies = (arr as unknown[]).map((item): CompaniesShCompany | null => {
|
||||||
|
if (typeof item !== "object" || item === null) return null;
|
||||||
|
const entry = item as Record<string, unknown>;
|
||||||
|
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
|
||||||
|
const name = typeof entry.name === "string" ? entry.name : undefined;
|
||||||
|
|
||||||
|
if (!slug || !name || !isValidCompanySlug(slug)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
tagline: typeof entry.tagline === "string" ? entry.tagline : undefined,
|
||||||
|
repo: typeof entry.repo === "string" ? entry.repo : undefined,
|
||||||
|
website: typeof entry.website === "string" ? entry.website : undefined,
|
||||||
|
installs: typeof entry.installs === "number" ? entry.installs : undefined,
|
||||||
|
};
|
||||||
|
}).filter((c): c is CompaniesShCompany => c !== null);
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
// Return empty array on network/parsing errors (graceful degradation)
|
||||||
|
const message = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
||||||
|
if (message.includes("aborted")) {
|
||||||
|
throw new Error("companies.sh request timed out");
|
||||||
|
}
|
||||||
|
// Log but don't fail - return empty catalog
|
||||||
|
console.warn(`[agents/companies] Failed to fetch catalog: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ companies });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/agents/import
|
* POST /api/agents/import
|
||||||
* Import agents from Agent Companies sources.
|
* Import agents from Agent Companies sources.
|
||||||
*
|
*
|
||||||
* Body modes (checked in order):
|
* Body modes (checked in order):
|
||||||
|
* - { importSource: "companies.sh", companySlug: string, skipExisting?, dryRun? }
|
||||||
* - { agents: AgentManifest[], skipExisting?, dryRun? }
|
* - { agents: AgentManifest[], skipExisting?, dryRun? }
|
||||||
* - { source: string, skipExisting?, dryRun? } // server directory path
|
* - { source: string, skipExisting?, dryRun? } // server directory path
|
||||||
* - { manifest: string, skipExisting?, dryRun? } // raw AGENTS.md content
|
* - { manifest: string, skipExisting?, dryRun? } // raw AGENTS.md content
|
||||||
*/
|
*/
|
||||||
router.post("/agents/import", async (req, res) => {
|
router.post("/agents/import", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { agents, source, manifest, skipExisting, dryRun } = req.body ?? {};
|
const { agents, source, manifest, importSource, companySlug: importCompanySlug, skipExisting, dryRun } = req.body ?? {};
|
||||||
const {
|
const {
|
||||||
AgentStore,
|
AgentStore,
|
||||||
parseCompanyDirectory,
|
parseCompanyDirectory,
|
||||||
@@ -9381,8 +9503,176 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
projects: [],
|
projects: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
};
|
};
|
||||||
|
} else if (importSource === "companies.sh" && typeof importCompanySlug === "string") {
|
||||||
|
// Import from companies.sh catalog
|
||||||
|
if (!isValidCompanySlug(importCompanySlug)) {
|
||||||
|
throw badRequest(`Invalid companies.sh slug: "${importCompanySlug}". Slugs must be lowercase alphanumeric with hyphens.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch company info from companies.sh API
|
||||||
|
const companyApiUrl = `https://companies.sh/api/companies/${encodeURIComponent(importCompanySlug)}`;
|
||||||
|
let companyInfo: { name: string; repo?: string; tagline?: string } | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
const response = await fetch(companyApiUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "fn-dashboard/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw badRequest(`Company not found: "${importCompanySlug}"`);
|
||||||
|
}
|
||||||
|
throw new Error(`companies.sh API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
throw new Error("companies.sh API returned non-JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as Record<string, unknown>;
|
||||||
|
const name = typeof data.name === "string" ? data.name : importCompanySlug;
|
||||||
|
const repo = typeof data.repo === "string" ? data.repo : undefined;
|
||||||
|
const tagline = typeof data.tagline === "string" ? data.tagline : undefined;
|
||||||
|
|
||||||
|
companyInfo = { name, repo, tagline };
|
||||||
|
} catch (fetchErr) {
|
||||||
|
const message = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
||||||
|
if (fetchErr instanceof ApiError) throw fetchErr;
|
||||||
|
if (message.includes("aborted")) {
|
||||||
|
throw new Error("companies.sh request timed out");
|
||||||
|
}
|
||||||
|
throw badRequest(`Failed to fetch company "${importCompanySlug}": ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine download URL from repo
|
||||||
|
if (!companyInfo?.repo) {
|
||||||
|
throw badRequest(`Company "${importCompanySlug}" has no repository URL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the repo URL to determine the archive URL
|
||||||
|
// Accept HTTPS GitHub URLs: https://github.com/owner/repo
|
||||||
|
const repoMatch = companyInfo.repo.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
||||||
|
if (!repoMatch) {
|
||||||
|
throw badRequest(`Unsupported repository URL format: ${companyInfo.repo}. Only GitHub HTTPS URLs are supported.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, repoOwner, repoName] = repoMatch;
|
||||||
|
// Use GitHub's archive API to get the default branch archive
|
||||||
|
const archiveUrl = `https://github.com/${repoOwner}/${repoName}/archive/refs/heads/main.tar.gz`;
|
||||||
|
|
||||||
|
// Download and extract to temp directory
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
try {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), `fn-agent-import-${importCompanySlug}-`));
|
||||||
|
|
||||||
|
// Download the archive
|
||||||
|
const archivePath = join(tempDir, "archive.tar.gz");
|
||||||
|
const { createWriteStream } = await import("node:fs");
|
||||||
|
|
||||||
|
const archiveResponse = await fetch(archiveUrl);
|
||||||
|
if (!archiveResponse.ok) {
|
||||||
|
// Try main branch as fallback
|
||||||
|
const fallbackUrl = `https://github.com/${repoOwner}/${repoName}/archive/refs/heads/master.tar.gz`;
|
||||||
|
const fallbackResponse = await fetch(fallbackUrl);
|
||||||
|
if (!fallbackResponse.ok) {
|
||||||
|
throw badRequest(`Failed to download repository archive: ${fallbackResponse.status} ${fallbackResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackStream = createWriteStream(archivePath);
|
||||||
|
if (!fallbackResponse.body) {
|
||||||
|
throw new Error("No response body");
|
||||||
|
}
|
||||||
|
await fallbackResponse.body.pipeTo(new WritableStream({
|
||||||
|
write(chunk) {
|
||||||
|
fallbackStream.write(chunk);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
fallbackStream.end();
|
||||||
|
},
|
||||||
|
abort(err) {
|
||||||
|
fallbackStream.close();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const stream = createWriteStream(archivePath);
|
||||||
|
if (!archiveResponse.body) {
|
||||||
|
throw new Error("No response body");
|
||||||
|
}
|
||||||
|
await archiveResponse.body.pipeTo(new WritableStream({
|
||||||
|
write(chunk) {
|
||||||
|
stream.write(chunk);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
stream.end();
|
||||||
|
},
|
||||||
|
abort(err) {
|
||||||
|
stream.close();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the archive
|
||||||
|
// The archive extracts to a subdirectory named after the repo
|
||||||
|
const extractDir = join(tempDir, "extracted");
|
||||||
|
nodeFs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
|
||||||
|
// Use tar to extract (available on Linux/macOS)
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
try {
|
||||||
|
await execFileAsync("tar", ["xzf", archivePath, "-C", extractDir], { timeout: 30000 });
|
||||||
|
} catch {
|
||||||
|
// Fallback: try with bsdtar (macOS Homebrew)
|
||||||
|
try {
|
||||||
|
await execFileAsync("bsdtar", ["xzf", archivePath, "-C", extractDir], { timeout: 30000 });
|
||||||
|
} catch {
|
||||||
|
throw badRequest("Failed to extract archive. Please ensure tar is installed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the extracted directory (GitHub archives extract to owner-repo-hash/)
|
||||||
|
const extractedEntries = nodeFs.readdirSync(extractDir);
|
||||||
|
if (extractedEntries.length === 0) {
|
||||||
|
throw badRequest("Archive extracted to empty directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive should have a single directory at the root
|
||||||
|
const extractedDir = join(extractDir, extractedEntries[0]);
|
||||||
|
if (!nodeFs.statSync(extractedDir).isDirectory()) {
|
||||||
|
throw badRequest("Archive did not extract to a directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the extracted company
|
||||||
|
pkg = parseCompanyDirectory(extractedDir);
|
||||||
|
|
||||||
|
// Override company info if available from API
|
||||||
|
if (companyInfo) {
|
||||||
|
pkg.company = {
|
||||||
|
name: companyInfo.name,
|
||||||
|
slug: importCompanySlug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
if (tempDir) {
|
||||||
|
try {
|
||||||
|
nodeFs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw badRequest("Provide one of: agents (array), source (path), or manifest (string)");
|
throw badRequest("Provide one of: agents (array), source (path), manifest (string), or importSource + companySlug");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { inputs, result } = convertAgentCompanies(pkg as any, conversionOptions);
|
const { inputs, result } = convertAgentCompanies(pkg as any, conversionOptions);
|
||||||
|
|||||||
Reference in New Issue
Block a user