diff --git a/src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs b/src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs new file mode 100644 index 0000000..895197b --- /dev/null +++ b/src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; + +namespace ClaudeDo.Worker.Services; + +public sealed record SeedResult(int Copied, int Skipped); + +public sealed class DefaultAgentSeeder +{ + private readonly string _bundleDir; + private readonly string _targetDir; + private readonly ILogger? _logger; + + public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger? logger = null) + { + _bundleDir = bundleDir; + _targetDir = targetDir; + _logger = logger; + } + + public async Task SeedMissingAsync(CancellationToken ct = default) + { + if (!Directory.Exists(_bundleDir)) + { + _logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir); + return new SeedResult(0, 0); + } + + Directory.CreateDirectory(_targetDir); + + int copied = 0; + int skipped = 0; + + foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md")) + { + ct.ThrowIfCancellationRequested(); + var fileName = Path.GetFileName(src); + var dst = Path.Combine(_targetDir, fileName); + + if (File.Exists(dst)) + { + skipped++; + continue; + } + + try + { + using var input = File.OpenRead(src); + using var output = File.Create(dst); + await input.CopyToAsync(output, ct); + copied++; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName); + } + } + + return new SeedResult(copied, skipped); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs b/tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs new file mode 100644 index 0000000..a5b53b6 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs @@ -0,0 +1,112 @@ +using ClaudeDo.Worker.Services; + +namespace ClaudeDo.Worker.Tests.Services; + +public sealed class DefaultAgentSeederTests : IDisposable +{ + private readonly string _bundleDir; + private readonly string _targetDir; + + public DefaultAgentSeederTests() + { + var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}"); + _bundleDir = Path.Combine(root, "bundle"); + _targetDir = Path.Combine(root, "target"); + Directory.CreateDirectory(_bundleDir); + Directory.CreateDirectory(_targetDir); + } + + public void Dispose() + { + try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { } + } + + private async Task WriteBundleAsync(string name, string content) + { + await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content); + } + + [Fact] + public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty() + { + await WriteBundleAsync("a.md", "A"); + await WriteBundleAsync("b.md", "B"); + var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir); + + var result = await seeder.SeedMissingAsync(); + + Assert.Equal(2, result.Copied); + Assert.Equal(0, result.Skipped); + Assert.True(File.Exists(Path.Combine(_targetDir, "a.md"))); + Assert.True(File.Exists(Path.Combine(_targetDir, "b.md"))); + } + + [Fact] + public async Task SeedMissing_SkipsExistingFiles() + { + await WriteBundleAsync("a.md", "bundled"); + await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified"); + var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir); + + var result = await seeder.SeedMissingAsync(); + + Assert.Equal(0, result.Copied); + Assert.Equal(1, result.Skipped); + var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")); + Assert.Equal("user-modified", content); + } + + [Fact] + public async Task SeedMissing_MixedState_CopiesOnlyMissing() + { + await WriteBundleAsync("a.md", "A"); + await WriteBundleAsync("b.md", "B"); + await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing"); + var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir); + + var result = await seeder.SeedMissingAsync(); + + Assert.Equal(1, result.Copied); + Assert.Equal(1, result.Skipped); + Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"))); + Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md"))); + } + + [Fact] + public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing() + { + var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}"); + var seeder = new DefaultAgentSeeder(missingBundle, _targetDir); + + var result = await seeder.SeedMissingAsync(); + + Assert.Equal(0, result.Copied); + Assert.Equal(0, result.Skipped); + } + + [Fact] + public async Task SeedMissing_CreatesTargetDir_IfMissing() + { + await WriteBundleAsync("a.md", "A"); + var missingTarget = Path.Combine(_targetDir, "nested", "created"); + var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget); + + var result = await seeder.SeedMissingAsync(); + + Assert.Equal(1, result.Copied); + Assert.True(File.Exists(Path.Combine(missingTarget, "a.md"))); + } + + [Fact] + public async Task SeedMissing_IgnoresNonMarkdownFiles() + { + await WriteBundleAsync("a.md", "A"); + await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent"); + var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir); + + var result = await seeder.SeedMissingAsync(); + + Assert.Equal(1, result.Copied); + Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt"))); + } +}