import "./AgentImportModal.css"; import { useState, useRef, useCallback, useEffect } from "react"; import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search, RefreshCw } from "lucide-react"; import { fetchCompanies, type CompanyEntry } from "../api"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; export interface AgentImportModalProps { isOpen: boolean; onClose: () => void; onImported: () => void; projectId?: string; initialInputMethod?: InputMethod; } /** Parsed agent preview item for display before import */ interface AgentPreview { name: string; role: string; title?: string; icon?: string; reportsTo?: string; instructionsText?: string; skills?: string[]; } interface SkillPreview { name: string; description?: string; } /** Skill import result from the API */ interface SkillImportResult { imported: Array<{ name: string; path: string }>; skipped: string[]; errors: Array<{ name: string; error: string }>; } /** Import result from the API */ interface ImportResult { companyName?: string; companySlug?: string; created: Array<{ id: string; name: string }>; skipped: string[]; errors: Array<{ name: string; error: string }>; skills?: SkillImportResult; } interface DirectoryAgentInput { name: string; title?: string; icon?: string; role?: string; reportsTo?: string; skills?: string[]; instructionBody?: string; } /** API error response shape */ interface ApiErrorResponse { error: string; } type ModalStep = "input" | "preview" | "result"; 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]*))?$/); if (!match) { throw new Error("Missing YAML frontmatter delimiters (---)"); } const frontmatterLines = match[1].split(/\r?\n/); const body = match[2] ?? ""; const result: DirectoryAgentInput = { name: "" }; const skills: string[] = []; let inSkills = false; for (const rawLine of frontmatterLines) { const line = rawLine.trimEnd(); const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.startsWith("skills:")) { inSkills = true; continue; } if (inSkills && trimmed.startsWith("- ")) { skills.push(trimmed.slice(2).trim()); continue; } inSkills = false; const [key, ...valueParts] = trimmed.split(":"); const value = valueParts.join(":").trim(); const normalizedValue = value.replace(/^['"]|['"]$/g, ""); if (key === "name") result.name = normalizedValue; if (key === "title") result.title = normalizedValue; if (key === "icon") result.icon = normalizedValue; if (key === "role") result.role = normalizedValue; if (key === "reportsTo") result.reportsTo = normalizedValue; } if (!result.name) { throw new Error("Missing required field: name"); } if (skills.length > 0) { result.skills = skills; } if (body.trim().length > 0) { result.instructionBody = body; } return result; } /** * Modal for importing agents from Agent Companies manifests. * * Supports three input methods: * - File upload (.md/.txt files) * - Directory upload (webkitdirectory) * - Paste raw manifest content * * Flow: Input → Preview parsed agents → Import → Show results */ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initialInputMethod = "paste" }: AgentImportModalProps) { useMobileScrollLock(isOpen); const [step, setStep] = useState("input"); const [inputMethod, setInputMethod] = useState(initialInputMethod); const [manifestContent, setManifestContent] = useState(""); const [directoryAgents, setDirectoryAgents] = useState([]); const [companyName, setCompanyName] = useState("Unknown"); const [agents, setAgents] = useState([]); const [skills, setSkills] = useState([]); const [selectedAgentNames, setSelectedAgentNames] = useState([]); const [selectedSkillNames, setSelectedSkillNames] = useState([]); const [isParsing, setIsParsing] = useState(false); const [isImporting, setIsImporting] = useState(false); const [parseError, setParseError] = useState(null); const [importResult, setImportResult] = useState(null); const [importError, setImportError] = useState(null); const fileInputRef = useRef(null); const directoryInputRef = useRef(null); // Browse mode state const [companies, setCompanies] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [selectedCompany, setSelectedCompany] = useState(null); const [isLoadingCompanies, setIsLoadingCompanies] = useState(false); const [companiesError, setCompaniesError] = useState(null); // Track whether we've attempted to fetch to prevent infinite retry loops const fetchAttemptedRef = useRef(false); // Load companies when browse mode is selected useEffect(() => { if (inputMethod === "browse" && !fetchAttemptedRef.current && !isLoadingCompanies) { fetchAttemptedRef.current = true; setIsLoadingCompanies(true); setCompaniesError(null); fetchCompanies() .then((data) => { if (data.error) { setCompaniesError(data.error); } else if (data.companies.length > 0) { setCompanies(data.companies); } else { setCompaniesError("No companies available"); } }) .catch((err) => { setCompaniesError(err instanceof Error ? err.message : "Failed to load companies"); }) .finally(() => { setIsLoadingCompanies(false); }); } }, [inputMethod, isLoadingCompanies]); /** Retry fetching companies after an error - calls fetch directly to bypass useEffect */ const handleRetryFetchCompanies = useCallback(() => { fetchAttemptedRef.current = true; // Prevent useEffect from also firing setCompaniesError(null); setCompanies([]); setSelectedCompany(null); setIsLoadingCompanies(true); fetchCompanies() .then((data) => { if (data.error) { setCompaniesError(data.error); } else if (data.companies.length > 0) { setCompanies(data.companies); } else { setCompaniesError("No companies available"); } }) .catch((err) => { setCompaniesError(err instanceof Error ? err.message : "Failed to load companies"); }) .finally(() => { setIsLoadingCompanies(false); }); }, []); const reset = useCallback(() => { setStep("input"); setInputMethod(initialInputMethod); setManifestContent(""); setDirectoryAgents([]); setCompanyName("Unknown"); setAgents([]); setSkills([]); setSelectedAgentNames([]); setSelectedSkillNames([]); setIsParsing(false); setIsImporting(false); setParseError(null); setImportResult(null); setImportError(null); setCompanies([]); setSearchQuery(""); setSelectedCompany(null); setIsLoadingCompanies(false); setCompaniesError(null); fetchAttemptedRef.current = false; }, [initialInputMethod]); const handleClose = useCallback(() => { reset(); onClose(); }, [reset, onClose]); const handleFileChange = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const content = ev.target?.result as string; setInputMethod("file"); setDirectoryAgents([]); setManifestContent(content); setParseError(null); }; reader.onerror = () => { setParseError("Failed to read file"); }; reader.readAsText(file); // Reset file input so the same file can be re-selected e.target.value = ""; }, []); const handleDirectoryChange = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []); if (files.length === 0) return; try { const agentFiles = files .filter((file) => (file.webkitRelativePath || file.name).toLowerCase().endsWith("agents.md")) .sort((a, b) => { const aPath = a.webkitRelativePath || a.name; const bPath = b.webkitRelativePath || b.name; return aPath.localeCompare(bPath); }); if (agentFiles.length === 0) { setParseError("Selected directory has no AGENTS.md files"); return; } const parsedAgents: DirectoryAgentInput[] = []; for (const file of agentFiles) { const content = await file.text(); parsedAgents.push(parseDirectoryAgentManifest(content)); } setInputMethod("directory"); setDirectoryAgents(parsedAgents); setManifestContent(""); setParseError(null); } catch { setParseError("Failed to parse AGENTS.md files from selected directory"); } finally { e.target.value = ""; } }, []); /** Build the API URL with optional projectId */ function buildUrl(path: string): string { if (!projectId) return `/api${path}`; const separator = path.includes("?") ? "&" : "?"; return `/api${path}${separator}projectId=${encodeURIComponent(projectId)}`; } /** Parse the manifest content by calling the API with dryRun=true */ const handleParse = useCallback(async () => { if (inputMethod === "directory" && directoryAgents.length === 0) { setParseError("Please select a directory containing AGENTS.md files"); return; } 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; } setIsParsing(true); setParseError(null); try { let body: Record; 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json() as ApiErrorResponse; throw new Error(data.error ?? `Parse failed (${res.status})`); } const data = await res.json() as { companyName?: string; agents?: AgentPreview[]; skills?: SkillPreview[]; created: string[]; skipped: string[]; errors: Array<{ name: string; error: string }>; }; const previewAgents = (data.agents && data.agents.length > 0) ? data.agents : data.created.map((name) => ({ name, role: "custom" })); const previewSkills = Array.isArray(data.skills) ? data.skills : []; setCompanyName(data.companyName ?? "Unknown"); setAgents(previewAgents); setSkills(previewSkills); setSelectedAgentNames(previewAgents.map((agent) => agent.name)); setSelectedSkillNames(previewSkills.map((skill) => skill.name)); setStep("preview"); } catch (err) { setParseError(err instanceof Error ? err.message : "Failed to parse manifest"); } finally { setIsParsing(false); } }, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId]); /** Execute the actual import */ const handleImport = useCallback(async () => { setIsImporting(true); setImportError(null); try { let body: Record; if (inputMethod === "directory") { body = { agents: directoryAgents, skipExisting: true, selectedAgents: selectedAgentNames, selectedSkills: selectedSkillNames, }; } else if (inputMethod === "browse" && selectedCompany) { body = { importSource: "companies.sh", companySlug: selectedCompany.slug, skipExisting: true, selectedAgents: selectedAgentNames, selectedSkills: selectedSkillNames, }; } else { body = { manifest: manifestContent, skipExisting: true, selectedAgents: selectedAgentNames, selectedSkills: selectedSkillNames, }; } const res = await fetch(buildUrl("/agents/import"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json() as ApiErrorResponse; throw new Error(data.error ?? `Import failed (${res.status})`); } const data = await res.json() as ImportResult; setImportResult(data); setStep("result"); onImported(); } catch (err) { setImportError(err instanceof Error ? err.message : "Failed to import agents"); } finally { setIsImporting(false); } }, [ inputMethod, directoryAgents, manifestContent, selectedCompany, selectedAgentNames, selectedSkillNames, projectId, onImported, ]); const selectedAgentCount = selectedAgentNames.length; const selectedSkillCount = selectedSkillNames.length; const selectedAgentLabel = `${selectedAgentCount} Agent${selectedAgentCount !== 1 ? "s" : ""}`; const selectedSkillLabel = `${selectedSkillCount} Skill${selectedSkillCount !== 1 ? "s" : ""}`; const importActionLabel = selectedAgentCount > 0 && selectedSkillCount > 0 ? `${selectedAgentLabel} + ${selectedSkillLabel}` : selectedSkillCount > 0 ? selectedSkillLabel : selectedAgentLabel; const importLoadingLabel = selectedAgentCount > 0 && selectedSkillCount > 0 ? `Importing ${selectedAgentCount} agent${selectedAgentCount !== 1 ? "s" : ""} and ${selectedSkillCount} skill${selectedSkillCount !== 1 ? "s" : ""}...` : selectedSkillCount > 0 ? `Importing ${selectedSkillCount} skill${selectedSkillCount !== 1 ? "s" : ""}...` : `Importing ${selectedAgentCount} agent${selectedAgentCount !== 1 ? "s" : ""}...`; const toggleAgentSelection = (name: string) => { setSelectedAgentNames((current) => ( current.includes(name) ? current.filter((selectedName) => selectedName !== name) : [...current, name] )); }; const toggleSkillSelection = (name: string) => { setSelectedSkillNames((current) => ( current.includes(name) ? current.filter((selectedName) => selectedName !== name) : [...current, name] )); }; if (!isOpen) return null; return (
{ if (e.target === e.currentTarget) handleClose(); }}>
{/* Header */}
Import Agents
{/* Body */}
{/* Step 1: Input */} {step === "input" && (

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.

{/* File upload */}
.md and .txt files supported
{/* Browse Catalog Mode */} {inputMethod === "browse" && (
setSearchQuery(e.target.value)} aria-label="Search companies" />
{selectedCompany && (
Selected: {selectedCompany.name}
)}
{isLoadingCompanies && (
Loading companies...
)} {companiesError && (
{companiesError}
)} {!isLoadingCompanies && !companiesError && (
{companies .filter((company) => searchQuery === "" || company.name.toLowerCase().includes(searchQuery.toLowerCase()) || (company.tagline?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false) ) .map((company) => (
setSelectedCompany(company)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { setSelectedCompany(company); } }} >
{company.name} {company.installs !== undefined && ( {company.installs.toLocaleString()} installs )}
{company.tagline && ( {company.tagline} )} {company.repo && ( {company.repo} )}
))} {companies.filter((company) => searchQuery === "" || company.name.toLowerCase().includes(searchQuery.toLowerCase()) || (company.tagline?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false) ).length === 0 && (

{searchQuery ? "No companies match your search" : "No companies available"}

)}
)}
)} {/* Or divider - only show when not in browse mode */} {inputMethod !== "browse" && ( <>
or paste manifest content
{/* Text area for paste */}