Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Source/Cli/Commands/Ai/AiCorpusAsset.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>A corpus file selected for installation.</summary>
/// <param name="Path">The absolute path of the corpus file.</param>
/// <param name="Destination">The path relative to the managed root.</param>
/// <param name="Source">The corpus-relative source recorded in the managed marker.</param>
sealed record AiCorpusAsset(string Path, string Destination, string Source);
122 changes: 122 additions & 0 deletions Source/Cli/Commands/Ai/AiCorpusInstaller.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Applies a resolved corpus selection to a project and reports what it replaced, added, or removed.</summary>
/// <param name="projectPath">The physical consuming repository root.</param>
/// <param name="previous">The manifest of the previous installation.</param>
/// <param name="desired">The corpus assets to install, by destination.</param>
/// <param name="integrations">The planned harness integrations, by path.</param>
/// <param name="operations">The file operations to apply, or only report in a dry run.</param>
sealed class AiCorpusInstaller(
string projectPath,
AiInstallationManifest previous,
Dictionary<string, AiCorpusAsset> desired,
Dictionary<string, AiManagedIntegration> integrations,
AiFileOperations operations)
{
readonly Dictionary<string, AiManagedIntegration> _previousIntegrations = (previous.Integrations ?? []).ToDictionary(integration => integration.Path, StringComparer.Ordinal);

internal List<string> Actions { get; } = [];

internal List<string> Warnings { get; } = [];

internal List<string> ModifiedPaths()
{
var modified = AiCorpusSynchronizer.ModifiedFiles(projectPath, previous);
modified.AddRange(AiCorpusSynchronizer.ModifiedIntegrations(projectPath, previous));
return modified;
}

internal List<string> 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<string> 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<AiManagedFile> Files, List<AiManagedIntegration> 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<AiManagedFile> InstallAssets()
{
var installed = new List<AiManagedFile>();
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<AiManagedIntegration> InstallIntegrations()
{
var installed = new List<AiManagedIntegration>();
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}");
}
}
51 changes: 51 additions & 0 deletions Source/Cli/Commands/Ai/AiCorpusMarker.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Adds the managed-by-Cratis marker to corpus content in the comment syntax of its file type.</summary>
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" => $"<!-- {marker} -->\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 $"<!-- {marker} -->\n{content}";
var frontmatterEnd = content.IndexOf("\n---\n", 4, StringComparison.Ordinal);
return frontmatterEnd < 0 ? $"<!-- {marker} -->\n{content}" : content.Insert(frontmatterEnd + 5, $"<!-- {marker} -->\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}");
}
}
70 changes: 70 additions & 0 deletions Source/Cli/Commands/Ai/AiCorpusProfiles.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Validates and expands the profiles a configuration selects from a corpus profile catalog.</summary>
static class AiCorpusProfiles
{
internal static HashSet<string> 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<string> 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<string> selected, IEnumerable<string> 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<string> 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<JsonElement> profiles, IReadOnlyList<string> languages)
{
internal HashSet<string> 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);
}
}
}
115 changes: 115 additions & 0 deletions Source/Cli/Commands/Ai/AiCorpusResolver.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Resolves which corpus files a configuration installs.</summary>
static class AiCorpusResolver
{
static readonly string[] _categories = ["agents", "prompts", "hooks"];

internal static IEnumerable<AiCorpusAsset> 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<AiCorpusAsset> 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<AiCorpusAsset> RuleAssets(string corpusRoot, AiConfiguration configuration, HashSet<string> selected) =>
AssetsUnder(corpusRoot, "rules").Where(rule => RuleMatchesConfiguration(rule.Path, configuration, selected));

static IEnumerable<AiCorpusAsset> CategoryAssets(string corpusRoot) =>
_categories.SelectMany(category => AssetsUnder(corpusRoot, category));

static IEnumerable<AiCorpusAsset> SkillAssets(string corpusRoot, IEnumerable<string> skillNames) =>
skillNames.SelectMany(skill => AssetsUnder(corpusRoot, $"skills/{skill}", excludeVerification: true));

static IEnumerable<AiCorpusAsset> HarnessAssets(string corpusRoot, AiConfiguration configuration) =>
configuration.Harnesses.SelectMany(harness => AssetsUnder(corpusRoot, $"harnesses/{harness}"));

static IEnumerable<AiCorpusAsset> 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<string> 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<string> 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<string> selectedProfiles, string prefix) =>
selectedProfiles.Any(candidate => candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));

static bool LanguageAllowsRule(string path, string applyTo, AiConfiguration configuration, HashSet<string> 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('"');
}
}
Loading