feat(worker): session skill registry (install/update/remove, pinned clone)

This commit is contained in:
Mika Kuns
2026-07-23 16:47:14 +02:00
committed by mika kuns
parent 54cdaf89d5
commit dea2b7db8b
8 changed files with 611 additions and 0 deletions
@@ -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);
}
}