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 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 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 */
|
||||
export interface AgentImportResult {
|
||||
companyName?: string;
|
||||
@@ -2512,12 +2522,29 @@ export interface AgentImportResult {
|
||||
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.
|
||||
* 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(
|
||||
input: { manifest: string } | { source: string } | { agents: unknown[] },
|
||||
input:
|
||||
| { manifest: string }
|
||||
| { source: string }
|
||||
| { agents: unknown[] }
|
||||
| { importSource: "companies.sh"; companySlug: string },
|
||||
options?: { dryRun?: boolean; skipExisting?: boolean },
|
||||
projectId?: string,
|
||||
): Promise<AgentImportResult> {
|
||||
|
||||
@@ -53,6 +53,12 @@ describe("AgentImportModal", () => {
|
||||
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 () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
||||
|
||||
@@ -169,4 +175,13 @@ describe("AgentImportModal", () => {
|
||||
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 { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen } from "lucide-react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search } from "lucide-react";
|
||||
import { fetchCompanies, type CompanyEntry } from "../api";
|
||||
|
||||
export interface AgentImportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -44,7 +45,7 @@ interface ApiErrorResponse {
|
||||
}
|
||||
|
||||
type ModalStep = "input" | "preview" | "result";
|
||||
type InputMethod = "paste" | "file" | "directory";
|
||||
type InputMethod = "paste" | "file" | "directory" | "browse";
|
||||
|
||||
function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
||||
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 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(() => {
|
||||
setStep("input");
|
||||
setInputMethod("paste");
|
||||
@@ -137,6 +163,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setParseError(null);
|
||||
setImportResult(null);
|
||||
setImportError(null);
|
||||
setCompanies([]);
|
||||
setSearchQuery("");
|
||||
setSelectedCompany(null);
|
||||
setIsLoadingCompanies(false);
|
||||
setCompaniesError(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -213,7 +244,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setParseError("Please select a directory containing AGENTS.md files");
|
||||
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");
|
||||
return;
|
||||
}
|
||||
@@ -222,9 +257,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setParseError(null);
|
||||
|
||||
try {
|
||||
const body = inputMethod === "directory"
|
||||
? { agents: directoryAgents, dryRun: true }
|
||||
: { manifest: manifestContent, dryRun: true };
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
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"), {
|
||||
method: "POST",
|
||||
@@ -257,7 +298,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
}, [inputMethod, directoryAgents, manifestContent, projectId]);
|
||||
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId]);
|
||||
|
||||
/** Execute the actual import */
|
||||
const handleImport = useCallback(async () => {
|
||||
@@ -265,9 +306,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setImportError(null);
|
||||
|
||||
try {
|
||||
const body = inputMethod === "directory"
|
||||
? { agents: directoryAgents, skipExisting: true }
|
||||
: { manifest: manifestContent, skipExisting: true };
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
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"), {
|
||||
method: "POST",
|
||||
@@ -289,7 +336,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [inputMethod, directoryAgents, manifestContent, projectId, onImported]);
|
||||
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId, onImported]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -310,7 +357,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
{step === "input" && (
|
||||
<div className="agent-import-input">
|
||||
<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>
|
||||
|
||||
{/* File upload */}
|
||||
@@ -349,28 +396,139 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
<FolderOpen size={16} />
|
||||
Select Directory
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Or divider */}
|
||||
<div className="agent-import-divider">
|
||||
<span>or paste manifest content</span>
|
||||
</div>
|
||||
{/* Browse Catalog Mode */}
|
||||
{inputMethod === "browse" && (
|
||||
<div className="agent-import-browse">
|
||||
<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 */}
|
||||
<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"
|
||||
/>
|
||||
{isLoadingCompanies && (
|
||||
<div className="agent-import-browse-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>Loading companies...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companiesError && (
|
||||
<div className="agent-import-browse-error">
|
||||
<AlertTriangle size={16} />
|
||||
<span>{companiesError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
|
||||
@@ -506,7 +664,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
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 ? (
|
||||
<>
|
||||
|
||||
@@ -25264,6 +25264,163 @@ html .column.drag-over * {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -27042,6 +27199,10 @@ html .column.drag-over * {
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-import-browse-list {
|
||||
max-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -243,4 +243,65 @@ describe("POST /api/agents/import", () => {
|
||||
expect(response.status).toBe(400);
|
||||
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
|
||||
* Import agents from Agent Companies sources.
|
||||
*
|
||||
* Body modes (checked in order):
|
||||
* - { importSource: "companies.sh", companySlug: string, skipExisting?, dryRun? }
|
||||
* - { agents: AgentManifest[], skipExisting?, dryRun? }
|
||||
* - { source: string, skipExisting?, dryRun? } // server directory path
|
||||
* - { manifest: string, skipExisting?, dryRun? } // raw AGENTS.md content
|
||||
*/
|
||||
router.post("/agents/import", async (req, res) => {
|
||||
try {
|
||||
const { agents, source, manifest, skipExisting, dryRun } = req.body ?? {};
|
||||
const { agents, source, manifest, importSource, companySlug: importCompanySlug, skipExisting, dryRun } = req.body ?? {};
|
||||
const {
|
||||
AgentStore,
|
||||
parseCompanyDirectory,
|
||||
@@ -9381,8 +9503,176 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
projects: [],
|
||||
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 {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user