feat: enhance API key management and GitHub stars integration
- Introduced ApiKeyService for managing API keys, including listing, saving, deleting, and looking up values. - Added IPC channels for API key operations and integrated them into the Electron API. - Implemented GitHub stars fetching for MCP servers, enhancing visibility of repository popularity. - Updated UI components to display GitHub stars and API key management features, including a dedicated API Keys tab. - Enhanced McpInstallService to support custom MCP server installations with improved validation and error handling. - Refactored various components to accommodate new features and improve user experience.
This commit is contained in:
parent
1949084bd8
commit
a4210936f9
47 changed files with 3060 additions and 220 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IpcResult<OperationResult>> {
|
||||
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<IpcResult<ApiKeyEntry[]>> {
|
||||
return wrapHandler('apiKeysList', () => getApiKeyService().list());
|
||||
}
|
||||
|
||||
async function handleApiKeysSave(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: ApiKeySaveRequest
|
||||
): Promise<IpcResult<ApiKeyEntry>> {
|
||||
return wrapHandler('apiKeysSave', () => {
|
||||
if (!request) throw new Error('Request is required');
|
||||
return getApiKeyService().save(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApiKeysDelete(
|
||||
_event: IpcMainInvokeEvent,
|
||||
id?: string
|
||||
): Promise<IpcResult<void>> {
|
||||
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<IpcResult<ApiKeyLookupResult[]>> {
|
||||
return wrapHandler('apiKeysLookup', () => {
|
||||
if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required');
|
||||
return getApiKeyService().lookup(envVarNames);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApiKeysStorageStatus(): Promise<IpcResult<ApiKeyStorageStatus>> {
|
||||
return wrapHandler('apiKeysStorageStatus', () => getApiKeyService().getStorageStatus());
|
||||
}
|
||||
|
||||
// ── GitHub Stars Handler ──────────────────────────────────────────────────
|
||||
|
||||
async function handleMcpGitHubStars(
|
||||
_event: IpcMainInvokeEvent,
|
||||
repositoryUrls?: string[]
|
||||
): Promise<IpcResult<Record<string, number>>> {
|
||||
return wrapHandler('mcpGitHubStars', () => {
|
||||
if (!Array.isArray(repositoryUrls)) throw new Error('repositoryUrls array is required');
|
||||
return starsService.fetchStars(repositoryUrls);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
398
src/main/services/extensions/apikeys/ApiKeyService.ts
Normal file
398
src/main/services/extensions/apikeys/ApiKeyService.ts
Normal file
|
|
@ -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<ApiKeyEntry[]> {
|
||||
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<ApiKeyEntry> {
|
||||
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<void> {
|
||||
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<ApiKeyLookupResult[]> {
|
||||
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<ApiKeyStorageStatus> {
|
||||
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<StoredApiKey[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/main/services/extensions/catalog/GitHubStarsService.ts
Normal file
184
src/main/services/extensions/catalog/GitHubStarsService.ts
Normal file
|
|
@ -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<string, CacheEntry>();
|
||||
|
||||
/**
|
||||
* Fetch GitHub stars for a batch of repository URLs.
|
||||
* Returns `Record<repositoryUrl, starCount>` — only includes URLs with valid results.
|
||||
*/
|
||||
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
|
||||
const result: Record<string, number> = {};
|
||||
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<number | null> {
|
||||
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<void>>,
|
||||
limit: number
|
||||
): Promise<Array<'ok' | 'error'>> {
|
||||
const results: Array<'ok' | 'error'> = [];
|
||||
let index = 0;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
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))));
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<OperationResult> {
|
||||
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<OperationResult> {
|
||||
if (!SERVER_NAME_RE.test(name)) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
||||
install: (request: McpInstallRequest) =>
|
||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
||||
installCustom: (request: McpCustomInstallRequest) =>
|
||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL_CUSTOM, request),
|
||||
uninstall: (name: string, scope?: string, projectPath?: string) =>
|
||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_UNINSTALL, name, scope, projectPath),
|
||||
githubStars: (repositoryUrls: string[]) =>
|
||||
invokeIpcWithResult<Record<string, number>>(MCP_GITHUB_STARS, repositoryUrls),
|
||||
},
|
||||
|
||||
// ===== API Keys API (Electron-only) =====
|
||||
apiKeys: {
|
||||
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
|
||||
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
|
||||
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
|
||||
lookup: (envVarNames: string[]) =>
|
||||
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames),
|
||||
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5"
|
||||
style={{
|
||||
backgroundColor: 'rgba(168, 85, 247, 0.15)',
|
||||
color: '#c084fc',
|
||||
borderRadius: '3px',
|
||||
boxShadow: '0 0 0 1.5px rgba(168, 85, 247, 0.15)',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'default',
|
||||
}}
|
||||
title={teamName ? `Team: ${teamName}` : undefined}
|
||||
>
|
||||
<UsersRound size={11} className="shrink-0" style={{ opacity: 0.8 }} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (href?.startsWith('task://')) {
|
||||
const taskId = href.slice('task://'.length);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
)}
|
||||
<Tabs
|
||||
value={tabState.activeSubTab}
|
||||
onValueChange={(v) => tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')}
|
||||
onValueChange={(v) =>
|
||||
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'api-keys')
|
||||
}
|
||||
>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="plugins" className="gap-1.5">
|
||||
<Puzzle className="size-3.5" />
|
||||
Plugins
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp-servers" className="gap-1.5">
|
||||
<Server className="size-3.5" />
|
||||
MCP Servers
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="plugins" className="gap-1.5">
|
||||
<Puzzle className="size-3.5" />
|
||||
Plugins
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp-servers" className="gap-1.5">
|
||||
<Server className="size-3.5" />
|
||||
MCP Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="api-keys" className="gap-1.5">
|
||||
<Key className="size-3.5" />
|
||||
API Keys
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{tabState.activeSubTab === 'mcp-servers' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCustomMcpDialogOpen(true)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Add Custom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="plugins">
|
||||
<PluginsPanel
|
||||
|
|
@ -144,7 +172,17 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api-keys">
|
||||
<ApiKeysPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Custom MCP server dialog (lifted to store view level) */}
|
||||
<CustomMcpServerDialog
|
||||
open={customMcpDialogOpen}
|
||||
onClose={() => setCustomMcpDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
137
src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
Normal file
137
src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-2.5 rounded-lg border border-border bg-surface p-4 transition-colors hover:border-border-emphasis">
|
||||
{/* Name + scope badge */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-sm font-medium text-text">{apiKey.name}</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
apiKey.scope === 'user'
|
||||
? 'border-blue-500/30 bg-blue-500/10 text-blue-400'
|
||||
: 'border-purple-500/30 bg-purple-500/10 text-purple-400'
|
||||
}
|
||||
>
|
||||
{apiKey.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Env var name */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<code className="rounded bg-surface-raised px-1.5 py-0.5 text-xs text-blue-400">
|
||||
{apiKey.envVarName}
|
||||
</code>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={() => void handleCopyEnvVar()}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="size-3 text-text-muted" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{copied ? 'Copied!' : 'Copy env var name'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Masked value */}
|
||||
<p className="font-mono text-xs text-text-muted">{apiKey.maskedValue}</p>
|
||||
|
||||
{/* Footer: date + actions */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-text-muted">{createdDate}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => onEdit(apiKey)}
|
||||
>
|
||||
<Pencil className="size-3.5 text-text-muted" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${confirmDelete ? 'text-red-400 hover:bg-red-500/10' : 'text-text-muted'}`}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{confirmDelete ? 'Click again to confirm' : 'Delete'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
243
src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
Normal file
243
src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
Normal file
|
|
@ -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<Scope>('user');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [envVarError, setEnvVarError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<Key className="size-4 text-text-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{isEdit ? 'Edit API Key' : 'Add API Key'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Update the key details. You must re-enter the value.'
|
||||
: 'Store an API key for auto-filling in MCP server installations.'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{storageStatus && storageStatus.encryptionMethod !== 'os-keychain' && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-400">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
OS keychain unavailable — keys encrypted with AES-256 locally. Install gnome-keyring for
|
||||
OS-level protection.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apikey-name" className="text-xs">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="apikey-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. OpenAI Production"
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Env var name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apikey-envvar" className="text-xs">
|
||||
Environment Variable Name
|
||||
</Label>
|
||||
<Input
|
||||
id="apikey-envvar"
|
||||
value={envVarName}
|
||||
onChange={(e) => {
|
||||
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 && <p className="text-xs text-red-400">{envVarError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apikey-value" className="text-xs">
|
||||
Value
|
||||
</Label>
|
||||
<Input
|
||||
id="apikey-value"
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={isEdit ? 'Re-enter key value' : 'sk-...'}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Scope</Label>
|
||||
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!canSubmit}>
|
||||
{apiKeySaving ? 'Saving...' : isEdit ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
139
src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
Normal file
139
src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
Normal file
|
|
@ -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<ApiKeyEntry | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
||||
Securely store API keys for auto-filling when installing MCP servers.
|
||||
{storageStatus && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isOsKeychain ? (
|
||||
<Info className="size-3.5 shrink-0 cursor-help text-text-muted" />
|
||||
) : (
|
||||
<AlertTriangle className="size-3.5 shrink-0 cursor-help text-amber-400" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{isOsKeychain ? (
|
||||
<p>
|
||||
Keys are encrypted via {storageStatus.backend} and stored with restricted file
|
||||
permissions (owner-only).
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
OS keychain unavailable — keys are encrypted locally with AES-256. For stronger
|
||||
protection, install a keyring service (gnome-keyring, kwallet).
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
|
||||
<Plus className="size-3.5" />
|
||||
Add API Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{apiKeysError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
||||
{apiKeysError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skeleton loading */}
|
||||
{apiKeysLoading && apiKeys.length === 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="skeleton-card flex flex-col gap-2 rounded-lg border border-border p-4"
|
||||
style={{ animationDelay: `${i * 80}ms` }}
|
||||
>
|
||||
<div className="h-4 w-32 rounded bg-surface-raised" />
|
||||
<div className="h-3 w-24 rounded bg-surface-raised" />
|
||||
<div className="h-3 w-40 rounded bg-surface-raised" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!apiKeysLoading && apiKeys.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<Key className="size-5 text-text-muted" />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">No API keys saved</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Add keys to auto-fill environment variables when installing MCP servers.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd} className="mt-2 gap-1.5">
|
||||
<Plus className="size-3.5" />
|
||||
Add your first key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key cards grid */}
|
||||
{apiKeys.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{apiKeys.map((key) => (
|
||||
<ApiKeyCard key={key.id} apiKey={key} onEdit={handleEdit} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
<ApiKeyFormDialog open={dialogOpen} editingKey={editingKey} onClose={handleDialogClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
444
src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
Normal file
444
src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
Normal file
|
|
@ -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<TransportMode>('stdio');
|
||||
const [scope, setScope] = useState<Scope>('user');
|
||||
|
||||
// Stdio fields
|
||||
const [npmPackage, setNpmPackage] = useState('');
|
||||
const [npmVersion, setNpmVersion] = useState('');
|
||||
|
||||
// HTTP fields
|
||||
const [httpUrl, setHttpUrl] = useState('');
|
||||
const [httpTransport, setHttpTransport] = useState<HttpTransport>('streamable-http');
|
||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||
|
||||
// Shared
|
||||
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(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<string, string> = {};
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<Server className="size-4 text-text-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Add Custom MCP Server</DialogTitle>
|
||||
<DialogDescription>Add a server manually without the catalog.</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Server name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-name" className="text-xs">
|
||||
Server Name
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-name"
|
||||
value={serverName}
|
||||
onChange={(e) => setServerName(e.target.value)}
|
||||
placeholder="my-server"
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transport toggle */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Transport</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={transportMode === 'stdio' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTransportMode('stdio')}
|
||||
>
|
||||
Stdio (npm)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={transportMode === 'http' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTransportMode('http')}
|
||||
>
|
||||
HTTP / SSE
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stdio fields */}
|
||||
{transportMode === 'stdio' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-npm" className="text-xs">
|
||||
npm Package
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-npm"
|
||||
value={npmPackage}
|
||||
onChange={(e) => setNpmPackage(e.target.value)}
|
||||
placeholder="@example/mcp-server"
|
||||
className="h-8 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-version" className="text-xs">
|
||||
Version (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-version"
|
||||
value={npmVersion}
|
||||
onChange={(e) => setNpmVersion(e.target.value)}
|
||||
placeholder="latest"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTTP fields */}
|
||||
{transportMode === 'http' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-url" className="text-xs">
|
||||
Server URL
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-url"
|
||||
value={httpUrl}
|
||||
onChange={(e) => setHttpUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/mcp"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Transport Type</Label>
|
||||
<Select
|
||||
value={httpTransport}
|
||||
onValueChange={(v) => setHttpTransport(v as HttpTransport)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_TRANSPORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Headers</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addHeader}
|
||||
className="h-6 px-1.5 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{headers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{headers.map((header, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(i, 'key', e.target.value)}
|
||||
className="h-7 w-32 text-xs"
|
||||
placeholder="Header-Name"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(i, 'value', e.target.value)}
|
||||
className="h-7 flex-1 text-xs"
|
||||
placeholder="value"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => removeHeader(i)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Scope</Label>
|
||||
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Environment variables */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Environment Variables</Label>
|
||||
<Button variant="ghost" size="sm" onClick={addEnvVar} className="h-6 px-1.5 text-xs">
|
||||
<Plus className="mr-1 size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{envVars.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{envVars.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEnvVar(i, 'key', e.target.value)}
|
||||
className="h-7 w-40 font-mono text-xs"
|
||||
placeholder="ENV_VAR_NAME"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEnvVar(i, 'value', e.target.value)}
|
||||
className="h-7 flex-1 text-xs"
|
||||
placeholder="value"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => removeEnvVar(i)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" disabled={!canSubmit} onClick={() => void handleInstall()}>
|
||||
{installing ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onClick(server.id)}
|
||||
className={`flex w-full 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)] ${
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick(server.id);
|
||||
}
|
||||
}}
|
||||
className={`relative flex w-full cursor-pointer flex-col gap-2 overflow-hidden 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)] ${
|
||||
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{/* Header: icon + name + source */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
{/* Server icon or fallback */}
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
{server.iconUrl && !imgError ? (
|
||||
{/* Source ribbon (top-left corner) */}
|
||||
<div className="pointer-events-none absolute -left-[1px] -top-[1px] size-16 overflow-hidden">
|
||||
<div
|
||||
className={`absolute left-[-18px] top-[8px] w-[80px] rotate-[-45deg] text-center text-[9px] font-semibold leading-[18px] shadow-sm ${RIBBON_STYLES[server.source] ?? RIBBON_STYLES.glama}`}
|
||||
>
|
||||
{server.source === 'official' ? 'Official' : 'Glama'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header: icon + name */}
|
||||
<div className={`flex items-start gap-2.5 ${hasIcon ? 'pl-5' : 'pl-7'}`}>
|
||||
{/* Server icon (only when available) */}
|
||||
{hasIcon && (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<img
|
||||
src={server.iconUrl}
|
||||
src={server.iconUrl!}
|
||||
alt=""
|
||||
className="size-7 rounded object-contain"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Server className="size-4 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
|
||||
|
|
@ -67,7 +97,6 @@ export const McpServerCard = ({
|
|||
Installed
|
||||
</Badge>
|
||||
)}
|
||||
<SourceBadge source={server.source} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,11 +107,17 @@ export const McpServerCard = ({
|
|||
|
||||
{/* Footer indicators + install button */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">
|
||||
{server.tools.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-raised px-1.5 py-0.5 ring-1 ring-border">
|
||||
<Wrench className="size-3" />
|
||||
{server.tools.length}
|
||||
{server.tools.length} {server.tools.length === 1 ? 'tool' : 'tools'}
|
||||
</span>
|
||||
)}
|
||||
{server.envVars.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<KeyRound className="size-3" />
|
||||
{server.envVars.length} {server.envVars.length === 1 ? 'env' : 'envs'}
|
||||
</span>
|
||||
)}
|
||||
{server.requiresAuth && (
|
||||
|
|
@ -91,7 +126,76 @@ export const McpServerCard = ({
|
|||
Auth
|
||||
</span>
|
||||
)}
|
||||
{server.license && <span>{server.license}</span>}
|
||||
{server.version && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Tag className="size-3" />
|
||||
{server.version}
|
||||
</span>
|
||||
)}
|
||||
{server.updatedAt && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatRelativeTime(server.updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
{server.author && <span className="truncate">by {server.author}</span>}
|
||||
{server.hostingType === 'remote' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Cloud className="size-3" />
|
||||
Remote
|
||||
</span>
|
||||
)}
|
||||
{server.hostingType === 'local' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Monitor className="size-3" />
|
||||
Local
|
||||
</span>
|
||||
)}
|
||||
{server.hostingType === 'both' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Globe className="size-3" />
|
||||
Both
|
||||
</span>
|
||||
)}
|
||||
{/* External links + stars */}
|
||||
{server.repositoryUrl && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 text-text-muted transition-colors hover:text-text"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void api.openExternal(server.repositoryUrl!);
|
||||
}}
|
||||
>
|
||||
<GithubIcon className="size-3.5" />
|
||||
{stars != null && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Star className="size-3 fill-amber-400 text-amber-400" />
|
||||
{formatCompactNumber(stars)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Repository</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{server.websiteUrl && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="inline-flex items-center text-text-muted transition-colors hover:text-text"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void api.openExternal(server.websiteUrl!);
|
||||
}}
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Website</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{canAutoInstall && (
|
||||
<div className="shrink-0">
|
||||
|
|
@ -114,6 +218,6 @@ export const McpServerCard = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Scope>('user');
|
||||
const [serverName, setServerName] = useState('');
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize form when server changes
|
||||
const [lastServerId, setLastServerId] = useState<string | null>(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<string>();
|
||||
const values: Record<string, string> = {};
|
||||
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<string>();
|
||||
const updates: Record<string, string> = {};
|
||||
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 = ({
|
|||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Server icon */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
{server.iconUrl && !imgError ? (
|
||||
{/* Server icon (only when available) */}
|
||||
{hasIcon && (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<img
|
||||
src={server.iconUrl}
|
||||
src={server.iconUrl!}
|
||||
alt=""
|
||||
className="size-8 rounded object-contain"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Server className="size-5 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
|
|
@ -154,6 +213,15 @@ export const McpServerDetailDialog = ({
|
|||
<span className="text-text-muted">Source</span>
|
||||
<p className="capitalize text-text">{server.source}</p>
|
||||
</div>
|
||||
{stars != null && (
|
||||
<div>
|
||||
<span className="text-text-muted">GitHub Stars</span>
|
||||
<p className="flex items-center gap-1 text-text">
|
||||
<Star className="size-3.5 fill-amber-400 text-amber-400" />
|
||||
{stars.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{server.version && (
|
||||
<div>
|
||||
<span className="text-text-muted">Version</span>
|
||||
|
|
@ -176,6 +244,30 @@ export const McpServerDetailDialog = ({
|
|||
: 'Manual setup required'}
|
||||
</p>
|
||||
</div>
|
||||
{server.author && (
|
||||
<div>
|
||||
<span className="text-text-muted">Author</span>
|
||||
<p className="text-text">{server.author}</p>
|
||||
</div>
|
||||
)}
|
||||
{server.hostingType && (
|
||||
<div>
|
||||
<span className="text-text-muted">Hosting</span>
|
||||
<p className="capitalize text-text">{server.hostingType}</p>
|
||||
</div>
|
||||
)}
|
||||
{server.publishedAt && (
|
||||
<div>
|
||||
<span className="text-text-muted">Published</span>
|
||||
<p className="text-text">{new Date(server.publishedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{server.updatedAt && (
|
||||
<div>
|
||||
<span className="text-text-muted">Updated</span>
|
||||
<p className="text-text">{new Date(server.updatedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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] && (
|
||||
<span className="shrink-0 text-[10px] text-emerald-400">Auto-filled</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -357,6 +452,16 @@ export const McpServerDetailDialog = ({
|
|||
Glama
|
||||
</Button>
|
||||
)}
|
||||
{server.websiteUrl && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-sm text-blue-400"
|
||||
onClick={() => void api.openExternal(server.websiteUrl!)}
|
||||
>
|
||||
<ExternalLink className="mr-1 size-3.5" />
|
||||
Website
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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<McpSortValue>('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;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const CapabilityChips = ({
|
|||
}, [plugins]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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)}
|
||||
<span className="ml-1 text-text-muted">({count})</span>
|
||||
<span>{getCapabilityLabel(cap)}</span>
|
||||
<span
|
||||
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||
isActive
|
||||
? 'bg-surface-raised text-text-secondary'
|
||||
: 'bg-surface-raised/70 text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const CategoryChips = ({
|
|||
if (categoryCounts.length === 0) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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}
|
||||
<span className="ml-1 text-text-muted">({count})</span>
|
||||
<span>{category}</span>
|
||||
<span
|
||||
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||
isActive
|
||||
? 'bg-surface-raised text-text-secondary'
|
||||
: 'bg-surface-raised/70 text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-text">{plugin.name}</h3>
|
||||
{plugin.isInstalled && (
|
||||
<Badge
|
||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
variant="outline"
|
||||
>
|
||||
Installed
|
||||
</Badge>
|
||||
)}
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="truncate text-sm font-semibold text-text">{plugin.name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-[11px]">
|
||||
{category}
|
||||
</Badge>
|
||||
{capabilities.map((cap) => (
|
||||
<Badge
|
||||
key={cap}
|
||||
variant="outline"
|
||||
className="bg-surface-raised/60 border-border text-[11px] text-text-secondary"
|
||||
>
|
||||
{getCapabilityLabel(cap)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<InstallCountBadge count={plugin.installCount} />
|
||||
{plugin.isInstalled && (
|
||||
<Badge
|
||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
variant="outline"
|
||||
>
|
||||
Installed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="line-clamp-2 text-xs text-text-secondary">{plugin.description}</p>
|
||||
<p className="line-clamp-3 min-h-[3.75rem] text-xs leading-5 text-text-secondary">
|
||||
{plugin.description}
|
||||
</p>
|
||||
|
||||
{/* Category + Capabilities */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{category}
|
||||
</Badge>
|
||||
{capabilities.map((cap) => (
|
||||
<Badge
|
||||
key={cap}
|
||||
variant="outline"
|
||||
className="border-purple-500/30 bg-purple-500/10 text-xs text-purple-400"
|
||||
>
|
||||
{getCapabilityLabel(cap)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer: author, install count, install button */}
|
||||
{/* Footer: author + version + install button */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-xs text-text-muted">
|
||||
{plugin.author?.name ?? 'Unknown author'}
|
||||
</span>
|
||||
<InstallCountBadge count={plugin.installCount} />
|
||||
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
||||
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
|
||||
{plugin.version && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1">
|
||||
<Tag className="size-3" />
|
||||
{plugin.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<span className="text-text-muted">Category</span>
|
||||
<p className="capitalize text-text">{category}</p>
|
||||
</div>
|
||||
{plugin.version && (
|
||||
<div>
|
||||
<span className="text-text-muted">Version</span>
|
||||
<p className="text-text">{plugin.version}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-text-muted">Capabilities</span>
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
|
|
@ -157,17 +163,29 @@ export const PluginDetailDialog = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Homepage link */}
|
||||
{plugin.homepage && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 text-sm text-blue-400"
|
||||
onClick={() => void api.openExternal(plugin.homepage!)}
|
||||
>
|
||||
<ExternalLink className="mr-1 size-3.5" />
|
||||
Homepage
|
||||
</Button>
|
||||
)}
|
||||
{/* Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
{plugin.homepage && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 text-sm text-blue-400"
|
||||
onClick={() => void api.openExternal(plugin.homepage!)}
|
||||
>
|
||||
<ExternalLink className="mr-1 size-3.5" />
|
||||
Homepage
|
||||
</Button>
|
||||
)}
|
||||
{plugin.author?.email && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 text-sm text-blue-400"
|
||||
onClick={() => void api.openExternal(`mailto:${plugin.author!.email}`)}
|
||||
>
|
||||
<Mail className="mr-1 size-3.5" />
|
||||
Contact
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* README */}
|
||||
<div className="mt-2 max-h-80 overflow-y-auto rounded-md border border-border bg-surface-raised p-4">
|
||||
|
|
|
|||
|
|
@ -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<PluginCapability>();
|
||||
for (const plugin of catalog) {
|
||||
for (const capability of inferCapabilities(plugin)) {
|
||||
counts.add(capability);
|
||||
}
|
||||
}
|
||||
return counts.size;
|
||||
}, [catalog]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search + Sort + Installed only row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex-1">
|
||||
<SearchInput
|
||||
value={pluginFilters.search}
|
||||
|
|
@ -149,76 +168,132 @@ export const PluginsPanel = ({
|
|||
placeholder="Search plugins..."
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={sortValue}
|
||||
onValueChange={(v) => {
|
||||
const [field, order] = v.split(':') as [PluginSortField, 'asc' | 'desc'];
|
||||
setPluginSort({ field, order });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="installed-only"
|
||||
checked={pluginFilters.installedOnly}
|
||||
onCheckedChange={toggleInstalledOnly}
|
||||
/>
|
||||
<Label htmlFor="installed-only" className="whitespace-nowrap text-xs text-text-secondary">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select
|
||||
value={sortValue}
|
||||
onValueChange={(v) => {
|
||||
const [field, order] = v.split(':') as [PluginSortField, 'asc' | 'desc'];
|
||||
setPluginSort({ field, order });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label
|
||||
htmlFor="installed-only"
|
||||
className="bg-surface-raised/40 flex h-10 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
|
||||
>
|
||||
<Checkbox
|
||||
id="installed-only"
|
||||
checked={pluginFilters.installedOnly}
|
||||
onCheckedChange={toggleInstalledOnly}
|
||||
/>
|
||||
Installed only
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-surface-raised/50 rounded-md border border-border p-3">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
|
||||
Categories
|
||||
</span>
|
||||
<div className="bg-surface-raised/20 rounded-xl p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="bg-surface-raised/60 flex size-8 items-center justify-center rounded-lg border border-border">
|
||||
<Filter className="size-4 text-text-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-text">Browse by fit</h2>
|
||||
<Badge variant="outline" className="text-[11px] text-text-muted">
|
||||
{activeFilterCount} active
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Narrow the catalog by category, capability, or installed state.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{catalog.length} plugins
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{totalCategoryCount} categories
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{totalCapabilityCount} capabilities
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-auto p-0 text-xs text-[var(--color-accent)]"
|
||||
className="justify-start rounded-lg border border-border px-3 text-xs text-text-secondary hover:text-text lg:justify-center"
|
||||
>
|
||||
Clear all
|
||||
Clear all filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CategoryChips
|
||||
plugins={catalog}
|
||||
selected={pluginFilters.categories}
|
||||
onToggle={toggleCategory}
|
||||
/>
|
||||
<div className="border-b border-border" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
|
||||
Capabilities
|
||||
</span>
|
||||
<CapabilityChips
|
||||
plugins={catalog}
|
||||
selected={pluginFilters.capabilities}
|
||||
onToggle={toggleCapability}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
||||
Categories
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{pluginFilters.categories.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<CategoryChips
|
||||
plugins={catalog}
|
||||
selected={pluginFilters.categories}
|
||||
onToggle={toggleCategory}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
||||
Capabilities
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{pluginFilters.capabilities.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<CapabilityChips
|
||||
plugins={catalog}
|
||||
selected={pluginFilters.capabilities}
|
||||
onToggle={toggleCapability}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{filtered.length} plugin{filtered.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-text-muted">
|
||||
Showing {filtered.length} of {catalog.length} plugin{catalog.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Results update instantly as you refine filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
<span className="text-xs text-text-muted">
|
||||
{globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
|
||||
</span>
|
||||
{!globalSearchEnabled && (
|
||||
{!globalSearchEnabled && selectedProjectId && (
|
||||
<>
|
||||
<span className="text-text-muted/50 mx-1 text-xs">·</span>
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
{repositoryGroups.find((r) =>
|
||||
r.worktrees.some((w) => w.id === selectedProjectId)
|
||||
)?.name ?? 'Current project'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearProject}
|
||||
className="flex items-center gap-1.5 rounded-full bg-surface-raised px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-overlay hover:text-text"
|
||||
>
|
||||
<span className="max-w-[200px] truncate">
|
||||
{repositoryGroups.find((r) =>
|
||||
r.worktrees.some((w) => w.id === selectedProjectId)
|
||||
)?.name ?? 'Current project'}
|
||||
</span>
|
||||
<X className="size-3 shrink-0" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(new Set());
|
||||
const isInitialTaskLoadRef = useRef(true);
|
||||
|
||||
const newTaskIds = useMemo(() => {
|
||||
if (!globalTasksInitialized || globalTasks.length === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// 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<string>();
|
||||
}
|
||||
|
||||
// Subsequent updates: detect truly new tasks
|
||||
const newIds = new Set<string>();
|
||||
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<string | null>(null);
|
||||
|
||||
|
|
@ -481,6 +516,7 @@ export const GlobalTaskList = ({
|
|||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
@ -578,6 +614,7 @@ export const GlobalTaskList = ({
|
|||
<SidebarTaskItem
|
||||
task={task}
|
||||
showTeamName
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
@ -643,6 +680,7 @@ export const GlobalTaskList = ({
|
|||
<SidebarTaskItem
|
||||
task={task}
|
||||
hideTeamName
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
@ -708,6 +746,7 @@ export const GlobalTaskList = ({
|
|||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
isNew={isNewTask(task)}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ interface SidebarTaskItemProps {
|
|||
task: GlobalTask;
|
||||
hideTeamName?: boolean;
|
||||
showTeamName?: boolean;
|
||||
/** When true, the item plays an enter animation */
|
||||
isNew?: boolean;
|
||||
/** The composite key "teamName:taskId" of the task being renamed, or null */
|
||||
renamingKey?: string | null;
|
||||
/** Called when rename is completed with Enter or blur */
|
||||
|
|
@ -72,6 +74,7 @@ export const SidebarTaskItem = ({
|
|||
task,
|
||||
hideTeamName,
|
||||
showTeamName,
|
||||
isNew,
|
||||
renamingKey,
|
||||
onRenameComplete,
|
||||
onRenameCancel,
|
||||
|
|
@ -144,10 +147,12 @@ export const SidebarTaskItem = ({
|
|||
|
||||
const showTeamRow = showTeamName && !hideTeamName;
|
||||
|
||||
const enterClass = isNew ? 'task-item-enter-animate' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
|
||||
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''} ${enterClass}`}
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
onClick={() => {
|
||||
if (!isRenaming) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{hasFileSearch ? 'No matching members or files' : 'No matching members'}
|
||||
{hasFileSearch ? 'No matching members, teams, or files' : 'No matching members'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<Section, string> = {
|
||||
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(<SectionHeader key={`section-${section}`} label={isFile ? 'Files' : 'Members'} />);
|
||||
items.push(<SectionHeader key={`section-${section}`} label={sectionLabel[section]} />);
|
||||
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 ? (
|
||||
<FileIcon fileName={s.name} className="size-3.5" />
|
||||
) : isTeam ? (
|
||||
<UsersRound
|
||||
size={13}
|
||||
className="shrink-0"
|
||||
style={{ color: colorSet?.text ?? 'var(--color-text-muted)' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="inline-block size-2.5 shrink-0 rounded-full"
|
||||
|
|
@ -128,6 +155,13 @@ export const MentionSuggestionList = ({
|
|||
>
|
||||
<HighlightedName name={s.name} query={query} />
|
||||
</span>
|
||||
{isTeam && s.isOnline !== undefined ? (
|
||||
<span
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: s.isOnline ? '#22c55e' : '#71717a' }}
|
||||
title={s.isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
) : null}
|
||||
{s.subtitle ? (
|
||||
<span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement, Mention
|
|||
onChipRemove,
|
||||
projectPath,
|
||||
onFileChipInsert,
|
||||
teamSuggestions = [],
|
||||
onModEnter,
|
||||
style,
|
||||
className,
|
||||
|
|
@ -271,7 +275,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
value,
|
||||
onValueChange,
|
||||
textareaRef: internalRef,
|
||||
enableTriggerAlways: enableFiles,
|
||||
enableTriggerAlways: enableFiles || teamSuggestions.length > 0,
|
||||
});
|
||||
|
||||
// --- File suggestions ---
|
||||
|
|
@ -281,12 +285,23 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
isOpen && enableFiles
|
||||
);
|
||||
|
||||
// Merged suggestion list: members first, then files
|
||||
// --- Team suggestions filtered by query ---
|
||||
const filteredTeamSuggestions = React.useMemo(() => {
|
||||
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<HTMLTextAreaElement, Mention
|
|||
setMergedIndex(0);
|
||||
}, [query, allSuggestions.length]);
|
||||
|
||||
// Effective index: use merged when files enabled, hook's index otherwise
|
||||
const effectiveIndex = enableFiles ? mergedIndex : selectedIndex;
|
||||
const effectiveSuggestions = enableFiles ? allSuggestions : memberSuggestions;
|
||||
// Use merged index when we have extra suggestion types (teams or files)
|
||||
const hasMergedSuggestions = enableFiles || teamSuggestions.length > 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<HTMLTextAreaElement, Mention
|
|||
}, [value]);
|
||||
|
||||
// --- Overlay activation ---
|
||||
const hasOverlay = suggestions.length > 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<HTMLTextAreaElement, Mention
|
|||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// 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<HTMLTextAreaElement, Mention
|
|||
}
|
||||
handleChipKeyDown(e);
|
||||
if (!e.defaultPrevented && !isOpen) {
|
||||
if (enableFiles) {
|
||||
if (hasMergedSuggestions) {
|
||||
fileMentionHandleKeyDown(e);
|
||||
} else {
|
||||
mentionHandleKeyDown(e);
|
||||
|
|
@ -537,7 +561,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
[
|
||||
onModEnter,
|
||||
handleChipKeyDown,
|
||||
enableFiles,
|
||||
hasMergedSuggestions,
|
||||
fileMentionHandleKeyDown,
|
||||
mentionHandleKeyDown,
|
||||
isOpen,
|
||||
|
|
@ -645,7 +669,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
}, [advanceTip]);
|
||||
|
||||
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
|
||||
const showHintRow = showHint && (suggestions.length > 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<HTMLTextAreaElement, Mention
|
|||
if (seg.type === 'chip') {
|
||||
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
|
||||
}
|
||||
// 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<HTMLTextAreaElement, Mention
|
|||
<MentionSuggestionList
|
||||
suggestions={effectiveSuggestions}
|
||||
selectedIndex={effectiveIndex}
|
||||
onSelect={enableFiles ? handleMergedSelect : selectSuggestion}
|
||||
onSelect={hasMergedSuggestions ? handleMergedSelect : selectSuggestion}
|
||||
query={query}
|
||||
hasFileSearch={enableFiles}
|
||||
filesLoading={enableFiles && filesLoading}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
PluginSortField,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
export type ExtensionsSubTab = 'plugins' | 'mcp-servers';
|
||||
export type ExtensionsSubTab = 'plugins' | 'mcp-servers' | 'api-keys';
|
||||
|
||||
interface PluginSortState {
|
||||
field: PluginSortField;
|
||||
|
|
|
|||
77
src/renderer/hooks/useTeamSuggestions.ts
Normal file
77
src/renderer/hooks/useTeamSuggestions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Hook for providing team @-mention suggestions.
|
||||
*
|
||||
* Returns non-deleted teams (excluding the current one) as MentionSuggestion[]
|
||||
* with online/offline status. Uses the alive list API to determine status.
|
||||
*
|
||||
* The returned list is unfiltered — query filtering is handled downstream
|
||||
* by useMentionDetection inside MentionableTextarea.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
||||
export interface UseTeamSuggestionsResult {
|
||||
suggestions: MentionSuggestion[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns team MentionSuggestion[] sorted by online status (online first).
|
||||
*
|
||||
* @param currentTeamName - The current team name to exclude from suggestions
|
||||
*/
|
||||
export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult {
|
||||
const teams = useStore((s) => s.teams);
|
||||
const [aliveTeams, setAliveTeams] = useState<Set<string>>(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<MentionSuggestion[]>(() => {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, ExtensionOperationState>;
|
||||
installErrors: Record<string, string>; // keyed by pluginId or registryId
|
||||
|
||||
// ── API Keys ──
|
||||
apiKeys: ApiKeyEntry[];
|
||||
apiKeysLoading: boolean;
|
||||
apiKeysError: string | null;
|
||||
apiKeySaving: boolean;
|
||||
apiKeyStorageStatus: ApiKeyStorageStatus | null;
|
||||
|
||||
// ── GitHub Stars (supplementary) ──
|
||||
mcpGitHubStars: Record<string, number>;
|
||||
|
||||
// ── Read actions ──
|
||||
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
|
||||
fetchPluginReadme: (pluginId: string) => void;
|
||||
|
|
@ -53,6 +67,7 @@ export interface ExtensionsSlice {
|
|||
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
||||
uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise<void>;
|
||||
installMcpServer: (request: McpInstallRequest) => Promise<void>;
|
||||
installCustomMcpServer: (request: McpCustomInstallRequest) => Promise<void>;
|
||||
uninstallMcpServer: (
|
||||
registryId: string,
|
||||
name: string,
|
||||
|
|
@ -60,8 +75,17 @@ export interface ExtensionsSlice {
|
|||
projectPath?: string
|
||||
) => Promise<void>;
|
||||
|
||||
// ── API Keys actions ──
|
||||
fetchApiKeys: () => Promise<void>;
|
||||
fetchApiKeyStorageStatus: () => Promise<void>;
|
||||
saveApiKey: (request: ApiKeySaveRequest) => Promise<void>;
|
||||
deleteApiKey: (id: string) => Promise<void>;
|
||||
|
||||
// ── Tab opener ──
|
||||
openExtensionsTab: () => void;
|
||||
|
||||
// ── GitHub Stars ──
|
||||
fetchMcpGitHubStars: (repositoryUrls: string[]) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -96,6 +120,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
mcpInstallProgress: {},
|
||||
installErrors: {},
|
||||
|
||||
apiKeys: [],
|
||||
apiKeysLoading: false,
|
||||
apiKeysError: null,
|
||||
apiKeySaving: false,
|
||||
apiKeyStorageStatus: null,
|
||||
|
||||
mcpGitHubStars: {},
|
||||
|
||||
// ── Plugin catalog fetch ──
|
||||
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
||||
if (!api.plugins) return;
|
||||
|
|
@ -163,11 +195,23 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
set({ mcpBrowseLoading: true, mcpBrowseError: null });
|
||||
try {
|
||||
const result = await api.mcpRegistry.browse(cursor);
|
||||
set((prev) => ({
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
},
|
||||
|
||||
// ── MCP custom install ──
|
||||
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
},
|
||||
|
||||
// ── API Keys fetch ──
|
||||
fetchApiKeys: async () => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
label: 'Extensions',
|
||||
});
|
||||
},
|
||||
|
||||
// ── GitHub Stars (fire-and-forget) ──
|
||||
fetchMcpGitHubStars: (repositoryUrls: string[]) => {
|
||||
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
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> | 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<string, string>,
|
||||
teamNames: ReadonlySet<string> | 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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<McpCatalogItem | null>;
|
||||
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
||||
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
||||
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
||||
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
||||
githubStars: (repositoryUrls: string[]) => Promise<Record<string, number>>;
|
||||
}
|
||||
|
||||
// ── API Keys API ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiKeysAPI {
|
||||
list: () => Promise<ApiKeyEntry[]>;
|
||||
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
|
||||
delete: (id: string) => Promise<void>;
|
||||
lookup: (envVarNames: string[]) => Promise<ApiKeyLookupResult[]>;
|
||||
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
|
||||
}
|
||||
|
|
|
|||
38
src/shared/types/extensions/apikey.ts
Normal file
38
src/shared/types/extensions/apikey.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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:<id>"
|
||||
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<string, string>;
|
||||
headers: McpHeaderDef[];
|
||||
}
|
||||
|
||||
// ── Search result wrapper ──────────────────────────────────────────────────
|
||||
|
||||
export interface McpSearchResult {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue