diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 4786e3f67ed..2d03b6e6920 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.CommandLine.Help; #if DEBUG using System.Globalization; @@ -121,13 +122,20 @@ public RootCommand( Options.Add(WaitForDebuggerOption); Options.Add(CliWaitForDebuggerOption); - // Handle standalone 'aspire --banner' (no subcommand) + // Handle standalone 'aspire' or 'aspire --banner' (no subcommand) this.SetAction((context, cancellationToken) => { var bannerRequested = context.GetValue(BannerOption); - // If --banner was passed, we've already shown it in Main, just exit successfully - // Otherwise, show the standard "no command" error - return Task.FromResult(bannerRequested ? 0 : 1); + if (bannerRequested) + { + // If --banner was passed, we've already shown it in Main, just exit successfully + return Task.FromResult(ExitCodeConstants.Success); + } + + // No subcommand provided - show help but return InvalidCommand to signal usage error + // This is consistent with other parent commands (DocsCommand, SdkCommand, etc.) + new HelpAction().Invoke(context); + return Task.FromResult(ExitCodeConstants.InvalidCommand); }); Subcommands.Add(newCommand); diff --git a/src/Aspire.Cli/Mcp/McpErrorMessages.cs b/src/Aspire.Cli/Mcp/McpErrorMessages.cs index 3f722db771a..e43d1cba90a 100644 --- a/src/Aspire.Cli/Mcp/McpErrorMessages.cs +++ b/src/Aspire.Cli/Mcp/McpErrorMessages.cs @@ -13,7 +13,7 @@ internal static class McpErrorMessages /// public const string NoAppHostRunning = "No Aspire AppHost is currently running. " + - "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " + + "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run --detach' in your AppHost project directory. " + "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands."; /// diff --git a/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs new file mode 100644 index 00000000000..53929cf7da7 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class CacheCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task CacheCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task CacheCommandWithHelpArgumentReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs index ee4354cd54a..61a8dd0397d 100644 --- a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs @@ -10,6 +10,21 @@ namespace Aspire.Cli.Tests.Commands; public class McpCommandTests(ITestOutputHelper outputHelper) { + [Fact] + public async Task McpCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("mcp"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + [Fact] public async Task McpCommandWithHelpArgumentReturnsZero() { @@ -82,6 +97,21 @@ public async Task McpCommandIsHidden() Assert.True(mcpCommand.Hidden, "The mcp command should be hidden for backward compatibility"); } + [Fact] + public async Task AgentCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + [Fact] public async Task AgentCommandWithHelpArgumentReturnsZero() { diff --git a/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs new file mode 100644 index 00000000000..e39d7afb833 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class SdkCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task SdkCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("sdk"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task SdkCommandWithHelpArgumentReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("sdk --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index d941afded2c..a83f51ff414 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -34,6 +34,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenNoAppHostRunnin () => tool.CallToolAsync(null!, CreateArguments("test-resource", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs index 0cd6eba5427..7f7b23bf0cb 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs @@ -28,6 +28,7 @@ public async Task ListConsoleLogsTool_ThrowsException_WhenNoAppHostRunning() () => tool.CallToolAsync(null!, arguments, CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs index 3dffdbf757f..26384f0af29 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs @@ -22,6 +22,7 @@ public async Task ListResourcesTool_ThrowsException_WhenNoAppHostRunning() () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact]