From 375d587ed1e691e33394bf536c3000f12e9e463f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 21 Apr 2026 23:21:16 +0800 Subject: [PATCH] Expose desktop plugin management through first-class APIs The desktop app could read plugin-produced skills and agents, but it had no plugin control plane of its own. This adds a dedicated Settings tab backed by server-side plugin APIs so installed plugins can be inspected, enabled, disabled, updated, reloaded, and uninstalled from the WebUI. The implementation also teaches browser-based desktop dev sessions to honor a custom backend URL, which made it possible to run isolated worktree ports for real UI automation. During verification, the long-lived desktop server kept a stale installed-plugin snapshot after external CLI mutations, so cache clearing now resets that session-level plugin installation state as well. Constraint: Desktop WebUI needed an isolated backend URL instead of the hard-coded 127.0.0.1:3456 fallback Constraint: Reuse existing plugin operations and loaders instead of rebuilding plugin lifecycle logic in the desktop layer Rejected: Fold plugin management into Skills or Adapters | mixed unrelated lifecycles and hid plugin-specific health/actions Rejected: Expose only read-only plugin status in desktop | did not satisfy enable-disable-reload-uninstall verification needs Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep desktop plugin actions routed through the shared plugin operation layer and clear installed-plugin session caches when plugin state changes externally Tested: cd desktop && bun run lint Tested: cd desktop && bun run test -- src/__tests__/pluginsSettings.test.tsx Tested: bun test src/server/__tests__/plugins.test.ts src/server/__tests__/skills.test.ts Tested: Browser automation against isolated ports 15120/38456 covering discord plugin list/detail/disable/apply/enable/update/uninstall flows Not-tested: Full desktop session runtime parity with CLI /reload-plugins AppState refresh beyond the new desktop API path --- .../src/__tests__/pluginsSettings.test.tsx | 227 ++++++++ desktop/src/api/client.ts | 9 +- desktop/src/api/plugins.ts | 43 ++ .../src/components/plugins/PluginDetail.tsx | 360 ++++++++++++ desktop/src/components/plugins/PluginList.tsx | 362 ++++++++++++ desktop/src/i18n/locales/en.ts | 68 +++ desktop/src/i18n/locales/zh.ts | 68 +++ desktop/src/pages/Settings.tsx | 30 + desktop/src/stores/pluginStore.ts | 157 ++++++ desktop/src/stores/uiStore.ts | 2 +- desktop/src/types/plugin.ts | 64 +++ desktop/src/vite-env.d.ts | 1 + src/server/__tests__/plugins.test.ts | 78 +++ src/server/api/plugins.ts | 106 ++++ src/server/router.ts | 4 + src/server/services/pluginService.ts | 530 ++++++++++++++++++ src/utils/plugins/cacheUtils.ts | 2 + 17 files changed, 2109 insertions(+), 2 deletions(-) create mode 100644 desktop/src/__tests__/pluginsSettings.test.tsx create mode 100644 desktop/src/api/plugins.ts create mode 100644 desktop/src/components/plugins/PluginDetail.tsx create mode 100644 desktop/src/components/plugins/PluginList.tsx create mode 100644 desktop/src/stores/pluginStore.ts create mode 100644 desktop/src/types/plugin.ts create mode 100644 desktop/src/vite-env.d.ts create mode 100644 src/server/__tests__/plugins.test.ts create mode 100644 src/server/api/plugins.ts create mode 100644 src/server/services/pluginService.ts 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()