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";
+ }
+}