import figures from 'figures' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' import { Byline } from '../../components/design-system/Byline.js' import { SearchBox } from '../../components/SearchBox.js' import { useSearchInput } from '../../hooks/useSearchInput.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for raw search mode text input import { Box, Text, useInput, useTerminalFocus } 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 } from '../../utils/plugins/installedPluginsManager.js' import { createPluginId, detectEmptyMarketplaceReason, type EmptyMarketplaceReason, formatFailureDetails, formatMarketplaceLoadingErrors, loadMarketplacesWithGracefulDegradation, } from '../../utils/plugins/marketplaceHelpers.js' import { 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, } 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 onSearchModeChange?: (isActive: boolean) => void targetPlugin?: string } type ViewState = | 'plugin-list' | 'plugin-details' | { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: string } export function DiscoverPlugins({ error, setError, result: _result, setResult, setViewState: setParentViewState, onInstallComplete, onSearchModeChange, targetPlugin, }: Props): React.ReactNode { // View state const [viewState, setViewState] = useState('plugin-list') const [selectedPlugin, setSelectedPlugin] = useState(null) // Data state const [availablePlugins, setAvailablePlugins] = useState( [], ) const [loading, setLoading] = useState(true) const [installCounts, setInstallCounts] = useState | null>(null) // Search state const [isSearchMode, setIsSearchModeRaw] = useState(false) const setIsSearchMode = useCallback( (active: boolean) => { setIsSearchModeRaw(active) onSearchModeChange?.(active) }, [onSearchModeChange], ) const { query: searchQuery, setQuery: setSearchQuery, cursorOffset: searchCursorOffset, } = useSearchInput({ isActive: viewState === 'plugin-list' && isSearchMode && !loading, onExit: () => { setIsSearchMode(false) }, }) const isTerminalFocused = useTerminalFocus() const { columns: terminalWidth } = useTerminalSize() // Filter plugins based on search query const filteredPlugins = useMemo(() => { if (!searchQuery) return availablePlugins const lowerQuery = searchQuery.toLowerCase() return availablePlugins.filter( plugin => plugin.entry.name.toLowerCase().includes(lowerQuery) || plugin.entry.description?.toLowerCase().includes(lowerQuery) || plugin.marketplaceName.toLowerCase().includes(lowerQuery), ) }, [availablePlugins, searchQuery]) // 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: filteredPlugins.length, selectedIndex, }) // Reset selection when search query changes useEffect(() => { setSelectedIndex(0) }, [searchQuery]) // Details view state const [detailsMenuIndex, setDetailsMenuIndex] = useState(0) const [isInstalling, setIsInstalling] = useState(false) const [installError, setInstallError] = useState(null) // Warning state for non-critical errors const [warning, setWarning] = useState(null) // Empty state reason const [emptyReason, setEmptyReason] = useState( null, ) // Load all plugins from all marketplaces useEffect(() => { async function loadAllPlugins() { try { const config = await loadKnownMarketplacesConfig() // Load marketplaces with graceful degradation const { marketplaces, failures } = await loadMarketplacesWithGracefulDegradation(config) // Collect all plugins from all marketplaces const allPlugins: InstallablePlugin[] = [] for (const { name, data: marketplace } of marketplaces) { if (marketplace) { for (const entry of marketplace.plugins) { const pluginId = createPluginId(entry.name, name) allPlugins.push({ entry, marketplaceName: name, pluginId, // Only block when globally installed (user/managed scope). // Project/local-scope installs don't block — user may want to // promote to user scope so it's available everywhere (gh-29997). isInstalled: isPluginGloballyInstalled(pluginId), }) } } } // Filter out installed and policy-blocked plugins const uninstalledPlugins = allPlugins.filter( p => !p.isInstalled && !isPluginBlockedByPolicy(p.pluginId), ) // Fetch install counts and sort by popularity try { const counts = await getInstallCounts() setInstallCounts(counts) if (counts) { // Sort by install count (descending), then alphabetically uninstalledPlugins.sort((a, b) => { const countA = counts.get(a.pluginId) ?? 0 const countB = counts.get(b.pluginId) ?? 0 if (countA !== countB) return countB - countA return a.entry.name.localeCompare(b.entry.name) }) } else { // No counts available - sort alphabetically uninstalledPlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name), ) } } catch (error) { // Log the error, then gracefully degrade to alphabetical sort logForDebugging( `Failed to fetch install counts: ${errorMessage(error)}`, ) uninstalledPlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name), ) } setAvailablePlugins(uninstalledPlugins) // Detect empty reason if no plugins available const configuredCount = Object.keys(config).length if (uninstalledPlugins.length === 0) { const reason = await detectEmptyMarketplaceReason({ configuredMarketplaceCount: configuredCount, failedMarketplaceCount: failures.length, }) setEmptyReason(reason) } // Handle marketplace loading errors/warnings const successCount = count(marketplaces, m => m.data !== null) const errorResult = formatMarketplaceLoadingErrors( failures, successCount, ) if (errorResult) { if (errorResult.type === 'warning') { setWarning(errorResult.message + '. Showing available plugins.') } else { throw new Error(errorResult.message) } } // Handle targetPlugin - navigate directly to plugin details // Search in allPlugins (before filtering) to handle installed plugins gracefully if (targetPlugin) { const foundPlugin = allPlugins.find( p => p.entry.name === targetPlugin, ) if (foundPlugin) { if (foundPlugin.isInstalled) { setError( `Plugin '${foundPlugin.pluginId}' is already installed. Use '/plugin' to manage existing plugins.`, ) } else { setSelectedPlugin(foundPlugin) setViewState('plugin-details') } } else { setError(`Plugin "${targetPlugin}" not found in any marketplace`) } } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load plugins') } finally { setLoading(false) } } void loadAllPlugins() }, [setError, targetPlugin]) // Install selected plugins const installSelectedPlugins = async () => { if (selectedForInstall.size === 0) return const pluginsToInstall = availablePlugins.filter(p => selectedForInstall.has(p.pluginId), ) setInstallingPlugins(new Set(pluginsToInstall.map(p => p.pluginId))) let successCount = 0 let failureCount = 0 const newFailedPlugins: Array<{ name: string; reason: string }> = [] for (const plugin of pluginsToInstall) { const result = await installPluginFromMarketplace({ pluginId: plugin.pluginId, entry: plugin.entry, marketplaceName: plugin.marketplaceName, scope: 'user', }) if (result.success) { successCount++ } else { failureCount++ newFailedPlugins.push({ name: plugin.entry.name, reason: result.error, }) } } setInstallingPlugins(new Set()) setSelectedForInstall(new Set()) clearAllCaches() // Handle installation results if (failureCount === 0) { const message = `✓ Installed ${successCount} ${plural(successCount, 'plugin')}. ` + `Run /reload-plugins to activate.` setResult(message) } else if (successCount === 0) { setError( `Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`, ) } else { const message = `✓ Installed ${successCount} of ${successCount + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + `Run /reload-plugins to activate successfully installed plugins.` setResult(message) } if (successCount > 0) { if (onInstallComplete) { await onInstallComplete() } } setParentViewState({ type: 'menu' }) } // Install single plugin from details view const handleSinglePluginInstall = async ( plugin: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user', ) => { setIsInstalling(true) setInstallError(null) const result = await installPluginFromMarketplace({ pluginId: plugin.pluginId, entry: plugin.entry, marketplaceName: plugin.marketplaceName, scope, }) if (result.success) { const loaded = await findPluginOptionsTarget(plugin.pluginId) if (loaded) { setIsInstalling(false) setViewState({ type: 'plugin-options', plugin: loaded, pluginId: plugin.pluginId, }) return } setResult(result.message) if (onInstallComplete) { await onInstallComplete() } setParentViewState({ type: 'menu' }) } else { setIsInstalling(false) setInstallError(result.error) } } // Handle error state useEffect(() => { if (error) { setResult(error) } }, [error, setResult]) // Escape in plugin-details view - go back to plugin-list useKeybinding( 'confirm:no', () => { setViewState('plugin-list') setSelectedPlugin(null) }, { context: 'Confirmation', isActive: viewState === 'plugin-details', }, ) // Escape in plugin-list view (not search mode) - exit to parent menu useKeybinding( 'confirm:no', () => { setParentViewState({ type: 'menu' }) }, { context: 'Confirmation', isActive: viewState === 'plugin-list' && !isSearchMode, }, ) // Handle entering search mode (non-escape keys) useInput( (input, _key) => { const keyIsNotCtrlOrMeta = !_key.ctrl && !_key.meta if (!isSearchMode) { // Enter search mode with '/' or any printable character if (input === '/' && keyIsNotCtrlOrMeta) { setIsSearchMode(true) setSearchQuery('') } else if ( keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input) && // Don't enter search mode for navigation keys input !== 'j' && input !== 'k' && input !== 'i' ) { setIsSearchMode(true) setSearchQuery(input) } } }, { isActive: viewState === 'plugin-list' && !loading }, ) // Plugin-list navigation (non-search mode) useKeybindings( { 'select:previous': () => { if (selectedIndex === 0) { setIsSearchMode(true) } else { pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex) } }, 'select:next': () => { if (selectedIndex < filteredPlugins.length - 1) { pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex) } }, 'select:accept': () => { if ( selectedIndex === filteredPlugins.length && selectedForInstall.size > 0 ) { void installSelectedPlugins() } else if (selectedIndex < filteredPlugins.length) { const plugin = filteredPlugins[selectedIndex] if (plugin) { if (plugin.isInstalled) { setParentViewState({ type: 'manage-plugins', targetPlugin: plugin.entry.name, targetMarketplace: plugin.marketplaceName, }) } else { setSelectedPlugin(plugin) setViewState('plugin-details') setDetailsMenuIndex(0) setInstallError(null) } } } }, }, { context: 'Select', isActive: viewState === 'plugin-list' && !isSearchMode, }, ) useKeybindings( { 'plugin:toggle': () => { if (selectedIndex < filteredPlugins.length) { const plugin = filteredPlugins[selectedIndex] if (plugin && !plugin.isInstalled) { const newSelection = new Set(selectedForInstall) if (newSelection.has(plugin.pluginId)) { newSelection.delete(plugin.pluginId) } else { newSelection.add(plugin.pluginId) } setSelectedForInstall(newSelection) } } }, 'plugin:install': () => { if (selectedForInstall.size > 0) { void installSelectedPlugins() } }, }, { context: 'Plugin', isActive: viewState === 'plugin-list' && !isSearchMode, }, ) // 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 = selectedPlugin.entry.homepage const githubRepo = 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) { void openBrowser(hasHomepage) } else if (action === 'github' && githubRepo) { void openBrowser(`https://github.com/${githubRepo}`) } 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, pluginId } = 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.name}. Run /reload-plugins to apply.`, ) break case 'skipped': finish( `✓ Installed ${plugin.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} } // Plugin details view if (viewState === 'plugin-details' && selectedPlugin) { const hasHomepage = selectedPlugin.entry.homepage const githubRepo = extractGitHubRepo(selectedPlugin) const menuOptions = buildPluginDetailsMenuOptions(hasHomepage, githubRepo) return ( Plugin details {selectedPlugin.entry.name} from {selectedPlugin.marketplaceName} {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} )} {installError && ( Error: {installError} )} {menuOptions.map((option, index) => ( {detailsMenuIndex === index && {'> '}} {detailsMenuIndex !== index && {' '}} {isInstalling && option.action.startsWith('install-') ? 'Installing…' : option.label} ))} ) } // Empty state if (availablePlugins.length === 0) { return ( Discover plugins Esc to go back ) } // Get visible plugins from pagination const visiblePlugins = pagination.getVisibleItems(filteredPlugins) return ( Discover plugins {pagination.needsPagination && ( {' '} ({pagination.scrollPosition.current}/ {pagination.scrollPosition.total}) )} {/* Search box */} {/* Warning banner */} {warning && ( {figures.warning} {warning} )} {/* No search results */} {filteredPlugins.length === 0 && searchQuery && ( No plugins match "{searchQuery}" )} {/* Scroll up indicator */} {pagination.scrollPosition.canScrollUp && ( {figures.arrowUp} more above )} {/* Plugin list - use startIndex in key to force re-render on scroll */} {visiblePlugins.map((plugin, visibleIndex) => { const actualIndex = pagination.toActualIndex(visibleIndex) const isSelected = selectedIndex === actualIndex const isSelectedForInstall = selectedForInstall.has(plugin.pluginId) const isInstallingThis = installingPlugins.has(plugin.pluginId) const isLast = visibleIndex === visiblePlugins.length - 1 return ( {isSelected && !isSearchMode ? figures.pointer : ' '}{' '} {isInstallingThis ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '} {plugin.entry.name} · {plugin.marketplaceName} {plugin.entry.tags?.includes('community-managed') && ( [Community Managed] )} {installCounts && plugin.marketplaceName === OFFICIAL_MARKETPLACE_NAME && ( {' · '} {formatInstallCount( installCounts.get(plugin.pluginId) ?? 0, )}{' '} installs )} {plugin.entry.description && ( {truncateToWidth(plugin.entry.description, 60)} )} ) })} {/* Scroll down indicator */} {pagination.scrollPosition.canScrollDown && ( {figures.arrowDown} more below )} {/* Error messages */} {error && ( {figures.cross} {error} )} 0} canToggle={ selectedIndex < filteredPlugins.length && !filteredPlugins[selectedIndex]?.isInstalled } /> ) } function DiscoverPluginsKeyHint({ hasSelection, canToggle, }: { hasSelection: boolean canToggle: boolean }): React.ReactNode { return ( {hasSelection && ( )} type to search {canToggle && ( )} ) } /** * Context-aware empty state message for the Discover screen */ function EmptyStateMessage({ reason, }: { reason: EmptyMarketplaceReason | null }): React.ReactNode { switch (reason) { case 'git-not-installed': return ( <> Git is required to install marketplaces. Please install git and restart Claude Code. ) case 'all-blocked-by-policy': return ( <> Your organization policy does not allow any external marketplaces. Contact your administrator. ) case 'policy-restricts-sources': return ( <> Your organization restricts which marketplaces can be added. Switch to the Marketplaces tab to view allowed sources. ) case 'all-marketplaces-failed': return ( <> Failed to load marketplace data. Check your network connection. ) case 'all-plugins-installed': return ( <> All available plugins are already installed. Check for new plugins later or add more marketplaces. ) case 'no-marketplaces-configured': default: return ( <> No plugins available. Add a marketplace first using the Marketplaces tab. ) } }