diff --git a/shell/AIShell.Abstraction/IShell.cs b/shell/AIShell.Abstraction/IShell.cs index 188ba85b..bd638465 100644 --- a/shell/AIShell.Abstraction/IShell.cs +++ b/shell/AIShell.Abstraction/IShell.cs @@ -15,6 +15,13 @@ public interface IShell /// CancellationToken CancellationToken { get; } + /// + /// Extracts code blocks that are surrounded by code fences from the passed-in markdown text. + /// + /// The markdown text. + /// A list of code blocks or null if there is no code block. + List ExtractCodeBlocks(string text, out List sourceInfos); + // TODO: // - methods to run code: python, command-line, powershell, node-js. // - methods to communicate with shell client. diff --git a/shell/AIShell.Abstraction/IStreamRender.cs b/shell/AIShell.Abstraction/IStreamRender.cs index d5f2d530..d26116fc 100644 --- a/shell/AIShell.Abstraction/IStreamRender.cs +++ b/shell/AIShell.Abstraction/IStreamRender.cs @@ -1,7 +1,18 @@ namespace AIShell.Abstraction; +/// +/// Represents a code block from a markdown text. +/// public record CodeBlock(string Code, string Language); +/// +/// Represents the source metadata information of a code block extracted from a given markdown text. +/// +/// The start index of the code block within the text. +/// The end index of the code block within the text. +/// Number of spaces for indentation used by the code block. +public record SourceInfo(int Start, int End, int Indents); + public interface IStreamRender : IDisposable { string AccumulatedContent { get; } diff --git a/shell/AIShell.Kernel/Render/StreamRender.cs b/shell/AIShell.Kernel/Render/StreamRender.cs index 726a5cb6..118badab 100644 --- a/shell/AIShell.Kernel/Render/StreamRender.cs +++ b/shell/AIShell.Kernel/Render/StreamRender.cs @@ -19,7 +19,7 @@ internal DummyStreamRender(CancellationToken token) public string AccumulatedContent => _buffer.ToString(); - public List CodeBlocks => Utils.ExtractCodeBlocks(_buffer.ToString()); + public List CodeBlocks => Utils.ExtractCodeBlocks(_buffer.ToString(), out _); public void Refresh(string newChunk) { diff --git a/shell/AIShell.Kernel/Shell.cs b/shell/AIShell.Kernel/Shell.cs index b2783d21..bd2bdb6f 100644 --- a/shell/AIShell.Kernel/Shell.cs +++ b/shell/AIShell.Kernel/Shell.cs @@ -78,6 +78,7 @@ internal sealed class Shell : IShell IHost IShell.Host => Host; CancellationToken IShell.CancellationToken => _cancellationSource.Token; + List IShell.ExtractCodeBlocks(string text, out List sourceInfos) => Utils.ExtractCodeBlocks(text, out sourceInfos); #endregion IShell implementation diff --git a/shell/AIShell.Kernel/Utility/Utils.cs b/shell/AIShell.Kernel/Utility/Utils.cs index e3cabf7c..0c224b5d 100644 --- a/shell/AIShell.Kernel/Utility/Utils.cs +++ b/shell/AIShell.Kernel/Utility/Utils.cs @@ -125,21 +125,63 @@ internal static bool Contains(string left, string right) } /// - /// Extract code blocks from the passed-in text. + /// Extracts code blocks that are surrounded by code fences from the passed-in markdown text. /// - internal static List ExtractCodeBlocks(string text) + internal static List ExtractCodeBlocks(string text, out List sourceInfos) { + sourceInfos = null; + if (string.IsNullOrEmpty(text)) { return null; } int start, index = -1; + int codeBlockStart = -1, codeBlockIndents = -1; bool inCodeBlock = false; - string language = null; + string language = null, codeFenceInUse = null; StringBuilder code = null; List codeBlocks = null; + bool CodeFenceStarts(ReadOnlySpan curLine) + { + const string BacktickFence = "```"; + const string TildeFence = "~~~"; + + if (inCodeBlock || curLine.IsEmpty) + { + return false; + } + + if (curLine.StartsWith(BacktickFence)) + { + inCodeBlock = true; + codeFenceInUse = BacktickFence; + return true; + } + + if (curLine.StartsWith(TildeFence)) + { + inCodeBlock = true; + codeFenceInUse = TildeFence; + return true; + } + + return false; + } + + bool CodeFenceEnds(ReadOnlySpan curLine) + { + if (inCodeBlock && curLine.SequenceEqual(codeFenceInUse)) + { + inCodeBlock = false; + codeFenceInUse = null; + return true; + } + + return false; + } + do { start = index + 1; @@ -156,33 +198,35 @@ internal static List ExtractCodeBlocks(string text) // Trim the line before checking for code fence. ReadOnlySpan lineTrimmed = line.Trim(); - if (lineTrimmed.StartsWith("```")) + + if (CodeFenceStarts(lineTrimmed)) { - if (inCodeBlock) - { - if (lineTrimmed.Length is 3) - { - // Current line is the ending code fence. - codeBlocks.Add(new CodeBlock(code.ToString(), language)); - - code.Clear(); - language = null; - inCodeBlock = false; - continue; - } - - // It's not the ending code fence, so keep appending to code. - code.Append(line); - } - else + // Current line is the starting code fence. + code ??= new StringBuilder(); + codeBlocks ??= []; + sourceInfos ??= []; + + language = lineTrimmed.Length > 3 ? lineTrimmed[3..].ToString() : null; + // No need to capture the code block start index if we already reached end of the text. + codeBlockStart = index is -1 ? -1 : index + 1; + codeBlockIndents = line.IndexOf(codeFenceInUse); + + continue; + } + + if (CodeFenceEnds(lineTrimmed)) + { + // Current line is the ending code fence. + if (code.Length > 0) { - // Current line is the starting code fence. - code ??= new StringBuilder(); - codeBlocks ??= []; - inCodeBlock = true; - language = lineTrimmed.Length > 3 ? lineTrimmed[3..].ToString() : null; + codeBlocks.Add(new CodeBlock(code.ToString(), language)); + sourceInfos.Add(new SourceInfo(codeBlockStart, start - 1, codeBlockIndents)); } + code.Clear(); + language = null; + codeBlockStart = codeBlockIndents = -1; + continue; } @@ -198,6 +242,7 @@ internal static List ExtractCodeBlocks(string text) { // It's possbile that the ending code fence is missing. codeBlocks.Add(new CodeBlock(code.ToString(), language)); + sourceInfos.Add(new SourceInfo(codeBlockStart, text.Length - 1, codeBlockIndents)); } return codeBlocks; diff --git a/shell/Markdown.VT/ColorCode.VT/Parser/Bash.cs b/shell/Markdown.VT/ColorCode.VT/Parser/Bash.cs index 63ce74fc..5a40b4b6 100644 --- a/shell/Markdown.VT/ColorCode.VT/Parser/Bash.cs +++ b/shell/Markdown.VT/ColorCode.VT/Parser/Bash.cs @@ -66,7 +66,8 @@ public bool HasAlias(string lang) { case "sh": return true; - + case "azurecli": + return true; default: return false; } diff --git a/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs b/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs index 6c6465f9..c46c22f0 100644 --- a/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs +++ b/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs @@ -1,37 +1,60 @@ -using AIShell.Abstraction; +using System.Diagnostics; +using System.Text; +using AIShell.Abstraction; namespace Microsoft.Azure.Agent; public sealed class AzureAgent : ILLMAgent { - public string Name => "Azure"; - public string Company => "Microsoft"; - public List SampleQueries => [ - "Create a VM with a public IP address", - "How to create a web app?", - "Backup an Azure SQL database to a storage container" - ]; - - public string Description { private set; get; } - public Dictionary LegalLinks { private set; get; } + public string Name { get; } + public string Company { get; } + public string Description { get; } + public List SampleQueries { get; } + public Dictionary LegalLinks { get; } public string SettingFile { private set; get; } + internal ArgumentPlaceholder ArgPlaceholder { set; get; } + private const string SettingFileName = "az.agent.json"; + private const string InstructionPrompt = """ + NOTE: follow the below instructions when generating responses that include Azure CLI commands with placeholders: + 1. User's OS is `{0}`. Make sure the generated commands are suitable for the specified OS. + 2. DO NOT include the command for creating a new resource group unless the query explicitly asks for it. Otherwise, assume a resource group already exists. + 3. DO NOT include an additional example with made-up values unless it provides additional context or value beyond the initial command. + 4. Always represent a placeholder in the form of ``. + 5. Always use the consistent placeholder names across all your responses. For example, `` should be used for all the places where a resource group name value is needed. + 6. When the commands contain placeholders, the placeholders should be summarized in markdown bullet points at the end of the response in the same order as they appear in the commands, following this format: + ``` + Placeholders: + - ``: + - ``: + ``` + 7. DO NOT include the placeholder summary when the commands contains no placeholder. + """; - private ChatSession _chatSession; private int _turnsLeft; + private readonly string _instructions; + private readonly StringBuilder _buffer; + private readonly ChatSession _chatSession; + private readonly Dictionary _valueStore; - public void Dispose() - { - _chatSession?.Dispose(); - } - - public void Initialize(AgentConfig config) + public AzureAgent() { + _buffer = new StringBuilder(); _chatSession = new ChatSession(); - _turnsLeft = int.MaxValue; + _valueStore = new Dictionary(StringComparer.OrdinalIgnoreCase); + _instructions = string.Format(InstructionPrompt, Environment.OSVersion.VersionString); + Name = "Azure"; + Company = "Microsoft"; Description = "This AI assistant can generate Azure CLI and Azure PowerShell commands for managing Azure resources, answer questions, and provides information tailored to your specific Azure environment."; + + SampleQueries = [ + "Create a VM with a public IP address", + "How to create a web app?", + "Backup an Azure SQL database to a storage container" + ]; + LegalLinks = new(StringComparer.OrdinalIgnoreCase) { ["Terms"] = "https://aka.ms/TermsofUseCopilot", @@ -39,11 +62,20 @@ public void Initialize(AgentConfig config) ["FAQ"] = "https://aka.ms/CopilotforAzureClientToolsFAQ", ["Transparency"] = "https://aka.ms/CopilotAzCLIPSTransparency", }; + } + + public void Dispose() + { + _chatSession?.Dispose(); + } + public void Initialize(AgentConfig config) + { + _turnsLeft = int.MaxValue; SettingFile = Path.Combine(config.ConfigurationRoot, SettingFileName); } - public IEnumerable GetCommands() => null; + public IEnumerable GetCommands() => [new ReplaceCommand(this)]; public bool CanAcceptFeedback(UserAction action) => false; public void OnUserAction(UserActionPayload actionPayload) {} @@ -66,9 +98,10 @@ public async Task ChatAsync(string input, IShell shell) try { + string query = $"{input}\n\n---\n\n{_instructions}"; CopilotResponse copilotResponse = await host.RunWithSpinnerAsync( status: "Thinking ...", - func: async context => await _chatSession.GetChatResponseAsync(input, context, token) + func: async context => await _chatSession.GetChatResponseAsync(query, context, token) ).ConfigureAwait(false); if (copilotResponse is null) @@ -79,7 +112,23 @@ public async Task ChatAsync(string input, IShell shell) if (copilotResponse.ChunkReader is null) { - host.RenderFullResponse(copilotResponse.Text); + ArgPlaceholder?.DataRetriever?.Dispose(); + ArgPlaceholder = null; + + // Process CLI handler response specially to support parameter injection. + ResponseData data = null; + if (copilotResponse.TopicName == CopilotActivity.CLIHandlerTopic) + { + data = ParseCLIHandlerResponse(copilotResponse, shell); + } + + string answer = data is null ? copilotResponse.Text : GenerateAnswer(data); + if (data?.PlaceholderSet is not null) + { + ArgPlaceholder = new ArgumentPlaceholder(input, data); + } + + host.RenderFullResponse(answer); } else { @@ -138,4 +187,217 @@ public async Task ChatAsync(string input, IShell shell) return true; } + + private ResponseData ParseCLIHandlerResponse(CopilotResponse copilotResponse, IShell shell) + { + string text = copilotResponse.Text; + List codeBlocks = shell.ExtractCodeBlocks(text, out List sourceInfos); + if (codeBlocks is null || codeBlocks.Count is 0) + { + return null; + } + + Debug.Assert(codeBlocks.Count == sourceInfos.Count, "There should be 1-to-1 mapping for code block and its source info."); + + HashSet phSet = null; + List placeholders = null; + List commands = new(capacity: codeBlocks.Count); + + for (int i = 0; i < codeBlocks.Count; i++) + { + string script = codeBlocks[i].Code; + commands.Add(new CommandItem { SourceInfo = sourceInfos[i], Script = script }); + + // Go through all code blocks to find placeholders. Placeholder is in the `` form. + int start = -1; + for (int k = 0; k < script.Length; k++) + { + char c = script[k]; + if (c is '<') + { + start = k; + } + else if (c is '>' && start > -1) + { + placeholders ??= []; + phSet ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + string ph = script[start..(k+1)]; + if (phSet.Add(ph)) + { + placeholders.Add(new PlaceholderItem { Name = ph, Desc = ph, Type = "string" }); + } + + start = -1; + } + } + } + + if (placeholders is null) + { + return null; + } + + ResponseData data = new() { + Text = text, + CommandSet = commands, + PlaceholderSet = placeholders, + Locale = copilotResponse.Locale, + }; + + string first = placeholders[0].Name; + int begin = sourceInfos[^1].End + 1; + + // We instruct Az Copilot to summarize placeholders in the fixed format shown below. + // So, we assume the response will adhere to this format and parse the text based on it. + // Placeholders: + // - ``: + // - ``: + const string pattern = "- `{0}`:"; + int index = text.IndexOf(string.Format(pattern, first), begin); + if (index > 0 && text[index - 1] is '\n' && text[index - 2] is ':') + { + // Get the start index of the placeholder section. + int n = index - 2; + for (; text[n] is not '\n'; n--); + begin = n + 1; + + // For each placeholder, try to extract its description. + foreach (var phItem in placeholders) + { + string key = string.Format(pattern, phItem.Name); + index = text.IndexOf(key, begin); + if (index > 0) + { + // Extract out the description of the particular placeholder. + int i = index + key.Length, k = i; + for (; k < text.Length && text[k] is not '\n'; k++); + var desc = text.AsSpan(i, k - i).Trim(); + if (desc.Length > 0) + { + phItem.Desc = desc.ToString(); + } + } + } + + data.Text = text[0..begin]; + } + else + { + // The placeholder section is not in the format as we've instructed ... + // TODO: send telemetry about this case. + } + + ReplaceKnownPlaceholders(data); + return data; + } + + internal void SaveUserValue(string phName, string value) + { + ArgumentException.ThrowIfNullOrEmpty(phName); + ArgumentException.ThrowIfNullOrEmpty(value); + + _valueStore[phName] = value; + } + + internal void ReplaceKnownPlaceholders(ResponseData data) + { + List placeholders = data.PlaceholderSet; + if (_valueStore.Count is 0 || placeholders is null) + { + return; + } + + List indices = null; + Dictionary pairs = null; + + for (int i = 0; i < placeholders.Count; i++) + { + PlaceholderItem item = placeholders[i]; + if (_valueStore.TryGetValue(item.Name, out string value)) + { + indices ??= []; + pairs ??= []; + + indices.Add(i); + pairs.Add(item.Name, value); + } + } + + if (pairs is null) + { + return; + } + + foreach (CommandItem command in data.CommandSet) + { + foreach (var entry in pairs) + { + string script = command.Script; + command.Script = script.Replace(entry.Key, entry.Value, StringComparison.OrdinalIgnoreCase); + command.Updated = !ReferenceEquals(script, command.Script); + } + } + + if (pairs.Count == placeholders.Count) + { + data.PlaceholderSet = null; + } + else + { + for (int i = indices.Count - 1; i >= 0; i--) + { + placeholders.RemoveAt(indices[i]); + } + } + } + + internal string GenerateAnswer(ResponseData data) + { + _buffer.Clear(); + string text = data.Text; + + int index = 0; + foreach (CommandItem item in data.CommandSet) + { + if (item.Updated) + { + _buffer.Append(text.AsSpan(index, item.SourceInfo.Start - index)); + _buffer.Append(item.Script); + index = item.SourceInfo.End + 1; + } + } + + if (index is 0) + { + _buffer.Append(text); + } + else if (index < text.Length) + { + _buffer.Append(text.AsSpan(index, text.Length - index)); + } + + if (data.PlaceholderSet is not null) + { + // Construct text about the placeholders if we successfully stripped the placeholder + // section off from the original response. + // + // TODO: Note that the original response could be in a different locale, and in + // that case, we should be using a localized resource string based on the locale. + // For now, we just hard code with English strings. + var first = data.PlaceholderSet[0]; + if (first.Name != first.Desc) + { + _buffer.Append("\nReplace the placeholders with your specific values:\n"); + foreach (var phItem in data.PlaceholderSet) + { + _buffer.Append($"- `{phItem.Name}`: {phItem.Desc}\n"); + } + + _buffer.Append("\nRun `/replace` to get assistance in placeholder replacement.\n"); + } + } + + return _buffer.ToString(); + } } diff --git a/shell/agents/Microsoft.Azure.Agent/ChatSession.cs b/shell/agents/Microsoft.Azure.Agent/ChatSession.cs index 6e45637a..82165af9 100644 --- a/shell/agents/Microsoft.Azure.Agent/ChatSession.cs +++ b/shell/agents/Microsoft.Azure.Agent/ChatSession.cs @@ -278,8 +278,8 @@ internal async Task GetChatResponseAsync(string input, IStatusC { CopilotResponse ret = activity.InputHint switch { - "typing" => new CopilotResponse(new ChunkReader(_copilotReceiver, activity), activity.TopicName), - "acceptingInput" => new CopilotResponse(activity.Text, activity.TopicName), + "typing" => new CopilotResponse(new ChunkReader(_copilotReceiver, activity), activity.Locale, activity.TopicName), + "acceptingInput" => new CopilotResponse(activity.Text, activity.Locale, activity.TopicName), _ => throw CorruptDataException.Create($"The 'inputHint' is {activity.InputHint}.", activity) }; diff --git a/shell/agents/Microsoft.Azure.Agent/Command.cs b/shell/agents/Microsoft.Azure.Agent/Command.cs new file mode 100644 index 00000000..edfb2077 --- /dev/null +++ b/shell/agents/Microsoft.Azure.Agent/Command.cs @@ -0,0 +1,241 @@ +using System.CommandLine; +using System.Text; +using AIShell.Abstraction; + +namespace Microsoft.Azure.Agent; + +internal sealed class ReplaceCommand : CommandBase +{ + private readonly AzureAgent _agent; + private readonly Dictionary _values; + private readonly HashSet _productNames; + private readonly HashSet _environmentNames; + + public ReplaceCommand(AzureAgent agent) + : base("replace", "Replace argument placeholders in the generated scripts with the real value.") + { + _agent = agent; + _values = []; + _productNames = []; + _environmentNames = []; + + this.SetHandler(ReplaceAction); + } + + private static string SyntaxHighlightAzCommand(string command, string parameter, string placeholder) + { + const string vtItalic = "\x1b[3m"; + const string vtCommand = "\x1b[93m"; + const string vtParameter = "\x1b[90m"; + const string vtVariable = "\x1b[92m"; + const string vtFgDefault = "\x1b[39m"; + const string vtReset = "\x1b[0m"; + + StringBuilder cStr = new(capacity: command.Length + parameter.Length + placeholder.Length + 50); + cStr.Append(vtItalic) + .Append(vtCommand).Append("az").Append(vtFgDefault).Append(command.AsSpan(2)).Append(' ') + .Append(vtParameter).Append(parameter).Append(vtFgDefault).Append(' ') + .Append(vtVariable).Append(placeholder).Append(vtFgDefault) + .Append(vtReset); + + return cStr.ToString(); + } + + private void ReplaceAction() + { + _values.Clear(); + _productNames.Clear(); + _environmentNames.Clear(); + + IHost host = Shell.Host; + ArgumentPlaceholder ap = _agent.ArgPlaceholder; + + if (ap is null) + { + host.WriteErrorLine("No argument placeholder to replace."); + return; + } + + DataRetriever dataRetriever = ap.DataRetriever; + List items = ap.ResponseData.PlaceholderSet; + string subText = items.Count > 1 + ? $"all {items.Count} argument placeholders" + : "the argument placeholder"; + host.WriteLine($"\nWe'll provide assistance in replacing {subText} and regenerating the result. You can press 'Enter' to skip to the next parameter or press 'Ctrl+c' to exit the assistance.\n"); + host.RenderDivider("Input Values", DividerAlignment.Left); + host.WriteLine(); + + try + { + for (int i = 0; i < items.Count; i++) + { + var item = items[i]; + var (command, parameter) = dataRetriever.GetMappedCommand(item.Name); + + string desc = item.Desc.TrimEnd('.'); + string coloredCmd = parameter is null ? null : SyntaxHighlightAzCommand(command, parameter, item.Name); + string cmdPart = coloredCmd is null ? null : $" [{coloredCmd}]"; + + host.WriteLine(item.Type is "string" + ? $"{i+1}. {desc}{cmdPart}" + : $"{i+1}. {desc}{cmdPart}. Value type: {item.Type}"); + + // Get the task for creating the 'ArgumentInfo' object and show a spinner + // if we have to wait for the task to complete. + Task argInfoTask = dataRetriever.GetArgInfo(item.Name); + ArgumentInfo argInfo = argInfoTask.IsCompleted + ? argInfoTask.Result + : host.RunWithSpinnerAsync( + () => WaitForArgInfoAsync(argInfoTask), + status: $"Requesting data for '{item.Name}' ...", + SpinnerKind.Processing).GetAwaiter().GetResult(); + + argInfo ??= new ArgumentInfo(item.Name, item.Desc, Enum.Parse(item.Type)); + + // Write out restriction for this argument if there is any. + if (!string.IsNullOrEmpty(argInfo.Restriction)) + { + host.WriteLine(argInfo.Restriction); + } + + ArgumentInfoWithNamingRule nameArgInfo = null; + if (argInfo is ArgumentInfoWithNamingRule v) + { + nameArgInfo = v; + SuggestForResourceName(nameArgInfo.NamingRule, nameArgInfo.Suggestions); + } + + // Prompt for argument without printing captions again. + string value = host.PromptForArgument(argInfo, printCaption: false); + if (!string.IsNullOrEmpty(value)) + { + _values.Add(item.Name, value); + _agent.SaveUserValue(item.Name, value); + + if (nameArgInfo is not null && nameArgInfo.NamingRule.TryMatchName(value, out string prodName, out string envName)) + { + _productNames.Add(prodName.ToLower()); + _environmentNames.Add(envName.ToLower()); + } + } + + // Write an extra new line. + host.WriteLine(); + } + } + catch (OperationCanceledException) + { + bool proceed = false; + if (_values.Count > 0) + { + host.WriteLine(); + proceed = host.PromptForConfirmationAsync( + "Would you like to regenerate with the provided values so far?", + defaultValue: false, + CancellationToken.None).GetAwaiter().GetResult(); + host.WriteLine(); + } + + if (!proceed) + { + host.WriteLine(); + return; + } + } + + if (_values.Count > 0) + { + host.RenderDivider("Summary", DividerAlignment.Left); + host.WriteLine("\nThe following placeholders will be replace:"); + host.RenderList(_values); + + host.RenderDivider("Regenerate", DividerAlignment.Left); + host.MarkupLine($"\nQuery: [teal]{ap.Query}[/]"); + + try + { + string answer = host.RunWithSpinnerAsync(RegenerateAsync).GetAwaiter().GetResult(); + host.RenderFullResponse(answer); + } + catch (OperationCanceledException) + { + // User cancelled the operation. + } + } + else + { + host.WriteLine("No value was specified for any of the argument placeholders."); + } + } + + private void SuggestForResourceName(NamingRule rule, IList suggestions) + { + if (_productNames.Count is 0) + { + return; + } + + foreach (string prodName in _productNames) + { + if (_environmentNames.Count is 0) + { + suggestions.Add($"{prodName}-{rule.Abbreviation}"); + continue; + } + + foreach (string envName in _environmentNames) + { + suggestions.Add($"{prodName}-{rule.Abbreviation}-{envName}"); + } + } + } + + private async Task WaitForArgInfoAsync(Task argInfoTask) + { + var token = Shell.CancellationToken; + var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + + // Do not let the user wait for more than 2 seconds. + var delayTask = Task.Delay(2000, cts.Token); + var completedTask = await Task.WhenAny(argInfoTask, delayTask); + + if (completedTask == delayTask) + { + if (delayTask.IsCanceled) + { + // User cancelled the operation. + throw new OperationCanceledException(token); + } + + // Timed out. Last try to see if it finished. Otherwise, return null. + return argInfoTask.IsCompletedSuccessfully ? argInfoTask.Result : null; + } + + // Finished successfully, so we cancel the delay task and return the result. + cts.Cancel(); + return argInfoTask.Result; + } + + /// + /// We use the pseudo values to regenerate the response data, so that real values will never go off the user's box. + /// + /// + private async Task RegenerateAsync() + { + ArgumentPlaceholder ap = _agent.ArgPlaceholder; + + // We are doing the replacement locally, but want to fake the regeneration. + await Task.Delay(2000, Shell.CancellationToken); + + ResponseData data = ap.ResponseData; + _agent.ReplaceKnownPlaceholders(data); + + if (data.PlaceholderSet is null) + { + _agent.ArgPlaceholder.DataRetriever.Dispose(); + _agent.ArgPlaceholder = null; + } + + return _agent.GenerateAnswer(data); + } +} diff --git a/shell/agents/Microsoft.Azure.Agent/DataRetriever.cs b/shell/agents/Microsoft.Azure.Agent/DataRetriever.cs new file mode 100644 index 00000000..c3befa9b --- /dev/null +++ b/shell/agents/Microsoft.Azure.Agent/DataRetriever.cs @@ -0,0 +1,781 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using AIShell.Abstraction; + +namespace Microsoft.Azure.Agent; + +internal class DataRetriever : IDisposable +{ + private static readonly Dictionary s_azNamingRules; + private static readonly ConcurrentDictionary s_azStaticDataCache; + + private readonly string _staticDataRoot; + private readonly Task _rootTask; + private readonly SemaphoreSlim _semaphore; + private readonly List _placeholders; + private readonly Dictionary _placeholderMap; + + private bool _stop; + + static DataRetriever() + { + List rules = [ + new("API Management Service", + "apim", + "The name only allows alphanumeric characters and hyphens, and the first character must be a letter. Length: 1 to 50 chars.", + "az apim create --name", + "New-AzApiManagement -Name"), + + new("Function App", + "func", + "The name only allows alphanumeric characters and hyphens, and cannot start or end with a hyphen. Length: 2 to 60 chars.", + "az functionapp create --name", + "New-AzFunctionApp -Name"), + + new("App Service Plan", + "asp", + "The name only allows alphanumeric characters and hyphens. Length: 1 to 60 chars.", + "az appservice plan create --name", + "New-AzAppServicePlan -Name"), + + new("Web App", + "web", + "The name only allows alphanumeric characters and hyphens. Length: 2 to 43 chars.", + "az webapp create --name", + "New-AzWebApp -Name"), + + new("Application Gateway", + "agw", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 - 80 chars.", + "az network application-gateway create --name", + "New-AzApplicationGateway -Name"), + + new("Application Insights", + "ai", + "The name only allows alphanumeric characters, underscores, periods, hyphens and parenthesis, and cannot end in a period. Length: 1 to 255 chars.", + "az monitor app-insights component create --app", + "New-AzApplicationInsights -Name"), + + new("Application Security Group", + "asg", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network asg create --name", + "New-AzApplicationSecurityGroup -Name"), + + new("Automation Account", + "aa", + "The name only allows alphanumeric characters and hyphens, and cannot start or end with a hyphen. Length: 6 to 50 chars.", + "az automation account create --name", + "New-AzAutomationAccount -Name"), + + new("Availability Set", + "as", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az vm availability-set create --name", + "New-AzAvailabilitySet -Name"), + + new("Redis Cache", + "redis", + "The name only allows alphanumeric characters and hyphens, and cannot start or end with a hyphen. Consecutive hyphens are not allowed. Length: 1 to 63 chars.", + "az redis create --name", + "New-AzRedisCache -Name"), + + new("Cognitive Service", + "cogs", + "The name only allows alphanumeric characters and hyphens, and cannot start or end with a hyphen. Length: 2 to 64 chars.", + "az cognitiveservices account create --name", + "New-AzCognitiveServicesAccount -Name"), + + new("Cosmos DB", + "cosmos", + "The name only allows lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen. Length: 3 to 44 chars.", + "az cosmosdb create --name", + "New-AzCosmosDBAccount -Name"), + + new("Event Hubs Namespace", + "eh", + "The name only allows alphanumeric characters and hyphens. It must start with a letter and end with a letter or number. Length: 6 to 50 chars.", + "az eventhubs namespace create --name", + "New-AzEventHubNamespace -Name"), + + new("Event Hubs", + abbreviation: null, + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start and end with a letter or number. Length: 1 to 256 chars.", + "az eventhubs eventhub create --name", + "New-AzEventHub -Name"), + + new("Key Vault", + "kv", + "The name only allows alphanumeric characters and hyphens. It must start with a letter and end with a letter or number. Consecutive hyphens are not allowed. Length: 3 to 24 chars.", + "az keyvault create --name", + "New-AzKeyVault -Name"), + + new("Load Balancer", + "lb", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network lb create --name", + "New-AzLoadBalancer -Name"), + + new("Log Analytics workspace", + "la", + "The name only allows alphanumeric characters and hyphens, and cannot start or end with a hyphen. Length: 4 to 63 chars.", + "az monitor log-analytics workspace create --name", + "New-AzOperationalInsightsWorkspace -Name"), + + new("Logic App", + "lapp", + "The name only allows alphanumeric characters and hyphens, and cannot start or end with a hyphen. Length: 2 to 64 chars.", + "az logic workflow create --name", + "New-AzLogicApp -Name"), + + new("Machine Learning workspace", + "mlw", + "The name only allows alphanumeric characters, underscores, and hyphens. It must start with a letter or number. Length: 3 to 33 chars.", + "az ml workspace create --name", + "New-AzMLWorkspace -Name"), + + new("Network Interface", + "nic", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 2 to 64 chars.", + "az network nic create --name", + "New-AzNetworkInterface -Name"), + + new("Network Security Group", + "nsg", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 2 to 64 chars.", + "az network nsg create --name", + "New-AzNetworkSecurityGroup -Name"), + + new("Notification Hub Namespace", + "nh", + "The name only allows alphanumeric characters and hyphens. It must start with a letter and end with a letter or number. Length: 6 to 50 chars.", + "az notification-hub namespace create --name", + "New-AzNotificationHubsNamespace -Namespace"), + + new("Notification Hub", + abbreviation: null, + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start and end with a letter or number. Length: 1 to 260 chars.", + "az notification-hub create --name", + "New-AzNotificationHub -Name"), + + new("Public IP address", + "pip", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network public-ip create --name", + "New-AzPublicIpAddress -Name"), + + new("Resource Group", + "rg", + "Resource group names can only include alphanumeric, underscore, parentheses, hyphen, period (except at end), and Unicode characters that match the allowed characters. Length: 1 to 90 chars.", + "az group create --name", + "New-AzResourceGroup -Name"), + + new("Route table", + "rt", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start and end with a letter or number. Length: 1 to 80 chars.", + "az network route-table create --name", + "New-AzRouteTable -Name"), + + new("Search Service", + "srch", + "Service name must only contain lowercase letters, digits or dashes, cannot use dash as the first two or last one characters, and cannot contain consecutive dashes. Length: 2 to 60 chars.", + "az search service create --name", + "New-AzSearchService -Name"), + + new("Service Bus Namespace", + "sb", + "The name only allows alphanumeric characters and hyphens. It must start with a letter and end with a letter or number. Length: 6 to 50 chars.", + "az servicebus namespace create --name", + "New-AzServiceBusNamespace -Name"), + + new("Service Bus queue", + abbreviation: null, + "The name only allows alphanumeric characters and hyphens. It must start with a letter and end with a letter or number. Length: 6 to 50 chars.", + "az servicebus queue create --name", + "New-AzServiceBusQueue -Name"), + + new("Azure SQL Managed Instance", + "sqlmi", + "The name can only contain lowercase letters, numbers and hyphens. It cannot start or end with a hyphen, nor can it have two consecutive hyphens in the third and fourth places of the name. Length: 1 to 63 chars.", + "az sql mi create --name", + "New-AzSqlInstance -Name"), + + new("SQL Server", + "sqldb", + "The name can only contain lowercase letters, numbers and hyphens. It cannot start or end with a hyphen, nor can it have two consecutive hyphens in the third and fourth places of the name. Length: 1 to 63 chars.", + "az sql server create --name", + "New-AzSqlServer -ServerName"), + + new("Storage Container", + abbreviation: null, + "The name can only contain lowercase letters, numbers and hyphens. It must start with a letter or a number, and each hyphen must be preceded and followed by a non-hyphen character. Length: 3 to 63 chars.", + "az storage container create --name", + "New-AzStorageContainer -Name"), + + new("Storage Queue", + abbreviation: null, + "The name can only contain lowercase letters, numbers and hyphens. It must start with a letter or a number, and each hyphen must be preceded and followed by a non-hyphen character. Length: 3 to 63 chars.", + "az storage queue create --name", + "New-AzStorageQueue -Name"), + + new("Storage Table", + abbreviation: null, + "The name can only contain letters and numbers, and must start with a letter. Length: 3 to 63 chars.", + "az storage table create --name", + "New-AzStorageTable -Name"), + + new("Storage File Share", + abbreviation: null, + "The name can only contain lowercase letters, numbers and hyphens. It must start and end with a letter or number, and cannot contain two consecutive hyphens. Length: 3 to 63 chars.", + "az storage share create --name", + "New-AzStorageShare -Name"), + + new("Container Registry", + "cr", + "The name only allows alphanumeric characters. Length: 5 to 50 chars.", + "cr[][]", + ["crnavigatorprod001", "crhadoopdev001"], + "az acr create --name", + "New-AzContainerRegistry -Name"), + + new("Storage Account", + "st", + "The name can only contain lowercase letters and numbers. Length: 3 to 24 chars.", + "st[][]", + ["stsalesappdataqa", "sthadoopoutputtest"], + "az storage account create --name", + "New-AzStorageAccount -Name"), + + new("Traffic Manager profile", + "tm", + "The name only allows alphanumeric characters and hyphens, and cannot start or end with a hyphen. Length: 1 to 63 chars.", + "az network traffic-manager profile create --name", + "New-AzTrafficManagerProfile -Name"), + + new("Virtual Machine", + "vm", + @"The name cannot contain special characters \/""[]:|<>+=;,?*@&#%, whitespace, or begin with '_' or end with '.' or '-'. Length: 1 to 15 chars for Windows; 1 to 64 chars for Linux.", + "az vm create --name", + "New-AzVM -Name"), + + new("Virtual Network Gateway", + "vgw", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network vnet-gateway create --name", + "New-AzVirtualNetworkGateway -Name"), + + new("Local Network Gateway", + "lgw", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network local-gateway create --name", + "New-AzLocalNetworkGateway -Name"), + + new("Virtual Network", + "vnet", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network vnet create --name", + "New-AzVirtualNetwork -Name"), + + new("Subnet", + "snet", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network vnet subnet create --name", + "Add-AzVirtualNetworkSubnetConfig -Name"), + + new("VPN Connection", + "vcn", + "The name only allows alphanumeric characters, underscores, periods, and hyphens. It must start with a letter or number, and end with a letter, number or underscore. Length: 1 to 80 chars.", + "az network vpn-connection create --name", + "New-AzVpnConnection -Name"), + ]; + + s_azNamingRules = new(capacity: rules.Count * 2, StringComparer.OrdinalIgnoreCase); + foreach (var rule in rules) + { + s_azNamingRules.Add(rule.AzCLICommand, rule); + s_azNamingRules.Add(rule.AzPSCommand, rule); + } + + s_azStaticDataCache = new(StringComparer.OrdinalIgnoreCase); + } + + internal DataRetriever(ResponseData data) + { + _stop = false; + _semaphore = new SemaphoreSlim(3, 3); + _staticDataRoot = @"E:\yard\tmp\az-cli-out\az"; + _placeholders = new(capacity: data.PlaceholderSet.Count); + _placeholderMap = new(capacity: data.PlaceholderSet.Count); + + PairPlaceholders(data); + _rootTask = Task.Run(StartProcessing); + } + + private void PairPlaceholders(ResponseData data) + { + var cmds = new Dictionary(data.CommandSet.Count); + + foreach (var item in data.PlaceholderSet) + { + string command = null, parameter = null; + + foreach (var cmd in data.CommandSet) + { + string script = cmd.Script.Trim(); + + // Handle AzCLI commands. + if (script.StartsWith("az ", StringComparison.OrdinalIgnoreCase)) + { + if (!cmds.TryGetValue(script, out command)) + { + int firstParamIndex = script.IndexOf("--"); + command = script.AsSpan(0, firstParamIndex).Trim().ToString(); + cmds.Add(script, command); + } + + int argIndex = script.IndexOf(item.Name, StringComparison.OrdinalIgnoreCase); + if (argIndex is -1) + { + continue; + } + + int paramIndex = script.LastIndexOf("--", argIndex); + parameter = script.AsSpan(paramIndex, argIndex - paramIndex).Trim().ToString(); + + break; + } + + // It's a non-AzCLI command, such as "ssh". + if (script.Contains(item.Name, StringComparison.OrdinalIgnoreCase)) + { + // Leave the parameter to be null for non-AzCLI commands, as there is + // no reliable way to parse an arbitrary command + command = script; + parameter = null; + + break; + } + } + + ArgumentPair pair = new(item, command, parameter); + _placeholders.Add(pair); + _placeholderMap.Add(item.Name, pair); + } + } + + private void StartProcessing() + { + foreach (var pair in _placeholders) + { + if (_stop) { break; } + + _semaphore.Wait(); + + if (pair.ArgumentInfo is null) + { + lock (pair) + { + if (pair.ArgumentInfo is null) + { + pair.ArgumentInfo = Task.Factory.StartNew(ProcessOne, pair); + continue; + } + } + } + + _semaphore.Release(); + } + + ArgumentInfo ProcessOne(object pair) + { + try + { + return CreateArgInfo((ArgumentPair)pair); + } + finally + { + _semaphore.Release(); + } + } + } + + private ArgumentInfo CreateArgInfo(ArgumentPair pair) + { + var item = pair.Placeholder; + var dataType = Enum.Parse(item.Type, ignoreCase: true); + + if (item.ValidValues?.Count > 0) + { + return new ArgumentInfo(item.Name, item.Desc, restriction: null, dataType, item.ValidValues); + } + + // Handle non-AzCLI command. + if (pair.Parameter is null) + { + return new ArgumentInfo(item.Name, item.Desc, dataType); + } + + string cmdAndParam = $"{pair.Command} {pair.Parameter}"; + if (s_azNamingRules.TryGetValue(cmdAndParam, out NamingRule rule)) + { + string restriction = rule.PatternText is null + ? rule.GeneralRule + : $""" + - {rule.GeneralRule} + - Recommended pattern: {rule.PatternText}, e.g. {string.Join(", ", rule.Example)}. + """; + return new ArgumentInfoWithNamingRule(item.Name, item.Desc, restriction, rule); + } + + if (string.Equals(pair.Parameter, "--name", StringComparison.OrdinalIgnoreCase) + && pair.Command.EndsWith(" create", StringComparison.OrdinalIgnoreCase)) + { + // Placeholder is for the name of a new resource to be created, but not in our cache. + return new ArgumentInfo(item.Name, item.Desc, dataType); + } + + if (_stop) { return null; } + + List suggestions = GetArgValues(pair, out Option option); + // If the option's description is less than the placeholder's description in length, then it's + // unlikely to provide more information than the latter. In that case, we don't use it. + string optionDesc = option?.Description?.Length > item.Desc.Length ? option.Description : null; + return new ArgumentInfo(item.Name, item.Desc, optionDesc, dataType, suggestions); + } + + private List GetArgValues(ArgumentPair pair, out Option option) + { + // First, try to get static argument values if they exist. + string command = pair.Command; + if (!s_azStaticDataCache.TryGetValue(command, out Command commandData)) + { + string[] cmdElements = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + string dirPath = _staticDataRoot; + for (int i = 1; i < cmdElements.Length - 1; i++) + { + dirPath = Path.Combine(dirPath, cmdElements[i]); + } + + string filePath = Path.Combine(dirPath, cmdElements[^1] + ".json"); + commandData = File.Exists(filePath) + ? JsonSerializer.Deserialize(File.OpenRead(filePath)) + : null; + s_azStaticDataCache.TryAdd(command, commandData); + } + + option = commandData?.FindOption(pair.Parameter); + List staticValues = option?.Arguments; + if (staticValues?.Count > 0) + { + return staticValues; + } + + if (_stop) { return null; } + + // Then, try to get dynamic argument values using AzCLI tab completion. + string commandLine = $"{pair.Command} {pair.Parameter} "; + string tempFile = Path.GetTempFileName(); + + try + { + using var process = new Process() + { + StartInfo = new ProcessStartInfo() + { + FileName = @"C:\Program Files\Microsoft SDKs\Azure\CLI2\python.exe", + Arguments = "-Im azure.cli", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + } + }; + + var env = process.StartInfo.Environment; + env.Add("ARGCOMPLETE_USE_TEMPFILES", "1"); + env.Add("_ARGCOMPLETE_STDOUT_FILENAME", tempFile); + env.Add("COMP_LINE", commandLine); + env.Add("COMP_POINT", (commandLine.Length + 1).ToString()); + env.Add("_ARGCOMPLETE", "1"); + env.Add("_ARGCOMPLETE_SUPPRESS_SPACE", "0"); + env.Add("_ARGCOMPLETE_IFS", "\n"); + env.Add("_ARGCOMPLETE_SHELL", "powershell"); + + process.Start(); + process.WaitForExit(); + + string line; + using FileStream stream = File.OpenRead(tempFile); + if (stream.Length is 0) + { + // No allowed values for the option. + return null; + } + + using StreamReader reader = new(stream); + List output = []; + + while ((line = reader.ReadLine()) is not null) + { + if (line.StartsWith('-')) + { + // Argument completion generates incorrect results -- options are written into the file instead of argument allowed values. + return null; + } + + string value = line.Trim(); + if (value != string.Empty) + { + output.Add(value); + } + } + + return output.Count > 0 ? output : null; + } + catch (Win32Exception e) + { + throw new ApplicationException($"Failed to get allowed values for 'az {commandLine}': {e.Message}", e); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + internal (string command, string parameter) GetMappedCommand(string placeholderName) + { + if (_placeholderMap.TryGetValue(placeholderName, out ArgumentPair pair)) + { + return (pair.Command, pair.Parameter); + } + + throw new ArgumentException($"Unknown placeholder name: '{placeholderName}'", nameof(placeholderName)); + } + + internal Task GetArgInfo(string placeholderName) + { + if (_placeholderMap.TryGetValue(placeholderName, out ArgumentPair pair)) + { + if (pair.ArgumentInfo is null) + { + lock (pair) + { + pair.ArgumentInfo ??= Task.Run(() => CreateArgInfo(pair)); + } + } + + return pair.ArgumentInfo; + } + + throw new ArgumentException($"Unknown placeholder name: '{placeholderName}'", nameof(placeholderName)); + } + + public void Dispose() + { + _stop = true; + _rootTask.Wait(); + _semaphore.Dispose(); + } +} + +internal class ArgumentPair +{ + internal PlaceholderItem Placeholder { get; } + internal string Command { get; } + internal string Parameter { get; } + internal Task ArgumentInfo { set; get; } + + internal ArgumentPair(PlaceholderItem placeholder, string command, string parameter) + { + Placeholder = placeholder; + Command = command; + Parameter = parameter; + ArgumentInfo = null; + } +} + +internal class ArgumentInfoWithNamingRule : ArgumentInfo +{ + internal ArgumentInfoWithNamingRule(string name, string description, string restriction, NamingRule rule) + : base(name, description, restriction, DataType.@string, suggestions: []) + { + ArgumentNullException.ThrowIfNull(rule); + NamingRule = rule; + } + + internal NamingRule NamingRule { get; } +} + +internal class NamingRule +{ + private static readonly string[] s_products = ["salesapp", "bookingweb", "navigator", "hadoop", "sharepoint"]; + private static readonly string[] s_envs = ["prod", "dev", "qa", "stage", "test"]; + + internal string ResourceName { get; } + internal string Abbreviation { get; } + internal string GeneralRule { get; } + internal string PatternText { get; } + internal Regex PatternRegex { get; } + internal string[] Example { get; } + + internal string AzCLICommand { get; } + internal string AzPSCommand { get; } + + internal NamingRule( + string resourceName, + string abbreviation, + string generalRule, + string azCLICommand, + string azPSCommand) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentException.ThrowIfNullOrEmpty(generalRule); + ArgumentException.ThrowIfNullOrEmpty(azCLICommand); + ArgumentException.ThrowIfNullOrEmpty(azPSCommand); + + ResourceName = resourceName; + Abbreviation = abbreviation; + GeneralRule = generalRule; + AzCLICommand = azCLICommand; + AzPSCommand = azPSCommand; + + if (abbreviation is not null) + { + PatternText = $"-{abbreviation}[-][-]"; + PatternRegex = new Regex($"^(?[a-zA-Z0-9]+)-{abbreviation}(?:-(?[a-zA-Z0-9]+))?(?:-[a-zA-Z0-9]+)?$", RegexOptions.Compiled); + + string product = s_products[Random.Shared.Next(0, s_products.Length)]; + int envIndex = Random.Shared.Next(0, s_envs.Length); + Example = [$"{product}-{abbreviation}-{s_envs[envIndex]}", $"{product}-{abbreviation}-{s_envs[(envIndex + 1) % s_envs.Length]}"]; + } + } + + internal NamingRule( + string resourceName, + string abbreviation, + string generalRule, + string patternText, + string[] example, + string azCLICommand, + string azPSCommand) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentException.ThrowIfNullOrEmpty(generalRule); + ArgumentException.ThrowIfNullOrEmpty(azCLICommand); + ArgumentException.ThrowIfNullOrEmpty(azPSCommand); + + ResourceName = resourceName; + Abbreviation = abbreviation; + GeneralRule = generalRule; + PatternText = patternText; + PatternRegex = null; + Example = example; + + AzCLICommand = azCLICommand; + AzPSCommand = azPSCommand; + } + + internal bool TryMatchName(string name, out string prodName, out string envName) + { + prodName = envName = null; + if (PatternRegex is null) + { + return false; + } + + Match match = PatternRegex.Match(name); + if (match.Success) + { + prodName = match.Groups["prod"].Value; + envName = match.Groups["env"].Value; + return true; + } + + return false; + } +} + +public class Option +{ + public string Name { get; } + public string[] Alias { get; } + public string[] Short { get; } + public string Attribute { get; } + public string Description { get; set; } + public List Arguments { get; set; } + + public Option(string name, string description, string[] alias, string[] @short, string attribute, List arguments) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(description); + + Name = name; + Alias = alias; + Short = @short; + Attribute = attribute; + Description = description; + Arguments = arguments; + } +} + +public sealed class Command +{ + public List