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' ? (
) : (
)}
)
}