diff --git a/src/ClaudeDo.Worker/Services/AgentFileService.cs b/src/ClaudeDo.Worker/Services/AgentFileService.cs new file mode 100644 index 0000000..adc598a --- /dev/null +++ b/src/ClaudeDo.Worker/Services/AgentFileService.cs @@ -0,0 +1,76 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Worker.Services; + +public sealed class AgentFileService +{ + private readonly string _agentsDir; + + public AgentFileService(string agentsDir) + { + _agentsDir = agentsDir; + } + + public Task> ScanAsync(CancellationToken ct = default) + { + var agents = new List(); + if (!Directory.Exists(_agentsDir)) + return Task.FromResult(agents); + + foreach (var file in Directory.EnumerateFiles(_agentsDir, "*.md")) + { + ct.ThrowIfCancellationRequested(); + var (name, description) = ParseFrontmatter(file); + agents.Add(new AgentInfo(name, description, file)); + } + + agents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(agents); + } + + public async Task ReadAsync(string path, CancellationToken ct = default) + { + return await File.ReadAllTextAsync(path, ct); + } + + public async Task WriteAsync(string path, string content, CancellationToken ct = default) + { + var dir = Path.GetDirectoryName(path); + if (dir is not null) Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(path, content, ct); + } + + public Task DeleteAsync(string path, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (File.Exists(path)) File.Delete(path); + return Task.CompletedTask; + } + + private static (string name, string description) ParseFrontmatter(string filePath) + { + var fileName = Path.GetFileNameWithoutExtension(filePath); + string name = fileName; + string description = ""; + + try + { + using var reader = new StreamReader(filePath); + var firstLine = reader.ReadLine(); + if (firstLine?.Trim() != "---") + return (name, description); + + while (reader.ReadLine() is { } line) + { + if (line.Trim() == "---") break; + if (line.StartsWith("name:")) + name = line["name:".Length..].Trim(); + else if (line.StartsWith("description:")) + description = line["description:".Length..].Trim(); + } + } + catch { /* Can't read file -- use filename fallback */ } + + return (name, description); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs new file mode 100644 index 0000000..d857da2 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs @@ -0,0 +1,84 @@ +using ClaudeDo.Worker.Services; + +namespace ClaudeDo.Worker.Tests.Services; + +public sealed class AgentFileServiceTests : IDisposable +{ + private readonly string _agentDir; + private readonly AgentFileService _service; + + public AgentFileServiceTests() + { + _agentDir = Path.Combine(Path.GetTempPath(), $"claudedo_agents_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_agentDir); + _service = new AgentFileService(_agentDir); + } + + [Fact] + public async Task Scan_Returns_Empty_For_Empty_Directory() + { + var agents = await _service.ScanAsync(); + Assert.Empty(agents); + } + + [Fact] + public async Task Scan_Parses_Frontmatter() + { + var content = "---\nname: Test Agent\ndescription: A test agent for unit tests\n---\n\nYou are a test agent."; + await File.WriteAllTextAsync(Path.Combine(_agentDir, "test.md"), content); + + var agents = await _service.ScanAsync(); + Assert.Single(agents); + Assert.Equal("Test Agent", agents[0].Name); + Assert.Equal("A test agent for unit tests", agents[0].Description); + Assert.EndsWith("test.md", agents[0].Path); + } + + [Fact] + public async Task Scan_Uses_Filename_When_No_Frontmatter() + { + await File.WriteAllTextAsync(Path.Combine(_agentDir, "simple.md"), "Just instructions."); + + var agents = await _service.ScanAsync(); + Assert.Single(agents); + Assert.Equal("simple", agents[0].Name); + Assert.Equal("", agents[0].Description); + } + + [Fact] + public async Task Write_And_Read_Roundtrips() + { + var path = Path.Combine(_agentDir, "new-agent.md"); + var content = "---\nname: New\ndescription: Desc\n---\nBody"; + await _service.WriteAsync(path, content); + + var read = await _service.ReadAsync(path); + Assert.Equal(content, read); + } + + [Fact] + public async Task Delete_Removes_File() + { + var path = Path.Combine(_agentDir, "to-delete.md"); + await File.WriteAllTextAsync(path, "temp"); + + await _service.DeleteAsync(path); + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task Scan_Ignores_Non_Md_Files() + { + await File.WriteAllTextAsync(Path.Combine(_agentDir, "notes.txt"), "not an agent"); + await File.WriteAllTextAsync(Path.Combine(_agentDir, "agent.md"), "---\nname: Real\ndescription: Yes\n---\nBody"); + + var agents = await _service.ScanAsync(); + Assert.Single(agents); + Assert.Equal("Real", agents[0].Name); + } + + public void Dispose() + { + try { Directory.Delete(_agentDir, true); } catch { } + } +}