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)