diff --git a/README.md b/README.md index 0aa535b360..58044f935c 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,17 @@ It provides three tools: The server is repo-aware — it resolves the current working directory to a repo hash at startup, and `search_sessions` defaults its `repo` filter to that hash unless you override it. +### Curate guidelines + +Sync the repo's promoted curation guidelines into its `CLAUDE.md` and/or `AGENTS.md` via a managed block. The server tracks which guidelines have been promoted for the current repo; `curate apply` fetches them and writes (or updates) a `…` block in the relevant files. Content outside the markers is never touched. + +```bash +kcap curate apply # preview changes and confirm interactively +kcap curate apply --dry-run # print what would change without writing anything +kcap curate apply --yes # apply without prompting (CI / scripted) +kcap curate apply -y # shorthand for --yes +``` + ### Loading historical sessions Backfill older sessions from every detected coding agent in a single run. All seven agents ship per-session `.jsonl` transcripts (`~/.claude/projects/`, `~/.codex/sessions/`, `~/.cursor/projects//agent-transcripts/`, `~/.copilot/session-state/`, `~/.gemini/tmp//chats/`, `~/.kiro/sessions/cli/`, `~/.pi/agent/sessions/`). They're discovered automatically and the command requires an explicit scope so personal/private repos aren't uploaded by accident: diff --git a/src/Capacitor.Cli.Core/Curation/CuratedBlock.cs b/src/Capacitor.Cli.Core/Curation/CuratedBlock.cs new file mode 100644 index 0000000000..7a923545ef --- /dev/null +++ b/src/Capacitor.Cli.Core/Curation/CuratedBlock.cs @@ -0,0 +1,88 @@ +namespace Capacitor.Cli.Core.Curation; + +public static class CuratedBlock { + public const string StartMarker = ""; + public const string EndMarker = ""; + + const string Heading = "## Curated guidelines"; + const string Note = "_Managed by `kcap curate apply` — do not edit; changes are overwritten._"; + + public static string? Render(IReadOnlyList guidelines) { + if (guidelines.Count == 0) return null; + + var sorted = guidelines + .OrderBy(g => g.Category, StringComparer.Ordinal) + .ThenBy(g => g.Text, StringComparer.Ordinal) + .ToList(); + + var lines = new List { StartMarker, Heading, "", Note, "" }; + foreach (var g in sorted) lines.Add($"- {g.Text}"); + lines.Add(EndMarker); + return string.Join("\n", lines); + } + + public static IReadOnlyList ExtractBullets(string content) { + var lines = Normalize(content); + var loc = Locate(lines); + if (loc is null) return []; + + var (s, e) = loc.Value; + var bullets = new List(); + for (var i = s + 1; i < e; i++) { + var t = lines[i]; + if (t.StartsWith("- ")) bullets.Add(t[2..]); + } + return bullets; + } + + public static string Splice(string content, string? renderedBlock) { + var newline = content.Contains("\r\n") ? "\r\n" : "\n"; + var lines = new List(Normalize(content)); + var loc = Locate(lines.ToArray()); // throws on malformed + + if (renderedBlock is null) { + if (loc is null) return content; // nothing to remove + RemoveBlock(lines, loc.Value); + var removedJoined = string.Join(newline, lines); + removedJoined = removedJoined.TrimEnd('\r', '\n'); + return removedJoined.Length == 0 ? removedJoined : removedJoined + newline; + } + + var blockLines = renderedBlock.Replace("\r\n", "\n").Split('\n'); + + if (loc is null) { + // Append, separated by a blank line if the file has trailing content. + if (lines.Count > 0 && !(lines.Count == 1 && lines[0].Length == 0) && lines[^1].Trim().Length != 0) + lines.Add(""); + lines.AddRange(blockLines); + } else { + var (s, e) = loc.Value; + lines.RemoveRange(s, e - s + 1); + lines.InsertRange(s, blockLines); + } + + var joined = string.Join(newline, lines); + joined = joined.TrimEnd('\r', '\n'); + return joined.Length == 0 ? joined : joined + newline; + } + + static string[] Normalize(string content) => content.Replace("\r\n", "\n").Split('\n'); + + static void RemoveBlock(List lines, (int start, int end) loc) { + var (s, e) = loc; + lines.RemoveRange(s, e - s + 1); + } + + static (int start, int end)? Locate(string[] lines) { + int start = -1, end = -1, starts = 0, ends = 0; + for (var i = 0; i < lines.Length; i++) { + if (lines[i] == StartMarker) { starts++; if (start < 0) start = i; } + else if (lines[i] == EndMarker) { ends++; end = i; } + } + + if (starts == 0 && ends == 0) return null; + if (starts == 1 && ends == 1 && start < end) return (start, end); + throw new CuratedBlockException( + $"Malformed managed block: found {starts} start and {ends} end marker(s). Fix the file by hand and re-run."); + } +} diff --git a/src/Capacitor.Cli.Core/Curation/CuratedGuideline.cs b/src/Capacitor.Cli.Core/Curation/CuratedGuideline.cs new file mode 100644 index 0000000000..275b9fb6cc --- /dev/null +++ b/src/Capacitor.Cli.Core/Curation/CuratedGuideline.cs @@ -0,0 +1,22 @@ +namespace Capacitor.Cli.Core.Curation; + +/// One promoted guideline destined for an instruction file. +public sealed record CuratedGuideline(string Category, string Text); + +/// Raised when an instruction file's managed markers are malformed; apply fails closed. +public sealed class CuratedBlockException : Exception { + public CuratedBlockException(string message) : base(message) { } +} + +/// What apply will do to one instruction file. +public enum CurateAction { Create, Update, Remove, NoOp } + +public sealed record FilePlan( + string Path, + CurateAction Action, + string? NewContent, // null only on NoOp; Create/Update/Remove carry the new file content + IReadOnlyList Added, // bullet texts added vs. the current block + IReadOnlyList Removed // bullet texts removed vs. the current block +); + +public sealed record ApplyPlan(IReadOnlyList Files); diff --git a/src/Capacitor.Cli.Core/Curation/CuratedTargets.cs b/src/Capacitor.Cli.Core/Curation/CuratedTargets.cs new file mode 100644 index 0000000000..d275efe3fe --- /dev/null +++ b/src/Capacitor.Cli.Core/Curation/CuratedTargets.cs @@ -0,0 +1,25 @@ +namespace Capacitor.Cli.Core.Curation; + +public static class CuratedTargets { + public const string ClaudeMdName = "CLAUDE.md"; + public const string AgentsMdName = "AGENTS.md"; + + public static IReadOnlyList Resolve( + string repoRoot, bool claudeMdExists, bool agentsMdExists, bool hasContent) { + var claude = Path.Combine(repoRoot, ClaudeMdName); + var agents = Path.Combine(repoRoot, AgentsMdName); + + if (!hasContent) { + // Nothing to write: only touch files that already exist (to remove a stale block). + var existing = new List(); + if (claudeMdExists) existing.Add(claude); + if (agentsMdExists) existing.Add(agents); + return existing; + } + + if (claudeMdExists && agentsMdExists) return [claude, agents]; + if (claudeMdExists) return [claude]; + if (agentsMdExists) return [agents]; + return [agents]; // neither exists → create the cross-harness default + } +} diff --git a/src/Capacitor.Cli.Core/Curation/CuratedTextNormalizer.cs b/src/Capacitor.Cli.Core/Curation/CuratedTextNormalizer.cs new file mode 100644 index 0000000000..ef4e2c8c4a --- /dev/null +++ b/src/Capacitor.Cli.Core/Curation/CuratedTextNormalizer.cs @@ -0,0 +1,35 @@ +using System.Text; + +namespace Capacitor.Cli.Core.Curation; + +public static class CuratedTextNormalizer { + /// + /// Promoted text is untrusted/multiline and only length-validated server-side. + /// Collapse it to a single logical line and neutralize HTML-comment terminators + /// so it can never be mistaken for a managed-block marker. + /// + public static string? Normalize(string? raw) { + if (string.IsNullOrEmpty(raw)) return null; + + var sb = new StringBuilder(raw.Length); + var lastSpace = false; + foreach (var ch in raw) { + var c = ch is '\r' or '\n' or '\t' ? ' ' : ch; + if (c == ' ') { + if (!lastSpace) sb.Append(' '); + lastSpace = true; + } else { + sb.Append(c); + lastSpace = false; + } + } + + var s = sb.ToString().Trim(); + if (s.Length == 0) return null; + + // Defang the comment terminator: a zero-width space (U+200B) breaks the "-->" + // token, which also neutralizes any embedded start/end marker (both end in "-->"). + // Use the \u200b escape, never a literal ZWSP, so the source stays reviewable. + return s.Replace("-->", "--\u200b>"); + } +} diff --git a/src/Capacitor.Cli.Core/Curation/CurationApplyPlanner.cs b/src/Capacitor.Cli.Core/Curation/CurationApplyPlanner.cs new file mode 100644 index 0000000000..429f04bb14 --- /dev/null +++ b/src/Capacitor.Cli.Core/Curation/CurationApplyPlanner.cs @@ -0,0 +1,49 @@ +namespace Capacitor.Cli.Core.Curation; + +public static class CurationApplyPlanner { + public static ApplyPlan BuildPlan( + string repoRoot, + IReadOnlyDictionary contentByPath, + IReadOnlyList guidelines) { + + var claude = Path.Combine(repoRoot, CuratedTargets.ClaudeMdName); + var agents = Path.Combine(repoRoot, CuratedTargets.AgentsMdName); + + var claudeExists = contentByPath.TryGetValue(claude, out var cc) && cc is not null; + var agentsExists = contentByPath.TryGetValue(agents, out var ac) && ac is not null; + + var hasContent = guidelines.Count > 0; + var block = CuratedBlock.Render(guidelines); // null when empty + var newBullets = guidelines.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : CuratedBlock.ExtractBullets(block!).ToHashSet(StringComparer.Ordinal); + + var targets = CuratedTargets.Resolve(repoRoot, claudeExists, agentsExists, hasContent); + var files = new List(); + + foreach (var path in targets) { + contentByPath.TryGetValue(path, out var current); // null when absent + var oldBullets = current is null + ? new HashSet(StringComparer.Ordinal) + : CuratedBlock.ExtractBullets(current).ToHashSet(StringComparer.Ordinal); + + var added = newBullets.Except(oldBullets).OrderBy(x => x, StringComparer.Ordinal).ToList(); + var removed = oldBullets.Except(newBullets).OrderBy(x => x, StringComparer.Ordinal).ToList(); + + var newContent = CuratedBlock.Splice(current ?? "", block); // may throw → caller fails closed + + CurateAction action; + if (current is null) action = CurateAction.Create; + else if (!hasContent) action = oldBullets.Count > 0 ? CurateAction.Remove : CurateAction.NoOp; + else if (string.Equals(newContent, current)) action = CurateAction.NoOp; + else action = CurateAction.Update; + + files.Add(new FilePlan( + path, action, + action == CurateAction.NoOp ? null : newContent, + added, removed)); + } + + return new ApplyPlan(files); + } +} diff --git a/src/Capacitor.Cli.Core/Models.cs b/src/Capacitor.Cli.Core/Models.cs index cd729ec0d5..0918e87d0b 100644 --- a/src/Capacitor.Cli.Core/Models.cs +++ b/src/Capacitor.Cli.Core/Models.cs @@ -510,6 +510,19 @@ public record RepoEntry { public required DateTimeOffset LastUsed { get; init; } } +public sealed record CurationApplyItem { + [JsonPropertyName("category")] public string? Category { get; init; } + [JsonPropertyName("cluster_id")] public string? ClusterId { get; init; } + [JsonPropertyName("promoted_text")] public string? PromotedText { get; init; } + [JsonPropertyName("target_kinds")] public IReadOnlyList? TargetKinds { get; init; } + [JsonPropertyName("status")] public string? Status { get; init; } +} + +public sealed record CurationApplyResponse { + [JsonPropertyName("repo_hash")] public string? RepoHash { get; init; } + [JsonPropertyName("items")] public IReadOnlyList? Items { get; init; } +} + [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(EvalContextResult))] @@ -584,6 +597,8 @@ public record RepoEntry { [JsonSerializable(typeof(string))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(RepoEntry[]))] +[JsonSerializable(typeof(CurationApplyResponse))] +[JsonSerializable(typeof(CurationApplyItem))] // UseStringEnumConverter=true matches the server's SignalR JSON protocol, which // serialises enums (e.g. LaunchKind) as camelCase strings. Without it the // source-gen LaunchKind JsonTypeInfo defaults to numeric and silently drops the diff --git a/src/Capacitor.Cli.Core/Resources/help-curate.txt b/src/Capacitor.Cli.Core/Resources/help-curate.txt new file mode 100644 index 0000000000..8d65fbb8f5 --- /dev/null +++ b/src/Capacitor.Cli.Core/Resources/help-curate.txt @@ -0,0 +1,18 @@ +kcap curate — apply curated guidelines to your project instruction files + +Usage: + kcap curate apply [--dry-run] [--yes] + +Writes the repo's promoted CLAUDE.md curation guidelines into CLAUDE.md and/or +AGENTS.md at the repository root, inside a managed block. Updates both files when +both exist, the one that exists otherwise, or creates AGENTS.md when neither does. + +Options: + --dry-run Show what would change and exit without writing. + --yes, -y Skip the confirmation prompt (for scripts/CI). + +Notes: + - Run from inside the git repository. + - Only guidelines promoted with the CLAUDE.md target are applied. + - Re-running re-syncs the managed block to the server's current promoted set; + content outside the markers is never touched. diff --git a/src/Capacitor.Cli.Core/Resources/help-usage.txt b/src/Capacitor.Cli.Core/Resources/help-usage.txt index 9609542027..fa82d3b01e 100644 --- a/src/Capacitor.Cli.Core/Resources/help-usage.txt +++ b/src/Capacitor.Cli.Core/Resources/help-usage.txt @@ -48,6 +48,9 @@ Session: hide Hide session (owner-only visibility) review Launch Claude with PR review context +Curation: + curate apply [--dry-run] [--yes] Write promoted guidelines into CLAUDE.md / AGENTS.md + MCP servers (stdio): mcp review [--owner --repo --pr] PR review context for agents mcp judge --session Per-session judge tools diff --git a/src/Capacitor.Cli/Commands/CurateCommand.cs b/src/Capacitor.Cli/Commands/CurateCommand.cs new file mode 100644 index 0000000000..b1b3498d5f --- /dev/null +++ b/src/Capacitor.Cli/Commands/CurateCommand.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Text.Json; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Curation; + +namespace Capacitor.Cli.Commands; + +static class CurateCommand { + public static async Task HandleApply(string baseUrl, bool dryRun, bool yes) { + var cwd = Environment.CurrentDirectory; + + // 1. Authoritative repo-root gate (never AppConfig.RepoRoot). + var repoRoot = GitRepository.FindRoot(cwd); + if (repoRoot is null) { + await Console.Error.WriteLineAsync("Not inside a git repository — run `kcap curate apply` from a repo."); + return 1; + } + + // 2. Identify the repo for the server key. + var repo = await RepositoryDetection.DetectRepositoryAsync(cwd); + if (repo?.Owner is null || repo.RepoName is null) { + await Console.Error.WriteLineAsync("Could not determine the repo's owner/name from its git remote."); + return 1; + } + var hash = RepoHashHelper.ComputeRepoHash(repo.Owner, repo.RepoName); + + // 3. Fetch promoted curation decisions. + using var client = await HttpClientExtensions.CreateAuthenticatedClientAsync(); + HttpResponseMessage resp; + try { + resp = await client.GetWithRetryAsync( + $"{baseUrl}/api/repositories/{hash}/curation?status=promoted&minWeight=1&limit=100"); + } catch (HttpRequestException ex) { + HttpClientExtensions.WriteUnreachableError(baseUrl, ex); + return 1; + } + + if (await HttpClientExtensions.HandleUnauthorizedAsync(resp)) return 1; + if (resp.StatusCode == HttpStatusCode.NotFound) { + await Console.Error.WriteLineAsync( + "Repo not found or not visible for this profile. Check `kcap whoami` / your active profile."); + return 1; + } + if (!resp.IsSuccessStatusCode) { + await Console.Error.WriteLineAsync($"HTTP {(int)resp.StatusCode}"); + return 1; + } + + var json = await resp.Content.ReadAsStringAsync(); + const string malformedMessage = "Malformed response from server (could not parse curation payload)."; + CurationApplyResponse? dto; + try { + dto = JsonSerializer.Deserialize(json, CapacitorJsonContext.Default.CurationApplyResponse); + } catch (JsonException) { + await Console.Error.WriteLineAsync(malformedMessage); + return 1; + } + if (dto is null) { + await Console.Error.WriteLineAsync(malformedMessage); + return 1; + } + var items = dto.Items ?? []; + + if (items.Count == 100) + await Console.Error.WriteLineAsync("Warning: hit the 100-item page limit; some guidelines may be omitted."); + + // 4. Keep claude_md targets, normalize text. + var guidelines = new List(); + foreach (var it in items) { + if (it.TargetKinds is null || !it.TargetKinds.Contains("claude_md")) continue; + var text = CuratedTextNormalizer.Normalize(it.PromotedText); + if (text is null) continue; + guidelines.Add(new CuratedGuideline(it.Category ?? "", text)); + } + + // 5. Read candidate files and build the plan (fails closed on malformed markers). + var claude = Path.Combine(repoRoot, CuratedTargets.ClaudeMdName); + var agents = Path.Combine(repoRoot, CuratedTargets.AgentsMdName); + var contentByPath = new Dictionary { + [claude] = File.Exists(claude) ? await File.ReadAllTextAsync(claude) : null, + [agents] = File.Exists(agents) ? await File.ReadAllTextAsync(agents) : null, + }; + + ApplyPlan plan; + try { + plan = CurationApplyPlanner.BuildPlan(repoRoot, contentByPath, guidelines); + } catch (CuratedBlockException ex) { + await Console.Error.WriteLineAsync(ex.Message); + return 1; + } + + // De-dup symlinked CLAUDE.md/AGENTS.md (same real file → write once, to the real target). + var actionable = ResolveAndDeduplicateTargets(plan.Files.Where(f => f.Action != CurateAction.NoOp).ToList()); + + if (actionable.Count == 0) { + await Console.Out.WriteLineAsync(guidelines.Count == 0 + ? "Nothing to apply (no promoted CLAUDE.md guidelines for this repo)." + : "Up to date — instruction files already match."); + return 0; + } + + // 6. Preview. + foreach (var f in actionable) { + await Console.Out.WriteLineAsync($"{f.Action} {f.Path}"); + foreach (var a in f.Added) await Console.Out.WriteLineAsync($" + {a}"); + foreach (var r in f.Removed) await Console.Out.WriteLineAsync($" - {r}"); + } + + if (dryRun) return 0; + + // 7. Confirm (daemon-stop pattern). + if (!yes) { + await Console.Out.WriteAsync("Apply? [y/N] "); + var reply = await Console.In.ReadLineAsync(); + if (!string.Equals(reply?.Trim(), "y", StringComparison.OrdinalIgnoreCase)) { + await Console.Out.WriteLineAsync("Cancelled."); + return 0; + } + } + + // 8. Write atomically. + try { + foreach (var f in actionable) { + if (f.NewContent is null) continue; // NoOp is already filtered out; defensive + WriteFileAtomic(f.Path, f.NewContent); + } + } catch (IOException ex) { + await Console.Error.WriteLineAsync($"Write failed: {ex.Message}"); + return 1; + } + + await Console.Out.WriteLineAsync($"Applied to {actionable.Count} file(s)."); + return 0; + } + + /// Temp-file + rename; preserves an existing file's Unix mode (default mode for new files). + public static string WriteFileAtomic(string path, string content) { + var tmp = path + ".tmp"; + UnixFileMode? mode = null; + if (!OperatingSystem.IsWindows() && File.Exists(path)) mode = File.GetUnixFileMode(path); + + File.WriteAllText(tmp, content); + if (mode is { } m && !OperatingSystem.IsWindows()) File.SetUnixFileMode(tmp, m); + File.Move(tmp, path, overwrite: true); + return path; + } + + /// + /// Resolve each plan's path to its real target (following symlinks) and de-duplicate + /// by that real path, so a symlinked CLAUDE.md/AGENTS.md pair is written once — to the + /// original file — and the symlink itself is preserved (we never overwrite the link). + /// + public static List ResolveAndDeduplicateTargets(List files) { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var f in files) { + string real; + try { + real = File.Exists(f.Path) + ? Path.GetFullPath(new FileInfo(f.Path).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? f.Path) + : f.Path; + } catch { + real = f.Path; + } + if (seen.Add(real)) result.Add(f with { Path = real }); // write to the resolved real path + } + return result; + } +} diff --git a/src/Capacitor.Cli/Program.cs b/src/Capacitor.Cli/Program.cs index 4b3642e5cd..caf7982db0 100644 --- a/src/Capacitor.Cli/Program.cs +++ b/src/Capacitor.Cli/Program.cs @@ -308,6 +308,23 @@ return 1; } } + case "curate": { + if (args.Length < 2) { + Console.Error.WriteLine("Usage: kcap curate apply [--dry-run] [--yes]"); + return 1; + } + switch (args[1]) { + case "apply": { + var dryRun = args.Contains("--dry-run"); + var yes = args.Contains("--yes") || args.Contains("-y"); + return await CurateCommand.HandleApply(baseUrl!, dryRun, yes); + } + default: + Console.Error.WriteLine($"Unknown curate subcommand: {args[1]}"); + Console.Error.WriteLine("Usage: kcap curate apply [--dry-run] [--yes]"); + return 1; + } + } case "cleanup": return await CleanupCommand.HandleCleanup(); case "uninstall": diff --git a/test/Capacitor.Cli.Tests.Unit/CurateApplyFileWriteTests.cs b/test/Capacitor.Cli.Tests.Unit/CurateApplyFileWriteTests.cs new file mode 100644 index 0000000000..e4baac6441 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/CurateApplyFileWriteTests.cs @@ -0,0 +1,69 @@ +using Capacitor.Cli.Commands; +using Capacitor.Cli.Core.Curation; + +namespace Capacitor.Cli.Tests.Unit; + +public class CurateApplyFileWriteTests { + static string NewTempDir() { + var dir = Path.Combine(Path.GetTempPath(), "kcap-curate-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + + [Test] + public async Task WriteFileAtomic_writes_content_and_leaves_no_tmp() { + var dir = NewTempDir(); + var path = Path.Combine(dir, "AGENTS.md"); + + CurateCommand.WriteFileAtomic(path, "hello\n"); + + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo("hello\n"); + await Assert.That(File.Exists(path + ".tmp")).IsFalse(); + Directory.Delete(dir, recursive: true); + } + + [Test] + public async Task ResolveAndDeduplicateTargets_writes_symlink_target_and_preserves_link() { + if (OperatingSystem.IsWindows()) return; + + var dir = NewTempDir(); + var agents = Path.Combine(dir, "AGENTS.md"); + var claude = Path.Combine(dir, "CLAUDE.md"); + await File.WriteAllTextAsync(agents, "old\n"); + File.CreateSymbolicLink(claude, agents); // CLAUDE.md -> AGENTS.md + + // Two plans (as HandleApply would build) — one per candidate name, same new content. + var plans = new List { + new(claude, CurateAction.Update, "new\n", Array.Empty(), Array.Empty()), + new(agents, CurateAction.Update, "new\n", Array.Empty(), Array.Empty()), + }; + + var resolved = CurateCommand.ResolveAndDeduplicateTargets(plans); + + await Assert.That(resolved.Count).IsEqualTo(1); // written once + await Assert.That(Path.GetFullPath(resolved[0].Path)).IsEqualTo(Path.GetFullPath(agents)); // real target + + CurateCommand.WriteFileAtomic(resolved[0].Path, resolved[0].NewContent!); + + await Assert.That(await File.ReadAllTextAsync(agents)).IsEqualTo("new\n"); // original updated + await Assert.That(new FileInfo(claude).LinkTarget).IsNotNull(); // CLAUDE.md still a symlink + Directory.Delete(dir, recursive: true); + } + + [Test] + public async Task WriteFileAtomic_preserves_existing_unix_mode() { + if (OperatingSystem.IsWindows()) return; + + var dir = NewTempDir(); + var path = Path.Combine(dir, "CLAUDE.md"); + await File.WriteAllTextAsync(path, "old\n"); + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead); + var before = File.GetUnixFileMode(path); + + CurateCommand.WriteFileAtomic(path, "new\n"); + + await Assert.That(File.GetUnixFileMode(path)).IsEqualTo(before); + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo("new\n"); + Directory.Delete(dir, recursive: true); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/CuratedBlockTests.cs b/test/Capacitor.Cli.Tests.Unit/CuratedBlockTests.cs new file mode 100644 index 0000000000..e1dbbf240d --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/CuratedBlockTests.cs @@ -0,0 +1,124 @@ +using Capacitor.Cli.Core.Curation; + +namespace Capacitor.Cli.Tests.Unit; + +public class CuratedBlockTests { + static CuratedGuideline G(string cat, string text) => new(cat, text); + + [Test] + public async Task Render_sorts_deterministically_and_returns_null_when_empty() { + await Assert.That(CuratedBlock.Render([])).IsNull(); + + var block = CuratedBlock.Render([G("quality", "b"), G("quality", "a"), G("efficiency", "c")])!; + // efficiency sorts before quality; within quality, "a" before "b" + var bullets = block.Split('\n').Where(l => l.StartsWith("- ")).ToArray(); + await Assert.That(bullets).IsEquivalentTo(new[] { "- c", "- a", "- b" }); + await Assert.That(block.StartsWith(CuratedBlock.StartMarker)).IsTrue(); + await Assert.That(block.TrimEnd().EndsWith(CuratedBlock.EndMarker)).IsTrue(); + } + + [Test] + public async Task Splice_appends_when_absent_preserving_existing() { + var content = "# My Project\n\nHand-written notes.\n"; + var block = CuratedBlock.Render([G("quality", "always close writers")])!; + var result = CuratedBlock.Splice(content, block); + + await Assert.That(result.StartsWith("# My Project")).IsTrue(); + await Assert.That(result.Contains("Hand-written notes.")).IsTrue(); + await Assert.That(result.Contains(CuratedBlock.StartMarker)).IsTrue(); + // idempotent + await Assert.That(CuratedBlock.Splice(result, block)).IsEqualTo(result); + } + + [Test] + public async Task Splice_replaces_in_place_and_leaves_surrounding_untouched() { + var head = "# Title\n\nbefore\n"; + var tail = "\nafter\n"; + var old = CuratedBlock.Render([G("quality", "old line")])!; + var start = head + old + tail; + + var fresh = CuratedBlock.Render([G("quality", "new line")])!; + var result = CuratedBlock.Splice(start, fresh); + + await Assert.That(result.Contains("new line")).IsTrue(); + await Assert.That(result.Contains("old line")).IsFalse(); + await Assert.That(result.Contains("before")).IsTrue(); + await Assert.That(result.Contains("after")).IsTrue(); + } + + [Test] + public async Task Splice_removes_block_when_null() { + var head = "# Title\n\nkeep me\n"; + var block = CuratedBlock.Render([G("quality", "x")])!; + var withIt = CuratedBlock.Splice(head, block); + + var removed = CuratedBlock.Splice(withIt, null); + await Assert.That(removed.Contains(CuratedBlock.StartMarker)).IsFalse(); + await Assert.That(removed.Contains("keep me")).IsTrue(); + } + + [Test] + public async Task Splice_fails_closed_on_malformed_markers() { + // start-only + var startOnly = "before\n" + CuratedBlock.StartMarker + "\n- x\n"; + await Assert.That(() => CuratedBlock.Splice(startOnly, null)).Throws(); + + // end-only + var endOnly = "before\n" + CuratedBlock.EndMarker + "\n- x\n"; + await Assert.That(() => CuratedBlock.Splice(endOnly, null)).Throws(); + + // out-of-order (end before start) + var outOfOrder = CuratedBlock.EndMarker + "\n- x\n" + CuratedBlock.StartMarker + "\n"; + await Assert.That(() => CuratedBlock.Splice(outOfOrder, null)).Throws(); + + // two blocks + var twoBlocks = + CuratedBlock.StartMarker + "\n- a\n" + CuratedBlock.EndMarker + "\n" + + CuratedBlock.StartMarker + "\n- b\n" + CuratedBlock.EndMarker + "\n"; + await Assert.That(() => CuratedBlock.Splice(twoBlocks, null)).Throws(); + } + + [Test] + public async Task Splice_result_ends_with_exactly_one_trailing_newline() { + var content = "# My Project\n\nHand-written notes.\n"; + var block = CuratedBlock.Render([G("quality", "always close writers")])!; + + // After appending a block the result ends with exactly one newline. + var appended = CuratedBlock.Splice(content, block); + await Assert.That(appended.EndsWith("\n")).IsTrue(); + await Assert.That(appended.EndsWith("\n\n")).IsFalse(); + + // Byte-idempotent: second splice produces same output. + await Assert.That(CuratedBlock.Splice(appended, block)).IsEqualTo(appended); + + // After removing the block the result ends with exactly one newline. + var removed = CuratedBlock.Splice(appended, null); + await Assert.That(removed.EndsWith("\n")).IsTrue(); + await Assert.That(removed.EndsWith("\n\n")).IsFalse(); + } + + [Test] + public async Task Splice_remove_does_not_eat_blank_lines_outside_markers() { + // A block surrounded by user-authored blank lines — the blank lines that live + // outside the markers must survive removal. + var block = CuratedBlock.Render([G("quality", "x")])!; + var input = "intro\n\n" + block + "\n\noutro\n"; + + var result = CuratedBlock.Splice(input, null); + + await Assert.That(result.Contains(CuratedBlock.StartMarker)).IsFalse(); + await Assert.That(result.Contains(CuratedBlock.EndMarker)).IsFalse(); + await Assert.That(result.Contains("intro")).IsTrue(); + await Assert.That(result.Contains("outro")).IsTrue(); + // The blank line between intro and the block, and between the block and outro, + // must both be preserved (the user put them there — they are outside the markers). + await Assert.That(result.Contains("\n\n")).IsTrue(); + } + + [Test] + public async Task ExtractBullets_returns_block_lines() { + var block = CuratedBlock.Render([G("quality", "a"), G("quality", "b")])!; + var bullets = CuratedBlock.ExtractBullets("noise\n" + block + "\nmore noise\n"); + await Assert.That(bullets).IsEquivalentTo(new[] { "a", "b" }); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/CuratedTargetsTests.cs b/test/Capacitor.Cli.Tests.Unit/CuratedTargetsTests.cs new file mode 100644 index 0000000000..36be33040a --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/CuratedTargetsTests.cs @@ -0,0 +1,33 @@ +using Capacitor.Cli.Core.Curation; + +namespace Capacitor.Cli.Tests.Unit; + +public class CuratedTargetsTests { + const string Root = "/repo"; + static string Claude => System.IO.Path.Combine(Root, "CLAUDE.md"); + static string Agents => System.IO.Path.Combine(Root, "AGENTS.md"); + + [Test] + public async Task With_content_both_exist_targets_both() { + var r = CuratedTargets.Resolve(Root, claudeMdExists: true, agentsMdExists: true, hasContent: true); + await Assert.That(r).IsEquivalentTo(new[] { Claude, Agents }); + } + + [Test] + public async Task With_content_only_one_targets_that_one() { + await Assert.That(CuratedTargets.Resolve(Root, true, false, true)).IsEquivalentTo(new[] { Claude }); + await Assert.That(CuratedTargets.Resolve(Root, false, true, true)).IsEquivalentTo(new[] { Agents }); + } + + [Test] + public async Task With_content_neither_creates_agents() { + await Assert.That(CuratedTargets.Resolve(Root, false, false, true)).IsEquivalentTo(new[] { Agents }); + } + + [Test] + public async Task Empty_set_never_creates_targets_only_existing() { + await Assert.That(CuratedTargets.Resolve(Root, false, false, false)).IsEmpty(); + await Assert.That(CuratedTargets.Resolve(Root, true, false, false)).IsEquivalentTo(new[] { Claude }); + await Assert.That(CuratedTargets.Resolve(Root, true, true, false)).IsEquivalentTo(new[] { Claude, Agents }); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/CuratedTextNormalizerTests.cs b/test/Capacitor.Cli.Tests.Unit/CuratedTextNormalizerTests.cs new file mode 100644 index 0000000000..bbed07a1ee --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/CuratedTextNormalizerTests.cs @@ -0,0 +1,23 @@ +using Capacitor.Cli.Core.Curation; + +namespace Capacitor.Cli.Tests.Unit; + +public class CuratedTextNormalizerTests { + [Test] + public async Task Collapses_newlines_to_single_line() { + var r = CuratedTextNormalizer.Normalize("line one\r\n line two\n\nline three"); + await Assert.That(r).IsEqualTo("line one line two line three"); + } + + [Test] + public async Task Defangs_html_comment_terminator() { + var r = CuratedTextNormalizer.Normalize("text with inside"); + await Assert.That(r!.Contains("-->")).IsFalse(); + } + + [Test] + public async Task Empty_or_whitespace_returns_null() { + await Assert.That(CuratedTextNormalizer.Normalize(" \n\t ")).IsNull(); + await Assert.That(CuratedTextNormalizer.Normalize(null)).IsNull(); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/CurationApplyPlannerTests.cs b/test/Capacitor.Cli.Tests.Unit/CurationApplyPlannerTests.cs new file mode 100644 index 0000000000..769d3eaad7 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/CurationApplyPlannerTests.cs @@ -0,0 +1,63 @@ +using Capacitor.Cli.Core.Curation; + +namespace Capacitor.Cli.Tests.Unit; + +public class CurationApplyPlannerTests { + const string Root = "/repo"; + static string Claude => System.IO.Path.Combine(Root, "CLAUDE.md"); + static string Agents => System.IO.Path.Combine(Root, "AGENTS.md"); + static CuratedGuideline G(string t) => new("quality", t); + + [Test] + public async Task Creates_agents_when_neither_exists() { + var plan = CurationApplyPlanner.BuildPlan( + Root, + new Dictionary { [Claude] = null, [Agents] = null }, + [G("alpha")]); + + await Assert.That(plan.Files.Count).IsEqualTo(1); + var f = plan.Files[0]; + await Assert.That(f.Path).IsEqualTo(Agents); + await Assert.That(f.Action).IsEqualTo(CurateAction.Create); + await Assert.That(f.Added).IsEquivalentTo(new[] { "alpha" }); + await Assert.That(f.NewContent!.Contains("- alpha")).IsTrue(); + } + + [Test] + public async Task Update_diffs_added_and_removed() { + var existing = CuratedBlock.Splice("# T\n", CuratedBlock.Render([G("keep"), G("drop")])!); + var plan = CurationApplyPlanner.BuildPlan( + Root, + new Dictionary { [Claude] = existing, [Agents] = null }, + [G("keep"), G("new")]); + + var f = plan.Files.Single(p => p.Path == Claude); + await Assert.That(f.Action).IsEqualTo(CurateAction.Update); + await Assert.That(f.Added).IsEquivalentTo(new[] { "new" }); + await Assert.That(f.Removed).IsEquivalentTo(new[] { "drop" }); + } + + [Test] + public async Task Noop_when_block_already_current() { + var existing = CuratedBlock.Splice("# T\n", CuratedBlock.Render([G("same")])!); + var plan = CurationApplyPlanner.BuildPlan( + Root, + new Dictionary { [Claude] = existing }, + [G("same")]); + await Assert.That(plan.Files.Single().Action).IsEqualTo(CurateAction.NoOp); + } + + [Test] + public async Task Empty_guidelines_removes_existing_block() { + var existing = CuratedBlock.Splice("# T\nkeep\n", CuratedBlock.Render([G("x")])!); + var plan = CurationApplyPlanner.BuildPlan( + Root, + new Dictionary { [Claude] = existing, [Agents] = null }, + []); + + var f = plan.Files.Single(p => p.Path == Claude); + await Assert.That(f.Action).IsEqualTo(CurateAction.Remove); + await Assert.That(f.Removed).IsEquivalentTo(new[] { "x" }); + await Assert.That(f.NewContent!.Contains(CuratedBlock.StartMarker)).IsFalse(); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/CurationDtoTests.cs b/test/Capacitor.Cli.Tests.Unit/CurationDtoTests.cs new file mode 100644 index 0000000000..783b1d62f4 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/CurationDtoTests.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Capacitor.Cli.Core; + +namespace Capacitor.Cli.Tests.Unit; + +public class CurationDtoTests { + [Test] + public async Task Deserializes_wrapper_and_snake_case_fields() { + const string json = """ + { + "repo_hash": "ab01", + "items": [ + { "category": "quality", "cluster_id": "c-1", + "promoted_text": "prefer JsonNode.Parse for AOT", + "target_kinds": ["claude_md", "injection"], "status": "promoted" } + ] + } + """; + + var dto = JsonSerializer.Deserialize(json, CapacitorJsonContext.Default.CurationApplyResponse); + + await Assert.That(dto).IsNotNull(); + await Assert.That(dto!.RepoHash).IsEqualTo("ab01"); + await Assert.That(dto.Items!.Count).IsEqualTo(1); + await Assert.That(dto.Items[0].PromotedText).IsEqualTo("prefer JsonNode.Parse for AOT"); + await Assert.That(dto.Items[0].TargetKinds!).Contains("claude_md"); + } + + [Test] + public async Task Ignores_unknown_fields_and_empty_items() { + const string json = """{ "repo_hash": "x", "items": [], "extra": 1 }"""; + var dto = JsonSerializer.Deserialize(json, CapacitorJsonContext.Default.CurationApplyResponse); + await Assert.That(dto!.Items!.Count).IsEqualTo(0); + } +}