diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx
new file mode 100644
index 00000000..d9b57a3e
--- /dev/null
+++ b/desktop/src/__tests__/pluginsSettings.test.tsx
@@ -0,0 +1,227 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+
+import { Settings } from '../pages/Settings'
+import { usePluginStore } from '../stores/pluginStore'
+import { useSettingsStore } from '../stores/settingsStore'
+import { useSessionStore } from '../stores/sessionStore'
+
+vi.mock('../api/agents', () => ({
+ agentsApi: {
+ list: vi.fn().mockResolvedValue({ activeAgents: [], allAgents: [] }),
+ },
+}))
+
+vi.mock('../stores/providerStore', () => ({
+ useProviderStore: () => ({
+ providers: [],
+ activeId: null,
+ isLoading: false,
+ fetchProviders: vi.fn(),
+ deleteProvider: vi.fn(),
+ activateProvider: vi.fn(),
+ activateOfficial: vi.fn(),
+ testProvider: vi.fn(),
+ createProvider: vi.fn(),
+ updateProvider: vi.fn(),
+ testConfig: vi.fn(),
+ }),
+}))
+
+vi.mock('../pages/AdapterSettings', () => ({
+ AdapterSettings: () =>
Adapter Settings Mock
,
+}))
+
+vi.mock('../stores/agentStore', () => ({
+ useAgentStore: () => ({
+ activeAgents: [],
+ allAgents: [],
+ isLoading: false,
+ error: null,
+ selectedAgent: null,
+ fetchAgents: vi.fn(),
+ selectAgent: vi.fn(),
+ }),
+}))
+
+vi.mock('../stores/skillStore', () => ({
+ useSkillStore: () => ({
+ skills: [],
+ selectedSkill: null,
+ isLoading: false,
+ isDetailLoading: false,
+ error: null,
+ fetchSkills: vi.fn(),
+ fetchSkillDetail: vi.fn(),
+ clearSelection: vi.fn(),
+ }),
+}))
+
+const noop = vi.fn()
+
+function switchToPluginsTab() {
+ fireEvent.click(screen.getByText('Plugins'))
+}
+
+describe('Settings > Plugins tab', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ locale: 'en' })
+ useSessionStore.setState({
+ sessions: [
+ {
+ id: 'session-1',
+ title: 'Active session',
+ createdAt: '2026-04-20T00:00:00.000Z',
+ modifiedAt: '2026-04-20T00:00:00.000Z',
+ messageCount: 1,
+ projectPath: '/workspace/project',
+ workDir: '/workspace/project',
+ workDirExists: true,
+ },
+ ],
+ activeSessionId: 'session-1',
+ isLoading: false,
+ error: null,
+ })
+ usePluginStore.setState({
+ plugins: [],
+ marketplaces: [],
+ summary: { total: 0, enabled: 0, errorCount: 0, marketplaceCount: 0 },
+ selectedPlugin: null,
+ lastReloadSummary: null,
+ isLoading: false,
+ isDetailLoading: false,
+ isApplying: false,
+ error: null,
+ fetchPlugins: noop,
+ fetchPluginDetail: noop,
+ reloadPlugins: vi.fn().mockResolvedValue({
+ enabled: 1,
+ disabled: 0,
+ skills: 2,
+ agents: 1,
+ hooks: 0,
+ mcpServers: 1,
+ lspServers: 0,
+ errors: 0,
+ }),
+ enablePlugin: vi.fn().mockResolvedValue('enabled'),
+ disablePlugin: vi.fn().mockResolvedValue('disabled'),
+ updatePlugin: vi.fn().mockResolvedValue('updated'),
+ uninstallPlugin: vi.fn().mockResolvedValue('uninstalled'),
+ clearSelection: vi.fn(),
+ })
+ })
+
+ it('renders plugin browser summary and grouped cards', () => {
+ usePluginStore.setState({
+ plugins: [
+ {
+ id: 'github@claude-plugins-official',
+ name: 'github',
+ marketplace: 'claude-plugins-official',
+ scope: 'user',
+ enabled: true,
+ hasErrors: false,
+ isBuiltin: false,
+ version: '1.2.3',
+ description: 'GitHub integration',
+ authorName: 'Anthropic',
+ componentCounts: {
+ commands: 1,
+ agents: 1,
+ skills: 2,
+ hooks: 0,
+ mcpServers: 1,
+ lspServers: 0,
+ },
+ errors: [],
+ },
+ {
+ id: 'pyright-lsp@claude-plugins-official',
+ name: 'pyright-lsp',
+ marketplace: 'claude-plugins-official',
+ scope: 'project',
+ enabled: false,
+ hasErrors: true,
+ isBuiltin: false,
+ description: 'Python language tooling',
+ componentCounts: {
+ commands: 0,
+ agents: 0,
+ skills: 0,
+ hooks: 0,
+ mcpServers: 0,
+ lspServers: 1,
+ },
+ errors: ['Executable not found in $PATH'],
+ },
+ ],
+ marketplaces: [
+ {
+ name: 'claude-plugins-official',
+ source: 'github:anthropics/claude-plugins-official',
+ autoUpdate: true,
+ installedCount: 2,
+ },
+ ],
+ summary: { total: 2, enabled: 1, errorCount: 1, marketplaceCount: 1 },
+ })
+
+ render()
+ switchToPluginsTab()
+
+ expect(screen.getByText('Browse installed plugins')).toBeInTheDocument()
+ expect(screen.getByText('Plugin Manager')).toBeInTheDocument()
+ expect(screen.getAllByText('Needs attention').length).toBeGreaterThan(0)
+ expect(screen.getByText('github')).toBeInTheDocument()
+ expect(screen.getByText('Python language tooling')).toBeInTheDocument()
+ expect(screen.getByText('Known marketplaces')).toBeInTheDocument()
+ })
+
+ it('renders plugin detail with bundled capability sections', () => {
+ usePluginStore.setState({
+ selectedPlugin: {
+ id: 'github@claude-plugins-official',
+ name: 'github',
+ marketplace: 'claude-plugins-official',
+ scope: 'user',
+ enabled: true,
+ hasErrors: false,
+ isBuiltin: false,
+ version: '1.2.3',
+ description: 'GitHub integration',
+ authorName: 'Anthropic',
+ installPath: '/Users/test/.claude/plugins/cache/github',
+ componentCounts: {
+ commands: 1,
+ agents: 1,
+ skills: 2,
+ hooks: 1,
+ mcpServers: 1,
+ lspServers: 0,
+ },
+ capabilities: {
+ commands: ['review-pr'],
+ agents: ['pr-reviewer'],
+ skills: ['commit', 'create-pr'],
+ hooks: ['SessionStart'],
+ mcpServers: ['github-api'],
+ lspServers: [],
+ },
+ errors: [],
+ },
+ })
+
+ render()
+ switchToPluginsTab()
+
+ expect(screen.getByText('Plugin Detail')).toBeInTheDocument()
+ expect(screen.getByText('GitHub integration')).toBeInTheDocument()
+ expect(screen.getByText('Bundled capabilities')).toBeInTheDocument()
+ expect(screen.getByText('review-pr')).toBeInTheDocument()
+ expect(screen.getByText('Apply changes')).toBeInTheDocument()
+ expect(screen.getByText('Uninstall')).toBeInTheDocument()
+ })
+})
diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts
index 6b2e570e..78468751 100644
--- a/desktop/src/api/client.ts
+++ b/desktop/src/api/client.ts
@@ -1,4 +1,11 @@
-const DEFAULT_BASE_URL = 'http://127.0.0.1:3456'
+const ENV_BASE_URL =
+ typeof import.meta !== 'undefined' &&
+ typeof import.meta.env?.VITE_DESKTOP_SERVER_URL === 'string' &&
+ import.meta.env.VITE_DESKTOP_SERVER_URL.length > 0
+ ? import.meta.env.VITE_DESKTOP_SERVER_URL
+ : undefined
+
+const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
let baseUrl = DEFAULT_BASE_URL
diff --git a/desktop/src/api/plugins.ts b/desktop/src/api/plugins.ts
new file mode 100644
index 00000000..11452b77
--- /dev/null
+++ b/desktop/src/api/plugins.ts
@@ -0,0 +1,43 @@
+import { api } from './client'
+import type {
+ PluginDetail,
+ PluginListResponse,
+ PluginReloadSummary,
+ PluginScope,
+} from '../types/plugin'
+
+type PluginActionPayload = {
+ id: string
+ scope?: PluginScope
+ keepData?: boolean
+}
+
+export const pluginsApi = {
+ list: (cwd?: string) => {
+ const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
+ return api.get(`/api/plugins${query}`)
+ },
+
+ detail: (id: string, cwd?: string) => {
+ const query = new URLSearchParams({ id })
+ if (cwd) query.set('cwd', cwd)
+ return api.get<{ detail: PluginDetail }>(`/api/plugins/detail?${query.toString()}`)
+ },
+
+ enable: (payload: PluginActionPayload) =>
+ api.post<{ ok: true; message: string }>('/api/plugins/enable', payload),
+
+ disable: (payload: PluginActionPayload) =>
+ api.post<{ ok: true; message: string }>('/api/plugins/disable', payload),
+
+ update: (payload: PluginActionPayload) =>
+ api.post<{ ok: true; message: string }>('/api/plugins/update', payload),
+
+ uninstall: (payload: PluginActionPayload) =>
+ api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload),
+
+ reload: (cwd?: string) => {
+ const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
+ return api.post<{ ok: true; summary: PluginReloadSummary }>(`/api/plugins/reload${query}`)
+ },
+}
diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx
new file mode 100644
index 00000000..f12bc1f3
--- /dev/null
+++ b/desktop/src/components/plugins/PluginDetail.tsx
@@ -0,0 +1,360 @@
+import { useState, type ReactNode } from 'react'
+import { usePluginStore } from '../../stores/pluginStore'
+import { useSessionStore } from '../../stores/sessionStore'
+import { useTranslation } from '../../i18n'
+import { useUIStore } from '../../stores/uiStore'
+import { Button } from '../shared/Button'
+import type { PluginCapabilityKey } from '../../types/plugin'
+
+const CAPABILITY_ORDER: PluginCapabilityKey[] = [
+ 'skills',
+ 'commands',
+ 'agents',
+ 'hooks',
+ 'mcpServers',
+ 'lspServers',
+]
+
+export function PluginDetail() {
+ const {
+ selectedPlugin,
+ isDetailLoading,
+ isApplying,
+ clearSelection,
+ enablePlugin,
+ disablePlugin,
+ updatePlugin,
+ uninstallPlugin,
+ reloadPlugins,
+ } = usePluginStore()
+ const sessions = useSessionStore((s) => s.sessions)
+ const activeSessionId = useSessionStore((s) => s.activeSessionId)
+ const addToast = useUIStore((s) => s.addToast)
+ const t = useTranslation()
+ const [actionKey, setActionKey] = useState(null)
+
+ const activeSession = sessions.find((session) => session.id === activeSessionId)
+ const currentWorkDir = activeSession?.workDir || undefined
+
+ if (isDetailLoading) {
+ return (
+
+ )
+ }
+
+ if (!selectedPlugin) return null
+
+ const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin'
+
+ const runAction = async (key: string, fn: () => Promise) => {
+ setActionKey(key)
+ try {
+ const message = await fn()
+ addToast({ type: 'success', message })
+ } catch (err) {
+ addToast({
+ type: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ })
+ } finally {
+ setActionKey(null)
+ }
+ }
+
+ const handleReload = async () => {
+ setActionKey('reload')
+ try {
+ const summary = await reloadPlugins(currentWorkDir)
+ addToast({
+ type: summary.errors > 0 ? 'warning' : 'success',
+ message: t('settings.plugins.reloadToast', {
+ enabled: String(summary.enabled),
+ skills: String(summary.skills),
+ errors: String(summary.errors),
+ }),
+ })
+ } catch (err) {
+ addToast({
+ type: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ })
+ } finally {
+ setActionKey(null)
+ }
+ }
+
+ const confirmUninstall = () => {
+ const label = t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })
+ return window.confirm(label)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t('settings.plugins.entryEyebrow')}
+
+
+
+ {selectedPlugin.name}
+
+
+ {t(`settings.plugins.scope.${selectedPlugin.scope}`)}
+ {selectedPlugin.marketplace}
+ {selectedPlugin.version && v{selectedPlugin.version}}
+
+
+ {selectedPlugin.description || t('settings.plugins.noDescription')}
+
+
+ {selectedPlugin.authorName && (
+ {t('settings.plugins.author', { value: selectedPlugin.authorName })}
+ )}
+ {selectedPlugin.projectPath && (
+ {t('settings.plugins.projectPath', { value: selectedPlugin.projectPath })}
+ )}
+ {selectedPlugin.installPath && (
+ {t('settings.plugins.installPath', { value: selectedPlugin.installPath })}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canMutate && (
+ selectedPlugin.enabled ? (
+
+ ) : (
+
+ )
+ )}
+
+ {canMutate && (
+
+ )}
+
+
+
+ {canMutate && (
+
+ )}
+
+
+ {!canMutate && (
+
+ {selectedPlugin.scope === 'managed'
+ ? t('settings.plugins.managedHint')
+ : t('settings.plugins.builtinHint')}
+
+ )}
+
+
+ {t('settings.plugins.applyHint')}
+
+
+
+ {selectedPlugin.errors.length > 0 && (
+
+
+
+ error
+
+
+ {t('settings.plugins.errorsTitle')}
+
+
+
+ {selectedPlugin.errors.map((error) => (
+
+ {error}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {t('settings.plugins.capabilitiesTitle')}
+
+
+ {t('settings.plugins.capabilitiesHint')}
+
+
+
+ {CAPABILITY_ORDER.map((key) => {
+ const items = selectedPlugin.capabilities[key]
+ return (
+
+
+
+ {t(`settings.plugins.capabilityLabel.${key}`)}
+
+
+ {items.length}
+
+
+ {items.length > 0 ? (
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+ ) : (
+
+ {t('settings.plugins.capabilityEmpty')}
+
+ )}
+
+ )
+ })}
+
+
+
+ )
+}
+
+function MetaPill({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function DetailStat({
+ label,
+ value,
+ icon,
+}: {
+ label: string
+ value: string
+ icon: string
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+function StatusPill({
+ enabled,
+ hasErrors,
+}: {
+ enabled: boolean
+ hasErrors: boolean
+}) {
+ const t = useTranslation()
+ const classes = hasErrors
+ ? 'bg-[var(--color-error)]/12 text-[var(--color-error)]'
+ : enabled
+ ? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
+ : 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
+
+ const label = hasErrors
+ ? t('settings.plugins.status.attention')
+ : enabled
+ ? t('settings.plugins.status.enabled')
+ : t('settings.plugins.status.disabled')
+
+ return (
+
+ {label}
+
+ )
+}
diff --git a/desktop/src/components/plugins/PluginList.tsx b/desktop/src/components/plugins/PluginList.tsx
new file mode 100644
index 00000000..273a44bd
--- /dev/null
+++ b/desktop/src/components/plugins/PluginList.tsx
@@ -0,0 +1,362 @@
+import { useEffect, useMemo } from 'react'
+import { usePluginStore } from '../../stores/pluginStore'
+import { useSessionStore } from '../../stores/sessionStore'
+import { useTranslation } from '../../i18n'
+import { useUIStore } from '../../stores/uiStore'
+import { Button } from '../shared/Button'
+import type { PluginSummary } from '../../types/plugin'
+
+type PluginBucket = 'attention' | 'enabled' | 'disabled'
+
+export function PluginList() {
+ const {
+ plugins,
+ marketplaces,
+ summary,
+ lastReloadSummary,
+ isLoading,
+ isApplying,
+ error,
+ fetchPlugins,
+ fetchPluginDetail,
+ reloadPlugins,
+ } = usePluginStore()
+ const sessions = useSessionStore((s) => s.sessions)
+ const activeSessionId = useSessionStore((s) => s.activeSessionId)
+ const addToast = useUIStore((s) => s.addToast)
+ const t = useTranslation()
+ const activeSession = sessions.find((session) => session.id === activeSessionId)
+ const currentWorkDir = activeSession?.workDir || undefined
+
+ useEffect(() => {
+ void fetchPlugins(currentWorkDir)
+ }, [fetchPlugins, currentWorkDir])
+
+ const grouped = useMemo(() => {
+ const buckets: Record = {
+ attention: [],
+ enabled: [],
+ disabled: [],
+ }
+
+ for (const plugin of plugins) {
+ if (plugin.hasErrors) {
+ buckets.attention.push(plugin)
+ } else if (plugin.enabled) {
+ buckets.enabled.push(plugin)
+ } else {
+ buckets.disabled.push(plugin)
+ }
+ }
+
+ return buckets
+ }, [plugins])
+
+ const handleReload = async () => {
+ try {
+ const reloadSummary = await reloadPlugins(currentWorkDir)
+ addToast({
+ type: reloadSummary.errors > 0 ? 'warning' : 'success',
+ message: t('settings.plugins.reloadToast', {
+ enabled: String(reloadSummary.enabled),
+ skills: String(reloadSummary.skills),
+ errors: String(reloadSummary.errors),
+ }),
+ })
+ } catch (err) {
+ addToast({
+ type: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return {error}
+ }
+
+ if (plugins.length === 0) {
+ return (
+
+
+ extension
+
+
+ {t('settings.plugins.empty')}
+
+
+ {t('settings.plugins.emptyHint')}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {t('settings.plugins.browserEyebrow')}
+
+
+
+ extension
+
+
+ {t('settings.plugins.browserTitle')}
+
+
+
+ {t('settings.plugins.browserDescription')}
+
+ {lastReloadSummary && (
+
+ {t('settings.plugins.lastReload', {
+ enabled: String(lastReloadSummary.enabled),
+ skills: String(lastReloadSummary.skills),
+ errors: String(lastReloadSummary.errors),
+ })}
+
+ )}
+
+
+
+
+
+ plugin.enabled).length)}
+ icon="check_circle"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {marketplaces.length > 0 && (
+
+
+
+ {t('settings.plugins.marketplacesTitle')}
+
+
+ {t('settings.plugins.marketplacesHint')}
+
+
+
+ {marketplaces.map((marketplace) => (
+
+
+
+ {marketplace.name}
+
+
+ {marketplace.autoUpdate
+ ? t('settings.plugins.marketplaceAutoUpdateOn')
+ : t('settings.plugins.marketplaceAutoUpdateOff')}
+
+
+
+ {marketplace.source}
+
+
+ {t('settings.plugins.marketplaceInstalledCount', { count: String(marketplace.installedCount) })}
+ {marketplace.lastUpdated && (
+ {t('settings.plugins.marketplaceUpdatedAt', { value: new Date(marketplace.lastUpdated).toLocaleString() })}
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {renderGroup('attention', grouped.attention, fetchPluginDetail, currentWorkDir, t)}
+ {renderGroup('enabled', grouped.enabled, fetchPluginDetail, currentWorkDir, t)}
+ {renderGroup('disabled', grouped.disabled, fetchPluginDetail, currentWorkDir, t)}
+
+ )
+}
+
+function renderGroup(
+ bucket: PluginBucket,
+ items: PluginSummary[],
+ fetchPluginDetail: (id: string, cwd?: string) => Promise,
+ cwd: string | undefined,
+ t: ReturnType,
+) {
+ if (items.length === 0) return null
+
+ const titleKey =
+ bucket === 'attention'
+ ? 'settings.plugins.group.attention'
+ : bucket === 'enabled'
+ ? 'settings.plugins.group.enabled'
+ : 'settings.plugins.group.disabled'
+
+ return (
+
+
+
+
+ {t(titleKey)}
+
+
+ {t('settings.plugins.groupHint', { count: String(items.length) })}
+
+
+
{items.length}
+
+
+ {items.map((plugin) => (
+
+ ))}
+
+
+ )
+}
+
+function SummaryCard({
+ label,
+ value,
+ icon,
+}: {
+ label: string
+ value: string
+ icon: string
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+function StatusPill({ plugin }: { plugin: PluginSummary }) {
+ const t = useTranslation()
+
+ if (plugin.hasErrors) {
+ return (
+
+ {t('settings.plugins.status.attention')}
+
+ )
+ }
+
+ return (
+
+ {plugin.enabled
+ ? t('settings.plugins.status.enabled')
+ : t('settings.plugins.status.disabled')}
+
+ )
+}
+
+function ScopePill({ scope }: { scope: PluginSummary['scope'] }) {
+ const t = useTranslation()
+ return (
+
+ {t(`settings.plugins.scope.${scope}`)}
+
+ )
+}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 63e738e3..c80dbbb0 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -51,6 +51,7 @@ export const en = {
'settings.tab.permissions': 'Permissions',
'settings.tab.general': 'General',
'settings.tab.skills': 'Skills',
+ 'settings.tab.plugins': 'Plugins',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.',
@@ -238,6 +239,73 @@ export const en = {
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': 'Built-in',
+ // Settings > Plugins
+ 'settings.plugins.title': 'Installed Plugins',
+ 'settings.plugins.description': 'Inspect installed plugins, see their health, and apply changes to the desktop runtime.',
+ 'settings.plugins.browserTitle': 'Browse installed plugins',
+ 'settings.plugins.browserEyebrow': 'Plugin Manager',
+ 'settings.plugins.browserDescription': 'Plugins bundle skills, agents, hooks, MCP servers, and language tooling. This view keeps the desktop surface focused on installed plugins, health, and apply-to-runtime feedback.',
+ 'settings.plugins.entryEyebrow': 'Plugin Detail',
+ 'settings.plugins.summary.total': 'Total plugins',
+ 'settings.plugins.summary.enabled': 'Enabled',
+ 'settings.plugins.summary.attention': 'Needs attention',
+ 'settings.plugins.summary.marketplaces': 'Marketplaces',
+ 'settings.plugins.summary.skills': 'Skills',
+ 'settings.plugins.summary.agents': 'Agents',
+ 'settings.plugins.summary.mcp': 'MCP',
+ 'settings.plugins.summary.hooks': 'Hooks',
+ 'settings.plugins.group.attention': 'Needs attention',
+ 'settings.plugins.group.enabled': 'Enabled',
+ 'settings.plugins.group.disabled': 'Disabled',
+ 'settings.plugins.groupHint': '{count} plugins in this section',
+ 'settings.plugins.refresh': 'Refresh',
+ 'settings.plugins.apply': 'Apply changes',
+ 'settings.plugins.applyHint': 'Enable, disable, and update changes become active after applying them to the current desktop runtime.',
+ 'settings.plugins.lastReload': 'Last apply: {enabled} active plugins, {skills} plugin skills, {errors} errors.',
+ 'settings.plugins.reloadToast': 'Applied plugin changes: {enabled} active plugins, {skills} skills, {errors} errors.',
+ 'settings.plugins.marketplacesTitle': 'Known marketplaces',
+ 'settings.plugins.marketplacesHint': 'Read-only summary of marketplace sources already configured for this machine.',
+ 'settings.plugins.marketplaceAutoUpdateOn': 'Auto-update on',
+ 'settings.plugins.marketplaceAutoUpdateOff': 'Auto-update off',
+ 'settings.plugins.marketplaceInstalledCount': '{count} installed',
+ 'settings.plugins.marketplaceUpdatedAt': 'Updated {value}',
+ 'settings.plugins.status.enabled': 'Enabled',
+ 'settings.plugins.status.disabled': 'Disabled',
+ 'settings.plugins.status.attention': 'Attention',
+ 'settings.plugins.scope.user': 'User',
+ 'settings.plugins.scope.project': 'Project',
+ 'settings.plugins.scope.local': 'Local',
+ 'settings.plugins.scope.managed': 'Managed',
+ 'settings.plugins.scope.builtin': 'Built-in',
+ 'settings.plugins.empty': 'No plugins installed',
+ 'settings.plugins.emptyHint': 'Install plugins from Claude Code to manage them here.',
+ 'settings.plugins.back': 'Back to list',
+ 'settings.plugins.noDescription': 'No description available for this plugin.',
+ 'settings.plugins.errorCount': '{count} errors',
+ 'settings.plugins.enable': 'Enable',
+ 'settings.plugins.disable': 'Disable',
+ 'settings.plugins.update': 'Update',
+ 'settings.plugins.uninstall': 'Uninstall',
+ 'settings.plugins.confirmUninstall': 'Uninstall plugin "{name}"? This removes the installed copy for the selected scope.',
+ 'settings.plugins.author': 'Author: {value}',
+ 'settings.plugins.projectPath': 'Project: {value}',
+ 'settings.plugins.installPath': 'Installed at: {value}',
+ 'settings.plugins.managedHint': 'This plugin is managed by policy and cannot be changed from the desktop app.',
+ 'settings.plugins.builtinHint': 'Built-in plugins can be reloaded here, but install and uninstall actions are not exposed.',
+ 'settings.plugins.errorsTitle': 'Plugin errors',
+ 'settings.plugins.capabilitiesTitle': 'Bundled capabilities',
+ 'settings.plugins.capabilitiesHint': 'Capabilities exposed by this plugin after loading its manifest and runtime integrations.',
+ 'settings.plugins.capabilityEmpty': 'None exposed',
+ 'settings.plugins.capability.skills': '{count} skills',
+ 'settings.plugins.capability.agents': '{count} agents',
+ 'settings.plugins.capability.mcpServers': '{count} MCP servers',
+ 'settings.plugins.capabilityLabel.skills': 'Skills',
+ 'settings.plugins.capabilityLabel.commands': 'Commands',
+ 'settings.plugins.capabilityLabel.agents': 'Agents',
+ 'settings.plugins.capabilityLabel.hooks': 'Hooks',
+ 'settings.plugins.capabilityLabel.mcpServers': 'MCP servers',
+ 'settings.plugins.capabilityLabel.lspServers': 'LSP servers',
+
// Settings > About
'settings.tab.about': 'About',
'settings.about.version': 'Version',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 6e353804..dd782131 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -53,6 +53,7 @@ export const zh: Record = {
'settings.tab.permissions': '权限',
'settings.tab.general': '通用',
'settings.tab.skills': '技能',
+ 'settings.tab.plugins': '插件',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。',
@@ -240,6 +241,73 @@ export const zh: Record = {
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': '内置',
+ // Settings > Plugins
+ 'settings.plugins.title': '已安装插件',
+ 'settings.plugins.description': '查看已安装插件、运行状态,并把插件变更应用到桌面端运行时。',
+ 'settings.plugins.browserTitle': '浏览已安装插件',
+ 'settings.plugins.browserEyebrow': '插件管理',
+ 'settings.plugins.browserDescription': '插件会把技能、Agent、Hook、MCP 服务和语言工具打包在一起。这里先聚焦已安装插件、健康状态和应用变更反馈,不把 CLI 的整套复杂交互直接搬过来。',
+ 'settings.plugins.entryEyebrow': '插件详情',
+ 'settings.plugins.summary.total': '插件总数',
+ 'settings.plugins.summary.enabled': '已启用',
+ 'settings.plugins.summary.attention': '需关注',
+ 'settings.plugins.summary.marketplaces': '市场',
+ 'settings.plugins.summary.skills': '技能',
+ 'settings.plugins.summary.agents': 'Agents',
+ 'settings.plugins.summary.mcp': 'MCP',
+ 'settings.plugins.summary.hooks': 'Hooks',
+ 'settings.plugins.group.attention': '需关注',
+ 'settings.plugins.group.enabled': '已启用',
+ 'settings.plugins.group.disabled': '已禁用',
+ 'settings.plugins.groupHint': '此分组下共有 {count} 个插件',
+ 'settings.plugins.refresh': '刷新',
+ 'settings.plugins.apply': '应用变更',
+ 'settings.plugins.applyHint': '启用、禁用或更新后,需要把插件变更重新应用到当前桌面端运行时。',
+ 'settings.plugins.lastReload': '最近一次应用:{enabled} 个活跃插件,{skills} 个插件技能,{errors} 个错误。',
+ 'settings.plugins.reloadToast': '插件变更已应用:{enabled} 个活跃插件,{skills} 个技能,{errors} 个错误。',
+ 'settings.plugins.marketplacesTitle': '已知插件市场',
+ 'settings.plugins.marketplacesHint': '这里只展示当前机器已经配置好的 marketplace 来源摘要。',
+ 'settings.plugins.marketplaceAutoUpdateOn': '自动更新开',
+ 'settings.plugins.marketplaceAutoUpdateOff': '自动更新关',
+ 'settings.plugins.marketplaceInstalledCount': '已安装 {count} 个',
+ 'settings.plugins.marketplaceUpdatedAt': '更新于 {value}',
+ 'settings.plugins.status.enabled': '已启用',
+ 'settings.plugins.status.disabled': '已禁用',
+ 'settings.plugins.status.attention': '需关注',
+ 'settings.plugins.scope.user': '用户',
+ 'settings.plugins.scope.project': '项目',
+ 'settings.plugins.scope.local': '本地',
+ 'settings.plugins.scope.managed': '托管',
+ 'settings.plugins.scope.builtin': '内置',
+ 'settings.plugins.empty': '暂无已安装插件',
+ 'settings.plugins.emptyHint': '先在 Claude Code 里安装插件,桌面端就可以在这里管理。',
+ 'settings.plugins.back': '返回列表',
+ 'settings.plugins.noDescription': '这个插件没有可用描述。',
+ 'settings.plugins.errorCount': '{count} 个错误',
+ 'settings.plugins.enable': '启用',
+ 'settings.plugins.disable': '禁用',
+ 'settings.plugins.update': '更新',
+ 'settings.plugins.uninstall': '卸载',
+ 'settings.plugins.confirmUninstall': '确定卸载插件“{name}”吗?会删除当前 scope 下的安装副本。',
+ 'settings.plugins.author': '作者:{value}',
+ 'settings.plugins.projectPath': '项目:{value}',
+ 'settings.plugins.installPath': '安装位置:{value}',
+ 'settings.plugins.managedHint': '这个插件由策略托管,不能在桌面端修改。',
+ 'settings.plugins.builtinHint': '内置插件支持在这里重新应用,但不开放安装和卸载操作。',
+ 'settings.plugins.errorsTitle': '插件错误',
+ 'settings.plugins.capabilitiesTitle': '打包能力',
+ 'settings.plugins.capabilitiesHint': '根据插件 manifest 和运行时集成解析出的能力列表。',
+ 'settings.plugins.capabilityEmpty': '没有暴露',
+ 'settings.plugins.capability.skills': '{count} 个技能',
+ 'settings.plugins.capability.agents': '{count} 个 Agent',
+ 'settings.plugins.capability.mcpServers': '{count} 个 MCP 服务',
+ 'settings.plugins.capabilityLabel.skills': '技能',
+ 'settings.plugins.capabilityLabel.commands': '命令',
+ 'settings.plugins.capabilityLabel.agents': 'Agents',
+ 'settings.plugins.capabilityLabel.hooks': 'Hooks',
+ 'settings.plugins.capabilityLabel.mcpServers': 'MCP 服务',
+ 'settings.plugins.capabilityLabel.lspServers': 'LSP 服务',
+
// Settings > About
'settings.tab.about': '关于',
'settings.about.version': '版本',
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index e53d0235..80f6d4d2 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -18,6 +18,9 @@ import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useSkillStore } from '../stores/skillStore'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'
+import { usePluginStore } from '../stores/pluginStore'
+import { PluginList } from '../components/plugins/PluginList'
+import { PluginDetail } from '../components/plugins/PluginDetail'
import { ComputerUseSettings } from './ComputerUseSettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
@@ -47,6 +50,7 @@ export function Settings() {
setActiveTab('adapters')} />
setActiveTab('agents')} />
setActiveTab('skills')} />
+ setActiveTab('plugins')} />
setActiveTab('computerUse')} />
@@ -62,6 +66,7 @@ export function Settings() {
{activeTab === 'adapters' &&
}
{activeTab === 'agents' &&
}
{activeTab === 'skills' &&
}
+ {activeTab === 'plugins' &&
}
{activeTab === 'computerUse' &&
}
{activeTab === 'about' &&
}
@@ -1263,6 +1268,31 @@ function SkillSettings() {
)
}
+function PluginSettings() {
+ const selectedPlugin = usePluginStore((s) => s.selectedPlugin)
+ const t = useTranslation()
+
+ if (selectedPlugin) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {t('settings.plugins.title')}
+
+
+ {t('settings.plugins.description')}
+
+
+
+ )
+}
+
// ─── About Settings ──────────────────────────────────────
const GITHUB_REPO = 'https://github.com/NanmiCoder/cc-haha'
diff --git a/desktop/src/stores/pluginStore.ts b/desktop/src/stores/pluginStore.ts
new file mode 100644
index 00000000..5fb39637
--- /dev/null
+++ b/desktop/src/stores/pluginStore.ts
@@ -0,0 +1,157 @@
+import { create } from 'zustand'
+import { pluginsApi } from '../api/plugins'
+import type {
+ PluginDetail,
+ PluginListResponse,
+ PluginReloadSummary,
+ PluginScope,
+ PluginSummary,
+} from '../types/plugin'
+
+type PluginStore = {
+ plugins: PluginSummary[]
+ marketplaces: PluginListResponse['marketplaces']
+ summary: PluginListResponse['summary'] | null
+ selectedPlugin: PluginDetail | null
+ lastReloadSummary: PluginReloadSummary | null
+ isLoading: boolean
+ isDetailLoading: boolean
+ isApplying: boolean
+ error: string | null
+ fetchPlugins: (cwd?: string) => Promise
+ fetchPluginDetail: (id: string, cwd?: string) => Promise
+ reloadPlugins: (cwd?: string) => Promise
+ enablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise
+ disablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise
+ updatePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise
+ uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string) => Promise
+ clearSelection: () => void
+}
+
+export const usePluginStore = create((set, get) => ({
+ plugins: [],
+ marketplaces: [],
+ summary: null,
+ selectedPlugin: null,
+ lastReloadSummary: null,
+ isLoading: false,
+ isDetailLoading: false,
+ isApplying: false,
+ error: null,
+
+ fetchPlugins: async (cwd) => {
+ set({ isLoading: true, error: null })
+ try {
+ const data = await pluginsApi.list(cwd)
+ set({
+ plugins: data.plugins,
+ marketplaces: data.marketplaces,
+ summary: data.summary,
+ isLoading: false,
+ })
+ } catch (err) {
+ set({
+ isLoading: false,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ },
+
+ fetchPluginDetail: async (id, cwd) => {
+ set({ isDetailLoading: true, error: null })
+ try {
+ const { detail } = await pluginsApi.detail(id, cwd)
+ set({ selectedPlugin: detail, isDetailLoading: false })
+ } catch (err) {
+ set({
+ isDetailLoading: false,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ },
+
+ reloadPlugins: async (cwd) => {
+ set({ isApplying: true, error: null })
+ try {
+ const { summary } = await pluginsApi.reload(cwd)
+ await get().fetchPlugins(cwd)
+ const selected = get().selectedPlugin
+ if (selected) {
+ await get().fetchPluginDetail(selected.id, cwd)
+ }
+ set({ isApplying: false, lastReloadSummary: summary })
+ return summary
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ set({ isApplying: false, error: message })
+ throw err
+ }
+ },
+
+ enablePlugin: async (id, scope, cwd) => {
+ return runAction(
+ () => pluginsApi.enable({ id, scope }),
+ set,
+ get,
+ cwd,
+ )
+ },
+
+ disablePlugin: async (id, scope, cwd) => {
+ return runAction(
+ () => pluginsApi.disable({ id, scope }),
+ set,
+ get,
+ cwd,
+ )
+ },
+
+ updatePlugin: async (id, scope, cwd) => {
+ return runAction(
+ () => pluginsApi.update({ id, scope }),
+ set,
+ get,
+ cwd,
+ )
+ },
+
+ uninstallPlugin: async (id, scope, keepData = false, cwd) => {
+ return runAction(
+ () => pluginsApi.uninstall({ id, scope, keepData }),
+ set,
+ get,
+ cwd,
+ true,
+ )
+ },
+
+ clearSelection: () => set({ selectedPlugin: null }),
+}))
+
+async function runAction(
+ action: () => Promise<{ ok: true; message: string }>,
+ set: (updater: Partial) => void,
+ get: () => PluginStore,
+ cwd?: string,
+ clearSelection = false,
+): Promise {
+ set({ isApplying: true, error: null })
+ try {
+ const { message } = await action()
+ await get().fetchPlugins(cwd)
+ const selected = get().selectedPlugin
+ if (clearSelection) {
+ set({ selectedPlugin: null })
+ } else if (selected) {
+ await get().fetchPluginDetail(selected.id, cwd)
+ }
+ set({ isApplying: false })
+ return message
+ } catch (err) {
+ set({
+ isApplying: false,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ throw err
+ }
+}
diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts
index 27903371..f7bf2f29 100644
--- a/desktop/src/stores/uiStore.ts
+++ b/desktop/src/stores/uiStore.ts
@@ -28,7 +28,7 @@ export type Toast = {
duration?: number
}
-export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents' | 'skills' | 'computerUse' | 'about'
+export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents' | 'skills' | 'plugins' | 'computerUse' | 'about'
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
diff --git a/desktop/src/types/plugin.ts b/desktop/src/types/plugin.ts
new file mode 100644
index 00000000..22dd0bad
--- /dev/null
+++ b/desktop/src/types/plugin.ts
@@ -0,0 +1,64 @@
+export type PluginScope = 'user' | 'project' | 'local' | 'managed' | 'builtin'
+
+export type PluginCapabilityKey =
+ | 'commands'
+ | 'agents'
+ | 'skills'
+ | 'hooks'
+ | 'mcpServers'
+ | 'lspServers'
+
+export type PluginCapabilities = Record
+
+export type PluginComponentCounts = Record
+
+export type PluginSummary = {
+ id: string
+ name: string
+ marketplace: string
+ scope: PluginScope
+ enabled: boolean
+ hasErrors: boolean
+ isBuiltin: boolean
+ version?: string
+ description?: string
+ authorName?: string
+ installPath?: string
+ projectPath?: string
+ componentCounts: PluginComponentCounts
+ errors: string[]
+}
+
+export type PluginDetail = PluginSummary & {
+ capabilities: PluginCapabilities
+}
+
+export type PluginMarketplaceSummary = {
+ name: string
+ source: string
+ lastUpdated?: string
+ autoUpdate: boolean
+ installedCount: number
+}
+
+export type PluginListResponse = {
+ plugins: PluginSummary[]
+ marketplaces: PluginMarketplaceSummary[]
+ summary: {
+ total: number
+ enabled: number
+ errorCount: number
+ marketplaceCount: number
+ }
+}
+
+export type PluginReloadSummary = {
+ enabled: number
+ disabled: number
+ skills: number
+ agents: number
+ hooks: number
+ mcpServers: number
+ lspServers: number
+ errors: number
+}
diff --git a/desktop/src/vite-env.d.ts b/desktop/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/desktop/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/src/server/__tests__/plugins.test.ts b/src/server/__tests__/plugins.test.ts
new file mode 100644
index 00000000..48021ba7
--- /dev/null
+++ b/src/server/__tests__/plugins.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+import * as fs from 'node:fs/promises'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import { handlePluginsApi } from '../api/plugins.js'
+
+let tmpDir: string
+let originalConfigDir: string | undefined
+
+function makeRequest(
+ method: string,
+ urlStr: string,
+ body?: Record,
+): { req: Request; url: URL; segments: string[] } {
+ const url = new URL(urlStr, 'http://localhost:3456')
+ const init: RequestInit = { method }
+ if (body) {
+ init.headers = { 'Content-Type': 'application/json' }
+ init.body = JSON.stringify(body)
+ }
+ const req = new Request(url.toString(), init)
+ return {
+ req,
+ url,
+ segments: url.pathname.split('/').filter(Boolean),
+ }
+}
+
+describe('Plugins API', () => {
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-plugins-api-'))
+ originalConfigDir = process.env.CLAUDE_CONFIG_DIR
+ process.env.CLAUDE_CONFIG_DIR = tmpDir
+ })
+
+ afterEach(async () => {
+ if (originalConfigDir === undefined) {
+ delete process.env.CLAUDE_CONFIG_DIR
+ } else {
+ process.env.CLAUDE_CONFIG_DIR = originalConfigDir
+ }
+ await fs.rm(tmpDir, { recursive: true, force: true })
+ })
+
+ it('GET /api/plugins returns an empty plugin list for a clean config', async () => {
+ const { req, url, segments } = makeRequest('GET', '/api/plugins')
+ const res = await handlePluginsApi(req, url, segments)
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as {
+ plugins: unknown[]
+ marketplaces: unknown[]
+ summary: { total: number; enabled: number; errorCount: number }
+ }
+
+ expect(body.plugins).toEqual([])
+ expect(Array.isArray(body.marketplaces)).toBe(true)
+ expect(body.summary.total).toBe(0)
+ expect(body.summary.enabled).toBe(0)
+ expect(body.summary.errorCount).toBe(0)
+ })
+
+ it('POST /api/plugins/reload returns numeric counters', async () => {
+ const { req, url, segments } = makeRequest('POST', '/api/plugins/reload', {})
+ const res = await handlePluginsApi(req, url, segments)
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as {
+ ok: boolean
+ summary: Record
+ }
+
+ expect(body.ok).toBe(true)
+ expect(typeof body.summary.enabled).toBe('number')
+ expect(typeof body.summary.skills).toBe('number')
+ expect(typeof body.summary.errors).toBe('number')
+ })
+})
diff --git a/src/server/api/plugins.ts b/src/server/api/plugins.ts
new file mode 100644
index 00000000..7a6f9794
--- /dev/null
+++ b/src/server/api/plugins.ts
@@ -0,0 +1,106 @@
+import type { PluginScope } from '../../utils/plugins/schemas.js'
+import { ApiError, errorResponse } from '../middleware/errorHandler.js'
+import { PluginService } from '../services/pluginService.js'
+
+const pluginService = new PluginService()
+
+export async function handlePluginsApi(
+ req: Request,
+ url: URL,
+ segments: string[],
+): Promise {
+ try {
+ const method = req.method
+ const sub = segments[2]
+ const cwd = url.searchParams.get('cwd') || undefined
+
+ if (method === 'GET' && !sub) {
+ return Response.json(await pluginService.listPlugins(cwd))
+ }
+
+ if (method === 'GET' && sub === 'detail') {
+ const pluginId = url.searchParams.get('id')
+ if (!pluginId) {
+ throw ApiError.badRequest('Missing required "id" query parameter')
+ }
+ return Response.json({
+ detail: await pluginService.getPluginDetail(pluginId, cwd),
+ })
+ }
+
+ if (method === 'POST' && sub === 'reload') {
+ return Response.json(await pluginService.reloadPlugins(cwd))
+ }
+
+ if (method === 'POST' && sub) {
+ const body = await parseJsonBody(req)
+ const pluginId = asString(body.id)
+ if (!pluginId) {
+ throw ApiError.badRequest('Missing or invalid "id" in request body')
+ }
+
+ const scope = coerceScope(body.scope)
+
+ switch (sub) {
+ case 'enable':
+ return Response.json(await pluginService.enablePlugin(pluginId, scope))
+ case 'disable':
+ return Response.json(await pluginService.disablePlugin(pluginId, scope))
+ case 'update':
+ return Response.json(
+ await pluginService.updatePlugin(pluginId, scope as PluginScope | undefined),
+ )
+ case 'uninstall':
+ return Response.json(
+ await pluginService.uninstallPlugin(
+ pluginId,
+ scope,
+ body.keepData === true,
+ ),
+ )
+ default:
+ throw ApiError.notFound(`Unknown plugins endpoint: ${sub}`)
+ }
+ }
+
+ throw new ApiError(
+ 405,
+ `Method ${method} not allowed on /api/plugins${sub ? `/${sub}` : ''}`,
+ 'METHOD_NOT_ALLOWED',
+ )
+ } catch (error) {
+ return errorResponse(error)
+ }
+}
+
+async function parseJsonBody(req: Request): Promise> {
+ try {
+ return (await req.json()) as Record
+ } catch {
+ throw ApiError.badRequest('Invalid JSON body')
+ }
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+function coerceScope(value: unknown):
+ | 'user'
+ | 'project'
+ | 'local'
+ | 'managed'
+ | undefined {
+ if (value == null) return undefined
+ if (
+ value === 'user' ||
+ value === 'project' ||
+ value === 'local' ||
+ value === 'managed'
+ ) {
+ return value
+ }
+ throw ApiError.badRequest(
+ 'Invalid "scope". Expected one of: user, project, local, managed',
+ )
+}
diff --git a/src/server/router.ts b/src/server/router.ts
index 472808d6..fb3f41d9 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -14,6 +14,7 @@ import { handleTeamsApi } from './api/teams.js'
import { handleFilesystemRoute } from './api/filesystem.js'
import { handleProvidersApi } from './api/providers.js'
import { handleAdaptersApi } from './api/adapters.js'
+import { handlePluginsApi } from './api/plugins.js'
import { handleSkillsApi } from './api/skills.js'
import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js'
@@ -76,6 +77,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise
+ errors: string[]
+}
+
+export type ApiPluginDetail = ApiPluginSummary & {
+ capabilities: ApiPluginCapabilitySet
+}
+
+export type ApiPluginMarketplaceSummary = {
+ name: string
+ source: string
+ lastUpdated?: string
+ autoUpdate: boolean
+ installedCount: number
+}
+
+export type ApiPluginListResponse = {
+ plugins: ApiPluginSummary[]
+ marketplaces: ApiPluginMarketplaceSummary[]
+ summary: {
+ total: number
+ enabled: number
+ errorCount: number
+ marketplaceCount: number
+ }
+}
+
+export type ApiPluginActionResponse = {
+ ok: true
+ message: string
+}
+
+export type ApiPluginReloadResponse = {
+ ok: true
+ summary: {
+ enabled: number
+ disabled: number
+ skills: number
+ agents: number
+ hooks: number
+ mcpServers: number
+ lspServers: number
+ errors: number
+ }
+}
+
+type HydratedPluginState = {
+ enabled: LoadedPlugin[]
+ disabled: LoadedPlugin[]
+ errors: PluginError[]
+}
+
+export class PluginService {
+ async listPlugins(cwd?: string): Promise {
+ const { plugins, marketplaces } = await this.collectPluginState(cwd)
+ return {
+ plugins,
+ marketplaces,
+ summary: {
+ total: plugins.length,
+ enabled: plugins.filter((plugin) => plugin.enabled).length,
+ errorCount: plugins.reduce((sum, plugin) => sum + plugin.errors.length, 0),
+ marketplaceCount: marketplaces.length,
+ },
+ }
+ }
+
+ async getPluginDetail(
+ pluginId: string,
+ cwd?: string,
+ ): Promise {
+ const { plugins, detailById } = await this.collectPluginState(cwd)
+ const detail = detailById.get(pluginId)
+
+ if (!detail) {
+ throw ApiError.notFound(`Plugin not found: ${pluginId}`)
+ }
+
+ return detail
+ }
+
+ async enablePlugin(
+ pluginId: string,
+ scope?: InstallableScope,
+ ): Promise {
+ const result = await enablePluginOp(pluginId, scope)
+ if (!result.success) {
+ throw ApiError.badRequest(result.message)
+ }
+ return { ok: true, message: result.message }
+ }
+
+ async disablePlugin(
+ pluginId: string,
+ scope?: InstallableScope,
+ ): Promise {
+ const result = await disablePluginOp(pluginId, scope)
+ if (!result.success) {
+ throw ApiError.badRequest(result.message)
+ }
+ return { ok: true, message: result.message }
+ }
+
+ async uninstallPlugin(
+ pluginId: string,
+ scope?: InstallableScope,
+ keepData = false,
+ ): Promise {
+ if (!scope) {
+ throw ApiError.badRequest('Plugin uninstall requires a scope')
+ }
+
+ const result = await uninstallPluginOp(pluginId, scope, keepData)
+ if (!result.success) {
+ throw ApiError.badRequest(result.message)
+ }
+ return { ok: true, message: result.message }
+ }
+
+ async updatePlugin(
+ pluginId: string,
+ scope?: PluginScope,
+ ): Promise {
+ if (!scope) {
+ throw ApiError.badRequest('Plugin update requires a scope')
+ }
+
+ const result = await updatePluginOp(pluginId, scope)
+ if (!result.success) {
+ throw ApiError.badRequest(result.message)
+ }
+ return { ok: true, message: result.message }
+ }
+
+ async reloadPlugins(cwd?: string): Promise {
+ clearAllCaches()
+ clearPluginCacheExclusions()
+
+ const pluginState = await this.loadPluginState()
+ const { enabled, disabled, errors } = pluginState
+
+ const [commands, agentDefinitions] = await Promise.all([
+ getPluginCommands(),
+ getAgentDefinitionsWithOverrides(cwd),
+ ])
+
+ const hookCount = await this.getHookCount(enabled)
+ const mcpCounts = await Promise.all(
+ enabled.map(async (plugin) => {
+ const servers = plugin.mcpServers || await loadPluginMcpServers(plugin, errors)
+ return servers ? Object.keys(servers).length : 0
+ }),
+ )
+ const lspCounts = await Promise.all(
+ enabled.map(async (plugin) => {
+ const servers = plugin.lspServers || await loadPluginLspServers(plugin, errors)
+ return servers ? Object.keys(servers).length : 0
+ }),
+ )
+
+ return {
+ ok: true,
+ summary: {
+ enabled: enabled.length,
+ disabled: disabled.length,
+ skills: commands.length,
+ agents: agentDefinitions.allAgents.length,
+ hooks: hookCount,
+ mcpServers: mcpCounts.reduce((sum, count) => sum + count, 0),
+ lspServers: lspCounts.reduce((sum, count) => sum + count, 0),
+ errors: errors.length,
+ },
+ }
+ }
+
+ private async collectPluginState(cwd?: string): Promise<{
+ plugins: ApiPluginSummary[]
+ detailById: Map
+ marketplaces: ApiPluginMarketplaceSummary[]
+ }> {
+ const [pluginState, installedData, marketplaceConfig] = await Promise.all([
+ this.loadPluginState(),
+ Promise.resolve(loadInstalledPluginsV2()),
+ loadKnownMarketplacesConfig(),
+ ])
+
+ const allLoaded = [...pluginState.enabled, ...pluginState.disabled]
+ const loadedById = new Map(
+ allLoaded
+ .filter((plugin) => !plugin.source.endsWith('@inline'))
+ .map((plugin) => [plugin.source, plugin]),
+ )
+
+ const pluginIds = new Set([
+ ...Object.keys(installedData.plugins),
+ ...allLoaded
+ .filter((plugin) => !plugin.source.endsWith('@inline'))
+ .map((plugin) => plugin.source),
+ ])
+
+ const detailById = new Map()
+
+ for (const pluginId of [...pluginIds].sort()) {
+ const installation = this.pickInstallation(
+ installedData.plugins[pluginId] ?? [],
+ cwd,
+ )
+ const loaded = loadedById.get(pluginId)
+ const detail = await this.serializePluginDetail(
+ pluginId,
+ installation,
+ loaded,
+ pluginState.errors,
+ )
+ detailById.set(pluginId, detail)
+ }
+
+ const plugins = [...detailById.values()].map((detail) =>
+ this.toSummary(detail),
+ )
+
+ const marketplaces = Object.entries(marketplaceConfig.marketplaces ?? {})
+ .map(([name, entry]) => ({
+ name,
+ source: getMarketplaceSourceDisplay(entry.source),
+ lastUpdated: entry.lastUpdated,
+ autoUpdate: entry.autoUpdate !== false,
+ installedCount: plugins.filter((plugin) => plugin.marketplace === name).length,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ return { plugins, detailById, marketplaces }
+ }
+
+ private async loadPluginState(): Promise {
+ const result = await loadAllPlugins()
+ await Promise.all(
+ result.enabled.map(async (plugin) => {
+ plugin.mcpServers = plugin.mcpServers || await loadPluginMcpServers(plugin, result.errors)
+ plugin.lspServers = plugin.lspServers || await loadPluginLspServers(plugin, result.errors)
+ }),
+ )
+ return result
+ }
+
+ private async serializePluginDetail(
+ pluginId: string,
+ installation: PluginInstallationEntry | null,
+ loaded: LoadedPlugin | undefined,
+ errors: PluginError[],
+ ): Promise {
+ const { name, marketplace } = parsePluginIdentifier(pluginId)
+ const pluginErrors = this.getErrorsForPlugin(pluginId, name, errors)
+
+ if (!loaded) {
+ return {
+ id: pluginId,
+ name,
+ marketplace: marketplace || 'unknown',
+ scope: installation?.scope ?? 'user',
+ enabled: false,
+ hasErrors: pluginErrors.length > 0,
+ isBuiltin: false,
+ installPath: installation?.installPath,
+ projectPath: installation?.projectPath,
+ errors: pluginErrors,
+ componentCounts: this.countCapabilities(this.emptyCapabilities()),
+ capabilities: this.emptyCapabilities(),
+ }
+ }
+
+ const capabilities = await this.collectCapabilities(loaded)
+ return {
+ id: pluginId,
+ name: loaded.name,
+ marketplace: marketplace || 'unknown',
+ scope: installation?.scope ?? 'user',
+ enabled: loaded.enabled !== false,
+ hasErrors: pluginErrors.length > 0,
+ isBuiltin: Boolean(loaded.isBuiltin),
+ version: loaded.manifest.version,
+ description: loaded.manifest.description,
+ authorName: loaded.manifest.author?.name,
+ installPath: installation?.installPath,
+ projectPath: installation?.projectPath,
+ errors: pluginErrors,
+ componentCounts: this.countCapabilities(capabilities),
+ capabilities,
+ }
+ }
+
+ private async collectCapabilities(
+ plugin: LoadedPlugin,
+ ): Promise {
+ if (plugin.isBuiltin) {
+ const definition = getBuiltinPluginDefinition(plugin.name)
+ return {
+ commands: [],
+ agents: [],
+ skills: definition?.skills?.map((skill) => skill.name) ?? [],
+ hooks: definition?.hooks ? Object.keys(definition.hooks) : [],
+ mcpServers: definition?.mcpServers ? Object.keys(definition.mcpServers) : [],
+ lspServers: [],
+ }
+ }
+
+ return {
+ commands: await this.collectMarkdownEntries([
+ plugin.commandsPath,
+ ...(plugin.commandsPaths ?? []),
+ ]),
+ agents: await this.collectMarkdownEntries([
+ plugin.agentsPath,
+ ...(plugin.agentsPaths ?? []),
+ ]),
+ skills: await this.collectSkillDirs([
+ plugin.skillsPath,
+ ...(plugin.skillsPaths ?? []),
+ ]),
+ hooks: plugin.hooksConfig ? Object.keys(plugin.hooksConfig) : [],
+ mcpServers: plugin.mcpServers ? Object.keys(plugin.mcpServers) : [],
+ lspServers: plugin.lspServers ? Object.keys(plugin.lspServers) : [],
+ }
+ }
+
+ private async collectMarkdownEntries(paths: Array): Promise {
+ const fs = await import('node:fs/promises')
+ const names = new Set()
+
+ for (const dirPath of paths) {
+ if (!dirPath) continue
+
+ try {
+ const entries = await fs.readdir(dirPath, { withFileTypes: true })
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
+ names.add(entry.name.replace(/\.md$/i, ''))
+ }
+ } catch {
+ // Ignore unreadable plugin component directories and keep rendering.
+ }
+ }
+
+ return [...names].sort()
+ }
+
+ private async collectSkillDirs(paths: Array): Promise {
+ const fs = await import('node:fs/promises')
+ const names = new Set()
+
+ for (const dirPath of paths) {
+ if (!dirPath) continue
+
+ try {
+ const entries = await fs.readdir(dirPath, { withFileTypes: true })
+ for (const entry of entries) {
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
+
+ try {
+ const stat = await fs.stat(`${dirPath}/${entry.name}/SKILL.md`)
+ if (stat.isFile()) {
+ names.add(entry.name)
+ }
+ } catch {
+ // Ignore non-skill directories.
+ }
+ }
+ } catch {
+ // Ignore unreadable plugin component directories and keep rendering.
+ }
+ }
+
+ return [...names].sort()
+ }
+
+ private getErrorsForPlugin(
+ pluginId: string,
+ pluginName: string,
+ errors: PluginError[],
+ ): string[] {
+ return errors
+ .filter((error) => {
+ if (error.source === pluginId) return true
+ if ('plugin' in error && error.plugin === pluginName) return true
+ return error.source.startsWith(`${pluginName}@`)
+ })
+ .map(getPluginErrorMessage)
+ }
+
+ private pickInstallation(
+ installations: PluginInstallationEntry[],
+ cwd?: string,
+ ): PluginInstallationEntry | null {
+ if (!installations.length) return null
+
+ const relevantForCwd = cwd
+ ? installations.filter((entry) =>
+ entry.projectPath ? this.isPathWithinProject(cwd, entry.projectPath) : false,
+ )
+ : []
+
+ const localMatch = relevantForCwd.find((entry) => entry.scope === 'local')
+ if (localMatch) return localMatch
+
+ const projectMatch = relevantForCwd.find((entry) => entry.scope === 'project')
+ if (projectMatch) return projectMatch
+
+ const userMatch = installations.find((entry) => entry.scope === 'user')
+ if (userMatch) return userMatch
+
+ return installations[0] ?? null
+ }
+
+ private isPathWithinProject(cwd: string, projectPath: string): boolean {
+ return cwd === projectPath || cwd.startsWith(`${projectPath}${sep}`)
+ }
+
+ private emptyCapabilities(): ApiPluginCapabilitySet {
+ return {
+ commands: [],
+ agents: [],
+ skills: [],
+ hooks: [],
+ mcpServers: [],
+ lspServers: [],
+ }
+ }
+
+ private countCapabilities(
+ capabilities: ApiPluginCapabilitySet,
+ ): Record {
+ return {
+ commands: capabilities.commands.length,
+ agents: capabilities.agents.length,
+ skills: capabilities.skills.length,
+ hooks: capabilities.hooks.length,
+ mcpServers: capabilities.mcpServers.length,
+ lspServers: capabilities.lspServers.length,
+ }
+ }
+
+ private toSummary(detail: ApiPluginDetail): ApiPluginSummary {
+ return {
+ id: detail.id,
+ name: detail.name,
+ marketplace: detail.marketplace,
+ scope: detail.scope,
+ enabled: detail.enabled,
+ hasErrors: detail.hasErrors,
+ isBuiltin: detail.isBuiltin,
+ version: detail.version,
+ description: detail.description,
+ authorName: detail.authorName,
+ installPath: detail.installPath,
+ projectPath: detail.projectPath,
+ componentCounts: detail.componentCounts,
+ errors: detail.errors,
+ }
+ }
+
+ private async getHookCount(plugins: LoadedPlugin[]): Promise {
+ try {
+ await loadPluginHooks()
+ } catch {
+ // Hook loading failures are already represented in the shared plugin errors.
+ }
+
+ return plugins.reduce((sum, plugin) => {
+ if (!plugin.hooksConfig) return sum
+ return sum + Object.values(plugin.hooksConfig).reduce((hookSum, matchers) => (
+ hookSum + (matchers?.reduce((matcherSum, matcher) => matcherSum + matcher.hooks.length, 0) ?? 0)
+ ), 0)
+ }, 0)
+ }
+}
diff --git a/src/utils/plugins/cacheUtils.ts b/src/utils/plugins/cacheUtils.ts
index 969898ea..f3931fef 100644
--- a/src/utils/plugins/cacheUtils.ts
+++ b/src/utils/plugins/cacheUtils.ts
@@ -9,6 +9,7 @@ import { logForDebugging } from '../debug.js'
import { getErrnoCode } from '../errors.js'
import { logError } from '../log.js'
import { loadInstalledPluginsFromDisk } from './installedPluginsManager.js'
+import { clearInstalledPluginsCache } from './installedPluginsManager.js'
import { clearPluginAgentCache } from './loadPluginAgents.js'
import { clearPluginCommandCache } from './loadPluginCommands.js'
import {
@@ -24,6 +25,7 @@ const ORPHANED_AT_FILENAME = '.orphaned_at'
const CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export function clearAllPluginCaches(): void {
+ clearInstalledPluginsCache()
clearPluginCache()
clearPluginCommandCache()
clearPluginAgentCache()