From ce35779b99027f71d957cfe6cc5a4ba611030fd0 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 10:58:55 +0200 Subject: [PATCH 01/11] Add optional llm section to the CLI configuration Introduce LlmConfiguration (kind, endpoint, apiKey, model) and an optional Llm property on CliConfiguration so Cratis tools like Prologue can resolve a shared language model configuration from ~/.cratis/config.json. The section is omitted from the file when not configured. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Cli/CliConfiguration.cs | 6 ++++++ Source/Cli/LlmConfiguration.cs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 Source/Cli/LlmConfiguration.cs diff --git a/Source/Cli/CliConfiguration.cs b/Source/Cli/CliConfiguration.cs index 8abd8a4..51cdca0 100644 --- a/Source/Cli/CliConfiguration.cs +++ b/Source/Cli/CliConfiguration.cs @@ -32,6 +32,12 @@ public class CliConfiguration /// public IDictionary Contexts { get; set; } = new Dictionary(); + /// + /// Gets or sets the language model configuration used by Cratis tools like Prologue. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LlmConfiguration? Llm { get; set; } + /// /// Gets the name of the active context, falling back to the default name. /// diff --git a/Source/Cli/LlmConfiguration.cs b/Source/Cli/LlmConfiguration.cs new file mode 100644 index 0000000..36837ce --- /dev/null +++ b/Source/Cli/LlmConfiguration.cs @@ -0,0 +1,30 @@ +// 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; + +/// +/// Represents the language model configuration used by Cratis tools like Prologue. +/// +public class LlmConfiguration +{ + /// + /// Gets or sets the kind of language model provider (anthropic, openai, or local). + /// + public string? Kind { get; set; } + + /// + /// Gets or sets the endpoint URL for the provider. Required for local (OpenAI-compatible) providers. + /// + public string? Endpoint { get; set; } + + /// + /// Gets or sets the API key used to authenticate with the provider. + /// + public string? ApiKey { get; set; } + + /// + /// Gets or sets the model to use with the provider. + /// + public string? Model { get; set; } +} From 9464b88c96cac373dbaa3190e0b1a3fe93d2a6c6 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 11:00:30 +0200 Subject: [PATCH 02/11] Add llm command group for configuring the language model Add 'cratis llm use ' (anthropic | openai | local) with --api-key/--endpoint/--model options and interactive prompts for missing values (secret prompt for the API key, endpoint required for local, per-kind default model hints), 'cratis llm show' with a masked API key, and 'cratis llm clear' honoring the --yes confirmation convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Cli/Commands/Llm/ApiKeyMask.cs | 24 ++++ Source/Cli/Commands/Llm/ClearLlmCommand.cs | 40 +++++++ Source/Cli/Commands/Llm/LlmKinds.cs | 62 +++++++++++ Source/Cli/Commands/Llm/ShowLlmCommand.cs | 51 +++++++++ Source/Cli/Commands/Llm/UseLlmCommand.cs | 122 +++++++++++++++++++++ Source/Cli/Commands/Llm/UseLlmSettings.cs | 38 +++++++ Source/Cli/Registration/LlmBranch.cs | 12 ++ 7 files changed, 349 insertions(+) create mode 100644 Source/Cli/Commands/Llm/ApiKeyMask.cs create mode 100644 Source/Cli/Commands/Llm/ClearLlmCommand.cs create mode 100644 Source/Cli/Commands/Llm/LlmKinds.cs create mode 100644 Source/Cli/Commands/Llm/ShowLlmCommand.cs create mode 100644 Source/Cli/Commands/Llm/UseLlmCommand.cs create mode 100644 Source/Cli/Commands/Llm/UseLlmSettings.cs create mode 100644 Source/Cli/Registration/LlmBranch.cs diff --git a/Source/Cli/Commands/Llm/ApiKeyMask.cs b/Source/Cli/Commands/Llm/ApiKeyMask.cs new file mode 100644 index 0000000..e379271 --- /dev/null +++ b/Source/Cli/Commands/Llm/ApiKeyMask.cs @@ -0,0 +1,24 @@ +// 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.Llm; + +/// +/// Masks API keys for display, revealing only enough to identify the key. +/// +public static class ApiKeyMask +{ + const int VisibleCharacters = 4; + const string FullMask = "********"; + + /// + /// Masks an API key, revealing the first and last four characters for keys long enough + /// to keep the rest secret. Shorter keys are fully masked. + /// + /// The API key to mask. + /// The masked representation of the key. + public static string Mask(string apiKey) => + apiKey.Length > VisibleCharacters * 2 + ? $"{apiKey[..VisibleCharacters]}...{apiKey[^VisibleCharacters..]}" + : FullMask; +} diff --git a/Source/Cli/Commands/Llm/ClearLlmCommand.cs b/Source/Cli/Commands/Llm/ClearLlmCommand.cs new file mode 100644 index 0000000..addee41 --- /dev/null +++ b/Source/Cli/Commands/Llm/ClearLlmCommand.cs @@ -0,0 +1,40 @@ +// 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.Llm; + +/// +/// Removes the language model configuration. +/// +[LlmDescription("Removes the language model configuration, including the stored API key. Destructive — prompts for confirmation unless --yes is specified.")] +[CliCommand("clear", "Remove the configured language model", Branch = typeof(LlmBranch))] +[CliExample("llm", "clear")] +[CliExample("llm", "clear", "--yes")] +[LlmOutputAdvice("plain", "Plain outputs a confirmation message.")] +public class ClearLlmCommand : AsyncCommand +{ + /// + protected override Task ExecuteAsync(CommandContext context, GlobalSettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + var config = CliConfiguration.Load(); + + if (config.Llm is null) + { + OutputFormatter.WriteMessage(format, "No language model configured."); + return Task.FromResult(ExitCodes.Success); + } + + if (!ConfirmationHelper.ShouldProceed(settings, "Are you sure you want to remove the language model configuration?")) + { + OutputFormatter.WriteMessage(format, "Aborted."); + return Task.FromResult(ExitCodes.Success); + } + + config.Llm = null; + config.Save(); + + OutputFormatter.WriteMessage(format, "Removed the language model configuration."); + return Task.FromResult(ExitCodes.Success); + } +} diff --git a/Source/Cli/Commands/Llm/LlmKinds.cs b/Source/Cli/Commands/Llm/LlmKinds.cs new file mode 100644 index 0000000..fdee169 --- /dev/null +++ b/Source/Cli/Commands/Llm/LlmKinds.cs @@ -0,0 +1,62 @@ +// 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.Llm; + +/// +/// Well-known language model provider kinds and their defaults. +/// +public static class LlmKinds +{ + /// + /// The Anthropic provider kind. + /// + public const string Anthropic = "anthropic"; + + /// + /// The OpenAI provider kind. + /// + public const string OpenAI = "openai"; + + /// + /// The local (OpenAI-compatible endpoint) provider kind. + /// + public const string Local = "local"; + + /// + /// The default endpoint suggested for local (OpenAI-compatible) providers. + /// + public const string DefaultLocalEndpoint = "http://localhost:11434/v1"; + + /// + /// All valid provider kinds. + /// + public static readonly string[] All = [Anthropic, OpenAI, Local]; + + /// + /// Determines whether the given kind is a known provider kind, ignoring casing. + /// + /// The kind to validate. + /// True when the kind is known; false otherwise. + public static bool IsValid(string? kind) => + kind is not null && All.Contains(kind, StringComparer.OrdinalIgnoreCase); + + /// + /// Normalizes a kind to its canonical lowercase form. + /// + /// The kind to normalize. + /// The normalized kind. + public static string Normalize(string kind) => kind.ToLowerInvariant(); + + /// + /// Gets the default model hint for the given kind, or null when the kind has none. + /// + /// The provider kind. + /// The default model hint, or null. + public static string? DefaultModelFor(string kind) => Normalize(kind) switch + { + Anthropic => "claude-opus-4-6", + OpenAI => "gpt-4o-mini", + _ => null + }; +} diff --git a/Source/Cli/Commands/Llm/ShowLlmCommand.cs b/Source/Cli/Commands/Llm/ShowLlmCommand.cs new file mode 100644 index 0000000..086e568 --- /dev/null +++ b/Source/Cli/Commands/Llm/ShowLlmCommand.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. + +namespace Cratis.Cli.Commands.Llm; + +/// +/// Shows the configured language model provider. +/// +[LlmDescription("Shows the configured language model provider: kind, endpoint, model, and a masked API key. Use to verify what Cratis tools like Prologue will use.")] +[CliCommand("show", "Show the configured language model", Branch = typeof(LlmBranch))] +[CliExample("llm", "show")] +[LlmOutputAdvice("json", "JSON is structured for key-value parsing. The API key is always masked.")] +public class ShowLlmCommand : AsyncCommand +{ + /// + protected override Task ExecuteAsync(CommandContext context, GlobalSettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + var llm = CliConfiguration.Load().Llm; + + if (llm is null || string.IsNullOrWhiteSpace(llm.Kind)) + { + OutputFormatter.WriteMessage(format, "No language model configured. Configure one with: cratis llm use "); + return Task.FromResult(ExitCodes.Success); + } + + var maskedKey = string.IsNullOrWhiteSpace(llm.ApiKey) ? null : ApiKeyMask.Mask(llm.ApiKey); + + OutputFormatter.WriteObject( + format, + new + { + llm.Kind, + llm.Endpoint, + llm.Model, + ApiKey = maskedKey + }, + _ => + { + AnsiConsole.MarkupLine($"[bold]Kind:[/] {OrNotSet(llm.Kind).EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[bold]Endpoint:[/] {OrNotSet(llm.Endpoint).EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[bold]Model:[/] {OrNotSet(llm.Model).EscapeMarkup()}"); + AnsiConsole.MarkupLine($"[bold]API Key:[/] {OrNotSet(maskedKey).EscapeMarkup()}"); + }); + + return Task.FromResult(ExitCodes.Success); + } + + static string OrNotSet(string? value) => + string.IsNullOrWhiteSpace(value) ? "(not set)" : value; +} diff --git a/Source/Cli/Commands/Llm/UseLlmCommand.cs b/Source/Cli/Commands/Llm/UseLlmCommand.cs new file mode 100644 index 0000000..22eb8eb --- /dev/null +++ b/Source/Cli/Commands/Llm/UseLlmCommand.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.Llm; + +/// +/// Configures the language model provider used by Cratis tools like Prologue. +/// +[LlmDescription("Configures the language model provider (anthropic, openai, or local OpenAI-compatible) that Cratis tools like Prologue use. Stores kind, API key, endpoint, and model in the user configuration. Prompts interactively for missing values; pass --api-key/--endpoint/--model for non-interactive use.")] +[CliCommand("use", "Configure the language model provider to use", Branch = typeof(LlmBranch))] +[CliExample("llm", "use", "anthropic")] +[CliExample("llm", "use", "openai", "--api-key", "sk-...", "--model", "gpt-4o-mini")] +[CliExample("llm", "use", "local", "--endpoint", "http://localhost:11434/v1")] +[LlmOutputAdvice("plain", "Plain outputs a confirmation message.")] +[LlmOption("", "string", "Provider kind: anthropic, openai, or local (positional)")] +[LlmOption("--api-key", "string", "API key for the provider. Required for anthropic and openai when not running interactively.")] +[LlmOption("--endpoint", "string", "Endpoint URL. Required for local (OpenAI-compatible, e.g. http://localhost:11434/v1).")] +[LlmOption("--model", "string", "Model to use. Defaults: claude-opus-4-6 (anthropic), gpt-4o-mini (openai).")] +public class UseLlmCommand : AsyncCommand +{ + /// + protected override async Task ExecuteAsync(CommandContext context, UseLlmSettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + + if (!LlmKinds.IsValid(settings.Kind)) + { + OutputFormatter.WriteError(format, $"Unknown language model kind '{settings.Kind}'", $"Valid kinds are: {string.Join(", ", LlmKinds.All)}", ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + + var kind = LlmKinds.Normalize(settings.Kind); + var interactive = AnsiConsole.Profile.Capabilities.Interactive && !settings.Quiet; + + var apiKey = settings.ApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + if (!interactive && kind != LlmKinds.Local) + { + OutputFormatter.WriteError(format, $"An API key is required for '{kind}'", "Pass it with --api-key, or run in an interactive terminal to be prompted for it", ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + + if (interactive) + { + apiKey = await PromptForApiKey(kind, cancellationToken); + } + } + + var endpoint = settings.Endpoint; + if (string.IsNullOrWhiteSpace(endpoint) && kind == LlmKinds.Local) + { + if (!interactive) + { + OutputFormatter.WriteError(format, "An endpoint is required for 'local'", $"Pass it with --endpoint (e.g. {LlmKinds.DefaultLocalEndpoint}), or run in an interactive terminal to be prompted for it", ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + + endpoint = await AnsiConsole.PromptAsync( + new TextPrompt("Endpoint (OpenAI-compatible):").DefaultValue(LlmKinds.DefaultLocalEndpoint), + cancellationToken); + } + + var model = settings.Model; + if (string.IsNullOrWhiteSpace(model) && interactive) + { + model = await PromptForModel(kind, cancellationToken); + } + + SaveConfiguration(kind, endpoint, apiKey, model); + + OutputFormatter.WriteMessage(format, ConfirmationMessage(kind, model)); + return ExitCodes.Success; + } + + static async Task PromptForApiKey(string kind, CancellationToken cancellationToken) + { + var required = kind != LlmKinds.Local; + var prompt = new TextPrompt(required ? "API key:" : "API key (leave empty if not needed):") + .PromptStyle("dim") + .Secret(); + + if (!required) + { + prompt = prompt.AllowEmpty(); + } + + return await AnsiConsole.PromptAsync(prompt, cancellationToken); + } + + static async Task PromptForModel(string kind, CancellationToken cancellationToken) + { + var prompt = new TextPrompt("Model (leave empty for provider default):").AllowEmpty(); + var defaultModel = LlmKinds.DefaultModelFor(kind); + if (defaultModel is not null) + { + prompt = prompt.DefaultValue(defaultModel); + } + + return await AnsiConsole.PromptAsync(prompt, cancellationToken); + } + + static void SaveConfiguration(string kind, string? endpoint, string? apiKey, string? model) + { + var config = CliConfiguration.Load(); + config.Llm = new LlmConfiguration + { + Kind = kind, + Endpoint = NullIfEmpty(endpoint), + ApiKey = NullIfEmpty(apiKey), + Model = NullIfEmpty(model) + }; + config.Save(); + } + + static string ConfirmationMessage(string kind, string? model) => + string.IsNullOrWhiteSpace(model) + ? $"Configured '{kind}' as the language model. Show it with: cratis llm show" + : $"Configured '{kind}' with model '{model}' as the language model. Show it with: cratis llm show"; + + static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/Source/Cli/Commands/Llm/UseLlmSettings.cs b/Source/Cli/Commands/Llm/UseLlmSettings.cs new file mode 100644 index 0000000..0526c32 --- /dev/null +++ b/Source/Cli/Commands/Llm/UseLlmSettings.cs @@ -0,0 +1,38 @@ +// 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.Llm; + +/// +/// Settings for configuring the language model provider. +/// +public class UseLlmSettings : GlobalSettings +{ + /// + /// Gets or sets the provider kind to use. + /// + [CommandArgument(0, "")] + [Description("Language model provider kind: anthropic, openai, or local (OpenAI-compatible endpoint)")] + public string Kind { get; set; } = string.Empty; + + /// + /// Gets or sets the API key used to authenticate with the provider. + /// + [CommandOption("--api-key ")] + [Description("API key for the provider. Prompted for interactively when omitted.")] + public string? ApiKey { get; set; } + + /// + /// Gets or sets the endpoint URL for the provider. + /// + [CommandOption("--endpoint ")] + [Description("Endpoint URL for the provider. Required for local (e.g. http://localhost:11434/v1).")] + public string? Endpoint { get; set; } + + /// + /// Gets or sets the model to use with the provider. + /// + [CommandOption("--model ")] + [Description("Model to use (e.g. claude-opus-4-6 for anthropic, gpt-4o-mini for openai)")] + public string? Model { get; set; } +} diff --git a/Source/Cli/Registration/LlmBranch.cs b/Source/Cli/Registration/LlmBranch.cs new file mode 100644 index 0000000..5888b98 --- /dev/null +++ b/Source/Cli/Registration/LlmBranch.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#pragma warning disable RCS1251, SA1502 // Marker type is intentionally empty + +namespace Cratis.Cli.Registration; + +/// +/// Language model configuration for Cratis tools. +/// +[CliBranch("llm", "Configure the language model used by Cratis tools like Prologue")] +public static class LlmBranch; From 2977dcfe1ef5d56aec02df2bb2dce2c24a0409e8 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 11:01:14 +0200 Subject: [PATCH 03/11] Add specs for llm configuration round-trip, key masking, and kinds Cover the llm section persisting and clearing through CliConfiguration, API key masking (partial for long keys, full for short ones), and provider kind validation including casing. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Cli.Specs/Usings.cs | 1 + .../with_key_longer_than_eight_characters.cs | 13 ++++++ .../with_key_of_exactly_eight_characters.cs | 13 ++++++ .../when_masking/with_short_key.cs | 14 ++++++ ...aving_config_after_clearing_llm_section.cs | 32 ++++++++++++++ .../when_saving_config_with_llm_section.cs | 43 +++++++++++++++++++ .../when_validating_kind/with_known_kind.cs | 13 ++++++ .../when_validating_kind/with_mixed_casing.cs | 13 ++++++ .../when_validating_kind/with_unknown_kind.cs | 13 ++++++ 9 files changed, 155 insertions(+) create mode 100644 Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_longer_than_eight_characters.cs create mode 100644 Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_of_exactly_eight_characters.cs create mode 100644 Source/Cli.Specs/for_ApiKeyMask/when_masking/with_short_key.cs create mode 100644 Source/Cli.Specs/for_CliConfiguration/when_saving_config_after_clearing_llm_section.cs create mode 100644 Source/Cli.Specs/for_CliConfiguration/when_saving_config_with_llm_section.cs create mode 100644 Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_known_kind.cs create mode 100644 Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_mixed_casing.cs create mode 100644 Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_unknown_kind.cs diff --git a/Source/Cli.Specs/Usings.cs b/Source/Cli.Specs/Usings.cs index 73859ae..a515461 100644 --- a/Source/Cli.Specs/Usings.cs +++ b/Source/Cli.Specs/Usings.cs @@ -7,6 +7,7 @@ global using Cratis.Cli.Commands.Chronicle.Observers; global using Cratis.Cli.Commands.Completions; global using Cratis.Cli.Commands.Init; +global using Cratis.Cli.Commands.Llm; global using Cratis.Cli.Commands.Run; global using Cratis.Specifications; global using Xunit; diff --git a/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_longer_than_eight_characters.cs b/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_longer_than_eight_characters.cs new file mode 100644 index 0000000..dacf623 --- /dev/null +++ b/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_longer_than_eight_characters.cs @@ -0,0 +1,13 @@ +// 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.for_ApiKeyMask.when_masking; + +public class with_key_longer_than_eight_characters : Specification +{ + string _result; + + void Because() => _result = ApiKeyMask.Mask("sk-ant-api03-secret"); + + [Fact] void should_reveal_only_the_first_and_last_four_characters() => _result.ShouldEqual("sk-a...cret"); +} diff --git a/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_of_exactly_eight_characters.cs b/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_of_exactly_eight_characters.cs new file mode 100644 index 0000000..d3b49cd --- /dev/null +++ b/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_key_of_exactly_eight_characters.cs @@ -0,0 +1,13 @@ +// 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.for_ApiKeyMask.when_masking; + +public class with_key_of_exactly_eight_characters : Specification +{ + string _result; + + void Because() => _result = ApiKeyMask.Mask("12345678"); + + [Fact] void should_fully_mask_the_key() => _result.ShouldEqual("********"); +} diff --git a/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_short_key.cs b/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_short_key.cs new file mode 100644 index 0000000..b4ccab4 --- /dev/null +++ b/Source/Cli.Specs/for_ApiKeyMask/when_masking/with_short_key.cs @@ -0,0 +1,14 @@ +// 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.for_ApiKeyMask.when_masking; + +public class with_short_key : Specification +{ + string _result; + + void Because() => _result = ApiKeyMask.Mask("abc"); + + [Fact] void should_fully_mask_the_key() => _result.ShouldEqual("********"); + [Fact] void should_not_reveal_the_key_length() => _result.Length.ShouldEqual(8); +} diff --git a/Source/Cli.Specs/for_CliConfiguration/when_saving_config_after_clearing_llm_section.cs b/Source/Cli.Specs/for_CliConfiguration/when_saving_config_after_clearing_llm_section.cs new file mode 100644 index 0000000..1d56af5 --- /dev/null +++ b/Source/Cli.Specs/for_CliConfiguration/when_saving_config_after_clearing_llm_section.cs @@ -0,0 +1,32 @@ +// 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.for_CliConfiguration; + +[Collection(CliSpecsCollection.Name)] +public class when_saving_config_after_clearing_llm_section : given.a_temp_config_directory +{ + CliConfiguration _loaded; + string _json; + + void Establish() + { + var config = new CliConfiguration + { + Llm = new LlmConfiguration { Kind = "anthropic", ApiKey = "sk-ant-test-key" } + }; + config.Save(); + + config.Llm = null; + config.Save(); + } + + void Because() + { + _json = File.ReadAllText(CliConfiguration.GetConfigPath()); + _loaded = CliConfiguration.Load(); + } + + [Fact] void should_not_have_an_llm_section() => _loaded.Llm.ShouldBeNull(); + [Fact] void should_not_write_the_section_to_the_file() => _json.ShouldNotContain("\"llm\""); +} diff --git a/Source/Cli.Specs/for_CliConfiguration/when_saving_config_with_llm_section.cs b/Source/Cli.Specs/for_CliConfiguration/when_saving_config_with_llm_section.cs new file mode 100644 index 0000000..ac51867 --- /dev/null +++ b/Source/Cli.Specs/for_CliConfiguration/when_saving_config_with_llm_section.cs @@ -0,0 +1,43 @@ +// 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.for_CliConfiguration; + +[Collection(CliSpecsCollection.Name)] +public class when_saving_config_with_llm_section : given.a_temp_config_directory +{ + const string ExpectedKind = "anthropic"; + const string ExpectedEndpoint = "http://localhost:11434/v1"; + const string ExpectedApiKey = "sk-ant-test-key"; + const string ExpectedModel = "claude-opus-4-6"; + + CliConfiguration _loaded; + string _json; + + void Establish() + { + var config = new CliConfiguration + { + Llm = new LlmConfiguration + { + Kind = ExpectedKind, + Endpoint = ExpectedEndpoint, + ApiKey = ExpectedApiKey, + Model = ExpectedModel + } + }; + config.Save(); + } + + void Because() + { + _json = File.ReadAllText(CliConfiguration.GetConfigPath()); + _loaded = CliConfiguration.Load(); + } + + [Fact] void should_roundtrip_the_kind() => _loaded.Llm.Kind.ShouldEqual(ExpectedKind); + [Fact] void should_roundtrip_the_endpoint() => _loaded.Llm.Endpoint.ShouldEqual(ExpectedEndpoint); + [Fact] void should_roundtrip_the_api_key() => _loaded.Llm.ApiKey.ShouldEqual(ExpectedApiKey); + [Fact] void should_roundtrip_the_model() => _loaded.Llm.Model.ShouldEqual(ExpectedModel); + [Fact] void should_serialize_the_section_with_camel_case() => _json.ShouldContain("\"llm\""); +} diff --git a/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_known_kind.cs b/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_known_kind.cs new file mode 100644 index 0000000..3b0f4db --- /dev/null +++ b/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_known_kind.cs @@ -0,0 +1,13 @@ +// 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.for_LlmKinds.when_validating_kind; + +public class with_known_kind : Specification +{ + bool _result; + + void Because() => _result = LlmKinds.All.All(LlmKinds.IsValid); + + [Fact] void should_accept_all_known_kinds() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_mixed_casing.cs b/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_mixed_casing.cs new file mode 100644 index 0000000..2b8653e --- /dev/null +++ b/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_mixed_casing.cs @@ -0,0 +1,13 @@ +// 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.for_LlmKinds.when_validating_kind; + +public class with_mixed_casing : Specification +{ + bool _result; + + void Because() => _result = LlmKinds.IsValid("Anthropic"); + + [Fact] void should_be_valid() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_unknown_kind.cs b/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_unknown_kind.cs new file mode 100644 index 0000000..2333609 --- /dev/null +++ b/Source/Cli.Specs/for_LlmKinds/when_validating_kind/with_unknown_kind.cs @@ -0,0 +1,13 @@ +// 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.for_LlmKinds.when_validating_kind; + +public class with_unknown_kind : Specification +{ + bool _result; + + void Because() => _result = LlmKinds.IsValid("gemini"); + + [Fact] void should_not_be_valid() => _result.ShouldBeFalse(); +} From e01cc56c47c6a0bb9354696976dd0421038f2947 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 11:01:55 +0200 Subject: [PATCH 04/11] Document the llm command group Co-Authored-By: Claude Opus 4.8 (1M context) --- Documentation/reference/index.md | 1 + Documentation/reference/llm.md | 68 ++++++++++++++++++++++++++++++++ Documentation/reference/toc.yml | 2 + 3 files changed, 71 insertions(+) create mode 100644 Documentation/reference/llm.md diff --git a/Documentation/reference/index.md b/Documentation/reference/index.md index 68651cc..8f41d29 100644 --- a/Documentation/reference/index.md +++ b/Documentation/reference/index.md @@ -4,6 +4,7 @@ This section documents the cross-cutting mechanics of the `cratis` CLI: the flag ## Topics +- [LLM](llm.md) — Configuring the language model that Cratis tools like Prologue use. - [Global Options](global-options.md) — Flags such as `--output`, `--quiet`, `--yes`, and `--debug` that apply to every command. - [Output Formats](output-formats.md) — The four output formats (`table`, `plain`, `json`, `json-compact`) with guidance on when to use each and token cost comparisons. - [Connection](connection.md) — Connection string format, resolution order, and environment variable configuration. diff --git a/Documentation/reference/llm.md b/Documentation/reference/llm.md new file mode 100644 index 0000000..0c67ef5 --- /dev/null +++ b/Documentation/reference/llm.md @@ -0,0 +1,68 @@ +# LLM + +`cratis llm` configures the language model that Cratis tools use. The CLI does not talk to a language model itself — it stores the provider settings in `~/.cratis/config.json`, where tools such as [Prologue](https://github.com/Cratis/Prologue) pick them up, for example when interpreting a captured system. + +```bash +cratis llm use +cratis llm show +cratis llm clear +``` + +## Providers + +| Kind | Description | Default model hint | +|---|---|---| +| `anthropic` | Anthropic's hosted API | `claude-opus-4-6` | +| `openai` | OpenAI's hosted API | `gpt-4o-mini` | +| `local` | Any OpenAI-compatible endpoint running on your own machine (e.g. Ollama, LM Studio) | none | + +## `cratis llm use ` + +Configures the provider and saves it to the configuration file. Values not passed as options are prompted for interactively when running in a terminal — the API key with hidden input, the endpoint only for `local` (where it is required), and the model with the provider's default pre-filled. + +| Option | Description | +|---|---| +| `--api-key ` | API key for the provider. Required for `anthropic` and `openai`; optional for `local`. | +| `--endpoint ` | Endpoint URL. Required for `local` (e.g. `http://localhost:11434/v1`); the hosted providers use their own default endpoints. | +| `--model ` | Model to use. Optional — each hosted provider has a sensible default. | + +```bash +cratis llm use anthropic +cratis llm use openai --api-key sk-... --model gpt-4o-mini +cratis llm use local --endpoint http://localhost:11434/v1 --model llama3 +``` + +When the terminal is not interactive (CI, piped input), all required values must be passed as options — a missing required value fails with a validation error and a non-zero exit code. + +## `cratis llm show` + +Displays the configured provider. The API key is always masked — only the first and last four characters are shown, and keys too short to mask partially are hidden entirely. Global options such as `-o/--output` select the output format — see [Global Options](global-options.md). + +## `cratis llm clear` + +Removes the language model configuration, including the stored API key. Prompts for confirmation; pass `-y/--yes` to skip the prompt. + +## What is stored + +The settings live in the `llm` section of `~/.cratis/config.json`: + +```json +{ + "llm": { + "kind": "anthropic", + "apiKey": "sk-ant-...", + "model": "claude-opus-4-6" + } +} +``` + +> [!WARNING] +> The API key is stored in plain text in your user profile. Use a key you can rotate, and prefer per-developer keys over shared ones. + +## Errors + +| Condition | Result | +|---|---| +| Unknown `` | Validation error listing the valid kinds. | +| Missing API key for `anthropic`/`openai` in a non-interactive terminal | Validation error. | +| Missing `--endpoint` for `local` in a non-interactive terminal | Validation error. | diff --git a/Documentation/reference/toc.yml b/Documentation/reference/toc.yml index e1db2b3..9522de8 100644 --- a/Documentation/reference/toc.yml +++ b/Documentation/reference/toc.yml @@ -1,5 +1,7 @@ - name: Run href: run.md +- name: LLM + href: llm.md - name: Global Options href: global-options.md - name: Output Formats From 92d01755ab58980d6a2da70ec34e79d14902f58c Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 12:06:00 +0200 Subject: [PATCH 05/11] Add Prologue package references from the local feed Cratis.Prologue.{Configuration,Contracts,Interpretation,Interpreter.Contracts,Screenplay} 1.1.0 and Cratis.Screenplay 1.3.0, plus Microsoft.Extensions.Logging.Abstractions for hosting the interpreter session in the CLI. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 8 ++++++++ Source/Cli/Cli.csproj | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 912f2ef..094a181 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,14 @@ + + + + + + + + diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 70dc0e3..27d7c45 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -22,6 +22,13 @@ + + + + + + + From dae6d22a4d0258bb5a44836655c153c741fc7c56 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 12:06:07 +0200 Subject: [PATCH 06/11] Add prologue start command with the capture setup wizard An interactive wizard that walks through the capture sources (SQL Server, PostgreSQL, API through a reverse proxy, OpenTelemetry) and output (rolling JSON capture files or the Prologue Receiver API), assembles a PrologueConfiguration through a testable builder, and writes it as cratis-prologue.json. Non-interactive terminals fail with a clear validation error since the wizard needs prompts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Cli/Commands/Prologue/ApiSourceInput.cs | 12 ++ .../Commands/Prologue/CaptureOutputInput.cs | 14 ++ .../Prologue/OpenTelemetrySourceInput.cs | 17 ++ .../Commands/Prologue/PostgresSourceInput.cs | 11 ++ .../Prologue/PrologueConfigurationBuilder.cs | 130 +++++++++++++++ .../Prologue/PrologueConfigurationFiles.cs | 55 +++++++ .../Cli/Commands/Prologue/PrologueWizard.cs | 152 ++++++++++++++++++ .../Commands/Prologue/PrologueWizardInput.cs | 22 +++ .../Commands/Prologue/SqlServerSourceInput.cs | 12 ++ .../Commands/Prologue/StartPrologueCommand.cs | 94 +++++++++++ .../Prologue/StartPrologueSettings.cs | 17 ++ Source/Cli/Registration/PrologueBranch.cs | 12 ++ 12 files changed, 548 insertions(+) create mode 100644 Source/Cli/Commands/Prologue/ApiSourceInput.cs create mode 100644 Source/Cli/Commands/Prologue/CaptureOutputInput.cs create mode 100644 Source/Cli/Commands/Prologue/OpenTelemetrySourceInput.cs create mode 100644 Source/Cli/Commands/Prologue/PostgresSourceInput.cs create mode 100644 Source/Cli/Commands/Prologue/PrologueConfigurationBuilder.cs create mode 100644 Source/Cli/Commands/Prologue/PrologueConfigurationFiles.cs create mode 100644 Source/Cli/Commands/Prologue/PrologueWizard.cs create mode 100644 Source/Cli/Commands/Prologue/PrologueWizardInput.cs create mode 100644 Source/Cli/Commands/Prologue/SqlServerSourceInput.cs create mode 100644 Source/Cli/Commands/Prologue/StartPrologueCommand.cs create mode 100644 Source/Cli/Commands/Prologue/StartPrologueSettings.cs create mode 100644 Source/Cli/Registration/PrologueBranch.cs diff --git a/Source/Cli/Commands/Prologue/ApiSourceInput.cs b/Source/Cli/Commands/Prologue/ApiSourceInput.cs new file mode 100644 index 0000000..c759dcc --- /dev/null +++ b/Source/Cli/Commands/Prologue/ApiSourceInput.cs @@ -0,0 +1,12 @@ +// 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.Prologue; + +/// +/// Represents the API capture source entered in the Prologue setup wizard — the extractor sits in front of the +/// system as a reverse proxy and observes the state-changing HTTP commands flowing through it. +/// +/// The base path the proxy forwards (for example /api). +/// The address of the system being captured that proxied requests are forwarded to. +public record ApiSourceInput(string BasePath, string Destination); diff --git a/Source/Cli/Commands/Prologue/CaptureOutputInput.cs b/Source/Cli/Commands/Prologue/CaptureOutputInput.cs new file mode 100644 index 0000000..151a755 --- /dev/null +++ b/Source/Cli/Commands/Prologue/CaptureOutputInput.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Represents where the extractor writes captured data, as entered in the Prologue setup wizard. +/// +/// The kind of output — the Prologue Receiver API or rolling JSON capture files. +/// The base address of the Prologue Receiver, used when is . +/// The directory capture files are written to, used when is . +public record CaptureOutputInput(OutputKind Kind, string ApiEndpoint, string JsonDirectory); diff --git a/Source/Cli/Commands/Prologue/OpenTelemetrySourceInput.cs b/Source/Cli/Commands/Prologue/OpenTelemetrySourceInput.cs new file mode 100644 index 0000000..6b7b982 --- /dev/null +++ b/Source/Cli/Commands/Prologue/OpenTelemetrySourceInput.cs @@ -0,0 +1,17 @@ +// 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.Prologue; + +/// +/// Represents the OpenTelemetry capture source entered in the Prologue setup wizard. +/// +/// The service names to capture spans for; empty captures every service. +/// The span attribute keys whose values are captured. +/// The upstream OTLP/HTTP collector telemetry is forwarded to; empty for a terminal capture. +/// The upstream OTLP/gRPC collector telemetry is forwarded to; empty for a terminal capture. +public record OpenTelemetrySourceInput( + IReadOnlyList ServiceNames, + IReadOnlyList AttributeKeys, + string UpstreamHttp, + string UpstreamGrpc); diff --git a/Source/Cli/Commands/Prologue/PostgresSourceInput.cs b/Source/Cli/Commands/Prologue/PostgresSourceInput.cs new file mode 100644 index 0000000..0d165b5 --- /dev/null +++ b/Source/Cli/Commands/Prologue/PostgresSourceInput.cs @@ -0,0 +1,11 @@ +// 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.Prologue; + +/// +/// Represents one PostgreSQL change-capture source entered in the Prologue setup wizard. +/// +/// The logical name identifying the source in captures. +/// The connection string to the PostgreSQL database. +public record PostgresSourceInput(string Name, string ConnectionString); diff --git a/Source/Cli/Commands/Prologue/PrologueConfigurationBuilder.cs b/Source/Cli/Commands/Prologue/PrologueConfigurationBuilder.cs new file mode 100644 index 0000000..0099fa9 --- /dev/null +++ b/Source/Cli/Commands/Prologue/PrologueConfigurationBuilder.cs @@ -0,0 +1,130 @@ +// 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.Nodes; +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Builds the a cratis-prologue.json file holds from the values +/// entered in the Prologue setup wizard — the pure assembly logic, separated from the interactive prompting +/// so it can be verified without a terminal. +/// +public static class PrologueConfigurationBuilder +{ + /// + /// The base path suggested for the API capture source. + /// + public const string DefaultApiBasePath = "/api"; + + /// + /// Builds the configuration for the values entered in the wizard. + /// + /// The values entered in the wizard. + /// The ready to be written as cratis-prologue.json. + public static PrologueConfiguration Build(PrologueWizardInput input) + { + var configuration = new PrologueConfiguration + { + Prologue = new PrologueOptions + { + PrologueId = input.PrologueId, + Output = new OutputOptions + { + Kind = input.Output.Kind, + Api = new ApiOptions { Endpoint = input.Output.ApiEndpoint }, + Json = new JsonFileOptions { Directory = input.Output.JsonDirectory } + }, + SqlServer = [.. input.SqlServer.Select(SqlServerFor)], + Postgres = [.. input.Postgres.Select(PostgresFor)] + } + }; + + if (input.OpenTelemetry is not null) + { + configuration.Prologue.OpenTelemetry = OpenTelemetryFor(input.OpenTelemetry); + } + + if (input.Api is not null) + { + configuration.ReverseProxy = ReverseProxyFor(input.Api); + } + + return configuration; + } + + /// + /// Normalizes a base path entered in the wizard — a leading slash, no trailing slash, falling back to + /// when empty. + /// + /// The base path as entered. + /// The normalized base path. + public static string NormalizeBasePath(string? basePath) + { + var trimmed = basePath?.Trim().TrimEnd('/') ?? string.Empty; + if (trimmed.Length == 0) + { + return DefaultApiBasePath; + } + + return trimmed.StartsWith('/') ? trimmed : $"/{trimmed}"; + } + + /// + /// Parses a comma-separated list entered in the wizard into its trimmed, non-empty values. + /// + /// The comma-separated value as entered; may be empty. + /// The parsed values. + public static IReadOnlyList ParseList(string? value) => + value?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []; + + static SqlServerOptions SqlServerFor(SqlServerSourceInput source) => new() + { + Name = source.Name, + ConnectionString = source.ConnectionString, + Tables = [.. source.Tables] + }; + + static PostgresOptions PostgresFor(PostgresSourceInput source) => new() + { + Name = source.Name, + ConnectionString = source.ConnectionString + }; + + static OpenTelemetryOptions OpenTelemetryFor(OpenTelemetrySourceInput source) => new() + { + Enabled = true, + ServiceNames = [.. source.ServiceNames], + AttributeKeys = [.. source.AttributeKeys], + Upstream = new UpstreamOptions { Http = source.UpstreamHttp, Grpc = source.UpstreamGrpc } + }; + + static JsonObject ReverseProxyFor(ApiSourceInput api) + { + // The extractor's HTTP capture is a YARP reverse proxy in front of the system being captured; the + // section follows YARP's own schema, so it is raw JSON on the configuration. + var basePath = NormalizeBasePath(api.BasePath); + return new JsonObject + { + ["Routes"] = new JsonObject + { + ["monitored"] = new JsonObject + { + ["ClusterId"] = "monitored", + ["Match"] = new JsonObject { ["Path"] = $"{basePath}/{{**catch-all}}" } + } + }, + ["Clusters"] = new JsonObject + { + ["monitored"] = new JsonObject + { + ["Destinations"] = new JsonObject + { + ["primary"] = new JsonObject { ["Address"] = api.Destination } + } + } + } + }; + } +} diff --git a/Source/Cli/Commands/Prologue/PrologueConfigurationFiles.cs b/Source/Cli/Commands/Prologue/PrologueConfigurationFiles.cs new file mode 100644 index 0000000..11b2617 --- /dev/null +++ b/Source/Cli/Commands/Prologue/PrologueConfigurationFiles.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Resolves where cratis-prologue.json files live for the prologue commands — where the setup wizard +/// writes a new one, and where an existing one is found for interpretation. +/// +public static class PrologueConfigurationFiles +{ + /// + /// Resolves the path the setup wizard writes the configuration file to. + /// + /// The path given on the command line — a file, an existing directory, or for the default. + /// The directory relative paths are resolved against. + /// The full path of the configuration file to write. + public static string ResolveOutputPath(string? file, string currentDirectory) + { + if (string.IsNullOrWhiteSpace(file)) + { + return Path.Combine(currentDirectory, PrologueConfigurationFile.FileName); + } + + var full = Path.GetFullPath(file, currentDirectory); + if (Directory.Exists(full) || file.EndsWith(Path.DirectorySeparatorChar) || file.EndsWith(Path.AltDirectorySeparatorChar)) + { + return Path.Combine(full, PrologueConfigurationFile.FileName); + } + + return full; + } + + /// + /// Finds an existing cratis-prologue.json for interpretation — in the given capture folder first, + /// then in the current directory. + /// + /// The capture folder given on the command line; when not given. + /// The directory relative paths are resolved against. + /// The full path of the configuration file, or when none exists. + public static string? Find(string? path, string currentDirectory) + { + var candidates = new List(); + if (!string.IsNullOrWhiteSpace(path)) + { + candidates.Add(Path.Combine(Path.GetFullPath(path, currentDirectory), PrologueConfigurationFile.FileName)); + } + + candidates.Add(Path.Combine(currentDirectory, PrologueConfigurationFile.FileName)); + + return candidates.FirstOrDefault(File.Exists); + } +} diff --git a/Source/Cli/Commands/Prologue/PrologueWizard.cs b/Source/Cli/Commands/Prologue/PrologueWizard.cs new file mode 100644 index 0000000..34ba1c9 --- /dev/null +++ b/Source/Cli/Commands/Prologue/PrologueWizard.cs @@ -0,0 +1,152 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// The interactive prompt flow of cratis prologue start — walks the user through sources and output and +/// collects a for to assemble. +/// +public static class PrologueWizard +{ + const string SqlServerChoice = "SQL Server database"; + const string PostgresChoice = "PostgreSQL database"; + const string ApiChoice = "API (HTTP commands through a reverse proxy)"; + const string OpenTelemetryChoice = "OpenTelemetry (spans, metrics, and logs)"; + + /// + /// Collects the wizard input by prompting in the terminal. + /// + /// The the prompts observe. + /// The collected . + public static async Task Collect(CancellationToken cancellationToken) + { + var sources = await AnsiConsole.PromptAsync( + new MultiSelectionPrompt() + .Title("Which sources should be captured?") + .InstructionsText($"[{OutputFormatter.Muted.ToMarkup()}](press [blue][/] to toggle a source, [green][/] to accept)[/]") + .AddChoices(SqlServerChoice, PostgresChoice, ApiChoice, OpenTelemetryChoice), + cancellationToken); + + var sqlServer = sources.Contains(SqlServerChoice) ? await CollectSqlServer(cancellationToken) : []; + var postgres = sources.Contains(PostgresChoice) ? await CollectPostgres(cancellationToken) : []; + var api = sources.Contains(ApiChoice) ? await CollectApi(cancellationToken) : null; + var openTelemetry = sources.Contains(OpenTelemetryChoice) ? await CollectOpenTelemetry(cancellationToken) : null; + var output = await CollectOutput(cancellationToken); + + return new(Guid.NewGuid(), sqlServer, postgres, api, openTelemetry, output); + } + + static async Task> CollectSqlServer(CancellationToken cancellationToken) + { + var instances = new List(); + do + { + OutputFormatter.WriteSection($"SQL Server database #{instances.Count + 1}"); + var name = await AnsiConsole.PromptAsync( + new TextPrompt("Name (identifies the source in captures):") + .DefaultValue(instances.Count == 0 ? "sqlserver" : $"sqlserver{instances.Count + 1}"), + cancellationToken); + var connectionString = await AnsiConsole.PromptAsync( + new TextPrompt("Connection string:"), + cancellationToken); + var tables = await AnsiConsole.PromptAsync( + new TextPrompt("Tables to capture (comma separated, empty for all):").AllowEmpty(), + cancellationToken); + instances.Add(new(name, connectionString, PrologueConfigurationBuilder.ParseList(tables))); + } + while (await AnsiConsole.ConfirmAsync("Add another SQL Server database?", defaultValue: false, cancellationToken)); + + return instances; + } + + static async Task> CollectPostgres(CancellationToken cancellationToken) + { + var instances = new List(); + do + { + OutputFormatter.WriteSection($"PostgreSQL database #{instances.Count + 1}"); + var name = await AnsiConsole.PromptAsync( + new TextPrompt("Name (identifies the source in captures):") + .DefaultValue(instances.Count == 0 ? "postgres" : $"postgres{instances.Count + 1}"), + cancellationToken); + var connectionString = await AnsiConsole.PromptAsync( + new TextPrompt("Connection string:"), + cancellationToken); + instances.Add(new(name, connectionString)); + } + while (await AnsiConsole.ConfirmAsync("Add another PostgreSQL database?", defaultValue: false, cancellationToken)); + + return instances; + } + + static async Task CollectApi(CancellationToken cancellationToken) + { + OutputFormatter.WriteSection("API capture"); + var basePath = await AnsiConsole.PromptAsync( + new TextPrompt("Base path the extractor proxies:") + .DefaultValue(PrologueConfigurationBuilder.DefaultApiBasePath), + cancellationToken); + var destination = await AnsiConsole.PromptAsync( + new TextPrompt("Address of your system (where proxied requests are forwarded):") + .DefaultValue("http://replace-with-your-system:8080/"), + cancellationToken); + + return new(basePath, destination); + } + + static async Task CollectOpenTelemetry(CancellationToken cancellationToken) + { + OutputFormatter.WriteSection("OpenTelemetry capture"); + var serviceNames = await AnsiConsole.PromptAsync( + new TextPrompt("Service names to capture (comma separated, empty for all):").AllowEmpty(), + cancellationToken); + var attributeKeys = await AnsiConsole.PromptAsync( + new TextPrompt("Span attribute keys to capture values for (comma separated):").AllowEmpty(), + cancellationToken); + var upstreamHttp = await AnsiConsole.PromptAsync( + new TextPrompt("Upstream OTLP/HTTP collector (empty for a terminal capture):").AllowEmpty(), + cancellationToken); + var upstreamGrpc = await AnsiConsole.PromptAsync( + new TextPrompt("Upstream OTLP/gRPC collector (empty for a terminal capture):").AllowEmpty(), + cancellationToken); + + return new( + PrologueConfigurationBuilder.ParseList(serviceNames), + PrologueConfigurationBuilder.ParseList(attributeKeys), + upstreamHttp.Trim(), + upstreamGrpc.Trim()); + } + + static async Task CollectOutput(CancellationToken cancellationToken) + { + OutputFormatter.WriteSection("Output"); + var kind = await AnsiConsole.PromptAsync( + new SelectionPrompt() + .Title("Where should captured data go?") + .UseConverter(candidate => candidate == OutputKind.Json + ? "Rolling JSON capture files (interpret them later with: cratis prologue interpret)" + : "The Prologue Receiver API") + .AddChoices(OutputKind.Json, OutputKind.Api), + cancellationToken); + + var endpoint = new ApiOptions().Endpoint; + var directory = new JsonFileOptions().Directory; + if (kind == OutputKind.Api) + { + endpoint = await AnsiConsole.PromptAsync( + new TextPrompt("Prologue Receiver endpoint:").DefaultValue(endpoint), + cancellationToken); + } + else + { + directory = await AnsiConsole.PromptAsync( + new TextPrompt("Directory for the capture files:").DefaultValue(directory), + cancellationToken); + } + + return new(kind, endpoint, directory); + } +} diff --git a/Source/Cli/Commands/Prologue/PrologueWizardInput.cs b/Source/Cli/Commands/Prologue/PrologueWizardInput.cs new file mode 100644 index 0000000..8712e7b --- /dev/null +++ b/Source/Cli/Commands/Prologue/PrologueWizardInput.cs @@ -0,0 +1,22 @@ +// 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.Prologue; + +/// +/// Represents everything entered across the Prologue setup wizard — the input +/// turns into a cratis-prologue.json configuration. +/// +/// The identity of the Prologue the captures will belong to. +/// The SQL Server change-capture sources; empty when none were selected. +/// The PostgreSQL change-capture sources; empty when none were selected. +/// The API capture source; when not selected. +/// The OpenTelemetry capture source; when not selected. +/// Where the extractor writes captured data. +public record PrologueWizardInput( + Guid PrologueId, + IReadOnlyList SqlServer, + IReadOnlyList Postgres, + ApiSourceInput? Api, + OpenTelemetrySourceInput? OpenTelemetry, + CaptureOutputInput Output); diff --git a/Source/Cli/Commands/Prologue/SqlServerSourceInput.cs b/Source/Cli/Commands/Prologue/SqlServerSourceInput.cs new file mode 100644 index 0000000..787a681 --- /dev/null +++ b/Source/Cli/Commands/Prologue/SqlServerSourceInput.cs @@ -0,0 +1,12 @@ +// 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.Prologue; + +/// +/// Represents one SQL Server change-capture source entered in the Prologue setup wizard. +/// +/// The logical name identifying the source in captures. +/// The connection string to the SQL Server database. +/// The tables to capture changes for; empty captures every user table. +public record SqlServerSourceInput(string Name, string ConnectionString, IReadOnlyList Tables); diff --git a/Source/Cli/Commands/Prologue/StartPrologueCommand.cs b/Source/Cli/Commands/Prologue/StartPrologueCommand.cs new file mode 100644 index 0000000..0faf8ca --- /dev/null +++ b/Source/Cli/Commands/Prologue/StartPrologueCommand.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 Cratis.Prologue.Configuration; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Sets up capture of a running system through an interactive wizard that writes a cratis-prologue.json +/// configuration for the Prologue extractor. +/// +[LlmDescription("Interactive wizard that produces a cratis-prologue.json capture configuration for the Prologue extractor: which sources to capture (SQL Server, PostgreSQL, API through a reverse proxy, OpenTelemetry) and where captured data goes (rolling JSON files or the Prologue Receiver API). Requires an interactive terminal — it cannot run in scripts or agent environments.")] +[CliCommand("start", "Set up capture of a running system with an interactive wizard", Branch = typeof(PrologueBranch))] +[CliExample("prologue", "start")] +[CliExample("prologue", "start", "--file", "./my-system")] +[LlmOption("--file", "string", "Where to write the configuration — a file path or a directory. Defaults to cratis-prologue.json in the current directory.")] +[LlmOutputAdvice("table", "The wizard is interactive and renders a summary table when done.")] +public class StartPrologueCommand : AsyncCommand +{ + /// + protected override async Task ExecuteAsync(CommandContext context, StartPrologueSettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + + if (!AnsiConsole.Profile.Capabilities.Interactive || settings.Yes) + { + OutputFormatter.WriteError( + format, + "The Prologue setup wizard needs an interactive terminal", + "Run it from a terminal without --yes, or write a cratis-prologue.json by hand — see https://github.com/Cratis/Prologue", + ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + + var input = await PrologueWizard.Collect(cancellationToken); + var configuration = PrologueConfigurationBuilder.Build(input); + var path = PrologueConfigurationFiles.ResolveOutputPath(settings.File, Directory.GetCurrentDirectory()); + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await PrologueConfigurationFile.WriteToFile(configuration, path); + + PrintSummary(input, path); + return ExitCodes.Success; + } + + static void PrintSummary(PrologueWizardInput input, string path) + { + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(OutputFormatter.Muted) + .AddColumn(new TableColumn("[bold]Setting[/]").Padding(1, 0)) + .AddColumn(new TableColumn("[bold]Value[/]").Padding(1, 0)); + table.AddRow(new Markup("Prologue id"), new Markup(input.PrologueId.ToString().EscapeMarkup())); + + foreach (var source in input.SqlServer) + { + table.AddRow(new Markup("SQL Server"), new Markup(SqlServerSummary(source).EscapeMarkup())); + } + + foreach (var source in input.Postgres) + { + table.AddRow(new Markup("PostgreSQL"), new Markup(source.Name.EscapeMarkup())); + } + + if (input.Api is not null) + { + table.AddRow(new Markup("API"), new Markup($"{PrologueConfigurationBuilder.NormalizeBasePath(input.Api.BasePath)} → {input.Api.Destination}".EscapeMarkup())); + } + + if (input.OpenTelemetry is not null) + { + table.AddRow(new Markup("OpenTelemetry"), new Markup(OpenTelemetrySummary(input.OpenTelemetry).EscapeMarkup())); + } + + table.AddRow(new Markup("Output"), new Markup(OutputSummary(input.Output).EscapeMarkup())); + table.AddRow(new Markup("File"), new Markup(path.EscapeMarkup())); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(table); + + var configDirectory = Path.GetDirectoryName(path); + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]→ Run the extractor next to your system: docker run --rm -p 8080:8080 -v \"{configDirectory.EscapeMarkup()}:/config\" cratis/prologue-extractor[/]"); + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]→ When captures have been collected, interpret them with: cratis prologue interpret[/]"); + } + + static string SqlServerSummary(SqlServerSourceInput source) => + source.Tables.Count == 0 ? $"{source.Name} (all tables)" : $"{source.Name} ({string.Join(", ", source.Tables)})"; + + static string OpenTelemetrySummary(OpenTelemetrySourceInput source) => + source.ServiceNames.Count == 0 ? "all services" : string.Join(", ", source.ServiceNames); + + static string OutputSummary(CaptureOutputInput output) => + output.Kind == OutputKind.Json ? $"JSON capture files in {output.JsonDirectory}" : $"Prologue Receiver at {output.ApiEndpoint}"; +} diff --git a/Source/Cli/Commands/Prologue/StartPrologueSettings.cs b/Source/Cli/Commands/Prologue/StartPrologueSettings.cs new file mode 100644 index 0000000..279529d --- /dev/null +++ b/Source/Cli/Commands/Prologue/StartPrologueSettings.cs @@ -0,0 +1,17 @@ +// 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.Prologue; + +/// +/// Settings for the prologue start command. +/// +public class StartPrologueSettings : GlobalSettings +{ + /// + /// Gets or sets where the configuration file is written — a file path or an existing directory. + /// + [CommandOption("--file ")] + [Description("Where to write the configuration — a file path or a directory. Defaults to cratis-prologue.json in the current directory.")] + public string? File { get; set; } +} diff --git a/Source/Cli/Registration/PrologueBranch.cs b/Source/Cli/Registration/PrologueBranch.cs new file mode 100644 index 0000000..591bd30 --- /dev/null +++ b/Source/Cli/Registration/PrologueBranch.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#pragma warning disable RCS1251, SA1502 // Marker type is intentionally empty + +namespace Cratis.Cli.Registration; + +/// +/// Capture and interpretation of a running system through Prologue. +/// +[CliBranch("prologue", "Capture and interpret a running system")] +public static class PrologueBranch; From 64688b6bf34aab00b58025ae1a0da2e53413d60a Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 12:06:17 +0200 Subject: [PATCH 07/11] Add prologue interpret command driving the interpreter session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads capture (.jsonl) files, resolves the language model from the cratis-prologue.json Llm section or the CLI's own llm configuration (heuristics only when neither is set), and drives the interpreter session with console callbacks — status transitions as progress lines and the model's questions one at a time with a selection prompt that always offers a free-text "Other" answer. On completion the extracted model is generated into a Screenplay and written to --file or .play, reporting the module/feature/slice counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Cli/Commands/Prologue/CaptureFolders.cs | 39 +++++ .../Prologue/ConsoleInterpreterCallbacks.cs | 81 +++++++++++ .../Prologue/InterpretPrologueCommand.cs | 133 ++++++++++++++++++ .../Prologue/InterpretPrologueSettings.cs | 31 ++++ .../Commands/Prologue/LlmOptionsResolver.cs | 68 +++++++++ .../Cli/Commands/Prologue/ScreenplayOutput.cs | 25 ++++ 6 files changed, 377 insertions(+) create mode 100644 Source/Cli/Commands/Prologue/CaptureFolders.cs create mode 100644 Source/Cli/Commands/Prologue/ConsoleInterpreterCallbacks.cs create mode 100644 Source/Cli/Commands/Prologue/InterpretPrologueCommand.cs create mode 100644 Source/Cli/Commands/Prologue/InterpretPrologueSettings.cs create mode 100644 Source/Cli/Commands/Prologue/LlmOptionsResolver.cs create mode 100644 Source/Cli/Commands/Prologue/ScreenplayOutput.cs diff --git a/Source/Cli/Commands/Prologue/CaptureFolders.cs b/Source/Cli/Commands/Prologue/CaptureFolders.cs new file mode 100644 index 0000000..7cb3106 --- /dev/null +++ b/Source/Cli/Commands/Prologue/CaptureFolders.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Resolves the folder the interpret command reads capture (.jsonl) files from. +/// +public static class CaptureFolders +{ + /// + /// Resolves the capture folder — the path given on the command line when there is one, otherwise the + /// configured JSON output directory when a cratis-prologue.json with JSON output was found, and the + /// current directory as the last resort. + /// + /// The capture folder given on the command line; when not given. + /// The configuration found for the run; when none exists. + /// The full path of the found configuration file; when none exists. + /// The directory relative paths are resolved against. + /// The full path of the folder to read captures from. + public static string Resolve(string? path, PrologueConfiguration? configuration, string? configurationPath, string currentDirectory) + { + if (!string.IsNullOrWhiteSpace(path)) + { + return Path.GetFullPath(path, currentDirectory); + } + + if (configuration?.Prologue.Output.Kind == OutputKind.Json && + configuration.Prologue.Output.Json.Directory is { Length: > 0 } directory && + configurationPath is not null) + { + return Path.GetFullPath(directory, Path.GetDirectoryName(configurationPath)!); + } + + return currentDirectory; + } +} diff --git a/Source/Cli/Commands/Prologue/ConsoleInterpreterCallbacks.cs b/Source/Cli/Commands/Prologue/ConsoleInterpreterCallbacks.cs new file mode 100644 index 0000000..c3a32f5 --- /dev/null +++ b/Source/Cli/Commands/Prologue/ConsoleInterpreterCallbacks.cs @@ -0,0 +1,81 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; +using Cratis.Prologue.Interpretation; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Represents the that surface an interpreter session in the terminal — +/// status transitions render as muted progress lines and the language model's questions become interactive +/// prompts, one at a time, always with an "other" entry for a free-text answer. +/// +/// The language-model options the session refines with — names the provider in the progress lines. +/// Whether status transitions are rendered; off for JSON and quiet output formats. +/// The the prompts observe. +public class ConsoleInterpreterCallbacks(LlmOptions llmOptions, bool showStatus, CancellationToken cancellationToken) : IInterpreterCallbacks +{ + const string OtherChoiceLabel = "Other (type your own answer)"; + InterpreterStatus? _lastStatus; + + /// + public async Task OnQuestion(InterpreterQuestion question) + { + AnsiConsole.WriteLine(); + if (question.Context.Length > 0) + { + AnsiConsole.MarkupLine($"[{OutputFormatter.Muted.ToMarkup()}]{question.Context.EscapeMarkup()}[/]"); + } + + if (question.Choices.Count == 0) + { + return new(question.Id, await PromptForText(question.Prompt)); + } + + var prompt = new SelectionPrompt() + .Title($"[bold]{question.Prompt.EscapeMarkup()}[/]") + .UseConverter(label => DisplayFor(question, label)) + .AddChoices(question.Choices.Select(choice => choice.Label)) + .AddChoices(OtherChoiceLabel); + + var selected = await AnsiConsole.PromptAsync(prompt, cancellationToken); + return new(question.Id, selected == OtherChoiceLabel ? await PromptForText("Your answer:") : selected); + } + + /// + public void OnStatusChanged(InterpreterStatus status) + { + if (!showStatus || status == _lastStatus) + { + return; + } + + _lastStatus = status; + if (TextFor(status) is { } text) + { + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]{text.EscapeMarkup()}[/]"); + } + } + + static string DisplayFor(InterpreterQuestion question, string label) + { + var choice = question.Choices.FirstOrDefault(candidate => candidate.Label == label); + return choice is { Description.Length: > 0 } + ? $"{label.EscapeMarkup()} [{OutputFormatter.Muted.ToMarkup()}]— {choice.Description.EscapeMarkup()}[/]" + : label.EscapeMarkup(); + } + + async Task PromptForText(string prompt) => + await AnsiConsole.PromptAsync(new TextPrompt(prompt.EscapeMarkup()), cancellationToken); + + string? TextFor(InterpreterStatus status) => status switch + { + InterpreterStatus.ReadingCaptures => "Reading captures…", + InterpreterStatus.AnalyzingEvidence => "Analyzing evidence…", + InterpreterStatus.BuildingModel => "Building model…", + InterpreterStatus.Refining => $"Refining with {llmOptions.Kind}…", + InterpreterStatus.GeneratingScreenplay => "Generating Screenplay…", + _ => null + }; +} diff --git a/Source/Cli/Commands/Prologue/InterpretPrologueCommand.cs b/Source/Cli/Commands/Prologue/InterpretPrologueCommand.cs new file mode 100644 index 0000000..3666312 --- /dev/null +++ b/Source/Cli/Commands/Prologue/InterpretPrologueCommand.cs @@ -0,0 +1,133 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; +using Cratis.Prologue.Contracts; +using Cratis.Prologue.Interpretation; +using Cratis.Prologue.Interpreter.Contracts; +using Cratis.Prologue.Screenplay; +using Cratis.Screenplay.Printing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Interprets captured system behavior into a Cratis Screenplay — reads the capture files, drives an +/// interpreter session (optionally refined by the configured language model, asking questions along the way), +/// and writes the resulting .play file. +/// +[LlmDescription("Interprets Prologue capture (.jsonl) files into a Cratis Screenplay (.play) file. Uses deterministic heuristics, refined by the configured language model when one is set up (cratis-prologue.json Llm section or cratis llm use). In an interactive terminal the language model may ask clarifying questions; non-interactive runs never ask. Writes .play to the current directory unless --file is given.")] +[CliCommand("interpret", "Interpret captured system behavior into a Screenplay", Branch = typeof(PrologueBranch))] +[CliExample("prologue", "interpret")] +[CliExample("prologue", "interpret", "./captures")] +[CliExample("prologue", "interpret", "./captures", "--file", "MySystem.play")] +[LlmOption("[PATH]", "string", "Folder holding the capture (.jsonl) files. Defaults to the configured JSON output directory when a cratis-prologue.json is found, otherwise the current directory.")] +[LlmOption("--file", "string", "File to write the generated Screenplay to. Defaults to .play in the current directory.")] +[LlmOption("--prologue-id", "guid", "The Prologue the captures belong to. Defaults from cratis-prologue.json when present.")] +[LlmOutputAdvice("json", "JSON outputs the written path, system name, and module/feature/slice counts as one object.")] +public class InterpretPrologueCommand : AsyncCommand +{ + /// + protected override async Task ExecuteAsync(CommandContext context, InterpretPrologueSettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + var currentDirectory = Directory.GetCurrentDirectory(); + + var configurationPath = PrologueConfigurationFiles.Find(settings.Path, currentDirectory); + var configuration = configurationPath is not null ? await PrologueConfigurationFile.ReadFromFile(configurationPath) : null; + var folder = CaptureFolders.Resolve(settings.Path, configuration, configurationPath, currentDirectory); + var prologueId = settings.PrologueId ?? configuration?.Prologue.PrologueId ?? Guid.Empty; + + var captures = Directory.Exists(folder) ? await CaptureFiles.ReadFromFolder(folder, prologueId) : []; + if (captures.Count == 0) + { + OutputFormatter.WriteError( + format, + $"No captures found in '{folder}'", + "Run the Prologue extractor with JSON output to produce capture (.jsonl) files, or point the command at the folder holding them", + ExitCodes.NotFoundCode); + return ExitCodes.NotFound; + } + + var llmOptions = LlmOptionsResolver.Resolve(configuration, CliConfiguration.Load().Llm); + var showStatus = string.Equals(format, OutputFormats.Table, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.Plain, StringComparison.Ordinal); + if (!llmOptions.Enabled && showStatus) + { + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]No language model configured — interpreting with heuristics only. Configure one with: cratis llm use [/]"); + } + + var callbacks = new ConsoleInterpreterCallbacks(llmOptions, showStatus, cancellationToken); + var factory = new InterpreterSessionFactory(new HeuristicModelBuilder(), new ChatClientFactory(), NullLogger.Instance); + var interactive = AnsiConsole.Profile.Capabilities.Interactive && !settings.Yes; + var session = factory.CreateNew( + prologueId, + captures, + llmOptions, + callbacks.OnStatusChanged, + interactive ? IInterpreterSessionFactory.DefaultMaxQuestionRounds : 0); + + var state = await new InterpreterRunner().Run(session, callbacks, cancellationToken); + if (state.Status == InterpreterStatus.Failed || state.Model is null) + { + OutputFormatter.WriteError( + format, + state.Error.Length > 0 ? state.Error : "Interpretation failed", + errorCode: ExitCodes.ServerErrorCode); + return ExitCodes.ServerError; + } + + callbacks.OnStatusChanged(InterpreterStatus.GeneratingScreenplay); + var source = new ScreenplayGenerator(new ScreenplayPrinter()).Generate(state.Model); + var outputPath = ScreenplayOutput.ResolvePath(settings.File, state.Model.SystemName, currentDirectory); + await File.WriteAllTextAsync(outputPath, source, cancellationToken); + + WriteResult(format, state.Model, outputPath); + return ExitCodes.Success; + } + + static void WriteResult(string format, ExtractionResult model, string outputPath) + { + var features = model.Modules.Sum(module => module.Features.Sum(CountFeatures)); + var slices = model.Modules.Sum(module => module.Features.Sum(CountSlices)); + + if (string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal)) + { + Console.WriteLine(outputPath); + return; + } + + OutputFormatter.WriteObject( + format, + new + { + Path = outputPath, + model.SystemName, + Modules = model.Modules.Count, + Features = features, + Slices = slices + }, + result => + { + var content = new Markup( + $"[bold]{result.Path.EscapeMarkup()}[/]\n" + + $"System: {result.SystemName.EscapeMarkup()}\n" + + $"Modules: {result.Modules}\n" + + $"Features: {result.Features}\n" + + $"Slices: {result.Slices}"); + var panel = new Panel(content) + .Header(" Screenplay generated ") + .Border(BoxBorder.Rounded) + .BorderStyle(new Style(OutputFormatter.Success)) + .Padding(1, 0); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]→ Run it in a local Stage sandbox with: cratis run[/]"); + }); + } + + static int CountFeatures(ExtractedFeature feature) => 1 + feature.SubFeatures.Sum(CountFeatures); + + static int CountSlices(ExtractedFeature feature) => feature.Slices.Count + feature.SubFeatures.Sum(CountSlices); +} diff --git a/Source/Cli/Commands/Prologue/InterpretPrologueSettings.cs b/Source/Cli/Commands/Prologue/InterpretPrologueSettings.cs new file mode 100644 index 0000000..a1465f2 --- /dev/null +++ b/Source/Cli/Commands/Prologue/InterpretPrologueSettings.cs @@ -0,0 +1,31 @@ +// 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.Prologue; + +/// +/// Settings for the prologue interpret command. +/// +public class InterpretPrologueSettings : GlobalSettings +{ + /// + /// Gets or sets the folder holding the capture files to interpret. + /// + [CommandArgument(0, "[PATH]")] + [Description("Folder holding the capture (.jsonl) files. Defaults to the configured JSON output directory when a cratis-prologue.json is found, otherwise the current directory.")] + public string? Path { get; set; } + + /// + /// Gets or sets the file the generated Screenplay is written to. + /// + [CommandOption("--file ")] + [Description("File to write the generated Screenplay to. Defaults to .play in the current directory.")] + public string? File { get; set; } + + /// + /// Gets or sets the Prologue the captures belong to. + /// + [CommandOption("--prologue-id ")] + [Description("The Prologue the captures belong to. Defaults from cratis-prologue.json when one is present in PATH or the current directory.")] + public Guid? PrologueId { get; set; } +} diff --git a/Source/Cli/Commands/Prologue/LlmOptionsResolver.cs b/Source/Cli/Commands/Prologue/LlmOptionsResolver.cs new file mode 100644 index 0000000..5af71e5 --- /dev/null +++ b/Source/Cli/Commands/Prologue/LlmOptionsResolver.cs @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Cli.Commands.Llm; +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Resolves the the interpreter refines with — the Llm section of a +/// cratis-prologue.json when it is enabled there, otherwise the llm section of the CLI's own +/// configuration (written by cratis llm use), and disabled refinement when neither is configured. +/// +public static class LlmOptionsResolver +{ + /// + /// Resolves the language-model options for an interpretation run. + /// + /// The cratis-prologue.json configuration found for the run; when none exists. + /// The CLI's language model configuration; when not configured. + /// The resolved ; disabled when nothing is configured. + public static LlmOptions Resolve(PrologueConfiguration? prologueConfiguration, LlmConfiguration? cliConfiguration) + { + if (prologueConfiguration?.Llm.Enabled == true) + { + return prologueConfiguration.Llm; + } + + if (KindFor(cliConfiguration) is { } kind) + { + return FromCli(cliConfiguration!, kind); + } + + return new LlmOptions { Enabled = false }; + } + + static LlmKind? KindFor(LlmConfiguration? configuration) => + configuration?.Kind is { Length: > 0 } value && LlmKinds.IsValid(value) + ? LlmKinds.Normalize(value) switch + { + LlmKinds.Anthropic => LlmKind.Anthropic, + LlmKinds.OpenAI => LlmKind.OpenAI, + LlmKinds.Local => LlmKind.OpenAICompatible, + _ => null + } + : null; + + static LlmOptions FromCli(LlmConfiguration configuration, LlmKind kind) + { + // An empty model falls back to the provider's default model, and only an explicitly configured + // endpoint overrides the LlmOptions default — the chat client treats that default as "use the hosted + // provider's public endpoint". + var options = new LlmOptions + { + Enabled = true, + Kind = kind, + AccessToken = configuration.ApiKey ?? string.Empty, + ModelId = configuration.Model ?? string.Empty + }; + + if (!string.IsNullOrWhiteSpace(configuration.Endpoint)) + { + options.Endpoint = configuration.Endpoint; + } + + return options; + } +} diff --git a/Source/Cli/Commands/Prologue/ScreenplayOutput.cs b/Source/Cli/Commands/Prologue/ScreenplayOutput.cs new file mode 100644 index 0000000..543edef --- /dev/null +++ b/Source/Cli/Commands/Prologue/ScreenplayOutput.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Screenplay; + +namespace Cratis.Cli.Commands.Prologue; + +/// +/// Resolves the path the interpret command writes the generated Screenplay (.play) file to. +/// +public static class ScreenplayOutput +{ + /// + /// Resolves the output path — the file given on the command line when there is one, otherwise a file named + /// after the interpreted system (through ) in the current directory. + /// + /// The output file given on the command line; when not given. + /// The name derived for the interpreted system. + /// The directory relative paths are resolved against. + /// The full path of the Screenplay file to write. + public static string ResolvePath(string? file, string systemName, string currentDirectory) => + string.IsNullOrWhiteSpace(file) + ? Path.Combine(currentDirectory, ScreenplayFileName.For(systemName)) + : Path.GetFullPath(file, currentDirectory); +} From 8ce580bd977c0a9e3c46c1ac334a82427bf89d99 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 12:06:24 +0200 Subject: [PATCH 08/11] Add specs for prologue configuration building and resolution Covers the wizard-to-configuration assembly (sources, output, reverse proxy), llm options resolution across the Prologue and CLI configurations, capture folder resolution, and the configuration and Screenplay output path derivations. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Cli.Specs/Usings.cs | 1 + .../with_api_output_configuration.cs | 19 +++++++++++ .../when_resolving/with_explicit_path.cs | 19 +++++++++++ .../with_json_output_configuration.cs | 28 ++++++++++++++++ .../when_resolving/without_configuration.cs | 13 ++++++++ .../with_anthropic_cli_configuration.cs | 20 ++++++++++++ ...gue_configuration_and_cli_configuration.cs | 18 +++++++++++ .../with_enabled_prologue_configuration.cs | 23 +++++++++++++ .../with_local_cli_configuration.cs | 20 ++++++++++++ .../with_openai_cli_configuration.cs | 20 ++++++++++++ .../when_resolving/with_unknown_cli_kind.cs | 15 +++++++++ .../without_any_configuration.cs | 15 +++++++++ .../when_building/with_api_output.cs | 22 +++++++++++++ .../when_building/with_api_source.cs | 23 +++++++++++++ .../when_building/with_json_output.cs | 23 +++++++++++++ .../with_open_telemetry_source.cs | 25 +++++++++++++++ .../when_building/with_postgres_sources.cs | 25 +++++++++++++++ .../when_building/with_sql_server_sources.cs | 32 +++++++++++++++++++ .../with_empty_value.cs | 13 ++++++++ .../with_missing_leading_slash.cs | 13 ++++++++ .../with_trailing_slash.cs | 13 ++++++++ .../with_comma_separated_values.cs | 13 ++++++++ .../when_parsing_list/with_empty_value.cs | 13 ++++++++ .../given/a_temporary_folder.cs | 19 +++++++++++ ...iguration_in_the_current_directory_only.cs | 20 ++++++++++++ .../with_configuration_in_the_path_folder.cs | 20 ++++++++++++ .../when_finding/without_any_configuration.cs | 13 ++++++++ .../with_a_directory.cs | 13 ++++++++ .../with_a_file_path.cs | 13 ++++++++ .../without_a_file.cs | 13 ++++++++ .../when_resolving_path/with_explicit_file.cs | 13 ++++++++ .../when_resolving_path/without_file.cs | 13 ++++++++ 32 files changed, 563 insertions(+) create mode 100644 Source/Cli.Specs/for_CaptureFolders/when_resolving/with_api_output_configuration.cs create mode 100644 Source/Cli.Specs/for_CaptureFolders/when_resolving/with_explicit_path.cs create mode 100644 Source/Cli.Specs/for_CaptureFolders/when_resolving/with_json_output_configuration.cs create mode 100644 Source/Cli.Specs/for_CaptureFolders/when_resolving/without_configuration.cs create mode 100644 Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_anthropic_cli_configuration.cs create mode 100644 Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_disabled_prologue_configuration_and_cli_configuration.cs create mode 100644 Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_enabled_prologue_configuration.cs create mode 100644 Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_local_cli_configuration.cs create mode 100644 Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_openai_cli_configuration.cs create mode 100644 Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_unknown_cli_kind.cs create mode 100644 Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/without_any_configuration.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_output.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_source.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_json_output.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_open_telemetry_source.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_postgres_sources.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_sql_server_sources.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_empty_value.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_missing_leading_slash.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_trailing_slash.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_comma_separated_values.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_empty_value.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationFiles/given/a_temporary_folder.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_current_directory_only.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_path_folder.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/without_any_configuration.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_directory.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_file_path.cs create mode 100644 Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/without_a_file.cs create mode 100644 Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/with_explicit_file.cs create mode 100644 Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/without_file.cs diff --git a/Source/Cli.Specs/Usings.cs b/Source/Cli.Specs/Usings.cs index a515461..a13ae28 100644 --- a/Source/Cli.Specs/Usings.cs +++ b/Source/Cli.Specs/Usings.cs @@ -8,6 +8,7 @@ global using Cratis.Cli.Commands.Completions; global using Cratis.Cli.Commands.Init; global using Cratis.Cli.Commands.Llm; +global using Cratis.Cli.Commands.Prologue; global using Cratis.Cli.Commands.Run; global using Cratis.Specifications; global using Xunit; diff --git a/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_api_output_configuration.cs b/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_api_output_configuration.cs new file mode 100644 index 0000000..1dc7dae --- /dev/null +++ b/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_api_output_configuration.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_CaptureFolders.when_resolving; + +public class with_api_output_configuration : Specification +{ + string _result; + + void Because() => _result = CaptureFolders.Resolve( + null, + new PrologueConfiguration { Prologue = new PrologueOptions { Output = new OutputOptions { Kind = OutputKind.Api } } }, + "/config/cratis-prologue.json", + "/work"); + + [Fact] void should_use_the_current_directory() => _result.ShouldEqual("/work"); +} diff --git a/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_explicit_path.cs b/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_explicit_path.cs new file mode 100644 index 0000000..a061bd5 --- /dev/null +++ b/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_explicit_path.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_CaptureFolders.when_resolving; + +public class with_explicit_path : Specification +{ + string _result; + + void Because() => _result = CaptureFolders.Resolve( + "captures", + new PrologueConfiguration(), + "/config/cratis-prologue.json", + "/work"); + + [Fact] void should_resolve_the_path_against_the_current_directory() => _result.ShouldEqual(Path.Combine("/work", "captures")); +} diff --git a/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_json_output_configuration.cs b/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_json_output_configuration.cs new file mode 100644 index 0000000..7f4399d --- /dev/null +++ b/Source/Cli.Specs/for_CaptureFolders/when_resolving/with_json_output_configuration.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_CaptureFolders.when_resolving; + +public class with_json_output_configuration : Specification +{ + PrologueConfiguration _configuration; + string _result; + + void Establish() => _configuration = new PrologueConfiguration + { + Prologue = new PrologueOptions + { + Output = new OutputOptions + { + Kind = OutputKind.Json, + Json = new JsonFileOptions { Directory = "./captures" } + } + } + }; + + void Because() => _result = CaptureFolders.Resolve(null, _configuration, "/config/cratis-prologue.json", "/work"); + + [Fact] void should_resolve_the_configured_directory_against_the_configuration_folder() => _result.ShouldEqual(Path.Combine("/config", "captures")); +} diff --git a/Source/Cli.Specs/for_CaptureFolders/when_resolving/without_configuration.cs b/Source/Cli.Specs/for_CaptureFolders/when_resolving/without_configuration.cs new file mode 100644 index 0000000..3b8776d --- /dev/null +++ b/Source/Cli.Specs/for_CaptureFolders/when_resolving/without_configuration.cs @@ -0,0 +1,13 @@ +// 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.for_CaptureFolders.when_resolving; + +public class without_configuration : Specification +{ + string _result; + + void Because() => _result = CaptureFolders.Resolve(null, null, null, "/work"); + + [Fact] void should_use_the_current_directory() => _result.ShouldEqual("/work"); +} diff --git a/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_anthropic_cli_configuration.cs b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_anthropic_cli_configuration.cs new file mode 100644 index 0000000..74ea1ea --- /dev/null +++ b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_anthropic_cli_configuration.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_LlmOptionsResolver.when_resolving; + +public class with_anthropic_cli_configuration : Specification +{ + LlmOptions _result; + + void Because() => _result = LlmOptionsResolver.Resolve( + null, + new LlmConfiguration { Kind = "anthropic", ApiKey = "sk-ant-key", Model = "claude-opus-4-6" }); + + [Fact] void should_be_enabled() => _result.Enabled.ShouldBeTrue(); + [Fact] void should_map_to_the_anthropic_kind() => _result.Kind.ShouldEqual(LlmKind.Anthropic); + [Fact] void should_carry_the_api_key_as_access_token() => _result.AccessToken.ShouldEqual("sk-ant-key"); + [Fact] void should_carry_the_model() => _result.ModelId.ShouldEqual("claude-opus-4-6"); +} diff --git a/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_disabled_prologue_configuration_and_cli_configuration.cs b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_disabled_prologue_configuration_and_cli_configuration.cs new file mode 100644 index 0000000..3ccdf0c --- /dev/null +++ b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_disabled_prologue_configuration_and_cli_configuration.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_LlmOptionsResolver.when_resolving; + +public class with_disabled_prologue_configuration_and_cli_configuration : Specification +{ + LlmOptions _result; + + void Because() => _result = LlmOptionsResolver.Resolve( + new PrologueConfiguration { Llm = new LlmOptions { Enabled = false } }, + new LlmConfiguration { Kind = "anthropic", ApiKey = "sk-ant-key" }); + + [Fact] void should_fall_back_to_the_cli_configuration() => _result.Kind.ShouldEqual(LlmKind.Anthropic); + [Fact] void should_be_enabled() => _result.Enabled.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_enabled_prologue_configuration.cs b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_enabled_prologue_configuration.cs new file mode 100644 index 0000000..ed7243f --- /dev/null +++ b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_enabled_prologue_configuration.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_LlmOptionsResolver.when_resolving; + +public class with_enabled_prologue_configuration : Specification +{ + PrologueConfiguration _configuration; + LlmOptions _result; + + void Establish() => _configuration = new PrologueConfiguration + { + Llm = new LlmOptions { Enabled = true, Kind = LlmKind.Ollama, ModelId = "gemma" } + }; + + void Because() => _result = LlmOptionsResolver.Resolve( + _configuration, + new LlmConfiguration { Kind = "anthropic", ApiKey = "sk-ant-key" }); + + [Fact] void should_use_the_prologue_configuration() => _result.ShouldEqual(_configuration.Llm); +} diff --git a/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_local_cli_configuration.cs b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_local_cli_configuration.cs new file mode 100644 index 0000000..1db7d1e --- /dev/null +++ b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_local_cli_configuration.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_LlmOptionsResolver.when_resolving; + +public class with_local_cli_configuration : Specification +{ + LlmOptions _result; + + void Because() => _result = LlmOptionsResolver.Resolve( + null, + new LlmConfiguration { Kind = "local", Endpoint = "http://localhost:11434/v1", Model = "llama3" }); + + [Fact] void should_be_enabled() => _result.Enabled.ShouldBeTrue(); + [Fact] void should_map_to_the_openai_compatible_kind() => _result.Kind.ShouldEqual(LlmKind.OpenAICompatible); + [Fact] void should_carry_the_endpoint() => _result.Endpoint.ShouldEqual("http://localhost:11434/v1"); + [Fact] void should_carry_the_model() => _result.ModelId.ShouldEqual("llama3"); +} diff --git a/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_openai_cli_configuration.cs b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_openai_cli_configuration.cs new file mode 100644 index 0000000..a43862b --- /dev/null +++ b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_openai_cli_configuration.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_LlmOptionsResolver.when_resolving; + +public class with_openai_cli_configuration : Specification +{ + LlmOptions _result; + + void Because() => _result = LlmOptionsResolver.Resolve( + null, + new LlmConfiguration { Kind = "openai", ApiKey = "sk-key" }); + + [Fact] void should_be_enabled() => _result.Enabled.ShouldBeTrue(); + [Fact] void should_map_to_the_openai_kind() => _result.Kind.ShouldEqual(LlmKind.OpenAI); + [Fact] void should_leave_the_model_empty_for_the_provider_default() => _result.ModelId.ShouldEqual(string.Empty); + [Fact] void should_keep_the_default_endpoint() => _result.Endpoint.ShouldEqual(new LlmOptions().Endpoint); +} diff --git a/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_unknown_cli_kind.cs b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_unknown_cli_kind.cs new file mode 100644 index 0000000..51be6c2 --- /dev/null +++ b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/with_unknown_cli_kind.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_LlmOptionsResolver.when_resolving; + +public class with_unknown_cli_kind : Specification +{ + LlmOptions _result; + + void Because() => _result = LlmOptionsResolver.Resolve(null, new LlmConfiguration { Kind = "gemini" }); + + [Fact] void should_not_be_enabled() => _result.Enabled.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/without_any_configuration.cs b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/without_any_configuration.cs new file mode 100644 index 0000000..b079281 --- /dev/null +++ b/Source/Cli.Specs/for_LlmOptionsResolver/when_resolving/without_any_configuration.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_LlmOptionsResolver.when_resolving; + +public class without_any_configuration : Specification +{ + LlmOptions _result; + + void Because() => _result = LlmOptionsResolver.Resolve(null, null); + + [Fact] void should_not_be_enabled() => _result.Enabled.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_output.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_output.cs new file mode 100644 index 0000000..bd5ed8e --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_output.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_PrologueConfigurationBuilder.when_building; + +public class with_api_output : Specification +{ + PrologueConfiguration _result; + + void Because() => _result = PrologueConfigurationBuilder.Build(new PrologueWizardInput( + Guid.NewGuid(), + [], + [], + null, + null, + new(OutputKind.Api, "http://receiver:5005", "./captures"))); + + [Fact] void should_use_the_api_output_kind() => _result.Prologue.Output.Kind.ShouldEqual(OutputKind.Api); + [Fact] void should_carry_the_endpoint() => _result.Prologue.Output.Api.Endpoint.ShouldEqual("http://receiver:5005"); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_source.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_source.cs new file mode 100644 index 0000000..842e88b --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_api_source.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_PrologueConfigurationBuilder.when_building; + +public class with_api_source : Specification +{ + PrologueConfiguration _result; + + void Because() => _result = PrologueConfigurationBuilder.Build(new PrologueWizardInput( + Guid.NewGuid(), + [], + [], + new("/api", "http://my-system:8080/"), + null, + new(OutputKind.Json, "http://localhost:5005", "./captures"))); + + [Fact] void should_have_a_reverse_proxy() => _result.ReverseProxy.ShouldNotBeNull(); + [Fact] void should_route_the_base_path_to_the_monitored_cluster() => _result.ReverseProxy!["Routes"]!["monitored"]!["Match"]!["Path"]!.GetValue().ShouldEqual("/api/{**catch-all}"); + [Fact] void should_point_the_cluster_at_the_system() => _result.ReverseProxy!["Clusters"]!["monitored"]!["Destinations"]!["primary"]!["Address"]!.GetValue().ShouldEqual("http://my-system:8080/"); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_json_output.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_json_output.cs new file mode 100644 index 0000000..702ac1f --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_json_output.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_PrologueConfigurationBuilder.when_building; + +public class with_json_output : Specification +{ + PrologueConfiguration _result; + + void Because() => _result = PrologueConfigurationBuilder.Build(new PrologueWizardInput( + Guid.NewGuid(), + [], + [], + null, + null, + new(OutputKind.Json, "http://localhost:5005", "./my-captures"))); + + [Fact] void should_use_the_json_output_kind() => _result.Prologue.Output.Kind.ShouldEqual(OutputKind.Json); + [Fact] void should_carry_the_directory() => _result.Prologue.Output.Json.Directory.ShouldEqual("./my-captures"); + [Fact] void should_not_enable_open_telemetry() => _result.Prologue.OpenTelemetry.Enabled.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_open_telemetry_source.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_open_telemetry_source.cs new file mode 100644 index 0000000..822852b --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_open_telemetry_source.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_PrologueConfigurationBuilder.when_building; + +public class with_open_telemetry_source : Specification +{ + PrologueConfiguration _result; + + void Because() => _result = PrologueConfigurationBuilder.Build(new PrologueWizardInput( + Guid.NewGuid(), + [], + [], + null, + new(["core", "billing"], ["http.route"], "http://collector:4318", "http://collector:4317"), + new(OutputKind.Json, "http://localhost:5005", "./captures"))); + + [Fact] void should_enable_open_telemetry() => _result.Prologue.OpenTelemetry.Enabled.ShouldBeTrue(); + [Fact] void should_carry_the_service_names() => _result.Prologue.OpenTelemetry.ServiceNames.ShouldEqual("core", "billing"); + [Fact] void should_carry_the_attribute_keys() => _result.Prologue.OpenTelemetry.AttributeKeys.ShouldEqual("http.route"); + [Fact] void should_carry_the_upstream_http_collector() => _result.Prologue.OpenTelemetry.Upstream.Http.ShouldEqual("http://collector:4318"); + [Fact] void should_carry_the_upstream_grpc_collector() => _result.Prologue.OpenTelemetry.Upstream.Grpc.ShouldEqual("http://collector:4317"); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_postgres_sources.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_postgres_sources.cs new file mode 100644 index 0000000..996878f --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_postgres_sources.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_PrologueConfigurationBuilder.when_building; + +public class with_postgres_sources : Specification +{ + PrologueConfiguration _result; + + void Because() => _result = PrologueConfigurationBuilder.Build(new PrologueWizardInput( + Guid.NewGuid(), + [], + [new("postgres", "Host=localhost;Database=library;")], + null, + null, + new(OutputKind.Json, "http://localhost:5005", "./captures"))); + + [Fact] void should_have_the_source() => _result.Prologue.Postgres.Count.ShouldEqual(1); + [Fact] void should_carry_the_name() => _result.Prologue.Postgres[0].Name.ShouldEqual("postgres"); + [Fact] void should_carry_the_connection_string() => _result.Prologue.Postgres[0].ConnectionString.ShouldEqual("Host=localhost;Database=library;"); + [Fact] void should_keep_the_default_slot() => _result.Prologue.Postgres[0].Slot.ShouldEqual(new PostgresOptions().Slot); + [Fact] void should_keep_the_default_publication() => _result.Prologue.Postgres[0].Publication.ShouldEqual(new PostgresOptions().Publication); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_sql_server_sources.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_sql_server_sources.cs new file mode 100644 index 0000000..f0d931b --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_building/with_sql_server_sources.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prologue.Configuration; + +namespace Cratis.Cli.for_PrologueConfigurationBuilder.when_building; + +public class with_sql_server_sources : Specification +{ + static readonly Guid _prologueId = Guid.NewGuid(); + PrologueConfiguration _result; + + void Because() => _result = PrologueConfigurationBuilder.Build(new PrologueWizardInput( + _prologueId, + [ + new("main", "Server=main;Database=Library;", ["Authors", "Books"]), + new("audit", "Server=audit;Database=Audit;", []) + ], + [], + null, + null, + new(OutputKind.Json, "http://localhost:5005", "./captures"))); + + [Fact] void should_carry_the_prologue_id() => _result.Prologue.PrologueId.ShouldEqual(_prologueId); + [Fact] void should_have_both_sources() => _result.Prologue.SqlServer.Count.ShouldEqual(2); + [Fact] void should_carry_the_first_source_name() => _result.Prologue.SqlServer[0].Name.ShouldEqual("main"); + [Fact] void should_carry_the_first_source_connection_string() => _result.Prologue.SqlServer[0].ConnectionString.ShouldEqual("Server=main;Database=Library;"); + [Fact] void should_carry_the_first_source_tables() => _result.Prologue.SqlServer[0].Tables.ShouldEqual("Authors", "Books"); + [Fact] void should_leave_the_second_source_tables_empty_for_all_tables() => _result.Prologue.SqlServer[1].Tables.ShouldBeEmpty(); + [Fact] void should_not_have_postgres_sources() => _result.Prologue.Postgres.ShouldBeEmpty(); + [Fact] void should_not_have_a_reverse_proxy() => _result.ReverseProxy.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_empty_value.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_empty_value.cs new file mode 100644 index 0000000..987960e --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_empty_value.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationBuilder.when_normalizing_base_path; + +public class with_empty_value : Specification +{ + string _result; + + void Because() => _result = PrologueConfigurationBuilder.NormalizeBasePath(" "); + + [Fact] void should_fall_back_to_the_default_base_path() => _result.ShouldEqual(PrologueConfigurationBuilder.DefaultApiBasePath); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_missing_leading_slash.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_missing_leading_slash.cs new file mode 100644 index 0000000..1aa9f79 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_missing_leading_slash.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationBuilder.when_normalizing_base_path; + +public class with_missing_leading_slash : Specification +{ + string _result; + + void Because() => _result = PrologueConfigurationBuilder.NormalizeBasePath("api"); + + [Fact] void should_add_the_leading_slash() => _result.ShouldEqual("/api"); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_trailing_slash.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_trailing_slash.cs new file mode 100644 index 0000000..195986e --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_normalizing_base_path/with_trailing_slash.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationBuilder.when_normalizing_base_path; + +public class with_trailing_slash : Specification +{ + string _result; + + void Because() => _result = PrologueConfigurationBuilder.NormalizeBasePath("/api/"); + + [Fact] void should_trim_the_trailing_slash() => _result.ShouldEqual("/api"); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_comma_separated_values.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_comma_separated_values.cs new file mode 100644 index 0000000..06d8897 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_comma_separated_values.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationBuilder.when_parsing_list; + +public class with_comma_separated_values : Specification +{ + IReadOnlyList _result; + + void Because() => _result = PrologueConfigurationBuilder.ParseList(" Authors, Books , ,Loans "); + + [Fact] void should_trim_and_drop_empty_entries() => _result.ShouldEqual("Authors", "Books", "Loans"); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_empty_value.cs b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_empty_value.cs new file mode 100644 index 0000000..1a3a65f --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationBuilder/when_parsing_list/with_empty_value.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationBuilder.when_parsing_list; + +public class with_empty_value : Specification +{ + IReadOnlyList _result; + + void Because() => _result = PrologueConfigurationBuilder.ParseList(null); + + [Fact] void should_be_empty() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationFiles/given/a_temporary_folder.cs b/Source/Cli.Specs/for_PrologueConfigurationFiles/given/a_temporary_folder.cs new file mode 100644 index 0000000..a7a8062 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationFiles/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// 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.for_PrologueConfigurationFiles.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_current_directory_only.cs b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_current_directory_only.cs new file mode 100644 index 0000000..2a981e7 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_current_directory_only.cs @@ -0,0 +1,20 @@ +// 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.for_PrologueConfigurationFiles.when_finding; + +public class with_configuration_in_the_current_directory_only : given.a_temporary_folder +{ + string _captures; + string? _result; + + void Establish() + { + _captures = Directory.CreateDirectory(Path.Combine(_folder, "captures")).FullName; + File.WriteAllText(Path.Combine(_folder, "cratis-prologue.json"), "{}"); + } + + void Because() => _result = PrologueConfigurationFiles.Find(_captures, _folder); + + [Fact] void should_fall_back_to_the_current_directory() => _result.ShouldEqual(Path.Combine(_folder, "cratis-prologue.json")); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_path_folder.cs b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_path_folder.cs new file mode 100644 index 0000000..cda8814 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/with_configuration_in_the_path_folder.cs @@ -0,0 +1,20 @@ +// 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.for_PrologueConfigurationFiles.when_finding; + +public class with_configuration_in_the_path_folder : given.a_temporary_folder +{ + string _captures; + string? _result; + + void Establish() + { + _captures = Directory.CreateDirectory(Path.Combine(_folder, "captures")).FullName; + File.WriteAllText(Path.Combine(_captures, "cratis-prologue.json"), "{}"); + } + + void Because() => _result = PrologueConfigurationFiles.Find(_captures, _folder); + + [Fact] void should_find_the_configuration_in_the_path_folder() => _result.ShouldEqual(Path.Combine(_captures, "cratis-prologue.json")); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/without_any_configuration.cs b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/without_any_configuration.cs new file mode 100644 index 0000000..1961697 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_finding/without_any_configuration.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationFiles.when_finding; + +public class without_any_configuration : given.a_temporary_folder +{ + string? _result; + + void Because() => _result = PrologueConfigurationFiles.Find(null, _folder); + + [Fact] void should_not_find_a_configuration() => _result.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_directory.cs b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_directory.cs new file mode 100644 index 0000000..728c697 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_directory.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationFiles.when_resolving_output_path; + +public class with_a_directory : given.a_temporary_folder +{ + string _result; + + void Because() => _result = PrologueConfigurationFiles.ResolveOutputPath(_folder, "/work"); + + [Fact] void should_place_the_well_known_file_within_the_directory() => _result.ShouldEqual(Path.Combine(_folder, "cratis-prologue.json")); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_file_path.cs b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_file_path.cs new file mode 100644 index 0000000..385f9ed --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/with_a_file_path.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationFiles.when_resolving_output_path; + +public class with_a_file_path : Specification +{ + string _result; + + void Because() => _result = PrologueConfigurationFiles.ResolveOutputPath("configs/my-prologue.json", "/work"); + + [Fact] void should_resolve_the_file_against_the_current_directory() => _result.ShouldEqual(Path.Combine("/work", "configs", "my-prologue.json")); +} diff --git a/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/without_a_file.cs b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/without_a_file.cs new file mode 100644 index 0000000..52a7346 --- /dev/null +++ b/Source/Cli.Specs/for_PrologueConfigurationFiles/when_resolving_output_path/without_a_file.cs @@ -0,0 +1,13 @@ +// 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.for_PrologueConfigurationFiles.when_resolving_output_path; + +public class without_a_file : Specification +{ + string _result; + + void Because() => _result = PrologueConfigurationFiles.ResolveOutputPath(null, "/work"); + + [Fact] void should_default_to_the_well_known_file_in_the_current_directory() => _result.ShouldEqual(Path.Combine("/work", "cratis-prologue.json")); +} diff --git a/Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/with_explicit_file.cs b/Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/with_explicit_file.cs new file mode 100644 index 0000000..cc609fa --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/with_explicit_file.cs @@ -0,0 +1,13 @@ +// 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.for_ScreenplayOutput.when_resolving_path; + +public class with_explicit_file : Specification +{ + string _result; + + void Because() => _result = ScreenplayOutput.ResolvePath("plays/MySystem.play", "Library", "/work"); + + [Fact] void should_resolve_the_file_against_the_current_directory() => _result.ShouldEqual(Path.Combine("/work", "plays", "MySystem.play")); +} diff --git a/Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/without_file.cs b/Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/without_file.cs new file mode 100644 index 0000000..31232e6 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayOutput/when_resolving_path/without_file.cs @@ -0,0 +1,13 @@ +// 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.for_ScreenplayOutput.when_resolving_path; + +public class without_file : Specification +{ + string _result; + + void Because() => _result = ScreenplayOutput.ResolvePath(null, "library system", "/work"); + + [Fact] void should_derive_the_file_from_the_system_name_in_the_current_directory() => _result.ShouldEqual(Path.Combine("/work", "LibrarySystem.play")); +} From 161d0c3b23683d767105b5a8941edeb13c8f2c88 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 12:06:24 +0200 Subject: [PATCH 09/11] Document the prologue command group Co-Authored-By: Claude Opus 4.8 (1M context) --- Documentation/reference/index.md | 1 + Documentation/reference/prologue.md | 73 +++++++++++++++++++++++++++++ Documentation/reference/toc.yml | 2 + 3 files changed, 76 insertions(+) create mode 100644 Documentation/reference/prologue.md diff --git a/Documentation/reference/index.md b/Documentation/reference/index.md index 8f41d29..1d295b7 100644 --- a/Documentation/reference/index.md +++ b/Documentation/reference/index.md @@ -5,6 +5,7 @@ This section documents the cross-cutting mechanics of the `cratis` CLI: the flag ## Topics - [LLM](llm.md) — Configuring the language model that Cratis tools like Prologue use. +- [Prologue](prologue.md) — Capturing a running system and interpreting the captures into a Screenplay. - [Global Options](global-options.md) — Flags such as `--output`, `--quiet`, `--yes`, and `--debug` that apply to every command. - [Output Formats](output-formats.md) — The four output formats (`table`, `plain`, `json`, `json-compact`) with guidance on when to use each and token cost comparisons. - [Connection](connection.md) — Connection string format, resolution order, and environment variable configuration. diff --git a/Documentation/reference/prologue.md b/Documentation/reference/prologue.md new file mode 100644 index 0000000..b0b6e99 --- /dev/null +++ b/Documentation/reference/prologue.md @@ -0,0 +1,73 @@ +# Prologue + +`cratis prologue` captures what a running system *does* and interprets it into a Cratis Screenplay. [Prologue](https://github.com/Cratis/Prologue) points its extractor at an ordinary system — no Cratis constructs required — observes the HTTP commands, database changes, and telemetry flowing through it, and the CLI turns those captures into a `.play` file you continue authoring from. + +```bash +cratis prologue start +cratis prologue interpret [PATH] +``` + +The typical flow: `start` writes a `cratis-prologue.json` capture configuration, you run the Prologue extractor container next to your system while exercising it, and `interpret` reads the captured `.jsonl` files and produces the Screenplay. + +## `cratis prologue start` + +An interactive wizard that produces the `cratis-prologue.json` configuration the Prologue extractor reads. It generates a fresh Prologue id and walks through: + +1. **Sources** — a multi-select of what to capture: + - **SQL Server** — one or more databases, each with a name, connection string, and an optional table allowlist (comma separated, empty captures every user table). The extractor enables Change Data Capture itself. + - **PostgreSQL** — one or more databases, each with a name and connection string. Captured through logical replication with default slot and publication names. + - **API** — the extractor sits in front of your system as a reverse proxy and observes the state-changing HTTP commands (`POST`, `PUT`, `DELETE`) flowing through it. You provide the base path (default `/api`) and the address of your system. + - **OpenTelemetry** — the extractor acts as an OTLP collector (HTTP and gRPC). You provide service names to capture (empty captures all), attribute keys whose values are captured, and optional upstream collectors to forward telemetry to (empty makes it a terminal collector). +2. **Output** — where captured data goes: rolling JSON capture files (default directory `./captures`, ready for `cratis prologue interpret`) or the Prologue Receiver API (default endpoint `http://localhost:5005`). + +When done it writes the file, prints a summary table, and shows how to run the extractor container. + +| Option | Description | +|---|---| +| `--file ` | Where to write the configuration — a file path or a directory. Defaults to `cratis-prologue.json` in the current directory. | + +```bash +cratis prologue start +cratis prologue start --file ./my-system +``` + +The wizard requires an interactive terminal. In CI, with piped input, or with `-y/--yes` it fails with a validation error — write the configuration by hand instead. + +## `cratis prologue interpret [PATH]` + +Reads Prologue capture (`.jsonl`) files and interprets them into a Screenplay. Deterministic heuristics build the event model's structure from the evidence — commands, events, read models, projections, and constraints per module, feature, and slice. When a language model is configured it refines the names into domain language, derives the system name, and may ask you clarifying questions. + +| Argument | Description | +|---|---| +| `PATH` | Folder holding the capture (`.jsonl`) files. Defaults to the configured JSON output directory when a `cratis-prologue.json` is found in `PATH` or the current directory, otherwise the current directory. | + +| Option | Description | +|---|---| +| `--file ` | File to write the generated Screenplay to. Defaults to `.play` in the current directory. | +| `--prologue-id ` | The Prologue the captures belong to. Defaults from `cratis-prologue.json` when one is present. | + +```bash +cratis prologue interpret +cratis prologue interpret ./captures +cratis prologue interpret ./captures --file MySystem.play +``` + +On success it prints a panel with the written path, the derived system name, and the module/feature/slice counts — and the natural next step is `cratis run` to boot the Screenplay in a local [Stage](run.md) sandbox. + +### Language model refinement + +The language model is resolved in this order: + +1. The `llm` section of a found `cratis-prologue.json`, when it is enabled there. +2. The `llm` section of `~/.cratis/config.json`, written by [`cratis llm use`](llm.md) — `anthropic`, `openai`, or `local` (OpenAI-compatible). +3. Neither configured — interpretation runs with heuristics only and an info line points at `cratis llm use`. + +When the language model is genuinely uncertain about a decision that materially changes the model, it asks questions — one at a time, each with its background context, a list of choices, and always an "Other" entry for typing your own answer. Questions are only asked in an interactive terminal; non-interactive runs (CI, piped output, `-y/--yes`) never ask and finalize with the model's best effort. + +## Errors + +| Condition | Result | +|---|---| +| `start` without an interactive terminal, or with `--yes` | Validation error — the wizard needs a terminal. | +| `interpret` finds no capture (`.jsonl`) files in the folder | Not-found error with a hint to run the extractor with JSON output. | +| Interpretation fails | Server error carrying the session's error message. | diff --git a/Documentation/reference/toc.yml b/Documentation/reference/toc.yml index 9522de8..ccb00da 100644 --- a/Documentation/reference/toc.yml +++ b/Documentation/reference/toc.yml @@ -2,6 +2,8 @@ href: run.md - name: LLM href: llm.md +- name: Prologue + href: prologue.md - name: Global Options href: global-options.md - name: Output Formats From b18e18829fa4793c887807bdedf6d65cab2b4557 Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 12:32:20 +0200 Subject: [PATCH 10/11] Bump Prologue packages to 1.1.1 and Screenplay to 1.4.0 Picks up command descriptions on the extraction contract and the multi-line description support in the Screenplay language. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 094a181..dec2e96 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,12 +9,12 @@ - - - - - - + + + + + + From ee62208c8e75ca7c0bce87d60342137e74f8612d Mon Sep 17 00:00:00 2001 From: Einar Date: Thu, 23 Jul 2026 13:26:42 +0200 Subject: [PATCH 11/11] Align Prologue package pins with the 1.1.0 release Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index dec2e96..2e0b32d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,11 +9,11 @@ - - - - - + + + + +