import figures from 'figures' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' import { Byline, Pane, Tabs } from '@anthropic/ink' import { Tab } from '../../components/design-system/Tabs.js' import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' import { Box, Text } from '@anthropic/ink' import { useKeybinding, useKeybindings, } from '../../keybindings/useKeybinding.js' import { useAppState, useSetAppState } from '../../state/AppState.js' import type { PluginError } from '../../types/plugin.js' import { errorMessage } from '../../utils/errors.js' import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' import { loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js' import { loadKnownMarketplacesConfig, removeMarketplaceSource, } from '../../utils/plugins/marketplaceManager.js' import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js' import type { EditableSettingSource } from '../../utils/settings/constants.js' import { getSettingsForSource, updateSettingsForSource, } from '../../utils/settings/settings.js' import { AddMarketplace } from './AddMarketplace.js' import { BrowseMarketplace } from './BrowseMarketplace.js' import { DiscoverPlugins } from './DiscoverPlugins.js' import { ManageMarketplaces } from './ManageMarketplaces.js' import { ManagePlugins } from './ManagePlugins.js' import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js' import { type ParsedCommand, parsePluginArgs } from './parseArgs.js' import type { PluginSettingsProps, ViewState } from './types.js' import { ValidatePlugin } from './ValidatePlugin.js' type TabId = 'discover' | 'installed' | 'marketplaces' | 'errors' function MarketplaceList({ onComplete, }: { onComplete: (result?: string) => void }): React.ReactNode { useEffect(() => { async function loadList() { try { const config = await loadKnownMarketplacesConfig() const names = Object.keys(config) if (names.length === 0) { onComplete('No marketplaces configured') } else { onComplete( `Configured marketplaces:\n${names.map(n => ` • ${n}`).join('\n')}`, ) } } catch (err) { onComplete(`Error loading marketplaces: ${errorMessage(err)}`) } } void loadList() }, [onComplete]) return Loading marketplaces... } function McpRedirectBanner(): React.ReactNode { if ("external" !== 'ant') { return null } return ( i{' '} [ANT-ONLY] MCP servers are now managed in /plugins. Use /mcp no-redirect to test old UI ) } type ErrorRowAction = | { kind: 'navigate'; tab: TabId; viewState: ViewState } | { kind: 'remove-extra-marketplace' name: string sources: Array<{ source: EditableSettingSource; scope: string }> } | { kind: 'remove-installed-marketplace'; name: string } | { kind: 'managed-only'; name: string } | { kind: 'none' } type ErrorRow = { label: string message: string guidance?: string | null action: ErrorRowAction scope?: string } /** * Determine which settings sources define an extraKnownMarketplace entry. * Returns the editable sources (user/project/local) and whether policy also has it. */ function getExtraMarketplaceSourceInfo(name: string): { editableSources: Array<{ source: EditableSettingSource; scope: string }> isInPolicy: boolean } { const editableSources: Array<{ source: EditableSettingSource scope: string }> = [] const sourcesToCheck = [ { source: 'userSettings' as const, scope: 'user' }, { source: 'projectSettings' as const, scope: 'project' }, { source: 'localSettings' as const, scope: 'local' }, ] for (const { source, scope } of sourcesToCheck) { const settings = getSettingsForSource(source) if (settings?.extraKnownMarketplaces?.[name]) { editableSources.push({ source, scope }) } } const policySettings = getSettingsForSource('policySettings') const isInPolicy = Boolean(policySettings?.extraKnownMarketplaces?.[name]) return { editableSources, isInPolicy } } function buildMarketplaceAction(name: string): ErrorRowAction { const { editableSources, isInPolicy } = getExtraMarketplaceSourceInfo(name) if (editableSources.length > 0) { return { kind: 'remove-extra-marketplace', name, sources: editableSources, } } if (isInPolicy) { return { kind: 'managed-only', name } } // Marketplace is in known_marketplaces.json but not in extraKnownMarketplaces // (e.g. previously installed manually) — route to ManageMarketplaces return { kind: 'navigate', tab: 'marketplaces', viewState: { type: 'manage-marketplaces', targetMarketplace: name, action: 'remove', }, } } function buildPluginAction(pluginName: string): ErrorRowAction { return { kind: 'navigate', tab: 'installed', viewState: { type: 'manage-plugins', targetPlugin: pluginName, action: 'uninstall', }, } } const TRANSIENT_ERROR_TYPES = new Set([ 'git-auth-failed', 'git-timeout', 'network-error', ]) function isTransientError(error: PluginError): boolean { return TRANSIENT_ERROR_TYPES.has(error.type) } /** * Extract the plugin name from a PluginError, checking explicit fields first, * then falling back to the source field (format: "pluginName@marketplace"). */ function getPluginNameFromError(error: PluginError): string | undefined { if ('pluginId' in error && error.pluginId) return error.pluginId if ('plugin' in error && error.plugin) return error.plugin // Fallback: source often contains "pluginName@marketplace" if (error.source.includes('@')) return error.source.split('@')[0] return undefined } function buildErrorRows( failedMarketplaces: Array<{ name: string; error?: string }>, extraMarketplaceErrors: PluginError[], pluginLoadingErrors: PluginError[], otherErrors: PluginError[], brokenInstalledMarketplaces: Array<{ name: string; error: string }>, transientErrors: PluginError[], pluginScopes: Map, ): ErrorRow[] { const rows: ErrorRow[] = [] // --- Transient errors at the top (restart to retry) --- for (const error of transientErrors) { const pluginName = 'pluginId' in error ? error.pluginId : 'plugin' in error ? error.plugin : undefined rows.push({ label: pluginName ?? error.source, message: formatErrorMessage(error), guidance: 'Restart to retry loading plugins', action: { kind: 'none' }, }) } // --- Marketplace errors --- // Track shown marketplace names to avoid duplicates across sources const shownMarketplaceNames = new Set() for (const m of failedMarketplaces) { shownMarketplaceNames.add(m.name) const action = buildMarketplaceAction(m.name) const sourceInfo = getExtraMarketplaceSourceInfo(m.name) const scope = sourceInfo.isInPolicy ? 'managed' : sourceInfo.editableSources[0]?.scope rows.push({ label: m.name, message: m.error ?? 'Installation failed', guidance: action.kind === 'managed-only' ? 'Managed by your organization — contact your admin' : undefined, action, scope, }) } for (const e of extraMarketplaceErrors) { const marketplace = 'marketplace' in e ? e.marketplace : e.source if (shownMarketplaceNames.has(marketplace)) continue shownMarketplaceNames.add(marketplace) const action = buildMarketplaceAction(marketplace) const sourceInfo = getExtraMarketplaceSourceInfo(marketplace) const scope = sourceInfo.isInPolicy ? 'managed' : sourceInfo.editableSources[0]?.scope rows.push({ label: marketplace, message: formatErrorMessage(e), guidance: action.kind === 'managed-only' ? 'Managed by your organization — contact your admin' : getErrorGuidance(e), action, scope, }) } // Installed marketplaces that fail to load data (from known_marketplaces.json) for (const m of brokenInstalledMarketplaces) { if (shownMarketplaceNames.has(m.name)) continue shownMarketplaceNames.add(m.name) rows.push({ label: m.name, message: m.error, action: { kind: 'remove-installed-marketplace', name: m.name }, }) } // --- Plugin errors --- const shownPluginNames = new Set() for (const error of pluginLoadingErrors) { const pluginName = getPluginNameFromError(error) if (pluginName && shownPluginNames.has(pluginName)) continue if (pluginName) shownPluginNames.add(pluginName) const marketplace = 'marketplace' in error ? error.marketplace : undefined // Try pluginId@marketplace format first, then just pluginName const scope = pluginName ? (pluginScopes.get(error.source) ?? pluginScopes.get(pluginName)) : undefined rows.push({ label: pluginName ? marketplace ? `${pluginName} @ ${marketplace}` : pluginName : error.source, message: formatErrorMessage(error), guidance: getErrorGuidance(error), action: pluginName ? buildPluginAction(pluginName) : { kind: 'none' }, scope, }) } // --- Other errors (non-marketplace, non-plugin-specific) --- for (const error of otherErrors) { rows.push({ label: error.source, message: formatErrorMessage(error), guidance: getErrorGuidance(error), action: { kind: 'none' }, }) } return rows } /** * Remove a marketplace from extraKnownMarketplaces in the given settings sources, * and also remove any associated enabled plugins. */ function removeExtraMarketplace( name: string, sources: Array<{ source: EditableSettingSource }>, ): void { for (const { source } of sources) { const settings = getSettingsForSource(source) if (!settings) continue const updates: Record = {} // Remove from extraKnownMarketplaces if (settings.extraKnownMarketplaces?.[name]) { updates.extraKnownMarketplaces = { ...settings.extraKnownMarketplaces, [name]: undefined, } } // Remove associated enabled plugins (format: "plugin@marketplace") if (settings.enabledPlugins) { const suffix = `@${name}` let removedPlugins = false const updatedPlugins = { ...settings.enabledPlugins } for (const pluginId in updatedPlugins) { if (pluginId.endsWith(suffix)) { updatedPlugins[pluginId] = undefined removedPlugins = true } } if (removedPlugins) { updates.enabledPlugins = updatedPlugins } } if (Object.keys(updates).length > 0) { updateSettingsForSource(source, updates) } } } function ErrorsTabContent({ setViewState, setActiveTab, markPluginsChanged, }: { setViewState: (state: ViewState) => void setActiveTab: (tab: TabId) => void markPluginsChanged: () => void }): React.ReactNode { const errors = useAppState(s => s.plugins.errors) const installationStatus = useAppState(s => s.plugins.installationStatus) const setAppState = useSetAppState() const [selectedIndex, setSelectedIndex] = useState(0) const [actionMessage, setActionMessage] = useState(null) const [marketplaceLoadFailures, setMarketplaceLoadFailures] = useState< Array<{ name: string; error: string }> >([]) // Detect marketplaces that are installed but fail to load their data useEffect(() => { void (async () => { try { const config = await loadKnownMarketplacesConfig() const { failures } = await loadMarketplacesWithGracefulDegradation(config) setMarketplaceLoadFailures(failures) } catch { // Ignore — if we can't load config, other tabs handle it } })() }, []) const failedMarketplaces = installationStatus.marketplaces.filter( m => m.status === 'failed', ) const failedMarketplaceNames = new Set(failedMarketplaces.map(m => m.name)) // Transient errors (git/network) — show at top with "restart to retry" const transientErrors = errors.filter(isTransientError) // Marketplace-related loading errors not already covered by install failures const extraMarketplaceErrors = errors.filter( e => (e.type === 'marketplace-not-found' || e.type === 'marketplace-load-failed' || e.type === 'marketplace-blocked-by-policy') && !failedMarketplaceNames.has(e.marketplace), ) // Plugin-specific loading errors const pluginLoadingErrors = errors.filter(e => { if (isTransientError(e)) return false if ( e.type === 'marketplace-not-found' || e.type === 'marketplace-load-failed' || e.type === 'marketplace-blocked-by-policy' ) { return false } return getPluginNameFromError(e) !== undefined }) // Remaining errors with no plugin association const otherErrors = errors.filter(e => { if (isTransientError(e)) return false if ( e.type === 'marketplace-not-found' || e.type === 'marketplace-load-failed' || e.type === 'marketplace-blocked-by-policy' ) { return false } return getPluginNameFromError(e) === undefined }) const pluginScopes = getPluginEditableScopes() const rows = buildErrorRows( failedMarketplaces, extraMarketplaceErrors, pluginLoadingErrors, otherErrors, marketplaceLoadFailures, transientErrors, pluginScopes, ) // Handle escape to exit the plugin menu useKeybinding( 'confirm:no', () => { setViewState({ type: 'menu' }) }, { context: 'Confirmation' }, ) const handleSelect = () => { const row = rows[selectedIndex] if (!row) return const { action } = row switch (action.kind) { case 'navigate': setActiveTab(action.tab) setViewState(action.viewState) break case 'remove-extra-marketplace': { const scopes = action.sources.map(s => s.scope).join(', ') removeExtraMarketplace(action.name, action.sources) clearAllCaches() // Synchronously clear all stale state for this marketplace so the UI // updates glitch-free. markPluginsChanged only sets needsRefresh — // it does not refresh plugins.errors, so this is the authoritative // cleanup until the user runs /reload-plugins. setAppState(prev => ({ ...prev, plugins: { ...prev.plugins, errors: prev.plugins.errors.filter( e => !('marketplace' in e && e.marketplace === action.name), ), installationStatus: { ...prev.plugins.installationStatus, marketplaces: prev.plugins.installationStatus.marketplaces.filter( m => m.name !== action.name, ), }, }, })) setActionMessage( `${figures.tick} Removed "${action.name}" from ${scopes} settings`, ) markPluginsChanged() break } case 'remove-installed-marketplace': { void (async () => { try { await removeMarketplaceSource(action.name) clearAllCaches() setMarketplaceLoadFailures(prev => prev.filter(f => f.name !== action.name), ) setActionMessage( `${figures.tick} Removed marketplace "${action.name}"`, ) markPluginsChanged() } catch (err) { setActionMessage( `Failed to remove "${action.name}": ${err instanceof Error ? err.message : String(err)}`, ) } })() break } case 'managed-only': // No action available — guidance text already shown break case 'none': break } } useKeybindings( { 'select:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), 'select:next': () => setSelectedIndex(prev => Math.min(rows.length - 1, prev + 1)), 'select:accept': handleSelect, }, { context: 'Select', isActive: rows.length > 0 }, ) // Clamp selectedIndex when rows shrink (e.g. after removal) const clampedIndex = Math.min(selectedIndex, Math.max(0, rows.length - 1)) if (clampedIndex !== selectedIndex) { setSelectedIndex(clampedIndex) } const selectedAction = rows[clampedIndex]?.action const hasAction = selectedAction && selectedAction.kind !== 'none' && selectedAction.kind !== 'managed-only' if (rows.length === 0) { return ( No plugin errors ) } return ( {rows.map((row, idx) => { const isSelected = idx === clampedIndex return ( {isSelected ? figures.pointer : figures.cross}{' '} {row.label} {row.scope && ({row.scope})} {row.message} {row.guidance && ( {row.guidance} )} ) })} {actionMessage && ( {actionMessage} )} {hasAction && ( )} ) } function getInitialViewState(parsedCommand: ParsedCommand): ViewState { switch (parsedCommand.type) { case 'help': return { type: 'help' } case 'validate': return { type: 'validate', path: parsedCommand.path } case 'install': if (parsedCommand.marketplace) { return { type: 'browse-marketplace', targetMarketplace: parsedCommand.marketplace, targetPlugin: parsedCommand.plugin, } } if (parsedCommand.plugin) { return { type: 'discover-plugins', targetPlugin: parsedCommand.plugin, } } return { type: 'discover-plugins' } case 'manage': return { type: 'manage-plugins' } case 'uninstall': return { type: 'manage-plugins', targetPlugin: parsedCommand.plugin, action: 'uninstall', } case 'enable': return { type: 'manage-plugins', targetPlugin: parsedCommand.plugin, action: 'enable', } case 'disable': return { type: 'manage-plugins', targetPlugin: parsedCommand.plugin, action: 'disable', } case 'marketplace': if (parsedCommand.action === 'list') { return { type: 'marketplace-list' } } if (parsedCommand.action === 'add') { return { type: 'add-marketplace', initialValue: parsedCommand.target, } } if (parsedCommand.action === 'remove') { return { type: 'manage-marketplaces', targetMarketplace: parsedCommand.target, action: 'remove', } } if (parsedCommand.action === 'update') { return { type: 'manage-marketplaces', targetMarketplace: parsedCommand.target, action: 'update', } } return { type: 'marketplace-menu' } case 'menu': default: // Default to discover view showing all plugins return { type: 'discover-plugins' } } } function getInitialTab(viewState: ViewState): TabId { if (viewState.type === 'manage-plugins') return 'installed' if (viewState.type === 'manage-marketplaces') return 'marketplaces' return 'discover' } export function PluginSettings({ onComplete, args, showMcpRedirectMessage, }: PluginSettingsProps): React.ReactNode { const parsedCommand = parsePluginArgs(args) const initialViewState = getInitialViewState(parsedCommand) const [viewState, setViewState] = useState(initialViewState) const [activeTab, setActiveTab] = useState( getInitialTab(initialViewState), ) const [inputValue, setInputValue] = useState( viewState.type === 'add-marketplace' ? viewState.initialValue || '' : '', ) const [cursorOffset, setCursorOffset] = useState(0) const [error, setError] = useState(null) const [result, setResult] = useState(null) const [childSearchActive, setChildSearchActive] = useState(false) const setAppState = useSetAppState() // Error count for the Errors tab badge — counts loader errors + background // marketplace install failures. Does NOT count marketplace-on-disk load // failures (those require I/O and are discovered lazily when the tab opens). // May slightly overcount vs. displayed rows when a marketplace has both a // loader error and a failed install status (buildErrorRows deduplicates). const pluginErrorCount = useAppState(s => { let count = s.plugins.errors.length for (const m of s.plugins.installationStatus.marketplaces) { if (m.status === 'failed') count++ } return count }) const errorsTabTitle = pluginErrorCount > 0 ? `Errors (${pluginErrorCount})` : 'Errors' const exitState = useExitOnCtrlCDWithKeybindings() /** * CLI mode is active when the user provides a complete command with all required arguments. * In this mode, the operation executes immediately without interactive prompts. * Interactive mode is used when arguments are missing, allowing the user to input them. */ const cliMode = parsedCommand.type === 'marketplace' && parsedCommand.action === 'add' && parsedCommand.target !== undefined // Signal that plugin state has changed on disk (Layer 2) and active // components (Layer 3) are stale. User runs /reload-plugins to apply. // Previously this was updatePluginState() which did a partial refresh // (commands only — agents/hooks/MCP were silently skipped). Now all // Layer-3 refresh flows through the unified refreshActivePlugins() // primitive via /reload-plugins, giving one consistent mental model: // plugin changes require /reload-plugins. const markPluginsChanged = useCallback(() => { setAppState(prev => prev.plugins.needsRefresh ? prev : { ...prev, plugins: { ...prev.plugins, needsRefresh: true } }, ) }, [setAppState]) // Handle tab switching (called by Tabs component) const handleTabChange = useCallback((tabId: string) => { const tab = tabId as TabId setActiveTab(tab) setError(null) switch (tab) { case 'discover': setViewState({ type: 'discover-plugins' }) break case 'installed': setViewState({ type: 'manage-plugins' }) break case 'marketplaces': setViewState({ type: 'manage-marketplaces' }) break case 'errors': // No viewState change needed — ErrorsTabContent renders inside break } }, []) // Handle exiting when child components set viewState to 'menu'. // Child components typically set BOTH setResult(msg) and setParentViewState // ({type:'menu'}) — both effects fire on the same render. Only close via this // path when there's no result, otherwise the result effect (below) handles // the close AND delivers the message to the transcript. useEffect(() => { if (viewState.type === 'menu' && !result) { onComplete() } }, [viewState.type, result, onComplete]) // Sync activeTab when viewState changes to a different tab's content // This handles cases like AddMarketplace navigating to browse-marketplace useEffect(() => { if (viewState.type === 'browse-marketplace' && activeTab !== 'discover') { setActiveTab('discover') } }, [viewState.type, activeTab]) // Handle escape key for add-marketplace mode only // Other tabbed views handle escape in their own components const handleAddMarketplaceEscape = useCallback(() => { setActiveTab('marketplaces') setViewState({ type: 'manage-marketplaces' }) setInputValue('') setError(null) }, []) useKeybinding('confirm:no', handleAddMarketplaceEscape, { context: 'Settings', isActive: viewState.type === 'add-marketplace', }) useEffect(() => { if (result) { onComplete(result) } }, [result, onComplete]) // Handle help view completion useEffect(() => { if (viewState.type === 'help') { onComplete() } }, [viewState.type, onComplete]) // Render different views based on state if (viewState.type === 'help') { return ( Plugin Command Usage: Installation: /plugin install - Browse and install plugins {' '} /plugin install <marketplace> - Install from specific marketplace /plugin install <plugin> - Install specific plugin {' '} /plugin install <plugin>@<market> - Install plugin from marketplace Management: /plugin manage - Manage installed plugins /plugin enable <plugin> - Enable a plugin /plugin disable <plugin> - Disable a plugin /plugin uninstall <plugin> - Uninstall a plugin Marketplaces: /plugin marketplace - Marketplace management menu /plugin marketplace add - Add a marketplace {' '} /plugin marketplace add <path/url> - Add marketplace directly /plugin marketplace update - Update marketplaces {' '} /plugin marketplace update <name> - Update specific marketplace /plugin marketplace remove - Remove a marketplace {' '} /plugin marketplace remove <name> - Remove specific marketplace /plugin marketplace list - List all marketplaces Validation: {' '} /plugin validate <path> - Validate a manifest file or directory Other: /plugin - Main plugin menu /plugin help - Show this help /plugins - Alias for /plugin ) } if (viewState.type === 'validate') { return } if (viewState.type === 'marketplace-menu') { // Show a simple menu for marketplace operations setViewState({ type: 'menu' }) return null } if (viewState.type === 'marketplace-list') { return } if (viewState.type === 'add-marketplace') { return ( ) } // Render tabbed interface using the design system Tabs component return ( ) : undefined } > {viewState.type === 'browse-marketplace' ? ( ) : ( )} ) }