feat(worker): session skill registry (install/update/remove, pinned clone)
This commit is contained in:
@@ -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<ClonedRepo> 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<string> 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public sealed record ClonedRepo(string LocalPath, string PinnedRef);
|
||||
|
||||
public interface IRepoCloner
|
||||
{
|
||||
Task<ClonedRepo> CloneAsync(string url, string destDir, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
public interface ISessionSkillRegistry
|
||||
{
|
||||
Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct);
|
||||
Task UpdateAsync(string sourceUrl, CancellationToken ct);
|
||||
Task RemoveAsync(string sourceUrl, CancellationToken ct);
|
||||
Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -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<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IRepoCloner _cloner;
|
||||
private readonly string _skillsRoot;
|
||||
|
||||
public SessionSkillRegistry(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
IRepoCloner cloner,
|
||||
string? skillsRoot = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_cloner = cloner;
|
||||
_skillsRoot = skillsRoot ?? Path.Combine(Paths.AppDataRoot(), "session-skills");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> 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<string>();
|
||||
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<IReadOnlyList<SessionSkillEntity>> 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<DiscoveredSkill> DiscoverSkills(string cloneRoot)
|
||||
{
|
||||
var skillsDir = Path.Combine(cloneRoot, "skills");
|
||||
var discovered = new List<DiscoveredSkill>();
|
||||
|
||||
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/<name>/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 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ClaudeDo.Worker.Skills;
|
||||
|
||||
internal static class SkillFrontmatter
|
||||
{
|
||||
public sealed record ParsedFrontmatter(string Name, string Description);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ClaudeDo.Worker.Skills;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Skills;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class FakeRepoCloner : IRepoCloner
|
||||
{
|
||||
private readonly Dictionary<string, (string FixtureDir, string PinnedRef)> _sources = new();
|
||||
private int _cloneCount;
|
||||
|
||||
public int CloneCount => _cloneCount;
|
||||
|
||||
public void Register(string url, string fixtureDir, string pinnedRef) =>
|
||||
_sources[url] = (fixtureDir, pinnedRef);
|
||||
|
||||
public Task<ClonedRepo> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ClaudeDo.Worker.Tests.Skills;
|
||||
|
||||
/// <summary>Builds local fixture directories mirroring skill-repo layouts for FakeRepoCloner.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user