From a8db40180cd0ada309fc704cc8a140f03224c549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 26 Mar 2026 00:27:39 -0400 Subject: [PATCH] feat(02-01): create canonical key derivation and skill source resolution - deriveCanonicalSkillKey handles all 5 source types deterministically - normalizeSkillSlug with lowercase, hyphen-delimited, edge case handling - hashSkillValue produces 10-char SHA-256 hex digests - parseSkillImportSource resolves GitHub, skills.sh, URL, local path inputs --- src/main/skill-keys.ts | 194 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/main/skill-keys.ts 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, + }; +}