Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
skills-marketplace-client.ts15.1 kB
/** * Skills Marketplace Client - Manage Anthropic Agent Skills marketplaces * * Integrates with the official anthropics/skills repository and * user-defined marketplaces to discover and install Agent Skills */ import * as fs from 'fs/promises'; import * as path from 'path'; import { existsSync } from 'fs'; import { logger } from '../utils/logger.js'; import { getNcpBaseDirectory } from '../utils/ncp-paths.js'; export interface SkillsMarketplace { name: string; repo: string; // For GitHub sources url: string; // Base URL for fetching sourceType: 'github' | 'git-ssh' | 'url' | 'local'; source: string; // Original input (for display) enabled: boolean; lastUpdated?: string; } export interface SkillsMarketplaceConfig { marketplaces: SkillsMarketplace[]; } /** * Skill metadata from SKILL.md YAML frontmatter */ export interface SkillMetadata { name: string; description: string; license?: string; version?: string; author?: string; tags?: string[]; tools?: string[]; plugin?: string; // Plugin name this skill belongs to (example-skills, document-skills) } /** * Claude Code Plugin from marketplace.json */ export interface ClaudePlugin { name: string; description: string; source: string; strict: boolean; skills: string[]; // Paths to SKILL.md files } /** * Marketplace manifest (.claude-plugin/marketplace.json) */ export interface SkillsMarketplaceManifest { name: string; owner?: { name: string; email?: string; }; metadata?: { version: string; description: string; }; plugins: ClaudePlugin[]; } // Helper functions to get directories (respects getNcpBaseDirectory for flexibility) function getConfigDir(): string { return getNcpBaseDirectory(); } function getConfigFile(): string { return path.join(getConfigDir(), 'skills-marketplaces.json'); } function getCacheDir(): string { return path.join(getConfigDir(), '.cache', 'skills-marketplaces'); } function getSkillsDir(): string { return path.join(getConfigDir(), 'skills'); } // Cache is considered stale after 24 hours const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_MARKETPLACE: SkillsMarketplace = { name: 'anthropic-skills', repo: 'anthropics/skills', url: 'https://raw.githubusercontent.com/anthropics/skills/main', sourceType: 'github', source: 'anthropics/skills', enabled: true, }; export class SkillsMarketplaceClient { private config: SkillsMarketplaceConfig = { marketplaces: [] }; async initialize() { await fs.mkdir(getConfigDir(), { recursive: true }); await fs.mkdir(getCacheDir(), { recursive: true }); await fs.mkdir(getSkillsDir(), { recursive: true }); if (existsSync(getConfigFile())) { const data = await fs.readFile(getConfigFile(), 'utf-8'); this.config = JSON.parse(data); } else { // Initialize with default marketplace this.config = { marketplaces: [DEFAULT_MARKETPLACE], }; await this.save(); } } async save() { await fs.writeFile(getConfigFile(), JSON.stringify(this.config, null, 2), 'utf-8'); } /** * Get all marketplaces */ getAll(): SkillsMarketplace[] { return this.config.marketplaces; } /** * Get enabled marketplaces */ getEnabled(): SkillsMarketplace[] { return this.config.marketplaces.filter((m) => m.enabled); } /** * Add a new marketplace */ async addMarketplace(source: string): Promise<SkillsMarketplace> { const name = this.extractMarketplaceName(source); const sourceType = this.detectSourceType(source); const url = this.buildUrl(source, sourceType); const marketplace: SkillsMarketplace = { name, repo: sourceType === 'github' ? source : '', url, sourceType, source, enabled: true, lastUpdated: new Date().toISOString() }; // Check if marketplace already exists const existing = this.config.marketplaces.find(m => m.name === name); if (!existing) { this.config.marketplaces.push(marketplace); await this.save(); } return marketplace; } /** * Remove a marketplace */ async removeMarketplace(name: string): Promise<boolean> { const index = this.config.marketplaces.findIndex(m => m.name === name); if (index === -1) { return false; } this.config.marketplaces.splice(index, 1); await this.save(); return true; } /** * Detect source type from a string * Supports: * - GitHub shorthand: username/repo * - GitHub HTTPS: https://github.com/username/repo[.git] * - GitHub SSH: git@github.com:username/repo.git * - Direct URL: https://example.com/skills.json * - Local path: ./path or /absolute/path */ private detectSourceType(source: string): 'github' | 'git-ssh' | 'url' | 'local' { if (source.startsWith('http://') || source.startsWith('https://')) { return 'url'; } else if (source.startsWith('/') || source.startsWith('.')) { return 'local'; } else if (source.startsWith('git@') || source.startsWith('ssh://')) { return 'git-ssh'; } else { // Assume GitHub format: username/repo return 'github'; } } /** * Extract marketplace name from source */ private extractMarketplaceName(source: string): string { if (source.includes('/')) { const parts = source.split('/'); return parts[parts.length - 1].replace('.git', '').toLowerCase(); } return source.toLowerCase(); } /** * Build URL from source based on type */ private buildUrl(source: string, sourceType: 'github' | 'git-ssh' | 'url' | 'local'): string { switch (sourceType) { case 'github': return `https://raw.githubusercontent.com/${source}/main`; case 'git-ssh': { // Convert SSH URL to HTTPS // git@github.com:username/repo.git -> https://raw.githubusercontent.com/username/repo/main const sshMatch = source.match(/^git@github\.com:([a-zA-Z0-9-]+)\/([a-zA-Z0-9-_.]+?)(\.git)?$/); if (sshMatch) { const [, username, repo] = sshMatch; const repoName = repo.replace(/\.git$/, ''); return `https://raw.githubusercontent.com/${username}/${repoName}/main`; } // Fallback for ssh:// URLs const sshUrlMatch = source.match(/^ssh:\/\/git@github\.com\/([a-zA-Z0-9-]+)\/([a-zA-Z0-9-_.]+?)(\.git)?$/); if (sshUrlMatch) { const [, username, repo] = sshUrlMatch; const repoName = repo.replace(/\.git$/, ''); return `https://raw.githubusercontent.com/${username}/${repoName}/main`; } return source; } case 'url': return source.endsWith('/') ? source.slice(0, -1) : source; case 'local': return source; default: return source; } } /** * Fetch marketplace manifest from remote source */ async fetchManifest(marketplace: SkillsMarketplace): Promise<SkillsMarketplaceManifest | null> { try { const manifestUrl = `${marketplace.url}/.claude-plugin/marketplace.json`; logger.debug(`Fetching skills manifest from: ${manifestUrl}`); const response = await fetch(manifestUrl); if (!response.ok) { logger.warn(`Failed to fetch manifest from ${manifestUrl}: ${response.status}`); return null; } const manifest: SkillsMarketplaceManifest = await response.json(); // Cache the manifest const cacheFile = path.join(getCacheDir(), `${marketplace.name}-manifest.json`); await fs.writeFile(cacheFile, JSON.stringify(manifest, null, 2), 'utf-8'); return manifest; } catch (error: any) { logger.error(`Failed to fetch manifest from ${marketplace.name}: ${error.message}`); // Try to load from cache try { const cacheFile = path.join(getCacheDir(), `${marketplace.name}-manifest.json`); if (existsSync(cacheFile)) { logger.debug(`Using cached manifest for ${marketplace.name}`); const cached = await fs.readFile(cacheFile, 'utf-8'); return JSON.parse(cached); } } catch { // Cache also failed } return null; } } /** * Fetch SKILL.md content from marketplace */ async fetchSkillContent(marketplace: SkillsMarketplace, skillPath: string): Promise<string | null> { try { const skillUrl = `${marketplace.url}/${skillPath}/SKILL.md`; logger.debug(`Fetching skill from: ${skillUrl}`); const response = await fetch(skillUrl); if (!response.ok) { logger.warn(`Failed to fetch skill from ${skillUrl}: ${response.status}`); return null; } return await response.text(); } catch (error: any) { logger.error(`Failed to fetch skill ${skillPath}: ${error.message}`); return null; } } /** * Parse SKILL.md YAML frontmatter */ parseSkillMetadata(content: string, skillPath: string): SkillMetadata | null { try { // Extract YAML frontmatter (between --- markers) const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); if (!frontmatterMatch) { logger.warn(`No frontmatter found in skill: ${skillPath}`); return null; } const frontmatter = frontmatterMatch[1]; const metadata: Partial<SkillMetadata> = {}; // Simple YAML parser (only handles key: value pairs) const lines = frontmatter.split('\n'); for (const line of lines) { const match = line.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/); if (match) { const [, key, value] = match; const trimmedValue = value.trim(); // Store as string (arrays would need more complex YAML parsing) (metadata as any)[key] = trimmedValue; } } if (!metadata.name) { logger.warn(`Skill missing required 'name' field: ${skillPath}`); return null; } return metadata as SkillMetadata; } catch (error: any) { logger.error(`Failed to parse skill metadata: ${error.message}`); return null; } } /** * Search for skills across all enabled marketplaces */ async search(query?: string): Promise<SkillMetadata[]> { const enabledMarketplaces = this.getEnabled(); const allSkills: SkillMetadata[] = []; for (const marketplace of enabledMarketplaces) { const manifest = await this.fetchManifest(marketplace); if (!manifest) continue; // Iterate through all plugins for (const plugin of manifest.plugins) { // Determine skill paths - use explicit list or auto-discover from plugin source const skillPaths = plugin.skills && Array.isArray(plugin.skills) ? plugin.skills : [plugin.source]; // Iterate through all skills in the plugin for (const skillPath of skillPaths) { const content = await this.fetchSkillContent(marketplace, skillPath); if (!content) continue; const metadata = this.parseSkillMetadata(content, skillPath); if (!metadata) continue; // Add plugin name for context metadata.plugin = plugin.name; // Filter by query if provided if (query) { const searchText = `${metadata.name} ${metadata.description}`.toLowerCase(); if (!searchText.includes(query.toLowerCase())) { continue; } } allSkills.push(metadata); } } } return allSkills; } /** * Install a skill from marketplace */ async install(skillName: string): Promise<{ success: boolean; message: string; skillPath?: string }> { try { const enabledMarketplaces = this.getEnabled(); for (const marketplace of enabledMarketplaces) { const manifest = await this.fetchManifest(marketplace); if (!manifest) continue; // Find the skill in the manifest for (const plugin of manifest.plugins) { // Determine skill paths - use explicit list or auto-discover from plugin source const skillPaths = plugin.skills && Array.isArray(plugin.skills) ? plugin.skills : [plugin.source]; for (const skillPath of skillPaths) { const content = await this.fetchSkillContent(marketplace, skillPath); if (!content) continue; const metadata = this.parseSkillMetadata(content, skillPath); if (!metadata || metadata.name !== skillName) continue; // Found the skill! Install it const skillDir = path.join(getSkillsDir(), skillName); await fs.mkdir(skillDir, { recursive: true }); const skillFile = path.join(skillDir, 'SKILL.md'); await fs.writeFile(skillFile, content, 'utf-8'); logger.info(`Installed skill: ${skillName} from ${marketplace.name}`); return { success: true, message: `Successfully installed ${skillName} from ${marketplace.name}`, skillPath: skillFile }; } } } return { success: false, message: `Skill '${skillName}' not found in any enabled marketplace` }; } catch (error: any) { logger.error(`Failed to install skill ${skillName}: ${error.message}`); return { success: false, message: `Failed to install ${skillName}: ${error.message}` }; } } /** * List installed skills from ~/.ncp/skills/ */ async listInstalled(): Promise<SkillMetadata[]> { try { const installed: SkillMetadata[] = []; const entries = await fs.readdir(getSkillsDir(), { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const skillFile = path.join(getSkillsDir(), entry.name, 'SKILL.md'); if (!existsSync(skillFile)) continue; const content = await fs.readFile(skillFile, 'utf-8'); const metadata = this.parseSkillMetadata(content, entry.name); if (metadata) { installed.push(metadata); } } return installed; } catch (error: any) { logger.error(`Failed to list installed skills: ${error.message}`); return []; } } /** * Remove an installed skill */ async remove(skillName: string): Promise<{ success: boolean; message: string }> { try { const skillDir = path.join(getSkillsDir(), skillName); if (!existsSync(skillDir)) { return { success: false, message: `Skill '${skillName}' is not installed` }; } await fs.rm(skillDir, { recursive: true, force: true }); logger.info(`Removed skill: ${skillName}`); return { success: true, message: `Successfully removed ${skillName}` }; } catch (error: any) { logger.error(`Failed to remove skill ${skillName}: ${error.message}`); return { success: false, message: `Failed to remove ${skillName}: ${error.message}` }; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/portel-dev/ncp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server