diff --git a/src/ClaudeDo.Worker/Skills/GitRepoCloner.cs b/src/ClaudeDo.Worker/Skills/GitRepoCloner.cs new file mode 100644 index 00000000..9058f0c8 --- /dev/null +++ b/src/ClaudeDo.Worker/Skills/GitRepoCloner.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using System.Text; +using ClaudeDo.Data.Git; + +namespace ClaudeDo.Worker.Skills; + +public sealed class GitRepoCloner : IRepoCloner +{ + private readonly GitService _git; + + public GitRepoCloner(GitService git) => _git = git; + + public async Task CloneAsync(string url, string destDir, CancellationToken ct) + { + var parentDir = Path.GetDirectoryName(destDir); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + var (exitCode, stderr) = await RunGitAsync(["clone", "--depth", "1", url, destDir], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git clone '{url}' failed (exit {exitCode}): {stderr}"); + + var pinnedRef = await _git.RevParseHeadAsync(destDir, ct); + return new ClonedRepo(destDir, pinnedRef); + } + + private static async Task<(int ExitCode, string Stderr)> RunGitAsync(IEnumerable args, CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = "git", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = new Process { StartInfo = psi }; + proc.Start(); + + await using var ctr = ct.Register(() => + { + try { proc.Kill(entireProcessTree: true); } + catch { /* already exited */ } + }); + + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + + await proc.WaitForExitAsync(CancellationToken.None); + await stdoutTask; + var stderr = await stderrTask; + + ct.ThrowIfCancellationRequested(); + + return (proc.ExitCode, stderr.TrimEnd()); + } +} diff --git a/src/ClaudeDo.Worker/Skills/Interfaces/IRepoCloner.cs b/src/ClaudeDo.Worker/Skills/Interfaces/IRepoCloner.cs new file mode 100644 index 00000000..27528c02 --- /dev/null +++ b/src/ClaudeDo.Worker/Skills/Interfaces/IRepoCloner.cs @@ -0,0 +1,8 @@ +namespace ClaudeDo.Worker.Skills; + +public sealed record ClonedRepo(string LocalPath, string PinnedRef); + +public interface IRepoCloner +{ + Task CloneAsync(string url, string destDir, CancellationToken ct); +} diff --git a/src/ClaudeDo.Worker/Skills/Interfaces/ISessionSkillRegistry.cs b/src/ClaudeDo.Worker/Skills/Interfaces/ISessionSkillRegistry.cs new file mode 100644 index 00000000..3f54c3f5 --- /dev/null +++ b/src/ClaudeDo.Worker/Skills/Interfaces/ISessionSkillRegistry.cs @@ -0,0 +1,11 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Worker.Skills; + +public interface ISessionSkillRegistry +{ + Task> InstallAsync(string url, CancellationToken ct); + Task UpdateAsync(string sourceUrl, CancellationToken ct); + Task RemoveAsync(string sourceUrl, CancellationToken ct); + Task> ListAsync(CancellationToken ct); +} diff --git a/src/ClaudeDo.Worker/Skills/SessionSkillRegistry.cs b/src/ClaudeDo.Worker/Skills/SessionSkillRegistry.cs new file mode 100644 index 00000000..449fa089 --- /dev/null +++ b/src/ClaudeDo.Worker/Skills/SessionSkillRegistry.cs @@ -0,0 +1,192 @@ +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 */ } + } +} diff --git a/src/ClaudeDo.Worker/Skills/SkillFrontmatter.cs b/src/ClaudeDo.Worker/Skills/SkillFrontmatter.cs new file mode 100644 index 00000000..32782452 --- /dev/null +++ b/src/ClaudeDo.Worker/Skills/SkillFrontmatter.cs @@ -0,0 +1,62 @@ +namespace ClaudeDo.Worker.Skills; + +internal static class SkillFrontmatter +{ + public sealed record ParsedFrontmatter(string Name, string Description); + + /// + /// Parses the YAML frontmatter block (between the first two "---" lines) of a SKILL.md + /// file, extracting "name" (required) and "description" (optional). The description may + /// be a folded scalar ("description: >"), whose indented continuation lines are joined + /// with spaces into a single string. + /// + public static ParsedFrontmatter Parse(string skillMdPath) + { + var lines = File.ReadAllLines(skillMdPath); + if (lines.Length == 0 || lines[0].Trim() != "---") + throw new InvalidOperationException($"'{skillMdPath}' has no YAML frontmatter block."); + + string? name = null; + var description = ""; + + var i = 1; + for (; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Trim() == "---") break; + + if (line.StartsWith("name:", StringComparison.Ordinal)) + { + name = line["name:".Length..].Trim().Trim('"', '\''); + } + else if (line.StartsWith("description:", StringComparison.Ordinal)) + { + var rest = line["description:".Length..].Trim(); + if (rest == ">" || rest == ">-" || rest == "|" || rest == "|-") + { + var folded = new List(); + var j = i + 1; + for (; j < lines.Length; j++) + { + var contLine = lines[j]; + if (contLine.Trim() == "---") break; + if (contLine.Length == 0) continue; + if (!char.IsWhiteSpace(contLine[0])) break; + folded.Add(contLine.Trim()); + } + description = string.Join(" ", folded); + i = j - 1; + } + else + { + description = rest.Trim('"', '\''); + } + } + } + + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException($"'{skillMdPath}' frontmatter is missing required 'name'."); + + return new ParsedFrontmatter(name, description); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Skills/FakeRepoCloner.cs b/tests/ClaudeDo.Worker.Tests/Skills/FakeRepoCloner.cs new file mode 100644 index 00000000..dc4387db --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Skills/FakeRepoCloner.cs @@ -0,0 +1,43 @@ +using ClaudeDo.Worker.Skills; + +namespace ClaudeDo.Worker.Tests.Skills; + +/// +/// Test double for IRepoCloner: "clones" by copying a pre-built local fixture directory, +/// so tests never touch the network or invoke real git. One fixture dir + pinned ref can +/// be registered per source URL. +/// +public sealed class FakeRepoCloner : IRepoCloner +{ + private readonly Dictionary _sources = new(); + private int _cloneCount; + + public int CloneCount => _cloneCount; + + public void Register(string url, string fixtureDir, string pinnedRef) => + _sources[url] = (fixtureDir, pinnedRef); + + public Task CloneAsync(string url, string destDir, CancellationToken ct) + { + _cloneCount++; + if (!_sources.TryGetValue(url, out var source)) + throw new InvalidOperationException($"FakeRepoCloner has no fixture registered for '{url}'."); + + CopyDirectory(source.FixtureDir, destDir); + return Task.FromResult(new ClonedRepo(destDir, source.PinnedRef)); + } + + private static void CopyDirectory(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourceDir, file); + var destPath = Path.Combine(destDir, relative); + var destParent = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destParent)) + Directory.CreateDirectory(destParent); + File.Copy(file, destPath, overwrite: true); + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Skills/SessionSkillRegistryTests.cs b/tests/ClaudeDo.Worker.Tests/Skills/SessionSkillRegistryTests.cs new file mode 100644 index 00000000..e73fbd33 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Skills/SessionSkillRegistryTests.cs @@ -0,0 +1,181 @@ +using ClaudeDo.Worker.Skills; +using ClaudeDo.Worker.Tests.Infrastructure; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Skills; + +public sealed class SessionSkillRegistryTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _skillsRoot; + private readonly string _workDir; + private readonly FakeRepoCloner _cloner = new(); + + public SessionSkillRegistryTests() + { + _workDir = Path.Combine(Path.GetTempPath(), $"claudedo_skill_test_{Guid.NewGuid():N}"); + _skillsRoot = Path.Combine(_workDir, "session-skills"); + Directory.CreateDirectory(_skillsRoot); + } + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_workDir, recursive: true); } catch { /* best effort */ } + } + + private SessionSkillRegistry CreateRegistry() => + new(_db.CreateFactory(), _cloner, _skillsRoot); + + private string NewFixtureDir([System.Runtime.CompilerServices.CallerMemberName] string name = "") => + Path.Combine(_workDir, "fixtures", $"{name}_{Guid.NewGuid():N}"); + + [Fact] + public async Task InstallAsync_multi_skill_layout_creates_one_row_and_dir_per_skill() + { + var fixtureDir = SkillFixtureBuilder.MultiSkillRepo(NewFixtureDir()); + _cloner.Register("https://example.com/multi.git", fixtureDir, "sha-multi"); + var registry = CreateRegistry(); + + var installed = await registry.InstallAsync("https://example.com/multi.git", default); + + Assert.Equal(new[] { "ponytail", "ponytail-help" }, installed.OrderBy(x => x).ToArray()); + + var rows = await registry.ListAsync(default); + Assert.Equal(2, rows.Count); + + var ponytail = rows.Single(r => r.Name == "ponytail"); + Assert.Equal("https://example.com/multi.git", ponytail.SourceUrl); + Assert.Equal("sha-multi", ponytail.PinnedRef); + Assert.Equal("skills/ponytail", ponytail.Subpath); + Assert.Equal("Ties hair into a ponytail. Works on long hair only.", ponytail.Description); + Assert.True(File.Exists(Path.Combine(_skillsRoot, "ponytail", "SKILL.md"))); + + var help = rows.Single(r => r.Name == "ponytail-help"); + Assert.Equal("skills/ponytail-help", help.Subpath); + Assert.Equal("Help text for the ponytail skill.", help.Description); + Assert.True(File.Exists(Path.Combine(_skillsRoot, "ponytail-help", "SKILL.md"))); + } + + [Fact] + public async Task InstallAsync_root_skill_md_creates_single_row_with_dot_subpath() + { + var fixtureDir = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); + _cloner.Register("https://example.com/solo.git", fixtureDir, "sha-solo"); + var registry = CreateRegistry(); + + var installed = await registry.InstallAsync("https://example.com/solo.git", default); + + Assert.Equal(new[] { "solo-skill" }, installed); + + var rows = await registry.ListAsync(default); + var row = Assert.Single(rows); + Assert.Equal("solo-skill", row.Name); + Assert.Equal(".", row.Subpath); + Assert.Equal("A single root-level skill.", row.Description); + Assert.True(File.Exists(Path.Combine(_skillsRoot, "solo-skill", "SKILL.md"))); + } + + [Fact] + public async Task InstallAsync_no_skills_found_throws() + { + var fixtureDir = SkillFixtureBuilder.EmptyRepo(NewFixtureDir()); + _cloner.Register("https://example.com/empty.git", fixtureDir, "sha-empty"); + var registry = CreateRegistry(); + + await Assert.ThrowsAsync( + () => registry.InstallAsync("https://example.com/empty.git", default)); + } + + [Fact] + public async Task InstallAsync_cross_source_name_collision_throws() + { + var fixtureA = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); + _cloner.Register("https://example.com/a.git", fixtureA, "sha-a"); + var registry = CreateRegistry(); + await registry.InstallAsync("https://example.com/a.git", default); + + // Different source URL, same skill name ("solo-skill"). + var fixtureB = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); + _cloner.Register("https://example.com/b.git", fixtureB, "sha-b"); + + await Assert.ThrowsAsync( + () => registry.InstallAsync("https://example.com/b.git", default)); + + // Original row must be untouched. + var rows = await registry.ListAsync(default); + var row = Assert.Single(rows); + Assert.Equal("https://example.com/a.git", row.SourceUrl); + Assert.Equal("sha-a", row.PinnedRef); + } + + [Fact] + public async Task InstallAsync_reinstalling_same_url_refreshes_pinned_ref_without_collision() + { + var fixtureDir = SkillFixtureBuilder.RootSkillRepo(NewFixtureDir()); + _cloner.Register("https://example.com/solo.git", fixtureDir, "sha-1"); + var registry = CreateRegistry(); + await registry.InstallAsync("https://example.com/solo.git", default); + + _cloner.Register("https://example.com/solo.git", fixtureDir, "sha-2"); + var installed = await registry.InstallAsync("https://example.com/solo.git", default); + + Assert.Equal(new[] { "solo-skill" }, installed); + var rows = await registry.ListAsync(default); + var row = Assert.Single(rows); + Assert.Equal("sha-2", row.PinnedRef); + } + + [Fact] + public async Task RemoveAsync_deletes_all_dirs_and_rows_for_source() + { + var fixtureDir = SkillFixtureBuilder.MultiSkillRepo(NewFixtureDir()); + _cloner.Register("https://example.com/multi.git", fixtureDir, "sha-multi"); + var registry = CreateRegistry(); + await registry.InstallAsync("https://example.com/multi.git", default); + + await registry.RemoveAsync("https://example.com/multi.git", default); + + Assert.Empty(await registry.ListAsync(default)); + Assert.False(Directory.Exists(Path.Combine(_skillsRoot, "ponytail"))); + Assert.False(Directory.Exists(Path.Combine(_skillsRoot, "ponytail-help"))); + } + + [Fact] + public async Task UpdateAsync_removes_stale_skill_when_source_drops_it() + { + var multiFixture = SkillFixtureBuilder.MultiSkillRepo(NewFixtureDir()); + _cloner.Register("https://example.com/multi.git", multiFixture, "sha-1"); + var registry = CreateRegistry(); + await registry.InstallAsync("https://example.com/multi.git", default); + + // Source now only provides "ponytail" (drops "ponytail-help"). + var reducedFixtureDir = NewFixtureDir(); + Directory.CreateDirectory(Path.Combine(reducedFixtureDir, "skills")); + CopyDir(Path.Combine(multiFixture, "skills", "ponytail"), Path.Combine(reducedFixtureDir, "skills", "ponytail")); + _cloner.Register("https://example.com/multi.git", reducedFixtureDir, "sha-2"); + + await registry.UpdateAsync("https://example.com/multi.git", default); + + var rows = await registry.ListAsync(default); + var row = Assert.Single(rows); + Assert.Equal("ponytail", row.Name); + Assert.Equal("sha-2", row.PinnedRef); + Assert.True(Directory.Exists(Path.Combine(_skillsRoot, "ponytail"))); + Assert.False(Directory.Exists(Path.Combine(_skillsRoot, "ponytail-help"))); + } + + private static void CopyDir(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourceDir, file); + var destPath = Path.Combine(destDir, relative); + var destParent = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destParent)) + Directory.CreateDirectory(destParent); + File.Copy(file, destPath, overwrite: true); + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Skills/SkillFixtureBuilder.cs b/tests/ClaudeDo.Worker.Tests/Skills/SkillFixtureBuilder.cs new file mode 100644 index 00000000..6d6e7fe9 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Skills/SkillFixtureBuilder.cs @@ -0,0 +1,53 @@ +namespace ClaudeDo.Worker.Tests.Skills; + +/// Builds local fixture directories mirroring skill-repo layouts for FakeRepoCloner. +public static class SkillFixtureBuilder +{ + public static string MultiSkillRepo(string rootDir) + { + WriteSkillMd(Path.Combine(rootDir, "skills", "ponytail"), """ + --- + name: ponytail + description: > + Ties hair into a ponytail. + Works on long hair only. + --- + # Ponytail skill body + """); + + WriteSkillMd(Path.Combine(rootDir, "skills", "ponytail-help"), """ + --- + name: ponytail-help + description: Help text for the ponytail skill. + --- + # Ponytail help body + """); + + return rootDir; + } + + public static string RootSkillRepo(string rootDir) + { + WriteSkillMd(rootDir, """ + --- + name: solo-skill + description: A single root-level skill. + --- + # Solo skill body + """); + return rootDir; + } + + public static string EmptyRepo(string rootDir) + { + Directory.CreateDirectory(rootDir); + File.WriteAllText(Path.Combine(rootDir, "README.md"), "nothing to see here"); + return rootDir; + } + + private static void WriteSkillMd(string dir, string content) + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "SKILL.md"), content); + } +}