- Implement tests for random ID generation, ensuring preference for crypto.randomUUID. - Create tests for runtime context capabilities, validating the injection of enabled skill capabilities. - Add tests for skill capability parsing, including classification and command example extraction. - Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements. - Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
499 lines
14 KiB
TypeScript
499 lines
14 KiB
TypeScript
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<SkillConfigRecord> & {
|
|
enabled?: boolean;
|
|
apiKey?: string;
|
|
env?: Record<string, string>;
|
|
};
|
|
manifestContent?: string | null;
|
|
}
|
|
|
|
type ParsedFrontmatter = {
|
|
name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
category?: string;
|
|
allowedTools: string[];
|
|
};
|
|
|
|
function uniq(values: Array<string | undefined | null>): 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,
|
|
};
|
|
}
|