import figures from 'figures' import * as React from 'react' import { useEffect, useState } from 'react' import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' import { Box, Byline, Text } from '@anthropic/ink' 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, failures } = await loadMarketplacesWithGracefulDegradation(config) const marketplaceInfos: MarketplaceInfo[] = [] for (const { name, config: marketplaceConfig, data: marketplace, } of marketplaces) { 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, 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] of Object.entries(config)) { const marketplace = await getMarketplace(name) if (marketplace) { const plugin = marketplace.plugins.find( p => p.name === targetPlugin, ) if (plugin) { const pluginId = createPluginId(plugin.name, name) foundPlugin = { entry: plugin, marketplaceName: name, 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 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 = foundPlugin.pluginId const globallyInstalled = isPluginGloballyInstalled(pluginId) if (globallyInstalled) { setError( `Plugin '${pluginId}' 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 => m.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 = await getMarketplace(marketplaceName) if (cancelled) return if (!marketplace) { throw new Error(`Failed to load marketplace: ${marketplaceName}`) } // Filter out already installed plugins const installablePlugins: InstallablePlugin[] = [] for (const entry of marketplace.plugins) { const pluginId = createPluginId(entry.name, marketplaceName) if (isPluginBlockedByPolicy(pluginId)) continue installablePlugins.push({ entry, marketplaceName: marketplaceName, pluginId, // 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), }) } // 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, 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 installablePlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name), ) } } catch (error) { if (cancelled) return // Log the error, then gracefully degrade to alphabetical sort logForDebugging( `Failed to fetch install counts: ${errorMessage(error)}`, ) installablePlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name), ) } setAvailablePlugins(installablePlugins) setSelectedIndex(0) setSelectedForInstall(new Set()) } catch (err) { if (cancelled) return setError(err instanceof Error ? err.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 => 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) { // All succeeded const message = `✓ Installed ${successCount} ${plural(successCount, 'plugin')}. ` + `Run /reload-plugins to activate.` setResult(message) } else if (successCount === 0) { // All failed - show error with reasons setError( `Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`, ) } else { // Mixed results - show partial success const message = `✓ Installed ${successCount} of ${successCount + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + `Run /reload-plugins to activate successfully installed plugins.` setResult(message) } // Handle completion callback and navigation 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]) // 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 = marketplaces[selectedIndex] if (marketplace) { setSelectedMarketplace(marketplace.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 = availablePlugins[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' }, ) useKeybindings( { 'plugin:toggle': () => { if (selectedIndex < availablePlugins.length) { const plugin = availablePlugins[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' }, ) // 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} } // 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, index) => ( {selectedIndex === index ? figures.pointer : ' '}{' '} {marketplace.name} {marketplace.totalPlugins}{' '} {plural(marketplace.totalPlugins, 'plugin')} available {marketplace.installedCount > 0 && ` · ${marketplace.installedCount} already installed`} {marketplace.source && ` · ${marketplace.source}`} ))} ) } // 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 {/* 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) => ( {detailsMenuIndex === index && {'> '}} {detailsMenuIndex !== index && {' '}} {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, visibleIndex) => { const actualIndex = pagination.toActualIndex(visibleIndex) const isSelected = selectedIndex === actualIndex const isSelectedForInstall = selectedForInstall.has(plugin.pluginId) const isInstalling = installingPlugins.has(plugin.pluginId) const isLast = visibleIndex === visiblePlugins.length - 1 return ( {isSelected ? figures.pointer : ' '}{' '} {plugin.isInstalled ? figures.tick : isInstalling ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '} {plugin.entry.name} {plugin.entry.category && ( [{plugin.entry.category}] )} {plugin.entry.tags?.includes('community-managed') && ( [Community Managed] )} {plugin.isInstalled && (installed)} {installCounts && selectedMarketplace === OFFICIAL_MARKETPLACE_NAME && ( {' · '} {formatInstallCount( installCounts.get(plugin.pluginId) ?? 0, )}{' '} installs )} {plugin.entry.description && ( {truncateToWidth(plugin.entry.description, 60)} {plugin.entry.version && ( · v{plugin.entry.version} )} )} ) })} {/* Scroll down indicator */} {pagination.scrollPosition.canScrollDown && ( {figures.arrowDown} more below )} {/* Error messages shown in the UI */} {error && ( {figures.cross} {error} )} 0} /> ) }