From 6f5dc649f21b00352b6a50ed35b775cf5b5d4fd0 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Sat, 12 Oct 2024 21:45:01 -0700 Subject: [PATCH 1/4] Adopt the same approache used in prototype. - assume placeholders are in '' form - the placeholder section from the original response is not handled, so placeholder info stays even after we replace locally. - the way to send pesudo values to AzCopilot doesn't work well, may need to use a better prompt --- shell/AIShell.Abstraction/IShell.cs | 7 + shell/AIShell.Abstraction/IStreamRender.cs | 11 + shell/AIShell.Kernel/Render/StreamRender.cs | 2 +- shell/AIShell.Kernel/Shell.cs | 1 + shell/AIShell.Kernel/Utility/Utils.cs | 22 +- shell/Markdown.VT/ColorCode.VT/Parser/Bash.cs | 3 +- .../Microsoft.Azure.Agent/AzureAgent.cs | 140 +++- shell/agents/Microsoft.Azure.Agent/Command.cs | 277 +++++++ .../Microsoft.Azure.Agent/DataRetriever.cs | 781 ++++++++++++++++++ shell/agents/Microsoft.Azure.Agent/Schema.cs | 40 + .../Microsoft.Azure.Agent/UserValueStore.cs | 155 ++++ 11 files changed, 1428 insertions(+), 11 deletions(-) create mode 100644 shell/agents/Microsoft.Azure.Agent/Command.cs create mode 100644 shell/agents/Microsoft.Azure.Agent/DataRetriever.cs create mode 100644 shell/agents/Microsoft.Azure.Agent/UserValueStore.cs 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..a8a5a4ed 100644 --- a/shell/AIShell.Kernel/Utility/Utils.cs +++ b/shell/AIShell.Kernel/Utility/Utils.cs @@ -8,6 +8,7 @@ using System.Text.Json.Serialization; using Microsoft.PowerShell; using AIShell.Abstraction; +using Microsoft.VisualBasic; namespace AIShell.Kernel; @@ -125,16 +126,19 @@ 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; StringBuilder code = null; @@ -163,11 +167,17 @@ internal static List ExtractCodeBlocks(string text) if (lineTrimmed.Length is 3) { // Current line is the ending code fence. - codeBlocks.Add(new CodeBlock(code.ToString(), language)); + if (code.Length > 0) + { + codeBlocks.Add(new CodeBlock(code.ToString(), language)); + sourceInfos.Add(new SourceInfo(codeBlockStart, start - 1, codeBlockIndents)); + } code.Clear(); language = null; inCodeBlock = false; + codeBlockStart = codeBlockIndents = -1; + continue; } @@ -179,8 +189,13 @@ internal static List ExtractCodeBlocks(string text) // Current line is the starting code fence. code ??= new StringBuilder(); codeBlocks ??= []; + sourceInfos ??= []; + inCodeBlock = true; 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("```"); } continue; @@ -198,6 +213,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..d04457b8 100644 --- a/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs +++ b/shell/agents/Microsoft.Azure.Agent/AzureAgent.cs @@ -1,4 +1,7 @@ -using AIShell.Abstraction; +using System.Diagnostics; +using System.Text; +using AIShell.Abstraction; +using Microsoft.Identity.Client; namespace Microsoft.Azure.Agent; @@ -16,10 +19,16 @@ public sealed class AzureAgent : ILLMAgent public Dictionary LegalLinks { private set; get; } public string SettingFile { private set; get; } + internal string UserValuePrompt { set; get; } + internal UserValueStore ValueStore { get; } = new(); + internal ArgumentPlaceholder ArgPlaceholder { set; get; } + private const string SettingFileName = "az.agent.json"; + private const string HorizontalRule = "\n---\n"; - private ChatSession _chatSession; private int _turnsLeft; + private StringBuilder _buffer; + private ChatSession _chatSession; public void Dispose() { @@ -28,8 +37,9 @@ public void Dispose() public void Initialize(AgentConfig config) { - _chatSession = new ChatSession(); _turnsLeft = int.MaxValue; + _buffer = new StringBuilder(); + _chatSession = new ChatSession(); 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."; LegalLinks = new(StringComparer.OrdinalIgnoreCase) @@ -43,7 +53,7 @@ public void Initialize(AgentConfig config) 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) {} @@ -64,11 +74,14 @@ public async Task ChatAsync(string input, IShell shell) return true; } + string query = UserValuePrompt is null ? input : $"{UserValuePrompt}\n{HorizontalRule}\n{input}"; + UserValuePrompt = null; + try { 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 +92,30 @@ public async Task ChatAsync(string input, IShell shell) if (copilotResponse.ChunkReader is null) { - host.RenderFullResponse(copilotResponse.Text); + string text = copilotResponse.Text; + + // Process response from CLI handler specially to support parameter injection. + if (CopilotActivity.CLIHandlerTopic.Equals(copilotResponse.TopicName, StringComparison.OrdinalIgnoreCase)) + { + text = text.Replace("~~~", "```"); + ResponseData data = ParseCLIHandlerResponse(text, shell); + if (data is null) + { + // No code blocks in the response, or there is no placeholders in its code blocks. + ArgPlaceholder = null; + host.RenderFullResponse(text); + } + else + { + string answer = GenerateAnswer(input, data); + host.RenderFullResponse(answer); + } + } + else + { + ArgPlaceholder = null; + host.RenderFullResponse(text); + } } else { @@ -138,4 +174,96 @@ public async Task ChatAsync(string input, IShell shell) return true; } + + private static ResponseData ParseCLIHandlerResponse(string text, IShell shell) + { + 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 }); + + // 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 '>') + { + 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; + } + } + } + + return new ResponseData { Text = text, CommandSet = commands, PlaceholderSet = placeholders }; + } + + internal string GenerateAnswer(string input, ResponseData data) + { + _buffer.Clear(); + string text = data.Text; + + // We keep 'ArgPlaceholder' unchanged when it's re-generating in '/replace' with only partial placeholders replaced. + if (!ReferenceEquals(ArgPlaceholder?.ResponseData, data) || data.PlaceholderSet is null) + { + ArgPlaceholder?.DataRetriever?.Dispose(); + ArgPlaceholder = null; + } + + if (data.PlaceholderSet?.Count > 0) + { + // Create the data retriever for the placeholders ASAP, so it gets + // more time to run in background. + ArgPlaceholder ??= new ArgumentPlaceholder(input, data); + } + + int index = 0; + foreach (CommandItem item in data.CommandSet) + { + // Replace the pseudo values with the real values. + string script = ValueStore.ReplacePseudoValues(item.Script); + if (!ReferenceEquals(script, item.Script)) + { + _buffer.Append(text.AsSpan(index, item.SourceInfo.Start - index)); + _buffer.Append(script); + index = item.SourceInfo.End + 1; + } + } + + if (index is 0) + { + return text; + } + + if (index < text.Length) + { + _buffer.Append(text.AsSpan(index, text.Length - index)); + } + + return _buffer.ToString(); + } } diff --git a/shell/agents/Microsoft.Azure.Agent/Command.cs b/shell/agents/Microsoft.Azure.Agent/Command.cs new file mode 100644 index 00000000..14a23542 --- /dev/null +++ b/shell/agents/Microsoft.Azure.Agent/Command.cs @@ -0,0 +1,277 @@ +using System.CommandLine; +using System.Text; +using System.Text.Json; +using AIShell.Abstraction; + +namespace Microsoft.Azure.Agent; + +internal sealed class ReplaceCommand : CommandBase +{ + private readonly AzureAgent _agent; + private readonly Dictionary _values; + private readonly Dictionary _pseudoValues; + 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 = []; + _pseudoValues = []; + _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(); + _pseudoValues.Clear(); + _productNames.Clear(); + _environmentNames.Clear(); + + IHost host = Shell.Host; + ArgumentPlaceholder ap = _agent.ArgPlaceholder; + UserValueStore uvs = _agent.ValueStore; + + 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)) + { + string pseudoValue = uvs.SaveUserInputValue(value); + _values.Add(item.Name, value); + _pseudoValues.Add(item.Name, pseudoValue); + + 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; + StringBuilder prompt = new(capacity: ap.Query.Length + _pseudoValues.Count * 15); + prompt.Append("For all subsequent queries regarding Azure CLI commands, please use the following values for the specified argument placeholders:"); + + // We use the pseudo values when building the new prompt, because the new prompt + // will be added to history, and we don't want real values to go off the box. + foreach (var entry in _pseudoValues) + { + prompt.Append($"\n- {entry.Key}: \"{entry.Value}\""); + } + + // We are doing the replacement locally, but want to fake the regeneration. + await Task.Delay(2000, Shell.CancellationToken); + + ResponseData data = ap.ResponseData; + foreach (CommandItem command in data.CommandSet) + { + foreach (var entry in _pseudoValues) + { + command.Script = command.Script.Replace(entry.Key, entry.Value, StringComparison.OrdinalIgnoreCase); + } + } + + List placeholders = data.PlaceholderSet; + if (placeholders.Count == _pseudoValues.Count) + { + data.PlaceholderSet = null; + } + else if (placeholders.Count > _pseudoValues.Count) + { + List newList = new(placeholders.Count - _pseudoValues.Count); + foreach (PlaceholderItem item in placeholders) + { + if (!_pseudoValues.ContainsKey(item.Name)) + { + newList.Add(item); + } + } + + data.PlaceholderSet = newList; + } + + _agent.UserValuePrompt = prompt.ToString(); + + return _agent.GenerateAnswer(ap.Query, 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..1e204395 --- /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; + + // 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