import { randomUUID } from "node:crypto"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { mkdir, writeFile, rm } from "node:fs/promises"; import { join, resolve, basename, dirname, relative } from "node:path"; import type { AppDatabase } from "../database"; import type { DesktopResult } from "@shared/contracts"; import type { FileInventoryEntry, SkillImportResult } from "../skill-types.js"; import { parseFrontmatterMarkdown, classifyInventoryKind, deriveTrustLevel, } from "../skill-format.js"; import { deriveCanonicalSkillKey, normalizeSkillSlug, hashSkillValue, parseSkillImportSource, } from "../skill-keys.js"; import { createGitHubImporter } from "./import-github"; interface ImportDeps { db: AppDatabase; profileDir: string; logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; }; skillsRoot: (companyId: string) => string; skillFilesDir: (companyId: string, slug: string) => string; } export function createSkillImporter({ db, profileDir: _profileDir, logger, skillsRoot: _skillsRoot, skillFilesDir }: ImportDeps) { function ok(data: T): DesktopResult { return { ok: true, data }; } function fail(code: string, message: string): DesktopResult { return { ok: false, error: { code, message } }; } const ghImporter = createGitHubImporter({ db, logger, skillFilesDir }); async function materializeSkillFiles( companyId: string, slug: string, markdown: string, _inventory: FileInventoryEntry[], ): Promise { const dir = skillFilesDir(companyId, slug); await mkdir(dir, { recursive: true }); await writeFile(join(dir, "SKILL.md"), markdown, "utf-8"); return dir; } async function cleanupSkillFiles(companyId: string, slug: string): Promise { const dir = skillFilesDir(companyId, slug); if (existsSync(dir)) { await rm(dir, { recursive: true, force: true }); } } async function importFromSource( companyId: string, rawSource: string, ): Promise> { try { const parsed = parseSkillImportSource(rawSource); const source = parsed.resolvedSource; if (parsed.originalSkillsShUrl) { const results = await ghImporter.importFromGitHub( companyId, source, parsed.requestedSkillSlug, parsed.originalSkillsShUrl, ); return ok(results); } if (source.includes("github.com")) { const results = await ghImporter.importFromGitHub( companyId, source, parsed.requestedSkillSlug, null, ); return ok(results); } if ( source.startsWith("/") || /^[A-Za-z]:/.test(source) || source.startsWith("~") ) { const results = await importFromLocal(companyId, source); return ok(results); } if (/^https?:\/\//i.test(source)) { const results = await importFromUrl(companyId, source); return ok(results); } return fail("invalid_source", `Unrecognized skill source format: ${rawSource}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error("Skill import failed:", message); return fail("import_error", message); } } async function importFromLocal( companyId: string, localPath: string, ): Promise { const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "")); if (!existsSync(resolvedPath)) { throw new Error(`Local path does not exist: ${resolvedPath}`); } const stat = statSync(resolvedPath); const results: SkillImportResult[] = []; const nowTs = new Date().toISOString(); if (stat.isFile()) { const markdown = readFileSync(resolvedPath, "utf-8"); const parsed = parseFrontmatterMarkdown(markdown); const rawSlug = normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? normalizeSkillSlug(basename(resolvedPath, ".md")) ?? "skill"; const inventory: FileInventoryEntry[] = [ { path: "SKILL.md", kind: "skill" }, ]; const trustLevel = deriveTrustLevel(inventory); const contentHash = hashSkillValue(markdown); const key = deriveCanonicalSkillKey(companyId, { slug: rawSlug, sourceType: "local_path", sourceLocator: resolvedPath, metadata: null, }); const skillId = randomUUID(); await materializeSkillFiles(companyId, rawSlug, markdown, inventory); const { isUpdate } = db.upsertCompanySkill({ id: skillId, companyId, key, slug: rawSlug, name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, markdown, sourceType: "local_path", sourceLocator: resolvedPath, sourceRef: contentHash, trustLevel, compatibility: "compatible", fileInventory: JSON.stringify(inventory), metadata: null, createdAt: nowTs, updatedAt: nowTs, }); results.push({ skillId, key, name: rawSlug, isUpdate }); logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`); return results; } // Directory: walk to find SKILL.md files const skillMdFiles = findSkillMdFiles(resolvedPath); if (skillMdFiles.length === 0) { throw new Error(`No SKILL.md files found in ${resolvedPath}`); } for (const skillMdPath of skillMdFiles) { const skillDir = dirname(skillMdPath); const markdown = readFileSync(skillMdPath, "utf-8"); const parsed = parseFrontmatterMarkdown(markdown); const rawSlug = normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? normalizeSkillSlug(basename(skillDir)) ?? "skill"; const inventory = buildLocalInventory(skillDir, skillMdPath); const trustLevel = deriveTrustLevel(inventory); const contentHash = hashSkillValue(markdown); const key = deriveCanonicalSkillKey(companyId, { slug: rawSlug, sourceType: "local_path", sourceLocator: skillDir, metadata: null, }); const skillId = randomUUID(); await materializeSkillFiles(companyId, rawSlug, markdown, inventory); const { isUpdate } = db.upsertCompanySkill({ id: skillId, companyId, key, slug: rawSlug, name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, markdown, sourceType: "local_path", sourceLocator: skillDir, sourceRef: contentHash, trustLevel, compatibility: "compatible", fileInventory: JSON.stringify(inventory), metadata: null, createdAt: nowTs, updatedAt: nowTs, }); results.push({ skillId, key, name: rawSlug, isUpdate }); logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`); } return results; } async function importFromUrl( companyId: string, url: string, ): Promise { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`); } const markdown = await response.text(); const parsed = parseFrontmatterMarkdown(markdown); const nowTs = new Date().toISOString(); let urlSlug: string | null = null; try { const urlObj = new URL(url); const pathParts = urlObj.pathname.split("/").filter(Boolean); const lastPart = pathParts[pathParts.length - 1] ?? ""; urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, "")); } catch { // ignore URL parsing errors } const rawSlug = normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? urlSlug ?? "skill"; const inventory: FileInventoryEntry[] = [ { path: "SKILL.md", kind: "skill" }, ]; const trustLevel = "markdown_only" as const; const key = deriveCanonicalSkillKey(companyId, { slug: rawSlug, sourceType: "url", sourceLocator: url, metadata: null, }); const skillId = randomUUID(); await materializeSkillFiles(companyId, rawSlug, markdown, inventory); const { isUpdate } = db.upsertCompanySkill({ id: skillId, companyId, key, slug: rawSlug, name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, markdown, sourceType: "url", sourceLocator: url, sourceRef: null, trustLevel, compatibility: "compatible", fileInventory: JSON.stringify(inventory), metadata: null, createdAt: nowTs, updatedAt: nowTs, }); const results: SkillImportResult[] = [ { skillId, key, name: rawSlug, isUpdate }, ]; logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`); return results; } function findSkillMdFiles(rootDir: string): string[] { const found: string[] = []; function walk(dir: string) { try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { walk(fullPath); } else if (entry.name === "SKILL.md") { found.push(fullPath); } } } catch { // Skip unreadable directories } } walk(rootDir); return found; } function buildLocalInventory(skillDir: string, skillMdPath: string): FileInventoryEntry[] { const inventory: FileInventoryEntry[] = [ { path: "SKILL.md", kind: "skill" }, ]; try { const entries = readdirSync(skillDir, { withFileTypes: true, recursive: true }); for (const entry of entries) { if (!entry.isFile()) continue; const entryPath = entry.parentPath ? join(entry.parentPath, entry.name) : join(skillDir, entry.name); const fullPath = resolve(entryPath); if (fullPath === skillMdPath) continue; const relativePath = relative(skillDir, fullPath).replace(/\\/g, "/"); inventory.push({ path: relativePath, kind: classifyInventoryKind(relativePath), }); } } catch { // If directory listing fails, return just the SKILL.md entry } return inventory; } return { importFromSource, importFromGitHub: ghImporter.importFromGitHub, importFromLocal, importFromUrl, cleanupSkillFiles, parseGitHubUrl: ghImporter.parseGitHubUrl, findSkillMdFiles, buildLocalInventory, }; }