From e2420a457fec37d12f456d20c8c6172f44940a24 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 20:57:35 +0000
Subject: [PATCH 1/2] Initial plan
From 3a4c86c99204b06b0eec050ac8e7428e490f8232 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 21:07:04 +0000
Subject: [PATCH 2/2] feat: add shell completion via `winapp complete` command
Agent-Logs-Url: https://github.com/microsoft/winappCli/sessions/74fa14bf-a1f5-4fa3-9a0d-3eba5885fdfb
Co-authored-by: nmetulev <711864+nmetulev@users.noreply.github.com>
---
docs/fragments/skills/winapp-cli/setup.md | 21 ++++
docs/usage.md | 31 +++++
scripts/generate-llm-docs.ps1 | 2 +-
.../WinApp.Cli.Tests/CliSchemaTests.cs | 2 +-
.../WinApp.Cli.Tests/CompleteCommandTests.cs | 106 ++++++++++++++++++
.../WinApp.Cli/Commands/CompleteCommand.cs | 83 ++++++++++++++
.../WinApp.Cli/Commands/WinAppRootCommand.cs | 6 +-
.../Helpers/HostBuilderExtensions.cs | 3 +-
8 files changed, 249 insertions(+), 5 deletions(-)
create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/CompleteCommandTests.cs
create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/CompleteCommand.cs
diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md
index c652f080..26568f6d 100644
--- a/docs/fragments/skills/winapp-cli/setup.md
+++ b/docs/fragments/skills/winapp-cli/setup.md
@@ -104,3 +104,24 @@ This updates `winapp.yaml` with the latest versions and reinstalls packages.
| "Directory not found" | Target directory doesn't exist | Create the directory first or check the path |
| SDK download fails | Network issue or firewall | Ensure internet access; check proxy settings |
| `init` prompts unexpectedly in CI | Missing `--use-defaults` flag | Add `--use-defaults` to skip all prompts |
+
+## Shell completion (PowerShell)
+
+Enable tab completion for `winapp` in PowerShell by adding the following snippet to your PowerShell profile (`$PROFILE`):
+
+```powershell
+Register-ArgumentCompleter -Native -CommandName winapp -ScriptBlock {
+ param($wordToComplete, $commandAst, $cursorPosition)
+ winapp complete --word "$wordToComplete" --commandline "$commandAst" --position $cursorPosition | ForEach-Object {
+ [System.Management.Automation.CompletionResult]::new($_)
+ }
+}
+```
+
+To apply immediately without restarting PowerShell, run:
+
+```powershell
+. $PROFILE
+```
+
+Once registered, pressing Tab after `winapp` will suggest commands, subcommands, and options.
diff --git a/docs/usage.md b/docs/usage.md
index d8117a67..143f474a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -606,6 +606,37 @@ winapp get-winapp-path [options]
---
+### complete
+
+Output shell completion suggestions for a partial command line. Designed for use with `Register-ArgumentCompleter` in PowerShell to enable tab completion.
+
+```bash
+winapp complete [options]
+```
+
+**Options:**
+
+- `--word ` - The word currently being completed
+- `--commandline ` - The full command line text as typed so far
+- `--position ` - The cursor position within the command line
+
+**PowerShell profile setup:**
+
+Add the following snippet to your PowerShell profile (`$PROFILE`) to enable tab completion for `winapp`:
+
+```powershell
+Register-ArgumentCompleter -Native -CommandName winapp -ScriptBlock {
+ param($wordToComplete, $commandAst, $cursorPosition)
+ winapp complete --word "$wordToComplete" --commandline "$commandAst" --position $cursorPosition | ForEach-Object {
+ [System.Management.Automation.CompletionResult]::new($_)
+ }
+}
+```
+
+After saving the profile, restart PowerShell (or run `. $PROFILE`) to activate tab completion.
+
+---
+
### node create-addon
*(Available in NPM package only)* Generate native C++ or C# addon templates with Windows SDK and Windows App SDK integration.
diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1
index 65eaa001..82b79a13 100644
--- a/scripts/generate-llm-docs.ps1
+++ b/scripts/generate-llm-docs.ps1
@@ -93,7 +93,7 @@ $SkillCommandMap = @{
"identity" = @("create-debug-identity")
"signing" = @("cert generate", "cert install", "cert info", "sign")
"manifest" = @("manifest generate", "manifest update-assets")
- "troubleshoot" = @("get-winapp-path", "tool", "store")
+ "troubleshoot" = @("get-winapp-path", "tool", "store", "complete")
"frameworks" = @() # No auto-generated command sections — links to guides
}
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CliSchemaTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CliSchemaTests.cs
index cdfcf515..e96ff52c 100644
--- a/src/winapp-CLI/WinApp.Cli.Tests/CliSchemaTests.cs
+++ b/src/winapp-CLI/WinApp.Cli.Tests/CliSchemaTests.cs
@@ -84,7 +84,7 @@ public async Task CliSchemaShouldContainSubcommands()
Assert.AreEqual(JsonValueKind.Object, subcommands.ValueKind, "Subcommands should be an object");
// Verify some known commands exist
- var expectedCommands = new[] { "init", "restore", "package", "manifest", "cert", "sign" };
+ var expectedCommands = new[] { "init", "restore", "package", "manifest", "cert", "sign", "complete" };
foreach (var commandName in expectedCommands)
{
Assert.IsTrue(subcommands.TryGetProperty(commandName, out _),
diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CompleteCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CompleteCommandTests.cs
new file mode 100644
index 00000000..1f2378fd
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli.Tests/CompleteCommandTests.cs
@@ -0,0 +1,106 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using WinApp.Cli.Commands;
+
+namespace WinApp.Cli.Tests;
+
+[TestClass]
+public class CompleteCommandTests : BaseCommandTests
+{
+ [TestMethod]
+ public async Task CompleteCommandShouldReturnZeroExitCode()
+ {
+ // Arrange
+ var rootCommand = GetRequiredService();
+ var args = new[] { "complete", "--word", "", "--commandline", "winapp ", "--position", "7" };
+
+ // Act
+ var exitCode = await ParseAndInvokeWithCaptureAsync(rootCommand, args);
+
+ // Assert
+ Assert.AreEqual(0, exitCode, "complete command should exit with 0");
+ }
+
+ [TestMethod]
+ public async Task CompleteCommandShouldOutputTopLevelSubcommands()
+ {
+ // Arrange
+ var rootCommand = GetRequiredService();
+ var args = new[] { "complete", "--word", "", "--commandline", "winapp ", "--position", "7" };
+
+ // Act
+ var exitCode = await ParseAndInvokeWithCaptureAsync(rootCommand, args);
+
+ // Assert
+ Assert.AreEqual(0, exitCode);
+ var output = TestAnsiConsole.Output;
+ Assert.IsTrue(output.Contains("init"), $"Expected 'init' in completions, got:\n{output}");
+ Assert.IsTrue(output.Contains("package"), $"Expected 'package' in completions, got:\n{output}");
+ Assert.IsTrue(output.Contains("cert"), $"Expected 'cert' in completions, got:\n{output}");
+ }
+
+ [TestMethod]
+ public async Task CompleteCommandShouldFilterByPartialWord()
+ {
+ // Arrange
+ var rootCommand = GetRequiredService();
+ // Typing "winapp ce" — expect "cert" as a completion
+ var args = new[] { "complete", "--word", "ce", "--commandline", "winapp ce", "--position", "9" };
+
+ // Act
+ var exitCode = await ParseAndInvokeWithCaptureAsync(rootCommand, args);
+
+ // Assert
+ Assert.AreEqual(0, exitCode);
+ var output = TestAnsiConsole.Output;
+ Assert.IsTrue(output.Contains("cert"), $"Expected 'cert' in filtered completions, got:\n{output}");
+ }
+
+ [TestMethod]
+ public async Task CompleteCommandShouldReturnSubcommandCompletions()
+ {
+ // Arrange
+ var rootCommand = GetRequiredService();
+ // Typing "winapp cert " — expect cert subcommands
+ var args = new[] { "complete", "--word", "", "--commandline", "winapp cert ", "--position", "12" };
+
+ // Act
+ var exitCode = await ParseAndInvokeWithCaptureAsync(rootCommand, args);
+
+ // Assert
+ Assert.AreEqual(0, exitCode);
+ var output = TestAnsiConsole.Output;
+ Assert.IsTrue(output.Contains("generate"), $"Expected 'generate' in cert subcommand completions, got:\n{output}");
+ Assert.IsTrue(output.Contains("install"), $"Expected 'install' in cert subcommand completions, got:\n{output}");
+ Assert.IsTrue(output.Contains("info"), $"Expected 'info' in cert subcommand completions, got:\n{output}");
+ }
+
+ [TestMethod]
+ public async Task CompleteCommandShouldHandleEmptyCommandLine()
+ {
+ // Arrange
+ var rootCommand = GetRequiredService();
+ var args = new[] { "complete", "--commandline", "winapp", "--position", "6" };
+
+ // Act
+ var exitCode = await ParseAndInvokeWithCaptureAsync(rootCommand, args);
+
+ // Assert — should not throw, just return 0 with no output (or subcommand list)
+ Assert.AreEqual(0, exitCode, "complete command should handle a bare program name gracefully");
+ }
+
+ [TestMethod]
+ public async Task CompleteCommandShouldHandleMissingOptions()
+ {
+ // Arrange — invoke with no options at all
+ var rootCommand = GetRequiredService();
+ var args = new[] { "complete" };
+
+ // Act
+ var exitCode = await ParseAndInvokeWithCaptureAsync(rootCommand, args);
+
+ // Assert — should succeed with exit code 0
+ Assert.AreEqual(0, exitCode, "complete command should succeed even without options");
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli/Commands/CompleteCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/CompleteCommand.cs
new file mode 100644
index 00000000..efe44a93
--- /dev/null
+++ b/src/winapp-CLI/WinApp.Cli/Commands/CompleteCommand.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
+// Licensed under the MIT License.
+
+using System.CommandLine;
+using System.CommandLine.Invocation;
+using System.CommandLine.Parsing;
+
+namespace WinApp.Cli.Commands;
+
+internal class CompleteCommand : Command, IShortDescription
+{
+ public string ShortDescription => "Output shell completion suggestions for use with Register-ArgumentCompleter";
+
+ public static readonly Option WordOption = new("--word")
+ {
+ Description = "The word being completed",
+ Arity = ArgumentArity.ZeroOrOne
+ };
+
+ public static readonly Option CommandlineOption = new("--commandline")
+ {
+ Description = "The full command line text",
+ Arity = ArgumentArity.ZeroOrOne
+ };
+
+ public static readonly Option PositionOption = new("--position")
+ {
+ Description = "The cursor position within the command line",
+ Arity = ArgumentArity.ZeroOrOne
+ };
+
+ public CompleteCommand() : base("complete", "Output shell completion suggestions for a partial command line. Used with Register-ArgumentCompleter in PowerShell to enable tab completion.")
+ {
+ Options.Add(WordOption);
+ Options.Add(CommandlineOption);
+ Options.Add(PositionOption);
+ }
+
+ public class Handler : AsynchronousCommandLineAction
+ {
+ public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default)
+ {
+ var commandLine = parseResult.GetValue(CommandlineOption) ?? string.Empty;
+ var position = parseResult.GetValue(PositionOption);
+
+ // Navigate up the parse tree to obtain the root command
+ var commandResult = parseResult.CommandResult;
+ while (commandResult.Parent is CommandResult parent)
+ {
+ commandResult = parent;
+ }
+ var rootCommand = (RootCommand)commandResult.Command;
+
+ // Strip the leading program name (e.g., "winapp ") from the command line
+ // so that the root command can parse the remaining tokens for completion
+ var firstSpaceIdx = commandLine.IndexOf(' ');
+ string partialLine;
+ int adjustedPosition;
+ if (firstSpaceIdx >= 0)
+ {
+ partialLine = commandLine[(firstSpaceIdx + 1)..];
+ adjustedPosition = Math.Max(0, position - (firstSpaceIdx + 1));
+ }
+ else
+ {
+ // Only the program name typed — return top-level subcommand completions
+ partialLine = string.Empty;
+ adjustedPosition = 0;
+ }
+
+ var completionResult = rootCommand.Parse(partialLine);
+ var completions = completionResult.GetCompletions(adjustedPosition);
+
+ var output = parseResult.InvocationConfiguration.Output;
+ foreach (var completion in completions)
+ {
+ output.WriteLine(completion.Label);
+ }
+
+ return Task.FromResult(0);
+ }
+ }
+}
diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs
index 36ff3b9c..09c832b9 100644
--- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs
+++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs
@@ -60,7 +60,8 @@ public WinAppRootCommand(
ToolCommand toolCommand,
MSStoreCommand msStoreCommand,
IAnsiConsole ansiConsole,
- CreateExternalCatalogCommand createExternalCatalogCommand) : base("CLI for Windows app development, including package identity, packaging, managing appxmanifest.xml, test certificates, Windows (App) SDK projections, and more. For use with any app framework targeting Windows")
+ CreateExternalCatalogCommand createExternalCatalogCommand,
+ CompleteCommand completeCommand) : base("CLI for Windows app development, including package identity, packaging, managing appxmanifest.xml, test certificates, Windows (App) SDK projections, and more. For use with any app framework targeting Windows")
{
Subcommands.Add(initCommand);
Subcommands.Add(restoreCommand);
@@ -74,6 +75,7 @@ public WinAppRootCommand(
Subcommands.Add(toolCommand);
Subcommands.Add(msStoreCommand);
Subcommands.Add(createExternalCatalogCommand);
+ Subcommands.Add(completeCommand);
Options.Add(CliSchemaOption);
@@ -82,7 +84,7 @@ public WinAppRootCommand(
helpOption.Action = new CustomHelpAction(this, ansiConsole,
("Setup", [typeof(InitCommand), typeof(RestoreCommand), typeof(UpdateCommand)]),
("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]),
- ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand)])
+ ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand), typeof(CompleteCommand)])
);
}
}
diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs
index 8418c178..09f41cc5 100644
--- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs
+++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs
@@ -63,7 +63,8 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi
.UseCommandHandler()
.UseCommandHandler()
.UseCommandHandler(false)
- .UseCommandHandler();
+ .UseCommandHandler()
+ .UseCommandHandler(false);
}
public static IServiceCollection UseCommandHandler<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(this IServiceCollection services, bool addDefaultOptions = true)