336 lines
9.6 KiB
TypeScript
336 lines
9.6 KiB
TypeScript
/**
|
|
* 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<PluginCapability, string> = {
|
|
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<InstalledPluginEntry, 'scope'>[],
|
|
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<InstalledPluginEntry, 'scope'>[]
|
|
): string | null {
|
|
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
|
return summarizeInstallationScopes(scopes);
|
|
}
|
|
|
|
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
|
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<InstalledMcpEntry, 'scope'>[]
|
|
): 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;
|
|
}
|
|
}
|