From d051a19a234e281c7b2da931b5fc55aef13dcf9e Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 23:07:04 +0000 Subject: [PATCH 1/3] docs: add command arguments, validation, and visibility to custom-resource-commands Documents new features from microsoft/aspire#16710: - Command arguments (InteractionInput, InputType, InteractionInputCollection) - Typed argument accessors (GetString, GetBoolean, GetInt32, GetDouble in C#; toArray, value, requiredValue in TypeScript) - Positional CLI argument passing - Argument validation (ValidateArguments callback, field-level errors) - Command visibility (ResourceCommandVisibility.Dashboard, Api, All) Covers both C# and TypeScript AppHost usage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fundamentals/custom-resource-commands.mdx | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) diff --git a/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx b/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx index 3f22f5d34..5d9aadac4 100644 --- a/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx +++ b/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx @@ -846,3 +846,333 @@ else exit 1 fi ``` + +## Command arguments + +Commands can declare input arguments that the dashboard renders as a prompt dialog before execution and that the CLI accepts as ordered positional values. Arguments are defined by setting `CommandOptions.Arguments` to an array of `InteractionInput` objects. + + + + +```csharp title="AppHost.cs" +using Aspire.Hosting.ApplicationModel; + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("myservice") + .WithCommand( + name: "send-message", + displayName: "Send Message", + executeCommand: context => + { + var text = context.Arguments.GetString("text"); + var repeat = context.Arguments.GetInt32("repeat") ?? 1; + + for (var i = 0; i < repeat; i++) + { + context.Logger.LogInformation("{Text}", text); + } + + return Task.FromResult(CommandResults.Success($"Sent '{text}' {repeat} time(s).")); + }, + commandOptions: new CommandOptions + { + Arguments = + [ + new InteractionInput { Name = "text", Label = "Message", InputType = InputType.Text, Required = true, MaxLength = 200 }, + new InteractionInput { Name = "repeat", Label = "Repeat", InputType = InputType.Number, Value = "1" }, + ] + }); + +builder.Build().Run(); +``` + + + + +```typescript title="apphost.ts" +import { + createBuilder, + InputType, + type ExecuteCommandContext, + type ExecuteCommandResult, +} from './.modules/aspire.js'; + +const builder = await createBuilder(); + +await builder + .addNodeApp("myservice", "./myservice", "src/server.ts") + .withCommand( + "send-message", + "Send Message", + async (context: ExecuteCommandContext): Promise => { + const args = await context.arguments(); + const text = await args.requiredValue("text"); + const repeat = Number(await args.value("repeat") ?? "1"); + + return { success: true, message: `Sent '${text}' ${repeat} time(s).` }; + }, + { + commandOptions: { + arguments: [ + { name: "text", label: "Message", inputType: InputType.Text, required: true, maxLength: 200 }, + { name: "repeat", label: "Repeat", inputType: InputType.Number, value: "1" }, + ] + } + }); + +await builder.build().run(); +``` + + + + +### InteractionInput properties + +Each `InteractionInput` object in the `Arguments` array configures one input field in the dashboard prompt and one positional argument for the CLI: + +| Property | Type | Description | +|----------|------|-------------| +| `Name` / `name` | `string` | Unique identifier used to read the value at runtime. | +| `Label` / `label` | `string` | Human-readable label shown in the dashboard input dialog. | +| `InputType` / `inputType` | `InputType` | Controls the widget type. See the table below. | +| `Value` / `value` | `string?` | Default value pre-filled in the dialog. | +| `Required` / `required` | `bool` | When `true`, the built-in validator rejects empty submissions. | +| `MaxLength` / `maxLength` | `int?` | Optional maximum character length for `Text` and `SecretText` inputs. | +| `Options` / `options` | `KeyValuePair[]?` | Required for `Choice` inputs. Each pair is `(value, label)`. | + +The `InputType` enum controls how the dashboard renders the field and how built-in validation works: + +| Value | Dashboard widget | CLI behavior | Built-in validation | +|-------|-----------------|--------------|---------------------| +| `Text` | Single-line text box | Positional string value | `Required`, `MaxLength` | +| `Number` | Number input | Positional string parsed as number | Must be a valid number | +| `Boolean` | Checkbox | `true` or `false` string | Must be `true` or `false` | +| `Choice` | Drop-down list | String must match one of `Options` | Must be a member of `Options` | +| `SecretText` | Masked text box | Positional string value | `Required`, `MaxLength` | + +### Read argument values at runtime + +The `ExecuteCommandContext` exposes an `Arguments` property (C#) / `arguments()` method (TypeScript) that returns an `InteractionInputCollection` for reading submitted values. + + + + +`InteractionInputCollection` provides typed convenience accessors for common argument reads: + +```csharp +var text = context.Arguments.GetString("text"); // string? +var repeat = context.Arguments.GetInt32("repeat"); // int? +var shout = context.Arguments.GetBoolean("shout"); // bool? +var amount = context.Arguments.GetDouble("amount"); // double? +``` + +All accessors return `null` when the argument is absent or empty. Use the `Required = true` property on `InteractionInput` to enforce presence before the callback runs. + + + + +`InteractionInputCollection` (returned by `await context.arguments()`) provides idiomatic async helpers: + +```typescript +const args = await context.arguments(); + +const all = await args.toArray(); // InteractionInput[] +const input = await args.required("text"); // InteractionInput (throws if absent) +const text = await args.value("text"); // string | undefined +const must = await args.requiredValue("text"); // string (throws if absent or empty) +``` + +Use `requiredValue` for arguments declared with `required: true` to get a non-nullable string directly. Use `value` when the argument is optional and you want to handle the absent case yourself. + + + + +### Pass arguments from the CLI + +When invoking a command from the CLI, supply argument values as ordered positional tokens after the command name. The CLI maps them onto the declared `Arguments` array by position: + +```bash title="Terminal" +aspire resource myservice send-message "Hello world" 3 --apphost MyApp.AppHost.csproj +``` + +In the example above, `"Hello world"` maps to the first argument (`text`) and `3` maps to the second (`repeat`). + + + +## Argument validation + +Commands can run server-side validation before the callback executes. Declare a `ValidateArguments` callback on `CommandOptions` to add custom rules on top of the built-in required, max-length, and type checks. + + + + +```csharp title="AppHost.cs" +using Aspire.Hosting.ApplicationModel; + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("myservice") + .WithCommand( + name: "deploy", + displayName: "Deploy", + executeCommand: context => + { + var target = context.Arguments.GetString("target")!; + // ... perform deployment ... + return Task.FromResult(CommandResults.Success($"Deployed to {target}.")); + }, + commandOptions: new CommandOptions + { + Arguments = + [ + new InteractionInput { Name = "target", Label = "Target", InputType = InputType.Text, Required = true }, + ], + ValidateArguments = context => + { + var target = context.Inputs.GetString("target"); + if (string.Equals(target, "prod", StringComparison.OrdinalIgnoreCase)) + { + context.AddValidationError("target", "Target must not be 'prod'."); + } + + return Task.CompletedTask; + } + }); + +builder.Build().Run(); +``` + + + + +```typescript title="apphost.ts" +import { + createBuilder, + InputType, + type ExecuteCommandContext, + type InputsDialogValidationContext, +} from './.modules/aspire.js'; + +const builder = await createBuilder(); + +await builder + .addNodeApp("myservice", "./myservice", "src/server.ts") + .withCommand( + "deploy", + "Deploy", + async (context: ExecuteCommandContext) => { + const args = await context.arguments(); + const target = await args.requiredValue("target"); + // ... perform deployment ... + return { success: true, message: `Deployed to ${target}.` }; + }, + { + commandOptions: { + arguments: [ + { name: "target", label: "Target", inputType: InputType.Text, required: true } + ], + validateArguments: async (context: InputsDialogValidationContext) => { + const inputs = await context.inputs(); + const target = await inputs.value("target"); + + if (target?.toLowerCase() === "prod") { + await context.addValidationError("target", "Target must not be 'prod'."); + } + } + } + }); + +await builder.build().run(); +``` + + + + +Built-in validation runs first (required fields, max length, choice membership, and type coercion). If built-in validation passes, the `ValidateArguments` callback runs next. Either kind of validation failure prevents the command callback from executing and returns structured field errors to the caller: + +- **Dashboard**: The input dialog stays open with inline field-level error messages. +- **CLI / MCP**: The command exits with a non-zero status and prints the field errors to stderr. + +The `ValidateArguments` context exposes the same `Inputs`/`inputs()` surface as `ExecuteCommandContext.Arguments`/`arguments()`, so the same typed accessors work in both places. + +## Command visibility + +By default, commands registered with `WithCommand` are visible to both the Aspire dashboard and to API/MCP callers. Use `CommandOptions.Visibility` to restrict or expand where a command appears: + +| Value | Where the command is available | +|-------|-------------------------------| +| `ResourceCommandVisibility.Dashboard` | Dashboard UI only | +| `ResourceCommandVisibility.Api` | API and MCP clients only | +| `ResourceCommandVisibility.All` | Both dashboard and API/MCP (default) | + + + + +```csharp title="AppHost.cs" +using Aspire.Hosting.ApplicationModel; + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("myservice") + // Only exposed through the API/MCP — not shown in the dashboard + .WithCommand( + name: "get-metrics", + displayName: "Get Metrics", + executeCommand: context => + { + // ... collect and return metrics ... + return Task.FromResult(CommandResults.Success( + message: "Metrics collected.", + value: new CommandResultData { Value = "{}", Format = CommandResultFormat.Json })); + }, + commandOptions: new CommandOptions + { + Visibility = ResourceCommandVisibility.Api, + }); + +builder.Build().Run(); +``` + + + + +```typescript title="apphost.ts" +import { + createBuilder, + ResourceCommandVisibility, + CommandResultFormat, +} from './.modules/aspire.js'; + +const builder = await createBuilder(); + +await builder + .addNodeApp("myservice", "./myservice", "src/server.ts") + // Only exposed through the API/MCP — not shown in the dashboard + .withCommand( + "get-metrics", + "Get Metrics", + async (_context) => { + return { + success: true, + message: "Metrics collected.", + data: { value: "{}", format: CommandResultFormat.Json }, + }; + }, + { + commandOptions: { + visibility: ResourceCommandVisibility.Api, + } + }); + +await builder.build().run(); +``` + + + + + From 26fa7f01eaf15b00af05c163259681ec5cbe42e7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 8 May 2026 17:21:04 -0700 Subject: [PATCH 2/3] docs: fix ResourceCommandVisibility names and accessor return types Verified against microsoft/aspire source: - ResourceCommandVisibility values are None, UI, Api (bit-combinable); the previously documented names Dashboard and All do not exist. Use UI | Api for "both" (the default). - InteractionInputCollection.GetInt32/GetBoolean/GetDouble return non-nullable values and throw on absent or unparseable input; only GetString returns string?. Updated comments and prose. - Replaced "GetInt32(...) ?? 1" in the example, which would not compile against a non-nullable int return, with int.TryParse against GetString. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fundamentals/custom-resource-commands.mdx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx b/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx index 5d9aadac4..5ace7e251 100644 --- a/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx +++ b/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx @@ -865,8 +865,8 @@ builder.AddProject("myservice") displayName: "Send Message", executeCommand: context => { - var text = context.Arguments.GetString("text"); - var repeat = context.Arguments.GetInt32("repeat") ?? 1; + var text = context.Arguments.GetString("text")!; + var repeat = int.TryParse(context.Arguments.GetString("repeat"), out var r) ? r : 1; for (var i = 0; i < repeat; i++) { @@ -961,13 +961,13 @@ The `ExecuteCommandContext` exposes an `Arguments` property (C#) / `arguments()` `InteractionInputCollection` provides typed convenience accessors for common argument reads: ```csharp -var text = context.Arguments.GetString("text"); // string? -var repeat = context.Arguments.GetInt32("repeat"); // int? -var shout = context.Arguments.GetBoolean("shout"); // bool? -var amount = context.Arguments.GetDouble("amount"); // double? +var text = context.Arguments.GetString("text"); // string? — null when absent or empty +var repeat = context.Arguments.GetInt32("repeat"); // int — throws when absent or unparseable +var shout = context.Arguments.GetBoolean("shout"); // bool — throws when absent or unparseable +var amount = context.Arguments.GetDouble("amount"); // double — throws when absent or unparseable ``` -All accessors return `null` when the argument is absent or empty. Use the `Required = true` property on `InteractionInput` to enforce presence before the callback runs. +`GetString` returns `null` when the argument is absent or empty. The numeric and boolean accessors throw `InvalidOperationException` when the value is missing and `FormatException` (or `OverflowException` for `GetInt32` and `GetDouble`) when the value can't be parsed. Use `Required = true` on `InteractionInput` to enforce presence before the callback runs, or call `GetString` first and parse the result manually if you need a fallback. @@ -1100,13 +1100,14 @@ The `ValidateArguments` context exposes the same `Inputs`/`inputs()` surface as ## Command visibility -By default, commands registered with `WithCommand` are visible to both the Aspire dashboard and to API/MCP callers. Use `CommandOptions.Visibility` to restrict or expand where a command appears: +By default, commands registered with `WithCommand` are visible to both the Aspire dashboard and to API/MCP callers. The `ResourceCommandVisibility` enum is bit-combinable, so you can restrict or expand where a command appears by setting `CommandOptions.Visibility`: | Value | Where the command is available | |-------|-------------------------------| -| `ResourceCommandVisibility.Dashboard` | Dashboard UI only | +| `ResourceCommandVisibility.UI` | Dashboard and other UI clients only | | `ResourceCommandVisibility.Api` | API and MCP clients only | -| `ResourceCommandVisibility.All` | Both dashboard and API/MCP (default) | +| `ResourceCommandVisibility.UI \| ResourceCommandVisibility.Api` | Both UI and API/MCP (default) | +| `ResourceCommandVisibility.None` | Hidden from all clients | @@ -1174,5 +1175,5 @@ await builder.build().run(); From 18d4ba32fc320580a14cec66a8d2d53ea3372f1f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 8 May 2026 17:49:53 -0700 Subject: [PATCH 3/3] docs: clarify custom command input property types Update the InteractionInput property table to reflect that Label is optional and that Choice options use the IReadOnlyList/InteractionInputOption shape from the product APIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../content/docs/fundamentals/custom-resource-commands.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx b/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx index 5ace7e251..96465780d 100644 --- a/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx +++ b/src/frontend/src/content/docs/fundamentals/custom-resource-commands.mdx @@ -934,12 +934,12 @@ Each `InteractionInput` object in the `Arguments` array configures one input fie | Property | Type | Description | |----------|------|-------------| | `Name` / `name` | `string` | Unique identifier used to read the value at runtime. | -| `Label` / `label` | `string` | Human-readable label shown in the dashboard input dialog. | +| `Label` / `label` | `string?` / `string \| undefined` | Optional human-readable label shown in the dashboard input dialog. When omitted, the name is used as the label. | | `InputType` / `inputType` | `InputType` | Controls the widget type. See the table below. | | `Value` / `value` | `string?` | Default value pre-filled in the dialog. | | `Required` / `required` | `bool` | When `true`, the built-in validator rejects empty submissions. | | `MaxLength` / `maxLength` | `int?` | Optional maximum character length for `Text` and `SecretText` inputs. | -| `Options` / `options` | `KeyValuePair[]?` | Required for `Choice` inputs. Each pair is `(value, label)`. | +| `Options` / `options` | `IReadOnlyList>?` / `InteractionInputOption[] \| undefined` | Required for `Choice` inputs. Each option provides the submitted key and display value. | The `InputType` enum controls how the dashboard renders the field and how built-in validation works: