import type { SkillConfigRecord } from '@electron/utils/skill-config'; import type { ToolRenderHints } from '@runtime/shared/chat-model'; const FRONTMATTER_PATTERN = /^---\s*\r?\n([\s\S]*?)\r?\n---/; const FILE_EXTENSION_PATTERN = /\.[a-z0-9]{2,8}\b/gi; const ENV_VAR_PATTERN = /\$\{([A-Z][A-Z0-9_]+)\}/g; const QUOTED_TEXT_PATTERN = /"([^"\n]{2,80})"|'([^'\n]{2,80})'/g; const CODE_FENCE_PATTERN = /```([^\n`]*)\r?\n([\s\S]*?)```/g; const SPREADSHEET_EXTENSIONS = ['.xlsx', '.xls', '.xlsm', '.csv', '.tsv', '.ods']; const DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.doc', '.pptx', '.ppt', '.odt', '.odp', '.rtf']; const COMMON_FILE_EXTENSIONS = [ ...SPREADSHEET_EXTENSIONS, ...DOCUMENT_EXTENSIONS, '.txt', '.md', '.json', '.xml', '.html', '.htm', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', ]; const OPERATION_KEYWORDS: Array<[string, string[]]> = [ ['search', ['search', 'look up', 'lookup', 'find', 'discover']], ['research', ['research', 'investigate']], ['extract', ['extract', 'scrape']], ['crawl', ['crawl', 'map']], ['read', ['read', 'open', 'inspect', 'review']], ['analyze', ['analyze', 'analysis', 'summarize', 'statistics']], ['create', ['create', 'build', 'generate', 'produce']], ['edit', ['edit', 'modify', 'update', 'fill cells', 'add formulas']], ['convert', ['convert', 'transform', 'export']], ['validate', ['validate', 'check formulas', 'verify']], ['repair', ['repair', 'fix', 'recover']], ['format', ['format', 'styling', 'style']], ]; export interface SkillCapability { skillKey: string; slug: string; name: string; description: string; enabled: boolean; category: string; version?: string; source?: string; baseDir?: string; manifestPath?: string; allowedTools: string[]; operationHints: string[]; triggerHints: string[]; inputExtensions: string[]; requiredEnvVars: string[]; requiresAuth: boolean; plannerSummary: string; commandExamples?: string[]; renderHints?: ToolRenderHints; } export interface SkillCapabilityParserInput { skillKey: string; config?: Partial & { enabled?: boolean; apiKey?: string; env?: Record; }; manifestContent?: string | null; } type ParsedFrontmatter = { name?: string; description?: string; version?: string; category?: string; allowedTools: string[]; }; function uniq(values: Array): string[] { return Array.from(new Set(values.map((value) => value?.trim()).filter(Boolean) as string[])); } function extractFrontmatter(markdown?: string | null): { frontmatter: string; body: string } { if (!markdown) { return { frontmatter: '', body: '' }; } const match = markdown.match(FRONTMATTER_PATTERN); if (!match) { return { frontmatter: '', body: markdown }; } return { frontmatter: match[1] || '', body: markdown.slice(match[0].length).trim(), }; } function parseScalarFrontmatterValue(frontmatter: string, key: string): string | undefined { const pattern = new RegExp(`^${key}:\\s*(.+)$`, 'im'); const match = frontmatter.match(pattern); if (!match?.[1]) { return undefined; } const value = match[1].trim().replace(/^["']|["']$/g, ''); if (value === '|' || value === '>') { return undefined; } return value; } function parseNestedFrontmatterValue(frontmatter: string, key: string): string | undefined { const pattern = new RegExp(`^\\s+${key}:\\s*(.+)$`, 'im'); const match = frontmatter.match(pattern); if (!match?.[1]) { return undefined; } return match[1].trim().replace(/^["']|["']$/g, ''); } function parseDescription(frontmatter: string, markdown?: string | null): string | undefined { const blockMatch = frontmatter.match(/^description:\s*\|\s*\r?\n([\s\S]*?)(?:\r?\n(?:[A-Za-z0-9_-]+:|[A-Za-z0-9_-]+:\s)|$)/im); if (blockMatch?.[1]) { return blockMatch[1] .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .join(' ') .trim(); } const scalar = parseScalarFrontmatterValue(frontmatter, 'description'); if (scalar) { return scalar; } if (!markdown) { return undefined; } const lines = markdown .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); for (const line of lines) { if (line.startsWith('#') || line === '---') { continue; } return line.replace(/\s+/g, ' ').trim(); } return undefined; } function parseAllowedTools(frontmatter: string): string[] { const scalar = parseScalarFrontmatterValue(frontmatter, 'allowed-tools'); if (!scalar) { return []; } return uniq(scalar.split(',')); } function parseCategory(frontmatter: string): string | undefined { const scalar = parseScalarFrontmatterValue(frontmatter, 'category'); if (scalar) { return scalar; } const nested = frontmatter.match(/^\s+category:\s*(.+)$/im); if (!nested?.[1]) { return undefined; } return nested[1].trim().replace(/^["']|["']$/g, ''); } function parseFrontmatter(markdown?: string | null): ParsedFrontmatter { const { frontmatter } = extractFrontmatter(markdown); return { name: parseScalarFrontmatterValue(frontmatter, 'name'), description: parseDescription(frontmatter, markdown), version: parseScalarFrontmatterValue(frontmatter, 'version') || parseNestedFrontmatterValue(frontmatter, 'version'), category: parseCategory(frontmatter), allowedTools: parseAllowedTools(frontmatter), }; } function collectMatches(pattern: RegExp, text: string): string[] { const matches: string[] = []; const regex = new RegExp(pattern.source, pattern.flags); let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { const value = match.slice(1).find(Boolean) || match[0]; if (value) { matches.push(value); } } return uniq(matches); } function detectOperations(text: string): string[] { const lowerText = text.toLowerCase(); const operations = OPERATION_KEYWORDS .filter(([, keywords]) => keywords.some((keyword) => lowerText.includes(keyword))) .map(([operation]) => operation); return operations.length > 0 ? operations : ['assist']; } function detectInputExtensions(text: string): string[] { return uniq( collectMatches(FILE_EXTENSION_PATTERN, text) .map((value) => value.toLowerCase()) .filter((value) => COMMON_FILE_EXTENSIONS.includes(value)), ); } function detectTriggerHints(text: string, inputExtensions: string[]): string[] { const quotedHints = collectMatches(QUOTED_TEXT_PATTERN, text) .filter((value) => value.length <= 64); const sentenceHints = text .split(/(?<=[.!?])\s+/) .map((sentence) => sentence.trim()) .filter((sentence) => { const lowerSentence = sentence.toLowerCase(); return lowerSentence.includes('use this skill') || lowerSentence.includes('use when') || lowerSentence.includes('trigger') || lowerSentence.includes('when to use'); }) .slice(0, 4) .map((sentence) => sentence.replace(/\s+/g, ' ').trim()); return uniq([...quotedHints, ...sentenceHints, ...inputExtensions]).slice(0, 8); } function detectRequiredEnvVars( manifestContent: string, config?: SkillCapabilityParserInput['config'], ): string[] { const fromManifest = collectMatches(ENV_VAR_PATTERN, manifestContent); const fromConfig = config?.env ? Object.keys(config.env) : []; return uniq([...fromManifest, ...fromConfig]); } function isCommandFenceLanguage(value: string): boolean { const normalized = value.trim().toLowerCase(); return normalized === '' || ['bash', 'sh', 'shell', 'zsh', 'powershell', 'pwsh', 'ps1', 'cmd', 'bat'].includes(normalized); } function normalizeCommandBlockLines(block: string): string[] { const lines = block .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .filter((line) => !line.startsWith('#')); const combined: string[] = []; for (const line of lines) { if (combined.length > 0 && combined[combined.length - 1].endsWith('\\')) { combined[combined.length - 1] = `${combined[combined.length - 1].slice(0, -1).trim()} ${line}`; continue; } combined.push(line); } return combined; } function isSafeCommandExample(line: string): boolean { const normalized = line.trim(); if (!normalized) { return false; } if (normalized.startsWith('$ ')) { return isSafeCommandExample(normalized.slice(2)); } if ( normalized.includes('|') || normalized.includes('&&') || normalized.includes('||') || normalized.includes(';') || normalized.includes('>$') || normalized.includes('$(') || normalized.includes('`') || normalized.includes('<<') ) { return false; } return !/^(curl|wget|brew|apt|apt-get|yum|dnf|pip|pip3|pnpm|npm|yarn)\s+(install|add)\b/i.test(normalized) && !/\blogin\b/i.test(normalized) && !/\bsetup\b/i.test(normalized); } function extractCommandExamples(markdown?: string | null): string[] { if (!markdown) { return []; } const examples: string[] = []; for (const match of markdown.matchAll(CODE_FENCE_PATTERN)) { const language = match[1] || ''; const block = match[2] || ''; if (!isCommandFenceLanguage(language)) { continue; } for (const line of normalizeCommandBlockLines(block)) { const candidate = line.startsWith('$ ') ? line.slice(2).trim() : line; if (!isSafeCommandExample(candidate)) { continue; } examples.push(candidate); } } return uniq(examples).slice(0, 12); } function inferCategory( parsedCategory: string | undefined, operationHints: string[], inputExtensions: string[], ): string { if (parsedCategory) { return parsedCategory; } if (inputExtensions.some((extension) => SPREADSHEET_EXTENSIONS.includes(extension))) { return 'document'; } if (inputExtensions.some((extension) => DOCUMENT_EXTENSIONS.includes(extension))) { return 'document'; } if (operationHints.some((operation) => ['search', 'research', 'crawl', 'extract'].includes(operation))) { return 'search'; } return 'general'; } function inferRenderHints( category: string, operationHints: string[], inputExtensions: string[], skillKey: string, ): ToolRenderHints { if (inputExtensions.some((extension) => SPREADSHEET_EXTENSIONS.includes(extension))) { return { card: 'document-analysis', preferredView: 'table', skillType: 'spreadsheet', metadata: { skillKey, operations: operationHints, }, }; } if (inputExtensions.some((extension) => DOCUMENT_EXTENSIONS.includes(extension)) || category === 'document') { return { card: 'document-analysis', preferredView: 'summary', skillType: 'document', metadata: { skillKey, operations: operationHints, inputExtensions, }, }; } if (category === 'search' || operationHints.some((operation) => ['search', 'research', 'crawl'].includes(operation))) { return { card: 'search-results', preferredView: 'summary', skillType: 'search', metadata: { skillKey, operations: operationHints, }, }; } return { card: 'generic', preferredView: 'summary', skillType: category, metadata: { skillKey, operations: operationHints, }, }; } function truncateDescription(value: string, maxLength = 180): string { const normalized = value.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, maxLength - 3).trim()}...`; } function buildPlannerSummary( category: string, operationHints: string[], inputExtensions: string[], description: string, requiresAuth: boolean, ): string { const segments: string[] = []; segments.push(`${category} skill`); if (operationHints.length > 0) { segments.push(`operations: ${operationHints.join(', ')}`); } if (inputExtensions.length > 0) { segments.push(`inputs: ${inputExtensions.join(', ')}`); } if (requiresAuth) { segments.push('auth/environment required'); } segments.push(truncateDescription(description)); return segments.join('; '); } export function parseSkillCapability(input: SkillCapabilityParserInput): SkillCapability { const manifestContent = input.manifestContent?.trim() || ''; const parsed = parseFrontmatter(manifestContent); const config = input.config || {}; const name = parsed.name || config.name || config.slug || input.skillKey; const description = parsed.description || config.description || `Enabled skill ${name}`; const combinedText = [description, manifestContent].filter(Boolean).join('\n'); const operationHints = detectOperations(combinedText); const inputExtensions = detectInputExtensions(combinedText); const requiredEnvVars = detectRequiredEnvVars(manifestContent, config); const commandExamples = extractCommandExamples(manifestContent); const requiresAuth = Boolean(config.apiKey) || requiredEnvVars.length > 0 || /requires api key|api key required|authentication required|authorization required|authorize|authorization|login\b|sign in|oauth|access token|bearer token/i.test(combinedText); const category = inferCategory(parsed.category, operationHints, inputExtensions); const renderHints = inferRenderHints(category, operationHints, inputExtensions, input.skillKey); return { skillKey: input.skillKey, slug: config.slug || input.skillKey, name, description, enabled: config.enabled !== false, category, version: config.version || parsed.version, source: config.source, baseDir: config.baseDir, manifestPath: config.filePath, allowedTools: uniq(parsed.allowedTools), operationHints, triggerHints: detectTriggerHints(combinedText, inputExtensions), inputExtensions, requiredEnvVars, requiresAuth, commandExamples, plannerSummary: buildPlannerSummary( category, operationHints, inputExtensions, description, requiresAuth, ), renderHints, }; }