/** * Pure-function normalizers for Extension Store data. */ import { getCliProviderExtensionCapability, isCliExtensionCapabilityMutable, } from './providerExtensionCapabilities'; import type { CliInstallationStatus, InstalledMcpEntry, InstalledPluginEntry, InstallScope, PluginCapability, PluginCatalogItem, } from '@shared/types'; /** * Normalize a repository URL for dedup comparison. * Lowercases, strips `.git` suffix, strips trailing `/`. */ export function normalizeRepoUrl(url: string): string { return url .toLowerCase() .replace(/\.git$/, '') .replace( /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, URL length bounded */ /\/+$/, '' ); } /** * Derive UI-visible capability labels from plugin capability flags. */ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] { const caps: PluginCapability[] = []; if (item.hasLspServers) caps.push('lsp'); if (item.hasMcpServers) caps.push('mcp'); if (item.hasAgents) caps.push('agent'); if (item.hasCommands) caps.push('command'); if (item.hasHooks) caps.push('hook'); if (caps.length === 0) caps.push('skill'); return caps; } const CAPABILITY_LABELS: Record = { lsp: 'LSP', mcp: 'MCP', agent: 'Agent', command: 'Command', hook: 'Hook', skill: 'Skill', }; /** * Get a human-readable label for the primary capability. */ export function getPrimaryCapabilityLabel(capabilities: PluginCapability[]): string { if (capabilities.length === 0) return 'Skill'; return CAPABILITY_LABELS[capabilities[0]]; } /** * Get human-readable label for a capability. */ export function getCapabilityLabel(capability: PluginCapability): string { return CAPABILITY_LABELS[capability]; } /** * Format large install counts for display. * 277472 → "277K", 1200000 → "1.2M", 42 → "42" */ export function formatInstallCount(count: number): string { if (count >= 1_000_000) { const millions = count / 1_000_000; return millions >= 10 ? `${Math.round(millions)}M` : `${millions.toFixed(1).replace(/\.0$/, '')}M`; } if (count >= 1_000) { const thousands = count / 1_000; return thousands >= 10 ? `${Math.round(thousands)}K` : `${thousands.toFixed(1).replace(/\.0$/, '')}K`; } return String(count); } /** * Normalize a category string for consistent comparison/display. * Lowercases, trims, falls back to "other". */ export function normalizeCategory(raw: string | undefined): string { if (!raw) return 'other'; const normalized = raw.trim().toLowerCase(); return normalized || 'other'; } /** * Build a pluginId (= qualifiedName) from marketplace plugin name + marketplace name. */ export function buildPluginId(pluginName: string, marketplaceName: string): string { return `${pluginName}@${marketplaceName}`; } /** * Namespaced operation-state key for plugin install/uninstall UI state. */ export function getPluginOperationKey( pluginId: string, scope: InstallScope, projectPath?: string | null ): string { if (scope === 'project' || scope === 'local') { return `plugin:${pluginId}:${scope}:${getMcpProjectStateKey(projectPath)}`; } return `plugin:${pluginId}:${scope}`; } /** * Namespaced operation-state key for MCP install/uninstall UI state. */ export function getMcpOperationKey( registryId: string, scope: InstallScope, projectPath?: string | null ): string { if (scope === 'project' || scope === 'local') { return `mcp:${registryId}:${scope}:${getMcpProjectStateKey(projectPath)}`; } return `mcp:${registryId}:${scope}`; } /** * Namespaced lookup key for MCP diagnostics. Scope is included when available * so the same server name can coexist across global/project/local installs. */ export function getMcpDiagnosticKey(name: string, scope?: string | null): string { return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`; } /** * Stable project-aware cache key for MCP installed/diagnostics state. */ export function getMcpProjectStateKey(projectPath?: string | null): string { return projectPath ?? '__global__'; } /** * Check whether a plugin has an installation for the selected scope. */ export function hasInstallationInScope( installations: Pick[], scope: InstallScope ): boolean { return installations.some((installation) => installation.scope === scope); } function summarizeInstallationScopes(scopes: InstallScope[]): string | null { if (scopes.length === 0) { return null; } if (scopes.length > 1) { return `Installed in ${scopes.length} scopes`; } switch (scopes[0]) { case 'global': case 'user': return 'Installed globally'; case 'project': return 'Installed in project'; case 'local': return 'Installed locally'; default: return 'Installed'; } } /** * Build a concise install-status label for plugin badges. */ export function getInstallationSummaryLabel( installations: Pick[] ): string | null { const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); return summarizeInstallationScopes(scopes); } const MCP_SCOPE_PRIORITY: Record = { local: 0, project: 1, global: 2, user: 2, }; /** * Pick the MCP installation entry that Claude will actually use. * Scope precedence matches Claude Code: local > project > user. */ export function getPreferredMcpInstallationEntry( installations: InstalledMcpEntry[] ): InstalledMcpEntry | null { if (installations.length === 0) { return null; } return [...installations].sort( (left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope] )[0]; } /** * Build a concise install-status label for MCP badges. */ export function getMcpInstallationSummaryLabel( installations: Pick[] ): string | null { const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); return summarizeInstallationScopes(scopes); } /** * Install actions require Claude auth, but uninstall only requires a working CLI. */ export function getExtensionActionDisableReason(options: { isInstalled: boolean; cliStatus: Pick< CliInstallationStatus, 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' | 'flavor' | 'providers' > | null; cliStatusLoading: boolean; section?: 'plugins' | 'mcp'; }): string | null { const { isInstalled, cliStatus, cliStatusLoading, section = 'plugins' } = options; if (cliStatus === null) { return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...'; } if (cliStatus.installed === false) { if (cliStatus.binaryPath && cliStatus.launchError) { return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.'; } return 'The configured runtime is required. Install or repair it from the Dashboard.'; } const providers = cliStatus.providers ?? []; const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator'; if (section === 'mcp') { if (!isMultimodel) { return null; } const mutableProviders = providers.filter((provider) => isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'mcp')) ); if (mutableProviders.length > 0) { return null; } const reason = providers .map((provider) => getCliProviderExtensionCapability(provider, 'mcp').reason) .find((value): value is string => typeof value === 'string' && value.trim().length > 0); return reason ?? 'MCP management is not supported by the current runtime.'; } if (!isMultimodel) { if (!isInstalled && !cliStatus.authLoggedIn) { return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'; } return null; } const pluginProviders = providers.filter((provider) => isCliExtensionCapabilityMutable(getCliProviderExtensionCapability(provider, 'plugins')) ); if (pluginProviders.length === 0) { const reason = providers .map((provider) => getCliProviderExtensionCapability(provider, 'plugins').reason) .find((value): value is string => typeof value === 'string' && value.trim().length > 0); return reason ?? 'Plugin installs are not supported by the current runtime.'; } if (isInstalled) { return null; } const authenticatedProvider = pluginProviders.find((provider) => provider.authenticated); if (!authenticatedProvider) { return `${pluginProviders[0]?.displayName ?? 'Anthropic'} is not connected. Open the Dashboard to sign in.`; } return null; } /** * Sanitize an MCP server display name into a CLI-safe server name. * Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService. */ export function sanitizeMcpServerName(displayName: string): string { return displayName .toLowerCase() .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( /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, pathname bounded */ /\/+$/, '' ) .split('/'); if (parts.length < 2 || !parts[0] || !parts[1]) return null; return { owner: parts[0], repo: parts[1] }; } catch { return null; } }