agent-ecosystem/src/main/services/extensions/state/PluginInstallationStateService.ts
iliya 126f8e2865 feat: add Extension Store with plugin catalog and MCP registry
Full Extension Store implementation (Phases 0-6):
- Plugin marketplace catalog with ETag caching and search/filter/sort
- MCP server registry with Official + Glama aggregation
- Install/uninstall flows for both plugins and MCP servers via CLI
- Per-tab UI state, skeleton loading, dashed empty states, card polish
- Input validation and security hardening (scope allowlists, env/header
  key regex, projectPath validation, HTTP body size limits)
- 8 test suites covering catalog, install, aggregation, normalizers
2026-03-08 01:00:18 +02:00

178 lines
5.8 KiB
TypeScript

/**
* Reads plugin installed state and install counts from the filesystem.
*
* Sources:
* - Installed state: ~/.claude/plugins/installed_plugins.json
* - Install counts: ~/.claude/plugins/install-counts-cache.json
*
* Both files are managed by the Claude CLI. This service is read-only.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createLogger } from '@shared/utils/logger';
import type { InstalledPluginEntry } from '@shared/types/extensions';
import type { InstallScope } from '@shared/types/extensions';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
const logger = createLogger('Extensions:PluginState');
// ── Constants ──────────────────────────────────────────────────────────────
const INSTALLED_STATE_TTL_MS = 10_000; // 10 seconds
const INSTALL_COUNTS_TTL_MS = 5 * 60_000; // 5 minutes
// ── Raw file shapes ────────────────────────────────────────────────────────
interface InstalledPluginsJson {
version: number;
plugins: Record<
string, // qualifiedName
Array<{
scope: string;
installPath?: string;
version?: string;
installedAt?: string;
lastUpdated?: string;
gitCommitSha?: string;
}>
>;
}
interface InstallCountsJson {
version: number;
fetchedAt: string;
counts: Array<{
plugin: string; // qualifiedName format
unique_installs: number;
}>;
}
// ── Cache ──────────────────────────────────────────────────────────────────
interface TimedCache<T> {
data: T;
fetchedAt: number;
}
// ── Service ────────────────────────────────────────────────────────────────
export class PluginInstallationStateService {
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null;
private countsCache: TimedCache<Map<string, number>> | null = null;
/**
* Get all installed plugins across all scopes.
* Returns merged list from installed_plugins.json with scope tags.
*/
async getInstalledPlugins(_projectPath?: string): Promise<InstalledPluginEntry[]> {
if (
this.installedCache &&
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS
) {
return this.installedCache.data;
}
const entries = await this.readInstalledPlugins();
this.installedCache = { data: entries, fetchedAt: Date.now() };
return entries;
}
/**
* Get install counts keyed by pluginId (qualifiedName).
*/
async getInstallCounts(): Promise<Map<string, number>> {
if (this.countsCache && Date.now() - this.countsCache.fetchedAt < INSTALL_COUNTS_TTL_MS) {
return this.countsCache.data;
}
const counts = await this.readInstallCounts();
this.countsCache = { data: counts, fetchedAt: Date.now() };
return counts;
}
/**
* Invalidate all caches. Call after install/uninstall operations.
*/
invalidateCache(): void {
this.installedCache = null;
this.countsCache = null;
}
// ── Private ────────────────────────────────────────────────────────────
private getPluginsDir(): string {
return path.join(getClaudeBasePath(), 'plugins');
}
private async readInstalledPlugins(): Promise<InstalledPluginEntry[]> {
const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as InstalledPluginsJson;
if (json.version !== 2 || !json.plugins) {
logger.warn(`Unexpected installed_plugins.json version: ${json.version}`);
return [];
}
const entries: InstalledPluginEntry[] = [];
for (const [qualifiedName, installations] of Object.entries(json.plugins)) {
for (const inst of installations) {
entries.push({
pluginId: qualifiedName,
scope: this.normalizeScope(inst.scope),
version: inst.version,
installedAt: inst.installedAt,
installPath: inst.installPath,
});
}
}
return entries;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return []; // No plugins installed yet
}
logger.error('Failed to read installed_plugins.json:', err);
return [];
}
}
private async readInstallCounts(): Promise<Map<string, number>> {
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as InstallCountsJson;
const map = new Map<string, number>();
if (json.counts && Array.isArray(json.counts)) {
for (const entry of json.counts) {
// Install counts use qualifiedName format (name@marketplace)
map.set(entry.plugin, entry.unique_installs);
}
}
return map;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return new Map();
}
logger.error('Failed to read install-counts-cache.json:', err);
return new Map();
}
}
private normalizeScope(raw: string): InstallScope {
const lower = raw.toLowerCase();
if (lower === 'user' || lower === 'project' || lower === 'local') {
return lower;
}
return 'user'; // safe default
}
}