193 lines
6.9 KiB
C#
193 lines
6.9 KiB
C#
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 */ }
|
|
}
|
|
}
|