diff --git a/docs/extensions/adr-001-contract-spike.md b/docs/extensions/adr-001-contract-spike.md new file mode 100644 index 00000000..97061688 --- /dev/null +++ b/docs/extensions/adr-001-contract-spike.md @@ -0,0 +1,307 @@ +# ADR-001: Extension Store Contract Spike + +**Date**: 2026-03-07 +**Status**: Accepted + +## Context + +Extension Store нуждается в точных внешних контрактах перед написанием парсеров. Этот ADR фиксирует результаты contract spike. + +--- + +## 1. Plugin CLI Contracts + +### Verified CLI Flags + +| Command | Syntax | Default scope | +|---------|--------|---------------| +| install | `claude plugin install [-s scope] ` | `user` | +| uninstall | `claude plugin uninstall [-s scope] ` | `user` | +| list | `claude plugin list [--json] [--available]` | — | +| enable | `claude plugin enable [-s scope] ` | — | +| disable | `claude plugin disable [plugin]` | — | + +**Scope flag**: `-s, --scope ` — values: `user`, `project`, `local` + +**qualifiedName format**: `@` (e.g. `context7@claude-plugins-official`) + +### Installed State — Source of Truth + +**File**: `~/.claude/plugins/installed_plugins.json` + +```json +{ + "version": 2, + "plugins": { + "": [ + { + "scope": "user", + "installPath": "/Users/.../.claude/plugins/cache///", + "version": "1.0.0", + "installedAt": "2026-03-01T11:14:21.926Z", + "lastUpdated": "2026-03-01T11:14:21.926Z", + "gitCommitSha": "..." + } + ] + } +} +``` + +- Key = `qualifiedName` = `@` +- Value = array (one entry per scope installation) +- `scope`: `"user"` | `"project"` | `"local"` +- **pluginId** for V1 = `qualifiedName` (globally unique) + +### Install Counts + +**File**: `~/.claude/plugins/install-counts-cache.json` + +```json +{ + "": // NOT qualifiedName, just name +} +``` + +- Key = plugin `name` (without marketplace suffix) +- 157 entries in current cache + +### Marketplaces + +**File**: `~/.claude/plugins/known_marketplaces.json` + +```json +{ + "": { + "source": { "source": "github", "repo": "/" }, + "installLocation": "...", + "lastUpdated": "..." + } +} +``` + +- V1: we read only `claude-plugins-official` marketplace +- Marketplace manifest: `raw.githubusercontent.com///main/.claude-plugin/marketplace.json` +- Supports ETag/If-None-Match → 304 Not Modified + +### `claude plugin list --json` + +- Supports `--json` flag +- Supports `--available` flag (requires `--json`) +- Output format: TBD (не тестировали мутирующе, но флаг существует) + +--- + +## 2. MCP CLI Contracts + +### Verified CLI Flags + +| Command | Syntax | +|---------|--------| +| add (stdio) | `claude mcp add [-s scope] [-e KEY=val...] -- [args...]` | +| add (http) | `claude mcp add [-s scope] -t http [-H "Header: val"...] ` | +| add (sse) | `claude mcp add [-s scope] -t sse [-H "Header: val"...] ` | +| remove | `claude mcp remove [-s scope] ` | +| list | `claude mcp list` | +| get | `claude mcp get ` | + +**Scope flag**: `-s, --scope ` — values: `local` (default), `user`, `project` + +**Transport flag**: `-t, --transport ` — values: `stdio` (default), `sse`, `http` + +**Env flag**: `-e, --env ` — format: `KEY=value` + +**Header flag**: `-H, --header ` — format: `"Key: value"` — YES, SUPPORTED! + +**OAuth**: `--client-id`, `--client-secret`, `--callback-port` — not needed for V1 + +### Installed State — Source of Truth + +**User scope**: `~/.claude.json` → `mcpServers` key +**Project scope**: `.mcp.json` in project root +**Local scope**: определяется в Phase 0 (likely `~/.claude.json`) + +--- + +## 3. Official MCP Registry API + +**Base URL**: `https://registry.modelcontextprotocol.io/v0.1/servers` + +**Pagination**: cursor-based via `metadata.nextCursor` + +**Query params**: +- `limit=N` — items per page +- `search=` — text search +- `cursor=` — pagination + +**Response structure**: +```json +{ + "servers": [ + { + "server": { + "name": "io.github.upstash/context7", // reverse-DNS ID + "description": "...", + "title": "Context7", // display name (optional) + "version": "1.0.30", + "repository": { "url": "...", "source": "github" }, + "packages": [{ // npm install info (optional) + "registryType": "npm", + "identifier": "@upstash/context7-mcp", + "version": "1.0.30", + "transport": { "type": "stdio" }, + "environmentVariables": [{ + "name": "CONTEXT7_API_KEY", + "description": "...", + "isSecret": true, + "format": "string" + }] + }], + "remotes": [{ // HTTP/SSE install info (optional) + "type": "streamable-http", + "url": "https://...", + "headers": [{ // auth headers (optional) + "name": "Authorization", + "description": "...", + "isRequired": true, + "isSecret": true, + "value": "Bearer {key}" // template (optional) + }] + }] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "isLatest": true + } + } + } + ], + "metadata": { + "nextCursor": "...", + "count": N + } +} +``` + +**Key fields for install**: +- `packages[0].identifier` → npm package name +- `packages[0].version` → npm version +- `packages[0].transport.type` → `"stdio"` +- `packages[0].environmentVariables` → env var definitions +- `remotes[0].type` → `"streamable-http"` | `"sse"` +- `remotes[0].url` → HTTP endpoint +- `remotes[0].headers` → auth headers (with `isSecret`, `isRequired`) + +**Version handling**: Multiple versions of same server returned separately. Use `_meta.isLatest: true` to pick latest. + +**Auth**: No authentication required. + +--- + +## 4. Glama API + +**Base URL**: `https://glama.ai/api/mcp/v1/servers` + +**Pagination**: cursor-based via `pageInfo.endCursor` + +**Query params**: +- `first=N` — items per page +- `search=` — text search +- `after=` — pagination cursor + +**Response structure**: +```json +{ + "pageInfo": { + "endCursor": "...", + "hasNextPage": true, + "hasPreviousPage": false, + "startCursor": "..." + }, + "servers": [{ + "id": "iu27vfrji2", + "name": "clelp-mcp-server", + "namespace": "oscarsterling", + "description": "...", + "slug": "clelp-mcp-server", + "url": "https://glama.ai/mcp/servers/iu27vfrji2", + "repository": { "url": "https://github.com/..." }, + "spdxLicense": { "name": "MIT License", "url": "..." }, + "tools": [], + "attributes": [], + "environmentVariablesJsonSchema": null + }] +} +``` + +**Key differences from Official Registry**: +- NO install info (no `packages`, no `remotes`) → can't auto-install Glama-only servers +- Has `spdxLicense` → enrichment data +- Has `tools[]` → enrichment data +- Has `url` → link to Glama page +- Pagination: `after` param (not `cursor`) + +**Auth**: No authentication required. + +--- + +## 5. Marketplace JSON Schema + +**URL**: `https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json` + +```json +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "claude-plugins-official", + "description": "...", + "owner": { "name": "Anthropic", "email": "..." }, + "plugins": [ + { + "name": "typescript-lsp", + "description": "...", + "version": "1.0.0", + "author": { "name": "Anthropic", "email": "..." }, + "source": "./plugins/typescript-lsp", // local path OR object with URL + "category": "development", + "strict": false, + "lspServers": { ... } // optional + } + ] +} +``` + +**Plugin fields**: +- `name`: display/install name +- `source`: string (local path) or `{ source: "url", url: "..." }` (external) +- `category`: open-ended string +- `lspServers`: optional dict → hasLspServers capability +- No `mcpServers`, `agents`, `commands`, `hooks` found in current V1 marketplace +- `homepage`: optional string (external plugins) +- `tags`: NOT present in marketplace.json (will be empty in V1) + +**Categories found**: database, deployment, design, development, learning, monitoring, productivity, security, testing + +**ETag support**: Yes — `If-None-Match` → 304 Not Modified + +--- + +## 6. Decisions + +| Decision | Value | +|----------|-------| +| Scope | Electron-only V1 | +| Plugin identity key (`pluginId`) | `@` = qualifiedName | +| Plugin install target | `qualifiedName` resolved by main from catalog | +| Plugin scope flag | `-s, --scope` (short) / `--scope` (long) | +| MCP scope flag | `-s, --scope` | +| MCP transport flag | `-t, --transport` | +| MCP env flag | `-e, --env KEY=val` | +| MCP header flag | `-H, --header "Key: val"` — **SUPPORTED** | +| MCP default scope | `local` | +| Plugin default scope | `user` | +| Official Registry API version | `v0.1` | +| Official Registry pagination | cursor-based, `cursor` param | +| Glama pagination | cursor-based, `after` param | +| Latest version filter | `_meta.isLatest: true` | +| Capability flags V1 | `hasLspServers` only (others not in current marketplace) | +| Install counts key | plugin `name` (without `@marketplace`) | diff --git a/src/main/index.ts b/src/main/index.ts index a8a0376d..d06e76f6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,17 @@ import { TeamProvisioningService, UpdaterService, } from './services'; +import { + ExtensionFacadeService, + GlamaMcpEnrichmentService, + McpCatalogAggregator, + McpInstallationStateService, + McpInstallService, + OfficialMcpRegistryService, + PluginCatalogService, + PluginInstallationStateService, + PluginInstallService, +} from './services/extensions'; import type { FileChangeEvent } from '@main/types'; import type { TeamChangeEvent } from '@shared/types'; @@ -431,9 +442,11 @@ function wireFileWatcherEvents(context: ServiceContext): void { // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { if (teamDataService) { - void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) => - logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`) - ); + void teamDataService + .reconcileTeamArtifacts(teamName) + .catch((e: unknown) => + logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`) + ); } // Auto-relay ONLY lead-inbox changes into the live lead process. @@ -485,9 +498,11 @@ function wireFileWatcherEvents(context: ServiceContext): void { // --- Task change events: notify lead when teammate starts a task via CLI --- if (row.type === 'task' && detail.endsWith('.json') && teamDataService) { - void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) => - logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`) - ); + void teamDataService + .reconcileTeamArtifacts(teamName) + .catch((e: unknown) => + logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`) + ); const taskId = detail.replace('.json', ''); void teamDataService @@ -648,6 +663,24 @@ function initializeServices(): void { const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const reviewApplier = new ReviewApplierService(); + // Extension Store services + const pluginCatalogService = new PluginCatalogService(); + const pluginStateService = new PluginInstallationStateService(); + const officialMcpRegistry = new OfficialMcpRegistryService(); + const glamaMcpService = new GlamaMcpEnrichmentService(); + const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); + const mcpStateService = new McpInstallationStateService(); + const extensionFacadeService = new ExtensionFacadeService( + pluginCatalogService, + pluginStateService, + mcpAggregator, + mcpStateService + ); + + // Install services — pass null for binary (uses PATH lookup via execCli fallback) + const pluginInstallService = new PluginInstallService(null, pluginCatalogService); + const mcpInstallService = new McpInstallService(null, mcpAggregator); + // warmup() and ensureInstalled() are deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. httpServer = new HttpServer(); @@ -695,7 +728,10 @@ function initializeServices(): void { reviewApplier, gitDiffFallback, cliInstallerService, - ptyTerminalService + ptyTerminalService, + extensionFacadeService, + pluginInstallService, + mcpInstallService ); // Forward SSH state changes to renderer and HTTP SSE clients diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts new file mode 100644 index 00000000..ed2818ac --- /dev/null +++ b/src/main/ipc/extensions.ts @@ -0,0 +1,280 @@ +/** + * IPC handlers for Extension Store (plugin catalog + MCP registry). + * + * Phase 2: read-only plugin catalog (getAll, getReadme). + * Phase 3: read-only MCP registry (search, browse, getById, getInstalled). + * Phase 5: install/uninstall mutations. + */ + +import { createLogger } from '@shared/utils/logger'; +import type { + EnrichedPlugin, + InstalledMcpEntry, + McpCatalogItem, + McpInstallRequest, + McpSearchResult, + OperationResult, + PluginInstallRequest, +} from '@shared/types/extensions'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; +import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; +import type { McpInstallService } from '../services/extensions/install/McpInstallService'; + +import { + MCP_REGISTRY_BROWSE, + MCP_REGISTRY_GET_BY_ID, + MCP_REGISTRY_GET_INSTALLED, + MCP_REGISTRY_INSTALL, + MCP_REGISTRY_SEARCH, + MCP_REGISTRY_UNINSTALL, + PLUGIN_GET_ALL, + PLUGIN_GET_README, + PLUGIN_INSTALL, + PLUGIN_UNINSTALL, +} from '@preload/constants/ipcChannels'; + +const logger = createLogger('IPC:extensions'); + +/** Allowed scope values */ +const VALID_SCOPES = new Set(['local', 'user', 'project']); + +// ── Module state ─────────────────────────────────────────────────────────── + +let extensionFacade: ExtensionFacadeService | null = null; +let pluginInstaller: PluginInstallService | null = null; +let mcpInstaller: McpInstallService | null = null; + +// ── Lifecycle ────────────────────────────────────────────────────────────── + +export function initializeExtensionHandlers( + facade: ExtensionFacadeService, + pluginInstall?: PluginInstallService, + mcpInstall?: McpInstallService +): void { + extensionFacade = facade; + pluginInstaller = pluginInstall ?? null; + mcpInstaller = mcpInstall ?? null; +} + +export function registerExtensionHandlers(ipcMain: IpcMain): void { + ipcMain.handle(PLUGIN_GET_ALL, handleGetAll); + ipcMain.handle(PLUGIN_GET_README, handleGetReadme); + ipcMain.handle(PLUGIN_INSTALL, handlePluginInstall); + ipcMain.handle(PLUGIN_UNINSTALL, handlePluginUninstall); + ipcMain.handle(MCP_REGISTRY_SEARCH, handleMcpSearch); + ipcMain.handle(MCP_REGISTRY_BROWSE, handleMcpBrowse); + ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById); + ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled); + ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall); + ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall); +} + +export function removeExtensionHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(PLUGIN_GET_ALL); + ipcMain.removeHandler(PLUGIN_GET_README); + ipcMain.removeHandler(PLUGIN_INSTALL); + ipcMain.removeHandler(PLUGIN_UNINSTALL); + ipcMain.removeHandler(MCP_REGISTRY_SEARCH); + ipcMain.removeHandler(MCP_REGISTRY_BROWSE); + ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID); + ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED); + ipcMain.removeHandler(MCP_REGISTRY_INSTALL); + ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL); +} + +// ── Service guard ────────────────────────────────────────────────────────── + +function getFacade(): ExtensionFacadeService { + if (!extensionFacade) { + throw new Error('Extension handlers are not initialized'); + } + return extensionFacade; +} + +// ── Error wrapper ────────────────────────────────────────────────────────── + +interface IpcResult { + success: boolean; + data?: T; + error?: string; +} + +async function wrapHandler(operation: string, handler: () => Promise): Promise> { + try { + const data = await handler(); + return { success: true, data }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[extensions:${operation}] ${message}`); + return { success: false, error: message }; + } +} + +// ── Plugin Handlers ──────────────────────────────────────────────────────── + +async function handleGetAll( + _event: IpcMainInvokeEvent, + projectPath?: string, + forceRefresh?: boolean +): Promise> { + return wrapHandler('getAll', () => + getFacade().getEnrichedPlugins( + typeof projectPath === 'string' ? projectPath : undefined, + typeof forceRefresh === 'boolean' ? forceRefresh : false + ) + ); +} + +async function handleGetReadme( + _event: IpcMainInvokeEvent, + pluginId?: string +): Promise> { + return wrapHandler('getReadme', () => { + if (typeof pluginId !== 'string' || !pluginId) { + throw new Error('pluginId is required'); + } + return getFacade().getPluginReadme(pluginId); + }); +} + +// ── MCP Handlers ─────────────────────────────────────────────────────────── + +async function handleMcpSearch( + _event: IpcMainInvokeEvent, + query?: string, + limit?: number +): Promise> { + return wrapHandler('mcpSearch', () => + getFacade().searchMcp( + typeof query === 'string' ? query : '', + typeof limit === 'number' ? limit : undefined + ) + ); +} + +async function handleMcpBrowse( + _event: IpcMainInvokeEvent, + cursor?: string, + limit?: number +): Promise> { + return wrapHandler('mcpBrowse', () => + getFacade().browseMcp( + typeof cursor === 'string' ? cursor : undefined, + typeof limit === 'number' ? limit : undefined + ) + ); +} + +async function handleMcpGetById( + _event: IpcMainInvokeEvent, + registryId?: string +): Promise> { + return wrapHandler('mcpGetById', () => { + if (typeof registryId !== 'string' || !registryId) { + throw new Error('registryId is required'); + } + return getFacade().getMcpById(registryId); + }); +} + +async function handleMcpGetInstalled( + _event: IpcMainInvokeEvent, + projectPath?: string +): Promise> { + return wrapHandler('mcpGetInstalled', () => + getFacade().getInstalledMcp(typeof projectPath === 'string' ? projectPath : undefined) + ); +} + +// ── Install/Uninstall Handlers ──────────────────────────────────────────── + +function getPluginInstaller(): PluginInstallService { + if (!pluginInstaller) { + throw new Error('Plugin installer not initialized'); + } + return pluginInstaller; +} + +function getMcpInstaller(): McpInstallService { + if (!mcpInstaller) { + throw new Error('MCP installer not initialized'); + } + return mcpInstaller; +} + +async function handlePluginInstall( + _event: IpcMainInvokeEvent, + request?: PluginInstallRequest +): Promise> { + return wrapHandler('pluginInstall', () => { + if (!request || typeof request.pluginId !== 'string' || !request.pluginId) { + throw new Error('Invalid install request: pluginId is required'); + } + if (request.scope && !VALID_SCOPES.has(request.scope)) { + throw new Error(`Invalid scope: "${request.scope}"`); + } + return getPluginInstaller().install(request); + }); +} + +async function handlePluginUninstall( + _event: IpcMainInvokeEvent, + pluginId?: string, + scope?: string, + projectPath?: string +): Promise> { + return wrapHandler('pluginUninstall', () => { + if (typeof pluginId !== 'string' || !pluginId) { + throw new Error('pluginId is required'); + } + if (scope && !VALID_SCOPES.has(scope)) { + throw new Error(`Invalid scope: "${scope}"`); + } + return getPluginInstaller().uninstall( + pluginId, + typeof scope === 'string' ? scope : undefined, + typeof projectPath === 'string' ? projectPath : undefined + ); + }); +} + +async function handleMcpInstall( + _event: IpcMainInvokeEvent, + request?: McpInstallRequest +): Promise> { + return wrapHandler('mcpInstall', () => { + if (!request || typeof request.registryId !== 'string' || !request.registryId) { + throw new Error('Invalid install request: registryId is required'); + } + if (typeof request.serverName !== 'string' || !request.serverName) { + throw new Error('Invalid install request: serverName is required'); + } + if (request.scope && !VALID_SCOPES.has(request.scope)) { + throw new Error(`Invalid scope: "${request.scope}"`); + } + return getMcpInstaller().install(request); + }); +} + +async function handleMcpUninstall( + _event: IpcMainInvokeEvent, + name?: string, + scope?: string, + projectPath?: string +): Promise> { + return wrapHandler('mcpUninstall', () => { + if (typeof name !== 'string' || !name) { + throw new Error('Server name is required'); + } + if (scope && !VALID_SCOPES.has(scope)) { + throw new Error(`Invalid scope: "${scope}"`); + } + return getMcpInstaller().uninstall( + name, + typeof scope === 'string' ? scope : undefined, + typeof projectPath === 'string' ? projectPath : undefined + ); + }); +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c17fee2a..75c1091c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -29,6 +29,11 @@ import { removeContextHandlers, } from './context'; import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor'; +import { + initializeExtensionHandlers, + registerExtensionHandlers, + removeExtensionHandlers, +} from './extensions'; import { initializeHttpServerHandlers, registerHttpServerHandlers, @@ -88,6 +93,9 @@ import type { UpdaterService, } from '../services'; import type { HttpServer } from '../services/infrastructure/HttpServer'; +import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; +import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; +import type { McpInstallService } from '../services/extensions/install/McpInstallService'; /** * Initializes IPC handlers with service registry. @@ -114,7 +122,10 @@ export function initializeIpcHandlers( reviewApplier?: ReviewApplierService, gitDiffFallback?: GitDiffFallback, cliInstaller?: CliInstallerService, - ptyTerminal?: PtyTerminalService + ptyTerminal?: PtyTerminalService, + extensionFacade?: ExtensionFacadeService, + pluginInstaller?: PluginInstallService, + mcpInstaller?: McpInstallService ): void { // Initialize domain handlers with registry initializeProjectHandlers(registry); @@ -147,6 +158,10 @@ export function initializeIpcHandlers( } initializeEditorHandlers(); + if (extensionFacade) { + initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller); + } + if (changeExtractor) { initializeReviewHandlers({ extractor: changeExtractor, @@ -182,6 +197,9 @@ export function initializeIpcHandlers( if (httpServerDeps) { registerHttpServerHandlers(ipcMain); } + if (extensionFacade) { + registerExtensionHandlers(ipcMain); + } logger.info('All handlers registered'); } @@ -210,6 +228,7 @@ export function removeIpcHandlers(): void { removeCliInstallerHandlers(ipcMain); removeTerminalHandlers(ipcMain); removeHttpServerHandlers(ipcMain); + removeExtensionHandlers(ipcMain); logger.info('All handlers removed'); } diff --git a/src/main/services/extensions/ExtensionFacadeService.ts b/src/main/services/extensions/ExtensionFacadeService.ts new file mode 100644 index 00000000..7a5df4f9 --- /dev/null +++ b/src/main/services/extensions/ExtensionFacadeService.ts @@ -0,0 +1,134 @@ +/** + * Facade service that combines plugin catalog + MCP catalog + installation state + * into enriched data ready for the renderer. + * + * Also provides install target resolution for the security model + * (main-side re-resolution: renderer sends pluginId/registryId, main resolves from catalog). + */ + +import { createLogger } from '@shared/utils/logger'; +import type { + EnrichedPlugin, + InstalledMcpEntry, + McpCatalogItem, + McpSearchResult, + PluginCatalogItem, +} from '@shared/types/extensions'; + +import { PluginCatalogService } from './catalog/PluginCatalogService'; +import { McpCatalogAggregator } from './catalog/McpCatalogAggregator'; +import { PluginInstallationStateService } from './state/PluginInstallationStateService'; +import { McpInstallationStateService } from './state/McpInstallationStateService'; + +const logger = createLogger('Extensions:Facade'); + +export class ExtensionFacadeService { + constructor( + private readonly pluginCatalog: PluginCatalogService, + private readonly pluginState: PluginInstallationStateService, + private readonly mcpAggregator: McpCatalogAggregator | null = null, + private readonly mcpState: McpInstallationStateService | null = null + ) {} + + // ── Plugin methods ─────────────────────────────────────────────────── + + /** + * Get all plugins enriched with install status and counts. + */ + async getEnrichedPlugins(projectPath?: string, forceRefresh = false): Promise { + const [catalog, installed, counts] = await Promise.all([ + this.pluginCatalog.getPlugins(forceRefresh), + this.pluginState.getInstalledPlugins(projectPath), + this.pluginState.getInstallCounts(), + ]); + + // Build installed lookup: pluginId → entries[] + const installedMap = new Map(); + for (const entry of installed) { + const list = installedMap.get(entry.pluginId) ?? []; + list.push(entry); + installedMap.set(entry.pluginId, list); + } + + return catalog.map((item): EnrichedPlugin => { + const installations = installedMap.get(item.pluginId) ?? []; + const installCount = counts.get(item.pluginId) ?? 0; + + return { + ...item, + installCount, + isInstalled: installations.length > 0, + installations, + }; + }); + } + + /** + * Get README content for a plugin. + */ + async getPluginReadme(pluginId: string): Promise { + return this.pluginCatalog.getPluginReadme(pluginId); + } + + /** + * Resolve a pluginId to its install target. + */ + async resolvePluginInstallTarget( + pluginId: string + ): Promise<{ qualifiedName: string; plugin: PluginCatalogItem } | null> { + const plugin = await this.pluginCatalog.resolvePlugin(pluginId); + if (!plugin) { + logger.warn(`Cannot resolve install target: pluginId "${pluginId}" not found in catalog`); + return null; + } + return { qualifiedName: plugin.qualifiedName, plugin }; + } + + // ── MCP methods ────────────────────────────────────────────────────── + + /** + * Search MCP servers across both registries. + */ + async searchMcp(query: string, limit?: number): Promise { + if (!this.mcpAggregator) { + return { servers: [], warnings: ['MCP catalog not configured'] }; + } + return this.mcpAggregator.search(query, limit); + } + + /** + * Browse MCP catalog with pagination. + */ + async browseMcp( + cursor?: string, + limit?: number + ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> { + if (!this.mcpAggregator) { + return { servers: [] }; + } + return this.mcpAggregator.browse(cursor, limit); + } + + /** + * Get a single MCP server by registry ID (for install flow). + */ + async getMcpById(registryId: string): Promise { + if (!this.mcpAggregator) return null; + return this.mcpAggregator.getById(registryId); + } + + /** + * Get installed MCP servers. + */ + async getInstalledMcp(projectPath?: string): Promise { + if (!this.mcpState) return []; + return this.mcpState.getInstalled(projectPath); + } + + // ── Cache invalidation ─────────────────────────────────────────────── + + invalidateInstalledCache(): void { + this.pluginState.invalidateCache(); + this.mcpState?.invalidateCache(); + } +} diff --git a/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts b/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts new file mode 100644 index 00000000..2f162780 --- /dev/null +++ b/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts @@ -0,0 +1,176 @@ +/** + * Fetches MCP server data from the Glama.ai API. + * + * Optional enrichment layer — NOT a hard dependency. + * Provides: license, tools, Glama URL. + * Does NOT provide install info (no packages/remotes). + * + * Base URL: https://glama.ai/api/mcp/v1/servers + * Cursor-based pagination (after), no auth required. + */ + +import https from 'node:https'; +import http from 'node:http'; + +import { createLogger } from '@shared/utils/logger'; +import type { McpCatalogItem, McpToolDef } from '@shared/types/extensions'; + +const logger = createLogger('Extensions:GlamaMcp'); + +// ── Constants ────────────────────────────────────────────────────────────── + +const GLAMA_BASE_URL = 'https://glama.ai/api/mcp/v1/servers'; +const HTTP_TIMEOUT_MS = 15_000; +const MAX_REDIRECTS = 5; +const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit + +// ── HTTP helper ──────────────────────────────────────────────────────────── + +function httpGet( + url: string, + redirectsLeft = MAX_REDIRECTS +): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'http:' ? http : https; + let settled = false; + + const settleResolve = (v: { statusCode: number; body: string }): void => { + if (!settled) { + settled = true; + resolve(v); + } + }; + const settleReject = (e: Error): void => { + if (!settled) { + settled = true; + reject(e); + } + }; + + const req = transport.get(url, (res) => { + const status = res.statusCode ?? 0; + if (status >= 300 && status < 400 && res.headers.location) { + if (redirectsLeft <= 0) { + res.destroy(); + settleReject(new Error('Too many redirects')); + return; + } + res.destroy(); + httpGet(new URL(res.headers.location, url).toString(), redirectsLeft - 1).then( + settleResolve, + settleReject + ); + return; + } + const chunks: Buffer[] = []; + let totalSize = 0; + res.on('data', (c: Buffer) => { + totalSize += c.length; + if (totalSize > MAX_BODY_SIZE) { + res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`)); + return; + } + chunks.push(c); + }); + res.on('end', () => + settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') }) + ); + res.on('error', settleReject); + }); + req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`))); + req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e)))); + }); +} + +// ── Raw Glama API shapes ─────────────────────────────────────────────────── + +interface GlamaResponse { + pageInfo: { + endCursor?: string; + hasNextPage?: boolean; + }; + servers: GlamaServer[]; +} + +interface GlamaServer { + id: string; + name: string; + namespace?: string; + description?: string; + slug?: string; + url?: string; + repository?: { url: string }; + spdxLicense?: { name: string; url?: string } | null; + tools?: { name?: string; description?: string }[]; +} + +// ── Service ──────────────────────────────────────────────────────────────── + +export class GlamaMcpEnrichmentService { + /** + * Search Glama for MCP servers. + */ + async search(query: string, limit = 20): Promise { + const params = new URLSearchParams({ search: query, first: String(limit) }); + const url = `${GLAMA_BASE_URL}?${params}`; + + try { + const resp = await httpGet(url); + if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`); + const json = JSON.parse(resp.body) as GlamaResponse; + return json.servers.map((s) => this.normalize(s)); + } catch (err) { + logger.warn('Glama MCP search failed:', err); + return []; + } + } + + /** + * Browse Glama catalog with cursor pagination. + */ + async browse( + cursor?: string, + limit = 20 + ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> { + const params = new URLSearchParams({ first: String(limit) }); + if (cursor) params.set('after', cursor); + const url = `${GLAMA_BASE_URL}?${params}`; + + try { + const resp = await httpGet(url); + if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`); + const json = JSON.parse(resp.body) as GlamaResponse; + return { + servers: json.servers.map((s) => this.normalize(s)), + nextCursor: json.pageInfo.hasNextPage ? json.pageInfo.endCursor : undefined, + }; + } catch (err) { + logger.warn('Glama MCP browse failed:', err); + return { servers: [] }; + } + } + + // ── Private ──────────────────────────────────────────────────────────── + + private normalize(raw: GlamaServer): McpCatalogItem { + const tools: McpToolDef[] = (raw.tools ?? []) + .filter((t): t is { name: string; description: string } => Boolean(t.name)) + .map((t) => ({ name: t.name, description: t.description ?? '' })); + + return { + id: `glama:${raw.id}`, + name: raw.name, + description: raw.description ?? '', + repositoryUrl: raw.repository?.url, + version: undefined, // Glama doesn't expose version + source: 'glama', + installSpec: null, // Glama has NO install info + envVars: [], + license: raw.spdxLicense?.name, + tools, + glamaUrl: raw.url, + requiresAuth: false, + }; + } +} diff --git a/src/main/services/extensions/catalog/McpCatalogAggregator.ts b/src/main/services/extensions/catalog/McpCatalogAggregator.ts new file mode 100644 index 00000000..0f882a07 --- /dev/null +++ b/src/main/services/extensions/catalog/McpCatalogAggregator.ts @@ -0,0 +1,147 @@ +/** + * Aggregates MCP catalog data from Official Registry + Glama. + * + * - Uses Promise.allSettled so partial API failures don't break the whole catalog + * - Dedup by repository URL; Official source takes priority + * - Enriches Official entries with Glama data (license, tools) when matched + * - Provides getById() for secure install flow + */ + +import { createLogger } from '@shared/utils/logger'; +import type { McpCatalogItem, McpSearchResult } from '@shared/types/extensions'; +import { normalizeRepoUrl } from '@shared/utils/extensionNormalizers'; + +import { OfficialMcpRegistryService } from './OfficialMcpRegistryService'; +import { GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService'; + +const logger = createLogger('Extensions:McpAggregator'); + +export class McpCatalogAggregator { + constructor( + private readonly official: OfficialMcpRegistryService, + private readonly glama: GlamaMcpEnrichmentService + ) {} + + /** + * Search both sources and return merged results. + */ + async search(query: string, limit = 20): Promise { + const warnings: string[] = []; + + const [officialResult, glamaResult] = await Promise.allSettled([ + this.official.search(query, limit), + this.glama.search(query, limit), + ]); + + const officialServers = officialResult.status === 'fulfilled' ? officialResult.value : []; + const glamaServers = glamaResult.status === 'fulfilled' ? glamaResult.value : []; + + if (officialResult.status === 'rejected') { + warnings.push('Official MCP Registry unavailable'); + logger.warn('Official registry search failed:', officialResult.reason); + } + if (glamaResult.status === 'rejected') { + warnings.push('Glama enrichment unavailable'); + logger.warn('Glama search failed:', glamaResult.reason); + } + + const merged = this.mergeAndDeduplicate(officialServers, glamaServers); + + return { servers: merged, warnings }; + } + + /** + * Browse the official registry with optional Glama enrichment. + */ + async browse( + cursor?: string, + limit = 20 + ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> { + // Browse primarily from official registry (has pagination) + const result = await this.official.browse(cursor, limit); + + // Optionally enrich with Glama data (best effort, no pagination sync) + try { + const glamaBrowse = await this.glama.browse(undefined, limit); + const enriched = this.enrichOfficialWithGlama(result.servers, glamaBrowse.servers); + return { servers: enriched, nextCursor: result.nextCursor }; + } catch { + return result; + } + } + + /** + * Get a single server by ID for secure install flow. + * Delegates to appropriate source based on ID prefix. + */ + async getById(registryId: string): Promise { + // Glama IDs are prefixed with "glama:" + if (registryId.startsWith('glama:')) { + logger.warn(`Cannot install Glama-only server: ${registryId}`); + return null; // Glama servers can't be auto-installed + } + + // Official registry lookup + return this.official.getById(registryId); + } + + // ── Private ──────────────────────────────────────────────────────────── + + /** + * Merge Official + Glama, dedup by repository URL. + * Official entries take priority. + */ + private mergeAndDeduplicate( + official: McpCatalogItem[], + glama: McpCatalogItem[] + ): McpCatalogItem[] { + // Build repo URL index from official entries + const officialRepoUrls = new Set(); + for (const item of official) { + if (item.repositoryUrl) { + officialRepoUrls.add(normalizeRepoUrl(item.repositoryUrl)); + } + } + + // Enrich official entries with Glama data + const enriched = this.enrichOfficialWithGlama(official, glama); + + // Add Glama-only entries (no matching official entry) + const glamaOnly = glama.filter((g) => { + if (!g.repositoryUrl) return true; // no repo URL = can't match, show separately + return !officialRepoUrls.has(normalizeRepoUrl(g.repositoryUrl)); + }); + + return [...enriched, ...glamaOnly]; + } + + /** + * Enrich official entries with Glama metadata (license, tools, glamaUrl). + */ + private enrichOfficialWithGlama( + official: McpCatalogItem[], + glama: McpCatalogItem[] + ): McpCatalogItem[] { + // Index Glama by normalized repo URL + const glamaByRepo = new Map(); + for (const g of glama) { + if (g.repositoryUrl) { + glamaByRepo.set(normalizeRepoUrl(g.repositoryUrl), g); + } + } + + return official.map((item) => { + if (!item.repositoryUrl) return item; + + const glamaMatch = glamaByRepo.get(normalizeRepoUrl(item.repositoryUrl)); + if (!glamaMatch) return item; + + return { + ...item, + license: item.license ?? glamaMatch.license, + tools: item.tools.length > 0 ? item.tools : glamaMatch.tools, + glamaUrl: glamaMatch.glamaUrl, + }; + }); + } +} diff --git a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts new file mode 100644 index 00000000..6fd4603b --- /dev/null +++ b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts @@ -0,0 +1,335 @@ +/** + * Fetches and normalizes MCP servers from the Official MCP Registry. + * + * Base URL: https://registry.modelcontextprotocol.io/v0.1/servers + * Cursor-based pagination, no auth required. + * Filters for _meta.isLatest to pick only latest versions. + */ + +import https from 'node:https'; +import http from 'node:http'; + +import { createLogger } from '@shared/utils/logger'; +import type { McpCatalogItem, McpEnvVarDef, McpInstallSpec } from '@shared/types/extensions'; + +const logger = createLogger('Extensions:OfficialMcpRegistry'); + +// ── Constants ────────────────────────────────────────────────────────────── + +const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1/servers'; +const HTTP_TIMEOUT_MS = 15_000; +const MAX_REDIRECTS = 5; +const CACHE_TTL_MS = 15 * 60_000; // 15 minutes +const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit + +// ── HTTP helper ──────────────────────────────────────────────────────────── + +function httpGet( + url: string, + redirectsLeft = MAX_REDIRECTS +): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'http:' ? http : https; + let settled = false; + + const settleResolve = (v: { statusCode: number; body: string }): void => { + if (!settled) { + settled = true; + resolve(v); + } + }; + const settleReject = (e: Error): void => { + if (!settled) { + settled = true; + reject(e); + } + }; + + const req = transport.get(url, (res) => { + const status = res.statusCode ?? 0; + if (status >= 300 && status < 400 && res.headers.location) { + if (redirectsLeft <= 0) { + res.destroy(); + settleReject(new Error('Too many redirects')); + return; + } + const redirectUrl = new URL(res.headers.location, url).toString(); + res.destroy(); + httpGet(redirectUrl, redirectsLeft - 1).then(settleResolve, settleReject); + return; + } + const chunks: Buffer[] = []; + let totalSize = 0; + res.on('data', (c: Buffer) => { + totalSize += c.length; + if (totalSize > MAX_BODY_SIZE) { + res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`)); + return; + } + chunks.push(c); + }); + res.on('end', () => + settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') }) + ); + res.on('error', settleReject); + }); + req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`))); + req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e)))); + }); +} + +// ── Raw API response shapes ──────────────────────────────────────────────── + +interface RegistryResponse { + servers: RegistryServerEntry[]; + metadata: { nextCursor?: string; count?: number }; +} + +interface RegistryIcon { + src: string; + mimeType?: string; + sizes?: string[]; + theme?: 'light' | 'dark'; +} + +interface RegistryServerEntry { + server: { + name: string; + description?: string; + title?: string; + version?: string; + repository?: { url: string; source?: string }; + websiteUrl?: string; + packages?: RegistryPackage[]; + remotes?: RegistryRemote[]; + icons?: RegistryIcon[]; + }; + _meta?: { + 'io.modelcontextprotocol.registry/official'?: { + status?: string; + isLatest?: boolean; + }; + }; +} + +interface RegistryPackage { + registryType: string; + identifier: string; + version?: string; + transport?: { type: string }; + environmentVariables?: RegistryEnvVar[]; +} + +interface RegistryRemote { + type: string; + url: string; + headers?: RegistryHeader[]; +} + +interface RegistryHeader { + name: string; + description?: string; + isRequired?: boolean; + isSecret?: boolean; + value?: string; +} + +interface RegistryEnvVar { + name: string; + description?: string; + isSecret?: boolean; + isRequired?: boolean; +} + +// ── Cache ────────────────────────────────────────────────────────────────── + +interface SearchCache { + key: string; + result: McpCatalogItem[]; + fetchedAt: number; +} + +// ── Service ──────────────────────────────────────────────────────────────── + +export class OfficialMcpRegistryService { + private searchCache: SearchCache | null = null; + + /** + * Search the official registry by query text. + */ + async search(query: string, limit = 20): Promise { + const cacheKey = `search:${query}:${limit}`; + if ( + this.searchCache?.key === cacheKey && + Date.now() - this.searchCache.fetchedAt < CACHE_TTL_MS + ) { + return this.searchCache.result; + } + + const params = new URLSearchParams({ search: query, limit: String(limit) }); + const url = `${REGISTRY_BASE_URL}?${params}`; + + try { + const resp = await httpGet(url); + if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`); + const json = JSON.parse(resp.body) as RegistryResponse; + const items = this.normalizeServers(json.servers); + this.searchCache = { key: cacheKey, result: items, fetchedAt: Date.now() }; + return items; + } catch (err) { + logger.error('Official MCP Registry search failed:', err); + return this.searchCache?.result ?? []; + } + } + + /** + * Browse the registry with cursor-based pagination. + */ + async browse( + cursor?: string, + limit = 20 + ): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set('cursor', cursor); + const url = `${REGISTRY_BASE_URL}?${params}`; + + try { + const resp = await httpGet(url); + if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`); + const json = JSON.parse(resp.body) as RegistryResponse; + return { + servers: this.normalizeServers(json.servers), + nextCursor: json.metadata.nextCursor, + }; + } catch (err) { + logger.error('Official MCP Registry browse failed:', err); + return { servers: [] }; + } + } + + /** + * Get a single server by its registry ID (reverse-DNS name). + * Used for secure install flow (main-side re-resolution). + */ + async getById(registryId: string): Promise { + // The official registry search API can find by exact name + const params = new URLSearchParams({ search: registryId, limit: '5' }); + const url = `${REGISTRY_BASE_URL}?${params}`; + + try { + const resp = await httpGet(url); + if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`); + const json = JSON.parse(resp.body) as RegistryResponse; + const items = this.normalizeServers(json.servers); + return items.find((s) => s.id === registryId) ?? null; + } catch (err) { + logger.error(`Official MCP Registry getById(${registryId}) failed:`, err); + return null; + } + } + + // ── Private ──────────────────────────────────────────────────────────── + + private normalizeServers(entries: RegistryServerEntry[]): McpCatalogItem[] { + // Filter to isLatest only (same server name may appear multiple times) + const latest = entries.filter((e) => { + const meta = e._meta?.['io.modelcontextprotocol.registry/official']; + return meta?.isLatest !== false; // include if isLatest is true or undefined + }); + + // Deduplicate by server name (take first = latest version) + const seen = new Set(); + const unique: RegistryServerEntry[] = []; + for (const entry of latest) { + if (!seen.has(entry.server.name)) { + seen.add(entry.server.name); + unique.push(entry); + } + } + + return unique.map((entry) => this.normalizeEntry(entry)); + } + + private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem { + const { server } = entry; + const installSpec = this.deriveInstallSpec(server); + const envVars = this.collectEnvVars(server); + const requiresAuth = this.detectAuthRequired(server); + + return { + id: server.name, + name: server.title ?? server.name.split('/').pop() ?? server.name, + description: server.description ?? '', + repositoryUrl: server.repository?.url, + version: server.version, + source: 'official', + installSpec, + envVars, + license: undefined, // Official registry doesn't expose license + tools: [], // Tools not included in registry list response + glamaUrl: undefined, + requiresAuth, + iconUrl: this.pickIconUrl(server.icons), + }; + } + + private deriveInstallSpec(server: RegistryServerEntry['server']): McpInstallSpec | null { + // Prefer npm stdio package + const npmPkg = server.packages?.find((p) => p.registryType === 'npm'); + if (npmPkg) { + return { + type: 'stdio', + npmPackage: npmPkg.identifier, + npmVersion: npmPkg.version, + }; + } + + // HTTP/SSE remote + const remote = server.remotes?.[0]; + if (remote) { + return { + type: 'http', + url: remote.url, + transportType: remote.type as 'streamable-http' | 'sse' | 'http', + }; + } + + return null; + } + + private collectEnvVars(server: RegistryServerEntry['server']): McpEnvVarDef[] { + const envVars: McpEnvVarDef[] = []; + + // From packages + for (const pkg of server.packages ?? []) { + for (const ev of pkg.environmentVariables ?? []) { + envVars.push({ + name: ev.name, + isSecret: ev.isSecret ?? false, + description: ev.description, + isRequired: ev.isRequired, + }); + } + } + + return envVars; + } + + private detectAuthRequired(server: RegistryServerEntry['server']): boolean { + for (const remote of server.remotes ?? []) { + for (const header of remote.headers ?? []) { + if (header.isRequired) return true; + } + } + return false; + } + + /** Pick best icon URL from the registry icons array (prefer dark theme PNG). */ + private pickIconUrl(icons?: RegistryIcon[]): string | undefined { + if (!icons || icons.length === 0) return undefined; + // Prefer dark-theme icon, then first available + const darkIcon = icons.find((i) => i.theme === 'dark'); + return (darkIcon ?? icons[0]).src; + } +} diff --git a/src/main/services/extensions/catalog/PluginCatalogService.ts b/src/main/services/extensions/catalog/PluginCatalogService.ts new file mode 100644 index 00000000..bc06d6c7 --- /dev/null +++ b/src/main/services/extensions/catalog/PluginCatalogService.ts @@ -0,0 +1,318 @@ +/** + * Fetches and caches the Claude Code plugin marketplace catalog. + * + * - Fetches marketplace.json from raw.githubusercontent.com + * - ETag + If-None-Match for bandwidth efficiency + * - In-memory cache with TTL (15 min) + * - Stale cache fallback on network error + * - Deduplicates concurrent requests + */ + +import https from 'node:https'; +import http from 'node:http'; + +import { createLogger } from '@shared/utils/logger'; +import type { PluginCatalogItem } from '@shared/types/extensions'; +import { buildPluginId } from '@shared/utils/extensionNormalizers'; + +const logger = createLogger('Extensions:PluginCatalog'); + +// ── Constants ────────────────────────────────────────────────────────────── + +const MARKETPLACE_URL = + 'https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json'; + +const CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes +const HTTP_TIMEOUT_MS = 15_000; // 15 seconds +const MAX_REDIRECTS = 5; +const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit +const MAX_README_CACHE_SIZE = 50; // Max README entries to cache + +// ── HTTP helpers (adapted from CliInstallerService) ──────────────────────── + +interface FetchOptions { + headers?: Record; +} + +interface FetchResponse { + statusCode: number; + headers: Record; + body: string; +} + +function httpsGetFollowRedirects( + url: string, + options: FetchOptions = {}, + redirectsLeft = MAX_REDIRECTS, + timeoutMs = HTTP_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'http:' ? http : https; + let settled = false; + + const settleResolve = (value: FetchResponse): void => { + if (settled) return; + settled = true; + resolve(value); + }; + const settleReject = (err: Error): void => { + if (settled) return; + settled = true; + reject(err); + }; + + const reqOptions = { + headers: options.headers ?? {}, + }; + + const req = transport.get(url, reqOptions, (res) => { + const status = res.statusCode ?? 0; + + if (status >= 300 && status < 400 && res.headers.location) { + if (redirectsLeft <= 0) { + res.destroy(); + settleReject(new Error('Too many redirects')); + return; + } + const redirectUrl = new URL(res.headers.location, url).toString(); + res.destroy(); + httpsGetFollowRedirects(redirectUrl, options, redirectsLeft - 1, timeoutMs).then( + settleResolve, + settleReject + ); + return; + } + + const chunks: Buffer[] = []; + let totalSize = 0; + res.on('data', (chunk: Buffer) => { + totalSize += chunk.length; + if (totalSize > MAX_BODY_SIZE) { + res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`)); + return; + } + chunks.push(chunk); + }); + res.on('end', () => + settleResolve({ + statusCode: status, + headers: res.headers as Record, + body: Buffer.concat(chunks).toString('utf-8'), + }) + ); + res.on('error', settleReject); + }); + + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Connection timed out after ${timeoutMs}ms fetching ${url}`)); + }); + req.on('error', (err) => settleReject(err instanceof Error ? err : new Error(String(err)))); + }); +} + +// ── Marketplace JSON shape ───────────────────────────────────────────────── + +interface MarketplaceJson { + name: string; + plugins: RawMarketplacePlugin[]; +} + +interface RawMarketplacePlugin { + name: string; + description?: string; + version?: string; + category?: string; + author?: { name: string; email?: string }; + source: string | { source: string; url: string; sha?: string }; + strict?: boolean; + lspServers?: Record; + mcpServers?: Record; + agents?: Record; + commands?: Record; + hooks?: Record; +} + +// ── Cache ────────────────────────────────────────────────────────────────── + +interface CatalogCache { + items: PluginCatalogItem[]; + etag: string | null; + fetchedAt: number; +} + +// ── Service ──────────────────────────────────────────────────────────────── + +export class PluginCatalogService { + private cache: CatalogCache | null = null; + private fetchInFlight: Promise | null = null; + private readmeCache = new Map(); + + /** + * Get all plugins from the marketplace catalog. + * Uses in-memory cache with ETag validation. + */ + async getPlugins(forceRefresh = false): Promise { + // Return cached if fresh and not forcing + if (!forceRefresh && this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { + return this.cache.items; + } + + // Deduplicate concurrent requests + if (this.fetchInFlight) { + return this.fetchInFlight; + } + + this.fetchInFlight = this.fetchCatalog().finally(() => { + this.fetchInFlight = null; + }); + + return this.fetchInFlight; + } + + /** + * Get README content for a plugin by its pluginId. + * For external plugins (source is URL), fetches README from the GitHub repo. + * Returns null for local/bundled plugins or on error. + */ + async getPluginReadme(pluginId: string): Promise { + const cached = this.readmeCache.get(pluginId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.content; + } + + // Need catalog to find the plugin's repo URL + const plugins = await this.getPlugins(); + const plugin = plugins.find((p) => p.pluginId === pluginId); + if (!plugin?.homepage) { + this.setReadmeCache(pluginId, null); + return null; + } + + const readmeUrl = this.buildReadmeUrl(plugin.homepage); + if (!readmeUrl) { + this.setReadmeCache(pluginId, null); + return null; + } + + try { + const response = await httpsGetFollowRedirects(readmeUrl); + if (response.statusCode === 200) { + this.setReadmeCache(pluginId, response.body); + return response.body; + } + this.setReadmeCache(pluginId, null); + return null; + } catch (err) { + logger.warn(`Failed to fetch README for ${pluginId}:`, err); + this.setReadmeCache(pluginId, null); + return null; + } + } + + /** + * Look up a single plugin by pluginId from the cached catalog. + * Used for main-side re-resolution during install. + */ + async resolvePlugin(pluginId: string): Promise { + const plugins = await this.getPlugins(); + return plugins.find((p) => p.pluginId === pluginId) ?? null; + } + + // ── Private ──────────────────────────────────────────────────────────── + + /** Set readme cache with LRU eviction */ + private setReadmeCache(pluginId: string, content: string | null): void { + // Evict oldest entries if at capacity + if (this.readmeCache.size >= MAX_README_CACHE_SIZE && !this.readmeCache.has(pluginId)) { + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, entry] of this.readmeCache) { + if (entry.fetchedAt < oldestTime) { + oldestTime = entry.fetchedAt; + oldestKey = key; + } + } + if (oldestKey) this.readmeCache.delete(oldestKey); + } + this.readmeCache.set(pluginId, { content, fetchedAt: Date.now() }); + } + + private async fetchCatalog(): Promise { + const headers: Record = {}; + if (this.cache?.etag) { + headers['If-None-Match'] = this.cache.etag; + } + + try { + const response = await httpsGetFollowRedirects(MARKETPLACE_URL, { headers }); + + // 304 Not Modified — cache is still valid + if (response.statusCode === 304 && this.cache) { + this.cache.fetchedAt = Date.now(); + logger.info('Marketplace catalog not modified (304)'); + return this.cache.items; + } + + if (response.statusCode !== 200) { + throw new Error(`HTTP ${response.statusCode} fetching marketplace`); + } + + const json = JSON.parse(response.body) as MarketplaceJson; + const items = this.parseMarketplace(json); + const etag = (response.headers['etag'] as string) ?? null; + + this.cache = { items, etag, fetchedAt: Date.now() }; + logger.info(`Fetched ${items.length} plugins from marketplace "${json.name}"`); + return items; + } catch (err) { + // Stale cache fallback + if (this.cache) { + logger.warn('Marketplace fetch failed, using stale cache:', err); + return this.cache.items; + } + logger.error('Marketplace fetch failed with no cache:', err); + throw err; + } + } + + private parseMarketplace(json: MarketplaceJson): PluginCatalogItem[] { + const marketplaceName = json.name; + + return json.plugins.map((raw): PluginCatalogItem => { + const qualifiedName = buildPluginId(raw.name, marketplaceName); + const isExternal = typeof raw.source === 'object'; + const homepage = isExternal ? (raw.source as { url: string }).url : undefined; + + return { + pluginId: qualifiedName, + marketplaceId: qualifiedName, + qualifiedName, + name: raw.name, + description: raw.description ?? '', + category: raw.category ?? 'other', + author: raw.author, + version: raw.version, + homepage: homepage?.replace(/\.git$/, ''), + tags: undefined, + hasLspServers: raw.lspServers != null && Object.keys(raw.lspServers).length > 0, + hasMcpServers: raw.mcpServers != null && Object.keys(raw.mcpServers).length > 0, + hasAgents: raw.agents != null && Object.keys(raw.agents).length > 0, + hasCommands: raw.commands != null && Object.keys(raw.commands).length > 0, + hasHooks: raw.hooks != null && Object.keys(raw.hooks).length > 0, + isExternal, + }; + }); + } + + /** + * Build raw GitHub README URL from a GitHub repo URL. + * e.g. https://github.com/org/repo → https://raw.githubusercontent.com/org/repo/main/README.md + */ + private buildReadmeUrl(repoUrl: string): string | null { + const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); + if (!match) return null; + const [, owner, repo] = match; + return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`; + } +} diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts new file mode 100644 index 00000000..65f871a7 --- /dev/null +++ b/src/main/services/extensions/index.ts @@ -0,0 +1,13 @@ +/** + * Extension services barrel export. + */ + +export { PluginCatalogService } from './catalog/PluginCatalogService'; +export { OfficialMcpRegistryService } from './catalog/OfficialMcpRegistryService'; +export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService'; +export { McpCatalogAggregator } from './catalog/McpCatalogAggregator'; +export { PluginInstallationStateService } from './state/PluginInstallationStateService'; +export { McpInstallationStateService } from './state/McpInstallationStateService'; +export { ExtensionFacadeService } from './ExtensionFacadeService'; +export { PluginInstallService } from './install/PluginInstallService'; +export { McpInstallService } from './install/McpInstallService'; diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts new file mode 100644 index 00000000..f813d8e4 --- /dev/null +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -0,0 +1,242 @@ +/** + * McpInstallService — installs/uninstalls MCP servers via Claude CLI. + * + * Security model: renderer sends ONLY registryId + user inputs (env values, + * headers, server name). Main re-fetches server spec from registry via getById() + * and builds CLI args from the fresh registry data (never trusts install spec + * from renderer). + */ + +import { execCli } from '@main/utils/childProcess'; +import { createLogger } from '@shared/utils/logger'; + +import type { McpInstallRequest, OperationResult } from '@shared/types/extensions'; +import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator'; + +const logger = createLogger('Extensions:McpInstall'); + +/** Validate server name: alphanumeric, dashes, underscores, dots */ +const SERVER_NAME_RE = /^[\w.-]{1,100}$/; + +/** Allowed scope values (prevent command injection) */ +const VALID_SCOPES = new Set(['local', 'user', 'project']); + +/** Env var key must be safe shell identifier */ +const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; + +/** HTTP header key must be safe (RFC 7230 token) */ +const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/; + +const TIMEOUT_MS = 30_000; + +export class McpInstallService { + constructor( + private readonly claudeBinary: string | null, + private readonly aggregator: McpCatalogAggregator + ) {} + + async install(request: McpInstallRequest): Promise { + const { registryId, serverName, scope, projectPath, envValues, headers } = request; + + // 1. Validate server name + if (!SERVER_NAME_RE.test(serverName)) { + return { + state: 'error', + error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`, + }; + } + + // 2. Validate scope + if (scope && !VALID_SCOPES.has(scope)) { + return { + state: 'error', + error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + }; + } + + // 3. Validate env var keys (prevent command injection) + for (const key of Object.keys(envValues)) { + if (!ENV_KEY_RE.test(key)) { + return { + state: 'error', + error: `Invalid environment variable name: "${key}". Use uppercase alphanumeric and underscores.`, + }; + } + } + + // 4. Validate header keys (prevent header injection) + for (const header of headers) { + if (header.key && !HEADER_KEY_RE.test(header.key)) { + return { + state: 'error', + error: `Invalid header name: "${header.key}". Use alphanumeric, dashes, underscores.`, + }; + } + } + + // 5. Validate projectPath (if provided, must be absolute) + if (projectPath && !projectPath.startsWith('/')) { + return { + state: 'error', + error: 'projectPath must be an absolute path', + }; + } + + // 6. Re-fetch from registry (don't trust renderer-provided install spec) + const server = await this.aggregator.getById(registryId); + if (!server) { + return { + state: 'error', + error: `MCP server "${registryId}" not found in registry`, + }; + } + + if (!server.installSpec) { + return { + state: 'error', + error: `MCP server "${server.name}" does not have an automatic install spec. Manual setup required.`, + }; + } + + // 7. Build CLI args based on install spec type + const args: string[] = ['mcp', 'add']; + + // Scope flag (-s) + if (scope && scope !== 'local') { + args.push('-s', scope); + } + + if (server.installSpec.type === 'stdio') { + // Stdio: claude mcp add [-s scope] [-e KEY=val...] -- npx -y [@version] + // Add env flags + for (const [key, value] of Object.entries(envValues)) { + if (key && value) { + args.push('-e', `${key}=${value}`); + } + } + + args.push(serverName); + args.push('--'); + args.push('npx', '-y'); + + const pkg = server.installSpec.npmVersion + ? `${server.installSpec.npmPackage}@${server.installSpec.npmVersion}` + : server.installSpec.npmPackage; + args.push(pkg); + } else if (server.installSpec.type === 'http') { + // HTTP/SSE: claude mcp add [-s scope] -t [-H "Key: val"...] + const transport = server.installSpec.transportType === 'sse' ? 'sse' : 'http'; + args.push('-t', transport); + + // Add header flags + for (const header of headers) { + if (header.key && header.value) { + args.push('-H', `${header.key}: ${header.value}`); + } + } + + // Add env flags (some HTTP servers also need env vars) + for (const [key, value] of Object.entries(envValues)) { + if (key && value) { + args.push('-e', `${key}=${value}`); + } + } + + args.push(serverName); + args.push(server.installSpec.url); + } else { + return { + state: 'error', + error: `Unsupported install spec type: ${(server.installSpec as { type: string }).type}`, + }; + } + + logger.info( + `Installing MCP server: ${serverName} (type: ${server.installSpec.type}, scope: ${scope ?? 'local'})` + ); + // Don't log env values or header values (may contain secrets) + + try { + const { stderr } = await execCli(this.claudeBinary, args, { + timeout: TIMEOUT_MS, + cwd: projectPath, + }); + + if (stderr) { + logger.warn(`MCP install stderr: ${stderr.slice(0, 200)}`); + } + + return { state: 'success' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Mask potential secrets in error output + const safeMessage = maskSecrets( + message, + envValues, + headers.map((h) => h.value) + ); + logger.error(`MCP install failed: ${safeMessage}`); + return { state: 'error', error: safeMessage }; + } + } + + async uninstall(name: string, scope?: string, projectPath?: string): Promise { + if (!SERVER_NAME_RE.test(name)) { + return { + state: 'error', + error: `Invalid server name: "${name}"`, + }; + } + + if (scope && !VALID_SCOPES.has(scope)) { + return { + state: 'error', + error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + }; + } + + if (projectPath && !projectPath.startsWith('/')) { + return { + state: 'error', + error: 'projectPath must be an absolute path', + }; + } + + const args = ['mcp', 'remove']; + if (scope && scope !== 'local') { + args.push('-s', scope); + } + args.push(name); + + logger.info(`Removing MCP server: ${name} (scope: ${scope ?? 'local'})`); + + try { + await execCli(this.claudeBinary, args, { + timeout: TIMEOUT_MS, + cwd: projectPath, + }); + return { state: 'success' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`MCP uninstall failed: ${message}`); + return { state: 'error', error: message }; + } + } +} + +/** Replace secret values in error messages with [REDACTED] */ +function maskSecrets( + message: string, + envValues: Record, + headerValues: string[] +): string { + let result = message; + const secrets = [ + ...Object.values(envValues).filter((v) => v.length > 3), + ...headerValues.filter((v) => v.length > 3), + ]; + for (const secret of secrets) { + result = result.replaceAll(secret, '[REDACTED]'); + } + return result; +} diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts new file mode 100644 index 00000000..ac4a76b5 --- /dev/null +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -0,0 +1,154 @@ +/** + * PluginInstallService — installs/uninstalls plugins via Claude CLI. + * + * Security model: renderer sends ONLY pluginId, main resolves qualifiedName + * from the current catalog snapshot (never trusts renderer-provided paths). + */ + +import { execCli } from '@main/utils/childProcess'; +import { createLogger } from '@shared/utils/logger'; + +import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions'; +import type { PluginCatalogService } from '../catalog/PluginCatalogService'; + +const logger = createLogger('Extensions:PluginInstall'); + +/** Validate qualifiedName: must be @ with safe characters */ +const QUALIFIED_NAME_RE = /^[\w.-]+@[\w.-]+$/; + +/** Allowed scope values (prevent command injection) */ +const VALID_SCOPES = new Set(['local', 'user', 'project']); + +const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos +const UNINSTALL_TIMEOUT_MS = 30_000; + +export class PluginInstallService { + constructor( + private readonly claudeBinary: string | null, + private readonly catalogService: PluginCatalogService + ) {} + + async install(request: PluginInstallRequest): Promise { + const { pluginId, scope, projectPath } = request; + + // 1. Validate scope + if (scope && !VALID_SCOPES.has(scope)) { + return { + state: 'error', + error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + }; + } + + // 2. Validate projectPath + if (projectPath && !projectPath.startsWith('/')) { + return { + state: 'error', + error: 'projectPath must be an absolute path', + }; + } + + // 3. Resolve qualifiedName from catalog (NOT from renderer) + const resolved = await this.catalogService.resolvePlugin(pluginId); + if (!resolved) { + return { + state: 'error', + error: `Plugin "${pluginId}" not found in catalog`, + }; + } + + const { qualifiedName } = resolved; + + // 2. Validate qualifiedName format (prevent injection) + if (!QUALIFIED_NAME_RE.test(qualifiedName)) { + return { + state: 'error', + error: `Invalid plugin identifier: ${qualifiedName}`, + }; + } + + // 5. Build CLI args: claude plugin install [-s scope] + const args = ['plugin', 'install']; + if (scope && scope !== 'user') { + args.push('-s', scope); + } + args.push(qualifiedName); + + logger.info(`Installing plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`); + + try { + const { stdout, stderr } = await execCli(this.claudeBinary, args, { + timeout: INSTALL_TIMEOUT_MS, + cwd: projectPath, + }); + + if (stderr && !stdout) { + logger.warn(`Plugin install stderr: ${stderr}`); + } + + return { state: 'success' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Plugin install failed: ${message}`); + return { state: 'error', error: message }; + } + } + + async uninstall( + pluginId: string, + scope?: string, + projectPath?: string + ): Promise { + // Validate scope + if (scope && !VALID_SCOPES.has(scope)) { + return { + state: 'error', + error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + }; + } + + if (projectPath && !projectPath.startsWith('/')) { + return { + state: 'error', + error: 'projectPath must be an absolute path', + }; + } + + // Resolve qualifiedName from catalog + const resolved = await this.catalogService.resolvePlugin(pluginId); + if (!resolved) { + return { + state: 'error', + error: `Plugin "${pluginId}" not found in catalog`, + }; + } + + const { qualifiedName } = resolved; + + if (!QUALIFIED_NAME_RE.test(qualifiedName)) { + return { + state: 'error', + error: `Invalid plugin identifier: ${qualifiedName}`, + }; + } + + const args = ['plugin', 'uninstall']; + if (scope && scope !== 'user') { + args.push('-s', scope); + } + args.push(qualifiedName); + + logger.info(`Uninstalling plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`); + + try { + await execCli(this.claudeBinary, args, { + timeout: UNINSTALL_TIMEOUT_MS, + cwd: projectPath, + }); + return { state: 'success' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Plugin uninstall failed: ${message}`); + return { state: 'error', error: message }; + } + } +} diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts new file mode 100644 index 00000000..4b75daaf --- /dev/null +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -0,0 +1,105 @@ +/** + * Reads installed MCP server state from the filesystem. + * + * Sources: + * - User scope: ~/.claude.json → mcpServers + * - Project scope: .mcp.json in project root + * - Local scope: determined by Claude CLI (may also be in ~/.claude.json) + * + * Both files are managed by the Claude CLI. This service is read-only. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { createLogger } from '@shared/utils/logger'; +import type { InstalledMcpEntry } from '@shared/types/extensions'; +import { getHomeDir } from '@main/utils/pathDecoder'; + +const logger = createLogger('Extensions:McpState'); + +const CACHE_TTL_MS = 10_000; // 10 seconds + +interface TimedCache { + data: T; + fetchedAt: number; +} + +export class McpInstallationStateService { + private cache: TimedCache | null = null; + + /** + * Get all installed MCP servers across user and project scopes. + */ + async getInstalled(projectPath?: string): Promise { + // Cache is project-path-dependent, so invalidate on path change + if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { + return this.cache.data; + } + + const entries: InstalledMcpEntry[] = []; + + // User scope: ~/.claude.json + const userEntries = await this.readUserMcpServers(); + entries.push(...userEntries); + + // Project scope: .mcp.json + if (projectPath) { + const projectEntries = await this.readProjectMcpServers(projectPath); + entries.push(...projectEntries); + } + + this.cache = { data: entries, fetchedAt: Date.now() }; + return entries; + } + + /** + * Invalidate cache. Call after install/uninstall operations. + */ + invalidateCache(): void { + this.cache = null; + } + + // ── Private ──────────────────────────────────────────────────────────── + + private async readUserMcpServers(): Promise { + const configPath = path.join(getHomeDir(), '.claude.json'); + return this.readMcpServersFromFile(configPath, 'user'); + } + + private async readProjectMcpServers(projectPath: string): Promise { + const configPath = path.join(projectPath, '.mcp.json'); + return this.readMcpServersFromFile(configPath, 'project'); + } + + private async readMcpServersFromFile( + filePath: string, + scope: 'user' | 'project' + ): Promise { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as Record; + const mcpServers = json.mcpServers as + | Record + | undefined; + + if (!mcpServers || typeof mcpServers !== 'object') { + return []; + } + + return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => { + let transport: string | undefined; + if (config.command) transport = 'stdio'; + else if (config.url) transport = 'http'; + + return { name, scope, transport }; + }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + logger.error(`Failed to read MCP servers from ${filePath}:`, err); + return []; + } + } +} diff --git a/src/main/services/extensions/state/PluginInstallationStateService.ts b/src/main/services/extensions/state/PluginInstallationStateService.ts new file mode 100644 index 00000000..08954eb0 --- /dev/null +++ b/src/main/services/extensions/state/PluginInstallationStateService.ts @@ -0,0 +1,178 @@ +/** + * Reads plugin installed state and install counts from the filesystem. + * + * Sources: + * - Installed state: ~/.claude/plugins/installed_plugins.json + * - Install counts: ~/.claude/plugins/install-counts-cache.json + * + * Both files are managed by the Claude CLI. This service is read-only. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { createLogger } from '@shared/utils/logger'; +import type { InstalledPluginEntry } from '@shared/types/extensions'; +import type { InstallScope } from '@shared/types/extensions'; +import { getClaudeBasePath } from '@main/utils/pathDecoder'; + +const logger = createLogger('Extensions:PluginState'); + +// ── Constants ────────────────────────────────────────────────────────────── + +const INSTALLED_STATE_TTL_MS = 10_000; // 10 seconds +const INSTALL_COUNTS_TTL_MS = 5 * 60_000; // 5 minutes + +// ── Raw file shapes ──────────────────────────────────────────────────────── + +interface InstalledPluginsJson { + version: number; + plugins: Record< + string, // qualifiedName + Array<{ + scope: string; + installPath?: string; + version?: string; + installedAt?: string; + lastUpdated?: string; + gitCommitSha?: string; + }> + >; +} + +interface InstallCountsJson { + version: number; + fetchedAt: string; + counts: Array<{ + plugin: string; // qualifiedName format + unique_installs: number; + }>; +} + +// ── Cache ────────────────────────────────────────────────────────────────── + +interface TimedCache { + data: T; + fetchedAt: number; +} + +// ── Service ──────────────────────────────────────────────────────────────── + +export class PluginInstallationStateService { + private installedCache: TimedCache | null = null; + private countsCache: TimedCache> | null = null; + + /** + * Get all installed plugins across all scopes. + * Returns merged list from installed_plugins.json with scope tags. + */ + async getInstalledPlugins(_projectPath?: string): Promise { + if ( + this.installedCache && + Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS + ) { + return this.installedCache.data; + } + + const entries = await this.readInstalledPlugins(); + this.installedCache = { data: entries, fetchedAt: Date.now() }; + return entries; + } + + /** + * Get install counts keyed by pluginId (qualifiedName). + */ + async getInstallCounts(): Promise> { + if (this.countsCache && Date.now() - this.countsCache.fetchedAt < INSTALL_COUNTS_TTL_MS) { + return this.countsCache.data; + } + + const counts = await this.readInstallCounts(); + this.countsCache = { data: counts, fetchedAt: Date.now() }; + return counts; + } + + /** + * Invalidate all caches. Call after install/uninstall operations. + */ + invalidateCache(): void { + this.installedCache = null; + this.countsCache = null; + } + + // ── Private ──────────────────────────────────────────────────────────── + + private getPluginsDir(): string { + return path.join(getClaudeBasePath(), 'plugins'); + } + + private async readInstalledPlugins(): Promise { + const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json'); + + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as InstalledPluginsJson; + + if (json.version !== 2 || !json.plugins) { + logger.warn(`Unexpected installed_plugins.json version: ${json.version}`); + return []; + } + + const entries: InstalledPluginEntry[] = []; + + for (const [qualifiedName, installations] of Object.entries(json.plugins)) { + for (const inst of installations) { + entries.push({ + pluginId: qualifiedName, + scope: this.normalizeScope(inst.scope), + version: inst.version, + installedAt: inst.installedAt, + installPath: inst.installPath, + }); + } + } + + return entries; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; // No plugins installed yet + } + logger.error('Failed to read installed_plugins.json:', err); + return []; + } + } + + private async readInstallCounts(): Promise> { + const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json'); + + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as InstallCountsJson; + + const map = new Map(); + + if (json.counts && Array.isArray(json.counts)) { + for (const entry of json.counts) { + // Install counts use qualifiedName format (name@marketplace) + map.set(entry.plugin, entry.unique_installs); + } + } + + return map; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return new Map(); + } + logger.error('Failed to read install-counts-cache.json:', err); + return new Map(); + } + } + + private normalizeScope(raw: string): InstallScope { + const lower = raw.toLowerCase(); + if (lower === 'user' || lower === 'project' || lower === 'local') { + return lower; + } + return 'user'; // safe default + } +} diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 08a5642a..3587a214 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -15,3 +15,4 @@ export * from './error'; export * from './infrastructure'; export * from './parsing'; export * from './team'; +export * from './extensions'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 18b29dc0..5672748a 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -512,3 +512,41 @@ export const EDITOR_CHANGE = 'editor:change'; /** List project files by path (for @file mentions, independent of editor state) */ export const PROJECT_LIST_FILES = 'project:listFiles'; + +// ============================================================================= +// Extensions / Plugin Catalog Channels +// ============================================================================= + +/** Get all enriched plugins (catalog + installed state + counts) */ +export const PLUGIN_GET_ALL = 'plugin:getAll'; + +/** Get README content for a plugin by pluginId */ +export const PLUGIN_GET_README = 'plugin:getReadme'; + +// ============================================================================= +// Extensions / MCP Registry Channels +// ============================================================================= + +/** Search MCP servers across registries */ +export const MCP_REGISTRY_SEARCH = 'mcpRegistry:search'; + +/** Browse MCP catalog with pagination */ +export const MCP_REGISTRY_BROWSE = 'mcpRegistry:browse'; + +/** Get a single MCP server by registry ID */ +export const MCP_REGISTRY_GET_BY_ID = 'mcpRegistry:getById'; + +/** Get installed MCP servers */ +export const MCP_REGISTRY_GET_INSTALLED = 'mcpRegistry:getInstalled'; + +/** Install a plugin */ +export const PLUGIN_INSTALL = 'plugin:install'; + +/** Uninstall a plugin */ +export const PLUGIN_UNINSTALL = 'plugin:uninstall'; + +/** Install an MCP server */ +export const MCP_REGISTRY_INSTALL = 'mcpRegistry:install'; + +/** Uninstall an MCP server */ +export const MCP_REGISTRY_UNINSTALL = 'mcpRegistry:uninstall'; diff --git a/src/preload/index.ts b/src/preload/index.ts index a397873e..598aedfa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -129,6 +129,16 @@ import { WINDOW_IS_MAXIMIZED, WINDOW_MAXIMIZE, WINDOW_MINIMIZE, + PLUGIN_GET_ALL, + PLUGIN_GET_README, + PLUGIN_INSTALL, + PLUGIN_UNINSTALL, + MCP_REGISTRY_SEARCH, + MCP_REGISTRY_BROWSE, + MCP_REGISTRY_GET_BY_ID, + MCP_REGISTRY_GET_INSTALLED, + MCP_REGISTRY_INSTALL, + MCP_REGISTRY_UNINSTALL, } from './constants/ipcChannels'; import { CONFIG_ADD_CUSTOM_PROJECT_PATH, @@ -222,6 +232,15 @@ import type { UpdateKanbanPatch, WslClaudeRootCandidate, } from '@shared/types'; +import type { + EnrichedPlugin, + InstalledMcpEntry, + McpCatalogItem, + McpInstallRequest, + McpSearchResult, + OperationResult, + PluginInstallRequest, +} from '@shared/types/extensions'; import type { BinaryPreviewResult, CreateDirResponse, @@ -1253,6 +1272,38 @@ const electronAPI: ElectronAPI = { }; }, }, + + // ===== Plugin Catalog API (Electron-only) ===== + plugins: { + getAll: (projectPath?: string, forceRefresh?: boolean) => + invokeIpcWithResult(PLUGIN_GET_ALL, projectPath, forceRefresh), + getReadme: (pluginId: string) => + invokeIpcWithResult(PLUGIN_GET_README, pluginId), + install: (request: PluginInstallRequest) => + invokeIpcWithResult(PLUGIN_INSTALL, request), + uninstall: (pluginId: string, scope?: string, projectPath?: string) => + invokeIpcWithResult(PLUGIN_UNINSTALL, pluginId, scope, projectPath), + }, + + // ===== MCP Registry API (Electron-only) ===== + mcpRegistry: { + search: (query: string, limit?: number) => + invokeIpcWithResult(MCP_REGISTRY_SEARCH, query, limit), + browse: (cursor?: string, limit?: number) => + invokeIpcWithResult<{ servers: McpCatalogItem[]; nextCursor?: string }>( + MCP_REGISTRY_BROWSE, + cursor, + limit + ), + getById: (registryId: string) => + invokeIpcWithResult(MCP_REGISTRY_GET_BY_ID, registryId), + getInstalled: (projectPath?: string) => + invokeIpcWithResult(MCP_REGISTRY_GET_INSTALLED, projectPath), + install: (request: McpInstallRequest) => + invokeIpcWithResult(MCP_REGISTRY_INSTALL, request), + uninstall: (name: string, scope?: string, projectPath?: string) => + invokeIpcWithResult(MCP_REGISTRY_UNINSTALL, name, scope, projectPath), + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx new file mode 100644 index 00000000..5d3da713 --- /dev/null +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -0,0 +1,134 @@ +/** + * ExtensionStoreView — top-level component for the Extensions tab. + * Uses per-tab UI state via useExtensionsTabState() hook. + * Global catalog data comes from Zustand store. + */ + +import { useCallback, useEffect } from 'react'; + +import { api } from '@renderer/api'; +import { Button } from '@renderer/components/ui/button'; +import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; +import { useStore } from '@renderer/store'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; +import { Puzzle, RefreshCw, Server } from 'lucide-react'; + +import { McpServersPanel } from './mcp/McpServersPanel'; +import { PluginsPanel } from './plugins/PluginsPanel'; + +export const ExtensionStoreView = (): React.JSX.Element => { + const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog); + const mcpBrowse = useStore((s) => s.mcpBrowse); + const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled); + const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading); + const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading); + + const tabState = useExtensionsTabState(); + + // Fetch plugin catalog on mount + useEffect(() => { + void fetchPluginCatalog(); + }, [fetchPluginCatalog]); + + // Fetch MCP installed state on mount + useEffect(() => { + void mcpFetchInstalled(); + }, [mcpFetchInstalled]); + + // Refresh all data (plugins + MCP browse + installed) + const handleRefresh = useCallback(() => { + void fetchPluginCatalog(undefined, true); + void mcpBrowse(); // re-fetch first page + void mcpFetchInstalled(); + }, [fetchPluginCatalog, mcpBrowse, mcpFetchInstalled]); + + const isRefreshing = pluginCatalogLoading || mcpBrowseLoading; + + // Browser mode guard + if (!api.plugins && !api.mcpRegistry) { + return ( +
+
+ +

