Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
skills-manager-fixed.ts•8 kB
/** * Skills Manager - Loads and manages Anthropic Agent Skills * * Skills are SKILL.md markdown files in ~/.ncp/skills/<skill-name>/ * Each skill contains metadata in YAML frontmatter and prompts for Claude * * This aligns with Anthropic's official skills format from: * https://github.com/anthropics/skills */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { existsSync } from 'fs'; import { logger } from '../utils/logger.js'; export interface SkillMetadata { name: string; description: string; version?: string; author?: string; license?: string; tags?: string[]; tools?: string[]; // Tool names provided by this skill plugin?: string; // Plugin this skill belongs to } export interface LoadedSkill { metadata: SkillMetadata; content: string; // Full SKILL.md content path: string; // Path to SKILL.md file directory: string; // Skill directory } export class SkillsManager { private skillsDir: string; private loadedSkills: Map<string, LoadedSkill> = new Map(); constructor() { this.skillsDir = path.join(os.homedir(), '.ncp', 'skills'); } /** * Initialize skills directory structure */ async initialize(): Promise<void> { await fs.mkdir(this.skillsDir, { recursive: true }); logger.debug(`Skills directory initialized: ${this.skillsDir}`); } /** * Discover and load all skills from ~/.ncp/skills/ * Each skill is in its own directory with a SKILL.md file */ async loadAllSkills(): Promise<LoadedSkill[]> { await this.initialize(); try { const entries = await fs.readdir(this.skillsDir, { withFileTypes: true }); const skillDirs = entries.filter(e => e.isDirectory()); logger.debug(`Found ${skillDirs.length} skill director(ies) in ${this.skillsDir}`); const skills: LoadedSkill[] = []; for (const dir of skillDirs) { try { const skill = await this.loadSkill(dir.name); if (skill) { skills.push(skill); this.loadedSkills.set(skill.metadata.name, skill); } } catch (error: any) { logger.warn(`Failed to load skill from ${dir.name}: ${error.message}`); } } if (skills.length > 0) { logger.info(`📚 Loaded ${skills.length} Anthropic Agent Skill(s)`); } return skills; } catch (error: any) { logger.error(`Failed to read skills directory: ${error.message}`); return []; } } /** * Load a single skill from its directory * Expects: ~/.ncp/skills/<skill-name>/SKILL.md */ async loadSkill(skillDirName: string): Promise<LoadedSkill | null> { const skillDir = path.join(this.skillsDir, skillDirName); const skillFile = path.join(skillDir, 'SKILL.md'); if (!existsSync(skillFile)) { logger.debug(`No SKILL.md found in ${skillDirName}, skipping`); return null; } logger.debug(`Loading skill: ${skillDirName}`); // Read SKILL.md content const content = await fs.readFile(skillFile, 'utf-8'); // Parse YAML frontmatter const metadata = this.parseSkillMetadata(content, skillDirName); if (!metadata) { logger.warn(`Invalid skill metadata in ${skillDirName}/SKILL.md`); return null; } logger.info(` ✓ ${metadata.name}${metadata.description ? ': ' + metadata.description : ''}`); return { metadata, content, path: skillFile, directory: skillDir }; } /** * Parse SKILL.md YAML frontmatter * Format: * --- * name: skill-name * description: Description text * version: 1.0.0 * --- */ private parseSkillMetadata(content: string, fallbackName: string): SkillMetadata | null { try { // Extract YAML frontmatter (between --- markers) const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); if (!frontmatterMatch) { logger.debug(`No frontmatter found in skill, using defaults`); return { name: fallbackName, description: '' }; } const frontmatter = frontmatterMatch[1]; const metadata: Partial<SkillMetadata> = {}; // Simple YAML parser (handles key: value pairs and arrays) const lines = frontmatter.split('\n'); for (const line of lines) { // Handle key: value const match = line.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/); if (match) { const [, key, value] = match; const trimmedValue = value.trim(); // Remove quotes if present const unquotedValue = trimmedValue.replace(/^["']|["']$/g, ''); (metadata as any)[key] = unquotedValue; } } // Use directory name if no name in metadata if (!metadata.name) { metadata.name = fallbackName; } return metadata as SkillMetadata; } catch (error: any) { logger.error(`Failed to parse skill metadata: ${error.message}`); return null; } } /** * Get all loaded skills */ getLoadedSkills(): LoadedSkill[] { return Array.from(this.loadedSkills.values()); } /** * Get a specific skill by name */ getSkill(name: string): LoadedSkill | undefined { return this.loadedSkills.get(name); } /** * Get skill content for inclusion in prompts * This is how skills work - they're injected into Claude's context */ getSkillPrompt(skillName: string): string | null { const skill = this.loadedSkills.get(skillName); if (!skill) { return null; } return skill.content; } /** * List all available skills with their tools */ listSkills(): Array<{ name: string; description: string; plugin?: string }> { return Array.from(this.loadedSkills.values()).map(skill => ({ name: skill.metadata.name, description: skill.metadata.description, plugin: skill.metadata.plugin })); } /** * Remove a skill */ async removeSkill(skillName: string): Promise<void> { const skill = this.loadedSkills.get(skillName); if (!skill) { throw new Error(`Skill not found: ${skillName}`); } // Remove skill directory await fs.rm(skill.directory, { recursive: true, force: true }); // Remove from loaded skills this.loadedSkills.delete(skillName); logger.info(`Removed skill: ${skillName}`); } /** * Install a skill from a SKILL.md file * This creates the directory structure expected by loadSkill() */ async installSkillFromFile(sourcePath: string, skillName?: string): Promise<LoadedSkill> { // Read SKILL.md content const content = await fs.readFile(sourcePath, 'utf-8'); // Parse metadata to get skill name const metadata = this.parseSkillMetadata(content, skillName || path.basename(path.dirname(sourcePath))); if (!metadata) { throw new Error('Invalid SKILL.md format'); } // Create skill directory const skillDir = path.join(this.skillsDir, metadata.name); await fs.mkdir(skillDir, { recursive: true }); // Copy SKILL.md to skill directory const destPath = path.join(skillDir, 'SKILL.md'); await fs.writeFile(destPath, content, 'utf-8'); // Load the skill const skill = await this.loadSkill(metadata.name); if (!skill) { throw new Error('Failed to load installed skill'); } this.loadedSkills.set(skill.metadata.name, skill); logger.info(`Installed skill: ${skill.metadata.name}`); return skill; } /** * Get all tool names across all loaded skills * Note: Anthropic skills don't provide executable tools, * they provide context/prompts that enhance Claude's capabilities */ getAllSkillTools(): string[] { const tools: string[] = []; for (const skill of this.loadedSkills.values()) { if (skill.metadata.tools) { for (const tool of skill.metadata.tools) { tools.push(`${skill.metadata.name}:${tool}`); } } } return tools; } }

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