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 as { name: string }).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 as { success: false; error: string }).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 as { success: false; error: string }).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} /> ); }