diff --git a/Source/Cli/Commands/Ai/AiCorpusAsset.cs b/Source/Cli/Commands/Ai/AiCorpusAsset.cs new file mode 100644 index 00000000..51cf4286 --- /dev/null +++ b/Source/Cli/Commands/Ai/AiCorpusAsset.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Ai; + +/// A corpus file selected for installation. +/// The absolute path of the corpus file. +/// The path relative to the managed root. +/// The corpus-relative source recorded in the managed marker. +sealed record AiCorpusAsset(string Path, string Destination, string Source); diff --git a/Source/Cli/Commands/Ai/AiCorpusInstaller.cs b/Source/Cli/Commands/Ai/AiCorpusInstaller.cs new file mode 100644 index 00000000..befc9719 --- /dev/null +++ b/Source/Cli/Commands/Ai/AiCorpusInstaller.cs @@ -0,0 +1,122 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Ai; + +/// Applies a resolved corpus selection to a project and reports what it replaced, added, or removed. +/// The physical consuming repository root. +/// The manifest of the previous installation. +/// The corpus assets to install, by destination. +/// The planned harness integrations, by path. +/// The file operations to apply, or only report in a dry run. +sealed class AiCorpusInstaller( + string projectPath, + AiInstallationManifest previous, + Dictionary desired, + Dictionary integrations, + AiFileOperations operations) +{ + readonly Dictionary _previousIntegrations = (previous.Integrations ?? []).ToDictionary(integration => integration.Path, StringComparer.Ordinal); + + internal List Actions { get; } = []; + + internal List Warnings { get; } = []; + + internal List ModifiedPaths() + { + var modified = AiCorpusSynchronizer.ModifiedFiles(projectPath, previous); + modified.AddRange(AiCorpusSynchronizer.ModifiedIntegrations(projectPath, previous)); + return modified; + } + + internal List UnknownCollisions() + { + var collisions = desired.Keys + .Where(destination => AiCorpusSynchronizer.PathExists(ManagedPath(destination)) && !previous.Files.Any(file => file.Destination == destination)) + .ToList(); + collisions.AddRange(integrations.Values + .Where(plan => !plan.PreserveExisting && AiCorpusSynchronizer.PathExists(Path.Combine(projectPath, plan.Path)) && !_previousIntegrations.ContainsKey(plan.Path) && !AiHarnessIntegrations.Matches(projectPath, plan)) + .Select(plan => plan.Path)); + return collisions; + } + + internal List UnchangeableModes() => [.. desired.Values + .Select(asset => (asset.Destination, Path: ManagedPath(asset.Destination), Content: MarkedContent(asset), Source: asset.Path)) + .Where(file => AiCorpusFileModes.Change(file.Path, file.Content, file.Source) is { } mode && AiCorpusFileModes.Adds(file.Path, mode) && !AiCorpusFileModes.CanChange(file.Path)) + .Select(file => $"{file.Destination} (it must become executable, but its file mode cannot be changed here; another user may own it, or the file system may refuse mode changes)")]; + + internal (List Files, List Integrations) Apply() + { + var files = InstallAssets(); + RemoveStaleFiles(); + RemoveStaleIntegrations(); + return (files, InstallIntegrations()); + } + + static string MarkedContent(AiCorpusAsset asset) => AiCorpusMarker.Add(asset.Source, asset.Path, File.ReadAllText(asset.Path)); + + string ManagedPath(string destination) => Path.Combine(projectPath, AiCorpusSynchronizer.ManagedRoot, destination); + + List InstallAssets() + { + var installed = new List(); + foreach (var asset in desired.Values.OrderBy(asset => asset.Destination, StringComparer.Ordinal)) + { + var destination = ManagedPath(asset.Destination); + var content = MarkedContent(asset); + var hash = AiCorpusSynchronizer.Hash(content); + var existed = File.Exists(destination); + operations.CreateDirectoryFor(destination); + var executable = operations.WriteCorpusFile(destination, content, asset.Path); + installed.Add(new(asset.Source, asset.Destination, hash)); + Actions.Add(existed ? $"Updated {asset.Destination}" : $"Added {asset.Destination}"); + if (!executable) Warnings.Add($"Could not make {asset.Destination} executable: the file system did not apply the mode change. Run it through its interpreter, or move the project to a file system that keeps file modes."); + } + return installed; + } + + void RemoveStaleFiles() + { + foreach (var existing in previous.Files.Where(file => !desired.ContainsKey(file.Destination))) + { + var destination = ManagedPath(existing.Destination); + if (!File.Exists(destination)) continue; + operations.DeleteFile(destination); + Actions.Add($"Removed {existing.Destination}"); + } + } + + void RemoveStaleIntegrations() + { + foreach (var existing in _previousIntegrations.Values.Where(integration => !integrations.ContainsKey(integration.Path))) + { + AiHarnessIntegrations.Delete(projectPath, existing, operations); + Actions.Add($"Removed {existing.Path}"); + } + } + + List InstallIntegrations() + { + var installed = new List(); + foreach (var plan in integrations.Values.OrderBy(plan => plan.Path, StringComparer.Ordinal)) + { + if (IsUserOwned(plan)) continue; + if (!AiHarnessIntegrations.Matches(projectPath, plan)) Reconfigure(plan); + installed.Add(plan); + } + return installed; + } + + bool IsUserOwned(AiManagedIntegration plan) + { + if (_previousIntegrations.ContainsKey(plan.Path)) return false; + return (plan.PreserveExisting && AiCorpusSynchronizer.PathExists(Path.Combine(projectPath, plan.Path))) || AiHarnessIntegrations.Matches(projectPath, plan); + } + + void Reconfigure(AiManagedIntegration plan) + { + if (AiCorpusSynchronizer.PathExists(Path.Combine(projectPath, plan.Path))) AiHarnessIntegrations.Delete(projectPath, plan, operations); + AiHarnessIntegrations.Create(projectPath, plan, operations); + Actions.Add($"Configured {plan.Path}"); + } +} diff --git a/Source/Cli/Commands/Ai/AiCorpusMarker.cs b/Source/Cli/Commands/Ai/AiCorpusMarker.cs new file mode 100644 index 00000000..ae0fb41d --- /dev/null +++ b/Source/Cli/Commands/Ai/AiCorpusMarker.cs @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Cratis.Cli.Commands.Ai; + +/// Adds the managed-by-Cratis marker to corpus content in the comment syntax of its file type. +static class AiCorpusMarker +{ + static readonly JsonSerializerOptions _serializerOptions = new() { WriteIndented = true }; + + internal static string Add(string source, string path, string content) + { + var marker = $"cratis-ai-managed: {source}"; + return Path.GetExtension(path).ToLowerInvariant() switch + { + ".md" or ".mdc" => AddMarkdownMarker(marker, content), + ".html" or ".htm" => $"\n{content}", + ".ts" or ".tsx" or ".js" or ".mjs" or ".cjs" or ".cs" => AddScriptMarker($"// {marker}", content), + ".css" or ".scss" => $"/* {marker} */\n{content}", + ".json" => AddJsonMarker(source, content), + _ => AddScriptMarker($"# {marker}", content) + }; + } + + static string AddMarkdownMarker(string marker, string content) + { + if (!content.StartsWith("---\n", StringComparison.Ordinal)) return $"\n{content}"; + var frontmatterEnd = content.IndexOf("\n---\n", 4, StringComparison.Ordinal); + return frontmatterEnd < 0 ? $"\n{content}" : content.Insert(frontmatterEnd + 5, $"\n"); + } + + static string AddJsonMarker(string source, string content) + { + if (JsonNode.Parse(content) is not JsonObject json) return content; + json["$cratisAiManaged"] = source; + return json.ToJsonString(_serializerOptions); + } + + static string AddScriptMarker(string marker, string content) + { + if (!content.StartsWith("#!", StringComparison.Ordinal)) return $"{marker}\n{content}"; + var lineEnd = content.IndexOf('\n'); + if (lineEnd < 0) return $"{content}\n{marker}\n"; + var newline = lineEnd > 0 && content[lineEnd - 1] == '\r' ? "\r\n" : "\n"; + + return content.Insert(lineEnd + 1, $"{marker}{newline}"); + } +} diff --git a/Source/Cli/Commands/Ai/AiCorpusProfiles.cs b/Source/Cli/Commands/Ai/AiCorpusProfiles.cs new file mode 100644 index 00000000..a1dae624 --- /dev/null +++ b/Source/Cli/Commands/Ai/AiCorpusProfiles.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +namespace Cratis.Cli.Commands.Ai; + +/// Validates and expands the profiles a configuration selects from a corpus profile catalog. +static class AiCorpusProfiles +{ + internal static HashSet Selected(string corpusPath, AiConfiguration configuration) + { + Validate(corpusPath, configuration); + using var catalog = JsonDocument.Parse(File.ReadAllText(Path.Combine(corpusPath, AiCorpusSynchronizer.ManagedRoot, "profile-catalog.json"))); + var selection = new Selection(CatalogProfiles(catalog), configuration.Languages); + foreach (var profile in configuration.Profiles) selection.Select(profile, explicitSelection: true); + return selection.Selected; + } + + internal static JsonElement[] CatalogProfiles(JsonDocument catalog) => [.. catalog.RootElement.EnumerateObject() + .Where(property => string.Equals(property.Name, "publicProfiles", StringComparison.Ordinal) || string.Equals(property.Name, "engineeringProfiles", StringComparison.Ordinal)) + .SelectMany(property => property.Value.EnumerateArray())]; + + internal static IEnumerable AvailableTargets(JsonElement profile) => profile.TryGetProperty("availableTargets", out var targets) + ? targets.EnumerateArray().Select(target => target.GetString()!) + : []; + + static void Validate(string corpusPath, AiConfiguration configuration) + { + var available = AiCorpusSynchronizer.Available(corpusPath); + Validate("harness", configuration.Harnesses, available.Harnesses); + Validate("profile", configuration.Profiles, available.Profiles); + Validate("language", configuration.Languages, available.Languages); + } + + static void Validate(string dimension, IEnumerable selected, IEnumerable available) + { + var known = available.ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var value in selected) + { + if (!known.Contains(value)) throw new InvalidOperationException($"Cratis AI does not offer {dimension} '{value}'."); + } + } + + static bool SupportsAnyLanguage(JsonElement profile, IReadOnlyList languages) + { + if (!profile.TryGetProperty("languages", out var supported)) return true; + + // A configuration that names no languages constrains nothing, so every language-specific profile + // still composes. Treating the empty set as "matches nothing" would silently drop composed + // profiles from a repository whose configuration simply omits the optional languages property. + if (languages.Count == 0) return true; + var values = supported.EnumerateArray().Select(language => language.GetString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + return values.Contains("language-agnostic") || languages.Any(values.Contains); + } + + sealed class Selection(IReadOnlyList profiles, IReadOnlyList languages) + { + internal HashSet Selected { get; } = new(StringComparer.Ordinal); + + internal void Select(string id, bool explicitSelection) + { + var profile = profiles.FirstOrDefault(item => item.GetProperty("id").GetString() == id); + if (profile.ValueKind == JsonValueKind.Undefined) throw new InvalidOperationException($"Unknown Cratis AI profile '{id}'."); + if (!explicitSelection && !SupportsAnyLanguage(profile, languages)) return; + if (!Selected.Add(id) || !profile.TryGetProperty("composes", out var composes)) return; + foreach (var child in composes.EnumerateArray()) Select(child.GetString()!, explicitSelection: false); + } + } +} diff --git a/Source/Cli/Commands/Ai/AiCorpusResolver.cs b/Source/Cli/Commands/Ai/AiCorpusResolver.cs new file mode 100644 index 00000000..904416bb --- /dev/null +++ b/Source/Cli/Commands/Ai/AiCorpusResolver.cs @@ -0,0 +1,115 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +namespace Cratis.Cli.Commands.Ai; + +/// Resolves which corpus files a configuration installs. +static class AiCorpusResolver +{ + static readonly string[] _categories = ["agents", "prompts", "hooks"]; + + internal static IEnumerable Resolve(string corpusPath, AiConfiguration configuration) + { + var selected = AiCorpusProfiles.Selected(corpusPath, configuration); + var catalogPath = Path.Combine(corpusPath, AiCorpusSynchronizer.ManagedRoot, "profile-catalog.json"); + using var catalog = JsonDocument.Parse(File.ReadAllText(catalogPath)); + var profiles = AiCorpusProfiles.CatalogProfiles(catalog); + foreach (var asset in DescriptorAssets(corpusPath, catalogPath)) yield return asset; + + var skillNames = profiles.Where(profile => selected.Contains(profile.GetProperty("id").GetString()!)) + .SelectMany(AiCorpusProfiles.AvailableTargets) + .ToHashSet(StringComparer.Ordinal); + var corpusRoot = Path.Combine(corpusPath, AiCorpusSynchronizer.ManagedRoot); + var assets = RuleAssets(corpusRoot, configuration, selected) + .Concat(CategoryAssets(corpusRoot)) + .Concat(SkillAssets(corpusRoot, skillNames)) + .Concat(HarnessAssets(corpusRoot, configuration)); + foreach (var asset in assets) yield return asset; + } + + static IEnumerable DescriptorAssets(string corpusPath, string catalogPath) + { + var descriptor = AiProjectPaths.Within(AiProjectPaths.PhysicalRoot(corpusPath), AiMcpDescriptor.RelativePath); + if (!File.Exists(descriptor)) yield break; + yield return new(descriptor, "mcp-servers.json", "mcp-servers.json"); + yield return new(catalogPath, "profile-catalog.json", "profile-catalog.json"); + } + + static IEnumerable RuleAssets(string corpusRoot, AiConfiguration configuration, HashSet selected) => + AssetsUnder(corpusRoot, "rules").Where(rule => RuleMatchesConfiguration(rule.Path, configuration, selected)); + + static IEnumerable CategoryAssets(string corpusRoot) => + _categories.SelectMany(category => AssetsUnder(corpusRoot, category)); + + static IEnumerable SkillAssets(string corpusRoot, IEnumerable skillNames) => + skillNames.SelectMany(skill => AssetsUnder(corpusRoot, $"skills/{skill}", excludeVerification: true)); + + static IEnumerable HarnessAssets(string corpusRoot, AiConfiguration configuration) => + configuration.Harnesses.SelectMany(harness => AssetsUnder(corpusRoot, $"harnesses/{harness}")); + + static IEnumerable AssetsUnder(string corpusRoot, string relativeRoot, bool excludeVerification = false) + { + var root = Path.Combine(corpusRoot, relativeRoot.Replace('/', Path.DirectorySeparatorChar)); + if (!Directory.Exists(root)) yield break; + foreach (var path in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(corpusRoot, path).Replace('\\', '/'); + if (excludeVerification && IsVerificationAsset(relative)) continue; + yield return new(path, relative, relative); + } + } + + static bool IsVerificationAsset(string relative) => + relative.EndsWith("/verification.json", StringComparison.Ordinal) || relative.Contains("/evals/", StringComparison.Ordinal); + + static bool RuleMatchesConfiguration(string path, AiConfiguration configuration, HashSet selectedProfiles) + { + var content = File.ReadAllText(path); + var frontmatterEnd = content.StartsWith("---\n", StringComparison.Ordinal) ? content.IndexOf("\n---\n", 4, StringComparison.Ordinal) : -1; + var frontmatter = frontmatterEnd < 0 ? string.Empty : content[4..frontmatterEnd]; + if (!ProfileAllowsRule(FrontmatterValue(frontmatter, "profile"), configuration, selectedProfiles)) return false; + + return LanguageAllowsRule(path, FrontmatterValue(frontmatter, "applyTo") ?? string.Empty, configuration, selectedProfiles); + } + + static bool ProfileAllowsRule(string? profile, AiConfiguration configuration, HashSet selectedProfiles) + { + if (string.Equals(profile, "application", StringComparison.OrdinalIgnoreCase)) return HasSelectedProfile(selectedProfiles, "cratis/application"); + if (string.Equals(profile, "framework", StringComparison.OrdinalIgnoreCase)) return HasSelectedProfile(selectedProfiles, "cratis/engineering"); + if (profile?.StartsWith("cratis/", StringComparison.Ordinal) is not true) return true; + + return configuration.Profiles.Any(candidate => candidate == profile || candidate.StartsWith($"{profile}/", StringComparison.Ordinal)); + } + + static bool HasSelectedProfile(HashSet selectedProfiles, string prefix) => + selectedProfiles.Any(candidate => candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + + static bool LanguageAllowsRule(string path, string applyTo, AiConfiguration configuration, HashSet selectedProfiles) + { + var requirements = LanguageRequirements(path, applyTo); + if (requirements == (false, false, false)) return true; + + var hasCSharp = configuration.Languages.Contains("csharp", StringComparer.OrdinalIgnoreCase); + var hasTypeScript = configuration.Languages.Contains("typescript", StringComparer.OrdinalIgnoreCase); + var hasDocumentation = selectedProfiles.Contains("cratis/documentation"); + return (requirements.CSharp && hasCSharp) || (requirements.TypeScript && hasTypeScript) || (requirements.Documentation && hasDocumentation); + } + + static (bool CSharp, bool TypeScript, bool Documentation) LanguageRequirements(string path, string applyTo) + { + var needsCSharp = applyTo.Contains(".cs", StringComparison.OrdinalIgnoreCase); + var needsTypeScript = applyTo.Contains(".ts", StringComparison.OrdinalIgnoreCase); + var needsDocumentation = applyTo.Contains("md", StringComparison.OrdinalIgnoreCase); + if (string.Equals(Path.GetFileName(path), "rtk.md", StringComparison.OrdinalIgnoreCase)) needsTypeScript = true; + return (needsCSharp, needsTypeScript, needsDocumentation); + } + + static string? FrontmatterValue(string frontmatter, string name) + { + var prefix = $"{name}:"; + var line = frontmatter.Split('\n').FirstOrDefault(candidate => candidate.StartsWith(prefix, StringComparison.Ordinal)); + return line?[prefix.Length..].Trim().Trim('"'); + } +} diff --git a/Source/Cli/Commands/Ai/AiCorpusSynchronizer.cs b/Source/Cli/Commands/Ai/AiCorpusSynchronizer.cs index 79103227..1d3c9329 100644 --- a/Source/Cli/Commands/Ai/AiCorpusSynchronizer.cs +++ b/Source/Cli/Commands/Ai/AiCorpusSynchronizer.cs @@ -12,10 +12,9 @@ namespace Cratis.Cli.Commands.Ai; /// Synchronizes only the files and harness integrations recorded as Cratis-owned in a project. public static class AiCorpusSynchronizer { + internal const string ManagedRoot = ".cratis/ai"; const string ConfigurationPath = ".cratis/ai.json"; const string ManifestPath = ".cratis/ai.manifest.json"; - const string ManagedRoot = ".cratis/ai"; - const string ProjectInstructionsPath = ".cratis/ai/rules/project.md"; static readonly JsonSerializerOptions _serializerOptions = new() { WriteIndented = true }; /// Installs or updates the selected corpus, preserving changed and unknown files. @@ -30,86 +29,26 @@ public static SyncResult Synchronize(string projectPath, string corpusPath, AiCo var operations = new AiFileOperations(dryRun); projectPath = AiProjectPaths.PhysicalRoot(projectPath, requireExists: false); var previous = ReadManifest(projectPath); - if (File.Exists(AiProjectPaths.Within(projectPath, ConfigurationPath))) - { - var existing = ReadConfiguration(projectPath); - configuration = configuration with { McpServers = configuration.McpServers ?? existing.McpServers }; - } + configuration = WithExistingMcpServers(projectPath, configuration); AiProjectPaths.Within(projectPath, AiMcpDescriptor.RelativePath); - var desired = Resolve(corpusPath, configuration).ToDictionary(asset => asset.Destination, StringComparer.Ordinal); - var hasPiBridge = desired.ContainsKey("harnesses/pi/extensions/cratis-mcp.ts") || desired.ContainsKey("harnesses/pi/extensions/cratis-mcp/index.ts"); - var mcp = AiMcpPlan.Create(projectPath, AiMcpDescriptor.Read(AiProjectPaths.PhysicalRoot(corpusPath)), configuration, SelectedProfiles(corpusPath, configuration), previous, hasPiBridge); + var desired = AiCorpusResolver.Resolve(corpusPath, configuration).ToDictionary(asset => asset.Destination, StringComparer.Ordinal); + var mcp = AiMcpPlan.Create(projectPath, AiMcpDescriptor.Read(AiProjectPaths.PhysicalRoot(corpusPath)), configuration, AiCorpusProfiles.Selected(corpusPath, configuration), previous, HasPiBridge(desired)); if (mcp.Conflicts.Count > 0) return new([], mcp.Conflicts, mcp.Unsupported); - var projectInstructionsSource = FindProjectInstructions(projectPath); - var integrationPlans = PlanHarnessIntegrations(configuration.Harnesses, desired.Keys, projectInstructionsSource is not null); - var previousIntegrations = (previous.Integrations ?? []).ToDictionary(integration => integration.Path, StringComparer.Ordinal); - var modified = ModifiedFiles(projectPath, previous); - modified.AddRange(ModifiedIntegrations(projectPath, previous)); - - var unknownCollisions = desired.Keys - .Where(destination => PathExists(Path.Combine(projectPath, ManagedRoot, destination)) && !previous.Files.Any(file => file.Destination == destination)) - .ToList(); - unknownCollisions.AddRange(integrationPlans.Values - .Where(plan => !plan.PreserveExisting && PathExists(Path.Combine(projectPath, plan.Path)) && !previousIntegrations.ContainsKey(plan.Path) && !IntegrationMatches(projectPath, plan)) - .Select(plan => plan.Path)); + var projectInstructionsSource = AiProjectInstructions.Find(projectPath); + var integrationPlans = AiHarnessIntegrations.Plan(configuration.Harnesses, desired.Keys, projectInstructionsSource is not null); + var installer = new AiCorpusInstaller(projectPath, previous, desired, integrationPlans, operations); + var modified = installer.ModifiedPaths(); - if (unknownCollisions.Count > 0) return new([], [.. unknownCollisions.Distinct(StringComparer.Ordinal).Order()], mcp.Unsupported); - if (modified.Count > 0 && !force) return new([], [.. modified.Distinct(StringComparer.Ordinal).Order()], mcp.Unsupported); + if (installer.UnknownCollisions() is { Count: > 0 } unknownCollisions) return new([], DistinctOrdered(unknownCollisions), mcp.Unsupported); + if (modified.Count > 0 && !force) return new([], DistinctOrdered(modified), mcp.Unsupported); - List unchangeableModes = dryRun ? [] : [.. desired.Values - .Select(asset => (asset.Destination, Path: Path.Combine(projectPath, ManagedRoot, asset.Destination), Content: AddMarker(asset.Source, asset.Path, File.ReadAllText(asset.Path)), Source: asset.Path)) - .Where(file => AiCorpusFileModes.Change(file.Path, file.Content, file.Source) is { } mode && AiCorpusFileModes.Adds(file.Path, mode) && !AiCorpusFileModes.CanChange(file.Path)) - .Select(file => $"{file.Destination} (it must become executable, but its file mode cannot be changed here; another user may own it, or the file system may refuse mode changes)")]; - if (unchangeableModes.Count > 0) return new([], [.. unchangeableModes.Order(StringComparer.Ordinal)], mcp.Unsupported); + if (!dryRun && installer.UnchangeableModes() is { Count: > 0 } unchangeableModes) return new([], [.. unchangeableModes.Order(StringComparer.Ordinal)], mcp.Unsupported); - var actions = new List(); - var warnings = new List(); - if (projectInstructionsSource is not null) MigrateProjectInstructions(projectPath, projectInstructionsSource, actions, operations); - - var installed = new List(); - foreach (var asset in desired.Values.OrderBy(asset => asset.Destination, StringComparer.Ordinal)) - { - var destination = Path.Combine(projectPath, ManagedRoot, asset.Destination); - var content = AddMarker(asset.Source, asset.Path, File.ReadAllText(asset.Path)); - var hash = Hash(content); - var existed = File.Exists(destination); - operations.CreateDirectoryFor(destination); - var executable = operations.WriteCorpusFile(destination, content, asset.Path); - installed.Add(new(asset.Source, asset.Destination, hash)); - actions.Add(existed ? $"Updated {asset.Destination}" : $"Added {asset.Destination}"); - if (!executable) warnings.Add($"Could not make {asset.Destination} executable: the file system did not apply the mode change. Run it through its interpreter, or move the project to a file system that keeps file modes."); - } - - foreach (var existing in previous.Files.Where(file => !desired.ContainsKey(file.Destination))) - { - var destination = Path.Combine(projectPath, ManagedRoot, existing.Destination); - if (!File.Exists(destination)) continue; - operations.DeleteFile(destination); - actions.Add($"Removed {existing.Destination}"); - } - - foreach (var existing in previousIntegrations.Values.Where(integration => !integrationPlans.ContainsKey(integration.Path))) - { - DeleteIntegration(projectPath, existing, operations); - actions.Add($"Removed {existing.Path}"); - } - - var installedIntegrations = new List(); - foreach (var plan in integrationPlans.Values.OrderBy(plan => plan.Path, StringComparer.Ordinal)) - { - if (!previousIntegrations.ContainsKey(plan.Path) && plan.PreserveExisting && PathExists(Path.Combine(projectPath, plan.Path))) continue; - if (!previousIntegrations.ContainsKey(plan.Path) && IntegrationMatches(projectPath, plan)) continue; - if (!IntegrationMatches(projectPath, plan)) - { - if (PathExists(Path.Combine(projectPath, plan.Path))) DeleteIntegration(projectPath, plan, operations); - CreateIntegration(projectPath, plan, operations); - actions.Add($"Configured {plan.Path}"); - } - installedIntegrations.Add(plan); - } + if (projectInstructionsSource is not null) AiProjectInstructions.Migrate(projectPath, projectInstructionsSource, installer.Actions, operations); + var (installed, installedIntegrations) = installer.Apply(); mcp.Apply(operations); - actions.AddRange(mcp.Actions); + installer.Actions.AddRange(mcp.Actions); WriteConfiguration(projectPath, configuration, operations); var manifest = new AiInstallationManifest( Revision(corpusPath), @@ -119,7 +58,7 @@ [.. installedIntegrations.OrderBy(integration => integration.Path, StringCompare mcp.Unsupported, mcp.Extensions); WriteJson(AiProjectPaths.Within(projectPath, ManifestPath), manifest, operations); - return new(actions, [], mcp.Unsupported, warnings); + return new(installer.Actions, [], mcp.Unsupported, installer.Warnings); } /// Reads the selections offered by a Cratis AI corpus. @@ -142,7 +81,7 @@ public static AiStatus Status(string projectPath) modified.AddRange(ModifiedIntegrations(projectPath, manifest)); modified.AddRange(AiMcpPlan.Drift(projectPath, manifest)); modified.AddRange(AiMcpPlan.RootProblems(projectPath, configuration, manifest)); - return new(configuration, manifest.SourceRevision, [.. modified.Distinct(StringComparer.Ordinal).Order()], manifest.UnsupportedMcpServers ?? [], manifest.McpServers ?? [], manifest.McpExtensions ?? []); + return new(configuration, manifest.SourceRevision, DistinctOrdered(modified), manifest.UnsupportedMcpServers ?? [], manifest.McpServers ?? [], manifest.McpExtensions ?? []); } /// Removes only unchanged managed files and Cratis-created harness integrations. @@ -159,23 +98,11 @@ public static SyncResult Uninstall(string projectPath, bool force = false, bool if (mcp.Conflicts.Count > 0) return new([], mcp.Conflicts); var modified = ModifiedFiles(projectPath, manifest); modified.AddRange(ModifiedIntegrations(projectPath, manifest)); - if (modified.Count > 0 && !force) return new([], [.. modified.Distinct(StringComparer.Ordinal).Order()]); + if (modified.Count > 0 && !force) return new([], DistinctOrdered(modified)); var actions = new List(); - foreach (var file in manifest.Files) - { - var path = Path.Combine(projectPath, ManagedRoot, file.Destination); - if (!File.Exists(path)) continue; - operations.DeleteFile(path); - actions.Add($"Removed {file.Destination}"); - } - - foreach (var integration in manifest.Integrations ?? []) - { - if (!PathExists(Path.Combine(projectPath, integration.Path))) continue; - DeleteIntegration(projectPath, integration, operations); - actions.Add($"Removed {integration.Path}"); - } + RemoveManagedFiles(projectPath, manifest, operations, actions); + RemoveManagedIntegrations(projectPath, manifest, operations, actions); mcp.Apply(operations); actions.AddRange(mcp.Actions); @@ -212,324 +139,68 @@ internal static AiConfiguration ReadConfiguration(string projectPath) return new(ReadArray(document, "harnesses"), ReadArray(document, "profiles"), ReadArray(document, "languages"), AiMcpConfiguration.Read(document.RootElement)); } - static IEnumerable Resolve(string corpusPath, AiConfiguration configuration) + internal static List ModifiedFiles(string projectPath, AiInstallationManifest manifest) => [.. manifest.Files.Where(file => { - var selected = SelectedProfiles(corpusPath, configuration); - var catalogPath = Path.Combine(corpusPath, ManagedRoot, "profile-catalog.json"); - using var catalog = JsonDocument.Parse(File.ReadAllText(catalogPath)); - var profiles = catalog.RootElement.EnumerateObject() - .Where(property => string.Equals(property.Name, "publicProfiles", StringComparison.Ordinal) || string.Equals(property.Name, "engineeringProfiles", StringComparison.Ordinal)) - .SelectMany(property => property.Value.EnumerateArray()) - .ToArray(); - var descriptor = AiProjectPaths.Within(AiProjectPaths.PhysicalRoot(corpusPath), AiMcpDescriptor.RelativePath); - if (File.Exists(descriptor)) - { - yield return new(descriptor, "mcp-servers.json", "mcp-servers.json"); - yield return new(catalogPath, "profile-catalog.json", "profile-catalog.json"); - } - - var skillNames = profiles.Where(profile => selected.Contains(profile.GetProperty("id").GetString()!)) - .SelectMany(AvailableTargets) - .ToHashSet(StringComparer.Ordinal); - var corpusRoot = Path.Combine(corpusPath, ManagedRoot); - foreach (var rule in AssetsUnder(corpusRoot, "rules")) - { - if (RuleMatchesConfiguration(rule.Path, configuration, selected)) yield return rule; - } - foreach (var category in new[] { "agents", "prompts", "hooks" }) - { - foreach (var asset in AssetsUnder(corpusRoot, category)) yield return asset; - } - - foreach (var skill in skillNames) - { - foreach (var asset in AssetsUnder(corpusRoot, $"skills/{skill}", excludeVerification: true)) yield return asset; - } + var path = Path.Combine(projectPath, ManagedRoot, file.Destination); + return !File.Exists(path) || !string.Equals(Hash(File.ReadAllText(path)), file.Hash, StringComparison.Ordinal); + }).Select(file => file.Destination)]; - foreach (var harness in configuration.Harnesses) - { - foreach (var asset in AssetsUnder(corpusRoot, $"harnesses/{harness}")) yield return asset; - } - } + internal static List ModifiedIntegrations(string projectPath, AiInstallationManifest manifest) => [.. (manifest.Integrations ?? []) + .Where(integration => !AiHarnessIntegrations.Matches(projectPath, integration)) + .Select(integration => integration.Path)]; - static HashSet SelectedProfiles(string corpusPath, AiConfiguration configuration) - { - ValidateSelection(corpusPath, configuration); - using var catalog = JsonDocument.Parse(File.ReadAllText(Path.Combine(corpusPath, ManagedRoot, "profile-catalog.json"))); - var profiles = catalog.RootElement.EnumerateObject() - .Where(property => string.Equals(property.Name, "publicProfiles", StringComparison.Ordinal) || string.Equals(property.Name, "engineeringProfiles", StringComparison.Ordinal)) - .SelectMany(property => property.Value.EnumerateArray()).ToArray(); - var selected = new HashSet(StringComparer.Ordinal); - foreach (var profile in configuration.Profiles) SelectProfile(profile, selected, profiles, configuration.Languages, explicitSelection: true); - return selected; - } + internal static string Hash(string content) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); - static IEnumerable AssetsUnder(string corpusRoot, string relativeRoot, bool excludeVerification = false) + internal static bool PathExists(string path) { - var root = Path.Combine(corpusRoot, relativeRoot.Replace('/', Path.DirectorySeparatorChar)); - if (!Directory.Exists(root)) yield break; - foreach (var path in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + try { - var relative = Path.GetRelativePath(corpusRoot, path).Replace('\\', '/'); - if (excludeVerification && (relative.EndsWith("/verification.json", StringComparison.Ordinal) || relative.Contains("/evals/", StringComparison.Ordinal))) continue; - yield return new(path, relative, relative); + _ = File.GetAttributes(path); + return true; } - } - - static bool RuleMatchesConfiguration(string path, AiConfiguration configuration, HashSet selectedProfiles) - { - var content = File.ReadAllText(path); - var frontmatterEnd = content.StartsWith("---\n", StringComparison.Ordinal) ? content.IndexOf("\n---\n", 4, StringComparison.Ordinal) : -1; - var frontmatter = frontmatterEnd < 0 ? string.Empty : content[4..frontmatterEnd]; - var profile = FrontmatterValue(frontmatter, "profile"); - var hasApplicationProfile = selectedProfiles.Any(candidate => candidate.StartsWith("cratis/application", StringComparison.OrdinalIgnoreCase)); - var hasEngineeringProfile = selectedProfiles.Any(candidate => candidate.StartsWith("cratis/engineering", StringComparison.OrdinalIgnoreCase)); - if (string.Equals(profile, "application", StringComparison.OrdinalIgnoreCase) && !hasApplicationProfile) return false; - if (string.Equals(profile, "framework", StringComparison.OrdinalIgnoreCase) && !hasEngineeringProfile) return false; - if (profile?.StartsWith("cratis/", StringComparison.Ordinal) is true && !configuration.Profiles.Any(candidate => candidate == profile || candidate.StartsWith($"{profile}/", StringComparison.Ordinal))) return false; - - var applyTo = FrontmatterValue(frontmatter, "applyTo") ?? string.Empty; - var needsCSharp = applyTo.Contains(".cs", StringComparison.OrdinalIgnoreCase); - var needsTypeScript = applyTo.Contains(".ts", StringComparison.OrdinalIgnoreCase); - var needsDocumentation = applyTo.Contains("md", StringComparison.OrdinalIgnoreCase); - if (string.Equals(Path.GetFileName(path), "rtk.md", StringComparison.OrdinalIgnoreCase)) needsTypeScript = true; - if (!needsCSharp && !needsTypeScript && !needsDocumentation) return true; - - var hasCSharp = configuration.Languages.Contains("csharp", StringComparer.OrdinalIgnoreCase); - var hasTypeScript = configuration.Languages.Contains("typescript", StringComparer.OrdinalIgnoreCase); - var hasDocumentation = selectedProfiles.Contains("cratis/documentation"); - return (needsCSharp && hasCSharp) || (needsTypeScript && hasTypeScript) || (needsDocumentation && hasDocumentation); - } - - static string? FrontmatterValue(string frontmatter, string name) - { - var prefix = $"{name}:"; - var line = frontmatter.Split('\n').FirstOrDefault(candidate => candidate.StartsWith(prefix, StringComparison.Ordinal)); - return line?[prefix.Length..].Trim().Trim('"'); - } - - static void ValidateSelection(string corpusPath, AiConfiguration configuration) - { - var available = Available(corpusPath); - Validate("harness", configuration.Harnesses, available.Harnesses); - Validate("profile", configuration.Profiles, available.Profiles); - Validate("language", configuration.Languages, available.Languages); - } - - static void Validate(string dimension, IEnumerable selected, IEnumerable available) - { - var known = available.ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var value in selected) + catch (FileNotFoundException) { - if (!known.Contains(value)) throw new InvalidOperationException($"Cratis AI does not offer {dimension} '{value}'."); + return new FileInfo(path).LinkTarget is not null || new DirectoryInfo(path).LinkTarget is not null; } - } - - static IEnumerable AvailableTargets(JsonElement profile) => profile.TryGetProperty("availableTargets", out var targets) - ? targets.EnumerateArray().Select(target => target.GetString()!) - : []; - - static void SelectProfile(string id, ISet selected, IReadOnlyList profiles, IReadOnlyList languages, bool explicitSelection) - { - var profile = profiles.FirstOrDefault(item => item.GetProperty("id").GetString() == id); - if (profile.ValueKind == JsonValueKind.Undefined) throw new InvalidOperationException($"Unknown Cratis AI profile '{id}'."); - if (!explicitSelection && !SupportsAnyLanguage(profile, languages)) return; - if (!selected.Add(id) || !profile.TryGetProperty("composes", out var composes)) return; - foreach (var child in composes.EnumerateArray()) SelectProfile(child.GetString()!, selected, profiles, languages, explicitSelection: false); - } - - static bool SupportsAnyLanguage(JsonElement profile, IReadOnlyList languages) - { - if (!profile.TryGetProperty("languages", out var supported)) return true; - - // A configuration that names no languages constrains nothing, so every language-specific profile - // still composes. Treating the empty set as "matches nothing" would silently drop composed - // profiles from a repository whose configuration simply omits the optional languages property. - if (languages.Count == 0) return true; - var values = supported.EnumerateArray().Select(language => language.GetString()).ToHashSet(StringComparer.OrdinalIgnoreCase); - return values.Contains("language-agnostic") || languages.Any(values.Contains); - } - - static string? FindProjectInstructions(string projectPath) - { - var destination = Path.Combine(projectPath, ProjectInstructionsPath); - if (File.Exists(destination)) return destination; - foreach (var candidate in new[] { ".cratis/PROJECT.md", ".ai/PROJECT.md", ".agents/PROJECT.md" }) + catch (DirectoryNotFoundException) { - var path = Path.Combine(projectPath, candidate); - if (File.Exists(path)) return path; + return false; } - return null; } - static void MigrateProjectInstructions(string projectPath, string source, List actions, AiFileOperations operations) - { - var destination = Path.Combine(projectPath, ProjectInstructionsPath); - var concernsDirectory = Path.Combine(Path.GetDirectoryName(destination)!, "project"); - if (Directory.Exists(concernsDirectory)) return; + static bool HasPiBridge(Dictionary desired) => + desired.ContainsKey("harnesses/pi/extensions/cratis-mcp.ts") || desired.ContainsKey("harnesses/pi/extensions/cratis-mcp/index.ts"); - var content = File.ReadAllText(source).Replace("\r\n", "\n", StringComparison.Ordinal); - var lines = content.Split('\n'); - var headingIndexes = new List(); - var insideCodeFence = false; - for (var index = 0; index < lines.Length; index++) - { - if (lines[index].TrimStart().StartsWith("```", StringComparison.Ordinal)) insideCodeFence = !insideCodeFence; - else if (!insideCodeFence && lines[index].StartsWith("## ", StringComparison.Ordinal)) headingIndexes.Add(index); - } - - operations.CreateDirectoryFor(destination); - if (headingIndexes.Count == 0) - { - if (!PathExists(destination)) operations.Copy(source, destination); - return; - } + static string[] DistinctOrdered(IEnumerable values) => [.. values.Distinct(StringComparer.Ordinal).Order()]; - operations.CreateDirectory(concernsDirectory); - var usedNames = new HashSet(StringComparer.Ordinal); - var links = new List(); - for (var sectionIndex = 0; sectionIndex < headingIndexes.Count; sectionIndex++) - { - var start = headingIndexes[sectionIndex]; - var end = sectionIndex + 1 < headingIndexes.Count ? headingIndexes[sectionIndex + 1] : lines.Length; - var title = lines[start][3..].Trim(); - var baseName = Slug(title); - var name = baseName; - var suffix = 2; - while (!usedNames.Add(name)) name = $"{baseName}-{suffix++}"; - var section = string.Join('\n', lines[start..end]).TrimEnd(); - operations.WriteAllText(Path.Combine(concernsDirectory, $"{name}.md"), $"---\napplyTo: \"**/*\"\n---\n\n{section}\n"); - links.Add($"- [{title}]({ProjectInstructionsPath[..^3]}/{name}.md)"); - } - - var preamble = string.Join('\n', lines[..headingIndexes[0]]).TrimEnd(); - var indexContent = $"{preamble}\n\n## Project concerns\n\nRead every concern below before working in this repository. Together they are the project-owned instructions and override conflicting shared guidance.\n\n{string.Join('\n', links)}\n"; - operations.WriteAllText(destination, indexContent); - actions.Add($"Split {Path.GetRelativePath(projectPath, source).Replace('\\', '/')} into {ProjectInstructionsPath} and {ProjectInstructionsPath[..^3]}/"); + static AiConfiguration WithExistingMcpServers(string projectPath, AiConfiguration configuration) + { + if (!File.Exists(AiProjectPaths.Within(projectPath, ConfigurationPath))) return configuration; + var existing = ReadConfiguration(projectPath); + return configuration with { McpServers = configuration.McpServers ?? existing.McpServers }; } - static string Slug(string value) + static void RemoveManagedFiles(string projectPath, AiInstallationManifest manifest, AiFileOperations operations, List actions) { - var slug = new StringBuilder(); - foreach (var character in value.ToLowerInvariant()) + foreach (var file in manifest.Files) { - if (char.IsLetterOrDigit(character)) slug.Append(character); - else if (slug.Length > 0 && slug[^1] != '-') slug.Append('-'); + var path = Path.Combine(projectPath, ManagedRoot, file.Destination); + if (!File.Exists(path)) continue; + operations.DeleteFile(path); + actions.Add($"Removed {file.Destination}"); } - return slug.ToString().Trim('-') is { Length: > 0 } result ? result : "instructions"; } - static Dictionary PlanHarnessIntegrations(IEnumerable harnesses, IEnumerable files, bool hasProjectInstructions) + static void RemoveManagedIntegrations(string projectPath, AiInstallationManifest manifest, AiFileOperations operations, List actions) { - var plans = new Dictionary(StringComparer.Ordinal); - var selected = harnesses.ToHashSet(StringComparer.OrdinalIgnoreCase); - var rootInstructionsTarget = hasProjectInstructions ? ProjectInstructionsPath : ".cratis/ai/rules/general.md"; - var nestedInstructionsTarget = hasProjectInstructions ? "../.cratis/ai/rules/project.md" : "../.cratis/ai/rules/general.md"; - void Add(string path, string target, bool isDirectory, bool preserveExisting = false) => plans.TryAdd(path, new(path, target, isDirectory, preserveExisting)); - void AddRootInstructions() => Add("AGENTS.md", rootInstructionsTarget, false, preserveExisting: true); - void AddCommands(string directory) - { - foreach (var prompt in files.Where(file => file.StartsWith("prompts/", StringComparison.Ordinal) && file.EndsWith(".prompt.md", StringComparison.Ordinal))) - { - var name = Path.GetFileName(prompt)[..^".prompt.md".Length]; - Add($"{directory}/{name}.md", $"../../.cratis/ai/{prompt}", false); - } - } - void AddAgents(string directory, string suffix) - { - foreach (var agent in files.Where(file => file.StartsWith("agents/", StringComparison.Ordinal) && file.EndsWith(".md", StringComparison.Ordinal))) - { - var name = Path.GetFileNameWithoutExtension(agent); - Add($"{directory}/{name}{suffix}", $"../../.cratis/ai/{agent}", false); - } - } - - if (selected.Contains("claude")) - { - Add("CLAUDE.md", rootInstructionsTarget, false, preserveExisting: true); - Add(".claude/CLAUDE.md", nestedInstructionsTarget, false, preserveExisting: true); - Add(".claude/agents", "../.cratis/ai/agents", true); - Add(".claude/hooks", "../.cratis/ai/hooks", true); - Add(".claude/prompts", "../.cratis/ai/prompts", true); - Add(".claude/rules", "../.cratis/ai/rules", true); - Add(".claude/settings.json", "../.cratis/ai/hooks/settings.template.json", false); - Add(".claude/skills", "../.cratis/ai/skills", true); - AddCommands(".claude/commands"); - } - if (selected.Contains("codex")) - { - AddRootInstructions(); - Add(".agents/skills", "../.cratis/ai/skills", true); - } - if (selected.Contains("copilot")) - { - Add(".github/copilot-instructions.md", nestedInstructionsTarget, false, preserveExisting: true); - Add(".github/instructions", "../.cratis/ai/rules", true); - Add(".github/prompts", "../.cratis/ai/prompts", true); - Add(".github/skills", "../.cratis/ai/skills", true); - AddAgents(".github/agents", ".agent.md"); - } - if (selected.Contains("pi")) - { - AddRootInstructions(); - Add(".pi/agents", "../.cratis/ai/agents", true); - Add(".pi/extensions", "../.cratis/ai/harnesses/pi/extensions", true); - Add(".pi/skills", "../.cratis/ai/skills", true); - AddCommands(".pi/prompts"); - } - if (selected.Contains("cursor")) - { - Add(".cursor/agents", "../.cratis/ai/agents", true); - Add(".cursor/rules", "../.cratis/ai/harnesses/cursor/rules", true); - Add(".cursor/skills", "../.cratis/ai/skills", true); - AddCommands(".cursor/commands"); - } - if (selected.Contains("opencode")) + foreach (var integration in manifest.Integrations ?? []) { - AddRootInstructions(); - - // OpenCode cannot consume the canonical agent shape: it has no tools allowlist and restricts through a - // permission map, so the corpus ships generated adapters under harnesses/opencode/agents. Link those when - // the corpus carries them; an older corpus without them falls back to the canonical directory. - var hasOpenCodeAgents = files.Any(file => file.StartsWith("harnesses/opencode/agents/", StringComparison.Ordinal)); - Add(".opencode/agents", hasOpenCodeAgents ? "../.cratis/ai/harnesses/opencode/agents" : "../.cratis/ai/agents", true); - Add(".opencode/skills", "../.cratis/ai/skills", true); - AddCommands(".opencode/commands"); + if (!PathExists(Path.Combine(projectPath, integration.Path))) continue; + AiHarnessIntegrations.Delete(projectPath, integration, operations); + actions.Add($"Removed {integration.Path}"); } - return plans; - } - - static void CreateIntegration(string projectPath, AiManagedIntegration integration, AiFileOperations operations) - { - var path = Path.Combine(projectPath, integration.Path); - operations.CreateDirectoryFor(path); - operations.CreateSymbolicLink(path, integration.Target, integration.IsDirectory); } - static void DeleteIntegration(string projectPath, AiManagedIntegration integration, AiFileOperations operations) - { - var path = Path.Combine(projectPath, integration.Path); - if (File.GetAttributes(path).HasFlag(FileAttributes.Directory)) operations.DeleteDirectory(path); - else operations.DeleteFile(path); - } - - static bool IntegrationMatches(string projectPath, AiManagedIntegration integration) - { - var path = Path.Combine(projectPath, integration.Path); - var target = integration.IsDirectory ? new DirectoryInfo(path).LinkTarget : new FileInfo(path).LinkTarget; - return string.Equals(target, integration.Target, StringComparison.Ordinal); - } - - static List ModifiedFiles(string projectPath, AiInstallationManifest manifest) => [.. manifest.Files.Where(file => - { - var path = Path.Combine(projectPath, ManagedRoot, file.Destination); - return !File.Exists(path) || !string.Equals(Hash(File.ReadAllText(path)), file.Hash, StringComparison.Ordinal); - }).Select(file => file.Destination)]; - - static List ModifiedIntegrations(string projectPath, AiInstallationManifest manifest) => [.. (manifest.Integrations ?? []) - .Where(integration => !IntegrationMatches(projectPath, integration)) - .Select(integration => integration.Path)]; - /// /// Reads an optional array of strings. A configuration may legitimately omit a dimension it does not /// constrain, and an absent property must read as "unconstrained" rather than throwing: the property @@ -549,68 +220,6 @@ static AiInstallationManifest ReadManifest(string projectPath) return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path))! : new("unknown", [], []); } - static string AddMarker(string source, string path, string content) - { - var marker = $"cratis-ai-managed: {source}"; - var extension = Path.GetExtension(path).ToLowerInvariant(); - if (string.Equals(extension, ".md", StringComparison.Ordinal) || string.Equals(extension, ".mdc", StringComparison.Ordinal)) - { - if (!content.StartsWith("---\n", StringComparison.Ordinal)) return $"\n{content}"; - var frontmatterEnd = content.IndexOf("\n---\n", 4, StringComparison.Ordinal); - return frontmatterEnd < 0 ? $"\n{content}" : content.Insert(frontmatterEnd + 5, $"\n"); - } - if (string.Equals(extension, ".html", StringComparison.Ordinal) || string.Equals(extension, ".htm", StringComparison.Ordinal)) return $"\n{content}"; - if (string.Equals(extension, ".ts", StringComparison.Ordinal) || - string.Equals(extension, ".tsx", StringComparison.Ordinal) || - string.Equals(extension, ".js", StringComparison.Ordinal) || - string.Equals(extension, ".mjs", StringComparison.Ordinal) || - string.Equals(extension, ".cjs", StringComparison.Ordinal) || - string.Equals(extension, ".cs", StringComparison.Ordinal)) - { - return AddScriptMarker($"// {marker}", content); - } - if (string.Equals(extension, ".css", StringComparison.Ordinal) || string.Equals(extension, ".scss", StringComparison.Ordinal)) return $"/* {marker} */\n{content}"; - if (extension == ".json") - { - if (JsonNode.Parse(content) is JsonObject json) - { - json["$cratisAiManaged"] = source; - return json.ToJsonString(_serializerOptions); - } - return content; - } - return AddScriptMarker($"# {marker}", content); - } - - static string AddScriptMarker(string marker, string content) - { - if (!content.StartsWith("#!", StringComparison.Ordinal)) return $"{marker}\n{content}"; - var lineEnd = content.IndexOf('\n'); - if (lineEnd < 0) return $"{content}\n{marker}\n"; - var newline = lineEnd > 0 && content[lineEnd - 1] == '\r' ? "\r\n" : "\n"; - - return content.Insert(lineEnd + 1, $"{marker}{newline}"); - } - - static string Hash(string content) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); - - static bool PathExists(string path) - { - try - { - _ = File.GetAttributes(path); - return true; - } - catch (FileNotFoundException) - { - return new FileInfo(path).LinkTarget is not null || new DirectoryInfo(path).LinkTarget is not null; - } - catch (DirectoryNotFoundException) - { - return false; - } - } - static void WriteConfiguration(string projectPath, AiConfiguration configuration, AiFileOperations operations) { var path = AiProjectPaths.Within(projectPath, ConfigurationPath); @@ -629,8 +238,6 @@ static void WriteJson(string path, object value, AiFileOperations operations) operations.CreateDirectoryFor(path); operations.WriteAllTextAtomically(path, JsonSerializer.Serialize(value, _serializerOptions)); } - - sealed record CorpusAsset(string Path, string Destination, string Source); } /// The result of an install, update, or uninstall operation. diff --git a/Source/Cli/Commands/Ai/AiHarnessIntegrations.cs b/Source/Cli/Commands/Ai/AiHarnessIntegrations.cs new file mode 100644 index 00000000..f2450caa --- /dev/null +++ b/Source/Cli/Commands/Ai/AiHarnessIntegrations.cs @@ -0,0 +1,141 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Ai; + +/// Plans, creates, removes, and checks the harness links Cratis owns in a project. +static class AiHarnessIntegrations +{ + static readonly (string Harness, Action Plan)[] _harnessPlans = + [ + ("claude", PlanClaude), + ("codex", PlanCodex), + ("copilot", PlanCopilot), + ("pi", PlanPi), + ("cursor", PlanCursor), + ("opencode", PlanOpenCode) + ]; + + internal static Dictionary Plan(IEnumerable harnesses, IEnumerable files, bool hasProjectInstructions) + { + var selected = harnesses.ToHashSet(StringComparer.OrdinalIgnoreCase); + var planner = new Planner(files, hasProjectInstructions); + foreach (var (harness, plan) in _harnessPlans) + { + if (selected.Contains(harness)) plan(planner); + } + return planner.Plans; + } + + internal static void Create(string projectPath, AiManagedIntegration integration, AiFileOperations operations) + { + var path = Path.Combine(projectPath, integration.Path); + operations.CreateDirectoryFor(path); + operations.CreateSymbolicLink(path, integration.Target, integration.IsDirectory); + } + + internal static void Delete(string projectPath, AiManagedIntegration integration, AiFileOperations operations) + { + var path = Path.Combine(projectPath, integration.Path); + if (File.GetAttributes(path).HasFlag(FileAttributes.Directory)) operations.DeleteDirectory(path); + else operations.DeleteFile(path); + } + + internal static bool Matches(string projectPath, AiManagedIntegration integration) + { + var path = Path.Combine(projectPath, integration.Path); + var target = integration.IsDirectory ? new DirectoryInfo(path).LinkTarget : new FileInfo(path).LinkTarget; + return string.Equals(target, integration.Target, StringComparison.Ordinal); + } + + static void PlanClaude(Planner planner) + { + planner.Add("CLAUDE.md", planner.RootInstructionsTarget, false, preserveExisting: true); + planner.Add(".claude/CLAUDE.md", planner.NestedInstructionsTarget, false, preserveExisting: true); + planner.Add(".claude/agents", "../.cratis/ai/agents", true); + planner.Add(".claude/hooks", "../.cratis/ai/hooks", true); + planner.Add(".claude/prompts", "../.cratis/ai/prompts", true); + planner.Add(".claude/rules", "../.cratis/ai/rules", true); + planner.Add(".claude/settings.json", "../.cratis/ai/hooks/settings.template.json", false); + planner.Add(".claude/skills", "../.cratis/ai/skills", true); + planner.AddCommands(".claude/commands"); + } + + static void PlanCodex(Planner planner) + { + planner.AddRootInstructions(); + planner.Add(".agents/skills", "../.cratis/ai/skills", true); + } + + static void PlanCopilot(Planner planner) + { + planner.Add(".github/copilot-instructions.md", planner.NestedInstructionsTarget, false, preserveExisting: true); + planner.Add(".github/instructions", "../.cratis/ai/rules", true); + planner.Add(".github/prompts", "../.cratis/ai/prompts", true); + planner.Add(".github/skills", "../.cratis/ai/skills", true); + planner.AddAgents(".github/agents", ".agent.md"); + } + + static void PlanPi(Planner planner) + { + planner.AddRootInstructions(); + planner.Add(".pi/agents", "../.cratis/ai/agents", true); + planner.Add(".pi/extensions", "../.cratis/ai/harnesses/pi/extensions", true); + planner.Add(".pi/skills", "../.cratis/ai/skills", true); + planner.AddCommands(".pi/prompts"); + } + + static void PlanCursor(Planner planner) + { + planner.Add(".cursor/agents", "../.cratis/ai/agents", true); + planner.Add(".cursor/rules", "../.cratis/ai/harnesses/cursor/rules", true); + planner.Add(".cursor/skills", "../.cratis/ai/skills", true); + planner.AddCommands(".cursor/commands"); + } + + static void PlanOpenCode(Planner planner) + { + planner.AddRootInstructions(); + + // OpenCode cannot consume the canonical agent shape: it has no tools allowlist and restricts through a + // permission map, so the corpus ships generated adapters under harnesses/opencode/agents. Link those when + // the corpus carries them; an older corpus without them falls back to the canonical directory. + var hasOpenCodeAgents = planner.Files.Any(file => file.StartsWith("harnesses/opencode/agents/", StringComparison.Ordinal)); + planner.Add(".opencode/agents", hasOpenCodeAgents ? "../.cratis/ai/harnesses/opencode/agents" : "../.cratis/ai/agents", true); + planner.Add(".opencode/skills", "../.cratis/ai/skills", true); + planner.AddCommands(".opencode/commands"); + } + + sealed class Planner(IEnumerable files, bool hasProjectInstructions) + { + internal Dictionary Plans { get; } = new(StringComparer.Ordinal); + + internal IEnumerable Files => files; + + internal string RootInstructionsTarget { get; } = hasProjectInstructions ? AiProjectInstructions.RelativePath : ".cratis/ai/rules/general.md"; + + internal string NestedInstructionsTarget { get; } = hasProjectInstructions ? "../.cratis/ai/rules/project.md" : "../.cratis/ai/rules/general.md"; + + internal void Add(string path, string target, bool isDirectory, bool preserveExisting = false) => Plans.TryAdd(path, new(path, target, isDirectory, preserveExisting)); + + internal void AddRootInstructions() => Add("AGENTS.md", RootInstructionsTarget, false, preserveExisting: true); + + internal void AddCommands(string directory) + { + foreach (var prompt in files.Where(file => file.StartsWith("prompts/", StringComparison.Ordinal) && file.EndsWith(".prompt.md", StringComparison.Ordinal))) + { + var name = Path.GetFileName(prompt)[..^".prompt.md".Length]; + Add($"{directory}/{name}.md", $"../../.cratis/ai/{prompt}", false); + } + } + + internal void AddAgents(string directory, string suffix) + { + foreach (var agent in files.Where(file => file.StartsWith("agents/", StringComparison.Ordinal) && file.EndsWith(".md", StringComparison.Ordinal))) + { + var name = Path.GetFileNameWithoutExtension(agent); + Add($"{directory}/{name}{suffix}", $"../../.cratis/ai/{agent}", false); + } + } + } +} diff --git a/Source/Cli/Commands/Ai/AiProjectInstructions.cs b/Source/Cli/Commands/Ai/AiProjectInstructions.cs new file mode 100644 index 00000000..9eb585e3 --- /dev/null +++ b/Source/Cli/Commands/Ai/AiProjectInstructions.cs @@ -0,0 +1,94 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.Commands.Ai; + +/// Finds a project's own instructions and splits them into the managed project concern files. +static class AiProjectInstructions +{ + internal const string RelativePath = ".cratis/ai/rules/project.md"; + + static readonly string[] _legacyPaths = [".cratis/PROJECT.md", ".ai/PROJECT.md", ".agents/PROJECT.md"]; + + internal static string? Find(string projectPath) + { + var destination = Path.Combine(projectPath, RelativePath); + if (File.Exists(destination)) return destination; + return _legacyPaths.Select(candidate => Path.Combine(projectPath, candidate)).FirstOrDefault(File.Exists); + } + + internal static void Migrate(string projectPath, string source, List actions, AiFileOperations operations) + { + var destination = Path.Combine(projectPath, RelativePath); + var concernsDirectory = Path.Combine(Path.GetDirectoryName(destination)!, "project"); + if (Directory.Exists(concernsDirectory)) return; + + var content = File.ReadAllText(source).Replace("\r\n", "\n", StringComparison.Ordinal); + var lines = content.Split('\n'); + var headingIndexes = FindHeadings(lines); + + operations.CreateDirectoryFor(destination); + if (headingIndexes.Count == 0) + { + if (!AiCorpusSynchronizer.PathExists(destination)) operations.Copy(source, destination); + return; + } + + operations.CreateDirectory(concernsDirectory); + var links = WriteSections(concernsDirectory, lines, headingIndexes, operations); + var preamble = string.Join('\n', lines[..headingIndexes[0]]).TrimEnd(); + var indexContent = $"{preamble}\n\n## Project concerns\n\nRead every concern below before working in this repository. Together they are the project-owned instructions and override conflicting shared guidance.\n\n{string.Join('\n', links)}\n"; + operations.WriteAllText(destination, indexContent); + actions.Add($"Split {Path.GetRelativePath(projectPath, source).Replace('\\', '/')} into {RelativePath} and {RelativePath[..^3]}/"); + } + + static List FindHeadings(string[] lines) + { + var headingIndexes = new List(); + var insideCodeFence = false; + for (var index = 0; index < lines.Length; index++) + { + if (lines[index].TrimStart().StartsWith("```", StringComparison.Ordinal)) insideCodeFence = !insideCodeFence; + else if (!insideCodeFence && lines[index].StartsWith("## ", StringComparison.Ordinal)) headingIndexes.Add(index); + } + return headingIndexes; + } + + static List WriteSections(string concernsDirectory, string[] lines, List headingIndexes, AiFileOperations operations) + { + var usedNames = new HashSet(StringComparer.Ordinal); + var links = new List(); + for (var sectionIndex = 0; sectionIndex < headingIndexes.Count; sectionIndex++) + { + var start = headingIndexes[sectionIndex]; + var end = sectionIndex + 1 < headingIndexes.Count ? headingIndexes[sectionIndex + 1] : lines.Length; + var title = lines[start][3..].Trim(); + var name = UniqueName(Slug(title), usedNames); + var section = string.Join('\n', lines[start..end]).TrimEnd(); + operations.WriteAllText(Path.Combine(concernsDirectory, $"{name}.md"), $"---\napplyTo: \"**/*\"\n---\n\n{section}\n"); + links.Add($"- [{title}]({RelativePath[..^3]}/{name}.md)"); + } + return links; + } + + static string UniqueName(string baseName, HashSet usedNames) + { + var name = baseName; + var suffix = 2; + while (!usedNames.Add(name)) name = $"{baseName}-{suffix++}"; + return name; + } + + static string Slug(string value) + { + var slug = new StringBuilder(); + foreach (var character in value.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(character)) slug.Append(character); + else if (slug.Length > 0 && slug[^1] != '-') slug.Append('-'); + } + return slug.ToString().Trim('-') is { Length: > 0 } result ? result : "instructions"; + } +}