diff --git a/README.md b/README.md index 63997838..1213f49f 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ ## What is this -A new approach to task management with AI agents. +A new approach to task management with AI agent teams. - **Assemble your team** — create agent teams with different roles that work autonomously in parallel - **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments +- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams - **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own - **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment - **Full tool visibility** — inspect exactly which tools an agent used to complete each task diff --git a/src/main/index.ts b/src/main/index.ts index b2c6272a..79e64197 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -73,6 +73,7 @@ import { UpdaterService, } from './services'; import { + ApiKeyService, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, @@ -720,6 +721,7 @@ function initializeServices(): void { // Install services — pass null for binary (uses PATH lookup via execCli fallback) const pluginInstallService = new PluginInstallService(null, pluginCatalogService); const mcpInstallService = new McpInstallService(null, mcpAggregator); + const apiKeyService = new ApiKeyService(); // warmup() and ensureInstalled() are deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. httpServer = new HttpServer(); @@ -780,6 +782,7 @@ function initializeServices(): void { extensionFacadeService, pluginInstallService, mcpInstallService, + apiKeyService, crossTeamService ); diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index bce3da19..53411a2b 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -8,9 +8,14 @@ import { createLogger } from '@shared/utils/logger'; import type { + ApiKeyEntry, + ApiKeyLookupResult, + ApiKeySaveRequest, + ApiKeyStorageStatus, EnrichedPlugin, InstalledMcpEntry, McpCatalogItem, + McpCustomInstallRequest, McpInstallRequest, McpSearchResult, OperationResult, @@ -21,12 +26,20 @@ 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 type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; import { + API_KEYS_DELETE, + API_KEYS_LIST, + API_KEYS_LOOKUP, + API_KEYS_SAVE, + API_KEYS_STORAGE_STATUS, + MCP_GITHUB_STARS, MCP_REGISTRY_BROWSE, MCP_REGISTRY_GET_BY_ID, MCP_REGISTRY_GET_INSTALLED, MCP_REGISTRY_INSTALL, + MCP_REGISTRY_INSTALL_CUSTOM, MCP_REGISTRY_SEARCH, MCP_REGISTRY_UNINSTALL, PLUGIN_GET_ALL, @@ -35,6 +48,8 @@ import { PLUGIN_UNINSTALL, } from '@preload/constants/ipcChannels'; +import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService'; + const logger = createLogger('IPC:extensions'); /** Allowed scope values */ @@ -42,20 +57,25 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']); // ── Module state ─────────────────────────────────────────────────────────── +const starsService = new GitHubStarsService(); + let extensionFacade: ExtensionFacadeService | null = null; let pluginInstaller: PluginInstallService | null = null; let mcpInstaller: McpInstallService | null = null; +let apiKeyService: ApiKeyService | null = null; // ── Lifecycle ────────────────────────────────────────────────────────────── export function initializeExtensionHandlers( facade: ExtensionFacadeService, pluginInstall?: PluginInstallService, - mcpInstall?: McpInstallService + mcpInstall?: McpInstallService, + apiKeys?: ApiKeyService ): void { extensionFacade = facade; pluginInstaller = pluginInstall ?? null; mcpInstaller = mcpInstall ?? null; + apiKeyService = apiKeys ?? null; } export function registerExtensionHandlers(ipcMain: IpcMain): void { @@ -68,7 +88,14 @@ export function registerExtensionHandlers(ipcMain: IpcMain): void { 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_INSTALL_CUSTOM, handleMcpInstallCustom); ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall); + ipcMain.handle(API_KEYS_LIST, handleApiKeysList); + ipcMain.handle(API_KEYS_SAVE, handleApiKeysSave); + ipcMain.handle(API_KEYS_DELETE, handleApiKeysDelete); + ipcMain.handle(API_KEYS_LOOKUP, handleApiKeysLookup); + ipcMain.handle(API_KEYS_STORAGE_STATUS, handleApiKeysStorageStatus); + ipcMain.handle(MCP_GITHUB_STARS, handleMcpGitHubStars); } export function removeExtensionHandlers(ipcMain: IpcMain): void { @@ -81,7 +108,14 @@ export function removeExtensionHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID); ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED); ipcMain.removeHandler(MCP_REGISTRY_INSTALL); + ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM); ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL); + ipcMain.removeHandler(API_KEYS_LIST); + ipcMain.removeHandler(API_KEYS_SAVE); + ipcMain.removeHandler(API_KEYS_DELETE); + ipcMain.removeHandler(API_KEYS_LOOKUP); + ipcMain.removeHandler(API_KEYS_STORAGE_STATUS); + ipcMain.removeHandler(MCP_GITHUB_STARS); } // ── Service guard ────────────────────────────────────────────────────────── @@ -270,6 +304,28 @@ async function handleMcpInstall( }); } +async function handleMcpInstallCustom( + _event: IpcMainInvokeEvent, + request?: McpCustomInstallRequest +): Promise> { + return wrapHandler('mcpInstallCustom', async () => { + if (!request || typeof request.serverName !== 'string' || !request.serverName) { + throw new Error('Invalid custom install request: serverName is required'); + } + if (!request.installSpec) { + throw new Error('Invalid custom install request: installSpec is required'); + } + if (request.scope && !VALID_SCOPES.has(request.scope)) { + throw new Error(`Invalid scope: "${request.scope}"`); + } + const result = await getMcpInstaller().installCustom(request); + if (result.state === 'success') { + getFacade().invalidateInstalledCache(); + } + return result; + }); +} + async function handleMcpUninstall( _event: IpcMainInvokeEvent, name?: string, @@ -294,3 +350,62 @@ async function handleMcpUninstall( return result; }); } + +// ── API Keys Guard ───────────────────────────────────────────────────────── + +function getApiKeyService(): ApiKeyService { + if (!apiKeyService) throw new Error('API key service not initialized'); + return apiKeyService; +} + +// ── API Keys Handlers ────────────────────────────────────────────────────── + +async function handleApiKeysList(): Promise> { + return wrapHandler('apiKeysList', () => getApiKeyService().list()); +} + +async function handleApiKeysSave( + _event: IpcMainInvokeEvent, + request?: ApiKeySaveRequest +): Promise> { + return wrapHandler('apiKeysSave', () => { + if (!request) throw new Error('Request is required'); + return getApiKeyService().save(request); + }); +} + +async function handleApiKeysDelete( + _event: IpcMainInvokeEvent, + id?: string +): Promise> { + return wrapHandler('apiKeysDelete', () => { + if (typeof id !== 'string' || !id) throw new Error('Key ID is required'); + return getApiKeyService().delete(id); + }); +} + +async function handleApiKeysLookup( + _event: IpcMainInvokeEvent, + envVarNames?: string[] +): Promise> { + return wrapHandler('apiKeysLookup', () => { + if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required'); + return getApiKeyService().lookup(envVarNames); + }); +} + +async function handleApiKeysStorageStatus(): Promise> { + return wrapHandler('apiKeysStorageStatus', () => getApiKeyService().getStorageStatus()); +} + +// ── GitHub Stars Handler ────────────────────────────────────────────────── + +async function handleMcpGitHubStars( + _event: IpcMainInvokeEvent, + repositoryUrls?: string[] +): Promise>> { + return wrapHandler('mcpGitHubStars', () => { + if (!Array.isArray(repositoryUrls)) throw new Error('repositoryUrls array is required'); + return starsService.fetchStars(repositoryUrls); + }); +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 6d05559f..0a733e85 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -107,6 +107,7 @@ import type { CrossTeamService } from '../services/team/CrossTeamService'; import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; +import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; import type { SchedulerService } from '../services/schedule/SchedulerService'; /** @@ -139,6 +140,7 @@ export function initializeIpcHandlers( extensionFacade?: ExtensionFacadeService, pluginInstaller?: PluginInstallService, mcpInstaller?: McpInstallService, + apiKeyService?: ApiKeyService, crossTeamService?: CrossTeamService ): void { // Initialize domain handlers with registry @@ -175,7 +177,7 @@ export function initializeIpcHandlers( initializeScheduleHandlers(schedulerService); } if (extensionFacade) { - initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller); + initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService); } if (crossTeamService) { initializeCrossTeamHandlers(crossTeamService); diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts new file mode 100644 index 00000000..be754ad3 --- /dev/null +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -0,0 +1,398 @@ +/** + * ApiKeyService — encrypted API key storage with CRUD operations. + * + * Encryption strategy (in priority order): + * 1. Electron safeStorage (OS keychain: macOS Keychain / Windows DPAPI / Linux gnome-libsecret/kwallet) + * 2. AES-256-GCM with machine-derived key (Linux fallback when no keyring is available) + * + * File permissions: 0o600 (owner read/write only) on Unix systems. + * Storage file: ~/.claude/api-keys.json + */ + +import { safeStorage } from 'electron'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import { createLogger } from '@shared/utils/logger'; +import type { + ApiKeyEntry, + ApiKeyLookupResult, + ApiKeySaveRequest, + ApiKeyStorageStatus, +} from '@shared/types/extensions'; + +const logger = createLogger('Extensions:ApiKeys'); +const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; + +/** How the value was encrypted on disk */ +type EncryptionMethod = 'safeStorage' | 'aes-local' | 'base64'; + +interface StoredApiKey { + id: string; + name: string; + envVarName: string; + encryptedValue: string; + /** @deprecated Use encryptionMethod instead. Kept for migration. */ + encrypted?: boolean; + encryptionMethod?: EncryptionMethod; + scope: 'user' | 'project'; + createdAt: string; + updatedAt?: string; +} + +/** AES-256-GCM constants */ +const AES_IV_BYTES = 12; +const AES_TAG_BYTES = 16; +const PBKDF2_ITERATIONS = 100_000; +const PBKDF2_KEY_BYTES = 32; +const PBKDF2_SALT = 'claude-apikey-storage-v1'; + +export class ApiKeyService { + private readonly filePath: string; + private cache: StoredApiKey[] | null = null; + private aesKey: Buffer | null = null; + + constructor(claudeDir?: string) { + const baseDir = claudeDir ?? path.join(os.homedir(), '.claude'); + this.filePath = path.join(baseDir, 'api-keys.json'); + } + + // ── Public API ────────────────────────────────────────────────────────── + + async list(): Promise { + const keys = await this.readStore(); + return keys.map((k) => ({ + id: k.id, + name: k.name, + envVarName: k.envVarName, + maskedValue: this.mask(this.decrypt(k)), + scope: k.scope, + createdAt: k.createdAt, + })); + } + + async save(request: ApiKeySaveRequest): Promise { + if (!request.name?.trim()) throw new Error('Key name is required'); + if (!request.envVarName?.trim()) throw new Error('Environment variable name is required'); + if (!ENV_KEY_RE.test(request.envVarName)) { + throw new Error( + `Invalid env var name: "${request.envVarName}". Use uppercase letters, digits, underscores.` + ); + } + if (!request.value) throw new Error('Key value is required'); + + const keys = await this.readStore(); + const now = new Date().toISOString(); + const { method, value } = this.encrypt(request.value); + + if (request.id) { + const idx = keys.findIndex((k) => k.id === request.id); + if (idx === -1) throw new Error(`API key not found: ${request.id}`); + keys[idx] = { + ...keys[idx], + name: request.name.trim(), + envVarName: request.envVarName.trim(), + encryptedValue: value, + encryptionMethod: method, + scope: request.scope, + updatedAt: now, + }; + // Remove legacy field + delete keys[idx].encrypted; + } else { + keys.push({ + id: crypto.randomUUID(), + name: request.name.trim(), + envVarName: request.envVarName.trim(), + encryptedValue: value, + encryptionMethod: method, + scope: request.scope, + createdAt: now, + }); + } + + await this.writeStore(keys); + const saved = keys[request.id ? keys.findIndex((k) => k.id === request.id) : keys.length - 1]; + return { + id: saved.id, + name: saved.name, + envVarName: saved.envVarName, + maskedValue: this.mask(request.value), + scope: saved.scope, + createdAt: saved.createdAt, + }; + } + + async delete(id: string): Promise { + const keys = await this.readStore(); + const filtered = keys.filter((k) => k.id !== id); + if (filtered.length === keys.length) throw new Error(`API key not found: ${id}`); + await this.writeStore(filtered); + } + + async lookup(envVarNames: string[]): Promise { + if (!envVarNames.length) return []; + const keys = await this.readStore(); + const nameSet = new Set(envVarNames); + return keys + .filter((k) => nameSet.has(k.envVarName)) + .map((k) => ({ + envVarName: k.envVarName, + value: this.decrypt(k), + })); + } + + async getStorageStatus(): Promise { + const secure = this.isSecureBackend(); + const backend = this.getBackendName(); + let fileSecure = true; + if (process.platform !== 'win32') { + try { + const stat = await fs.stat(this.filePath); + fileSecure = (stat.mode & 0o077) === 0; + } catch { + // File doesn't exist yet — considered secure + } + } + return { + encryptionMethod: secure ? 'os-keychain' : 'aes-local', + backend, + fileSecure, + }; + } + + // ── Encryption ────────────────────────────────────────────────────────── + + /** + * Check if the OS provides a real secure backend (not basic_text). + * On Linux, safeStorage.isEncryptionAvailable() returns true even with basic_text + * backend (hardcoded password), so we must check the actual backend name. + */ + private isSecureBackend(): boolean { + try { + if (!safeStorage.isEncryptionAvailable()) return false; + if (process.platform === 'linux') { + const backend = safeStorage.getSelectedStorageBackend(); + return backend !== 'basic_text' && backend !== 'unknown'; + } + return true; // macOS Keychain / Windows DPAPI are always reliable + } catch { + return false; + } + } + + private getBackendName(): string { + if (process.platform === 'darwin') return 'macOS Keychain'; + if (process.platform === 'win32') return 'Windows DPAPI'; + try { + return safeStorage.getSelectedStorageBackend(); + } catch { + return 'unknown'; + } + } + + private encrypt(value: string): { method: EncryptionMethod; value: string } { + // Try OS keychain first + if (this.isSecureBackend()) { + try { + const buf = safeStorage.encryptString(value); + return { method: 'safeStorage', value: buf.toString('base64') }; + } catch (err) { + logger.warn('safeStorage encryption failed, falling back to AES-local:', err); + } + } + + // Fallback: AES-256-GCM with machine-derived key + return { method: 'aes-local', value: this.encryptAesLocal(value) }; + } + + private decrypt(stored: StoredApiKey): string { + try { + const method = this.resolveMethod(stored); + + switch (method) { + case 'safeStorage': { + const buf = Buffer.from(stored.encryptedValue, 'base64'); + return safeStorage.decryptString(buf); + } + case 'aes-local': + return this.decryptAesLocal(stored.encryptedValue); + case 'base64': + return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8'); + } + } catch (err) { + logger.error(`Failed to decrypt API key "${stored.name}":`, err); + return ''; + } + } + + /** Resolve encryption method, handling legacy entries without encryptionMethod field */ + private resolveMethod(stored: StoredApiKey): EncryptionMethod { + if (stored.encryptionMethod) return stored.encryptionMethod; + // Legacy migration: encrypted boolean → method + return stored.encrypted ? 'safeStorage' : 'base64'; + } + + // ── AES-256-GCM local encryption ─────────────────────────────────────── + + /** + * Derive an AES key from machine-specific info. + * Not as secure as OS keychain (extractable with root), but far better than plaintext. + * Protects against casual read by other users. + */ + private getAesKey(): Buffer { + if (this.aesKey) return this.aesKey; + + const material = [os.hostname(), os.userInfo().username, this.getMachineId()].join(':'); + + this.aesKey = crypto.pbkdf2Sync( + material, + PBKDF2_SALT, + PBKDF2_ITERATIONS, + PBKDF2_KEY_BYTES, + 'sha512' + ); + return this.aesKey; + } + + private getMachineId(): string { + if (process.platform !== 'linux') { + return `${process.platform}-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`; + } + // Linux: try /etc/machine-id (systemd) or /var/lib/dbus/machine-id + const candidates = ['/etc/machine-id', '/var/lib/dbus/machine-id']; + for (const p of candidates) { + try { + // Synchronous read is OK — called once, result cached in aesKey + const { readFileSync } = require('node:fs') as typeof import('node:fs'); + const id = readFileSync(p, 'utf-8').trim(); + if (id) return id; + } catch { + // Try next candidate + } + } + return `linux-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`; + } + + private encryptAesLocal(value: string): string { + const key = this.getAesKey(); + const iv = crypto.randomBytes(AES_IV_BYTES); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]); + const tag = cipher.getAuthTag(); + // Format: iv:tag:ciphertext (all base64) + return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':'); + } + + private decryptAesLocal(stored: string): string { + const parts = stored.split(':'); + if (parts.length !== 3) throw new Error('Invalid AES-local format'); + const [ivB64, tagB64, dataB64] = parts; + const key = this.getAesKey(); + const iv = Buffer.from(ivB64, 'base64'); + const tag = Buffer.from(tagB64, 'base64'); + const data = Buffer.from(dataB64, 'base64'); + + if (iv.length !== AES_IV_BYTES) throw new Error('Invalid IV length'); + if (tag.length !== AES_TAG_BYTES) throw new Error('Invalid auth tag length'); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return decipher.update(data).toString('utf-8') + decipher.final('utf-8'); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private mask(value: string): string { + if (value.length <= 3) return '***'; + return '*'.repeat(Math.min(value.length - 3, 20)) + value.slice(-3); + } + + // ── Storage I/O ───────────────────────────────────────────────────────── + + private async readStore(): Promise { + if (this.cache) return this.cache; + try { + const raw = await fs.readFile(this.filePath, 'utf-8'); + const data = JSON.parse(raw); + const keys: StoredApiKey[] = Array.isArray(data) ? data : []; + + // Fix file permissions if insecure + await this.ensureFilePermissions(); + + // Migrate legacy entries (base64 → current method) + const migrated = this.migrateKeys(keys); + if (migrated) { + await this.writeStore(keys); + } + + this.cache = keys; + } catch { + this.cache = []; + } + return this.cache; + } + + /** + * Migrate legacy entries: re-encrypt base64 entries with current best method. + * Returns true if any entries were migrated. + */ + private migrateKeys(keys: StoredApiKey[]): boolean { + let migrated = false; + for (let i = 0; i < keys.length; i++) { + const method = this.resolveMethod(keys[i]); + if (method === 'base64') { + try { + const plaintext = Buffer.from(keys[i].encryptedValue, 'base64').toString('utf-8'); + if (!plaintext) continue; + const { method: newMethod, value } = this.encrypt(plaintext); + keys[i] = { + ...keys[i], + encryptedValue: value, + encryptionMethod: newMethod, + }; + delete keys[i].encrypted; + migrated = true; + logger.info(`Migrated API key "${keys[i].name}" from base64 to ${newMethod}`); + } catch (err) { + logger.warn(`Failed to migrate API key "${keys[i].name}":`, err); + } + } + } + return migrated; + } + + private async writeStore(keys: StoredApiKey[]): Promise { + const dir = path.dirname(this.filePath); + await fs.mkdir(dir, { recursive: true }); + const tmpPath = this.filePath + `.tmp.${crypto.randomUUID()}`; + await fs.writeFile(tmpPath, JSON.stringify(keys, null, 2), 'utf-8'); + + // Set restrictive permissions before rename (Unix only) + if (process.platform !== 'win32') { + try { + await fs.chmod(tmpPath, 0o600); + } catch { + // Best-effort — Windows or restricted FS + } + } + + await fs.rename(tmpPath, this.filePath); + this.cache = keys; + } + + /** Ensure the storage file has owner-only permissions */ + private async ensureFilePermissions(): Promise { + if (process.platform === 'win32') return; + try { + const stat = await fs.stat(this.filePath); + if ((stat.mode & 0o077) !== 0) { + logger.warn('API keys file has insecure permissions, fixing to 0600'); + await fs.chmod(this.filePath, 0o600); + } + } catch { + // File may not exist yet + } + } +} diff --git a/src/main/services/extensions/catalog/GitHubStarsService.ts b/src/main/services/extensions/catalog/GitHubStarsService.ts new file mode 100644 index 00000000..99ebfa6c --- /dev/null +++ b/src/main/services/extensions/catalog/GitHubStarsService.ts @@ -0,0 +1,184 @@ +/** + * GitHubStarsService — fetches stargazer counts from the GitHub public API. + * + * - Batch interface: accepts repository URLs, returns map of URL → stars + * - In-memory cache with 1-hour TTL + * - Concurrency-limited to 5 parallel requests + * - Silent failure: 404, timeout, rate-limit → entry skipped + */ + +import https from 'node:https'; + +import { createLogger } from '@shared/utils/logger'; +import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers'; + +const logger = createLogger('Extensions:GitHubStars'); + +// ── Constants ────────────────────────────────────────────────────────────── + +const CACHE_TTL_MS = 60 * 60_000; // 1 hour +const HTTP_TIMEOUT_MS = 10_000; // 10 seconds +const MAX_CONCURRENCY = 5; +const MAX_BODY_SIZE = 256 * 1024; // 256KB (GitHub repo JSON is ~10KB) + +// ── Cache entry ──────────────────────────────────────────────────────────── + +interface CacheEntry { + stars: number; + fetchedAt: number; +} + +// ── Service ──────────────────────────────────────────────────────────────── + +export class GitHubStarsService { + private cache = new Map(); + + /** + * Fetch GitHub stars for a batch of repository URLs. + * Returns `Record` — only includes URLs with valid results. + */ + async fetchStars(repositoryUrls: string[]): Promise> { + const result: Record = {}; + const tasks: Array<{ url: string; owner: string; repo: string }> = []; + + for (const url of repositoryUrls) { + const parsed = parseGitHubOwnerRepo(url); + if (!parsed) continue; + + const cacheKey = `${parsed.owner}/${parsed.repo}`.toLowerCase(); + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + result[url] = cached.stars; + continue; + } + + tasks.push({ url, owner: parsed.owner, repo: parsed.repo }); + } + + if (tasks.length === 0) return result; + + // Fetch with concurrency limit + const outcomes = await this.withConcurrencyLimit( + tasks.map(({ url, owner, repo }) => async () => { + const stars = await this.fetchSingle(owner, repo); + if (stars != null) { + const cacheKey = `${owner}/${repo}`.toLowerCase(); + this.cache.set(cacheKey, { stars, fetchedAt: Date.now() }); + result[url] = stars; + } + }), + MAX_CONCURRENCY + ); + + // Log any failures for debugging (already silent to caller) + const failures = outcomes.filter((o) => o === 'error').length; + if (failures > 0) { + logger.debug( + `GitHub stars: ${tasks.length - failures}/${tasks.length} fetched, ${failures} failed` + ); + } + + return result; + } + + /** + * Fetch stargazer count for a single repo. Returns null on any error. + */ + private async fetchSingle(owner: string, repo: string): Promise { + try { + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const { statusCode, body } = await githubGet(url); + + if (statusCode !== 200) { + if (statusCode === 403 || statusCode === 429) { + logger.debug(`GitHub API rate limit hit (${statusCode}), skipping remaining`); + } + return null; + } + + const data = JSON.parse(body) as { stargazers_count?: number }; + return typeof data.stargazers_count === 'number' ? data.stargazers_count : null; + } catch { + return null; + } + } + + /** + * Run async tasks with a concurrency limit. + */ + private async withConcurrencyLimit( + tasks: Array<() => Promise>, + limit: number + ): Promise> { + const results: Array<'ok' | 'error'> = []; + let index = 0; + + const run = async (): Promise => { + while (index < tasks.length) { + const i = index++; + try { + await tasks[i](); + results[i] = 'ok'; + } catch { + results[i] = 'error'; + } + } + }; + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => run()); + await Promise.all(workers); + return results; + } +} + +// ── HTTP helper (GitHub-specific) ────────────────────────────────────────── + +function githubGet(url: string): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + 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 = https.get( + url, + { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'claude-devtools', + }, + }, + (res) => { + const status = res.statusCode ?? 0; + const chunks: Buffer[] = []; + let totalSize = 0; + + res.on('data', (c: Buffer) => { + totalSize += c.length; + if (totalSize > MAX_BODY_SIZE) { + res.destroy(new Error('Response too large')); + 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: ${url}`))); + req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e)))); + }); +} diff --git a/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts b/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts index 2f162780..01ec4230 100644 --- a/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts +++ b/src/main/services/extensions/catalog/GlamaMcpEnrichmentService.ts @@ -13,7 +13,7 @@ import https from 'node:https'; import http from 'node:http'; import { createLogger } from '@shared/utils/logger'; -import type { McpCatalogItem, McpToolDef } from '@shared/types/extensions'; +import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions'; const logger = createLogger('Extensions:GlamaMcp'); @@ -103,6 +103,7 @@ interface GlamaServer { repository?: { url: string }; spdxLicense?: { name: string; url?: string } | null; tools?: { name?: string; description?: string }[]; + attributes?: string[]; } // ── Service ──────────────────────────────────────────────────────────────── @@ -171,6 +172,18 @@ export class GlamaMcpEnrichmentService { tools, glamaUrl: raw.url, requiresAuth: false, + author: raw.namespace, + hostingType: this.deriveHostingType(raw.attributes), }; } + + private deriveHostingType(attributes?: string[]): McpHostingType | undefined { + if (!attributes?.length) return undefined; + const hasLocal = attributes.includes('hosting:local-only'); + const hasRemote = attributes.includes('hosting:remote-capable'); + if (hasLocal && hasRemote) return 'both'; + if (hasLocal) return 'local'; + if (hasRemote) return 'remote'; + return undefined; + } } diff --git a/src/main/services/extensions/catalog/McpCatalogAggregator.ts b/src/main/services/extensions/catalog/McpCatalogAggregator.ts index 0f882a07..11de57e4 100644 --- a/src/main/services/extensions/catalog/McpCatalogAggregator.ts +++ b/src/main/services/extensions/catalog/McpCatalogAggregator.ts @@ -141,6 +141,8 @@ export class McpCatalogAggregator { license: item.license ?? glamaMatch.license, tools: item.tools.length > 0 ? item.tools : glamaMatch.tools, glamaUrl: glamaMatch.glamaUrl, + author: item.author ?? glamaMatch.author, + hostingType: item.hostingType ?? glamaMatch.hostingType, }; }); } diff --git a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts index 6fd4603b..aed9f7a7 100644 --- a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts +++ b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts @@ -109,6 +109,8 @@ interface RegistryServerEntry { 'io.modelcontextprotocol.registry/official'?: { status?: string; isLatest?: boolean; + publishedAt?: string; + updatedAt?: string; }; }; } @@ -238,10 +240,17 @@ export class OfficialMcpRegistryService { return meta?.isLatest !== false; // include if isLatest is true or undefined }); + // Filter to active only (include servers with no status or status "active") + const active = latest.filter((e) => { + const meta = e._meta?.['io.modelcontextprotocol.registry/official']; + const status = meta?.status; + return !status || status === 'active'; + }); + // Deduplicate by server name (take first = latest version) const seen = new Set(); const unique: RegistryServerEntry[] = []; - for (const entry of latest) { + for (const entry of active) { if (!seen.has(entry.server.name)) { seen.add(entry.server.name); unique.push(entry); @@ -253,6 +262,7 @@ export class OfficialMcpRegistryService { private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem { const { server } = entry; + const meta = entry._meta?.['io.modelcontextprotocol.registry/official']; const installSpec = this.deriveInstallSpec(server); const envVars = this.collectEnvVars(server); const requiresAuth = this.detectAuthRequired(server); @@ -271,6 +281,10 @@ export class OfficialMcpRegistryService { glamaUrl: undefined, requiresAuth, iconUrl: this.pickIconUrl(server.icons), + websiteUrl: server.websiteUrl, + status: meta?.status, + publishedAt: meta?.publishedAt, + updatedAt: meta?.updatedAt, }; } diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 65f871a7..9e2517cb 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -11,3 +11,5 @@ export { McpInstallationStateService } from './state/McpInstallationStateService export { ExtensionFacadeService } from './ExtensionFacadeService'; export { PluginInstallService } from './install/PluginInstallService'; export { McpInstallService } from './install/McpInstallService'; +export { ApiKeyService } from './apikeys/ApiKeyService'; +export { GitHubStarsService } from './catalog/GitHubStarsService'; diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index f813d8e4..0cb8cf46 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -10,7 +10,11 @@ import { execCli } from '@main/utils/childProcess'; import { createLogger } from '@shared/utils/logger'; -import type { McpInstallRequest, OperationResult } from '@shared/types/extensions'; +import type { + McpCustomInstallRequest, + McpInstallRequest, + OperationResult, +} from '@shared/types/extensions'; import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator'; const logger = createLogger('Extensions:McpInstall'); @@ -180,6 +184,107 @@ export class McpInstallService { } } + /** + * Install a custom MCP server — user provides installSpec directly (bypasses registry). + */ + async installCustom(request: McpCustomInstallRequest): Promise { + const { serverName, scope, projectPath, installSpec, envValues, headers } = request; + + // Validate inputs (same rules as registry install) + if (!SERVER_NAME_RE.test(serverName)) { + return { + state: 'error', + error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`, + }; + } + + if (scope && !VALID_SCOPES.has(scope)) { + return { state: 'error', error: `Invalid scope: "${scope}".` }; + } + + for (const key of Object.keys(envValues)) { + if (!ENV_KEY_RE.test(key)) { + return { state: 'error', error: `Invalid env var name: "${key}".` }; + } + } + + for (const header of headers) { + if (header.key && !HEADER_KEY_RE.test(header.key)) { + return { state: 'error', error: `Invalid header name: "${header.key}".` }; + } + } + + if (projectPath && !projectPath.startsWith('/')) { + return { state: 'error', error: 'projectPath must be an absolute path' }; + } + + // Build CLI args from provided installSpec + const args: string[] = ['mcp', 'add']; + + if (scope && scope !== 'local') { + args.push('-s', scope); + } + + if (installSpec.type === 'stdio') { + 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 = installSpec.npmVersion + ? `${installSpec.npmPackage}@${installSpec.npmVersion}` + : installSpec.npmPackage; + args.push(pkg); + } else if (installSpec.type === 'http') { + const transport = installSpec.transportType === 'sse' ? 'sse' : 'http'; + args.push('-t', transport); + + for (const header of headers) { + if (header.key && header.value) { + args.push('-H', `${header.key}: ${header.value}`); + } + } + + for (const [key, value] of Object.entries(envValues)) { + if (key && value) args.push('-e', `${key}=${value}`); + } + + args.push(serverName); + args.push(installSpec.url); + } else { + return { state: 'error', error: 'Unsupported install spec type' }; + } + + logger.info( + `Installing custom MCP server: ${serverName} (type: ${installSpec.type}, scope: ${scope ?? 'local'})` + ); + + try { + const { stderr } = await execCli(this.claudeBinary, args, { + timeout: TIMEOUT_MS, + cwd: projectPath, + }); + + if (stderr) { + logger.warn(`Custom MCP install stderr: ${stderr.slice(0, 200)}`); + } + + return { state: 'success' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const safeMessage = maskSecrets( + message, + envValues, + headers.map((h) => h.value) + ); + logger.error(`Custom 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 { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 95a625ed..48e543ab 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -605,3 +605,28 @@ export const MCP_REGISTRY_INSTALL = 'mcpRegistry:install'; /** Uninstall an MCP server */ export const MCP_REGISTRY_UNINSTALL = 'mcpRegistry:uninstall'; + +/** Install a custom MCP server (bypasses registry) */ +export const MCP_REGISTRY_INSTALL_CUSTOM = 'mcpRegistry:installCustom'; + +/** Fetch GitHub stars for MCP server repositories */ +export const MCP_GITHUB_STARS = 'mcpRegistry:githubStars'; + +// ============================================================================= +// API Keys Management Channels +// ============================================================================= + +/** List all saved API keys (masked values) */ +export const API_KEYS_LIST = 'apiKeys:list'; + +/** Save (create or update) an API key */ +export const API_KEYS_SAVE = 'apiKeys:save'; + +/** Delete an API key by ID */ +export const API_KEYS_DELETE = 'apiKeys:delete'; + +/** Lookup decrypted values by env var names (for auto-fill) */ +export const API_KEYS_LOOKUP = 'apiKeys:lookup'; + +/** Get storage encryption status (for UI display) */ +export const API_KEYS_STORAGE_STATUS = 'apiKeys:storageStatus'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 65f08dfa..6acf7f22 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -154,7 +154,14 @@ import { MCP_REGISTRY_GET_BY_ID, MCP_REGISTRY_GET_INSTALLED, MCP_REGISTRY_INSTALL, + MCP_REGISTRY_INSTALL_CUSTOM, MCP_REGISTRY_UNINSTALL, + MCP_GITHUB_STARS, + API_KEYS_LIST, + API_KEYS_SAVE, + API_KEYS_DELETE, + API_KEYS_LOOKUP, + API_KEYS_STORAGE_STATUS, } from './constants/ipcChannels'; import { CONFIG_ADD_CUSTOM_PROJECT_PATH, @@ -259,9 +266,14 @@ import type { WslClaudeRootCandidate, } from '@shared/types'; import type { + ApiKeyEntry, + ApiKeyLookupResult, + ApiKeySaveRequest, + ApiKeyStorageStatus, EnrichedPlugin, InstalledMcpEntry, McpCatalogItem, + McpCustomInstallRequest, McpInstallRequest, McpSearchResult, OperationResult, @@ -1384,8 +1396,22 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(MCP_REGISTRY_GET_INSTALLED, projectPath), install: (request: McpInstallRequest) => invokeIpcWithResult(MCP_REGISTRY_INSTALL, request), + installCustom: (request: McpCustomInstallRequest) => + invokeIpcWithResult(MCP_REGISTRY_INSTALL_CUSTOM, request), uninstall: (name: string, scope?: string, projectPath?: string) => invokeIpcWithResult(MCP_REGISTRY_UNINSTALL, name, scope, projectPath), + githubStars: (repositoryUrls: string[]) => + invokeIpcWithResult>(MCP_GITHUB_STARS, repositoryUrls), + }, + + // ===== API Keys API (Electron-only) ===== + apiKeys: { + list: () => invokeIpcWithResult(API_KEYS_LIST), + save: (request: ApiKeySaveRequest) => invokeIpcWithResult(API_KEYS_SAVE, request), + delete: (id: string) => invokeIpcWithResult(API_KEYS_DELETE, id), + lookup: (envVarNames: string[]) => + invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames), + getStorageStatus: () => invokeIpcWithResult(API_KEYS_STORAGE_STATUS), }, }; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 79f1ea0e..aec3ac8e 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -28,7 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; -import { FileText } from 'lucide-react'; +import { FileText, UsersRound } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -67,11 +67,12 @@ interface MarkdownViewerProps { // ============================================================================= /** - * Custom URL transform that preserves task:// and mention:// protocols. + * Custom URL transform that preserves task://, mention://, and team:// protocols. * react-markdown v10 strips non-standard protocols by default. */ function allowCustomProtocols(url: string): string { - if (url.startsWith('task://') || url.startsWith('mention://')) return url; + if (url.startsWith('task://') || url.startsWith('mention://') || url.startsWith('team://')) + return url; return defaultUrlTransform(url); } @@ -245,6 +246,31 @@ function createViewerMarkdownComponents( } return badge; } + if (href?.startsWith('team://')) { + let teamName = ''; + try { + teamName = decodeURIComponent(href.slice('team://'.length)); + } catch { + // malformed percent-encoding + } + return ( + + + {children} + + ); + } if (href?.startsWith('task://')) { const taskId = href.slice('task://'.length); return ( diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 5b890cf2..36060669 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -4,7 +4,7 @@ * Global catalog data comes from Zustand store. */ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; @@ -17,13 +17,16 @@ import { TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; -import { AlertTriangle, Info, Puzzle, RefreshCw, Server } from 'lucide-react'; +import { AlertTriangle, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; +import { ApiKeysPanel } from './apikeys/ApiKeysPanel'; +import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog'; import { McpServersPanel } from './mcp/McpServersPanel'; import { PluginsPanel } from './plugins/PluginsPanel'; export const ExtensionStoreView = (): React.JSX.Element => { const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog); + const fetchApiKeys = useStore((s) => s.fetchApiKeys); const mcpBrowse = useStore((s) => s.mcpBrowse); const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled); const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading); @@ -33,6 +36,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing)); const tabState = useExtensionsTabState(); + const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false); // Fetch plugin catalog on mount useEffect(() => { @@ -44,6 +48,11 @@ export const ExtensionStoreView = (): React.JSX.Element => { void mcpFetchInstalled(); }, [mcpFetchInstalled]); + // Fetch API keys on mount + useEffect(() => { + void fetchApiKeys(); + }, [fetchApiKeys]); + // Refresh all data (plugins + MCP browse + installed) const handleRefresh = useCallback(() => { void fetchPluginCatalog(undefined, true); @@ -104,18 +113,37 @@ export const ExtensionStoreView = (): React.JSX.Element => { )} tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')} + onValueChange={(v) => + tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'api-keys') + } > - - - - Plugins - - - - MCP Servers - - +
+ + + + Plugins + + + + MCP Servers + + + + API Keys + + + {tabState.activeSubTab === 'mcp-servers' && ( + + )} +
{ setSelectedMcpServerId={tabState.setSelectedMcpServerId} /> + + + +
+ + {/* Custom MCP server dialog (lifted to store view level) */} + setCustomMcpDialogOpen(false)} + /> ); diff --git a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx new file mode 100644 index 00000000..30db3d68 --- /dev/null +++ b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx @@ -0,0 +1,137 @@ +/** + * ApiKeyCard — displays a single API key entry with edit/delete controls. + */ + +import { useState } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { Copy, Check, Pencil, Trash2 } from 'lucide-react'; + +import type { ApiKeyEntry } from '@shared/types/extensions'; + +interface ApiKeyCardProps { + apiKey: ApiKeyEntry; + onEdit: (key: ApiKeyEntry) => void; +} + +export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Element => { + const deleteApiKey = useStore((s) => s.deleteApiKey); + const [copied, setCopied] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + const handleCopyEnvVar = async () => { + await navigator.clipboard.writeText(apiKey.envVarName); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const handleDelete = () => { + if (!confirmDelete) { + setConfirmDelete(true); + setTimeout(() => setConfirmDelete(false), 3000); + return; + } + void deleteApiKey(apiKey.id); + setConfirmDelete(false); + }; + + const createdDate = new Date(apiKey.createdAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + return ( +
+ {/* Name + scope badge */} +
+

{apiKey.name}

+ + {apiKey.scope} + +
+ + {/* Env var name */} +
+ + {apiKey.envVarName} + + + + + + + {copied ? 'Copied!' : 'Copy env var name'} + + +
+ + {/* Masked value */} +

{apiKey.maskedValue}

+ + {/* Footer: date + actions */} +
+ {createdDate} +
+ + + + + + Edit + + + + + + + + + {confirmDelete ? 'Click again to confirm' : 'Delete'} + + +
+
+
+ ); +}; diff --git a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx new file mode 100644 index 00000000..72e07225 --- /dev/null +++ b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx @@ -0,0 +1,243 @@ +/** + * ApiKeyFormDialog — create or edit an API key entry. + * Edit mode pre-fills all fields except the value (which must be re-entered). + */ + +import { useEffect, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +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 { AlertTriangle, Key } from 'lucide-react'; + +import type { ApiKeyEntry } from '@shared/types/extensions'; + +const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; + +interface ApiKeyFormDialogProps { + open: boolean; + editingKey: ApiKeyEntry | null; + onClose: () => void; +} + +type Scope = 'user' | 'project'; + +const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ + { value: 'user', label: 'User (global)' }, + { value: 'project', label: 'Project' }, +]; + +export const ApiKeyFormDialog = ({ + open, + editingKey, + onClose, +}: ApiKeyFormDialogProps): React.JSX.Element => { + const saveApiKey = useStore((s) => s.saveApiKey); + const apiKeySaving = useStore((s) => s.apiKeySaving); + const storageStatus = useStore((s) => s.apiKeyStorageStatus); + + const [name, setName] = useState(''); + const [envVarName, setEnvVarName] = useState(''); + const [value, setValue] = useState(''); + const [scope, setScope] = useState('user'); + const [error, setError] = useState(null); + const [envVarError, setEnvVarError] = useState(null); + + // Reset form when dialog opens/closes or editing key changes + useEffect(() => { + if (open) { + if (editingKey) { + setName(editingKey.name); + setEnvVarName(editingKey.envVarName); + setScope(editingKey.scope); + setValue(''); + } else { + setName(''); + setEnvVarName(''); + setValue(''); + setScope('user'); + } + setError(null); + setEnvVarError(null); + } + }, [open, editingKey]); + + const validateEnvVar = (v: string) => { + if (!v.trim()) { + setEnvVarError(null); + return; + } + if (!ENV_KEY_RE.test(v)) { + setEnvVarError('Use letters, digits, underscores. Must start with a letter or underscore.'); + } else { + setEnvVarError(null); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!name.trim()) { + setError('Name is required'); + return; + } + if (!envVarName.trim()) { + setError('Environment variable name is required'); + return; + } + if (!ENV_KEY_RE.test(envVarName)) { + setError('Invalid environment variable name'); + return; + } + if (!value) { + setError('Key value is required'); + return; + } + + try { + await saveApiKey({ + id: editingKey?.id, + name: name.trim(), + envVarName: envVarName.trim(), + value, + scope, + }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save'); + } + }; + + const isEdit = editingKey !== null; + const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving; + + return ( + !o && onClose()}> + + +
+
+ +
+
+ {isEdit ? 'Edit API Key' : 'Add API Key'} + + {isEdit + ? 'Update the key details. You must re-enter the value.' + : 'Store an API key for auto-filling in MCP server installations.'} + +
+
+
+ + {storageStatus && storageStatus.encryptionMethod !== 'os-keychain' && ( +
+ + OS keychain unavailable — keys encrypted with AES-256 locally. Install gnome-keyring for + OS-level protection. +
+ )} + +
void handleSubmit(e)} className="space-y-4"> + {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g. OpenAI Production" + className="h-8 text-sm" + autoFocus + /> +
+ + {/* Env var name */} +
+ + { + setEnvVarName(e.target.value); + validateEnvVar(e.target.value); + }} + placeholder="e.g. OPENAI_API_KEY" + className={`h-8 font-mono text-sm ${envVarError ? 'border-red-500/50' : ''}`} + /> + {envVarError &&

{envVarError}

} +
+ + {/* Value */} +
+ + setValue(e.target.value)} + placeholder={isEdit ? 'Re-enter key value' : 'sk-...'} + className="h-8 text-sm" + /> +
+ + {/* Scope */} +
+ + +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +}; diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx new file mode 100644 index 00000000..4863ce24 --- /dev/null +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -0,0 +1,139 @@ +/** + * ApiKeysPanel — grid of saved API keys with add button and empty state. + */ + +import { useEffect, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; + +import { ApiKeyCard } from './ApiKeyCard'; +import { ApiKeyFormDialog } from './ApiKeyFormDialog'; + +import type { ApiKeyEntry } from '@shared/types/extensions'; + +export const ApiKeysPanel = (): React.JSX.Element => { + const apiKeys = useStore((s) => s.apiKeys); + const apiKeysLoading = useStore((s) => s.apiKeysLoading); + const apiKeysError = useStore((s) => s.apiKeysError); + const storageStatus = useStore((s) => s.apiKeyStorageStatus); + const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingKey, setEditingKey] = useState(null); + + useEffect(() => { + void fetchStorageStatus(); + }, [fetchStorageStatus]); + + const handleAdd = () => { + setEditingKey(null); + setDialogOpen(true); + }; + + const handleEdit = (key: ApiKeyEntry) => { + setEditingKey(key); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setEditingKey(null); + }; + + const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain'; + + return ( +
+ {/* Header row */} +
+

+ Securely store API keys for auto-filling when installing MCP servers. + {storageStatus && ( + + + {isOsKeychain ? ( + + ) : ( + + )} + + + {isOsKeychain ? ( +

+ Keys are encrypted via {storageStatus.backend} and stored with restricted file + permissions (owner-only). +

+ ) : ( +

+ OS keychain unavailable — keys are encrypted locally with AES-256. For stronger + protection, install a keyring service (gnome-keyring, kwallet). +

+ )} + + + )} +

+ +
+ + {/* Error */} + {apiKeysError && ( +
+ {apiKeysError} +
+ )} + + {/* Skeleton loading */} + {apiKeysLoading && apiKeys.length === 0 && ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+
+
+
+
+ ))} +
+ )} + + {/* Empty state */} + {!apiKeysLoading && apiKeys.length === 0 && ( +
+
+ +
+

No API keys saved

+

+ Add keys to auto-fill environment variables when installing MCP servers. +

+ +
+ )} + + {/* Key cards grid */} + {apiKeys.length > 0 && ( +
+ {apiKeys.map((key) => ( + + ))} +
+ )} + + {/* Form dialog */} + +
+ ); +}; diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx new file mode 100644 index 00000000..d1586b21 --- /dev/null +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -0,0 +1,444 @@ +/** + * CustomMcpServerDialog — add a custom MCP server by providing install spec directly. + * Supports stdio (npm package) and HTTP/SSE transports. + */ + +import { useEffect, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +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 { Plus, Server, Trash2 } from 'lucide-react'; + +import type { + McpCustomInstallRequest, + McpHeaderDef, + McpInstallSpec, +} from '@shared/types/extensions'; + +const SERVER_NAME_RE = /^[\w.-]{1,100}$/; + +interface CustomMcpServerDialogProps { + open: boolean; + onClose: () => void; +} + +type TransportMode = 'stdio' | 'http'; +type HttpTransport = 'streamable-http' | 'sse' | 'http'; +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' }, +]; + +const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [ + { value: 'streamable-http', label: 'Streamable HTTP' }, + { value: 'sse', label: 'SSE' }, + { value: 'http', label: 'HTTP' }, +]; + +interface EnvEntry { + key: string; + value: string; +} + +export const CustomMcpServerDialog = ({ + open, + onClose, +}: CustomMcpServerDialogProps): React.JSX.Element => { + const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); + + // Form state + const [serverName, setServerName] = useState(''); + const [transportMode, setTransportMode] = useState('stdio'); + const [scope, setScope] = useState('user'); + + // Stdio fields + const [npmPackage, setNpmPackage] = useState(''); + const [npmVersion, setNpmVersion] = useState(''); + + // HTTP fields + const [httpUrl, setHttpUrl] = useState(''); + const [httpTransport, setHttpTransport] = useState('streamable-http'); + const [headers, setHeaders] = useState([]); + + // Shared + const [envVars, setEnvVars] = useState([]); + const [error, setError] = useState(null); + const [installing, setInstalling] = useState(false); + + // Reset on open + useEffect(() => { + if (open) { + setServerName(''); + setTransportMode('stdio'); + setScope('user'); + setNpmPackage(''); + setNpmVersion(''); + setHttpUrl(''); + setHttpTransport('streamable-http'); + setHeaders([]); + setEnvVars([]); + setError(null); + setInstalling(false); + } + }, [open]); + + // Auto-fill env vars from saved API keys + useEffect(() => { + if (!open || envVars.length === 0 || !api.apiKeys) return; + + const envVarNames = envVars.map((e) => e.key).filter(Boolean); + if (envVarNames.length === 0) return; + + void api.apiKeys.lookup(envVarNames).then( + (results) => { + if (results.length === 0) return; + const lookup = new Map(results.map((r) => [r.envVarName, r.value])); + setEnvVars((prev) => + prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e)) + ); + }, + () => { + // Silently fail + } + ); + }, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleInstall = async () => { + setError(null); + + if (!serverName.trim()) { + setError('Server name is required'); + return; + } + if (!SERVER_NAME_RE.test(serverName)) { + setError('Invalid server name. Use alphanumeric characters, dashes, underscores, dots.'); + return; + } + + let installSpec: McpInstallSpec; + + if (transportMode === 'stdio') { + if (!npmPackage.trim()) { + setError('npm package name is required'); + return; + } + installSpec = { + type: 'stdio', + npmPackage: npmPackage.trim(), + npmVersion: npmVersion.trim() || undefined, + }; + } else { + if (!httpUrl.trim()) { + setError('Server URL is required'); + return; + } + installSpec = { + type: 'http', + url: httpUrl.trim(), + transportType: httpTransport, + }; + } + + const envValues: Record = {}; + for (const entry of envVars) { + if (entry.key.trim() && entry.value) { + envValues[entry.key.trim()] = entry.value; + } + } + + const request: McpCustomInstallRequest = { + serverName, + scope, + installSpec, + envValues, + headers: headers.filter((h) => h.key.trim() && h.value.trim()), + }; + + setInstalling(true); + try { + await installCustomMcpServer(request); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Install failed'); + } finally { + setInstalling(false); + } + }; + + const addEnvVar = () => setEnvVars((prev) => [...prev, { key: '', value: '' }]); + const removeEnvVar = (i: number) => setEnvVars((prev) => prev.filter((_, idx) => idx !== i)); + const updateEnvVar = (i: number, field: 'key' | 'value', val: string) => + setEnvVars((prev) => prev.map((e, idx) => (idx === i ? { ...e, [field]: val } : e))); + + const addHeader = () => setHeaders((prev) => [...prev, { key: '', value: '' }]); + const removeHeader = (i: number) => setHeaders((prev) => prev.filter((_, idx) => idx !== i)); + const updateHeader = (i: number, field: 'key' | 'value', val: string) => + setHeaders((prev) => prev.map((h, idx) => (idx === i ? { ...h, [field]: val } : h))); + + const canSubmit = + serverName.trim() && + (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && + !installing; + + return ( + !o && onClose()}> + + +
+
+ +
+
+ Add Custom MCP Server + Add a server manually without the catalog. +
+
+
+ +
+ {/* Server name */} +
+ + setServerName(e.target.value)} + placeholder="my-server" + className="h-8 text-sm" + autoFocus + /> +
+ + {/* Transport toggle */} +
+ +
+ + +
+
+ + {/* Stdio fields */} + {transportMode === 'stdio' && ( +
+
+ + setNpmPackage(e.target.value)} + placeholder="@example/mcp-server" + className="h-8 font-mono text-sm" + /> +
+
+ + setNpmVersion(e.target.value)} + placeholder="latest" + className="h-8 text-sm" + /> +
+
+ )} + + {/* HTTP fields */} + {transportMode === 'http' && ( +
+
+ + setHttpUrl(e.target.value)} + placeholder="https://api.example.com/mcp" + className="h-8 text-sm" + /> +
+
+ + +
+ + {/* Headers */} +
+
+ + +
+ {headers.length > 0 && ( +
+ {headers.map((header, i) => ( +
+ updateHeader(i, 'key', e.target.value)} + className="h-7 w-32 text-xs" + placeholder="Header-Name" + /> + updateHeader(i, 'value', e.target.value)} + className="h-7 flex-1 text-xs" + placeholder="value" + /> + +
+ ))} +
+ )} +
+
+ )} + + {/* Scope */} +
+ + +
+ + {/* Environment variables */} +
+
+ + +
+ {envVars.length > 0 && ( +
+ {envVars.map((entry, i) => ( +
+ updateEnvVar(i, 'key', e.target.value)} + className="h-7 w-40 font-mono text-xs" + placeholder="ENV_VAR_NAME" + /> + updateEnvVar(i, 'value', e.target.value)} + className="h-7 flex-1 text-xs" + placeholder="value" + /> + +
+ ))} +
+ )} +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +}; diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index c41aeb81..dcab2697 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -6,15 +6,26 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { Lock, Server, Wrench } from 'lucide-react'; +import { api } from '@renderer/api'; +import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters'; +import { Cloud, Clock, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; + +// eslint-disable-next-line @typescript-eslint/no-deprecated -- lucide naming migration, alias is stable +import { Github as GithubIcon } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; -import { SourceBadge } from '../common/SourceBadge'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; import type { McpCatalogItem } from '@shared/types/extensions'; +/** Ribbon colors by source */ +const RIBBON_STYLES: Record = { + official: 'bg-blue-500/90 text-white', + glama: 'bg-zinc-600/90 text-zinc-200', +}; + interface McpServerCardProps { server: McpCatalogItem; isInstalled: boolean; @@ -30,31 +41,50 @@ export const McpServerCard = ({ const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); const installError = useStore((s) => s.installErrors[server.id]); + const stars = useStore((s) => + server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined + ); const canAutoInstall = !!server.installSpec; const [imgError, setImgError] = useState(false); + const hasIcon = !!server.iconUrl && !imgError; return ( - + + Repository + + )} + {server.websiteUrl && ( + + + + + Website + + )}
{canAutoInstall && (
@@ -114,6 +218,6 @@ export const McpServerCard = ({
)}
- +
); }; diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index ce2473ec..deef5be5 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -3,7 +3,7 @@ * Uses Radix UI Kit for all form elements. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Dialog, @@ -25,7 +25,7 @@ import { } 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 { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { SourceBadge } from '../common/SourceBadge'; @@ -60,12 +60,16 @@ export const McpServerDetailDialog = ({ const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined)); + const stars = useStore((s) => + server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined + ); const [scope, setScope] = useState('user'); const [serverName, setServerName] = useState(''); const [envValues, setEnvValues] = useState>({}); const [headers, setHeaders] = useState([]); const [imgError, setImgError] = useState(false); + const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); // Initialize form when server changes const [lastServerId, setLastServerId] = useState(null); @@ -75,12 +79,69 @@ export const McpServerDetailDialog = ({ setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, '']))); setHeaders([]); setImgError(false); + setAutoFilledFields(new Set()); } + // Auto-fill env values from saved API keys + useEffect(() => { + if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; + + const envVarNames = server.envVars.map((e) => e.name); + void api.apiKeys.lookup(envVarNames).then( + (results) => { + if (results.length === 0) return; + const filled = new Set(); + const values: Record = {}; + for (const r of results) { + values[r.envVarName] = r.value; + filled.add(r.envVarName); + } + setEnvValues((prev) => ({ ...prev, ...values })); + setAutoFilledFields(filled); + }, + () => { + // Silently fail — auto-fill is supplementary + } + ); + }, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-fill env vars from saved API keys + useEffect(() => { + if (!server || server.envVars.length === 0 || !api.apiKeys) return; + + const envVarNames = server.envVars.map((env) => env.name); + void api.apiKeys.lookup(envVarNames).then( + (results) => { + if (results.length === 0) return; + const filled = new Set(); + const updates: Record = {}; + for (const r of results) { + updates[r.envVarName] = r.value; + filled.add(r.envVarName); + } + setEnvValues((prev) => { + const next = { ...prev }; + for (const [k, v] of Object.entries(updates)) { + // Only auto-fill if the field is empty + if (!next[k]) { + next[k] = v; + } + } + return next; + }); + setAutoFilledFields(filled); + }, + () => { + // Silently ignore lookup failures + } + ); + }, [server?.id]); // eslint-disable-line react-hooks/exhaustive-deps + if (!server) return <>; const canAutoInstall = !!server.installSpec; const isHttp = server.installSpec?.type === 'http'; + const hasIcon = !!server.iconUrl && !imgError; const handleInstall = () => { installMcpServer({ @@ -113,19 +174,17 @@ export const McpServerDetailDialog = ({
- {/* Server icon */} -
- {server.iconUrl && !imgError ? ( + {/* Server icon (only when available) */} + {hasIcon && ( +
setImgError(true)} /> - ) : ( - - )} -
+
+ )}
@@ -154,6 +213,15 @@ export const McpServerDetailDialog = ({ Source

{server.source}

+ {stars != null && ( +
+ GitHub Stars +

+ + {stars.toLocaleString()} +

+
+ )} {server.version && (
Version @@ -176,6 +244,30 @@ export const McpServerDetailDialog = ({ : 'Manual setup required'}

+ {server.author && ( +
+ Author +

{server.author}

+
+ )} + {server.hostingType && ( +
+ Hosting +

{server.hostingType}

+
+ )} + {server.publishedAt && ( +
+ Published +

{new Date(server.publishedAt).toLocaleDateString()}

+
+ )} + {server.updatedAt && ( +
+ Updated +

{new Date(server.updatedAt).toLocaleDateString()}

+
+ )}
{/* Auth indicator */} @@ -243,6 +335,9 @@ export const McpServerDetailDialog = ({ className="h-7 flex-1 text-xs" placeholder={env.description ?? env.name} /> + {autoFilledFields.has(env.name) && envValues[env.name] && ( + Auto-filled + )}
))}
@@ -357,6 +452,16 @@ export const McpServerDetailDialog = ({ Glama )} + {server.websiteUrl && ( + + )}
diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 17083121..e9e4ffb5 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -73,6 +73,7 @@ export const McpServersPanel = ({ const browseError = useStore((s) => s.mcpBrowseError); const mcpBrowse = useStore((s) => s.mcpBrowse); const installedServers = useStore((s) => s.mcpInstalledServers); + const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars); const [mcpSort, setMcpSort] = useState('name-asc'); const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false); @@ -84,6 +85,14 @@ export const McpServersPanel = ({ } }, [browseCatalog.length, browseLoading, mcpBrowse]); + // Fetch GitHub stars after catalog loads (fire-and-forget) + useEffect(() => { + const urls = browseCatalog.map((s) => s.repositoryUrl).filter((u): u is string => !!u); + if (urls.length > 0) { + fetchMcpGitHubStars(urls); + } + }, [browseCatalog, fetchMcpGitHubStars]); + // Decide which list to show: search results or browse const isSearching = mcpSearchQuery.trim().length > 0; const rawServers = isSearching ? mcpSearchResults : browseCatalog; diff --git a/src/renderer/components/extensions/plugins/CapabilityChips.tsx b/src/renderer/components/extensions/plugins/CapabilityChips.tsx index 7101cd89..a91dc604 100644 --- a/src/renderer/components/extensions/plugins/CapabilityChips.tsx +++ b/src/renderer/components/extensions/plugins/CapabilityChips.tsx @@ -34,7 +34,7 @@ export const CapabilityChips = ({ }, [plugins]); return ( -
+
{ALL_CAPABILITIES.map((cap) => { const count = capabilityCounts.get(cap) ?? 0; if (count === 0) return null; @@ -45,14 +45,23 @@ export const CapabilityChips = ({ variant="ghost" size="sm" onClick={() => onToggle(cap)} - className={`h-7 rounded-full px-2.5 text-xs font-medium ${ + aria-pressed={isActive} + className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${ isActive - ? 'bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/40 hover:bg-purple-500/30' - : 'text-text-muted hover:text-text-secondary' + ? 'border-purple-500/40 bg-purple-500/15 text-purple-300 shadow-sm' + : 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text' }`} > - {getCapabilityLabel(cap)} - ({count}) + {getCapabilityLabel(cap)} + + {count} + ); })} diff --git a/src/renderer/components/extensions/plugins/CategoryChips.tsx b/src/renderer/components/extensions/plugins/CategoryChips.tsx index 4d8d7008..c837fd2b 100644 --- a/src/renderer/components/extensions/plugins/CategoryChips.tsx +++ b/src/renderer/components/extensions/plugins/CategoryChips.tsx @@ -33,7 +33,7 @@ export const CategoryChips = ({ if (categoryCounts.length === 0) return <>; return ( -
+
{categoryCounts.map(([category, count]) => { const isActive = selected.includes(category); return ( @@ -42,14 +42,23 @@ export const CategoryChips = ({ variant="ghost" size="sm" onClick={() => onToggle(category)} - className={`h-7 rounded-full px-2.5 text-xs font-medium ${ + aria-pressed={isActive} + className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${ isActive - ? 'bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/40 hover:bg-blue-500/30' - : 'text-text-muted hover:text-text-secondary' + ? 'border-blue-500/40 bg-blue-500/15 text-blue-300 shadow-sm' + : 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text' }`} > - {category} - ({count}) + {category} + + {count} + ); })} diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index c3e4e550..43f44e02 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -9,6 +9,7 @@ import { inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; +import { Tag } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { InstallCountBadge } from '../common/InstallCountBadge'; @@ -39,49 +40,57 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem onClick(plugin.pluginId); } }} - className={`flex w-full cursor-pointer flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${ - plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border' + className={`hover:bg-surface-raised/45 flex w-full cursor-pointer flex-col gap-3 rounded-xl border bg-transparent p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${ + plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/35' : 'border-border' }`} > - {/* Header: name + installed badge */} + {/* Header: name + status/meta */}
-

{plugin.name}

- {plugin.isInstalled && ( - - Installed - - )} +
+

{plugin.name}

+
+ + {category} + + {capabilities.map((cap) => ( + + {getCapabilityLabel(cap)} + + ))} +
+
+
+ + {plugin.isInstalled && ( + + Installed + + )} +
{/* Description */} -

{plugin.description}

+

+ {plugin.description} +

- {/* Category + Capabilities */} -
- - {category} - - {capabilities.map((cap) => ( - - {getCapabilityLabel(cap)} - - ))} -
- - {/* Footer: author, install count, install button */} + {/* Footer: author + version + install button */}
-
- - {plugin.author?.name ?? 'Unknown author'} - - +
+ {plugin.author?.name ?? 'Unknown author'} + {plugin.version && ( + + + {plugin.version} + + )}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
e.stopPropagation()}> diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 957cd85f..45d731b6 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -28,7 +28,7 @@ import { inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; -import { ExternalLink, Loader2 } from 'lucide-react'; +import { ExternalLink, Loader2, Mail } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { InstallCountBadge } from '../common/InstallCountBadge'; @@ -108,6 +108,12 @@ export const PluginDetailDialog = ({ Category

{category}

+ {plugin.version && ( +
+ Version +

{plugin.version}

+
+ )}
Capabilities
@@ -157,17 +163,29 @@ export const PluginDetailDialog = ({ />
- {/* Homepage link */} - {plugin.homepage && ( - - )} + {/* Links */} +
+ {plugin.homepage && ( + + )} + {plugin.author?.email && ( + + )} +
{/* README */}
diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 3d235daa..1ad168d8 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react'; +import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Label } from '@renderer/components/ui/label'; @@ -16,7 +17,7 @@ import { } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; -import { Puzzle, Search } from 'lucide-react'; +import { Filter, Puzzle, Search } from 'lucide-react'; import { SearchInput } from '../common/SearchInput'; @@ -95,7 +96,7 @@ function selectFilteredPlugins( result = [...result].sort((a, b) => { switch (sort.field) { case 'popularity': - return (b.installCount - a.installCount) * direction; + return (a.installCount - b.installCount) * direction; case 'name': return a.name.localeCompare(b.name) * direction; case 'category': @@ -137,11 +138,29 @@ export const PluginsPanel = ({ ); const sortValue = `${pluginSort.field}:${pluginSort.order}`; + const activeFilterCount = + pluginFilters.categories.length + + pluginFilters.capabilities.length + + (pluginFilters.installedOnly ? 1 : 0) + + (pluginFilters.search ? 1 : 0); + const totalCategoryCount = useMemo( + () => new Set(catalog.map((plugin) => normalizeCategory(plugin.category))).size, + [catalog] + ); + const totalCapabilityCount = useMemo(() => { + const counts = new Set(); + for (const plugin of catalog) { + for (const capability of inferCapabilities(plugin)) { + counts.add(capability); + } + } + return counts.size; + }, [catalog]); return (
{/* Search + Sort + Installed only row */} -
+
- -
- -
{/* Filters */} -
-
-
- - Categories - +
+
+
+
+
+
+ +
+
+
+

Browse by fit

+ + {activeFilterCount} active + +
+

+ Narrow the catalog by category, capability, or installed state. +

+
+
+
+ + {catalog.length} plugins + + + {totalCategoryCount} categories + + + {totalCapabilityCount} capabilities + +
+
{hasActiveFilters && ( )}
- -
- - Capabilities - - + +
+
+
+ + Categories + + + {pluginFilters.categories.length} selected + +
+ +
+ +
+
+ + Capabilities + + + {pluginFilters.capabilities.length} selected + +
+ +
+
{/* Result count */} {!loading && !error && filtered.length > 0 && ( -

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

+
+

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

+ {hasActiveFilters && ( +

+ Results update instantly as you refine filters. +

+ )} +
)} {/* Content */} diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 70703c9b..58d8ebe5 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -183,10 +183,16 @@ export const CommandPalette = (): React.JSX.Element | null => { const [totalMatches, setTotalMatches] = useState(0); const [searchIsPartial, setSearchIsPartial] = useState(false); const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false); + const [browsingProjects, setBrowsingProjects] = useState(false); const latestSearchRequestRef = useRef(0); // Determine search mode based on whether a project is selected OR global search is enabled - const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects'; + // browsingProjects overrides back to project selection + const searchMode: SearchMode = browsingProjects + ? 'projects' + : selectedProjectId || globalSearchEnabled + ? 'sessions' + : 'projects'; // Filter projects for project search mode const filteredProjects = useMemo(() => { @@ -235,6 +241,7 @@ export const CommandPalette = (): React.JSX.Element | null => { setTotalMatches(0); setSearchIsPartial(false); setGlobalSearchEnabled(false); + setBrowsingProjects(false); } }, [commandPaletteOpen]); @@ -291,15 +298,32 @@ export const CommandPalette = (): React.JSX.Element | null => { setSelectedIndex(0); }, [filteredProjects, sessionResults]); - // Handle project click + // Handle project click — select project without closing palette const handleProjectClick = useCallback( (repo: RepositoryGroup) => { - closeCommandPalette(); selectRepository(repo.id); + setBrowsingProjects(false); + setQuery(''); + setSessionResults([]); + setSelectedIndex(0); + setTotalMatches(0); + setSearchIsPartial(false); + inputRef.current?.focus(); }, - [closeCommandPalette, selectRepository] + [selectRepository] ); + // Handle clearing project filter — go back to project browsing + const handleClearProject = useCallback(() => { + setBrowsingProjects(true); + setQuery(''); + setSessionResults([]); + setSelectedIndex(0); + setTotalMatches(0); + setSearchIsPartial(false); + inputRef.current?.focus(); + }, []); + // Handle session result click const handleSessionResultClick = useCallback( (result: SearchResult) => { @@ -438,14 +462,20 @@ export const CommandPalette = (): React.JSX.Element | null => { {globalSearchEnabled ? 'Search across all projects' : 'Search in project'} - {!globalSearchEnabled && ( + {!globalSearchEnabled && selectedProjectId && ( <> · - - {repositoryGroups.find((r) => - r.worktrees.some((w) => w.id === selectedProjectId) - )?.name ?? 'Current project'} - + )} diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 08eddf6b..afa747da 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -198,6 +198,41 @@ export const GlobalTaskList = ({ const readState = useReadStateSnapshot(); const taskLocalState = useTaskLocalState(); + // --- New-task animation tracking (same pattern as ChatHistory) --- + const knownTaskIdsRef = useRef>(new Set()); + const isInitialTaskLoadRef = useRef(true); + + const newTaskIds = useMemo(() => { + if (!globalTasksInitialized || globalTasks.length === 0) { + return new Set(); + } + + // First load: seed all known IDs, no animations + if (isInitialTaskLoadRef.current) { + isInitialTaskLoadRef.current = false; + for (const t of globalTasks) { + knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); + } + return new Set(); + } + + // Subsequent updates: detect truly new tasks + const newIds = new Set(); + for (const t of globalTasks) { + const key = `${t.teamName}:${t.id}`; + if (!knownTaskIdsRef.current.has(key)) { + newIds.add(key); + knownTaskIdsRef.current.add(key); + } + } + return newIds; + }, [globalTasks, globalTasksInitialized]); + + const isNewTask = useCallback( + (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), + [newTaskIds] + ); + // Local project filter (independent from sessions tab) const [localProjectFilter, setLocalProjectFilter] = useState(null); @@ -481,6 +516,7 @@ export const GlobalTaskList = ({ { if (!isRenaming) { diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 0a805f32..ca91255f 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; @@ -183,6 +184,8 @@ export const SendMessageDialog = ({ [members, colorMap] ); + const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const trimmedText = textDraft.value.trim(); @@ -437,6 +440,7 @@ export const SendMessageDialog = ({ value={textDraft.value} onValueChange={textDraft.setValue} suggestions={mentionSuggestions} + teamSuggestions={teamMentionSuggestions} chips={chipDraft.chips} onChipRemove={handleChipRemove} projectPath={projectPath} diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 7ac48d1e..93c02fea 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useComposerDraft } from '@renderer/hooks/useComposerDraft'; +import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; @@ -131,6 +132,8 @@ export const MessageComposer = ({ [members, colorMap] ); + const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const trimmed = draft.text.trim(); const selectedMember = members.find((m) => m.name === recipient); @@ -751,6 +754,7 @@ export const MessageComposer = ({ value={draft.text} onValueChange={draft.setText} suggestions={mentionSuggestions} + teamSuggestions={teamMentionSuggestions} chips={draft.chips} onChipRemove={draft.removeChip} projectPath={projectPath} diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index 0823ef33..bd673662 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef } from 'react'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -import { Loader2 } from 'lucide-react'; +import { nameColorSet } from '@renderer/utils/projectColor'; +import { Loader2, UsersRound } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -68,33 +69,53 @@ export const MentionSuggestionList = ({ if (suggestions.length === 0) { return (
- {hasFileSearch ? 'No matching members or files' : 'No matching members'} + {hasFileSearch ? 'No matching members, teams, or files' : 'No matching members'}
); } - // Determine if we need grouped sections - const hasMemberItems = suggestions.some((s) => s.type !== 'file'); - const hasFileItems = suggestions.some((s) => s.type === 'file'); - const showSections = hasMemberItems && hasFileItems; + // Categorize suggestions + type Section = 'member' | 'team' | 'file'; + const getSuggestionSection = (s: MentionSuggestion): Section => { + if (s.type === 'file') return 'file'; + if (s.type === 'team') return 'team'; + return 'member'; + }; + + const sectionLabel: Record = { + member: 'Members', + team: 'Teams', + file: 'Files', + }; + + // Determine which sections are present + const presentSections = new Set(suggestions.map(getSuggestionSection)); + const showSections = presentSections.size > 1; // Build items with section headers inserted const items: React.JSX.Element[] = []; - let currentSection: 'member' | 'file' | null = null; + let currentSection: Section | null = null; let optionIndex = 0; for (const s of suggestions) { - const isFile = s.type === 'file'; - const section = isFile ? 'file' : 'member'; + const section = getSuggestionSection(s); + const isFile = section === 'file'; + const isTeam = section === 'team'; // Insert section header on transition if (showSections && section !== currentSection) { - items.push(); + items.push(); currentSection = section; } const isSelected = optionIndex === selectedIndex; - const colorSet = !isFile && s.color ? getTeamColorSet(s.color) : null; + const colorSet = isFile + ? null + : s.color + ? getTeamColorSet(s.color) + : isTeam + ? nameColorSet(s.name) + : null; const idx = optionIndex; optionIndex++; @@ -116,6 +137,12 @@ export const MentionSuggestionList = ({ > {isFile ? ( + ) : isTeam ? ( + ) : ( + {isTeam && s.isOnline !== undefined ? ( + + ) : null} {s.subtitle ? ( {s.subtitle} ) : null} diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 8b46c6df..0474d281 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -6,6 +6,7 @@ import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { chipToken } from '@renderer/types/inlineChip'; +import { nameColorSet } from '@renderer/utils/projectColor'; import { createChipFromSelection, findChipBoundary, @@ -207,6 +208,8 @@ interface MentionableTextareaProps extends Omit< projectPath?: string | null; /** Called when a file chip is created via @ selection. Parent must add chip to state. */ onFileChipInsert?: (chip: InlineChip) => void; + /** Team suggestions for cross-team @mentions */ + teamSuggestions?: MentionSuggestion[]; /** Called when Enter (without Shift) is pressed. */ onModEnter?: () => void; } @@ -226,6 +229,7 @@ export const MentionableTextarea = React.forwardRef 0, }); // --- File suggestions --- @@ -281,12 +285,23 @@ export const MentionableTextarea = React.forwardRef { + if (teamSuggestions.length === 0 || !isOpen) return []; + if (!query) return teamSuggestions; + const lower = query.toLowerCase(); + return teamSuggestions.filter((t) => t.name.toLowerCase().includes(lower)); + }, [teamSuggestions, isOpen, query]); + + // Merged suggestion list: members → online teams → offline teams → files const allSuggestions = React.useMemo(() => { - if (!enableFiles) return memberSuggestions; - if (fileSuggestions.length === 0) return memberSuggestions; - return [...memberSuggestions, ...fileSuggestions]; - }, [enableFiles, memberSuggestions, fileSuggestions]); + const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline); + const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline); + const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams]; + if (!enableFiles) return merged; + if (fileSuggestions.length === 0) return merged; + return [...merged, ...fileSuggestions]; + }, [memberSuggestions, filteredTeamSuggestions, enableFiles, fileSuggestions]); // When files are enabled, manage our own selectedIndex for the merged list const [mergedIndex, setMergedIndex] = React.useState(0); @@ -296,9 +311,12 @@ export const MentionableTextarea = React.forwardRef 0; + + // Effective index: use merged when extra types present, hook's index otherwise + const effectiveIndex = hasMergedSuggestions ? mergedIndex : selectedIndex; + const effectiveSuggestions = hasMergedSuggestions ? allSuggestions : memberSuggestions; // --- File selection handler --- const handleFileSelect = React.useCallback( @@ -386,11 +404,17 @@ export const MentionableTextarea = React.forwardRef 0 || chips.length > 0; + const hasOverlay = suggestions.length > 0 || teamSuggestions.length > 0 || chips.length > 0; + + // Combine member + team suggestions for overlay parsing + const allOverlaySuggestions = React.useMemo( + () => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions), + [suggestions, teamSuggestions] + ); const segments = React.useMemo( - () => (hasOverlay ? parseSegments(value, suggestions, chips) : []), - [hasOverlay, value, suggestions, chips] + () => (hasOverlay ? parseSegments(value, allOverlaySuggestions, chips) : []), + [hasOverlay, value, allOverlaySuggestions, chips] ); // Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer @@ -512,7 +536,7 @@ export const MentionableTextarea = React.forwardRef) => { // When mention dropdown is open, let mention handler consume Enter/Arrow keys first if (isOpen && effectiveSuggestions.length > 0) { - if (enableFiles) { + if (hasMergedSuggestions) { fileMentionHandleKeyDown(e); } else { mentionHandleKeyDown(e); @@ -527,7 +551,7 @@ export const MentionableTextarea = React.forwardRef 0 || enableFiles); + const showHintRow = + showHint && (suggestions.length > 0 || enableFiles || teamSuggestions.length > 0); const showFooter = showHintRow || footerRight; return ( @@ -673,10 +698,13 @@ export const MentionableTextarea = React.forwardRef; } - // mention + // mention (member or team) + const isTeamMention = seg.suggestion.type === 'team'; const colorSet = seg.suggestion.color ? getTeamColorSet(seg.suggestion.color) - : null; + : isTeamMention + ? nameColorSet(seg.suggestion.name, isLight) + : null; const bg = colorSet ? getThemedBadge(colorSet, isLight) : DEFAULT_MENTION_BG; const fg = colorSet?.text ?? DEFAULT_MENTION_TEXT; return ( @@ -751,7 +779,7 @@ export const MentionableTextarea = React.forwardRef s.teams); + const [aliveTeams, setAliveTeams] = useState>(new Set()); + const [loading, setLoading] = useState(false); + + const fetchAlive = useCallback(async () => { + setLoading(true); + try { + const list = await api.teams.aliveList(); + setAliveTeams(new Set(list)); + } catch { + // best-effort — treat all as offline on error + } finally { + setLoading(false); + } + }, []); + + // Fetch on mount and when teams list changes + useEffect(() => { + void fetchAlive(); + }, [fetchAlive, teams]); + + // Build suggestion list sorted: online first, then offline + const suggestions = useMemo(() => { + const nonDeleted = teams.filter((t) => !t.deletedAt && t.teamName !== currentTeamName); + + const result: MentionSuggestion[] = nonDeleted.map((t) => { + const isOnline = aliveTeams.has(t.teamName); + return { + id: `team:${t.teamName}`, + name: t.displayName || t.teamName, + subtitle: isOnline ? 'online' : 'offline', + color: t.color, + type: 'team' as const, + isOnline, + }; + }); + + // Sort: online teams first + result.sort((a, b) => { + if (a.isOnline && !b.isOnline) return -1; + if (!a.isOnline && b.isOnline) return 1; + return 0; + }); + + return result; + }, [teams, currentTeamName, aliveTeams]); + + return { suggestions, loading }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 25d6ca79..49a6c68c 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -709,6 +709,24 @@ body { animation: chat-message-enter 350ms ease-out both; } +@keyframes task-item-enter { + from { + opacity: 0; + max-height: 0; + transform: translateY(-4px); + overflow: hidden; + } + to { + opacity: 1; + max-height: 80px; + transform: translateY(0); + } +} + +.task-item-enter-animate { + animation: task-item-enter 280ms ease-out both; +} + @keyframes thought-expand { from { max-height: 0; diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 8bdb2141..f4574549 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -7,11 +7,15 @@ import { api } from '@renderer/api'; import type { AppState } from '../types'; import type { + ApiKeyEntry, + ApiKeySaveRequest, + ApiKeyStorageStatus, EnrichedPlugin, ExtensionOperationState, InstallScope, InstalledMcpEntry, McpCatalogItem, + McpCustomInstallRequest, McpInstallRequest, PluginInstallRequest, } from '@shared/types/extensions'; @@ -43,6 +47,16 @@ export interface ExtensionsSlice { mcpInstallProgress: Record; installErrors: Record; // keyed by pluginId or registryId + // ── API Keys ── + apiKeys: ApiKeyEntry[]; + apiKeysLoading: boolean; + apiKeysError: string | null; + apiKeySaving: boolean; + apiKeyStorageStatus: ApiKeyStorageStatus | null; + + // ── GitHub Stars (supplementary) ── + mcpGitHubStars: Record; + // ── Read actions ── fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise; fetchPluginReadme: (pluginId: string) => void; @@ -53,6 +67,7 @@ export interface ExtensionsSlice { installPlugin: (request: PluginInstallRequest) => Promise; uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise; installMcpServer: (request: McpInstallRequest) => Promise; + installCustomMcpServer: (request: McpCustomInstallRequest) => Promise; uninstallMcpServer: ( registryId: string, name: string, @@ -60,8 +75,17 @@ export interface ExtensionsSlice { projectPath?: string ) => Promise; + // ── API Keys actions ── + fetchApiKeys: () => Promise; + fetchApiKeyStorageStatus: () => Promise; + saveApiKey: (request: ApiKeySaveRequest) => Promise; + deleteApiKey: (id: string) => Promise; + // ── Tab opener ── openExtensionsTab: () => void; + + // ── GitHub Stars ── + fetchMcpGitHubStars: (repositoryUrls: string[]) => void; } // ============================================================================= @@ -96,6 +120,14 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; @@ -163,11 +195,23 @@ export const createExtensionsSlice: StateCreator ({ - mcpBrowseCatalog: cursor ? [...prev.mcpBrowseCatalog, ...result.servers] : result.servers, - mcpBrowseNextCursor: result.nextCursor, - mcpBrowseLoading: false, - })); + set((prev) => { + if (!cursor) { + return { + mcpBrowseCatalog: result.servers, + mcpBrowseNextCursor: result.nextCursor, + mcpBrowseLoading: false, + }; + } + // Deduplicate: existing IDs take precedence + const existingIds = new Set(prev.mcpBrowseCatalog.map((s) => s.id)); + const newServers = result.servers.filter((s) => !existingIds.has(s.id)); + return { + mcpBrowseCatalog: [...prev.mcpBrowseCatalog, ...newServers], + mcpBrowseNextCursor: result.nextCursor, + mcpBrowseLoading: false, + }; + }); } catch (err) { set({ mcpBrowseLoading: false, @@ -324,6 +368,53 @@ export const createExtensionsSlice: StateCreator { + if (!api.mcpRegistry) { + const progressKey = `custom:${request.serverName}`; + set((prev) => ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, + installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, + })); + return; + } + + const progressKey = `custom:${request.serverName}`; + set((prev) => ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, + })); + + try { + const result = await api.mcpRegistry.installCustom(request); + if (result.state === 'error') { + set((prev) => ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, + installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' }, + })); + return; + } + + set((prev) => ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' }, + })); + + // Refresh installed list + void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined); + + setTimeout(() => { + set((prev) => ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' }, + })); + }, SUCCESS_DISPLAY_MS); + } catch (err) { + const message = err instanceof Error ? err.message : 'Install failed'; + set((prev) => ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, + installErrors: { ...prev.installErrors, [progressKey]: message }, + })); + } + }, + // ── MCP uninstall ── uninstallMcpServer: async ( registryId: string, @@ -376,6 +467,67 @@ export const createExtensionsSlice: StateCreator { + if (!api.apiKeys) return; + + set({ apiKeysLoading: true, apiKeysError: null }); + try { + const keys = await api.apiKeys.list(); + set({ apiKeys: keys, apiKeysLoading: false }); + } catch (err) { + set({ + apiKeysLoading: false, + apiKeysError: err instanceof Error ? err.message : 'Failed to load API keys', + }); + } + }, + + fetchApiKeyStorageStatus: async () => { + if (!api.apiKeys) return; + try { + const status = await api.apiKeys.getStorageStatus(); + set({ apiKeyStorageStatus: status }); + } catch { + // Non-critical — UI will just not show the info icon + } + }, + + // ── API Key save ── + saveApiKey: async (request: ApiKeySaveRequest) => { + if (!api.apiKeys) return; + + set({ apiKeySaving: true, apiKeysError: null }); + try { + await api.apiKeys.save(request); + // Refresh the list to get updated masked values + const keys = await api.apiKeys.list(); + set({ apiKeys: keys, apiKeySaving: false }); + } catch (err) { + set({ + apiKeySaving: false, + apiKeysError: err instanceof Error ? err.message : 'Failed to save API key', + }); + throw err; // Re-throw so the dialog can show the error + } + }, + + // ── API Key delete ── + deleteApiKey: async (id: string) => { + if (!api.apiKeys) return; + + try { + await api.apiKeys.delete(id); + set((prev) => ({ + apiKeys: prev.apiKeys.filter((k) => k.id !== id), + })); + } catch (err) { + set({ + apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key', + }); + } + }, + // ── Tab opener ── openExtensionsTab: () => { const state = get(); @@ -391,4 +543,21 @@ export const createExtensionsSlice: StateCreator { + if (!api.mcpRegistry || repositoryUrls.length === 0) return; + void api.mcpRegistry + .githubStars(repositoryUrls) + .then((stars) => { + if (Object.keys(stars).length > 0) { + set((prev) => ({ + mcpGitHubStars: { ...prev.mcpGitHubStars, ...stars }, + })); + } + }) + .catch(() => { + // Silent failure — stars are supplementary data + }); + }, }); diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index 27ee8f82..b7fc9026 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -7,8 +7,10 @@ export interface MentionSuggestion { subtitle?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default) or 'file' */ - type?: 'member' | 'file'; + /** Suggestion type — 'member' (default), 'team', or 'file' */ + type?: 'member' | 'team' | 'file'; + /** Whether the team is currently online (team suggestions only) */ + isOnline?: boolean; /** Absolute file path (file suggestions only) */ filePath?: string; /** Relative display path (file suggestions only) */ diff --git a/src/renderer/utils/formatters.ts b/src/renderer/utils/formatters.ts index c914b799..07127454 100644 --- a/src/renderer/utils/formatters.ts +++ b/src/renderer/utils/formatters.ts @@ -45,3 +45,17 @@ export function formatDuration(ms: number): string { const remainingSeconds = Math.round(seconds % 60); return `${minutes}m ${remainingSeconds}s`; } + +/** + * Formats a number in compact notation for UI display. + * 42 → "42", 1200 → "1.2k", 45300 → "45.3k", 120000 → "120k", 1500000 → "1.5M" + */ +export function formatCompactNumber(n: number): string { + if (n < 1_000) return String(n); + if (n < 1_000_000) { + const k = n / 1_000; + return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`; + } + const m = n / 1_000_000; + return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`; +} diff --git a/src/renderer/utils/mentionLinkify.ts b/src/renderer/utils/mentionLinkify.ts index 43c221a5..e33755c0 100644 --- a/src/renderer/utils/mentionLinkify.ts +++ b/src/renderer/utils/mentionLinkify.ts @@ -1,9 +1,9 @@ /** - * Shared utility for converting @memberName mentions in plain text - * to markdown links with mention:// protocol. + * Shared utility for converting @memberName and @teamName mentions in plain text + * to markdown links with mention:// and team:// protocols. * * Used by UserChatGroup, TeammateMessageItem, ActivityItem, TaskCommentsSection. - * MarkdownViewer already handles rendering mention:// links as colored badges. + * MarkdownViewer already handles rendering mention:// and team:// links as colored badges. */ /** @@ -34,3 +34,51 @@ export function linkifyMentionsInMarkdown( return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`; }); } + +/** + * Convert `@teamName` in plain text to markdown links with team:// protocol. + * Greedy match: longer names are tried first to avoid partial matches. + * + * @param text - The plain text to process + * @param teamNames - Set or array of known team names + * @returns Text with @teamName replaced by markdown links + */ +export function linkifyTeamMentionsInMarkdown( + text: string, + teamNames: ReadonlySet | readonly string[] +): string { + const names = Array.isArray(teamNames) ? teamNames : [...teamNames]; + if (names.length === 0) return text; + + // Sort by name length descending for greedy matching + const sorted = [...names].sort((a, b) => b.length - a.length); + const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + // eslint-disable-next-line no-useless-escape -- escaped chars needed for regex character class + const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi'); + + return text.replace(pattern, (_match, prefix: string, name: string) => { + const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; + return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`; + }); +} + +/** + * Apply both member and team linkification. Team names are matched first to avoid + * team names being captured as member mentions when they share similar names. + * + * @param text - The plain text to process + * @param memberColorMap - Map of member name → color key + * @param teamNames - Known team names + * @returns Text with both @member and @team mentions replaced by markdown links + */ +export function linkifyAllMentionsInMarkdown( + text: string, + memberColorMap: Map, + teamNames: ReadonlySet | readonly string[] = [] +): string { + // Apply team linkification first (team names tend to be longer / more specific) + let result = linkifyTeamMentionsInMarkdown(text, teamNames); + // Then member linkification on the remaining text + result = linkifyMentionsInMarkdown(result, memberColorMap); + return result; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b199e86f..5c5a42db 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -10,7 +10,7 @@ import type { CliArgsValidationResult } from '../utils/cliArgsParser'; import type { CliInstallerAPI } from './cliInstaller'; import type { EditorAPI, ProjectAPI } from './editor'; -import type { McpCatalogAPI, PluginCatalogAPI } from './extensions'; +import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI } from './extensions'; import type { AppConfig, DetectedError, @@ -811,6 +811,9 @@ export interface ElectronAPI { // Extension Store — MCP Registry API (Electron-only, optional) mcpRegistry?: McpCatalogAPI; + + // Extension Store — API Keys Management (Electron-only, optional) + apiKeys?: ApiKeysAPI; } // ============================================================================= diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts index 0b692737..d4121bdf 100644 --- a/src/shared/types/extensions/api.ts +++ b/src/shared/types/extensions/api.ts @@ -3,9 +3,21 @@ * Both APIs are OPTIONAL in ElectronAPI (Electron-only V1). */ +import type { + ApiKeyEntry, + ApiKeyLookupResult, + ApiKeySaveRequest, + ApiKeyStorageStatus, +} from './apikey'; import type { InstallScope, OperationResult } from './common'; import type { EnrichedPlugin, PluginInstallRequest } from './plugin'; -import type { InstalledMcpEntry, McpCatalogItem, McpInstallRequest, McpSearchResult } from './mcp'; +import type { + InstalledMcpEntry, + McpCatalogItem, + McpCustomInstallRequest, + McpInstallRequest, + McpSearchResult, +} from './mcp'; // ── Plugin API ───────────────────────────────────────────────────────────── @@ -31,5 +43,17 @@ export interface McpCatalogAPI { getById: (registryId: string) => Promise; getInstalled: (projectPath?: string) => Promise; install: (request: McpInstallRequest) => Promise; + installCustom: (request: McpCustomInstallRequest) => Promise; uninstall: (name: string, scope?: string, projectPath?: string) => Promise; + githubStars: (repositoryUrls: string[]) => Promise>; +} + +// ── API Keys API ────────────────────────────────────────────────────────── + +export interface ApiKeysAPI { + list: () => Promise; + save: (request: ApiKeySaveRequest) => Promise; + delete: (id: string) => Promise; + lookup: (envVarNames: string[]) => Promise; + getStorageStatus: () => Promise; } diff --git a/src/shared/types/extensions/apikey.ts b/src/shared/types/extensions/apikey.ts new file mode 100644 index 00000000..c36f11b9 --- /dev/null +++ b/src/shared/types/extensions/apikey.ts @@ -0,0 +1,38 @@ +/** + * API Key management types — stored encrypted, transmitted masked. + */ + +/** API key entry returned from backend (with masked value) */ +export interface ApiKeyEntry { + id: string; + name: string; + envVarName: string; + maskedValue: string; + scope: 'user' | 'project'; + createdAt: string; +} + +/** Request to create or update an API key */ +export interface ApiKeySaveRequest { + id?: string; + name: string; + envVarName: string; + value: string; + scope: 'user' | 'project'; +} + +/** Decrypted key lookup result (for auto-fill) */ +export interface ApiKeyLookupResult { + envVarName: string; + value: string; +} + +/** Storage encryption status (for UI display) */ +export interface ApiKeyStorageStatus { + /** How keys are encrypted: OS keychain or local AES-256-GCM */ + encryptionMethod: 'os-keychain' | 'aes-local'; + /** Human-readable backend name: "macOS Keychain", "gnome_libsecret", etc. */ + backend: string; + /** Whether the storage file has secure permissions (0o600) */ + fileSecure: boolean; +} diff --git a/src/shared/types/extensions/index.ts b/src/shared/types/extensions/index.ts index 825c8f1f..2d51a1d5 100644 --- a/src/shared/types/extensions/index.ts +++ b/src/shared/types/extensions/index.ts @@ -18,8 +18,10 @@ export { inferCapabilities } from './plugin'; export type { InstalledMcpEntry, McpCatalogItem, + McpCustomInstallRequest, McpEnvVarDef, McpHeaderDef, + McpHostingType, McpHttpInstallSpec, McpInstallRequest, McpInstallSpec, @@ -28,4 +30,11 @@ export type { McpToolDef, } from './mcp'; -export type { McpCatalogAPI, PluginCatalogAPI } from './api'; +export type { + ApiKeyEntry, + ApiKeyLookupResult, + ApiKeySaveRequest, + ApiKeyStorageStatus, +} from './apikey'; + +export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI } from './api'; diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index 4158f45a..0131fc09 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -4,6 +4,8 @@ // ── Catalog item (normalized from Official Registry / Glama) ─────────────── +export type McpHostingType = 'local' | 'remote' | 'both'; + export interface McpCatalogItem { id: string; // Official: reverse-DNS (e.g. "io.github.upstash/context7"), Glama: "glama:" name: string; // display name @@ -18,6 +20,12 @@ export interface McpCatalogItem { glamaUrl?: string; requiresAuth: boolean; // true if HTTP server has required headers iconUrl?: string; // First icon URL from official registry (icons[0].src) + websiteUrl?: string; + status?: string; + publishedAt?: string; + updatedAt?: string; + author?: string; + hostingType?: McpHostingType; } export interface McpToolDef { @@ -77,6 +85,17 @@ export interface McpInstallRequest { headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag) } +// ── Custom install request (bypasses registry, user provides spec) ────────── + +export interface McpCustomInstallRequest { + serverName: string; + scope: 'local' | 'user' | 'project'; + projectPath?: string; + installSpec: McpInstallSpec; // user provides directly + envValues: Record; + headers: McpHeaderDef[]; +} + // ── Search result wrapper ────────────────────────────────────────────────── export interface McpSearchResult { diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 19327d28..e3f701e0 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -100,3 +100,23 @@ export function sanitizeMcpServerName(displayName: string): string { .replace(/\s+/g, '-') .replace(/[^\w.-]/g, ''); } + +/** + * Extract owner/repo from a GitHub URL. Returns null for non-GitHub URLs. + * Handles: https://github.com/owner/repo, https://github.com/owner/repo.git, trailing slashes. + */ +export function parseGitHubOwnerRepo(url: string): { owner: string; repo: string } | null { + try { + const parsed = new URL(url); + if (parsed.hostname !== 'github.com') return null; + const parts = parsed.pathname + .replace(/^\//, '') + .replace(/\.git$/, '') + .replace(/\/+$/, '') + .split('/'); + if (parts.length < 2 || !parts[0] || !parts[1]) return null; + return { owner: parts[0], repo: parts[1] }; + } catch { + return null; + } +}