using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Skills; public sealed class SessionSkillRegistry : ISessionSkillRegistry { private readonly IDbContextFactory _dbFactory; private readonly IRepoCloner _cloner; private readonly string _skillsRoot; public SessionSkillRegistry( IDbContextFactory dbFactory, IRepoCloner cloner, string? skillsRoot = null) { _dbFactory = dbFactory; _cloner = cloner; _skillsRoot = skillsRoot ?? Path.Combine(Paths.AppDataRoot(), "session-skills"); } public async Task> InstallAsync(string url, CancellationToken ct) { var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_skill_clone_{Guid.NewGuid():N}"); try { var cloned = await _cloner.CloneAsync(url, tempDir, ct); var discovered = DiscoverSkills(cloned.LocalPath); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var repo = new SessionSkillRepository(ctx); foreach (var skill in discovered) { var existing = await repo.GetAsync(skill.Name, ct); if (existing is not null && existing.SourceUrl != url) { throw new InvalidOperationException( $"Skill '{skill.Name}' is already installed from a different source ('{existing.SourceUrl}'). " + "Remove it before installing from a new source."); } } var installedNames = new List(); foreach (var skill in discovered) { InstallSkillFiles(skill, url, cloned.PinnedRef, out var entity); await repo.UpsertAsync(entity, ct); installedNames.Add(skill.Name); } return installedNames; } finally { TryDeleteDirectory(tempDir); } } public async Task UpdateAsync(string sourceUrl, CancellationToken ct) { var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_skill_clone_{Guid.NewGuid():N}"); try { var cloned = await _cloner.CloneAsync(sourceUrl, tempDir, ct); var discovered = DiscoverSkills(cloned.LocalPath); var discoveredNames = discovered.Select(s => s.Name).ToHashSet(); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var repo = new SessionSkillRepository(ctx); var existingRows = await repo.ListBySourceAsync(sourceUrl, ct); foreach (var stale in existingRows.Where(r => !discoveredNames.Contains(r.Name))) { TryDeleteDirectory(Path.Combine(_skillsRoot, stale.Name)); await repo.DeleteAsync(stale.Name, ct); } foreach (var skill in discovered) { InstallSkillFiles(skill, sourceUrl, cloned.PinnedRef, out var entity); await repo.UpsertAsync(entity, ct); } } finally { TryDeleteDirectory(tempDir); } } public async Task RemoveAsync(string sourceUrl, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var repo = new SessionSkillRepository(ctx); var rows = await repo.ListBySourceAsync(sourceUrl, ct); foreach (var row in rows) TryDeleteDirectory(Path.Combine(_skillsRoot, row.Name)); await repo.DeleteBySourceAsync(sourceUrl, ct); } public async Task> ListAsync(CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); return await new SessionSkillRepository(ctx).ListAsync(ct); } private sealed record DiscoveredSkill(string Name, string Description, string SourceDir, string Subpath); private static List DiscoverSkills(string cloneRoot) { var skillsDir = Path.Combine(cloneRoot, "skills"); var discovered = new List(); if (Directory.Exists(skillsDir)) { foreach (var dir in Directory.EnumerateDirectories(skillsDir).OrderBy(d => d, StringComparer.Ordinal)) { var skillMd = Path.Combine(dir, "SKILL.md"); if (!File.Exists(skillMd)) continue; var frontmatter = SkillFrontmatter.Parse(skillMd); var dirName = Path.GetFileName(dir); discovered.Add(new DiscoveredSkill(frontmatter.Name, frontmatter.Description, dir, $"skills/{dirName}")); } if (discovered.Count > 0) return discovered; } var rootSkillMd = Path.Combine(cloneRoot, "SKILL.md"); if (File.Exists(rootSkillMd)) { var frontmatter = SkillFrontmatter.Parse(rootSkillMd); discovered.Add(new DiscoveredSkill(frontmatter.Name, frontmatter.Description, cloneRoot, ".")); return discovered; } throw new InvalidOperationException( "No skills found: expected either a top-level 'skills//SKILL.md' layout or a root 'SKILL.md'."); } private void InstallSkillFiles(DiscoveredSkill skill, string sourceUrl, string pinnedRef, out SessionSkillEntity entity) { var destDir = Path.Combine(_skillsRoot, skill.Name); if (Directory.Exists(destDir)) Directory.Delete(destDir, recursive: true); Directory.CreateDirectory(destDir); CopyFlat(skill.SourceDir, destDir); entity = new SessionSkillEntity { Name = skill.Name, SourceUrl = sourceUrl, PinnedRef = pinnedRef, Subpath = skill.Subpath, Description = skill.Description, AddedAt = DateTimeOffset.UtcNow, }; } private static void CopyFlat(string sourceDir, string destDir) { foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) { var relative = Path.GetRelativePath(sourceDir, file); if (relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) .Contains(".git", StringComparer.Ordinal)) continue; var destPath = Path.Combine(destDir, relative); var destParent = Path.GetDirectoryName(destPath); if (!string.IsNullOrEmpty(destParent)) Directory.CreateDirectory(destParent); File.Copy(file, destPath, overwrite: true); } } private static void TryDeleteDirectory(string dir) { try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } catch { /* best effort */ } } }