// --------------------------------------------------------------------------- // SkillService -- full skill lifecycle: import, CRUD, assignment, token budget, // update detection. Factory pattern following createOrchestrationService. // --------------------------------------------------------------------------- import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; import { mkdir, writeFile, rm } from "node:fs/promises"; import { join, resolve, basename, dirname, relative, posix } from "node:path"; import type { AppDatabase } from "./database"; import type { DesktopResult } from "@shared/contracts"; import type { FileInventoryEntry, SkillRecord, SkillImportResult, SkillSourceType, SkillUpdateStatus, RuntimeSkillEntry, } from "./skill-types.js"; import { parseFrontmatterMarkdown, classifyInventoryKind, deriveTrustLevel, estimateTokens, } from "./skill-format.js"; import { deriveCanonicalSkillKey, normalizeSkillSlug, hashSkillValue, parseSkillImportSource, } from "./skill-keys.js"; import { resolveGitHubCommitSha, fetchGitHubSkills, } from "./skill-github.js"; import { TOKEN_BUDGETS } from "./skill-types.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface SkillServiceDependencies { db: AppDatabase; profileDir: string; logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; }; } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) { // ---- Result helpers ---- function ok(data: T): DesktopResult { return { ok: true, data }; } function fail(code: string, message: string): DesktopResult { return { ok: false, error: { code, message } }; } // ---- Path helpers (D-10: skills stored in profileDir/skills/{companyId}/) ---- function skillsRoot(companyId: string): string { return join(profileDir, "skills", companyId); } function skillFilesDir(companyId: string, slug: string): string { return join(skillsRoot(companyId), slug); } function runtimeDir(companyId: string): string { return join(skillsRoot(companyId), "__runtime__"); } // ---- File materialization helpers ---- /** * Write SKILL.md (and tracked inventory files from GitHub downloads) * to the local skill directory. */ 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; } /** Remove a skill's file directory from disk. */ async function cleanupSkillFiles(companyId: string, slug: string): Promise { const dir = skillFilesDir(companyId, slug); if (existsSync(dir)) { await rm(dir, { recursive: true, force: true }); } } // ---- GitHub URL parser ---- function parseGitHubUrl(url: string): { owner: string; repo: string } | null { try { const parsed = new URL(url); if (parsed.hostname !== "github.com") return null; const parts = parsed.pathname.split("/").filter(Boolean); if (parts.length < 2) return null; return { owner: parts[0]!, repo: parts[1]!.replace(/\.git$/i, ""), }; } catch { return null; } } // ---- Import routing ---- /** * Import skills from any supported source. * Routes to the correct handler based on parseSkillImportSource. */ async function importFromSource( companyId: string, rawSource: string, ): Promise> { try { const parsed = parseSkillImportSource(rawSource); const source = parsed.resolvedSource; // skills.sh resolved sources are always GitHub URLs if (parsed.originalSkillsShUrl) { const results = await importFromGitHub( companyId, source, parsed.requestedSkillSlug, parsed.originalSkillsShUrl, ); return ok(results); } // GitHub URL if (source.includes("github.com")) { const results = await importFromGitHub( companyId, source, parsed.requestedSkillSlug, null, ); return ok(results); } // Local path (absolute path, drive letter on Windows, or ~ home dir) if ( source.startsWith("/") || /^[A-Za-z]:/.test(source) || source.startsWith("~") ) { const results = await importFromLocal(companyId, source); return ok(results); } // HTTP/HTTPS URL 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); } } // ---- GitHub import (SKILL-01) ---- async function importFromGitHub( companyId: string, url: string, requestedSlug: string | null, originalSkillsShUrl: string | null, ): Promise { const ghParsed = parseGitHubUrl(url); if (!ghParsed) { throw new Error(`Invalid GitHub URL: ${url}`); } const { owner, repo } = ghParsed; // Resolve latest commit SHA const sha = await resolveGitHubCommitSha(owner, repo, undefined, logger); if (!sha) { throw new Error(`Failed to resolve commit SHA for ${owner}/${repo}`); } // Fetch SKILL.md files from repo const { skills: githubSkills, commitSha } = await fetchGitHubSkills( owner, repo, sha, requestedSlug, logger, ); if (githubSkills.length === 0) { throw new Error(`No SKILL.md files found in ${owner}/${repo}`); } const results: SkillImportResult[] = []; const sourceType: SkillSourceType = originalSkillsShUrl ? "skills_sh" : "github"; const nowTs = new Date().toISOString(); for (const ghSkill of githubSkills) { const parsed = parseFrontmatterMarkdown(ghSkill.markdown); const rawSlug = normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? normalizeSkillSlug( ghSkill.skillMdPath === "SKILL.md" ? repo : basename(dirname(ghSkill.skillMdPath)), ) ?? repo; // Build file inventory from the tree paths const skillDir = ghSkill.skillMdPath === "SKILL.md" ? "" : ghSkill.skillMdPath.slice(0, ghSkill.skillMdPath.lastIndexOf("/")); const prefix = skillDir ? `${skillDir}/` : ""; const inventory: FileInventoryEntry[] = [ { path: "SKILL.md", kind: "skill" as const }, ...ghSkill.inventory.map((filePath) => { const relativePath = prefix ? filePath.slice(prefix.length) : filePath; return { path: relativePath, kind: classifyInventoryKind(relativePath), }; }), ]; const metadata: Record = { sourceKind: "github", owner, repo, ref: commitSha, trackingRef: "HEAD", ...(originalSkillsShUrl ? { originalSkillsShUrl } : {}), }; const key = deriveCanonicalSkillKey(companyId, { slug: rawSlug, sourceType, sourceLocator: url, metadata, }); const trustLevel = deriveTrustLevel(inventory); const name = (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug; const description = typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null; const skillId = randomUUID(); // Materialize files to disk await materializeSkillFiles(companyId, rawSlug, ghSkill.markdown, inventory); // Upsert to database const { isUpdate } = db.upsertCompanySkill({ id: skillId, companyId, key, slug: rawSlug, name, description, markdown: ghSkill.markdown, sourceType, sourceLocator: url, sourceRef: commitSha, trustLevel, compatibility: "compatible", fileInventory: JSON.stringify(inventory), metadata: JSON.stringify(metadata), createdAt: nowTs, updatedAt: nowTs, }); results.push({ skillId, key, name, isUpdate }); logger.info(`Skill ${isUpdate ? "updated" : "imported"}: ${key} from ${owner}/${repo}`); } return results; } // ---- Local path import (SKILL-02, D-04) ---- 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()) { // Single file treated as a skill 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"; // Build inventory from directory listing 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; } // ---- URL import (SKILL-03, D-05) ---- 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(); // Derive slug from frontmatter or URL path 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"; // Single-file skill: only SKILL.md const inventory: FileInventoryEntry[] = [ { path: "SKILL.md", kind: "skill" }, ]; const trustLevel: "markdown_only" = "markdown_only"; 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; } // ---- CRUD ---- function listForCompany(companyId: string): SkillRecord[] { return db.listCompanySkills(companyId); } function getDetail(companyId: string, skillId: string): DesktopResult { const skill = db.getCompanySkill(companyId, skillId); if (!skill) return fail("not_found", `Skill ${skillId} not found`); return ok(skill); } /** Delete a skill: (1) assignments cascade via FK, (2) files from disk, (3) DB record per D-12. */ async function deleteSkill( companyId: string, skillId: string, ): Promise> { const skill = db.getCompanySkill(companyId, skillId); if (!skill) return fail("not_found", `Skill ${skillId} not found`); try { // D-12 order: (1) assignments cascade via FK on delete, // (2) delete files from disk await cleanupSkillFiles(companyId, skill.slug); // (3) delete DB record (cascades agent_skill_assignments via FK) db.deleteCompanySkill(companyId, skillId); logger.info(`Skill deleted: ${skill.key}`); return ok(undefined); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error("Skill deletion failed:", message); return fail("delete_error", message); } } // ---- Agent assignment (SKILL-07) ---- function assignToAgent( companyId: string, agentId: string, skillId: string, ): DesktopResult { const skill = db.getCompanySkill(companyId, skillId); if (!skill) return fail("not_found", `Skill ${skillId} not found for company`); db.assignSkillToAgent(agentId, skillId); return ok(undefined); } function removeFromAgent( _companyId: string, agentId: string, skillId: string, ): DesktopResult { db.removeSkillFromAgent(agentId, skillId); return ok(undefined); } // ---- Update detection (SKILL-11, D-11) ---- async function checkUpdateStatus( companyId: string, skillId: string, ): Promise> { const skill = db.getCompanySkill(companyId, skillId); if (!skill) return fail("not_found", `Skill ${skillId} not found`); // Only GitHub/skills_sh sources support update checks if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") { return ok({ skillId: skill.id, key: skill.key, currentRef: skill.sourceRef, latestRef: null, hasUpdate: false, }); } // Parse owner/repo from metadata const metadata = skill.metadata; const owner = typeof metadata?.owner === "string" ? metadata.owner : null; const repo = typeof metadata?.repo === "string" ? metadata.repo : null; if (!owner || !repo) { // Try to parse from sourceLocator URL const ghParsed = skill.sourceLocator ? parseGitHubUrl(skill.sourceLocator) : null; if (!ghParsed) { return ok({ skillId: skill.id, key: skill.key, currentRef: skill.sourceRef, latestRef: null, hasUpdate: false, }); } const latestRef = await resolveGitHubCommitSha(ghParsed.owner, ghParsed.repo, undefined, logger); return ok({ skillId: skill.id, key: skill.key, currentRef: skill.sourceRef, latestRef, hasUpdate: latestRef != null && latestRef !== skill.sourceRef, }); } const trackingRef = typeof metadata?.trackingRef === "string" ? metadata.trackingRef : undefined; const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, logger); return ok({ skillId: skill.id, key: skill.key, currentRef: skill.sourceRef, latestRef, hasUpdate: latestRef != null && latestRef !== skill.sourceRef, }); } // ---- Runtime skill entries (SKILL-09 token budget) ---- /** * Build the list of RuntimeSkillEntry for adapter injection. * Applies token budget enforcement: skills sorted by creation date, * accumulated until budget exceeded, then skipped with warning. */ function listRuntimeSkillEntries( companyId: string, agentId: string, adapterType: string, ): RuntimeSkillEntry[] { const allSkills = db.listCompanySkills(companyId); const agentAssignments = db.listAgentSkillAssignments(agentId); // If agent has specific assignments, filter to those; otherwise use all const skills = agentAssignments.length > 0 ? allSkills.filter((s) => agentAssignments.includes(s.id)) : allSkills; // Sort by creation date (oldest first -- first imported gets priority) const sorted = [...skills].sort( (a, b) => a.createdAt.localeCompare(b.createdAt), ); const budget = TOKEN_BUDGETS[adapterType] ?? 30000; let accumulated = 0; const entries: RuntimeSkillEntry[] = []; for (const skill of sorted) { const tokenEstimate = estimateTokens(skill.markdown); if (accumulated + tokenEstimate > budget) { logger.warn( `Token budget exceeded for ${adapterType}: skipping skill "${skill.name}" (${tokenEstimate} tokens, budget ${budget}, used ${accumulated})`, ); continue; } accumulated += tokenEstimate; entries.push({ runtimeName: skill.slug, sourcePath: skillFilesDir(companyId, skill.slug), name: skill.name, tokenEstimate, }); } return entries; } // ---- Local directory walker ---- 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; } // ---- Public API ---- return { importFromSource, importFromGitHub, importFromLocal, importFromUrl, listForCompany, getDetail, deleteSkill, assignToAgent, removeFromAgent, checkUpdateStatus, listRuntimeSkillEntries, }; }