Extensions

+

Available in the desktop app only.

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Extensions

+
+ + + + + + Refresh catalog + + +
+ + {/* Sub-tabs */} +
+ tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')} + > + + + + Plugins + + + + MCP Servers + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx new file mode 100644 index 00000000..1aafa577 --- /dev/null +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -0,0 +1,96 @@ +/** + * InstallButton — animated install/uninstall button for extensions. + * States: idle → pending (spinner) → success (checkmark, 2s) → idle + */ + +import { Check, Loader2, Trash2 } from 'lucide-react'; + +import { Button } from '@renderer/components/ui/button'; + +import type { ExtensionOperationState } from '@shared/types/extensions'; + +interface InstallButtonProps { + state: ExtensionOperationState; + isInstalled: boolean; + onInstall: () => void; + onUninstall: () => void; + disabled?: boolean; + size?: 'sm' | 'default'; +} + +export function InstallButton({ + state, + isInstalled, + onInstall, + onUninstall, + disabled, + size = 'sm', +}: InstallButtonProps) { + if (state === 'pending') { + return ( + + ); + } + + if (state === 'success') { + return ( + + ); + } + + if (state === 'error') { + return ( + + ); + } + + // idle + if (isInstalled) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/renderer/components/extensions/common/InstallCountBadge.tsx b/src/renderer/components/extensions/common/InstallCountBadge.tsx new file mode 100644 index 00000000..9e04f667 --- /dev/null +++ b/src/renderer/components/extensions/common/InstallCountBadge.tsx @@ -0,0 +1,22 @@ +/** + * InstallCountBadge — formatted download count with icon. + */ + +import { Download } from 'lucide-react'; + +import { formatInstallCount } from '@shared/utils/extensionNormalizers'; + +interface InstallCountBadgeProps { + count: number; +} + +export const InstallCountBadge = ({ count }: InstallCountBadgeProps): React.JSX.Element | null => { + if (count <= 0) return null; + + return ( + + + {formatInstallCount(count)} + + ); +}; diff --git a/src/renderer/components/extensions/common/SearchInput.tsx b/src/renderer/components/extensions/common/SearchInput.tsx new file mode 100644 index 00000000..59271519 --- /dev/null +++ b/src/renderer/components/extensions/common/SearchInput.tsx @@ -0,0 +1,70 @@ +/** + * SearchInput — debounced search input with clear button. + */ + +import { useEffect, useRef, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Input } from '@renderer/components/ui/input'; +import { Search, X } from 'lucide-react'; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + debounceMs?: number; +} + +export const SearchInput = ({ + value, + onChange, + placeholder = 'Search...', + debounceMs, +}: SearchInputProps): React.JSX.Element => { + const [localValue, setLocalValue] = useState(value); + const timerRef = useRef | null>(null); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleChange = (next: string) => { + setLocalValue(next); + if (debounceMs && debounceMs > 0) { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => onChange(next), debounceMs); + } else { + onChange(next); + } + }; + + return ( +
+ + handleChange(e.target.value)} + placeholder={placeholder} + className="pl-9 pr-8" + /> + {localValue && ( + + )} +
+ ); +}; diff --git a/src/renderer/components/extensions/common/SourceBadge.tsx b/src/renderer/components/extensions/common/SourceBadge.tsx new file mode 100644 index 00000000..1458781a --- /dev/null +++ b/src/renderer/components/extensions/common/SourceBadge.tsx @@ -0,0 +1,31 @@ +/** + * SourceBadge — displays the source of a catalog item. + */ + +import { Badge } from '@renderer/components/ui/badge'; + +interface SourceBadgeProps { + source: 'official' | 'glama' | string; +} + +export const SourceBadge = ({ source }: SourceBadgeProps): React.JSX.Element => { + if (source === 'official') { + return ( + + Official + + ); + } + if (source === 'glama') { + return ( + + Glama + + ); + } + return ( + + Community + + ); +}; diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx new file mode 100644 index 00000000..6109c356 --- /dev/null +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -0,0 +1,118 @@ +/** + * McpServerCard — grid card for a single MCP server in the catalog. + * Shows server icon from registry when available. + */ + +import { useState } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { useStore } from '@renderer/store'; +import { Lock, Server, Wrench } from 'lucide-react'; + +import { InstallButton } from '../common/InstallButton'; +import { SourceBadge } from '../common/SourceBadge'; + +import type { McpCatalogItem } from '@shared/types/extensions'; + +interface McpServerCardProps { + server: McpCatalogItem; + isInstalled: boolean; + onClick: (serverId: string) => void; +} + +export const McpServerCard = ({ + server, + isInstalled, + onClick, +}: McpServerCardProps): React.JSX.Element => { + const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle'); + const installMcpServer = useStore((s) => s.installMcpServer); + const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); + const canAutoInstall = !!server.installSpec; + const [imgError, setImgError] = useState(false); + + return ( + + ); +}; diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx new file mode 100644 index 00000000..ad3f8aa5 --- /dev/null +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -0,0 +1,361 @@ +/** + * McpServerDetailDialog — full detail view for a single MCP server with install controls. + * Uses Radix UI Kit for all form elements. + */ + +import { useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { useStore } from '@renderer/store'; +import { api } from '@renderer/api'; +import { ExternalLink, Lock, Plus, Server, Trash2, Wrench } from 'lucide-react'; + +import { InstallButton } from '../common/InstallButton'; +import { SourceBadge } from '../common/SourceBadge'; + +import type { McpCatalogItem, McpHeaderDef } from '@shared/types/extensions'; + +interface McpServerDetailDialogProps { + server: McpCatalogItem | null; + isInstalled: boolean; + open: boolean; + onClose: () => void; +} + +type Scope = 'local' | 'user' | 'project'; + +const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ + { value: 'user', label: 'User (global)' }, + { value: 'project', label: 'Project' }, + { value: 'local', label: 'Local' }, +]; + +export const McpServerDetailDialog = ({ + server, + isInstalled, + open, + onClose, +}: McpServerDetailDialogProps): React.JSX.Element => { + const installProgress = useStore( + (s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle' + ); + const installMcpServer = useStore((s) => s.installMcpServer); + const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); + + const [scope, setScope] = useState('user'); + const [serverName, setServerName] = useState(''); + const [envValues, setEnvValues] = useState>({}); + const [headers, setHeaders] = useState([]); + const [imgError, setImgError] = useState(false); + + // Initialize form when server changes + const [lastServerId, setLastServerId] = useState(null); + if (server && server.id !== lastServerId) { + setLastServerId(server.id); + setServerName(server.name.toLowerCase().replaceAll(/\s+/g, '-')); + setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, '']))); + setHeaders([]); + setImgError(false); + } + + if (!server) return <>; + + const canAutoInstall = !!server.installSpec; + const isHttp = server.installSpec?.type === 'http'; + + const handleInstall = () => { + installMcpServer({ + registryId: server.id, + serverName, + scope, + envValues, + headers, + }); + }; + + const handleUninstall = () => { + uninstallMcpServer(server.id, serverName, scope); + }; + + const addHeader = () => { + setHeaders((prev) => [...prev, { key: '', value: '' }]); + }; + + const removeHeader = (index: number) => { + setHeaders((prev) => prev.filter((_, i) => i !== index)); + }; + + const updateHeader = (index: number, field: 'key' | 'value', value: string) => { + setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))); + }; + + return ( + !o && onClose()}> + + +
+ {/* Server icon */} +
+ {server.iconUrl && !imgError ? ( + setImgError(true)} + /> + ) : ( + + )} +
+
+
+
+ {server.name} + {server.description} +
+
+ {isInstalled && ( + + Installed + + )} + +
+
+
+
+
+ + {/* Metadata grid */} +
+
+ Source +

