Skip to content
Draft
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
21 changes: 21 additions & 0 deletions docs/fragments/skills/winapp-cli/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Tab</kbd> after `winapp` will suggest commands, subcommands, and options.
31 changes: 31 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <word>` - The word currently being completed
- `--commandline <commandline>` - The full command line text as typed so far
- `--position <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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-llm-docs.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion src/winapp-CLI/WinApp.Cli.Tests/CliSchemaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _),
Expand Down
106 changes: 106 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/CompleteCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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<WinAppRootCommand>();
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<WinAppRootCommand>();
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<WinAppRootCommand>();
// 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<WinAppRootCommand>();
// 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<WinAppRootCommand>();
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<WinAppRootCommand>();
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");
}
}
83 changes: 83 additions & 0 deletions src/winapp-CLI/WinApp.Cli/Commands/CompleteCommand.cs
Original file line number Diff line number Diff line change
@@ -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<string> WordOption = new("--word")
{
Description = "The word being completed",
Arity = ArgumentArity.ZeroOrOne
};

public static readonly Option<string> CommandlineOption = new("--commandline")
{
Description = "The full command line text",
Arity = ArgumentArity.ZeroOrOne
};

public static readonly Option<int> 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<int> 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);
}
}
}
6 changes: 4 additions & 2 deletions src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -74,6 +75,7 @@ public WinAppRootCommand(
Subcommands.Add(toolCommand);
Subcommands.Add(msStoreCommand);
Subcommands.Add(createExternalCatalogCommand);
Subcommands.Add(completeCommand);

Options.Add(CliSchemaOption);

Expand All @@ -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)])
);
}
}
3 changes: 2 additions & 1 deletion src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi
.UseCommandHandler<SignCommand, SignCommand.Handler>()
.UseCommandHandler<ToolCommand, ToolCommand.Handler>()
.UseCommandHandler<MSStoreCommand, MSStoreCommand.Handler>(false)
.UseCommandHandler<CreateExternalCatalogCommand, CreateExternalCatalogCommand.Handler>();
.UseCommandHandler<CreateExternalCatalogCommand, CreateExternalCatalogCommand.Handler>()
.UseCommandHandler<CompleteCommand, CompleteCommand.Handler>(false);
}

public static IServiceCollection UseCommandHandler<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(this IServiceCollection services, bool addDefaultOptions = true)
Expand Down