import figures from 'figures'; import * as React from 'react'; import { useEffect, useState } from 'react'; import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; import { Byline } from '../../components/design-system/Byline.js'; import { Box, Text } from '../../ink.js'; import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; import type { LoadedPlugin } from '../../types/plugin.js'; import { count } from '../../utils/array.js'; import { openBrowser } from '../../utils/browser.js'; import { logForDebugging } from '../../utils/debug.js'; import { errorMessage } from '../../utils/errors.js'; import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; import { formatInstallCount, getInstallCounts } from '../../utils/plugins/installCounts.js'; import { isPluginGloballyInstalled, isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js'; import { createPluginId, formatFailureDetails, formatMarketplaceLoadingErrors, getMarketplaceSourceDisplay, loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js'; import { getMarketplace, loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js'; import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'; import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js'; import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'; import { plural } from '../../utils/stringUtils.js'; import { truncateToWidth } from '../../utils/truncate.js'; import { findPluginOptionsTarget, PluginOptionsFlow } from './PluginOptionsFlow.js'; import { PluginTrustWarning } from './PluginTrustWarning.js'; import { buildPluginDetailsMenuOptions, extractGitHubRepo, type InstallablePlugin, PluginSelectionKeyHint } from './pluginDetailsHelpers.js'; import type { ViewState as ParentViewState } from './types.js'; import { usePagination } from './usePagination.js'; type Props = { error: string | null; setError: (error: string | null) => void; result: string | null; setResult: (result: string | null) => void; setViewState: (state: ParentViewState) => void; onInstallComplete?: () => void | Promise; targetMarketplace?: string; targetPlugin?: string; }; type ViewState = 'marketplace-list' | 'plugin-list' | 'plugin-details' | { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: string; }; type MarketplaceInfo = { name: string; totalPlugins: number; installedCount: number; source?: string; }; export function BrowseMarketplace({ error, setError, result: _result, setResult, setViewState: setParentViewState, onInstallComplete, targetMarketplace, targetPlugin }: Props): React.ReactNode { // View state const [viewState, setViewState] = useState('marketplace-list'); const [selectedMarketplace, setSelectedMarketplace] = useState(null); const [selectedPlugin, setSelectedPlugin] = useState(null); // Data state const [marketplaces, setMarketplaces] = useState([]); const [availablePlugins, setAvailablePlugins] = useState([]); const [loading, setLoading] = useState(true); const [installCounts, setInstallCounts] = useState | null>(null); // Selection state const [selectedIndex, setSelectedIndex] = useState(0); const [selectedForInstall, setSelectedForInstall] = useState>(new Set()); const [installingPlugins, setInstallingPlugins] = useState>(new Set()); // Pagination for plugin list (continuous scrolling) const pagination = usePagination({ totalItems: availablePlugins.length, selectedIndex }); // Details view state const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); const [isInstalling, setIsInstalling] = useState(false); const [installError, setInstallError] = useState(null); // Warning state for non-critical errors (e.g., some marketplaces failed to load) const [warning, setWarning] = useState(null); // Handle escape to go back - viewState-dependent navigation const handleBack = React.useCallback(() => { if (viewState === 'plugin-list') { // If navigated directly to a specific marketplace via targetMarketplace, // go back to manage-marketplaces showing that marketplace's details if (targetMarketplace) { setParentViewState({ type: 'manage-marketplaces', targetMarketplace }); } else if (marketplaces.length === 1) { // If there's only one marketplace, skip the marketplace-list view // since we auto-navigated past it on load setParentViewState({ type: 'menu' }); } else { setViewState('marketplace-list'); setSelectedMarketplace(null); setSelectedForInstall(new Set()); } } else if (viewState === 'plugin-details') { setViewState('plugin-list'); setSelectedPlugin(null); } else { // At root level (marketplace-list), exit the plugin menu setParentViewState({ type: 'menu' }); } }, [viewState, targetMarketplace, setParentViewState, marketplaces.length]); useKeybinding('confirm:no', handleBack, { context: 'Confirmation' }); // Load marketplaces and count installed plugins useEffect(() => { async function loadMarketplaceData() { try { const config = await loadKnownMarketplacesConfig(); // Load marketplaces with graceful degradation const { marketplaces: marketplaces_0, failures } = await loadMarketplacesWithGracefulDegradation(config); const marketplaceInfos: MarketplaceInfo[] = []; for (const { name, config: marketplaceConfig, data: marketplace } of marketplaces_0) { if (marketplace) { // Count how many plugins from this marketplace are installed const installedFromThisMarketplace = count(marketplace.plugins, plugin => isPluginInstalled(createPluginId(plugin.name, name))); marketplaceInfos.push({ name, totalPlugins: marketplace.plugins.length, installedCount: installedFromThisMarketplace, source: getMarketplaceSourceDisplay(marketplaceConfig.source) }); } } // Sort so claude-plugin-directory is always first marketplaceInfos.sort((a, b) => { if (a.name === 'claude-plugin-directory') return -1; if (b.name === 'claude-plugin-directory') return 1; return 0; }); setMarketplaces(marketplaceInfos); // Handle marketplace loading errors/warnings const successCount = count(marketplaces_0, m => m.data !== null); const errorResult = formatMarketplaceLoadingErrors(failures, successCount); if (errorResult) { if (errorResult.type === 'warning') { setWarning(errorResult.message + '. Showing available marketplaces.'); } else { throw new Error(errorResult.message); } } // Skip marketplace selection if there's only one marketplace if (marketplaceInfos.length === 1 && !targetMarketplace && !targetPlugin) { const singleMarketplace = marketplaceInfos[0]; if (singleMarketplace) { setSelectedMarketplace(singleMarketplace.name); setViewState('plugin-list'); } } // Handle targetMarketplace and targetPlugin after marketplaces are loaded if (targetPlugin) { // Search for the plugin across all marketplaces let foundPlugin: InstallablePlugin | null = null; let foundMarketplace: string | null = null; for (const [name_0] of Object.entries(config)) { const marketplace_0 = await getMarketplace(name_0); if (marketplace_0) { const plugin_0 = marketplace_0.plugins.find(p => p.name === targetPlugin); if (plugin_0) { const pluginId = createPluginId(plugin_0.name, name_0); foundPlugin = { entry: plugin_0, marketplaceName: name_0, pluginId, // isPluginGloballyInstalled: only block when user/managed scope // exists (nothing to add). Project/local-scope installs don't // block — user may want to promote to user scope (gh-29997). isInstalled: isPluginGloballyInstalled(pluginId) }; foundMarketplace = name_0; break; } } } if (foundPlugin && foundMarketplace) { // Block only on global (user/managed) install — project/local scope // means the user might still want to add a user-scope entry so the // plugin is available in other projects (gh-29997, gh-29240, gh-29392). // The plugin-details view offers all three scope options; the backend // (installPluginOp → addInstalledPlugin) already supports multiple // scope entries per plugin. const pluginId_0 = foundPlugin.pluginId; const globallyInstalled = isPluginGloballyInstalled(pluginId_0); if (globallyInstalled) { setError(`Plugin '${pluginId_0}' is already installed globally. Use '/plugin' to manage existing plugins.`); } else { // Navigate to the plugin details view setSelectedMarketplace(foundMarketplace); setSelectedPlugin(foundPlugin); setViewState('plugin-details'); } } else { setError(`Plugin "${targetPlugin}" not found in any marketplace`); } } else if (targetMarketplace) { // Navigate directly to the specified marketplace const marketplaceExists = marketplaceInfos.some(m_0 => m_0.name === targetMarketplace); if (marketplaceExists) { setSelectedMarketplace(targetMarketplace); setViewState('plugin-list'); } else { setError(`Marketplace "${targetMarketplace}" not found`); } } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load marketplaces'); } finally { setLoading(false); } } void loadMarketplaceData(); }, [setError, targetMarketplace, targetPlugin]); // Load plugins when a marketplace is selected useEffect(() => { if (!selectedMarketplace) return; let cancelled = false; async function loadPluginsForMarketplace(marketplaceName: string) { setLoading(true); try { const marketplace_1 = await getMarketplace(marketplaceName); if (cancelled) return; if (!marketplace_1) { throw new Error(`Failed to load marketplace: ${marketplaceName}`); } // Filter out already installed plugins const installablePlugins: InstallablePlugin[] = []; for (const entry of marketplace_1.plugins) { const pluginId_1 = createPluginId(entry.name, marketplaceName); if (isPluginBlockedByPolicy(pluginId_1)) continue; installablePlugins.push({ entry, marketplaceName: marketplaceName, pluginId: pluginId_1, // Only mark as "installed" when globally scoped (user/managed). // Project/local installs don't block — user can add user scope // via the plugin-details view (gh-29997). isInstalled: isPluginGloballyInstalled(pluginId_1) }); } // Fetch install counts and sort by popularity try { const counts = await getInstallCounts(); if (cancelled) return; setInstallCounts(counts); if (counts) { // Sort by install count (descending), then alphabetically installablePlugins.sort((a_1, b_1) => { const countA = counts.get(a_1.pluginId) ?? 0; const countB = counts.get(b_1.pluginId) ?? 0; if (countA !== countB) return countB - countA; return a_1.entry.name.localeCompare(b_1.entry.name); }); } else { // No counts available - sort alphabetically installablePlugins.sort((a_2, b_2) => a_2.entry.name.localeCompare(b_2.entry.name)); } } catch (error_0) { if (cancelled) return; // Log the error, then gracefully degrade to alphabetical sort logForDebugging(`Failed to fetch install counts: ${errorMessage(error_0)}`); installablePlugins.sort((a_0, b_0) => a_0.entry.name.localeCompare(b_0.entry.name)); } setAvailablePlugins(installablePlugins); setSelectedIndex(0); setSelectedForInstall(new Set()); } catch (err_0) { if (cancelled) return; setError(err_0 instanceof Error ? err_0.message : 'Failed to load plugins'); } finally { setLoading(false); } } void loadPluginsForMarketplace(selectedMarketplace); return () => { cancelled = true; }; }, [selectedMarketplace, setError]); // Install selected plugins const installSelectedPlugins = async () => { if (selectedForInstall.size === 0) return; const pluginsToInstall = availablePlugins.filter(p_0 => selectedForInstall.has(p_0.pluginId)); setInstallingPlugins(new Set(pluginsToInstall.map(p_1 => p_1.pluginId))); let successCount_0 = 0; let failureCount = 0; const newFailedPlugins: Array<{ name: string; reason: string; }> = []; for (const plugin_1 of pluginsToInstall) { const result = await installPluginFromMarketplace({ pluginId: plugin_1.pluginId, entry: plugin_1.entry, marketplaceName: plugin_1.marketplaceName, scope: 'user' }); if (result.success) { successCount_0++; } else { failureCount++; newFailedPlugins.push({ name: plugin_1.entry.name, reason: result.error }); } } setInstallingPlugins(new Set()); setSelectedForInstall(new Set()); clearAllCaches(); // Handle installation results if (failureCount === 0) { // All succeeded const message = `✓ Installed ${successCount_0} ${plural(successCount_0, 'plugin')}. ` + `Run /reload-plugins to activate.`; setResult(message); } else if (successCount_0 === 0) { // All failed - show error with reasons setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`); } else { // Mixed results - show partial success const message_0 = `✓ Installed ${successCount_0} of ${successCount_0 + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + `Run /reload-plugins to activate successfully installed plugins.`; setResult(message_0); } // Handle completion callback and navigation if (successCount_0 > 0) { if (onInstallComplete) { await onInstallComplete(); } } setParentViewState({ type: 'menu' }); }; // Install single plugin from details view const handleSinglePluginInstall = async (plugin_2: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => { setIsInstalling(true); setInstallError(null); const result_0 = await installPluginFromMarketplace({ pluginId: plugin_2.pluginId, entry: plugin_2.entry, marketplaceName: plugin_2.marketplaceName, scope }); if (result_0.success) { const loaded = await findPluginOptionsTarget(plugin_2.pluginId); if (loaded) { setIsInstalling(false); setViewState({ type: 'plugin-options', plugin: loaded, pluginId: plugin_2.pluginId }); return; } setResult(result_0.message); if (onInstallComplete) { await onInstallComplete(); } setParentViewState({ type: 'menu' }); } else { setIsInstalling(false); setInstallError(result_0.error); } }; // Handle error state useEffect(() => { if (error) { setResult(error); } }, [error, setResult]); // Marketplace-list navigation useKeybindings({ 'select:previous': () => { if (selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); } }, 'select:next': () => { if (selectedIndex < marketplaces.length - 1) { setSelectedIndex(selectedIndex + 1); } }, 'select:accept': () => { const marketplace_2 = marketplaces[selectedIndex]; if (marketplace_2) { setSelectedMarketplace(marketplace_2.name); setViewState('plugin-list'); } } }, { context: 'Select', isActive: viewState === 'marketplace-list' }); // Plugin-list navigation useKeybindings({ 'select:previous': () => { if (selectedIndex > 0) { pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex); } }, 'select:next': () => { if (selectedIndex < availablePlugins.length - 1) { pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex); } }, 'select:accept': () => { if (selectedIndex === availablePlugins.length && selectedForInstall.size > 0) { void installSelectedPlugins(); } else if (selectedIndex < availablePlugins.length) { const plugin_3 = availablePlugins[selectedIndex]; if (plugin_3) { if (plugin_3.isInstalled) { setParentViewState({ type: 'manage-plugins', targetPlugin: plugin_3.entry.name, targetMarketplace: plugin_3.marketplaceName }); } else { setSelectedPlugin(plugin_3); setViewState('plugin-details'); setDetailsMenuIndex(0); setInstallError(null); } } } } }, { context: 'Select', isActive: viewState === 'plugin-list' }); useKeybindings({ 'plugin:toggle': () => { if (selectedIndex < availablePlugins.length) { const plugin_4 = availablePlugins[selectedIndex]; if (plugin_4 && !plugin_4.isInstalled) { const newSelection = new Set(selectedForInstall); if (newSelection.has(plugin_4.pluginId)) { newSelection.delete(plugin_4.pluginId); } else { newSelection.add(plugin_4.pluginId); } setSelectedForInstall(newSelection); } } }, 'plugin:install': () => { if (selectedForInstall.size > 0) { void installSelectedPlugins(); } } }, { context: 'Plugin', isActive: viewState === 'plugin-list' }); // Plugin-details navigation const detailsMenuOptions = React.useMemo(() => { if (!selectedPlugin) return []; const hasHomepage = selectedPlugin.entry.homepage; const githubRepo = extractGitHubRepo(selectedPlugin); return buildPluginDetailsMenuOptions(hasHomepage, githubRepo); }, [selectedPlugin]); useKeybindings({ 'select:previous': () => { if (detailsMenuIndex > 0) { setDetailsMenuIndex(detailsMenuIndex - 1); } }, 'select:next': () => { if (detailsMenuIndex < detailsMenuOptions.length - 1) { setDetailsMenuIndex(detailsMenuIndex + 1); } }, 'select:accept': () => { if (!selectedPlugin) return; const action = detailsMenuOptions[detailsMenuIndex]?.action; const hasHomepage_0 = selectedPlugin.entry.homepage; const githubRepo_0 = extractGitHubRepo(selectedPlugin); if (action === 'install-user') { void handleSinglePluginInstall(selectedPlugin, 'user'); } else if (action === 'install-project') { void handleSinglePluginInstall(selectedPlugin, 'project'); } else if (action === 'install-local') { void handleSinglePluginInstall(selectedPlugin, 'local'); } else if (action === 'homepage' && hasHomepage_0) { void openBrowser(hasHomepage_0); } else if (action === 'github' && githubRepo_0) { void openBrowser(`https://github.com/${githubRepo_0}`); } else if (action === 'back') { setViewState('plugin-list'); setSelectedPlugin(null); } } }, { context: 'Select', isActive: viewState === 'plugin-details' && !!selectedPlugin }); if (typeof viewState === 'object' && viewState.type === 'plugin-options') { const { plugin: plugin_5, pluginId: pluginId_2 } = viewState; function finish(msg: string): void { setResult(msg); if (onInstallComplete) { void onInstallComplete(); } setParentViewState({ type: 'menu' }); } return { switch (outcome) { case 'configured': finish(`✓ Installed and configured ${plugin_5.name}. Run /reload-plugins to apply.`); break; case 'skipped': finish(`✓ Installed ${plugin_5.name}. Run /reload-plugins to apply.`); break; case 'error': finish(`Installed but failed to save config: ${detail}`); break; } }} />; } // Loading state if (loading) { return Loading…; } // Error state if (error) { return {error}; } // Marketplace selection view if (viewState === 'marketplace-list') { if (marketplaces.length === 0) { return Select marketplace No marketplaces configured. Add a marketplace first using {"'Add marketplace'"}. ; } return Select marketplace {/* Warning banner for marketplace load failures */} {warning && {figures.warning} {warning} } {marketplaces.map((marketplace_3, index) => {selectedIndex === index ? figures.pointer : ' '}{' '} {marketplace_3.name} {marketplace_3.totalPlugins}{' '} {plural(marketplace_3.totalPlugins, 'plugin')} available {marketplace_3.installedCount > 0 && ` · ${marketplace_3.installedCount} already installed`} {marketplace_3.source && ` · ${marketplace_3.source}`} )} ; } // Plugin details view if (viewState === 'plugin-details' && selectedPlugin) { const hasHomepage_1 = selectedPlugin.entry.homepage; const githubRepo_1 = extractGitHubRepo(selectedPlugin); const menuOptions = buildPluginDetailsMenuOptions(hasHomepage_1, githubRepo_1); return Plugin Details {/* Plugin metadata */} {selectedPlugin.entry.name} {selectedPlugin.entry.version && Version: {selectedPlugin.entry.version}} {selectedPlugin.entry.description && {selectedPlugin.entry.description} } {selectedPlugin.entry.author && By:{' '} {typeof selectedPlugin.entry.author === 'string' ? selectedPlugin.entry.author : selectedPlugin.entry.author.name} } {/* What will be installed */} Will install: {selectedPlugin.entry.commands && · Commands:{' '} {Array.isArray(selectedPlugin.entry.commands) ? selectedPlugin.entry.commands.join(', ') : Object.keys(selectedPlugin.entry.commands).join(', ')} } {selectedPlugin.entry.agents && · Agents:{' '} {Array.isArray(selectedPlugin.entry.agents) ? selectedPlugin.entry.agents.join(', ') : Object.keys(selectedPlugin.entry.agents).join(', ')} } {selectedPlugin.entry.hooks && · Hooks: {Object.keys(selectedPlugin.entry.hooks).join(', ')} } {selectedPlugin.entry.mcpServers && · MCP Servers:{' '} {Array.isArray(selectedPlugin.entry.mcpServers) ? selectedPlugin.entry.mcpServers.join(', ') : typeof selectedPlugin.entry.mcpServers === 'object' ? Object.keys(selectedPlugin.entry.mcpServers).join(', ') : 'configured'} } {!selectedPlugin.entry.commands && !selectedPlugin.entry.agents && !selectedPlugin.entry.hooks && !selectedPlugin.entry.mcpServers && <> {typeof selectedPlugin.entry.source === 'object' && 'source' in selectedPlugin.entry.source && (selectedPlugin.entry.source.source === 'github' || selectedPlugin.entry.source.source === 'url' || selectedPlugin.entry.source.source === 'npm' || selectedPlugin.entry.source.source === 'pip') ? · Component summary not available for remote plugin : // TODO: Actually scan local plugin directories to show real components // This would require accessing the filesystem to check for: // - commands/ directory and list files // - agents/ directory and list files // - hooks/ directory and list files // - .mcp.json or mcp-servers.json files · Components will be discovered at installation } } {/* Error message */} {installError && Error: {installError} } {/* Menu options */} {menuOptions.map((option, index_0) => {detailsMenuIndex === index_0 && {'> '}} {detailsMenuIndex !== index_0 && {' '}} {isInstalling && option.action === 'install' ? 'Installing…' : option.label} )} ; } // Plugin installation view if (availablePlugins.length === 0) { return Install plugins No new plugins available to install. All plugins from this marketplace are already installed. ; } // Get visible plugins from pagination const visiblePlugins = pagination.getVisibleItems(availablePlugins); return Install Plugins {/* Scroll up indicator */} {pagination.scrollPosition.canScrollUp && {figures.arrowUp} more above } {/* Plugin list */} {visiblePlugins.map((plugin_6, visibleIndex) => { const actualIndex = pagination.toActualIndex(visibleIndex); const isSelected = selectedIndex === actualIndex; const isSelectedForInstall = selectedForInstall.has(plugin_6.pluginId); const isInstalling_0 = installingPlugins.has(plugin_6.pluginId); const isLast = visibleIndex === visiblePlugins.length - 1; return {isSelected ? figures.pointer : ' '}{' '} {plugin_6.isInstalled ? figures.tick : isInstalling_0 ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '} {plugin_6.entry.name} {plugin_6.entry.category && [{plugin_6.entry.category}]} {plugin_6.entry.tags?.includes('community-managed') && [Community Managed]} {plugin_6.isInstalled && (installed)} {installCounts && selectedMarketplace === OFFICIAL_MARKETPLACE_NAME && {' · '} {formatInstallCount(installCounts.get(plugin_6.pluginId) ?? 0)}{' '} installs } {plugin_6.entry.description && {truncateToWidth(plugin_6.entry.description, 60)} {plugin_6.entry.version && · v{plugin_6.entry.version}} } ; })} {/* Scroll down indicator */} {pagination.scrollPosition.canScrollDown && {figures.arrowDown} more below } {/* Error messages shown in the UI */} {error && {figures.cross} {error} } 0} /> ; }