{server.source}

+
+ {server.version && ( +
+ Version +

{server.version}

+
+ )} + {server.license && ( +
+ License +

{server.license}

+
+ )} +
+ Install Type +

+ {server.installSpec + ? server.installSpec.type === 'stdio' + ? `npm: ${server.installSpec.npmPackage}` + : `HTTP: ${server.installSpec.transportType}` + : 'Manual setup required'} +

+
+
+ + {/* Auth indicator */} + {server.requiresAuth && ( +
+ + This server requires authentication +
+ )} + + {/* Install form */} + {canAutoInstall && ( +
+

+ {isInstalled ? 'Manage Installation' : 'Install Server'} +

+ + {/* Server name */} +
+ + setServerName(e.target.value)} + placeholder="my-server" + className="h-8 text-sm" + /> +
+ + {/* Scope */} +
+ + +
+ + {/* Environment variables */} + {server.envVars.length > 0 && ( +
+ +
+ {server.envVars.map((env) => ( +
+ + {env.name} + + + setEnvValues((prev) => ({ ...prev, [env.name]: e.target.value })) + } + className="h-7 flex-1 text-xs" + placeholder={env.description ?? env.name} + /> +
+ ))} +
+
+ )} + + {/* Headers (for HTTP/SSE servers) */} + {isHttp && ( +
+
+ + +
+ {headers.length > 0 && ( +
+ {headers.map((header, index) => ( +
+ updateHeader(index, 'key', e.target.value)} + className="h-7 w-32 text-xs" + placeholder="Header-Name" + /> + updateHeader(index, 'value', e.target.value)} + className="h-7 flex-1 text-xs" + placeholder="value" + /> + +
+ ))} +
+ )} +
+ )} + + {/* Install/Uninstall button */} +
+ +
+
+ )} + + {!canAutoInstall && ( +
+ This server requires manual setup. Check the repository for installation instructions. +
+ )} + + {/* Tools */} + {server.tools.length > 0 && ( +
+

+ + Tools ({server.tools.length}) +

+
+ {server.tools.map((tool) => ( +
+ {tool.name} + {tool.description &&

{tool.description}

} +
+ ))} +
+
+ )} + + {/* Links */} +
+ {server.repositoryUrl && ( + + )} + {server.glamaUrl && ( + + )} +
+
+
+ ); +}; diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx new file mode 100644 index 00000000..3ea91731 --- /dev/null +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -0,0 +1,269 @@ +/** + * McpServersPanel — search and browse the MCP server catalog. + */ + +import { useEffect, useMemo, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { useStore } from '@renderer/store'; +import { AlertTriangle, Search, Server } from 'lucide-react'; + +import { SearchInput } from '../common/SearchInput'; + +import { McpServerCard } from './McpServerCard'; +import { McpServerDetailDialog } from './McpServerDetailDialog'; + +import type { McpCatalogItem } from '@shared/types/extensions'; + +type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc'; + +const MCP_SORT_OPTIONS: { value: McpSortValue; label: string }[] = [ + { value: 'name-asc', label: 'Name A→Z' }, + { value: 'name-desc', label: 'Name Z→A' }, + { value: 'tools-desc', label: 'Most tools' }, +]; + +function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatalogItem[] { + return [...servers].sort((a, b) => { + switch (sort) { + case 'name-asc': + return a.name.localeCompare(b.name); + case 'name-desc': + return b.name.localeCompare(a.name); + case 'tools-desc': + return b.tools.length - a.tools.length; + default: + return 0; + } + }); +} + +interface McpServersPanelProps { + mcpSearchQuery: string; + mcpSearch: (query: string) => void; + mcpSearchResults: McpCatalogItem[]; + mcpSearchLoading: boolean; + mcpSearchWarnings: string[]; + selectedMcpServerId: string | null; + setSelectedMcpServerId: (id: string | null) => void; +} + +export const McpServersPanel = ({ + mcpSearchQuery, + mcpSearch, + mcpSearchResults, + mcpSearchLoading, + mcpSearchWarnings, + selectedMcpServerId, + setSelectedMcpServerId, +}: McpServersPanelProps): React.JSX.Element => { + const browseCatalog = useStore((s) => s.mcpBrowseCatalog); + const browseNextCursor = useStore((s) => s.mcpBrowseNextCursor); + const browseLoading = useStore((s) => s.mcpBrowseLoading); + const browseError = useStore((s) => s.mcpBrowseError); + const mcpBrowse = useStore((s) => s.mcpBrowse); + const installedServers = useStore((s) => s.mcpInstalledServers); + + const [mcpSort, setMcpSort] = useState('name-asc'); + const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false); + + // Load initial browse data + useEffect(() => { + if (browseCatalog.length === 0 && !browseLoading) { + void mcpBrowse(); + } + }, [browseCatalog.length, browseLoading, mcpBrowse]); + + // Decide which list to show: search results or browse + const isSearching = mcpSearchQuery.trim().length > 0; + const rawServers = isSearching ? mcpSearchResults : browseCatalog; + const isLoading = isSearching ? mcpSearchLoading : browseLoading; + const warnings = isSearching ? mcpSearchWarnings : []; + + // Installed lookup set + const installedNames = useMemo( + () => new Set(installedServers.map((s) => s.name)), + [installedServers] + ); + + // Sort + filter + const displayServers = useMemo(() => { + let result = rawServers; + if (mcpInstalledOnly) { + result = result.filter((s) => installedNames.has(s.name)); + } + return sortMcpServers(result, mcpSort); + }, [rawServers, mcpSort, mcpInstalledOnly, installedNames]); + + // Find selected server (search in both lists to avoid losing selection during search toggle) + const selectedServer = useMemo(() => { + if (!selectedMcpServerId) return null; + return ( + displayServers.find((s) => s.id === selectedMcpServerId) ?? + browseCatalog.find((s) => s.id === selectedMcpServerId) ?? + mcpSearchResults.find((s) => s.id === selectedMcpServerId) ?? + null + ); + }, [displayServers, browseCatalog, mcpSearchResults, selectedMcpServerId]); + + return ( +
+ {/* Search + Sort + Installed only row */} +
+
+ +
+ +
+ setMcpInstalledOnly(!mcpInstalledOnly)} + /> + +
+
+ + {/* Warnings */} + {warnings.length > 0 && ( +
+ {warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ )} + + {/* Skeleton loading */} + {isLoading && displayServers.length === 0 && ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {browseError && !isSearching && ( +
+ {browseError} +
+ )} + + {/* Empty state */} + {!isLoading && displayServers.length === 0 && ( +
+
+ {isSearching || mcpInstalledOnly ? ( + + ) : ( + + )} +
+

+ {isSearching + ? 'No servers found' + : mcpInstalledOnly + ? 'No installed servers' + : 'No MCP servers available'} +

+

+ {isSearching + ? 'Try a different search term' + : mcpInstalledOnly + ? 'Install servers from the catalog to see them here' + : 'Check back later for new servers'} +

+
+ )} + + {displayServers.length > 0 && ( +
+ {displayServers.map((server) => ( + + ))} +
+ )} + + {/* Load more for browse */} + {!isSearching && browseNextCursor && ( +
+ +
+ )} + + {/* Detail dialog */} + setSelectedMcpServerId(null)} + /> +
+ ); +}; diff --git a/src/renderer/components/extensions/plugins/CapabilityChips.tsx b/src/renderer/components/extensions/plugins/CapabilityChips.tsx new file mode 100644 index 00000000..7101cd89 --- /dev/null +++ b/src/renderer/components/extensions/plugins/CapabilityChips.tsx @@ -0,0 +1,61 @@ +/** + * CapabilityChips — filter chips for plugin capability types. + */ + +import { useMemo } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { getCapabilityLabel, inferCapabilities } from '@shared/utils/extensionNormalizers'; + +import type { EnrichedPlugin, PluginCapability } from '@shared/types/extensions'; + +const ALL_CAPABILITIES: PluginCapability[] = ['lsp', 'mcp', 'agent', 'command', 'hook', 'skill']; + +interface CapabilityChipsProps { + plugins: EnrichedPlugin[]; + selected: PluginCapability[]; + onToggle: (capability: PluginCapability) => void; +} + +export const CapabilityChips = ({ + plugins, + selected, + onToggle, +}: CapabilityChipsProps): React.JSX.Element => { + const capabilityCounts = useMemo(() => { + const counts = new Map(); + for (const p of plugins) { + const caps = inferCapabilities(p); + for (const cap of caps) { + counts.set(cap, (counts.get(cap) ?? 0) + 1); + } + } + return counts; + }, [plugins]); + + return ( +
+ {ALL_CAPABILITIES.map((cap) => { + const count = capabilityCounts.get(cap) ?? 0; + if (count === 0) return null; + const isActive = selected.includes(cap); + return ( + + ); + })} +
+ ); +}; diff --git a/src/renderer/components/extensions/plugins/CategoryChips.tsx b/src/renderer/components/extensions/plugins/CategoryChips.tsx new file mode 100644 index 00000000..4d8d7008 --- /dev/null +++ b/src/renderer/components/extensions/plugins/CategoryChips.tsx @@ -0,0 +1,58 @@ +/** + * CategoryChips — horizontal filter chips for plugin categories. + */ + +import { useMemo } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { normalizeCategory } from '@shared/utils/extensionNormalizers'; + +import type { EnrichedPlugin } from '@shared/types/extensions'; + +interface CategoryChipsProps { + plugins: EnrichedPlugin[]; + selected: string[]; + onToggle: (category: string) => void; +} + +export const CategoryChips = ({ + plugins, + selected, + onToggle, +}: CategoryChipsProps): React.JSX.Element => { + const categoryCounts = useMemo(() => { + const counts = new Map(); + for (const p of plugins) { + const cat = normalizeCategory(p.category); + counts.set(cat, (counts.get(cat) ?? 0) + 1); + } + // Sort by count descending + return [...counts.entries()].sort((a, b) => b[1] - a[1]); + }, [plugins]); + + if (categoryCounts.length === 0) return <>; + + return ( +
+ {categoryCounts.map(([category, count]) => { + const isActive = selected.includes(category); + return ( + + ); + })} +
+ ); +}; diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx new file mode 100644 index 00000000..f82f1c08 --- /dev/null +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -0,0 +1,89 @@ +/** + * PluginCard — grid card for a single plugin in the catalog. + */ + +import { Badge } from '@renderer/components/ui/badge'; +import { useStore } from '@renderer/store'; +import { + getCapabilityLabel, + inferCapabilities, + normalizeCategory, +} from '@shared/utils/extensionNormalizers'; + +import { InstallButton } from '../common/InstallButton'; +import { InstallCountBadge } from '../common/InstallCountBadge'; + +import type { EnrichedPlugin } from '@shared/types/extensions'; + +interface PluginCardProps { + plugin: EnrichedPlugin; + onClick: (pluginId: string) => void; +} + +export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Element => { + const capabilities = inferCapabilities(plugin); + const category = normalizeCategory(plugin.category); + const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle'); + const installPlugin = useStore((s) => s.installPlugin); + const uninstallPlugin = useStore((s) => s.uninstallPlugin); + + return ( + + ); +}; diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx new file mode 100644 index 00000000..2a04a5f2 --- /dev/null +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -0,0 +1,188 @@ +/** + * PluginDetailDialog — full detail view for a single plugin with install controls. + */ + +import { useEffect, useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { useStore } from '@renderer/store'; +import { api } from '@renderer/api'; +import { + getCapabilityLabel, + inferCapabilities, + normalizeCategory, +} from '@shared/utils/extensionNormalizers'; +import { ExternalLink, Loader2 } from 'lucide-react'; + +import { InstallButton } from '../common/InstallButton'; +import { InstallCountBadge } from '../common/InstallCountBadge'; + +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; + +import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions'; + +interface PluginDetailDialogProps { + plugin: EnrichedPlugin | null; + open: boolean; + onClose: () => void; +} + +const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ + { value: 'user', label: 'User (global)' }, + { value: 'project', label: 'Project' }, +]; + +export const PluginDetailDialog = ({ + plugin, + open, + onClose, +}: PluginDetailDialogProps): React.JSX.Element => { + const fetchPluginReadme = useStore((s) => s.fetchPluginReadme); + const readmes = useStore((s) => s.pluginReadmes); + const readmeLoading = useStore((s) => s.pluginReadmeLoading); + const installProgress = useStore( + (s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle' + ); + const installPlugin = useStore((s) => s.installPlugin); + const uninstallPlugin = useStore((s) => s.uninstallPlugin); + + const [scope, setScope] = useState('user'); + + useEffect(() => { + if (plugin && open) { + fetchPluginReadme(plugin.pluginId); + } + }, [plugin, open, fetchPluginReadme]); + + if (!plugin) return <>; + + const capabilities = inferCapabilities(plugin); + const category = normalizeCategory(plugin.category); + const readme = readmes[plugin.pluginId]; + const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false; + + return ( + !o && onClose()}> + + +
+
+ {plugin.name} + {plugin.description} +
+ {plugin.isInstalled && ( + + Installed + + )} +
+
+ + {/* Metadata grid */} +
+
+ Author +

{plugin.author?.name ?? 'Unknown'}

+
+
+ Category +

{category}

+
+
+ Capabilities +
+ {capabilities.map((cap) => ( + + {getCapabilityLabel(cap)} + + ))} +
+
+
+ Installs +
+ +
+
+
+ + {/* Install controls */} +
+
+ + +
+ installPlugin({ pluginId: plugin.pluginId, scope })} + onUninstall={() => uninstallPlugin(plugin.pluginId, scope)} + size="default" + /> +
+ + {/* Homepage link */} + {plugin.homepage && ( + + )} + + {/* README */} +
+ {isReadmeLoading && ( +
+ + Loading README... +
+ )} + {!isReadmeLoading && readme && ( + + )} + {!isReadmeLoading && !readme && ( +

No README available.

+ )} +
+
+
+ ); +}; diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx new file mode 100644 index 00000000..3d235daa --- /dev/null +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -0,0 +1,301 @@ +/** + * PluginsPanel — search, filter, sort and browse the plugin catalog. + */ + +import { useMemo } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { useStore } from '@renderer/store'; +import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; +import { Puzzle, Search } from 'lucide-react'; + +import { SearchInput } from '../common/SearchInput'; + +import { CapabilityChips } from './CapabilityChips'; +import { CategoryChips } from './CategoryChips'; +import { PluginCard } from './PluginCard'; +import { PluginDetailDialog } from './PluginDetailDialog'; + +import type { + EnrichedPlugin, + PluginCapability, + PluginFilters, + PluginSortField, +} from '@shared/types/extensions'; + +interface PluginsPanelProps { + pluginFilters: PluginFilters; + pluginSort: { field: PluginSortField; order: 'asc' | 'desc' }; + selectedPluginId: string | null; + updatePluginSearch: (search: string) => void; + toggleCategory: (category: string) => void; + toggleCapability: (capability: PluginCapability) => void; + toggleInstalledOnly: () => void; + setSelectedPluginId: (id: string | null) => void; + clearFilters: () => void; + hasActiveFilters: boolean; + setPluginSort: (sort: { field: PluginSortField; order: 'asc' | 'desc' }) => void; +} + +const SORT_OPTIONS: { value: string; label: string }[] = [ + { value: 'popularity:desc', label: 'Popular' }, + { value: 'name:asc', label: 'Name A-Z' }, + { value: 'name:desc', label: 'Name Z-A' }, + { value: 'category:asc', label: 'Category' }, +]; + +/** Pure function: filter + sort the catalog */ +function selectFilteredPlugins( + catalog: EnrichedPlugin[], + filters: PluginFilters, + sort: { field: PluginSortField; order: 'asc' | 'desc' } +): EnrichedPlugin[] { + let result = catalog; + + // Search + if (filters.search) { + const q = filters.search.toLowerCase(); + result = result.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.description.toLowerCase().includes(q) || + p.pluginId.toLowerCase().includes(q) + ); + } + + // Categories + if (filters.categories.length > 0) { + result = result.filter((p) => filters.categories.includes(normalizeCategory(p.category))); + } + + // Capabilities + if (filters.capabilities.length > 0) { + result = result.filter((p) => { + const caps = inferCapabilities(p); + return filters.capabilities.some((fc) => caps.includes(fc)); + }); + } + + // Installed only + if (filters.installedOnly) { + result = result.filter((p) => p.isInstalled); + } + + // Sort + const direction = sort.order === 'asc' ? 1 : -1; + result = [...result].sort((a, b) => { + switch (sort.field) { + case 'popularity': + return (b.installCount - a.installCount) * direction; + case 'name': + return a.name.localeCompare(b.name) * direction; + case 'category': + return a.category.localeCompare(b.category) * direction; + default: + return 0; + } + }); + + return result; +} + +export const PluginsPanel = ({ + pluginFilters, + pluginSort, + selectedPluginId, + updatePluginSearch, + toggleCategory, + toggleCapability, + toggleInstalledOnly, + setSelectedPluginId, + clearFilters, + hasActiveFilters, + setPluginSort, +}: PluginsPanelProps): React.JSX.Element => { + const catalog = useStore((s) => s.pluginCatalog); + const loading = useStore((s) => s.pluginCatalogLoading); + const error = useStore((s) => s.pluginCatalogError); + + const filtered = useMemo( + () => selectFilteredPlugins(catalog, pluginFilters, pluginSort), + [catalog, pluginFilters, pluginSort] + ); + + const selectedPlugin = useMemo( + () => + selectedPluginId ? (catalog.find((p) => p.pluginId === selectedPluginId) ?? null) : null, + [catalog, selectedPluginId] + ); + + const sortValue = `${pluginSort.field}:${pluginSort.order}`; + + return ( +
+ {/* Search + Sort + Installed only row */} +
+
+ +
+ +
+ + +
+
+ + {/* Filters */} +
+
+
+ + Categories + + {hasActiveFilters && ( + + )} +
+ +
+ + Capabilities + + +
+
+ + {/* Result count */} + {!loading && !error && filtered.length > 0 && ( +

+ {filtered.length} plugin{filtered.length !== 1 ? 's' : ''} +

+ )} + + {/* Content */} + {loading && ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && filtered.length === 0 && ( +
+
+ {hasActiveFilters ? ( + + ) : ( + + )} +
+

+ {hasActiveFilters ? 'No plugins match your filters' : 'No plugins available'} +

+

+ {hasActiveFilters + ? 'Try adjusting your search or filter criteria' + : 'Check back later for new plugins'} +

+ {hasActiveFilters && ( + + )} +
+ )} + + {!loading && !error && filtered.length > 0 && ( +
+ {filtered.map((plugin) => ( + + ))} +
+ )} + + {/* Detail dialog */} + setSelectedPluginId(null)} + /> +
+ ); +}; diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index ed036990..b9f502ae 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -6,6 +6,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { DashboardView } from '../dashboard/DashboardView'; +import { ExtensionStoreView } from '../extensions/ExtensionStoreView'; import { NotificationsView } from '../notifications/NotificationsView'; import { SessionReportTab } from '../report/SessionReportTab'; import { SettingsView } from '../settings/SettingsView'; @@ -57,6 +58,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { )} {tab.type === 'report' && } + {tab.type === 'extensions' && ( + + + + )}
); })} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 2be1cc5d..ff77a565 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -17,6 +17,7 @@ import { FileText, LayoutDashboard, Pin, + Puzzle, Search, Settings, Users, @@ -48,6 +49,7 @@ const TAB_ICONS = { teams: Users, team: Users, report: Activity, + extensions: Puzzle, } as const; export const SortableTab = ({ diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 6f8a167d..3fe4459a 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -15,7 +15,7 @@ import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Bell, PanelLeft, Plus, RefreshCw, Settings, Users } from 'lucide-react'; +import { Bell, PanelLeft, Plus, Puzzle, RefreshCw, Settings, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { MoreMenu } from './MoreMenu'; @@ -44,6 +44,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { unreadCount, openNotificationsTab, openTeamsTab, + openExtensionsTab, openSettingsTab, sidebarCollapsed, toggleSidebar, @@ -71,6 +72,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, openTeamsTab: s.openTeamsTab, + openExtensionsTab: s.openExtensionsTab, openSettingsTab: s.openSettingsTab, sidebarCollapsed: s.sidebarCollapsed, toggleSidebar: s.toggleSidebar, @@ -104,6 +106,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { const [newTabHover, setNewTabHover] = useState(false); const [notificationsHover, setNotificationsHover] = useState(false); const [teamsHover, setTeamsHover] = useState(false); + const [extensionsHover, setExtensionsHover] = useState(false); const [githubHover, setGithubHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false); @@ -416,6 +419,21 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { + {/* Extensions icon */} + + {/* GitHub link */}