import { extname } from "node:path"; import type { FileInventoryEntry, SkillContextMode, TrustLevel } from "./skill-types.js"; function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseYamlScalar(rawValue: string): unknown { const trimmed = rawValue.trim(); if (trimmed === "") return ""; if (trimmed === "null" || trimmed === "~") return null; if (trimmed === "true") return true; if (trimmed === "false") return false; if (trimmed === "[]") return []; if (trimmed === "{}") return {}; if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); if ( trimmed.startsWith('"') || trimmed.startsWith("[") || trimmed.startsWith("{") ) { try { return JSON.parse(trimmed); } catch { return trimmed; } } return trimmed; } function prepareYamlLines( raw: string, ): Array<{ indent: number; content: string }> { return raw .split("\n") .map((line) => ({ indent: line.match(/^ */)?.[0].length ?? 0, content: line.trim(), })) .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); } function parseYamlBlock( lines: Array<{ indent: number; content: string }>, startIndex: number, indentLevel: number, ): { value: unknown; nextIndex: number } { let index = startIndex; while (index < lines.length && lines[index]!.content.length === 0) index += 1; if (index >= lines.length || lines[index]!.indent < indentLevel) { return { value: {}, nextIndex: index }; } const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-"); if (isArray) { const values: unknown[] = []; while (index < lines.length) { const line = lines[index]!; if (line.indent < indentLevel) break; if (line.indent !== indentLevel || !line.content.startsWith("-")) break; const remainder = line.content.slice(1).trim(); index += 1; if (!remainder) { const nested = parseYamlBlock(lines, index, indentLevel + 2); values.push(nested.value); index = nested.nextIndex; continue; } const inlineObjectSeparator = remainder.indexOf(":"); if ( inlineObjectSeparator > 0 && !remainder.startsWith('"') && !remainder.startsWith("{") && !remainder.startsWith("[") ) { const key = remainder.slice(0, inlineObjectSeparator).trim(); const rawValue = remainder.slice(inlineObjectSeparator + 1).trim(); const nextObject: Record = { [key]: parseYamlScalar(rawValue), }; if (index < lines.length && lines[index]!.indent > indentLevel) { const nested = parseYamlBlock(lines, index, indentLevel + 2); if (isPlainRecord(nested.value)) { Object.assign(nextObject, nested.value); } index = nested.nextIndex; } values.push(nextObject); continue; } values.push(parseYamlScalar(remainder)); } return { value: values, nextIndex: index }; } // Object parsing const record: Record = {}; while (index < lines.length) { const line = lines[index]!; if (line.indent < indentLevel) break; const colonIndex = line.content.indexOf(":"); if (colonIndex < 0) { index += 1; continue; } const key = line.content.slice(0, colonIndex).trim(); const remainder = line.content.slice(colonIndex + 1).trim(); index += 1; if (!remainder && index < lines.length && lines[index]!.indent > indentLevel) { const nested = parseYamlBlock(lines, index, lines[index]!.indent); record[key] = nested.value; index = nested.nextIndex; continue; } record[key] = parseYamlScalar(remainder); } return { value: record, nextIndex: index }; } function parseYamlFrontmatter(raw: string): Record { const prepared = prepareYamlLines(raw); if (prepared.length === 0) return {}; const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); return isPlainRecord(parsed.value) ? parsed.value : {}; } /** * Parse a SKILL.md file with optional YAML frontmatter. * Normalizes CRLF to LF before parsing. */ export function parseFrontmatterMarkdown( raw: string, ): { frontmatter: Record; body: string } { const normalized = raw.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) { return { frontmatter: {}, body: normalized.trim() }; } const closing = normalized.indexOf("\n---\n", 4); if (closing < 0) { // Check for closing delimiter at EOF (no trailing newline) const eofClosing = normalized.indexOf("\n---", 4); if (eofClosing >= 0 && eofClosing + 4 >= normalized.length) { const frontmatterRaw = normalized.slice(4, eofClosing).trim(); return { frontmatter: parseYamlFrontmatter(frontmatterRaw), body: "", }; } return { frontmatter: {}, body: normalized.trim() }; } const frontmatterRaw = normalized.slice(4, closing).trim(); const body = normalized.slice(closing + 5).trim(); return { frontmatter: parseYamlFrontmatter(frontmatterRaw), body, }; } /** * Classify a file path within a skill package into its inventory kind. * Uses directory prefixes and file extensions for classification. */ export function classifyInventoryKind( relativePath: string, ): FileInventoryEntry["kind"] { // Normalize path separators and lowercase for matching const normalized = relativePath.replace(/\\/g, "/").toLowerCase(); if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill"; if (normalized.startsWith("references/")) return "reference"; if (normalized.startsWith("scripts/")) return "script"; if (normalized.startsWith("assets/")) return "asset"; if (normalized.endsWith(".md")) return "markdown"; const ext = extname(normalized).toLowerCase(); const scriptExtensions = new Set([ ".sh", ".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".bash", ]); if (scriptExtensions.has(ext)) return "script"; const assetExtensions = new Set([ ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf", ]); if (assetExtensions.has(ext)) return "asset"; return "other"; } /** * Derive the trust level for a skill based on its file inventory. * Scripts/executables -> highest risk; assets -> medium; markdown only -> lowest. */ export function deriveTrustLevel(inventory: FileInventoryEntry[]): TrustLevel { if (inventory.some((entry) => entry.kind === "script")) return "scripts_executables"; if ( inventory.some( (entry) => entry.kind === "asset" || entry.kind === "other", ) ) return "assets"; return "markdown_only"; } /** * Estimate the token count for a text string. * Uses a rough 1:4 character-to-token ratio. */ export function estimateTokens(text: string): number { if (text.length === 0) return 0; return Math.ceil(text.length / 4); } /** * Runtime semantics extracted from a skill's YAML frontmatter. * These fields control how the skill interacts with the execution runtime. */ export interface SkillRuntimeSemantics { allowedTools: string[]; hooks: string[]; contextMode: SkillContextMode; isAgent: boolean; effort: "low" | "medium" | "high"; whenToUse: string; } function toStringArray(value: unknown): string[] { if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean); return []; } /** * Extract runtime semantics from parsed frontmatter. * Missing fields default to safe, passive values. */ export function extractRuntimeSemantics( frontmatter: Record, ): SkillRuntimeSemantics { const contextRaw = typeof frontmatter.context === "string" ? frontmatter.context : "inline"; const contextMode: SkillContextMode = contextRaw === "fork" ? "fork" : "inline"; const effortRaw = typeof frontmatter.effort === "string" ? frontmatter.effort : "medium"; const validEfforts = new Set(["low", "medium", "high"]); const effort: "low" | "medium" | "high" = validEfforts.has(effortRaw) ? (effortRaw as "low" | "medium" | "high") : "medium"; return { allowedTools: toStringArray(frontmatter["allowed-tools"] ?? frontmatter.allowedTools), hooks: toStringArray(frontmatter.hooks), contextMode, isAgent: frontmatter.agent === true || frontmatter.agent === "true", effort, whenToUse: typeof frontmatter.when_to_use === "string" ? frontmatter.when_to_use : "", }; }