mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
86cd2b8d18
commit
375d587ed1
227
desktop/src/__tests__/pluginsSettings.test.tsx
Normal file
227
desktop/src/__tests__/pluginsSettings.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
|
||||
43
desktop/src/api/plugins.ts
Normal file
43
desktop/src/api/plugins.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
360
desktop/src/components/plugins/PluginDetail.tsx
Normal file
360
desktop/src/components/plugins/PluginDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
362
desktop/src/components/plugins/PluginList.tsx
Normal file
362
desktop/src/components/plugins/PluginList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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': '版本',
|
||||
|
||||
@ -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'
|
||||
|
||||
157
desktop/src/stores/pluginStore.ts
Normal file
157
desktop/src/stores/pluginStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
|
||||
64
desktop/src/types/plugin.ts
Normal file
64
desktop/src/types/plugin.ts
Normal 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
1
desktop/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
78
src/server/__tests__/plugins.test.ts
Normal file
78
src/server/__tests__/plugins.test.ts
Normal 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
106
src/server/api/plugins.ts
Normal 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',
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
530
src/server/services/pluginService.ts
Normal file
530
src/server/services/pluginService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user