using System.Diagnostics; using System.Text; using ClaudeDo.Data; using Microsoft.Extensions.Logging; namespace ClaudeDo.Worker.Skills; public sealed class SessionSkillSeeder : ISessionSkillSeeder { private readonly string _skillsRoot; private readonly ILogger _logger; public SessionSkillSeeder(ILogger logger, string? skillsRoot = null) { _logger = logger; _skillsRoot = skillsRoot ?? Path.Combine(Paths.AppDataRoot(), "session-skills"); } public async Task SeedAsync(string workingDir, IReadOnlyList skillNames, bool isWorktree, CancellationToken ct) { if (skillNames.Count == 0) return; var skillsDestRoot = Path.Combine(workingDir, ".claude", "skills"); foreach (var name in skillNames) { var sourceDir = Path.Combine(_skillsRoot, name); if (!Directory.Exists(sourceDir)) { _logger.LogWarning("Session skill '{SkillName}' not found on disk at {SourceDir}; skipping", name, sourceDir); continue; } var destDir = Path.Combine(skillsDestRoot, name); CopyDirectory(sourceDir, destDir); if (isWorktree) await AppendExcludeLineAsync(workingDir, $"/.claude/skills/{name}/", ct); } } 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); } } private static async Task AppendExcludeLineAsync(string workingDir, string excludeLine, CancellationToken ct) { var relativeExcludePath = await RunGitCaptureAsync(workingDir, ["rev-parse", "--git-path", "info/exclude"], ct); var excludeFile = Path.IsPathRooted(relativeExcludePath) ? relativeExcludePath : Path.GetFullPath(Path.Combine(workingDir, relativeExcludePath)); var excludeDir = Path.GetDirectoryName(excludeFile); if (!string.IsNullOrEmpty(excludeDir)) Directory.CreateDirectory(excludeDir); if (File.Exists(excludeFile)) { var existingLines = await File.ReadAllLinesAsync(excludeFile, ct); if (existingLines.Any(l => l.Trim() == excludeLine)) return; } await File.AppendAllTextAsync(excludeFile, excludeLine + Environment.NewLine, ct); } private static async Task RunGitCaptureAsync(string workingDir, IEnumerable args, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = "git", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; psi.ArgumentList.Add("-C"); psi.ArgumentList.Add(workingDir); 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); var stdout = await stdoutTask; var stderr = await stderrTask; ct.ThrowIfCancellationRequested(); if (proc.ExitCode != 0) throw new InvalidOperationException($"git rev-parse --git-path failed (exit {proc.ExitCode}): {stderr.TrimEnd()}"); return stdout.TrimEnd('\r', '\n'); } }