diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index fd825014621..237d26620a5 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -55,7 +55,7 @@ public static bool TryAddSkillFileApplicator( context.AddApplicator(new AgentEnvironmentApplicator( $"{description} (update - content has changed)", ct => UpdateSkillFileAsync(skillFilePath, ct), - promptGroup: McpInitPromptGroup.AdditionalOptions, + promptGroup: McpInitPromptGroup.SkillFiles, priority: 0)); return true; } @@ -68,7 +68,7 @@ public static bool TryAddSkillFileApplicator( context.AddApplicator(new AgentEnvironmentApplicator( description, ct => CreateSkillFileAsync(skillFilePath, ct), - promptGroup: McpInitPromptGroup.AdditionalOptions, + promptGroup: McpInitPromptGroup.SkillFiles, priority: 0)); return true; @@ -98,9 +98,9 @@ public static void AddPlaywrightCliApplicator( context.PlaywrightApplicatorAdded = true; context.AddApplicator(new AgentEnvironmentApplicator( - "Install Playwright CLI for browser automation", + "Install Playwright CLI (Recommended for browser automation)", ct => installer.InstallAsync(context, ct), - promptGroup: McpInitPromptGroup.AdditionalOptions, + promptGroup: McpInitPromptGroup.Tools, priority: 1)); } @@ -146,136 +146,96 @@ private static string NormalizeLineEndings(string content) """ --- name: aspire - description: "**WORKFLOW SKILL** - Orchestrates Aspire applications using the Aspire CLI and MCP tools for running, debugging, and managing distributed apps. USE FOR: aspire run, aspire stop, start aspire app, aspire describe, list aspire integrations, debug aspire issues, view aspire logs, add aspire resource, aspire dashboard, update aspire apphost. DO NOT USE FOR: non-Aspire .NET apps (use dotnet CLI), container-only deployments (use docker/podman), Azure deployment after local testing (use azure-deploy skill). INVOKES: Aspire MCP tools (list_resources, list_integrations, list_structured_logs, get_doc, search_docs), bash for CLI commands. FOR SINGLE OPERATIONS: Use Aspire MCP tools directly for quick resource status or doc lookups." + description: "Orchestrates Aspire distributed applications using the Aspire CLI for running, debugging, and managing distributed apps. USE FOR: aspire start, aspire stop, start aspire app, aspire describe, list aspire integrations, debug aspire issues, view aspire logs, add aspire resource, aspire dashboard, update aspire apphost. DO NOT USE FOR: non-Aspire .NET apps (use dotnet CLI), container-only deployments (use docker/podman), Azure deployment after local testing (use azure-deploy skill). INVOKES: Aspire CLI commands (aspire start, aspire describe, aspire otel logs, aspire docs search, aspire add), bash. FOR SINGLE OPERATIONS: Use Aspire CLI commands directly for quick resource status or doc lookups." --- # Aspire Skill - This repository is set up to use Aspire. Aspire is an orchestrator for the entire application and will take care of configuring dependencies, building, and running the application. The resources that make up the application are defined in `apphost.cs` including application code and external dependencies. - - ## General recommendations for working with Aspire - - 1. Before making any changes always run the apphost using `aspire run` and inspect the state of resources to make sure you are building from a known state. - 2. Changes to the _apphost.cs_ file will require a restart of the application to take effect. - 3. Make changes incrementally and run the aspire application using the `aspire run` command to validate changes. - 4. Use the Aspire MCP tools to check the status of resources and debug issues. - - ## Running Aspire in agent environments - - Agent environments may terminate foreground processes when a command finishes. Use detached mode: + This repository uses Aspire to orchestrate its distributed application. Resources are defined in the AppHost project (`apphost.cs` or `apphost.ts`). + + ## CLI command reference + + | Task | Command | + |---|---| + | Start the app | `aspire start` | + | Start isolated (worktrees) | `aspire start --isolated` | + | Restart the app | `aspire start` (stops previous automatically) | + | Wait for resource healthy | `aspire wait ` | + | Stop the app | `aspire stop` | + | List resources | `aspire describe` or `aspire resources` | + | Run resource command | `aspire resource ` | + | Start/stop/restart resource | `aspire resource start|stop|restart` | + | View console logs | `aspire logs [resource]` | + | View structured logs | `aspire otel logs [resource]` | + | View traces | `aspire otel traces [resource]` | + | Logs for a trace | `aspire otel logs --trace-id ` | + | Add an integration | `aspire add` | + | List running AppHosts | `aspire ps` | + | Update AppHost packages | `aspire update` | + | Search docs | `aspire docs search ` | + | Get doc page | `aspire docs get ` | + | List doc pages | `aspire docs list` | + | Environment diagnostics | `aspire doctor` | + | List resource MCP tools | `aspire mcp tools` | + | Call resource MCP tool | `aspire mcp call --input ` | + + Most commands support `--format Json` for machine-readable output. Use `--apphost ` to target a specific AppHost. + + ## Key workflows + + ### Running in agent environments + + Use `aspire start` to run the AppHost in the background. When working in a git worktree, use `--isolated` to avoid port conflicts and to prevent sharing user secrets or other local state with other running instances: ```bash - aspire run --detach + aspire start --isolated ``` - This starts the AppHost in the background and returns immediately. The CLI will: - - Automatically stop any existing running instance before starting a new one - - Display a summary with the Dashboard URL and resource endpoints - - ### Running with isolation - - The `--isolated` flag starts the AppHost with randomized port numbers and its own copy of user secrets. + Use `aspire wait ` to block until a resource is healthy before interacting with it: ```bash - aspire run --detach --isolated + aspire start --isolated + aspire wait myapi ``` - Isolation should be used when: - - When AppHosts are started by background agents - - When agents are using source code from a work tree - - There are port conflicts when starting the AppHost without isolation + Relaunching is safe — `aspire start` automatically stops any previous instance. Re-run `aspire start` whenever changes are made to the AppHost project. - ### Stopping the application + ### Debugging issues - To stop a running AppHost: + Before making code changes, inspect the app state: - ```bash - aspire stop - ``` + 1. `aspire describe` — check resource status + 2. `aspire otel logs ` — view structured logs + 3. `aspire logs ` — view console output + 4. `aspire otel traces ` — view distributed traces - This will scan for running AppHosts and stop them gracefully. + ### Adding integrations - ### Relaunch rules + Use `aspire docs search` to find integration documentation, then `aspire docs get` to read the full guide. Use `aspire add` to add the integration package to the AppHost. - - If AppHost code changes, run `aspire run --detach` again to restart with the new code. - - Relaunching is safe: starting a new instance will automatically stop the previous instance. - - Do not attempt to keep multiple instances running. + After adding an integration, restart the app with `aspire start` for the new resource to take effect. - ## Running the application + ### Using resource MCP tools - To run the application run the following command: + Some resources expose MCP tools (e.g. `WithPostgresMcp()` adds SQL query tools). Discover and call them via CLI: ```bash - aspire run + aspire mcp tools # list available tools + aspire mcp tools --format Json # includes input schemas + aspire mcp call --input '{"key":"value"}' # invoke a tool ``` - If there is already an instance of the application running it will prompt to stop the existing instance. You only need to restart the application if code in `apphost.cs` is changed, but if you experience problems it can be useful to reset everything to the starting state. - - ## Checking resources + ## Important rules - To check the status of resources defined in the app model use the _list resources_ tool. This will show you the current state of each resource and if there are any issues. If a resource is not running as expected you can use the _execute resource command_ tool to restart it or perform other actions. - - ## Listing integrations - - IMPORTANT! When a user asks you to add a resource to the app model you should first use the _list integrations_ tool to get a list of the current versions of all the available integrations. You should try to use the version of the integration which aligns with the version of the Aspire.AppHost.Sdk. Some integration versions may have a preview suffix. Once you have identified the correct integration you should always use the _get integration docs_ tool to fetch the latest documentation for the integration and follow the links to get additional guidance. - - ## Debugging issues - - IMPORTANT! Aspire is designed to capture rich logs and telemetry for all resources defined in the app model. Use the following diagnostic tools when debugging issues with the application before making changes to make sure you are focusing on the right things. - - 1. _list structured logs_; use this tool to get details about structured logs. - 2. _list console logs_; use this tool to get details about console logs. - 3. _list traces_; use this tool to get details about traces. - 4. _list trace structured logs_; use this tool to get logs related to a trace - - ## Other Aspire MCP tools - - 1. _select apphost_; use this tool if working with multiple app hosts within a workspace. - 2. _list apphosts_; use this tool to get details about active app hosts. + - **Always start the app first** (`aspire start`) before making changes to verify the starting state. + - **To restart, just run `aspire start` again** — it automatically stops the previous instance. NEVER use `aspire stop` then `aspire run`. NEVER use `aspire run` at all. + - Use `--isolated` when working in a worktree. + - **Avoid persistent containers** early in development to prevent state management issues. + - **Never install the Aspire workload** — it is obsolete. + - Prefer `aspire.dev` and `learn.microsoft.com/dotnet/aspire` for official documentation. ## Playwright CLI - The Playwright CLI has been installed in this repository for browser automation. Use it to perform functional investigations of the resources defined in the app model as you work on the codebase. To get endpoints that can be used for navigation use the list resources tool. Run `playwright-cli --help` for available commands. - - ## Updating the app host - - The user may request that you update the Aspire apphost. You can do this using the `aspire update` command. This will update the apphost to the latest version and some of the Aspire specific packages in referenced projects, however you may need to manually update other packages in the solution to ensure compatibility. You can consider using the `dotnet-outdated` with the users consent. To install the `dotnet-outdated` tool use the following command: - - ```bash - dotnet tool install --global dotnet-outdated-tool - ``` - - ## Persistent containers - - IMPORTANT! Consider avoiding persistent containers early during development to avoid creating state management issues when restarting the app. - - ## Aspire workload - - IMPORTANT! The aspire workload is obsolete. You should never attempt to install or use the Aspire workload. - - ## Aspire Documentation Tools - - IMPORTANT! The Aspire MCP server provides tools to search and retrieve official Aspire documentation directly. Use these tools to find accurate, up-to-date information about Aspire features, APIs, and integrations: - - 1. **list_docs**: Lists all available documentation pages from aspire.dev. Returns titles, slugs, and summaries. Use this to discover available topics. - - 2. **search_docs**: Searches the documentation using keywords. Returns ranked results with titles, slugs, and matched content. Use this when looking for specific features, APIs, or concepts. - - 3. **get_doc**: Retrieves the full content of a documentation page by its slug. After using `list_docs` or `search_docs` to find a relevant page, pass the slug to `get_doc` to retrieve the complete documentation. - - ### Recommended workflow for documentation - - 1. Use `search_docs` with relevant keywords to find documentation about a topic - 2. Review the search results - each result includes a **Slug** that identifies the page - 3. Use `get_doc` with the slug to retrieve the full documentation content - 4. Optionally use the `section` parameter with `get_doc` to retrieve only a specific section - - ## Official documentation - - IMPORTANT! Always prefer official documentation when available. The following sites contain the official documentation for Aspire and related components - - 1. https://aspire.dev - 2. https://learn.microsoft.com/dotnet/aspire - 3. https://nuget.org (for specific integration package details) + If configured, use Playwright CLI for functional testing of resources. Get endpoints via `aspire describe`. Run `playwright-cli --help` for available commands. """; } diff --git a/src/Aspire.Cli/Agents/McpInitPromptGroup.cs b/src/Aspire.Cli/Agents/McpInitPromptGroup.cs index 1c1c4186fd8..5890926c656 100644 --- a/src/Aspire.Cli/Agents/McpInitPromptGroup.cs +++ b/src/Aspire.Cli/Agents/McpInitPromptGroup.cs @@ -9,19 +9,29 @@ namespace Aspire.Cli.Agents; internal sealed class McpInitPromptGroup { /// - /// Group for updating deprecated agent configurations (highest priority). + /// Group for updating deprecated agent configurations (applied silently). /// public static readonly McpInitPromptGroup ConfigUpdates = new("ConfigUpdates", priority: -1); /// - /// Group for agent environment configurations (VS Code, Copilot CLI, etc.). + /// Group for agent environment MCP server configurations (VS Code, Copilot CLI, etc.). /// public static readonly McpInitPromptGroup AgentEnvironments = new("AgentEnvironments", priority: 0); /// - /// Group for additional optional configurations (AGENTS.md, Playwright, etc.). + /// Group for skill file installations. /// - public static readonly McpInitPromptGroup AdditionalOptions = new("AdditionalOptions", priority: 1); + public static readonly McpInitPromptGroup SkillFiles = new("SkillFiles", priority: 1); + + /// + /// Group for additional tool installations (Playwright CLI, etc.). + /// + public static readonly McpInitPromptGroup Tools = new("Tools", priority: 2); + + /// + /// Group for additional optional configurations. + /// + public static readonly McpInitPromptGroup AdditionalOptions = new("AdditionalOptions", priority: 3); private McpInitPromptGroup(string name, int priority) { diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index cdd140c8f70..f74fea3d316 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -150,43 +150,107 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, Cance return ExitCodeConstants.Success; } - // Group applicators by prompt group and sort by priority - var groupedApplicators = applicators - .GroupBy(a => a.PromptGroup) - .OrderBy(g => g.Key.Priority) + // Apply deprecated config migrations silently (these are fixes, not choices) + var configUpdates = applicators.Where(a => a.PromptGroup == McpInitPromptGroup.ConfigUpdates).ToList(); + var userChoices = applicators.Where(a => a.PromptGroup != McpInitPromptGroup.ConfigUpdates).ToList(); + + foreach (var update in configUpdates) + { + try + { + await update.ApplyAsync(cancellationToken); + _interactionService.DisplayMessage(KnownEmojis.Wrench, update.Description); + } + catch (InvalidOperationException ex) + { + _interactionService.DisplayError(ex.Message); + } + } + + if (userChoices.Count == 0) + { + _interactionService.DisplaySuccess(McpCommandStrings.InitCommand_ConfigurationComplete); + return ExitCodeConstants.Success; + } + + // Categorize by prompt group (not string matching) + var skillApplicators = userChoices + .Where(a => a.PromptGroup == McpInitPromptGroup.SkillFiles) + .ToList(); + var mcpApplicators = userChoices + .Where(a => a.PromptGroup == McpInitPromptGroup.AgentEnvironments) + .ToList(); + var toolApplicators = userChoices + .Where(a => a.PromptGroup == McpInitPromptGroup.Tools) + .ToList(); + var otherApplicators = userChoices + .Except(skillApplicators) + .Except(mcpApplicators) + .Except(toolApplicators) .ToList(); - var selectedApplicators = new List(); + // Build a flat list: collapsed skill (pre-selected), then others (Playwright CLI, etc.) + var promptChoices = new List(); - // Present each group of prompts in priority order - foreach (var group in groupedApplicators) + // Collapse all skill applicators into a single line (pre-selected) + AgentEnvironmentApplicator? combinedSkillApplicator = null; + if (skillApplicators.Count > 0) { - // Get the prompt text for this group - var promptText = group.Key.Name switch - { - "ConfigUpdates" => AgentCommandStrings.ConfigUpdatesSelectPrompt, - "AgentEnvironments" => McpCommandStrings.InitCommand_AgentConfigurationSelectPrompt, - "AdditionalOptions" => McpCommandStrings.InitCommand_AdditionalOptionsSelectPrompt, - _ => $"Select {group.Key.Name}:" - }; - - // Sort applicators within the group by priority - var sortedApplicators = group.OrderBy(a => a.Priority).ToArray(); - - // Prompt user for selection from this group - var selected = await _interactionService.PromptForSelectionsAsync( - promptText, - sortedApplicators, - applicator => applicator.Description, - optional: true, - cancellationToken); - - selectedApplicators.AddRange(selected); + combinedSkillApplicator = new AgentEnvironmentApplicator( + AgentCommandStrings.InitCommand_InstallSkillFile, + async ct => + { + foreach (var skill in skillApplicators) + { + await skill.ApplyAsync(ct); + _interactionService.DisplayMessage(KnownEmojis.CheckMark, skill.Description); + } + }, + promptGroup: McpInitPromptGroup.AdditionalOptions); + promptChoices.Add(combinedSkillApplicator); + } + + promptChoices.AddRange(toolApplicators); + promptChoices.AddRange(otherApplicators); + + // Add collapsed MCP as last option for compatibility + if (mcpApplicators.Count > 0) + { + var combinedMcpApplicator = new AgentEnvironmentApplicator( + AgentCommandStrings.InitCommand_ConfigureMcpServer, + async ct => + { + foreach (var mcp in mcpApplicators) + { + await mcp.ApplyAsync(ct); + _interactionService.DisplayMessage(KnownEmojis.CheckMark, mcp.Description); + } + }, + promptGroup: McpInitPromptGroup.AdditionalOptions); + promptChoices.Add(combinedMcpApplicator); + } + + // Pre-select the skill applicator + var preSelected = combinedSkillApplicator is not null ? [combinedSkillApplicator] : Array.Empty(); + + // Present a single flat prompt with skill pre-selected + var selected = await _interactionService.PromptForSelectionsAsync( + AgentCommandStrings.InitCommand_WhatToConfigure, + promptChoices, + applicator => applicator.Description, + preSelected: preSelected, + optional: true, + cancellationToken); + + if (selected.Count == 0) + { + _interactionService.DisplaySubtleMessage(AgentCommandStrings.InitCommand_NothingSelected); + return ExitCodeConstants.Success; } - // Apply all selected applicators + // Apply selected applicators var hasErrors = false; - foreach (var applicator in selectedApplicators) + foreach (var applicator in selected) { try { diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index ef796959a6d..c0f472c5160 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -266,8 +266,7 @@ later as needed. initContext.ExecutableProjects, project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), optional: true, - cancellationToken); - + cancellationToken: cancellationToken); initContext.ExecutableProjectsToAddToAppHost = selectedProjects; // If projects were selected, prompt for which should have ServiceDefaults added @@ -320,7 +319,7 @@ ServiceDefaults project contains helper code to make it easier initContext.ExecutableProjectsToAddToAppHost, project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), optional: true, - cancellationToken); + cancellationToken: cancellationToken); break; case "none": initContext.ProjectsToAddServiceDefaultsTo = Array.Empty(); diff --git a/src/Aspire.Cli/Commands/McpCallCommand.cs b/src/Aspire.Cli/Commands/McpCallCommand.cs new file mode 100644 index 00000000000..5fc296c8d5e --- /dev/null +++ b/src/Aspire.Cli/Commands/McpCallCommand.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Commands; + +/// +/// Calls an MCP tool on a running Aspire resource. +/// +internal sealed class McpCallCommand : BaseCommand +{ + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + + private static readonly Argument s_resourceArgument = new("resource") + { + Description = "The name of the resource that exposes the MCP tool." + }; + + private static readonly Argument s_toolArgument = new("tool") + { + Description = "The name of the MCP tool to call." + }; + + private static readonly Option s_inputOption = new("--input", "-i") + { + Description = "JSON input to pass to the tool." + }; + + private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); + + public McpCallCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + ILogger logger) + : base("call", "Call an MCP tool on a running resource.", features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + + Arguments.Add(s_resourceArgument); + Arguments.Add(s_toolArgument); + Options.Add(s_inputOption); + Options.Add(s_appHostOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var resourceName = parseResult.GetValue(s_resourceArgument)!; + var toolName = parseResult.GetValue(s_toolArgument)!; + var inputJson = parseResult.GetValue(s_inputOption); + var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); + + var result = await _connectionResolver.ResolveConnectionAsync( + passedAppHostProjectFile, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, "call MCP tool on"), + SharedCommandStrings.AppHostNotRunning, + cancellationToken); + + if (!result.Success) + { + _interactionService.DisplayError(result.ErrorMessage); + return ExitCodeConstants.FailedToDotnetRunAppHost; + } + + var connection = result.Connection!; + + // Parse input JSON into arguments dictionary + IReadOnlyDictionary? arguments = null; + if (!string.IsNullOrEmpty(inputJson)) + { + try + { + using var doc = JsonDocument.Parse(inputJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + _interactionService.DisplayError("Invalid JSON input: expected a JSON object."); + return ExitCodeConstants.InvalidCommand; + } + var dict = new Dictionary(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + dict[prop.Name] = prop.Value.Clone(); + } + arguments = dict; + } + catch (JsonException ex) + { + _interactionService.DisplayError($"Invalid JSON input: {ex.Message}"); + return ExitCodeConstants.InvalidCommand; + } + } + + try + { + var callResult = await connection.CallResourceMcpToolAsync( + resourceName, + toolName, + arguments, + cancellationToken); + + // Output the result content + if (callResult.Content is { Count: > 0 }) + { + foreach (var content in callResult.Content) + { + if (content is TextContentBlock textContent) + { + _interactionService.DisplayRawText(textContent.Text); + } + else + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteString("type", content.Type); + writer.WriteEndObject(); + } + _interactionService.DisplayRawText(System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + } + } + + if (callResult.IsError == true) + { + return ExitCodeConstants.InvalidCommand; + } + + return ExitCodeConstants.Success; + } + catch (Exception ex) + { + _interactionService.DisplayError($"Failed to call tool '{toolName}' on resource '{resourceName}': {ex.Message}"); + return ExitCodeConstants.InvalidCommand; + } + } +} diff --git a/src/Aspire.Cli/Commands/McpCommand.cs b/src/Aspire.Cli/Commands/McpCommand.cs index 455fa01cb35..c3c8dffc07e 100644 --- a/src/Aspire.Cli/Commands/McpCommand.cs +++ b/src/Aspire.Cli/Commands/McpCommand.cs @@ -12,10 +12,13 @@ namespace Aspire.Cli.Commands; /// -/// Legacy MCP command for backward compatibility. Hidden in favor of 'aspire agent' command. +/// MCP command for interacting with MCP tools exposed by running resources. +/// Also provides legacy 'start' and 'init' subcommands for backward compatibility. /// internal sealed class McpCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + public McpCommand( IInteractionService interactionService, IFeatures features, @@ -23,12 +26,17 @@ public McpCommand( CliExecutionContext executionContext, McpStartCommand startCommand, McpInitCommand initCommand, + McpToolsCommand toolsCommand, + McpCallCommand callCommand, AspireCliTelemetry telemetry) : base("mcp", McpCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - // Mark as hidden - use 'aspire agent' instead - Hidden = true; + Subcommands.Add(toolsCommand); + Subcommands.Add(callCommand); + // Legacy subcommands — hidden, use 'aspire agent' instead + startCommand.Hidden = true; + initCommand.Hidden = true; Subcommands.Add(startCommand); Subcommands.Add(initCommand); } diff --git a/src/Aspire.Cli/Commands/McpToolsCommand.cs b/src/Aspire.Cli/Commands/McpToolsCommand.cs new file mode 100644 index 00000000000..b9de96297bb --- /dev/null +++ b/src/Aspire.Cli/Commands/McpToolsCommand.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Lists MCP tools exposed by running Aspire resources. +/// +internal sealed class McpToolsCommand : BaseCommand +{ + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + + private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); + private static readonly Option s_formatOption = new("--format") + { + Description = "Output format (Table or Json)." + }; + + public McpToolsCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + ILogger logger) + : base("tools", "List MCP tools exposed by running resources.", features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + + Options.Add(s_appHostOption); + Options.Add(s_formatOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); + var format = parseResult.GetValue(s_formatOption); + + var result = await _connectionResolver.ResolveConnectionAsync( + passedAppHostProjectFile, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, "list MCP tools for"), + SharedCommandStrings.AppHostNotRunning, + cancellationToken); + + if (!result.Success) + { + _interactionService.DisplayMessage(KnownEmojis.Information, result.ErrorMessage); + return ExitCodeConstants.Success; + } + + var connection = result.Connection!; + var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken); + var resourcesWithTools = snapshots.Where(r => r.McpServer is not null).ToList(); + + if (resourcesWithTools.Count == 0) + { + _interactionService.DisplayMessage(KnownEmojis.Information, "No resources with MCP tools found."); + return ExitCodeConstants.Success; + } + + if (format == OutputFormat.Json) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartArray(); + foreach (var r in resourcesWithTools) + { + var resourceName = r.DisplayName ?? r.Name; + foreach (var t in r.McpServer!.Tools) + { + writer.WriteStartObject(); + writer.WriteString("resource", resourceName); + writer.WriteString("tool", t.Name); + writer.WriteString("description", t.Description ?? ""); + writer.WritePropertyName("inputSchema"); + t.InputSchema.WriteTo(writer); + writer.WriteEndObject(); + } + } + writer.WriteEndArray(); + } + + _interactionService.DisplayRawText(System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + else + { + var table = new Table(); + table.AddColumn("Resource"); + table.AddColumn("Tool"); + table.AddColumn("Description"); + + foreach (var resource in resourcesWithTools) + { + var resourceName = resource.DisplayName ?? resource.Name; + foreach (var tool in resource.McpServer!.Tools) + { + table.AddRow( + resourceName.EscapeMarkup(), + tool.Name.EscapeMarkup(), + (tool.Description ?? "").EscapeMarkup()); + } + } + + _interactionService.DisplayRenderable(table); + } + + return ExitCodeConstants.Success; + } +} diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index d9f2aefe395..48536e726d9 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -190,7 +190,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull { ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); ArgumentNullException.ThrowIfNull(choices, nameof(choices)); @@ -201,21 +201,29 @@ public async Task> PromptForSelectionsAsync(string promptTex throw new InvalidOperationException(InteractionServiceStrings.InteractiveInputNotSupported); } - // Check if the choices collection is empty to avoid throwing an InvalidOperationException - if (!choices.Any()) + var choicesList = choices.ToList(); + + if (choicesList.Count == 0) { throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NoItemsAvailableForSelection, promptText)); } + var preSelectedSet = preSelected is not null ? new HashSet(preSelected) : null; + var prompt = new MultiSelectionPrompt() .Title(promptText) .UseConverter(choiceFormatter) - .AddChoices(choices) .PageSize(10); - if (optional) + prompt.Required = !optional; + + foreach (var choice in choicesList) { - prompt.NotRequired(); + var item = prompt.AddChoice(choice); + if (preSelectedSet?.Contains(choice) == true) + { + item.Select(); + } } var result = await _outConsole.PromptAsync(prompt, cancellationToken); diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 688b65e453a..e47b8ad75a8 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -263,7 +263,7 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, - bool optional = false, CancellationToken cancellationToken = default) where T : notnull + IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull { if (_extensionPromptEnabled) { @@ -273,6 +273,8 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => { try { + // Note: The extension backchannel protocol does not yet support preSelected items. + // Pre-selected items are applied only when falling back to the console interaction service. var result = await Backchannel.PromptForSelectionsAsync(promptText.RemoveSpectreFormatting(), choices, choiceFormatter, _cancellationToken).ConfigureAwait(false); tcs.SetResult(result); } @@ -290,7 +292,7 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } else { - return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, optional, cancellationToken); + return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, cancellationToken); } } diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index d7f5dbd9442..b3765c4bc59 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -15,7 +15,7 @@ internal interface IInteractionService Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default); public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default); Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; - Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull; + Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull; int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion); void DisplayError(string errorMessage); void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 4f4dba7d551..44d9bdd085f 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -389,6 +389,8 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index b98194c480b..db15a7e79b5 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -149,5 +149,50 @@ internal static string ConfigurationCompletedWithErrors { return ResourceManager.GetString("ConfigurationCompletedWithErrors", resourceCulture); } } + + /// + /// Looks up a localized string similar to None — skip. + /// + internal static string SkipNoneDescription { + get { + return ResourceManager.GetString("SkipNoneDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to What would you like to configure?. + /// + internal static string InitCommand_WhatToConfigure { + get { + return ResourceManager.GetString("InitCommand_WhatToConfigure", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No options selected. Nothing to configure.. + /// + internal static string InitCommand_NothingSelected { + get { + return ResourceManager.GetString("InitCommand_NothingSelected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Install Aspire MCP server. + /// + internal static string InitCommand_ConfigureMcpServer { + get { + return ResourceManager.GetString("InitCommand_ConfigureMcpServer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Install Aspire skill file (Recommended). + /// + internal static string InitCommand_InstallSkillFile { + get { + return ResourceManager.GetString("InitCommand_InstallSkillFile", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 704169f984c..469b6daecd7 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -90,4 +90,19 @@ Configuration completed with errors. Please fix the reported issues and re-run the command. + + None — skip + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + + No options selected. Nothing to configure. + + + Install Aspire MCP server + + + Install Aspire skill file (Recommended) + diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 7c2bdc107d5..04973482f71 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -27,11 +27,31 @@ Spravujte integrace agentů AI. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Inicializujte konfiguraci prostředí agentů pro zjištěné agenty. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Spusťte server MCP (Model Context Protocol). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 136075c4f77..013b82a5681 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -27,11 +27,31 @@ Verwalten von KI-Agent-Integrationen. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Initialisieren Sie die Agent-Umgebungskonfiguration für erkannte Agenten. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Starten Sie den MCP-Server (Model Context Protocol). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index ec2f3308ed1..08cd9c1cd9b 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -27,11 +27,31 @@ Administrar integraciones de agentes de IA. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Inicialice la configuración del entorno del agente para los agentes detectados. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Inicie el servidor MCP (protocolo de contexto de modelo). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 07497d61499..91a79ad57c1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -27,11 +27,31 @@ Gérer les intégrations des agents IA. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Initialiser la configuration de l’environnement des agents détectés. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Démarrez le serveur MCP (Model Context Protocol). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 44154133e4e..27173128ae7 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -27,11 +27,31 @@ Gestire le integrazioni dell'agente AI. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Consente di inizializzare la configurazione dell'ambiente agente per gli agenti rilevati. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Avviare il server MCP (Model Context Protocol). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index b6ae4c482c1..4a9d12ed7f7 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -27,11 +27,31 @@ AI エージェントの統合を管理します。 + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. 検出されたエージェントのエージェント環境構成を初期化します。 + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ MCP (モデル コンテキスト プロトコル) サーバーを起動します。 + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index f678b846072..8255451a81a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -27,11 +27,31 @@ AI 에이전트 통합을 관리합니다. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. 감지된 에이전트에 대한 에이전트 환경 구성을 초기화합니다. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ MCP(모델 컨텍스트 프로토콜) 서버를 시작합니다. + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 1aa4bbd4897..7b2104378be 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -27,11 +27,31 @@ Zarządzaj integracjami agentów sztucznej inteligencji. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Zainicjuj konfigurację środowiska agenta dla wykrytych agentów. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Uruchom serwer MCP (Model Context Protocol). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index cee50ee7a09..e0b6b0b825b 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -27,11 +27,31 @@ Gerencie integrações de agente de IA. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Inicialize a configuração de ambiente do agente para agentes detectados. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Inicie o servidor MCP (Protocolo de Contexto de Modelo). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index ad3ffe20ec6..975e9799e3e 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -27,11 +27,31 @@ Управляйте интеграциями агентов ИИ. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Инициализировать конфигурацию среды агента для обнаруженных агентов. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ Запуск сервера MCP (протокол контекста модели). + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 16522538ba5..e9969f613df 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -27,11 +27,31 @@ AI detekli aracı entegrasyonlarınızı yönetin. + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. Tespit edilen aracılar için aracı ortam yapılandırmasını başlatın. + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ MCP (Model Bağlam Protokolü) sunucusunu başlat. + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 67760d202e2..3d6965fa542 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -27,11 +27,31 @@ 管理 AI 智能体集成。 + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. 初始化检测到的智能体的智能体环境配置。 + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ 启动 MCP (模型上下文协议)服务器。 + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 356b44af5d8..ac0431a136a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -27,11 +27,31 @@ 管理 AI Agent 整合。 + + Install Aspire MCP server + Install Aspire MCP server + + Initialize agent environment configuration for detected agents. 為偵測到的代理程式初始化代理程式環境設定。 + + Install Aspire skill file (Recommended) + Install Aspire skill file (Recommended) + + + + No options selected. Nothing to configure. + No options selected. Nothing to configure. + + + + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + What would you like to configure? [dim](Enter to skip, Ctrl+C to cancel)[/] + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. @@ -42,6 +62,11 @@ 啟動 MCP (模型內容通訊協定) 伺服器。 + + None — skip + None — skip + + Skipping update of '{0}'. Skipping update of '{0}'. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index d04c23714b8..a30b83efd18 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -38,10 +38,6 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect() var agentMcpSubcommand = new CellPatternSearcher().Find("mcp"); var agentInitSubcommand = new CellPatternSearcher().Find("init"); - // Pattern for legacy aspire mcp --help (should still work) - var legacyMcpStart = new CellPatternSearcher().Find("start"); - var legacyMcpInit = new CellPatternSearcher().Find("init"); - var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); @@ -83,24 +79,26 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect() .WaitUntil(s => initHelpPattern.Search(s).Count > 0, TimeSpan.FromSeconds(30)) .WaitForSuccessPrompt(counter); - // Test 4: aspire mcp --help (legacy, should still work) + // Test 4: aspire mcp --help (now shows tools and call subcommands) + var mcpToolsSubcommand = new CellPatternSearcher().Find("tools"); + var mcpCallSubcommand = new CellPatternSearcher().Find("call"); sequenceBuilder .Type("aspire mcp --help") .Enter() .WaitUntil(s => { - var hasStart = legacyMcpStart.Search(s).Count > 0; - var hasInit = legacyMcpInit.Search(s).Count > 0; - return hasStart && hasInit; + var hasTools = mcpToolsSubcommand.Search(s).Count > 0; + var hasCall = mcpCallSubcommand.Search(s).Count > 0; + return hasTools && hasCall; }, TimeSpan.FromSeconds(30)) .WaitForSuccessPrompt(counter); - // Test 5: aspire mcp start --help (legacy, should still work) - var legacyMcpStartPattern = new CellPatternSearcher().Find("aspire mcp start [options]"); + // Test 5: aspire mcp tools --help + var mcpToolsHelpPattern = new CellPatternSearcher().Find("aspire mcp tools [options]"); sequenceBuilder - .Type("aspire mcp start --help") + .Type("aspire mcp tools --help") .Enter() - .WaitUntil(s => legacyMcpStartPattern.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitUntil(s => mcpToolsHelpPattern.Search(s).Count > 0, TimeSpan.FromSeconds(30)) .WaitForSuccessPrompt(counter); sequenceBuilder @@ -138,9 +136,6 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() // the prompt is ready for input var workspacePathPrompt = new CellPatternSearcher().Find("workspace:"); - // Patterns for deprecated config detection in agent init - var deprecatedPrompt = new CellPatternSearcher().Find("Update"); - // Pattern to detect if no environments are found var noEnvironmentsMessage = new CellPatternSearcher().Find("No agent environments"); @@ -175,7 +170,11 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() .WaitUntil(s => fileExistsPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) .WaitForSuccessPrompt(counter); - // Step 2: Run aspire agent init - should detect deprecated config + // Step 2: Run aspire agent init - should detect and auto-migrate deprecated config + // In the new flow, deprecated config migrations are applied silently + var configurePrompt = new CellPatternSearcher().Find("configure"); + var configComplete = new CellPatternSearcher().Find("omplete"); + sequenceBuilder .Type("aspire agent init") .Enter() @@ -184,17 +183,19 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() .Enter() // Accept default workspace path .WaitUntil(s => { - // Either we should see the deprecated config prompt, OR the "no environments" message - // This helps us diagnose whether the scanner is finding anything - var hasDeprecated = deprecatedPrompt.Search(s).Count > 0; + // Migration happens silently. We'll see either: + // - The configure prompt (if other environments were detected) + // - "Configuration complete" (if only deprecated configs were found) + // - "No agent environments" (if nothing was found) + var hasConfigure = configurePrompt.Search(s).Count > 0; var hasNoEnv = noEnvironmentsMessage.Search(s).Count > 0; - return hasDeprecated || hasNoEnv; + var hasComplete = configComplete.Search(s).Count > 0; + return hasConfigure || hasNoEnv || hasComplete; }, TimeSpan.FromSeconds(60)); - // Verify we got the deprecated prompt (not "no environments") - // This will show in the terminal capture if the test fails + // If we got the configure prompt, just press Enter to accept defaults + // If we got complete/no-env, this Enter is harmless sequenceBuilder - .Type(" ") // Space to select update .Enter() .WaitForSuccessPrompt(counter); @@ -289,15 +290,12 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig() } /// - /// Tests that aspire agent init gracefully handles malformed JSON in MCP config files. - /// When a .vscode/mcp.json file contains invalid JSON, the command should: - /// - Display an error message identifying the malformed file - /// - Display a "Skipping" message - /// - NOT overwrite the malformed file - /// - Exit with a non-zero exit code + /// Tests that aspire agent init with a .vscode folder shows the skill pre-selected + /// and MCP as an opt-in option, and that accepting the defaults (skill only) completes + /// successfully and creates the skill file. /// [Fact] - public async Task AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZero() + public async Task AgentInitCommand_DefaultSelection_InstallsSkillOnly() { var workspace = TemporaryWorkspace.Create(output); @@ -309,28 +307,14 @@ public async Task AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZer var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - // Set up paths + // Set up .vscode folder so VS Code scanner detects it var vscodePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".vscode"); - var mcpConfigPath = Path.Combine(vscodePath, "mcp.json"); - var malformedContent = "{ invalid json content"; - // Patterns for agent init prompts + // Patterns var workspacePathPrompt = new CellPatternSearcher().Find("workspace:"); - - // Pattern for the malformed JSON error message - var malformedError = new CellPatternSearcher().Find("malformed JSON"); - - // Pattern for the skip message - var skippingMessage = new CellPatternSearcher().Find("Skipping"); - - // Pattern for the partial success warning message - var completedWithErrors = new CellPatternSearcher().Find("completed with errors"); - - // Pattern for the agent environment selection prompt - var agentSelectPrompt = new CellPatternSearcher().Find("agent environments"); - - // Pattern for the additional options prompt that appears after agent environment selection - var additionalOptionsPrompt = new CellPatternSearcher().Find("additional options"); + var configurePrompt = new CellPatternSearcher().Find("configure"); + var skillOption = new CellPatternSearcher().Find("skill"); + var configComplete = new CellPatternSearcher().Find("complete"); var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); @@ -344,44 +328,26 @@ public async Task AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZer sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); } - // Step 1: Create .vscode folder with malformed mcp.json + // Create .vscode folder so the scanner detects VS Code environment sequenceBuilder - .CreateVsCodeFolder(vscodePath) - .CreateMalformedMcpConfig(mcpConfigPath, malformedContent); + .CreateVsCodeFolder(vscodePath); - // Verify the malformed config was created - sequenceBuilder - .VerifyFileContains(mcpConfigPath, "invalid json"); - - // Step 2: Run aspire agent init + // Run aspire agent init and accept defaults (skill is pre-selected) sequenceBuilder .Type("aspire agent init") .Enter() .WaitUntil(s => workspacePathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) .Wait(500) .Enter() // Accept default workspace path - .WaitUntil(s => agentSelectPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) - .Type(" ") // Select first option (VS Code) - .Enter() - // Handle the additional options prompt - must select at least one item - // (Spectre.Console MultiSelectionPrompt requires at least one selection) - // Select the first skill file option which is harmless (doesn't touch mcp.json) - .WaitUntil(s => additionalOptionsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) - .Type(" ") // Select first additional option (skill file) - .Enter() - // After all prompts, wait for the error about malformed JSON and non-zero exit - .WaitUntil(s => - { - var hasError = malformedError.Search(s).Count > 0; - var hasSkip = skippingMessage.Search(s).Count > 0; - var hasCompletedWithErrors = completedWithErrors.Search(s).Count > 0; - return hasError && hasSkip && hasCompletedWithErrors; - }, TimeSpan.FromSeconds(30)) - .WaitForErrorPrompt(counter); + .WaitUntil(s => configurePrompt.Search(s).Count > 0 && skillOption.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Accept defaults (skill pre-selected) + .WaitUntil(s => configComplete.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitForSuccessPrompt(counter); - // Step 3: Verify the malformed file was NOT overwritten + // Verify skill file was created + var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspire", "SKILL.md"); sequenceBuilder - .VerifyFileContains(mcpConfigPath, "invalid json"); + .VerifyFileContains(skillFilePath, "aspire start"); sequenceBuilder .Type("exit") diff --git a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs index 4d9c42c482c..de89d5552bb 100644 --- a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs @@ -180,7 +180,7 @@ public async Task TryAddSkillFileApplicator_CreatesSkillFileWhenItDoesNotExist() Assert.True(File.Exists(skillFilePath)); var content = await File.ReadAllTextAsync(skillFilePath); Assert.Contains("# Aspire Skill", content); - Assert.Contains("Running Aspire in agent environments", content); + Assert.Contains("aspire start --isolated", content); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs index 61a8dd0397d..669e6c0d85a 100644 --- a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs @@ -84,7 +84,7 @@ public async Task McpCommandHasStartSubcommand() } [Fact] - public async Task McpCommandIsHidden() + public async Task McpCommandIsVisible() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); @@ -94,7 +94,7 @@ public async Task McpCommandIsHidden() var mcpCommand = rootCommand.Subcommands.FirstOrDefault(c => c.Name == "mcp"); Assert.NotNull(mcpCommand); - Assert.True(mcpCommand.Hidden, "The mcp command should be hidden for backward compatibility"); + Assert.False(mcpCommand.Hidden, "The mcp command should be visible (contains tools and call subcommands)"); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 6e49b87a023..afbf6f97885 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -16,6 +16,8 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; +using Spectre.Console.Rendering; +using Aspire.Cli.Backchannel; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.Commands; @@ -1363,6 +1365,80 @@ public override Task PromptForOutputPath(string path, CancellationToken } } +internal sealed class OrderTrackingInteractionService(List operationOrder) : IInteractionService +{ + public ConsoleOutput Console { get; set; } + + public Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) + { + return action(); + } + + public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) + { + action(); + } + + public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(defaultValue ?? string.Empty); + } + + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull + { + if (!choices.Any()) + { + throw new EmptyChoicesException($"No items available for selection: {promptText}"); + } + + // Track template option prompts + if (promptText?.Contains("Redis") == true || + promptText?.Contains("test framework") == true || + promptText?.Contains("Create a test project") == true || + promptText?.Contains("xUnit") == true) + { + operationOrder.Add("TemplateOption"); + } + + return Task.FromResult(choices.First()); + } + + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + { + if (!choices.Any()) + { + throw new EmptyChoicesException($"No items available for selection: {promptText}"); + } + + if (preSelected is not null) + { + return Task.FromResult>(preSelected.ToList()); + } + + return Task.FromResult>(choices.ToList()); + } + + public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; + public void DisplayError(string errorMessage) { } + public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } + public void DisplaySuccess(string message, bool allowMarkup = false) { } + public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } + public void DisplayCancellationMessage() { } + public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => Task.FromResult(true); + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { } + public void DisplayEmptyLine() { } + public void DisplayPlainText(string text) { } + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } + public void DisplayMarkdown(string markdown) { } + public void DisplayMarkupLine(string markup) { } + public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) { } + public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } + public void DisplayRenderable(IRenderable renderable) { } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); +} + internal sealed class NewCommandTestPackagingService : IPackagingService { public Func>>? GetChannelsAsyncCallback { get; set; } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 497e008ce15..4e10044b760 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -10,6 +10,9 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; +using Spectre.Console.Rendering; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.InternalTesting; @@ -841,6 +844,135 @@ internal sealed record PromptInputData(string Name, string Label, string InputTy internal sealed record PromptData(string PromptId, IReadOnlyList Inputs, string Message, string? Title = null); internal sealed record PromptCompletion(string PromptId, PublishingPromptInputAnswer[] Answers, bool UpdateResponse); +// Enhanced TestConsoleInteractionService that tracks interaction types +[SuppressMessage("Usage", "ASPIREINTERACTION001:Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] +internal sealed class TestConsoleInteractionServiceWithPromptTracking : IInteractionService +{ + private readonly Queue<(string response, ResponseType type)> _responses = new(); + private bool _shouldCancel; + + public ConsoleOutput Console { get; set; } + public List StringPromptCalls { get; } = []; + public List SelectionPromptCalls { get; } = []; // Using object to handle generic types + public List BooleanPromptCalls { get; } = []; + public List DisplayedErrors { get; } = []; + + public void SetupStringPromptResponse(string response) => _responses.Enqueue((response, ResponseType.String)); + public void SetupSelectionResponse(string response) => _responses.Enqueue((response, ResponseType.Selection)); + public void SetupBooleanResponse(bool response) => _responses.Enqueue((response.ToString().ToLower(), ResponseType.Boolean)); + public void SetupCancellationResponse() => _shouldCancel = true; + + public void SetupSequentialResponses(params (string response, ResponseType type)[] responses) + { + foreach (var (response, type) in responses) + { + _responses.Enqueue((response, type)); + } + } + + public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) + { + StringPromptCalls.Add(new StringPromptCall(promptText, defaultValue, isSecret)); + + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + + if (_responses.TryDequeue(out var response)) + { + return Task.FromResult(response.response); + } + + return Task.FromResult(defaultValue ?? string.Empty); + } + + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull + { + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + + if (_responses.TryDequeue(out var response)) + { + // Find the choice that matches the response + var matchingChoice = choices.FirstOrDefault(c => choiceFormatter(c) == response.response || c.ToString() == response.response); + if (matchingChoice != null) + { + return Task.FromResult(matchingChoice); + } + } + + return Task.FromResult(choices.First()); + } + + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + { + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + + _ = _responses.TryDequeue(out _); + + if (preSelected is not null) + { + return Task.FromResult>(preSelected.ToList()); + } + + // For simplicity, return all choices in the test + return Task.FromResult>(choices.ToList()); + } + + public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) + { + BooleanPromptCalls.Add(new BooleanPromptCall(promptText, defaultValue)); + + if (_shouldCancel || cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(); + } + + if (_responses.TryDequeue(out var response)) + { + return Task.FromResult(bool.Parse(response.response)); + } + + return Task.FromResult(defaultValue); + } + + // Default implementations for other interface methods + public Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) => action(); + public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) => action(); + public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; + public void DisplayError(string errorMessage) => DisplayedErrors.Add(errorMessage); + public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } + public void DisplaySuccess(string message, bool allowMarkup = false) { } + public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } + public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } + public void DisplayCancellationMessage() { } + public void DisplayEmptyLine() { } + public void DisplayPlainText(string text) { } + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } + public void DisplayMarkdown(string markdown) { } + public void DisplayMarkupLine(string markup) { } + + public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } + + public void DisplayRenderable(IRenderable renderable) { } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); + + public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) + { + var messageType = isErrorMessage ? "error" : "info"; + System.Console.WriteLine($"#{lineNumber} [{messageType}] {message}"); + } +} + // Input type constants that match the Aspire CLI implementation internal static class InputTypes { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index f66e03464b6..7879770275f 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -961,8 +961,8 @@ public Task ConfirmAsync(string promptText, bool defaultValue = true, Canc => _innerService.ConfirmAsync(promptText, defaultValue, cancellationToken); public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull => _innerService.PromptForSelectionAsync(promptText, choices, choiceFormatter, cancellationToken); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull - => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, optional, cancellationToken); + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, cancellationToken); public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => _innerService.DisplayIncompatibleVersionError(ex, appHostHostingVersion); public void DisplayError(string errorMessage) => _innerService.DisplayError(errorMessage); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 5045f14ea41..749efcba94d 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; +using Aspire.Cli.Interaction; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Templating; @@ -16,6 +17,8 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Aspire.Shared; +using Spectre.Console; +using Spectre.Console.Rendering; namespace Aspire.Cli.Tests.Templating; @@ -447,6 +450,52 @@ public bool IsFeatureEnabled(string featureFlag, bool defaultValue) } } + private sealed class TestInteractionService : IInteractionService + { + public ConsoleOutput Console { get; set; } + + public Task PromptForSelectionAsync(string prompt, IEnumerable choices, Func displaySelector, CancellationToken cancellationToken) where T : notnull + => throw new NotImplementedException(); + + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + => throw new NotImplementedException(); + + public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task ConfirmAsync(string prompt, bool defaultAnswer, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public Task ShowStatusAsync(string message, Func> work, KnownEmoji? emoji = null, bool allowMarkup = false) + => throw new NotImplementedException(); + + public Task ShowStatusAsync(string message, Func work) + => throw new NotImplementedException(); + + public void ShowStatus(string message, Action work, KnownEmoji? emoji = null, bool allowMarkup = false) + => throw new NotImplementedException(); + + public void DisplaySuccess(string message, bool allowMarkup = false) { } + public void DisplayError(string message) { } + public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { } + public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } + public void DisplayCancellationMessage() { } + public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; + public void DisplayPlainText(string text) { } + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } + public void DisplayMarkdown(string markdown) { } + public void DisplayMarkupLine(string markup) { } + public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } + public void DisplayEmptyLine() { } + public void DisplayVersionUpdateNotification(string message, string? updateCommand = null) { } + public void WriteConsoleLog(string message, int? resourceHashCode, string? resourceName, bool isError) { } + public void DisplayRenderable(IRenderable renderable) { } + public Task DisplayLiveAsync(IRenderable initialRenderable, Func, Task> callback) => callback(_ => { }); + } + private sealed class TestDotNetCliRunner : IDotNetCliRunner { public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index b4820e662e7..aa3bc81bc9b 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -54,13 +54,18 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any()) { throw new EmptyChoicesException($"No items available for selection: {promptText}"); } + if (preSelected is not null) + { + return Task.FromResult>(preSelected.ToList()); + } + return Task.FromResult>(choices.ToList()); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index 583166c4ba8..7bf0f3c190e 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -112,7 +112,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, bool optional = false, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, CancellationToken cancellationToken = default) where T : notnull { if (_shouldCancel || cancellationToken.IsCancellationRequested) { @@ -125,6 +125,13 @@ public Task> PromptForSelectionsAsync(string promptText, IEn } _ = _responses.TryDequeue(out _); + + // In tests, return pre-selected items if provided, otherwise all items + if (preSelected is not null) + { + return Task.FromResult>(preSelected.ToList()); + } + return Task.FromResult>(choices.ToList()); } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index fbe3b82c187..91cf0d342c4 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -186,6 +186,8 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient();