@mastra/core
Version:
1 lines • 105 kB
Source Map (JSON)
{"version":3,"file":"workspace-skills-CjnfyQuP.cjs","names":["fs","FileNotFoundError","#basePath","#resolvePath","fs","#source","#skillsResolver","#searchEngine","#validateOnLoad","#checkSkillFileMtime","#ensureInitialized","#skills","#dedupeCanonicalCandidates","#resolveByName","#resolveByPath","#tieBreak","#getCanonicalSkillPath","#removeSkillFromIndex","#initialized","#initPromise","#discoverSkills","#resolvePaths","#arePathsEqual","#resolvedPaths","#isSkillsPathStale","#getParentPath","#joinPath","#inferSource","#parseSkillFile","#indexSkill","#lastDiscoveryTime","#simpleSearch","#assertRelativePath","#globDirCache","#globResolveTimes","#determineSource","#discoverDirectSkill","#discoverSkillsInPath","#addToSkillsMap","#validateSkillMetadata","#discoverFilesInSubdir","#buildIndexableContent","#walkDirectory"],"sources":["../src/workspace/filesystem/fs-utils.ts","../src/workspace/glob.ts","../src/workspace/skills/schemas.ts","../src/workspace/skills/local-skill-source.ts","../src/workspace/skills/workspace-skills.ts"],"sourcesContent":["/**\n * Shared filesystem utilities for LocalFilesystem and LocalSkillSource.\n *\n * These utilities provide consistent implementations for common fs operations.\n */\n\nimport * as fs from 'node:fs/promises';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\n\nimport { FileNotFoundError } from '../errors';\n\n// =============================================================================\n// Tilde Expansion\n// =============================================================================\n\n/**\n * Expand a leading `~` or `~/` to the user's home directory.\n * Shell commands handle this automatically, but Node.js path APIs do not.\n */\nexport function expandTilde(p: string): string {\n if (p === '~') return os.homedir();\n if (p.startsWith('~/') || p.startsWith('~\\\\')) {\n return path.join(os.homedir(), p.slice(2));\n }\n return p;\n}\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/**\n * Full file stat information.\n * Used by both WorkspaceFilesystem and SkillSource.\n */\nexport interface FsStatResult {\n /** File or directory name */\n name: string;\n /** 'file' or 'directory' */\n type: 'file' | 'directory';\n /** Size in bytes (0 for directories) */\n size: number;\n /** Creation time */\n createdAt: Date;\n /** Last modification time */\n modifiedAt: Date;\n /** MIME type (for files) */\n mimeType?: string;\n}\n\n// =============================================================================\n// Error Utilities\n// =============================================================================\n\n/**\n * Check if an error is an ENOENT (file not found) error.\n */\nexport function isEnoentError(error: unknown): error is NodeJS.ErrnoException & { code: 'ENOENT' } {\n return (\n error !== null && typeof error === 'object' && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'\n );\n}\n\n/**\n * Check if an error is an EEXIST (file exists) error.\n */\nexport function isEexistError(error: unknown): error is NodeJS.ErrnoException & { code: 'EEXIST' } {\n return (\n error !== null && typeof error === 'object' && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST'\n );\n}\n\n// =============================================================================\n// MIME Type Detection\n// =============================================================================\n\nconst MIME_TYPES: Record<string, string> = {\n // Text\n txt: 'text/plain',\n html: 'text/html',\n htm: 'text/html',\n css: 'text/css',\n csv: 'text/csv',\n md: 'text/markdown',\n // Code\n js: 'application/javascript',\n mjs: 'application/javascript',\n ts: 'application/typescript',\n tsx: 'application/typescript',\n jsx: 'application/javascript',\n json: 'application/json',\n xml: 'application/xml',\n yaml: 'text/yaml',\n yml: 'text/yaml',\n // Programming languages\n py: 'text/x-python',\n rb: 'text/x-ruby',\n go: 'text/x-go',\n rs: 'text/x-rust',\n java: 'text/x-java',\n c: 'text/x-c',\n cpp: 'text/x-c++',\n h: 'text/x-c',\n hpp: 'text/x-c++',\n sh: 'text/x-sh',\n bash: 'text/x-sh',\n zsh: 'text/x-sh',\n // Config\n toml: 'text/toml',\n ini: 'text/plain',\n env: 'text/plain',\n // Database/Query\n sql: 'text/x-sql',\n graphql: 'application/graphql',\n gql: 'application/graphql',\n // Frameworks\n vue: 'text/x-vue',\n svelte: 'text/x-svelte',\n // Web styles\n scss: 'text/x-scss',\n sass: 'text/x-sass',\n less: 'text/x-less',\n // Additional languages\n php: 'application/x-php',\n swift: 'text/x-swift',\n kt: 'text/x-kotlin',\n kts: 'text/x-kotlin',\n dart: 'application/dart',\n lua: 'text/x-lua',\n r: 'text/x-r',\n tf: 'text/x-terraform',\n tfvars: 'text/x-terraform',\n mdx: 'text/markdown',\n // Images\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n gif: 'image/gif',\n svg: 'image/svg+xml',\n webp: 'image/webp',\n ico: 'image/x-icon',\n bmp: 'image/bmp',\n tiff: 'image/tiff',\n tif: 'image/tiff',\n heic: 'image/heic',\n heif: 'image/heif',\n avif: 'image/avif',\n // Documents\n pdf: 'application/pdf',\n // Audio\n mp3: 'audio/mpeg',\n wav: 'audio/wav',\n ogg: 'audio/ogg',\n flac: 'audio/flac',\n m4a: 'audio/mp4',\n aac: 'audio/aac',\n // Video\n mp4: 'video/mp4',\n webm: 'video/webm',\n mov: 'video/quicktime',\n avi: 'video/x-msvideo',\n mkv: 'video/x-matroska',\n // Archives\n zip: 'application/zip',\n tar: 'application/x-tar',\n gz: 'application/gzip',\n tgz: 'application/gzip',\n bz2: 'application/x-bzip2',\n '7z': 'application/x-7z-compressed',\n rar: 'application/vnd.rar',\n // Executables / binaries\n exe: 'application/vnd.microsoft.portable-executable',\n dll: 'application/vnd.microsoft.portable-executable',\n so: 'application/x-sharedlib',\n dylib: 'application/x-sharedlib',\n bin: 'application/x-binary',\n dat: 'application/x-binary',\n // Disk images / packages\n dmg: 'application/x-apple-diskimage',\n iso: 'application/x-iso9660-image',\n deb: 'application/vnd.debian.binary-package',\n rpm: 'application/x-rpm',\n // Office documents\n doc: 'application/msword',\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n xls: 'application/vnd.ms-excel',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n ppt: 'application/vnd.ms-powerpoint',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n // Fonts\n ttf: 'font/ttf',\n otf: 'font/otf',\n woff: 'font/woff',\n woff2: 'font/woff2',\n // Compiled code\n wasm: 'application/wasm',\n class: 'application/java-vm',\n pyc: 'application/x-python-code',\n};\n\n/**\n * Get MIME type for a filename based on extension.\n */\nexport function getMimeType(filename: string): string {\n const ext = path.extname(filename).slice(1).toLowerCase();\n return MIME_TYPES[ext] ?? 'application/octet-stream';\n}\n\n/**\n * Extensions that should be treated as text files.\n */\nconst TEXT_EXTENSIONS = new Set([\n '.md',\n '.txt',\n '.json',\n '.yaml',\n '.yml',\n '.js',\n '.mjs',\n '.ts',\n '.tsx',\n '.jsx',\n '.py',\n '.rb',\n '.go',\n '.rs',\n '.java',\n '.c',\n '.cpp',\n '.h',\n '.hpp',\n '.sh',\n '.bash',\n '.zsh',\n '.html',\n '.htm',\n '.css',\n '.xml',\n '.toml',\n '.ini',\n '.env',\n '.csv',\n '.sql',\n '.graphql',\n '.gql',\n '.vue',\n '.svg',\n '.mdx',\n '.scss',\n '.sass',\n '.less',\n '.svelte',\n '.php',\n '.swift',\n '.kt',\n '.kts',\n '.dart',\n '.lua',\n '.r',\n '.tf',\n '.tfvars',\n]);\n\n/**\n * Check if a file should be treated as text based on extension.\n */\nexport function isTextFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase();\n return TEXT_EXTENSIONS.has(ext);\n}\n\n// =============================================================================\n// Path Resolution\n// =============================================================================\n\n/**\n * Resolve a path against a base directory.\n *\n * - Tilde (`~`) is expanded to the user's home directory.\n * - Absolute paths are normalized and returned as-is.\n * - Relative paths (including `../`) are resolved against `basePath`.\n *\n * @param basePath - The absolute base path to resolve against\n * @param filePath - The path to resolve\n * @returns The absolute resolved path\n */\nexport function resolveToBasePath(basePath: string, filePath: string): string {\n const expanded = expandTilde(filePath);\n if (path.isAbsolute(expanded)) {\n return path.normalize(expanded);\n }\n return path.resolve(basePath, expanded);\n}\n\n// =============================================================================\n// Filesystem Operations\n// =============================================================================\n\n/**\n * Check if a path exists.\n * Never throws - returns false on any error.\n *\n * @param absolutePath - The absolute path to check\n * @returns true if path exists and is accessible\n */\nexport async function fsExists(absolutePath: string): Promise<boolean> {\n try {\n await fs.access(absolutePath);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get file/directory stats.\n * Throws FileNotFoundError if path doesn't exist.\n *\n * @param absolutePath - The absolute path to stat\n * @param userPath - The user-facing path for error messages\n * @returns File stat information\n * @throws {FileNotFoundError} if path doesn't exist\n */\nexport async function fsStat(absolutePath: string, userPath: string): Promise<FsStatResult> {\n try {\n const stats = await fs.stat(absolutePath);\n return {\n name: path.basename(absolutePath),\n type: stats.isDirectory() ? 'directory' : 'file',\n size: stats.size,\n createdAt: stats.birthtime,\n modifiedAt: stats.mtime,\n mimeType: stats.isFile() ? getMimeType(absolutePath) : undefined,\n };\n } catch (error: unknown) {\n if (isEnoentError(error)) {\n throw new FileNotFoundError(userPath);\n }\n throw error;\n }\n}\n","/**\n * Glob Pattern Utilities\n *\n * Shared glob pattern matching for workspace operations.\n * Uses picomatch for battle-tested glob support including\n * brace expansion, character classes, negation, and `**`.\n */\n\nimport picomatch from 'picomatch';\n\n// =============================================================================\n// Glob Metacharacter Detection\n// =============================================================================\n\n/** Characters that indicate a glob pattern (not a plain path) */\nconst GLOB_CHARS = /[*?{}[\\]]/;\n\n/**\n * Check if a string contains glob metacharacters.\n *\n * @example\n * isGlobPattern('/docs') // false\n * isGlobPattern('/docs/**\\/*.md') // true\n * isGlobPattern('*.ts') // true\n * isGlobPattern('/src/{a,b}') // true\n */\nexport function isGlobPattern(input: string): boolean {\n return GLOB_CHARS.test(input);\n}\n\n// =============================================================================\n// Glob Base Extraction\n// =============================================================================\n\n/**\n * Extract the static directory prefix before the first glob metacharacter.\n * Returns the deepest non-glob ancestor directory.\n *\n * @example\n * extractGlobBase('docs/**\\/*.md') // 'docs'\n * extractGlobBase('**\\/*.md') // '.'\n * extractGlobBase('src/*.ts') // 'src'\n * extractGlobBase('exact/path') // 'exact/path'\n */\nexport function extractGlobBase(pattern: string): string {\n // Find position of first glob metacharacter\n const firstMeta = pattern.search(GLOB_CHARS);\n\n if (firstMeta === -1) {\n // No glob chars — return the pattern as-is (it's a plain path)\n return pattern;\n }\n\n // Get the portion before the first metacharacter\n const prefix = pattern.slice(0, firstMeta);\n\n // Walk back to the last directory separator\n const lastSlash = prefix.lastIndexOf('/');\n\n if (lastSlash <= 0) {\n // No slash or only root slash — base is workspace root\n return '.';\n }\n\n return prefix.slice(0, lastSlash);\n}\n\n// =============================================================================\n// Glob Matcher\n// =============================================================================\n\n/** A compiled matcher function: returns true if a path matches */\nexport type GlobMatcher = (path: string) => boolean;\n\nexport interface GlobMatcherOptions {\n /** Match dotfiles (default: false) */\n dot?: boolean;\n}\n\n/**\n * Strip leading './' or '/' from a path for picomatch matching.\n * picomatch does not match paths with these prefixes, so both\n * patterns and test paths must be normalized before matching.\n *\n * This only affects matching — filesystem paths should keep their\n * original form for correct resolution with contained/uncontained modes.\n */\nfunction normalizeForMatch(input: string): string {\n if (input.startsWith('./')) return input.slice(2);\n if (input.startsWith('/')) return input.slice(1);\n return input;\n}\n\n/**\n * Compile glob pattern(s) into a reusable matcher function.\n * The matcher tests paths using workspace-style forward slashes.\n *\n * Automatically normalizes leading './' and '/' from both patterns\n * and test paths, since picomatch does not match these prefixes.\n *\n * @example\n * const match = createGlobMatcher('**\\/*.ts');\n * match('src/index.ts') // true\n * match('src/style.css') // false\n *\n * const multi = createGlobMatcher(['**\\/*.ts', '**\\/*.tsx']);\n * multi('App.tsx') // true\n */\nexport function createGlobMatcher(patterns: string | string[], options?: GlobMatcherOptions): GlobMatcher {\n const patternArray = (Array.isArray(patterns) ? patterns : [patterns]).map(normalizeForMatch);\n const matcher = picomatch(patternArray, {\n posix: true,\n dot: options?.dot ?? false,\n });\n return (path: string) => matcher(normalizeForMatch(path));\n}\n\n/**\n * One-off convenience: test if a path matches a glob pattern.\n *\n * For repeated matching against the same pattern, prefer createGlobMatcher()\n * to compile once and reuse.\n *\n * @example\n * matchGlob('src/index.ts', '**\\/*.ts') // true\n */\nexport function matchGlob(path: string, pattern: string | string[], options?: GlobMatcherOptions): boolean {\n return createGlobMatcher(pattern, options)(path);\n}\n\n// =============================================================================\n// Path Pattern Resolution\n// =============================================================================\n\n/** A filesystem entry returned by resolvePathPattern */\nexport interface PathEntry {\n path: string;\n type: 'file' | 'directory';\n}\n\n/** Minimal readdir entry — compatible with both FileEntry and SkillSourceEntry */\nexport interface ReaddirEntry {\n name: string;\n type: 'file' | 'directory';\n isSymlink?: boolean;\n}\n\nexport interface ResolvePathOptions {\n /** Match dotfiles (default: false) */\n dot?: boolean;\n /** Maximum directory depth to walk (default: 10) */\n maxDepth?: number;\n}\n\n/**\n * Walk a directory tree recursively, returning all entries (files and directories).\n * Skips symlinked directories to prevent infinite loops.\n */\nasync function walkAll(\n readdir: (dir: string) => Promise<ReaddirEntry[]>,\n dir: string,\n depth: number,\n maxDepth: number,\n): Promise<PathEntry[]> {\n if (depth >= maxDepth) return [];\n try {\n const entries = await readdir(dir);\n const results: PathEntry[] = [];\n for (const entry of entries) {\n if (entry.type === 'directory' && entry.isSymlink) continue;\n const fullPath = dir === '.' || dir === '' ? entry.name : `${dir}/${entry.name}`;\n results.push({ path: fullPath, type: entry.type });\n if (entry.type === 'directory') {\n results.push(...(await walkAll(readdir, fullPath, depth + 1, maxDepth)));\n }\n }\n return results;\n } catch {\n return [];\n }\n}\n\n/**\n * Resolve a path pattern to matching filesystem entries.\n *\n * Handles both plain paths and glob patterns consistently:\n * - Plain paths: determines file vs directory via readdir probe, returns single entry\n * - Glob patterns: walks from the glob base, matches both files and directories\n *\n * @example\n * // Plain paths\n * resolvePathPattern('/docs', readdir) // [{ path: '/docs', type: 'directory' }]\n * resolvePathPattern('/docs/readme.md', readdir) // [{ path: '/docs/readme.md', type: 'file' }]\n *\n * // Glob patterns — matches files and directories\n * resolvePathPattern('/docs/**\\/*.md', readdir) // all .md files under /docs\n * resolvePathPattern('**\\/skills', readdir) // all directories (and files) named 'skills'\n * resolvePathPattern('/skills/**', readdir) // everything under /skills\n */\nexport async function resolvePathPattern(\n pattern: string,\n readdir: (dir: string) => Promise<ReaddirEntry[]>,\n options?: ResolvePathOptions,\n): Promise<PathEntry[]> {\n const maxDepth = options?.maxDepth ?? 10;\n\n // Strip trailing slash for consistent path handling (e.g. '/skills/' → '/skills')\n const normalized = pattern.length > 1 && pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;\n\n if (!isGlobPattern(normalized)) {\n // Plain path — probe with readdir to determine if it's a directory or file\n try {\n await readdir(normalized);\n return [{ path: normalized, type: 'directory' }];\n } catch {\n // readdir failed — treat as a file path (consumer handles non-existence)\n return [{ path: normalized, type: 'file' }];\n }\n }\n\n // Glob pattern — walk from base, match all entries (files and directories)\n const walkRoot = extractGlobBase(normalized);\n const matcher = createGlobMatcher(normalized, { dot: options?.dot ?? false });\n const allEntries = await walkAll(readdir, walkRoot, 0, maxDepth);\n return allEntries.filter(entry => matcher(entry.path));\n}\n","/**\n * Validation for Skills following the Agent Skills specification.\n * @see https://agentskills.io/specification\n *\n * This module uses plain validation functions instead of Zod to avoid\n * version compatibility issues between Zod 3 and Zod 4.\n */\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/**\n * Recommended limits from the Agent Skills spec\n */\nexport const SKILL_LIMITS = {\n /** Recommended max tokens for instructions */\n MAX_INSTRUCTION_TOKENS: 5000,\n /** Recommended max lines for SKILL.md */\n MAX_INSTRUCTION_LINES: 500,\n /** Max characters for name field */\n MAX_NAME_LENGTH: 64,\n /** Max characters for description field */\n MAX_DESCRIPTION_LENGTH: 1024,\n /** Max characters for compatibility field */\n MAX_COMPATIBILITY_LENGTH: 500,\n} as const;\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/**\n * Skill metadata input type (what users provide)\n */\nexport interface SkillMetadataInput {\n /** Skill name (1-64 chars, lowercase letters/numbers/hyphens only, must match directory name) */\n name: string;\n /** Description of what the skill does and when to use it (1-1024 characters) */\n description: string;\n /** License for the skill (e.g., \"Apache-2.0\", \"MIT\") */\n license?: string;\n /** Environment requirements or compatibility notes (string or object for flexibility) */\n compatibility?: unknown;\n /** Whether this skill should be directly invokable by users. Defaults to true. */\n 'user-invocable'?: boolean;\n /** Arbitrary key-value metadata - values can be strings, arrays, objects, etc. */\n metadata?: Record<string, unknown>;\n}\n\n/**\n * Skill metadata output type (after validation)\n */\nexport type SkillMetadataOutput = SkillMetadataInput;\n\n/**\n * Validation result with warnings\n */\nexport interface SkillValidationResult {\n valid: boolean;\n errors: string[];\n warnings: string[];\n}\n\n// =============================================================================\n// Field Validators\n// =============================================================================\n\n/**\n * Validate skill name according to spec:\n * - 1-64 characters\n * - Lowercase letters, numbers, hyphens only\n * - Must not start or end with hyphen\n * - Must not contain consecutive hyphens\n *\n * @param name - The name to validate\n * @returns Array of error messages (empty if valid)\n */\nfunction validateSkillName(name: unknown): string[] {\n const errors: string[] = [];\n const fieldPath = 'name';\n\n // Check type\n if (typeof name !== 'string') {\n errors.push(`${fieldPath}: Expected string, received ${typeof name}`);\n return errors;\n }\n\n // Check not empty\n if (name.length === 0) {\n errors.push(`${fieldPath}: Skill name cannot be empty`);\n return errors;\n }\n\n // Check max length\n if (name.length > SKILL_LIMITS.MAX_NAME_LENGTH) {\n errors.push(`${fieldPath}: Skill name must be ${SKILL_LIMITS.MAX_NAME_LENGTH} characters or less`);\n }\n\n // Check allowed characters (lowercase letters, numbers, hyphens only)\n if (!/^[a-z0-9-]+$/.test(name)) {\n errors.push(`${fieldPath}: Skill name must contain only lowercase letters, numbers, and hyphens`);\n }\n\n // Check not starting or ending with hyphen\n if (name.startsWith('-') || name.endsWith('-')) {\n errors.push(`${fieldPath}: Skill name must not start or end with a hyphen`);\n }\n\n // Check no consecutive hyphens\n if (name.includes('--')) {\n errors.push(`${fieldPath}: Skill name must not contain consecutive hyphens`);\n }\n\n return errors;\n}\n\n/**\n * Validate skill description according to spec:\n * - 1-1024 characters\n * - Cannot be empty or only whitespace\n *\n * @param description - The description to validate\n * @returns Array of error messages (empty if valid)\n */\nfunction validateSkillDescription(description: unknown): string[] {\n const errors: string[] = [];\n const fieldPath = 'description';\n\n // Check type\n if (typeof description !== 'string') {\n errors.push(`${fieldPath}: Expected string, received ${typeof description}`);\n return errors;\n }\n\n // Check not empty\n if (description.length === 0) {\n errors.push(`${fieldPath}: Skill description cannot be empty`);\n return errors;\n }\n\n // Check max length\n if (description.length > SKILL_LIMITS.MAX_DESCRIPTION_LENGTH) {\n errors.push(`${fieldPath}: Skill description must be ${SKILL_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`);\n }\n\n // Check not only whitespace\n if (description.trim().length === 0) {\n errors.push(`${fieldPath}: Skill description cannot be only whitespace`);\n }\n\n return errors;\n}\n\n/**\n * Validate skill license (optional string).\n *\n * @param license - The license to validate\n * @returns Array of error messages (empty if valid)\n */\nfunction validateSkillLicense(license: unknown): string[] {\n const errors: string[] = [];\n const fieldPath = 'license';\n\n // Optional field - undefined/null is valid\n if (license === undefined || license === null) {\n return errors;\n }\n\n // If provided, must be string\n if (typeof license !== 'string') {\n errors.push(`${fieldPath}: Expected string, received ${typeof license}`);\n }\n\n return errors;\n}\n\n/**\n * Validate skill compatibility notes (optional).\n * Accepts string or any JSON-serializable value for flexibility with external skills.\n *\n * @param compatibility - The compatibility value to validate\n * @returns Array of error messages (empty if valid)\n */\nfunction validateSkillCompatibility(_compatibility: unknown): string[] {\n // Optional field - any value is allowed (string, object, array, etc.)\n // External skills don't always follow the spec strictly\n return [];\n}\n\n/**\n * Validate skill metadata field (optional Record<string, unknown>).\n * Accepts any values (not just strings) for flexibility with external skills.\n *\n * @param metadata - The metadata object to validate\n * @returns Array of error messages (empty if valid)\n */\nfunction validateSkillMetadataField(metadata: unknown): string[] {\n const errors: string[] = [];\n const fieldPath = 'metadata';\n\n // Optional field - undefined/null is valid\n if (metadata === undefined || metadata === null) {\n return errors;\n }\n\n // If provided, must be object (but values can be anything)\n if (typeof metadata !== 'object' || Array.isArray(metadata)) {\n errors.push(`${fieldPath}: Expected object, received ${Array.isArray(metadata) ? 'array' : typeof metadata}`);\n return errors;\n }\n\n // Allow any values - external skills use arrays, objects, etc.\n return errors;\n}\n\nfunction validateUserInvocable(userInvocable: unknown): string[] {\n if (userInvocable === undefined || typeof userInvocable === 'boolean') return [];\n return [`user-invocable: Expected boolean, received ${typeof userInvocable}`];\n}\n\n// =============================================================================\n// Validation Helpers\n// =============================================================================\n\n/**\n * Rough token estimate (words * 1.3)\n * This is a simple heuristic; actual token counts vary by model\n */\nfunction estimateTokens(text: string): number {\n const words = text.split(/\\s+/).filter(Boolean).length;\n return Math.ceil(words * 1.3);\n}\n\n/**\n * Count lines in text\n */\nfunction countLines(text: string): number {\n return text.split('\\n').length;\n}\n\n// =============================================================================\n// Main Validation Function\n// =============================================================================\n\n/**\n * Validate skill metadata with optional content warnings.\n *\n * @param metadata - The skill metadata to validate\n * @param dirName - The directory name (must match skill name)\n * @param instructions - Optional instructions content for token/line warnings\n * @returns Validation result with errors and warnings\n *\n * @example\n * ```typescript\n * const result = validateSkillMetadata(\n * { name: 'my-skill', description: 'A helpful skill' },\n * 'my-skill',\n * '# Instructions\\n...'\n * );\n *\n * if (!result.valid) {\n * console.error('Validation errors:', result.errors);\n * }\n * if (result.warnings.length > 0) {\n * console.warn('Warnings:', result.warnings);\n * }\n * ```\n */\nexport function validateSkillMetadata(\n metadata: unknown,\n dirName?: string,\n instructions?: string,\n): SkillValidationResult {\n const errors: string[] = [];\n const warnings: string[] = [];\n\n // Check that metadata is an object\n if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {\n errors.push(\n `Expected object, received ${metadata === null ? 'null' : Array.isArray(metadata) ? 'array' : typeof metadata}`,\n );\n return { valid: false, errors, warnings };\n }\n\n const data = metadata as Record<string, unknown>;\n\n // Validate each field\n errors.push(...validateSkillName(data.name));\n errors.push(...validateSkillDescription(data.description));\n errors.push(...validateSkillLicense(data.license));\n errors.push(...validateSkillCompatibility(data.compatibility));\n errors.push(...validateUserInvocable(data['user-invocable']));\n errors.push(...validateSkillMetadataField(data.metadata));\n\n // Check directory name match (only if no name errors and name is valid)\n if (dirName && typeof data.name === 'string' && data.name !== dirName) {\n errors.push(`Skill name \"${data.name}\" must match directory name \"${dirName}\"`);\n }\n\n // Check instruction limits (warnings only)\n if (instructions) {\n const lineCount = countLines(instructions);\n const tokenEstimate = estimateTokens(instructions);\n\n if (lineCount > SKILL_LIMITS.MAX_INSTRUCTION_LINES) {\n warnings.push(\n `Instructions have ${lineCount} lines (recommended: <${SKILL_LIMITS.MAX_INSTRUCTION_LINES}). Consider moving content to references/.`,\n );\n }\n\n if (tokenEstimate > SKILL_LIMITS.MAX_INSTRUCTION_TOKENS) {\n warnings.push(\n `Instructions have ~${tokenEstimate} estimated tokens (recommended: <${SKILL_LIMITS.MAX_INSTRUCTION_TOKENS}). Consider moving content to references/.`,\n );\n }\n }\n\n return {\n valid: errors.length === 0,\n errors,\n warnings,\n };\n}\n","/**\n * LocalSkillSource - Read-only skill source backed by local filesystem.\n *\n * Uses Node.js fs/promises to read skills directly from disk.\n * This allows skills to be loaded without requiring a full WorkspaceFilesystem.\n *\n * @example\n * ```typescript\n * const source = new LocalSkillSource({\n * basePath: process.cwd(),\n * });\n *\n * // skills paths are relative to basePath\n * const skillsImpl = new WorkspaceSkillsImpl({\n * source,\n * skills: ['./skills', './node_modules/@company/skills'],\n * });\n * ```\n */\n\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\n\nimport { fsExists, fsStat, isTextFile } from '../filesystem';\nimport type { SkillSource, SkillSourceEntry, SkillSourceStat } from './skill-source';\n\n/**\n * Configuration for LocalSkillSource.\n */\nexport interface LocalSkillSourceOptions {\n /**\n * Base path for resolving relative skill paths.\n * Defaults to process.cwd().\n */\n basePath?: string;\n}\n\n/**\n * Read-only skill source that loads skills from the local filesystem.\n *\n * Unlike WorkspaceFilesystem, this doesn't provide write operations.\n * Skills loaded from this source are read-only.\n */\nexport class LocalSkillSource implements SkillSource {\n readonly #basePath: string;\n\n constructor(options: LocalSkillSourceOptions = {}) {\n this.#basePath = options.basePath ?? process.cwd();\n }\n\n /**\n * Resolve a path relative to the base path.\n * Handles both absolute and relative paths.\n */\n #resolvePath(skillPath: string): string {\n if (path.isAbsolute(skillPath)) {\n return skillPath;\n }\n return path.resolve(this.#basePath, skillPath);\n }\n\n async exists(skillPath: string): Promise<boolean> {\n return fsExists(this.#resolvePath(skillPath));\n }\n\n async stat(skillPath: string): Promise<SkillSourceStat> {\n return fsStat(this.#resolvePath(skillPath), skillPath);\n }\n\n async readFile(skillPath: string): Promise<string | Buffer> {\n const resolved = this.#resolvePath(skillPath);\n const content = await fs.readFile(resolved);\n // Convert to string for text files\n if (isTextFile(skillPath)) {\n return content.toString('utf-8');\n }\n return content;\n }\n\n async readdir(skillPath: string): Promise<SkillSourceEntry[]> {\n const resolved = this.#resolvePath(skillPath);\n const entries = await fs.readdir(resolved, { withFileTypes: true });\n // Dirent.isDirectory() returns false for symlinks, even when they point to\n // directories. Detect the target type so skill discovery can load symlinked\n // skills while still letting higher layers decide whether to recurse.\n return Promise.all(\n entries.map(async entry => {\n const entryPath = path.join(resolved, entry.name);\n const isSymlink = entry.isSymbolicLink();\n let type: SkillSourceEntry['type'] = entry.isDirectory() ? 'directory' : 'file';\n\n if (isSymlink) {\n try {\n const targetStat = await fs.stat(entryPath);\n type = targetStat.isDirectory() ? 'directory' : 'file';\n } catch {\n type = 'file';\n }\n }\n\n return {\n name: entry.name,\n type,\n isSymlink: isSymlink || undefined,\n };\n }),\n );\n }\n\n async realpath(skillPath: string): Promise<string> {\n return fs.realpath(this.#resolvePath(skillPath));\n }\n}\n","/**\n * WorkspaceSkills - Skills implementation.\n *\n * Provides discovery and search operations for skills stored\n * in skills paths. All operations are async.\n */\n\nimport matter from 'gray-matter';\n\nimport { isGlobPattern, resolvePathPattern } from '../glob';\nimport type { ReaddirEntry } from '../glob';\nimport type { IndexDocument, SearchResult } from '../search';\nimport { validateSkillMetadata } from './schemas';\nimport type { SkillSource as SkillSourceInterface } from './skill-source';\nimport type {\n ContentSource,\n Skill,\n SkillMetadata,\n SkillSearchResult,\n SkillSearchOptions,\n WorkspaceSkills,\n SkillsResolver,\n SkillsContext,\n} from './types';\n\n// =============================================================================\n// Internal Types\n// =============================================================================\n\n/**\n * Minimal search engine interface - only the methods we actually use.\n * This allows both the real SearchEngine and test mocks to be used.\n */\ninterface SkillSearchEngine {\n index(doc: IndexDocument): Promise<void>;\n remove?(id: string): Promise<void>;\n search(\n query: string,\n options?: { topK?: number; minScore?: number; mode?: 'bm25' | 'vector' | 'hybrid' },\n ): Promise<SearchResult[]>;\n clear(): void;\n}\n\ninterface InternalSkill extends Skill {\n /** Content for BM25 indexing (instructions + all references) */\n indexableContent: string;\n}\n\n// =============================================================================\n// WorkspaceSkillsImpl\n// =============================================================================\n\n/**\n * Configuration for WorkspaceSkillsImpl\n */\nexport interface WorkspaceSkillsImplConfig {\n /**\n * Source for loading skills.\n */\n source: SkillSourceInterface;\n /**\n * Paths to scan for skills.\n * Can be a static array or a function that returns paths based on context.\n */\n skills: SkillsResolver;\n /** Search engine for skill search (optional) */\n searchEngine?: SkillSearchEngine;\n /** Validate skills on load (default: true) */\n validateOnLoad?: boolean;\n /**\n * Check SKILL.md file mtime in addition to directory mtime for staleness detection.\n * Enables detection of in-place file edits (e.g., fixing validation errors).\n * Increases stat calls - recommended for local development only.\n * Default: false\n */\n checkSkillFileMtime?: boolean;\n}\n\n/**\n * Implementation of WorkspaceSkills interface.\n */\nexport class WorkspaceSkillsImpl implements WorkspaceSkills {\n readonly #source: SkillSourceInterface;\n readonly #skillsResolver: SkillsResolver;\n readonly #searchEngine?: SkillSearchEngine;\n readonly #validateOnLoad: boolean;\n readonly #checkSkillFileMtime: boolean;\n\n /** Map of skill name -> array of candidates (supports same-named skills from different sources) */\n #skills: Map<string, InternalSkill[]> = new Map();\n\n /** Whether skills have been discovered */\n #initialized = false;\n\n /** Promise for ongoing initialization (prevents concurrent discovery) */\n #initPromise: Promise<void> | null = null;\n\n /** Timestamp of last skills discovery (for staleness check) */\n #lastDiscoveryTime = 0;\n\n /** Currently resolved skills paths (used to detect changes) */\n #resolvedPaths: string[] = [];\n\n /** Cached glob-resolved directories and per-pattern resolve timestamps */\n #globDirCache: Map<string, string[]> = new Map();\n #globResolveTimes: Map<string, number> = new Map();\n static readonly GLOB_RESOLVE_INTERVAL = 5_000; // Re-walk glob dirs every 5s\n static readonly STALENESS_CHECK_COOLDOWN = 2_000; // Skip staleness check for 2s after discovery\n\n constructor(config: WorkspaceSkillsImplConfig) {\n this.#source = config.source;\n this.#skillsResolver = config.skills;\n this.#searchEngine = config.searchEngine;\n this.#validateOnLoad = config.validateOnLoad ?? true;\n this.#checkSkillFileMtime = config.checkSkillFileMtime ?? false;\n }\n\n // ===========================================================================\n // Discovery\n // ===========================================================================\n\n async list(): Promise<SkillMetadata[]> {\n await this.#ensureInitialized();\n\n const results: SkillMetadata[] = [];\n for (const candidates of this.#skills.values()) {\n const canonicalCandidates = await this.#dedupeCanonicalCandidates(candidates);\n for (const skill of canonicalCandidates) {\n results.push({\n name: skill.name,\n path: skill.path,\n description: skill.description,\n license: skill.license,\n compatibility: skill.compatibility,\n 'user-invocable': skill['user-invocable'],\n metadata: skill.metadata,\n });\n }\n }\n return results;\n }\n\n async get(name: string): Promise<Skill | null> {\n await this.#ensureInitialized();\n // Try name-based lookup first, then fall back to path-based (escape hatch)\n const skill = (await this.#resolveByName(name)) ?? this.#resolveByPath(name);\n if (!skill) return null;\n\n // Return without internal indexableContent field\n const { indexableContent: _, ...skillData } = skill;\n return skillData;\n }\n\n async has(name: string): Promise<boolean> {\n await this.#ensureInitialized();\n return ((await this.#resolveByName(name)) ?? this.#resolveByPath(name)) !== null;\n }\n\n // ===========================================================================\n // Skill Resolution (Private)\n // ===========================================================================\n\n /**\n * Resolve a skill by name with tie-breaking when multiple candidates exist.\n * Priority: local > managed > external, then alphabetical path.\n */\n async #resolveByName(name: string): Promise<InternalSkill | null> {\n const candidates = this.#skills.get(name);\n if (!candidates || candidates.length === 0) return null;\n return this.#tieBreak(candidates);\n }\n\n /**\n * Resolve a skill by exact path (escape hatch for disambiguation).\n * Searches across all candidate arrays.\n * Accepts paths with or without a trailing `/SKILL.md` suffix, since\n * SkillsProcessor.formatLocation() exposes `${path}/SKILL.md` to the LLM.\n */\n #resolveByPath(skillPath: string): InternalSkill | null {\n const normalized = skillPath.replace(/\\/SKILL\\.md$/, '');\n for (const candidates of this.#skills.values()) {\n const match = candidates.find(s => s.path === normalized);\n if (match) return match;\n }\n return null;\n }\n\n async #getCanonicalSkillPath(skillPath: string): Promise<string> {\n if (!this.#source.realpath) return skillPath;\n\n try {\n return await this.#source.realpath(skillPath);\n } catch {\n return skillPath;\n }\n }\n\n async #dedupeCanonicalCandidates(candidates: InternalSkill[]): Promise<InternalSkill[]> {\n const canonicalGroups = new Map<string, InternalSkill[]>();\n for (const candidate of candidates) {\n const canonicalPath = await this.#getCanonicalSkillPath(candidate.path);\n const group = canonicalGroups.get(canonicalPath) ?? [];\n group.push(candidate);\n canonicalGroups.set(canonicalPath, group);\n }\n\n const SOURCE_PRIORITY: Record<string, number> = { local: 0, managed: 1, external: 2 };\n return [...canonicalGroups.values()].map(\n group =>\n [...group].sort((a, b) => {\n const aPri = SOURCE_PRIORITY[a.source.type] ?? 99;\n const bPri = SOURCE_PRIORITY[b.source.type] ?? 99;\n if (aPri !== bPri) return aPri - bPri;\n return a.path.localeCompare(b.path);\n })[0]!,\n );\n }\n\n /**\n * Pick the winning skill from an array of same-named candidates.\n * When there's only one candidate, returns it directly (no warning).\n * When there are multiple, de-duplicates alias paths that point to the same\n * canonical skill, then applies source-type priority and warns.\n *\n * Priority: local (0) > managed (1) > external (2).\n * Throws if source-type priority can't resolve the tie (e.g., two distinct local skills with same name).\n */\n async #tieBreak(candidates: InternalSkill[]): Promise<InternalSkill | null> {\n if (candidates.length === 0) return null;\n if (candidates.length === 1) return candidates[0]!;\n\n const deduped = await this.#dedupeCanonicalCandidates(candidates);\n\n if (deduped.length === 1) return deduped[0]!;\n\n const SOURCE_PRIORITY: Record<string, number> = { local: 0, managed: 1, external: 2 };\n const sorted = [...deduped].sort((a, b) => {\n const aPri = SOURCE_PRIORITY[a.source.type] ?? 99;\n const bPri = SOURCE_PRIORITY[b.source.type] ?? 99;\n if (aPri !== bPri) return aPri - bPri;\n return a.path.localeCompare(b.path);\n });\n\n const winner = sorted[0]!;\n const runnerUp = sorted[1]!;\n\n // Error if source-type priority can't break the tie\n if (winner.source.type === runnerUp.source.type) {\n const paths = sorted\n .filter(s => s.source.type === winner.source.type)\n .map(s => `\"${s.path}\"`)\n .join(', ');\n throw new Error(\n `[WorkspaceSkills] Cannot resolve skill \"${winner.name}\": multiple ${winner.source.type} skills found at ${paths}. ` +\n `Rename one or move it to a different source type.`,\n );\n }\n\n console.warn(\n `[WorkspaceSkills] Multiple skills named \"${winner.name}\" found. ` +\n `Using \"${winner.path}\" (source: ${winner.source.type}). ` +\n `Other candidates: ${sorted\n .slice(1)\n .map(s => `\"${s.path}\" (${s.source.type})`)\n .join(', ')}`,\n );\n\n return winner;\n }\n\n async refresh(): Promise<void> {\n // Remove only skill entries from the shared search engine (not workspace content)\n for (const candidates of this.#skills.values()) {\n for (const skill of candidates) {\n await this.#removeSkillFromIndex(skill);\n }\n }\n this.#skills.clear();\n this.#initialized = false;\n this.#initPromise = null;\n await this.#discoverSkills();\n this.#initialized = true;\n }\n\n async maybeRefresh(context?: SkillsContext): Promise<void> {\n // Ensure initial discovery is complete\n await this.#ensureInitialized();\n\n // Resolve current paths (may be dynamic based on context)\n const currentPaths = await this.#resolvePaths(context);\n\n // Check if paths have changed (for dynamic resolvers)\n const pathsChanged = !this.#arePathsEqual(this.#resolvedPaths, currentPaths);\n if (pathsChanged) {\n // Paths changed - need full refresh with new paths\n this.#resolvedPaths = currentPaths;\n await this.refresh();\n return;\n }\n\n // Check if any skills path has been modified since last discovery\n const isStale = await this.#isSkillsPathStale();\n if (isStale) {\n await this.refresh();\n }\n }\n\n async addSkill(skillPath: string): Promise<void> {\n await this.#ensureInitialized();\n\n // Determine SKILL.md path and dirName\n let skillFilePath: string;\n let dirName: string;\n if (isSkillFilePath(skillPath)) {\n skillFilePath = skillPath;\n dirName = splitPathSegments(this.#getParentPath(skillPath)).pop() || 'unknown';\n } else {\n skillFilePath = this.#joinPath(skillPath, 'SKILL.md');\n dirName = splitPathSegments(skillPath).pop() || 'unknown';\n }\n\n // Determine source from existing resolved paths\n const source = this.#inferSource(skillPath);\n\n // Parse and add to cache\n const skill = await this.#parseSkillFile(skillFilePath, dirName, source);\n\n // Remove old index entries if skill already exists at same path (for update case)\n const candidates = this.#skills.get(skill.name) ?? [];\n const existingIdx = candidates.findIndex(s => s.path === skill.path);\n if (existingIdx >= 0) {\n await this.#removeSkillFromIndex(candidates[existingIdx]!);\n candidates[existingIdx] = skill;\n } else {\n candidates.push(skill);\n }\n this.#skills.set(skill.name, candidates);\n await this.#indexSkill(skill);\n\n // Update discovery time so maybeRefresh() doesn't trigger full scan\n this.#lastDiscoveryTime = Date.now();\n }\n\n async removeSkill(skillName: string): Promise<void> {\n await this.#ensureInitialized();\n\n // Resolve by name (tie-break winner), then fall back to path-based lookup\n const skill = (await this.#resolveByName(skillName)) ?? this.#resolveByPath(skillName);\n if (!skill) return;\n\n // Remove from search index\n await this.#removeSkillFromIndex(skill);\n\n // Remove from candidates array\n const candidates = this.#skills.get(skill.name);\n if (candidates) {\n const idx = candidates.findIndex(s => s.path === skill.path);\n if (idx >= 0) candidates.splice(idx, 1);\n if (candidates.length === 0) {\n this.#skills.delete(skill.name);\n }\n }\n\n // Update discovery time so maybeRefresh() doesn't trigger full scan\n this.#lastDiscoveryTime = Date.now();\n }\n\n /**\n * Resolve skills paths from the resolver (static array or function).\n */\n async #resolvePaths(context?: SkillsContext): Promise<string[]> {\n if (Array.isArray(this.#skillsResolver)) {\n return this.#skillsResolver;\n }\n return this.#skillsResolver(context ?? {});\n }\n\n /**\n * Compare two path arrays for equality (order-independent).\n */\n #arePathsEqual(a: string[], b: string[]): boolean {\n if (a.length !== b.length) return false;\n const sortedA = [...a].sort();\n const sortedB = [...b].sort();\n return sortedA.every((path, i) => path === sortedB[i]);\n }\n\n // ===========================================================================\n // Search\n // ===========================================================================\n\n async search(query: string, options: SkillSearchOptions = {}): Promise<SkillSearchResult[]> {\n await this.#ensureInitialized();\n\n if (!this.#searchEngine) {\n // Fall back to simple text matching if no search engine\n return this.#simpleSearch(query, options);\n }\n\n const { topK = 5, minScore, skillNames, includeReferences = true, mode } = options;\n\n // Ask the search engine for enough rows to survive post-search filtering and\n // canonical alias de-duplication before applying the final topK.\n const totalIndexedDocuments = [...this.#skills.values()].reduce(\n (count, candidates) =>\n count + candidates.reduce((skillCount, skill) => skillCount + 1 + skill.references.length, 0),\n 0,\n );\n const expandedTopK = Math.max(skillNames ? topK * 3 : topK, totalIndexedDocuments);\n\n // Delegate to SearchEngine\n const searchResults = await this.#searchEngine.search(query, {\n topK: expandedTopK,\n minScore,\n mode,\n });\n\n const results: SkillSearchResult[] = [];\n const seenCanonicalSources = new Set<string>();\n\n for (const result of searchResults) {\n const skillPath = result.metadata?.skillPath as string;\n const source = result.metadata?.source as string;\n\n if (!skillPath || !source) continue;\n\n // Map path back to the canonical skill winner for filtering and results.\n const matchedSkill = this.#resolveByPath(skillPath);\n if (!matchedSkill) continue;\n\n const skill = (await this.#resolveByName(matchedSkill.name)) ?? matchedSkill;\n\n // Filter by skill names if specified\n if (skillNames && !skillNames.includes(skill.name)) {\n continue;\n }\n\n // Filter out references if not included\n if (!includeReferences && source !== 'SKILL.md') {\n continue;\n }\n\n const canonicalSourceKey = `${skill.path}:${source}`;\n if (seenCanonicalSources.has(canonicalSourceKey)) {\n continue;\n }\n seenCanonicalSources.add(canonicalSourceKey);\n\n results.push({\n skillName: skill.name,\n skillPath: skill.path,\n source,\n content: result.content,\n score: result.score,\n lineRange: result.lineRange,\n scoreDetails: result.scoreDetails,\n });\n\n if (results.length >= topK) break;\n }\n\n return results;\n }\n\n // ===========================================================================\n // Single-item Accessors\n // ===========================================================================\n\n async getReference(skillName: string, referencePath: string): Promise<string | null> {\n await this.#ensureInitialized();\n\n const skill = (await this.#resolveByName(skillName)) ?? this.#resolveByPath(skillName);\n if (!skill) return null;\n\n const safeRefPath = this.#assertRelativePath(referencePath, 'reference');\n const refFilePath = this.#joinPath(skill.path, safeRefPath);\n\n if (!(await this.#source.exists(refFilePath))) {\n return null;\n }\n\n try {\n const content = await this.#source.readFile(refFilePath);\n return typeof content === 'string' ? content : content.toString('utf-8');\n } catch {\n return null;\n }\n }\n\n async getScript(skillName: string, scriptPath: string): Promise<string | null> {\n await this.#ensureInitialized();\n\n const skill = (await this.#resolveByName(skillName)) ?? this.#resolveByPath(skillName);\n if (!skill) return null;\n\n const sa