Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/aspire/references/resource-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ aspire resource <resource> start
aspire resource <resource> stop
aspire resource <resource> restart
aspire resource <resource> <command>
aspire resource <resource> <command> --non-interactive
```

Keep these points in mind:

- Prefer resource-scoped commands when the task does not require an AppHost-wide restart.
- If the user says one resource is wedged, use `aspire resource <resource> restart` before escalating to `aspire start`.
- Use `aspire resource <resource> <command>` when the AppHost exposes a resource-specific dashboard or operational command.
- Resource commands that declare a confirmation message prompt before execution. Use `--non-interactive` only when automation should explicitly accept that confirmation without prompting.
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl
c => c.Name,
c => new ResourceCommandJson
{
Description = c.Description
Description = c.Description,
ConfirmationMessage = c.ConfirmationMessage
});

// Get source information using the shared ResourceSourceViewModel
Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Cli/Commands/ResourceCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ internal sealed class ResourceCommand : BaseCommand
};

private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
private static readonly Option<bool> s_nonInteractiveOption = new("--non-interactive")
{
Description = ResourceCommandStrings.NonInteractiveOptionDescription
};
Comment on lines +36 to +39
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--non-interactive is already defined as a recursive global option on RootCommand (see RootCommand.NonInteractiveOption). Adding a second Option<bool>("--non-interactive") on ResourceCommand can create ambiguous binding depending on where the flag appears (e.g. aspire --non-interactive resource ... may bind to the root option while this code reads the resource-scoped option), which would break the intended auto-confirm behavior in non-interactive runs. Consider removing the resource-scoped option and instead using RootCommand.NonInteractiveOption for PromptBinding.Create(...) (or introduce a distinct option name for the resource-confirmation override).

Suggested change
private static readonly Option<bool> s_nonInteractiveOption = new("--non-interactive")
{
Description = ResourceCommandStrings.NonInteractiveOptionDescription
};
private static readonly Option<bool> s_nonInteractiveOption = RootCommand.NonInteractiveOption;

Copilot uses AI. Check for mistakes.

/// <summary>
/// Well-known commands with their display metadata.
Expand Down Expand Up @@ -63,13 +67,15 @@ public ResourceCommand(
Arguments.Add(s_resourceArgument);
Arguments.Add(s_commandArgument);
Options.Add(s_appHostOption);
Options.Add(s_nonInteractiveOption);
}

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var resourceName = parseResult.GetValue(s_resourceArgument)!;
var commandName = parseResult.GetValue(s_commandArgument)!;
var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
var confirmationBinding = PromptBinding.Create(parseResult, s_nonInteractiveOption);

var result = await _connectionResolver.ResolveConnectionAsync(
passedAppHostProjectFile,
Expand All @@ -95,6 +101,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
knownCommand.ProgressVerb,
knownCommand.BaseVerb,
knownCommand.PastTenseVerb,
confirmationBinding,
cancellationToken);
}

Expand All @@ -104,6 +111,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
_logger,
resourceName,
commandName,
confirmationBinding,
cancellationToken);
}
}
49 changes: 49 additions & 0 deletions src/Aspire.Cli/Commands/ResourceCommandHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal static class ResourceCommandHelper
/// <param name="progressVerb">The verb to display during progress (e.g., "Starting", "Stopping").</param>
/// <param name="baseVerb">The base verb for error messages (e.g., "start", "stop").</param>
/// <param name="pastTenseVerb">The past tense verb for success messages (e.g., "started", "stopped").</param>
/// <param name="confirmationBinding">The binding that controls command confirmation prompts.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Exit code indicating success or failure.</returns>
public static async Task<int> ExecuteResourceCommandAsync(
Expand All @@ -35,10 +36,17 @@ public static async Task<int> ExecuteResourceCommandAsync(
string progressVerb,
string baseVerb,
string pastTenseVerb,
PromptBinding<bool>? confirmationBinding,
CancellationToken cancellationToken)
{
logger.LogDebug("{Verb} resource '{ResourceName}'", progressVerb, resourceName);

if (!await ConfirmExecutionAsync(connection, interactionService, resourceName, commandName, confirmationBinding, cancellationToken).ConfigureAwait(false))
{
interactionService.DisplayMessage(KnownEmojis.Warning, $"{progressVerb} command for '{resourceName}' was canceled.");
return ExitCodeConstants.FailedToExecuteResourceCommand;
}

var response = await interactionService.ShowStatusAsync(
$"{progressVerb} resource '{resourceName}'...",
async () => await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken));
Expand All @@ -55,13 +63,20 @@ public static async Task<int> ExecuteGenericCommandAsync(
ILogger logger,
string resourceName,
string commandName,
PromptBinding<bool>? confirmationBinding,
CancellationToken cancellationToken)
{
logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName);

// Route status messages to stderr so command results in stdout remain pipeable (e.g., | jq)
interactionService.Console = ConsoleOutput.Error;

if (!await ConfirmExecutionAsync(connection, interactionService, resourceName, commandName, confirmationBinding, cancellationToken).ConfigureAwait(false))
{
interactionService.DisplayMessage(KnownEmojis.Warning, $"Command '{commandName}' on '{resourceName}' was canceled.");
return ExitCodeConstants.FailedToExecuteResourceCommand;
}

var response = await interactionService.ShowStatusAsync(
$"Executing command '{commandName}' on resource '{resourceName}'...",
async () => await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken));
Expand Down Expand Up @@ -91,6 +106,40 @@ public static async Task<int> ExecuteGenericCommandAsync(
return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand;
}

private static async Task<bool> ConfirmExecutionAsync(
IAppHostAuxiliaryBackchannel connection,
IInteractionService interactionService,
string resourceName,
string commandName,
PromptBinding<bool>? confirmationBinding,
CancellationToken cancellationToken)
{
var confirmationMessage = await GetConfirmationMessageAsync(connection, resourceName, commandName, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(confirmationMessage))
{
return true;
}

return await interactionService.PromptConfirmAsync(confirmationMessage, confirmationBinding, cancellationToken).ConfigureAwait(false);
}

private static async Task<string?> GetConfirmationMessageAsync(
IAppHostAuxiliaryBackchannel connection,
string resourceName,
string commandName,
CancellationToken cancellationToken)
{
var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);
var resources = ResourceSnapshotMapper.ResolveResources(resourceName, snapshots);
if (resources.Count == 0)
{
return null;
}

var command = resources[0].Commands.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparison.OrdinalIgnoreCase));
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetConfirmationMessageAsync looks up the command by name but doesn't consider the command state. If a command exists but is Disabled/Hidden, the CLI will still prompt for confirmation and then the backchannel execution will fail anyway, which is a confusing flow. Consider filtering to State == "Enabled" (or whatever enabled value is expected) before prompting, or skipping confirmation when the command isn't executable.

Suggested change
var command = resources[0].Commands.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparison.OrdinalIgnoreCase));
var command = resources[0].Commands.FirstOrDefault(c =>
string.Equals(c.Name, commandName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.State, "Enabled", StringComparison.OrdinalIgnoreCase));

Copilot uses AI. Check for mistakes.
return command?.ConfirmationMessage;
}

private static int HandleResponse(
ExecuteResourceCommandResponse response,
IInteractionService interactionService,
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Aspire.Cli/Resources/ResourceCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,7 @@
<data name="CommandNameArgumentDescription" xml:space="preserve">
<value>The name of the command to execute (e.g. start, stop, restart)</value>
</data>
<data name="NonInteractiveOptionDescription" xml:space="preserve">
<value>Execute without prompting for command confirmation</value>
</data>
</root>
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ public async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync([Enu
Name = c.Name,
DisplayName = c.DisplayName,
Description = c.DisplayDescription,
ConfirmationMessage = c.ConfirmationMessage,
State = c.State.ToString()
})
.ToArray();
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,11 @@ internal sealed class ResourceSnapshotCommand
/// </summary>
public string? Description { get; init; }

/// <summary>
/// Gets the confirmation message shown before executing the command.
/// </summary>
public string? ConfirmationMessage { get; init; }

/// <summary>
/// Gets the state of the command (e.g., "Enabled", "Disabled", "Hidden").
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Shared/Model/Serialization/ResourceJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,9 @@ internal sealed class ResourceCommandJson
/// The description of the command.
/// </summary>
public string? Description { get; set; }

/// <summary>
/// The confirmation message shown before executing the command.
/// </summary>
public string? ConfirmationMessage { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly()
],
Commands =
[
new ResourceSnapshotCommand { Name = "stop", State = "Enabled", Description = "Stop" },
new ResourceSnapshotCommand { Name = "stop", State = "Enabled", Description = "Stop", ConfirmationMessage = "Stop frontend?" },
new ResourceSnapshotCommand { Name = "start", State = "Disabled", Description = "Start" }
],
EnvironmentVariables =
Expand All @@ -46,6 +46,7 @@ public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly()
// Only enabled commands should be included
Assert.Single(result.Commands!);
Assert.True(result.Commands!.ContainsKey("stop"));
Assert.Equal("Stop frontend?", result.Commands["stop"].ConfirmationMessage);

// Only IsFromSpec environment variables should be included
Assert.Single(result.Environment!);
Expand Down
Loading
Loading