276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
import { extname } from "node:path";
|
|
import type { FileInventoryEntry, SkillContextMode, TrustLevel } from "./skill-types.js";
|
|
|
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
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<string, unknown> = {
|
|
[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<string, unknown> = {};
|
|
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<string, unknown> {
|
|
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<string, unknown>; 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<string, unknown>,
|
|
): 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 : "",
|
|
};
|
|
}
|