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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-21 23:21:16 +08:00
parent 86cd2b8d18
commit 375d587ed1
17 changed files with 2109 additions and 2 deletions

View File

@ -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: () => <div>Adapter Settings Mock</div>,
}))
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(<Settings />)
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(<Settings />)
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()
})
})

View File

@ -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

View File

@ -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<PluginListResponse>(`/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}`)
},
}

View File

@ -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<string | null>(null)
const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
if (isDetailLoading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
)
}
if (!selectedPlugin) return null
const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin'
const runAction = async (key: string, fn: () => Promise<string>) => {
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 (
<div className="flex flex-col gap-4 min-w-0">
<div>
<button
onClick={clearSelection}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
{t('settings.plugins.back')}
</button>
</div>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.plugins.entryEyebrow')}
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
{selectedPlugin.name}
</h3>
<StatusPill enabled={selectedPlugin.enabled} hasErrors={selectedPlugin.hasErrors} />
<MetaPill>{t(`settings.plugins.scope.${selectedPlugin.scope}`)}</MetaPill>
<MetaPill>{selectedPlugin.marketplace}</MetaPill>
{selectedPlugin.version && <MetaPill>v{selectedPlugin.version}</MetaPill>}
</div>
<p className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
{selectedPlugin.description || t('settings.plugins.noDescription')}
</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
{selectedPlugin.authorName && (
<span>{t('settings.plugins.author', { value: selectedPlugin.authorName })}</span>
)}
{selectedPlugin.projectPath && (
<span>{t('settings.plugins.projectPath', { value: selectedPlugin.projectPath })}</span>
)}
{selectedPlugin.installPath && (
<span>{t('settings.plugins.installPath', { value: selectedPlugin.installPath })}</span>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-2">
<DetailStat
label={t('settings.plugins.summary.skills')}
value={String(selectedPlugin.componentCounts.skills)}
icon="auto_awesome"
/>
<DetailStat
label={t('settings.plugins.summary.agents')}
value={String(selectedPlugin.componentCounts.agents)}
icon="smart_toy"
/>
<DetailStat
label={t('settings.plugins.summary.mcp')}
value={String(selectedPlugin.componentCounts.mcpServers)}
icon="hub"
/>
<DetailStat
label={t('settings.plugins.summary.hooks')}
value={String(selectedPlugin.componentCounts.hooks)}
icon="bolt"
/>
</div>
</div>
</section>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
<div className="flex flex-wrap gap-2">
{canMutate && (
selectedPlugin.enabled ? (
<Button
variant="secondary"
size="sm"
loading={isApplying && actionKey === 'disable'}
onClick={() => void runAction('disable', () => disablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
>
{t('settings.plugins.disable')}
</Button>
) : (
<Button
size="sm"
loading={isApplying && actionKey === 'enable'}
onClick={() => void runAction('enable', () => enablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
>
{t('settings.plugins.enable')}
</Button>
)
)}
{canMutate && (
<Button
variant="secondary"
size="sm"
loading={isApplying && actionKey === 'update'}
onClick={() => void runAction('update', () => updatePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
>
{t('settings.plugins.update')}
</Button>
)}
<Button
variant="secondary"
size="sm"
loading={isApplying && actionKey === 'reload'}
onClick={() => void handleReload()}
>
{t('settings.plugins.apply')}
</Button>
{canMutate && (
<Button
variant="danger"
size="sm"
loading={isApplying && actionKey === 'uninstall'}
onClick={() => {
if (!confirmUninstall()) return
void runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir))
}}
>
{t('settings.plugins.uninstall')}
</Button>
)}
</div>
{!canMutate && (
<p className="mt-3 text-xs text-[var(--color-text-tertiary)]">
{selectedPlugin.scope === 'managed'
? t('settings.plugins.managedHint')
: t('settings.plugins.builtinHint')}
</p>
)}
<p className="mt-3 text-xs text-[var(--color-text-tertiary)]">
{t('settings.plugins.applyHint')}
</p>
</section>
{selectedPlugin.errors.length > 0 && (
<section className="rounded-2xl border border-[var(--color-error)]/20 bg-[var(--color-error)]/6 px-5 py-4">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-[18px] text-[var(--color-error)]">
error
</span>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.plugins.errorsTitle')}
</h4>
</div>
<div className="flex flex-col gap-2">
{selectedPlugin.errors.map((error) => (
<div
key={error}
className="rounded-xl border border-[var(--color-error)]/15 bg-[var(--color-surface)] px-3 py-3 text-sm text-[var(--color-text-secondary)]"
>
{error}
</div>
))}
</div>
</section>
)}
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.plugins.capabilitiesTitle')}
</h4>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.plugins.capabilitiesHint')}
</p>
</div>
<div className="grid gap-4 p-4 md:grid-cols-2 xl:grid-cols-3">
{CAPABILITY_ORDER.map((key) => {
const items = selectedPlugin.capabilities[key]
return (
<div
key={key}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{t(`settings.plugins.capabilityLabel.${key}`)}
</div>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{items.length}
</span>
</div>
{items.length > 0 ? (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<span
key={item}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] break-all"
>
{item}
</span>
))}
</div>
) : (
<div className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.plugins.capabilityEmpty')}
</div>
)}
</div>
)
})}
</div>
</section>
</div>
)
}
function MetaPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{children}
</span>
)
}
function DetailStat({
label,
value,
icon,
}: {
label: string
value: string
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
</div>
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
{value}
</div>
</div>
)
}
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 (
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${classes}`}>
{label}
</span>
)
}

View File

@ -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<PluginBucket, PluginSummary[]> = {
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 (
<div className="flex justify-center py-12">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
)
}
if (error) {
return <div className="text-sm text-[var(--color-error)] py-4">{error}</div>
}
if (plugins.length === 0) {
return (
<div className="text-center py-12 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
extension
</span>
<p className="text-sm text-[var(--color-text-tertiary)]">
{t('settings.plugins.empty')}
</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.plugins.emptyHint')}
</p>
</div>
)
}
return (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.5fr)_minmax(340px,1fr)] xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.plugins.browserEyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
extension
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.plugins.browserTitle')}
</h3>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.plugins.browserDescription')}
</p>
{lastReloadSummary && (
<p className="mt-3 text-xs text-[var(--color-text-tertiary)]">
{t('settings.plugins.lastReload', {
enabled: String(lastReloadSummary.enabled),
skills: String(lastReloadSummary.skills),
errors: String(lastReloadSummary.errors),
})}
</p>
)}
</div>
<div className="flex flex-col gap-3 min-w-0">
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-4">
<SummaryCard
label={t('settings.plugins.summary.total')}
value={String(summary?.total ?? plugins.length)}
icon="extension"
/>
<SummaryCard
label={t('settings.plugins.summary.enabled')}
value={String(summary?.enabled ?? plugins.filter((plugin) => plugin.enabled).length)}
icon="check_circle"
/>
<SummaryCard
label={t('settings.plugins.summary.attention')}
value={String(grouped.attention.length)}
icon="warning"
/>
<SummaryCard
label={t('settings.plugins.summary.marketplaces')}
value={String(summary?.marketplaceCount ?? marketplaces.length)}
icon="storefront"
/>
</div>
<div className="flex flex-wrap gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => void fetchPlugins(currentWorkDir)}>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{t('settings.plugins.refresh')}
</Button>
<Button size="sm" onClick={handleReload} loading={isApplying}>
<span className="material-symbols-outlined text-[16px]">sync</span>
{t('settings.plugins.apply')}
</Button>
</div>
</div>
</div>
</section>
{marketplaces.length > 0 && (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.plugins.marketplacesTitle')}
</h4>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.plugins.marketplacesHint')}
</p>
</div>
<div className="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-3">
{marketplaces.map((marketplace) => (
<div
key={marketplace.name}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{marketplace.name}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
marketplace.autoUpdate
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}`}>
{marketplace.autoUpdate
? t('settings.plugins.marketplaceAutoUpdateOn')
: t('settings.plugins.marketplaceAutoUpdateOff')}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
{marketplace.source}
</div>
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('settings.plugins.marketplaceInstalledCount', { count: String(marketplace.installedCount) })}</span>
{marketplace.lastUpdated && (
<span>{t('settings.plugins.marketplaceUpdatedAt', { value: new Date(marketplace.lastUpdated).toLocaleString() })}</span>
)}
</div>
</div>
))}
</div>
</section>
)}
{renderGroup('attention', grouped.attention, fetchPluginDetail, currentWorkDir, t)}
{renderGroup('enabled', grouped.enabled, fetchPluginDetail, currentWorkDir, t)}
{renderGroup('disabled', grouped.disabled, fetchPluginDetail, currentWorkDir, t)}
</div>
)
}
function renderGroup(
bucket: PluginBucket,
items: PluginSummary[],
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>,
cwd: string | undefined,
t: ReturnType<typeof useTranslation>,
) {
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 (
<section
key={bucket}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t(titleKey)}
</h4>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)] mt-1">
{t('settings.plugins.groupHint', { count: String(items.length) })}
</p>
</div>
<span className="text-xs text-[var(--color-text-tertiary)]">{items.length}</span>
</div>
<div className="flex flex-col p-2">
{items.map((plugin) => (
<button
key={plugin.id}
onClick={() => void fetchPluginDetail(plugin.id, cwd)}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
{plugin.hasErrors ? 'warning' : plugin.enabled ? 'extension' : 'extension_off'}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
{plugin.name}
</span>
<StatusPill plugin={plugin} />
<ScopePill scope={plugin.scope} />
{plugin.version && (
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
v{plugin.version}
</span>
)}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
{plugin.description || t('settings.plugins.noDescription')}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{plugin.marketplace}</span>
{plugin.componentCounts.skills > 0 && (
<span>{t('settings.plugins.capability.skills', { count: String(plugin.componentCounts.skills) })}</span>
)}
{plugin.componentCounts.agents > 0 && (
<span>{t('settings.plugins.capability.agents', { count: String(plugin.componentCounts.agents) })}</span>
)}
{plugin.componentCounts.mcpServers > 0 && (
<span>{t('settings.plugins.capability.mcpServers', { count: String(plugin.componentCounts.mcpServers) })}</span>
)}
{plugin.errors.length > 0 && (
<span className="text-[var(--color-error)]">
{t('settings.plugins.errorCount', { count: String(plugin.errors.length) })}
</span>
)}
</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
chevron_right
</span>
</div>
</button>
))}
</div>
</section>
)
}
function SummaryCard({
label,
value,
icon,
}: {
label: string
value: string
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
{value}
</div>
</div>
)
}
function StatusPill({ plugin }: { plugin: PluginSummary }) {
const t = useTranslation()
if (plugin.hasErrors) {
return (
<span className="rounded-full bg-[var(--color-error)]/12 px-2 py-0.5 text-[10px] font-medium text-[var(--color-error)]">
{t('settings.plugins.status.attention')}
</span>
)
}
return (
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
plugin.enabled
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}`}>
{plugin.enabled
? t('settings.plugins.status.enabled')
: t('settings.plugins.status.disabled')}
</span>
)
}
function ScopePill({ scope }: { scope: PluginSummary['scope'] }) {
const t = useTranslation()
return (
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t(`settings.plugins.scope.${scope}`)}
</span>
)
}

View File

@ -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',

View File

@ -53,6 +53,7 @@ export const zh: Record<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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': '版本',

View File

@ -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() {
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
</div>
<div className="border-t border-[var(--color-border)]/40 pt-1">
@ -62,6 +66,7 @@ export function Settings() {
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
{activeTab === 'about' && <AboutSettings />}
</div>
@ -1263,6 +1268,31 @@ function SkillSettings() {
)
}
function PluginSettings() {
const selectedPlugin = usePluginStore((s) => s.selectedPlugin)
const t = useTranslation()
if (selectedPlugin) {
return (
<div className="w-full min-w-0">
<PluginDetail />
</div>
)
}
return (
<div className="w-full min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.plugins.title')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
{t('settings.plugins.description')}
</p>
<PluginList />
</div>
)
}
// ─── About Settings ──────────────────────────────────────
const GITHUB_REPO = 'https://github.com/NanmiCoder/cc-haha'

View File

@ -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<void>
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>
reloadPlugins: (cwd?: string) => Promise<PluginReloadSummary>
enablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
disablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
updatePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string) => Promise<string>
clearSelection: () => void
}
export const usePluginStore = create<PluginStore>((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<PluginStore>) => void,
get: () => PluginStore,
cwd?: string,
clearSelection = false,
): Promise<string> {
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
}
}

View File

@ -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'

View File

@ -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<PluginCapabilityKey, string[]>
export type PluginComponentCounts = Record<PluginCapabilityKey, number>
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
}

1
desktop/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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<string, unknown>,
): { 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<string, number>
}
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')
})
})

106
src/server/api/plugins.ts Normal file
View File

@ -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<Response> {
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<Record<string, unknown>> {
try {
return (await req.json()) as Record<string, unknown>
} 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',
)
}

View File

@ -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<Response
case 'skills':
return handleSkillsApi(req, url, segments)
case 'plugins':
return handlePluginsApi(req, url, segments)
case 'computer-use':
return handleComputerUseApi(req, url, segments)

View File

@ -0,0 +1,530 @@
import { sep } from 'node:path'
import { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js'
import {
disablePluginOp,
enablePluginOp,
type InstallableScope,
uninstallPluginOp,
updatePluginOp,
} from '../../services/plugins/pluginOperations.js'
import { getAgentDefinitionsWithOverrides } from '../../tools/AgentTool/loadAgentsDir.js'
import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
import { getPluginErrorMessage } from '../../types/plugin.js'
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
import {
getMarketplaceSourceDisplay,
} from '../../utils/plugins/marketplaceHelpers.js'
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
import {
loadKnownMarketplacesConfig,
} from '../../utils/plugins/marketplaceManager.js'
import { loadPluginLspServers } from '../../utils/plugins/lspPluginIntegration.js'
import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js'
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
import { loadPluginHooks } from '../../utils/plugins/loadPluginHooks.js'
import { getPluginCommands } from '../../utils/plugins/loadPluginCommands.js'
import { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js'
import type {
PluginInstallationEntry,
PluginScope,
} from '../../utils/plugins/schemas.js'
import { ApiError } from '../middleware/errorHandler.js'
export type ApiPluginCapabilitySet = {
commands: string[]
agents: string[]
skills: string[]
hooks: string[]
mcpServers: string[]
lspServers: string[]
}
export type ApiPluginSummary = {
id: string
name: string
marketplace: string
scope: PluginScope | 'builtin'
enabled: boolean
hasErrors: boolean
isBuiltin: boolean
version?: string
description?: string
authorName?: string
installPath?: string
projectPath?: string
componentCounts: Record<keyof ApiPluginCapabilitySet, number>
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<ApiPluginListResponse> {
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<ApiPluginDetail> {
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<ApiPluginActionResponse> {
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<ApiPluginActionResponse> {
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<ApiPluginActionResponse> {
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<ApiPluginActionResponse> {
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<ApiPluginReloadResponse> {
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<string, ApiPluginDetail>
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<string>([
...Object.keys(installedData.plugins),
...allLoaded
.filter((plugin) => !plugin.source.endsWith('@inline'))
.map((plugin) => plugin.source),
])
const detailById = new Map<string, ApiPluginDetail>()
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<HydratedPluginState> {
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<ApiPluginDetail> {
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<ApiPluginCapabilitySet> {
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<string | undefined>): Promise<string[]> {
const fs = await import('node:fs/promises')
const names = new Set<string>()
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<string | undefined>): Promise<string[]> {
const fs = await import('node:fs/promises')
const names = new Set<string>()
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<keyof ApiPluginCapabilitySet, number> {
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<number> {
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)
}
}

View File

@ -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()