diff --git a/.claude/worktrees/agent-ac899a98 b/.claude/worktrees/agent-ac899a98 new file mode 160000 index 00000000..fe0d3698 --- /dev/null +++ b/.claude/worktrees/agent-ac899a98 @@ -0,0 +1 @@ +Subproject commit fe0d3698dc514e3b2c97a41b1f2a00738575b608 diff --git a/src/main/skill-format.ts b/src/main/skill-format.ts new file mode 100644 index 00000000..c3448b18 --- /dev/null +++ b/src/main/skill-format.ts @@ -0,0 +1,243 @@ +// --------------------------------------------------------------------------- +// SKILL.md format parser, inventory classifier, and trust level derivation +// --------------------------------------------------------------------------- + +import { extname } from "node:path"; +import type { FileInventoryEntry, TrustLevel } from "./skill-types.js"; + +// --------------------------------------------------------------------------- +// Minimal YAML frontmatter parsing (no external YAML library) +// Follows the Paperclip reference pattern for SKILL.md files. +// --------------------------------------------------------------------------- + +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 : {}; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * 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); +} diff --git a/src/main/skill-keys.ts b/src/main/skill-keys.ts new file mode 100644 index 00000000..15ae0510 --- /dev/null +++ b/src/main/skill-keys.ts @@ -0,0 +1,194 @@ +// --------------------------------------------------------------------------- +// Canonical skill key derivation and skill import source resolution +// --------------------------------------------------------------------------- + +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import type { SkillSourceType } from "./skill-types.js"; + +// --------------------------------------------------------------------------- +// Slug normalization helpers +// --------------------------------------------------------------------------- + +const SLUG_DELIM_RE = /[^a-z0-9]+/g; +const SLUG_TRIM_RE = /^-+|-+$/g; + +/** + * Normalize a value into a URL-safe slug. + * Returns null if the value is falsy or results in an empty string. + */ +export function normalizeSkillSlug( + value: string | undefined | null, +): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed + .toLowerCase() + .replace(SLUG_DELIM_RE, "-") + .replace(SLUG_TRIM_RE, ""); + return normalized.length > 0 ? normalized : null; +} + +// --------------------------------------------------------------------------- +// Hashing +// --------------------------------------------------------------------------- + +/** + * Compute the first 10 characters of a SHA-256 hex digest. + * Used for hash segments in canonical keys. + */ +export function hashSkillValue(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 10); +} + +// --------------------------------------------------------------------------- +// Canonical key derivation +// --------------------------------------------------------------------------- + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Derive a deterministic canonical key for a skill. + * + * Key formats by source type: + * - GitHub/skills_sh with owner+repo: `{owner}/{repo}/{slug}` + * - URL with sourceLocator: `url/{normalizedHost}/{hash10}/{slug}` + * - local_path with sourceLocator: `local/{hash10 of resolved path}/{slug}` + * - Fallback (managed or unknown): `company/{companyId}/{slug}` + */ +export function deriveCanonicalSkillKey( + companyId: string, + input: { + slug: string; + sourceType: SkillSourceType; + sourceLocator: string | null; + metadata: Record | null; + }, +): string { + const slug = normalizeSkillSlug(input.slug) ?? "skill"; + const metadata = isPlainRecord(input.metadata) ? input.metadata : null; + + const owner = normalizeSkillSlug(asString(metadata?.owner)); + const repo = normalizeSkillSlug(asString(metadata?.repo)); + + // GitHub or skills.sh with owner/repo metadata + if ( + (input.sourceType === "github" || input.sourceType === "skills_sh") && + owner && + repo + ) { + return `${owner}/${repo}/${slug}`; + } + + // URL source with locator + if (input.sourceType === "url") { + const locator = asString(input.sourceLocator); + if (locator) { + try { + const url = new URL(locator); + const host = normalizeSkillSlug(url.host) ?? "url"; + return `url/${host}/${hashSkillValue(locator)}/${slug}`; + } catch { + return `url/unknown/${hashSkillValue(locator)}/${slug}`; + } + } + } + + // Local path source with locator + if (input.sourceType === "local_path") { + const locator = asString(input.sourceLocator); + if (locator) { + return `local/${hashSkillValue(resolve(locator))}/${slug}`; + } + } + + // Fallback: company-scoped key + return `company/${companyId}/${slug}`; +} + +// --------------------------------------------------------------------------- +// Skill import source resolution +// --------------------------------------------------------------------------- + +// Three-part key: org/repo/skill +const THREE_PART_KEY_RE = + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +// Two-part key: org/repo +const TWO_PART_KEY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +// skills.sh URL pattern +const SKILLS_SH_RE = + /^https?:\/\/(?:www\.)?skills\.sh\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/([A-Za-z0-9_.-]+))?(?:[?#].*)?$/i; + +export interface ParsedSkillImportSource { + resolvedSource: string; + requestedSkillSlug: string | null; + originalSkillsShUrl: string | null; +} + +/** + * Parse a raw skill import source string and resolve it to a normalized form. + * + * Supported input formats: + * - `owner/repo/skill` -> GitHub URL with skills.sh origin tracking + * - `owner/repo` -> GitHub URL + * - `https://skills.sh/owner/repo/skill` -> GitHub URL with skills.sh origin + * - `https://github.com/owner/repo` -> pass through + * - `/local/path` -> pass through + */ +export function parseSkillImportSource( + rawInput: string, +): ParsedSkillImportSource { + const trimmed = rawInput.trim(); + + // Three-part key (org/repo/skill) -- originates from skills.sh registry + if ( + !/^https?:\/\//i.test(trimmed) && + THREE_PART_KEY_RE.test(trimmed) + ) { + const [owner, repo, skillSlugRaw] = trimmed.split("/"); + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: normalizeSkillSlug(skillSlugRaw), + originalSkillsShUrl: `https://skills.sh/${owner}/${repo}/${skillSlugRaw}`, + }; + } + + // Two-part key (org/repo) -- resolve to GitHub + if ( + !/^https?:\/\//i.test(trimmed) && + TWO_PART_KEY_RE.test(trimmed) + ) { + return { + resolvedSource: `https://github.com/${trimmed}`, + requestedSkillSlug: null, + originalSkillsShUrl: null, + }; + } + + // skills.sh URL -> resolve to GitHub + const skillsShMatch = trimmed.match(SKILLS_SH_RE); + if (skillsShMatch) { + const [, owner, repo, skillSlugRaw] = skillsShMatch; + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: skillSlugRaw + ? normalizeSkillSlug(skillSlugRaw) + : null, + originalSkillsShUrl: trimmed, + }; + } + + // Everything else -- pass through as resolvedSource + return { + resolvedSource: trimmed, + requestedSkillSlug: null, + originalSkillsShUrl: null, + }; +} diff --git a/src/main/skill-types.ts b/src/main/skill-types.ts new file mode 100644 index 00000000..43cfe4f1 --- /dev/null +++ b/src/main/skill-types.ts @@ -0,0 +1,114 @@ +// --------------------------------------------------------------------------- +// Skill system type definitions and constants +// --------------------------------------------------------------------------- + +/** + * Source type for a skill -- matches the company_skills.source_type column. + */ +export type SkillSourceType = + | "github" + | "local_path" + | "url" + | "skills_sh" + | "managed"; + +/** + * Trust level derived from the file inventory of a skill package. + * Determines what content is injected and how cautiously. + */ +export type TrustLevel = + | "markdown_only" + | "assets" + | "scripts_executables"; + +/** + * A single file entry within a skill package inventory. + */ +export interface FileInventoryEntry { + path: string; + kind: "skill" | "reference" | "script" | "asset" | "markdown" | "other"; +} + +/** + * Full row interface matching the company_skills table. + * Property names use camelCase (JS convention) even though DB columns are snake_case. + */ +export interface SkillRecord { + id: string; + companyId: string; + key: string; + slug: string; + name: string; + description: string | null; + markdown: string; + sourceType: SkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: TrustLevel; + compatibility: string; + fileInventory: FileInventoryEntry[]; + metadata: Record | null; + createdAt: string; + updatedAt: string; +} + +/** + * Result returned after importing or updating a skill. + */ +export interface SkillImportResult { + skillId: string; + key: string; + name: string; + isUpdate: boolean; +} + +/** + * Runtime representation of a skill entry for adapter injection. + */ +export interface RuntimeSkillEntry { + runtimeName: string; + sourcePath: string; + name: string; + tokenEstimate: number; +} + +/** + * Minimal skill reference passed to worker threads. + * Subset of RuntimeSkillEntry to minimize serialization overhead. + */ +export interface ManagedSkillRef { + runtimeName: string; + sourcePath: string; +} + +/** + * Skill-to-agent assignment record. + */ +export interface SkillAssignment { + agentId: string; + skillId: string; +} + +/** + * Update status for a skill -- used to check if a newer version is available. + */ +export interface SkillUpdateStatus { + skillId: string; + key: string; + currentRef: string | null; + latestRef: string | null; + hasUpdate: boolean; +} + +/** + * Per-adapter token budgets for skill content injection. + * Values represent the maximum token count of skill content that can be + * included in a single prompt for each adapter type. + */ +export const TOKEN_BUDGETS: Readonly> = { + claude_local: 50000, + codex_local: 30000, + gemini_local: 50000, + opencode_local: 30000, + kimi_local: 20000, +}; diff --git a/tests/unit/skill-format.test.ts b/tests/unit/skill-format.test.ts new file mode 100644 index 00000000..34e9a8c5 --- /dev/null +++ b/tests/unit/skill-format.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "vitest"; +import { + parseFrontmatterMarkdown, + classifyInventoryKind, + deriveTrustLevel, + estimateTokens, +} from "@main/skill-format"; +import type { FileInventoryEntry } from "@main/skill-types"; + +describe("skill-format", () => { + describe("parseFrontmatterMarkdown", () => { + it("parses standard frontmatter and body", () => { + const result = parseFrontmatterMarkdown( + "---\nname: test\nslug: test\n---\n# Body", + ); + expect(result.frontmatter).toEqual({ name: "test", slug: "test" }); + expect(result.body).toBe("# Body"); + }); + + it("returns empty frontmatter for text without frontmatter", () => { + const result = parseFrontmatterMarkdown("No frontmatter"); + expect(result.frontmatter).toEqual({}); + expect(result.body).toBe("No frontmatter"); + }); + + it("handles frontmatter with no body", () => { + const result = parseFrontmatterMarkdown("---\nname: test\n---"); + expect(result.frontmatter).toEqual({ name: "test" }); + expect(result.body).toBe(""); + }); + + it("normalizes CRLF to LF before parsing", () => { + const result = parseFrontmatterMarkdown( + "---\r\nname: test\r\n---\r\n# Body", + ); + expect(result.frontmatter).toEqual({ name: "test" }); + expect(result.body).toBe("# Body"); + }); + + it("handles quoted string values", () => { + const result = parseFrontmatterMarkdown( + '---\nname: "My Skill"\n---\n', + ); + expect(result.frontmatter.name).toBe("My Skill"); + }); + + it("parses boolean values", () => { + const result = parseFrontmatterMarkdown( + "---\nenabled: true\ndisabled: false\n---\n", + ); + expect(result.frontmatter.enabled).toBe(true); + expect(result.frontmatter.disabled).toBe(false); + }); + + it("parses numeric values", () => { + const result = parseFrontmatterMarkdown( + "---\nversion: 42\nprecision: 3.14\n---\n", + ); + expect(result.frontmatter.version).toBe(42); + expect(result.frontmatter.precision).toBe(3.14); + }); + + it("parses null values", () => { + const result = parseFrontmatterMarkdown("---\nvalue: null\n---\n"); + expect(result.frontmatter.value).toBeNull(); + }); + + it("handles empty arrays and objects", () => { + const result = parseFrontmatterMarkdown( + "---\nitems: []\nopts: {}\n---\n", + ); + expect(result.frontmatter.items).toEqual([]); + expect(result.frontmatter.opts).toEqual({}); + }); + + it("handles body with multiple lines", () => { + const result = parseFrontmatterMarkdown( + "---\nname: test\n---\nLine 1\nLine 2\nLine 3", + ); + expect(result.body).toBe("Line 1\nLine 2\nLine 3"); + }); + }); + + describe("classifyInventoryKind", () => { + it("classifies SKILL.md as skill", () => { + expect(classifyInventoryKind("SKILL.md")).toBe("skill"); + }); + + it("classifies nested SKILL.md as skill", () => { + expect(classifyInventoryKind("subdir/SKILL.md")).toBe("skill"); + }); + + it("classifies references/ path as reference", () => { + expect(classifyInventoryKind("references/api.md")).toBe("reference"); + }); + + it("classifies scripts/ path as script", () => { + expect(classifyInventoryKind("scripts/build.sh")).toBe("script"); + }); + + it("classifies assets/ path as asset", () => { + expect(classifyInventoryKind("assets/logo.png")).toBe("asset"); + }); + + it("classifies .md files as markdown", () => { + expect(classifyInventoryKind("docs/guide.md")).toBe("markdown"); + }); + + it("classifies config.json as other", () => { + expect(classifyInventoryKind("config.json")).toBe("other"); + }); + + it("classifies .py files as script", () => { + expect(classifyInventoryKind("helper.py")).toBe("script"); + }); + + it("classifies .sh files as script", () => { + expect(classifyInventoryKind("run.sh")).toBe("script"); + }); + + it("classifies .js files as script", () => { + expect(classifyInventoryKind("index.js")).toBe("script"); + }); + + it("classifies .ts files as script", () => { + expect(classifyInventoryKind("main.ts")).toBe("script"); + }); + + it("classifies image files as asset", () => { + expect(classifyInventoryKind("photo.jpg")).toBe("asset"); + expect(classifyInventoryKind("icon.svg")).toBe("asset"); + expect(classifyInventoryKind("banner.webp")).toBe("asset"); + expect(classifyInventoryKind("doc.pdf")).toBe("asset"); + }); + + it("handles case insensitivity", () => { + expect(classifyInventoryKind("Skill.MD")).toBe("skill"); + expect(classifyInventoryKind("References/API.md")).toBe("reference"); + }); + }); + + describe("deriveTrustLevel", () => { + it("returns scripts_executables when any script file exists", () => { + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + { path: "scripts/build.sh", kind: "script" }, + ]; + expect(deriveTrustLevel(inventory)).toBe("scripts_executables"); + }); + + it("returns assets when asset but no script exists", () => { + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + { path: "assets/logo.png", kind: "asset" }, + ]; + expect(deriveTrustLevel(inventory)).toBe("assets"); + }); + + it("returns assets when other but no script exists", () => { + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + { path: "config.json", kind: "other" }, + ]; + expect(deriveTrustLevel(inventory)).toBe("assets"); + }); + + it("returns markdown_only when only markdown and skill files exist", () => { + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + { path: "references/api.md", kind: "reference" }, + { path: "docs/guide.md", kind: "markdown" }, + ]; + expect(deriveTrustLevel(inventory)).toBe("markdown_only"); + }); + + it("returns markdown_only for empty inventory", () => { + expect(deriveTrustLevel([])).toBe("markdown_only"); + }); + }); + + describe("estimateTokens", () => { + it("returns 1 for a 4-character string", () => { + expect(estimateTokens("abcd")).toBe(1); + }); + + it("returns 0 for empty string", () => { + expect(estimateTokens("")).toBe(0); + }); + + it("rounds up with Math.ceil", () => { + expect(estimateTokens("ab")).toBe(1); + expect(estimateTokens("abcde")).toBe(2); + }); + + it("handles longer strings", () => { + const text = "a".repeat(100); + expect(estimateTokens(text)).toBe(25); + }); + }); +}); diff --git a/tests/unit/skill-keys.test.ts b/tests/unit/skill-keys.test.ts new file mode 100644 index 00000000..1975ee8d --- /dev/null +++ b/tests/unit/skill-keys.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from "vitest"; +import { + deriveCanonicalSkillKey, + normalizeSkillSlug, + hashSkillValue, + parseSkillImportSource, +} from "@main/skill-keys"; + +describe("skill-keys", () => { + describe("normalizeSkillSlug", () => { + it("lowercases and replaces non-alphanumeric with hyphens", () => { + expect(normalizeSkillSlug("Hello World!")).toBe("hello-world"); + }); + + it("returns null for undefined", () => { + expect(normalizeSkillSlug(undefined)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(normalizeSkillSlug("")).toBeNull(); + }); + + it("returns null for null", () => { + expect(normalizeSkillSlug(null)).toBeNull(); + }); + + it("trims leading and trailing hyphens", () => { + expect(normalizeSkillSlug("--hello--")).toBe("hello"); + }); + + it("collapses consecutive non-alphanumeric chars", () => { + expect(normalizeSkillSlug("a b c")).toBe("a-b-c"); + }); + + it("returns null for string that becomes empty", () => { + expect(normalizeSkillSlug("!!!")).toBeNull(); + }); + + it("handles already-normalized slugs", () => { + expect(normalizeSkillSlug("code-review")).toBe("code-review"); + }); + }); + + describe("hashSkillValue", () => { + it("returns first 10 chars of SHA-256 hex digest", () => { + const hash = hashSkillValue("test-value"); + expect(hash).toHaveLength(10); + expect(hash).toMatch(/^[0-9a-f]{10}$/); + }); + + it("produces deterministic output", () => { + const hash1 = hashSkillValue("same-input"); + const hash2 = hashSkillValue("same-input"); + expect(hash1).toBe(hash2); + }); + + it("produces different output for different inputs", () => { + const hash1 = hashSkillValue("input-a"); + const hash2 = hashSkillValue("input-b"); + expect(hash1).not.toBe(hash2); + }); + }); + + describe("deriveCanonicalSkillKey", () => { + it("returns owner/repo/slug for GitHub source with metadata", () => { + const result = deriveCanonicalSkillKey("company-1", { + slug: "code-review", + sourceType: "github", + sourceLocator: "https://github.com/anthropics/skills", + metadata: { owner: "anthropics", repo: "skills" }, + }); + expect(result).toBe("anthropics/skills/code-review"); + }); + + it("returns url/host/hash/slug for URL source", () => { + const result = deriveCanonicalSkillKey("company-1", { + slug: "my-skill", + sourceType: "url", + sourceLocator: "https://example.com/skill.md", + metadata: null, + }); + expect(result).toMatch(/^url\/example-com\/[0-9a-f]{10}\/my-skill$/); + }); + + it("returns local/hash/slug for local_path source", () => { + const result = deriveCanonicalSkillKey("company-1", { + slug: "review", + sourceType: "local_path", + sourceLocator: "/home/user/skills/review", + metadata: null, + }); + expect(result).toMatch(/^local\/[0-9a-f]{10}\/review$/); + }); + + it("returns same format as GitHub for skills_sh source with owner/repo", () => { + const result = deriveCanonicalSkillKey("company-1", { + slug: "lint", + sourceType: "skills_sh", + sourceLocator: null, + metadata: { owner: "anthropics", repo: "skills" }, + }); + expect(result).toBe("anthropics/skills/lint"); + }); + + it("returns company/companyId/slug as fallback", () => { + const result = deriveCanonicalSkillKey("company-1", { + slug: "custom", + sourceType: "managed", + sourceLocator: null, + metadata: null, + }); + expect(result).toBe("company/company-1/custom"); + }); + + it("defaults slug to skill when null-like", () => { + const result = deriveCanonicalSkillKey("company-1", { + slug: "", + sourceType: "managed", + sourceLocator: null, + metadata: null, + }); + expect(result).toBe("company/company-1/skill"); + }); + }); + + describe("parseSkillImportSource", () => { + it("parses three-part key as GitHub with skills.sh origin", () => { + const result = parseSkillImportSource("owner/repo/skill"); + expect(result.resolvedSource).toBe("https://github.com/owner/repo"); + expect(result.requestedSkillSlug).toBe("skill"); + expect(result.originalSkillsShUrl).toBe( + "https://skills.sh/owner/repo/skill", + ); + }); + + it("parses two-part key as GitHub", () => { + const result = parseSkillImportSource("owner/repo"); + expect(result.resolvedSource).toBe("https://github.com/owner/repo"); + expect(result.requestedSkillSlug).toBeNull(); + expect(result.originalSkillsShUrl).toBeNull(); + }); + + it("parses skills.sh URL", () => { + const result = parseSkillImportSource( + "https://skills.sh/owner/repo/skill", + ); + expect(result.resolvedSource).toBe("https://github.com/owner/repo"); + expect(result.requestedSkillSlug).toBe("skill"); + expect(result.originalSkillsShUrl).toBe( + "https://skills.sh/owner/repo/skill", + ); + }); + + it("passes through GitHub URL", () => { + const result = parseSkillImportSource( + "https://github.com/owner/repo", + ); + expect(result.resolvedSource).toBe( + "https://github.com/owner/repo", + ); + expect(result.requestedSkillSlug).toBeNull(); + expect(result.originalSkillsShUrl).toBeNull(); + }); + + it("passes through local path", () => { + const result = parseSkillImportSource("/local/path"); + expect(result.resolvedSource).toBe("/local/path"); + expect(result.requestedSkillSlug).toBeNull(); + expect(result.originalSkillsShUrl).toBeNull(); + }); + + it("handles skills.sh URL without skill slug", () => { + const result = parseSkillImportSource( + "https://skills.sh/owner/repo", + ); + expect(result.resolvedSource).toBe("https://github.com/owner/repo"); + expect(result.requestedSkillSlug).toBeNull(); + }); + }); +}); diff --git a/tests/unit/skill-types.test.ts b/tests/unit/skill-types.test.ts new file mode 100644 index 00000000..6572d96e --- /dev/null +++ b/tests/unit/skill-types.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { + TOKEN_BUDGETS, +} from "@main/skill-types"; +import type { + SkillSourceType, + TrustLevel, + FileInventoryEntry, + SkillRecord, + SkillImportResult, + RuntimeSkillEntry, + ManagedSkillRef, + SkillAssignment, + SkillUpdateStatus, +} from "@main/skill-types"; + +describe("skill-types", () => { + describe("TOKEN_BUDGETS", () => { + it("has entries for all five adapter types", () => { + expect(TOKEN_BUDGETS).toHaveProperty("claude_local"); + expect(TOKEN_BUDGETS).toHaveProperty("codex_local"); + expect(TOKEN_BUDGETS).toHaveProperty("gemini_local"); + expect(TOKEN_BUDGETS).toHaveProperty("opencode_local"); + expect(TOKEN_BUDGETS).toHaveProperty("kimi_local"); + }); + + it("has numeric token caps for each adapter", () => { + expect(TOKEN_BUDGETS.claude_local).toBe(50000); + expect(TOKEN_BUDGETS.codex_local).toBe(30000); + expect(TOKEN_BUDGETS.gemini_local).toBe(50000); + expect(TOKEN_BUDGETS.opencode_local).toBe(30000); + expect(TOKEN_BUDGETS.kimi_local).toBe(20000); + }); + }); + + describe("type shape checks", () => { + it("SkillSourceType accepts all five source kinds", () => { + const sources: SkillSourceType[] = [ + "github", + "local_path", + "url", + "skills_sh", + "managed", + ]; + expect(sources).toHaveLength(5); + }); + + it("TrustLevel accepts all three levels", () => { + const levels: TrustLevel[] = [ + "markdown_only", + "assets", + "scripts_executables", + ]; + expect(levels).toHaveLength(3); + }); + + it("FileInventoryEntry has path and kind", () => { + const entry: FileInventoryEntry = { path: "SKILL.md", kind: "skill" }; + expect(entry.path).toBe("SKILL.md"); + expect(entry.kind).toBe("skill"); + }); + + it("FileInventoryEntry kind accepts all six values", () => { + const kinds: FileInventoryEntry["kind"][] = [ + "skill", + "reference", + "script", + "asset", + "markdown", + "other", + ]; + expect(kinds).toHaveLength(6); + }); + + it("SkillRecord has all expected properties", () => { + const record: SkillRecord = { + id: "test-id", + companyId: "company-1", + key: "owner/repo/slug", + slug: "slug", + name: "Test Skill", + description: null, + markdown: "# Test", + sourceType: "github", + sourceLocator: "https://github.com/owner/repo", + sourceRef: "abc123", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }; + expect(record.id).toBe("test-id"); + expect(record.companyId).toBe("company-1"); + }); + + it("SkillImportResult has skillId, key, name, isUpdate", () => { + const result: SkillImportResult = { + skillId: "id-1", + key: "owner/repo/slug", + name: "Test", + isUpdate: false, + }; + expect(result.isUpdate).toBe(false); + }); + + it("RuntimeSkillEntry has runtimeName, sourcePath, name, tokenEstimate", () => { + const entry: RuntimeSkillEntry = { + runtimeName: "claude", + sourcePath: "/path/to/skill", + name: "test-skill", + tokenEstimate: 1000, + }; + expect(entry.tokenEstimate).toBe(1000); + }); + + it("ManagedSkillRef has runtimeName and sourcePath only", () => { + const ref: ManagedSkillRef = { + runtimeName: "claude", + sourcePath: "/path/to/skill", + }; + expect(ref.runtimeName).toBe("claude"); + expect(ref.sourcePath).toBe("/path/to/skill"); + }); + + it("SkillAssignment has agentId and skillId", () => { + const assignment: SkillAssignment = { + agentId: "agent-1", + skillId: "skill-1", + }; + expect(assignment.agentId).toBe("agent-1"); + }); + + it("SkillUpdateStatus has all expected fields", () => { + const status: SkillUpdateStatus = { + skillId: "skill-1", + key: "owner/repo/slug", + currentRef: "abc", + latestRef: "def", + hasUpdate: true, + }; + expect(status.hasUpdate).toBe(true); + }); + }); +});