Skip to content
Merged
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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ All notable changes to Wolfe.CommandLine will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] - 2026-08-26
## [0.2.0] - 2026-08-27

### Added

- **Auto-install on startup.** `CompletionAutoInstall.Run("<app>", args)` — called once before invoking the parsed command — installs completion for the user's current shell the first time the app runs: silently (with a notice on stderr) when the install lands in a directory the shell loads automatically, behind a `[y/N]` prompt when it would edit a startup file. It runs at most once per shell (a decline is remembered under the XDG state home), never throws, and stays quiet on CI, without an interactive terminal, during completion callbacks, and when the `completion` command itself is being run.

## [0.1.0] - 2026-08-27

### Added

Expand All @@ -13,4 +19,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- `completion install <shell>` prefers a directory the shell loads completions from automatically (fish always; bash when bash-completion is present; zsh when a writable `$fpath` directory exists), falling back to a managed block in the shell's startup file (always for pwsh).
- `completion uninstall <shell>` removes the completion from every location it may have been installed to.

[0.2.0]: https://github.com/tom-wolfe/Wolfe.CommandLine/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/tom-wolfe/Wolfe.CommandLine/releases/tag/v0.1.0
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ package management (`Directory.Packages.props`).
command name so multiple apps coexist in one startup file.
- `CompletionCommand` / `RootCommandExtensions.AddCompletions` — the drop-in surface; hosts with
their own presentation layer can call the installer directly instead.
- `CompletionAutoInstall` — startup auto-install: silent for completion-directory installs, `[y/N]`
prompt for startup-file edits, at most once per shell (`AutoInstallLedger` markers in the XDG
state home remember installs and declines). Skips CI, non-interactive streams, `[suggest:…]`
callbacks, and explicit `completion` invocations; the public `Run` never throws.
`AutoInstallConsole` is the terminal seam, `Shell.DetectCurrent` picks the shell (PowerShell
session signal wins over the login shell).

## Tests

Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Wolfe.CommandLine is a set of utility extensions for the [System.CommandLine](ht
Installation is as easy as just adding the package:

```bash
dotnet package add Wolfe.CommanLine
dotnet package add Wolfe.CommandLine
```

## Usage
Expand Down Expand Up @@ -46,5 +46,24 @@ Installation will pick a directory that the shell loads completions from automat
When no such directory is available (always for **pwsh**), it falls back to a managed
`# >>> my-app completion >>>` block in the shell's startup file. Uninstall sweeps both.

The scripts are thin bridges: on tab they run `my-app [suggest:<cursor>] "<line>"` and feed the
#### Auto-install

To skip the manual `completion install` step entirely, call the auto-installer once at startup,
before invoking the parsed command:

```csharp
var root = new RootCommand()
.AddCompletions("my-app");
await CompletionAutoInstall.Run("my-app", args);
return await root.Parse(args).InvokeAsync();
```

The first time the app runs, completion is installed for the user's current shell:
- silently (with a notice on stderr) when there is a directory the shell loads automatically
- behind a `[y/N]` prompt when it would have to edit a startup file

It runs at most once per shell, and never throws. It won't prompt unless the terminal is interactive,
and if the user declines, their answer is recorded under the XDG state home.

The installed scripts are thin bridges: on tab they run `my-app [suggest:<cursor>] "<line>"` and feed the
candidates back to the shell, so completions always match the installed binary and never go stale.
28 changes: 28 additions & 0 deletions src/Wolfe.CommandLine/Completions/AutoInstallLedger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Wolfe.CommandLine.Completions.Models;
using Wolfe.CommandLine.Completions.Models.Shells;

namespace Wolfe.CommandLine.Completions;

/// <summary>
/// Remembers, that auto-install already ran for a command, so the user is never asked twice.
/// One marker file per shell under the XDG state home.
/// </summary>
internal sealed class AutoInstallLedger(string command, CompletionEnvironment environment)
{
/// <summary>
/// Whether auto-install has already run for <paramref name="shell"/>.
/// </summary>
public bool Contains(Shell shell) => File.Exists(MarkerPath(shell));

/// <summary>
/// Records that auto-install ran for <paramref name="shell"/> with the given outcome.
/// </summary>
public void Record(Shell shell, AutoInstallOutcome outcome)
{
var path = MarkerPath(shell);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, $"{outcome}\n");
}

private string MarkerPath(Shell shell) => Path.Combine(environment.StateHome, command, $"completion.{shell.Name}");
}
88 changes: 88 additions & 0 deletions src/Wolfe.CommandLine/Completions/CompletionAutoInstall.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using Wolfe.CommandLine.Completions.Models;
using Wolfe.CommandLine.Completions.Models.Shells;

namespace Wolfe.CommandLine.Completions;

/// <summary>
/// Installs tab completion for the user's current shell on app startup, without a package-manager hook:
/// silently when the install lands in a directory the shell loads automatically, behind a yes/no prompt
/// when it would edit a startup file. Runs at most once per shell (a decline is remembered), and only when
/// a person is at an interactive terminal — never on CI, in a pipe, or during a completion callback.
/// </summary>
public static class CompletionAutoInstall
{
/// <summary>
/// Offers completion install for this invocation of <paramref name="command"/>. Call it once at startup,
/// before invoking the parsed command, passing the raw <paramref name="args"/>. Never throws: completion
/// is a convenience and must not break the command the user actually ran.
/// </summary>
public static async Task Run(string command, IReadOnlyList<string> args, CancellationToken ct = default)
{
try
{
await Run(command, args, CompletionEnvironment.Detect(), AutoInstallConsole.System(), ct);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
}
}

internal static async Task<AutoInstallOutcome> Run(
string command,
IReadOnlyList<string> args,
CompletionEnvironment environment,
AutoInstallConsole console,
CancellationToken ct = default
)
{
// A completion callback must answer with candidates only, and managing completion explicitly
// is the user already doing this by hand.
if (args.Any(argument => argument.StartsWith("[suggest", StringComparison.Ordinal))
|| args.FirstOrDefault() == "completion")
{
return AutoInstallOutcome.Skipped;
}

if (environment.IsContinuousIntegration || !console.IsInteractive())
{
return AutoInstallOutcome.Skipped;
}

if (Shell.DetectCurrent(environment) is not { } shell)
{
return AutoInstallOutcome.Skipped;
}

var ledger = new AutoInstallLedger(command, environment);
if (ledger.Contains(shell))
{
return AutoInstallOutcome.AlreadyHandled;
}

var installer = new CompletionInstaller(command, environment);

// A completion-directory install touches nothing the user owns, so it needs no permission — just a notice.
if (shell.CompletionFilePath(command, environment) is not null)
{
var installed = await installer.Install(shell, ct);
ledger.Record(shell, AutoInstallOutcome.Installed);
console.Notify(
$"Installed {shell} tab completion at {installed.Path}. " +
$"Remove with `{command} completion uninstall {shell}`.");
return AutoInstallOutcome.Installed;
}

var (startupFile, _) = shell.StartupFile(command, environment);
if (!console.Confirm($"Install {shell} tab completion? This adds a managed block to {startupFile}."))
{
ledger.Record(shell, AutoInstallOutcome.Declined);
console.Notify($"Skipped. Run `{command} completion install {shell}` to install later.");
return AutoInstallOutcome.Declined;
}

var outcome = await installer.Install(shell, ct);
ledger.Record(shell, AutoInstallOutcome.Installed);
console.Notify($"Installed in {outcome.Path}. Restart your shell to enable it.");
return AutoInstallOutcome.Installed;
}
}
37 changes: 37 additions & 0 deletions src/Wolfe.CommandLine/Completions/Models/AutoInstallConsole.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Wolfe.CommandLine.Completions.Models;

/// <summary>
/// The terminal auto-install talks through. <see cref="System"/> uses the real console (messages and the
/// prompt go to stderr, so a piped stdout stays clean); tests supply a scripted fixture.
/// </summary>
internal sealed class AutoInstallConsole
{
/// <summary>
/// Whether a person is on the other end (stdin, stdout, and stderr are all attached to a terminal).
/// </summary>
public required Func<bool> IsInteractive { get; init; }

/// <summary>
/// Asks a yes/no question, defaulting to no.
/// </summary>
public required Func<string, bool> Confirm { get; init; }

/// <summary>
/// Writes a one-line notice.
/// </summary>
public required Action<string> Notify { get; init; }

/// <summary>
/// The real console.
/// </summary>
public static AutoInstallConsole System() => new()
{
IsInteractive = static () => !Console.IsInputRedirected && !Console.IsOutputRedirected && !Console.IsErrorRedirected,
Confirm = static question =>
{
Console.Error.Write($"{question} [y/N] ");
return Console.ReadLine()?.Trim().ToLowerInvariant() is "y" or "yes";
},
Notify = static message => Console.Error.WriteLine(message),
};
}
27 changes: 27 additions & 0 deletions src/Wolfe.CommandLine/Completions/Models/AutoInstallOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Wolfe.CommandLine.Completions.Models;

/// <summary>
/// What an auto-install attempt did.
/// </summary>
internal enum AutoInstallOutcome
{
/// <summary>
/// The invocation was not a moment to act (completion callback, CI, no terminal, unknown shell).
/// </summary>
Skipped,

/// <summary>
/// A previous run already installed or was declined; nothing was asked again.
/// </summary>
AlreadyHandled,

/// <summary>
/// Completion was installed.
/// </summary>
Installed,

/// <summary>
/// The user declined the startup-file edit; the decline is remembered.
/// </summary>
Declined,
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,52 @@ public sealed class CompletionEnvironment
/// </summary>
public bool IsWindows { get; init; }

/// <summary>
/// The <c>XDG_STATE_HOME</c> override, when set.
/// </summary>
public string? XdgStateHome { get; init; }

/// <summary>
/// The user's login shell (the <c>SHELL</c> environment variable), when known.
/// </summary>
public string? LoginShell { get; init; }

/// <summary>
/// Whether the process was launched from a PowerShell session.
/// </summary>
public bool RunningInPowerShell { get; init; }

/// <summary>
/// Whether the process is running on a CI agent.
/// </summary>
public bool IsContinuousIntegration { get; init; }

internal string ConfigHome => XdgConfigHome is { Length: > 0 } ? XdgConfigHome : Path.Combine(Home, ".config");

internal string DataHome => XdgDataHome is { Length: > 0 } ? XdgDataHome : Path.Combine(Home, ".local", "share");

/// <summary>The real environment.</summary>
internal string StateHome => XdgStateHome is { Length: > 0 } ? XdgStateHome : Path.Combine(Home, ".local", "state");

/// <summary>
/// The real environment.
/// </summary>
public static CompletionEnvironment Detect() => new()
{
Home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
XdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"),
XdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"),
XdgStateHome = Environment.GetEnvironmentVariable("XDG_STATE_HOME"),
ZshFunctionPath = ProbeZshFunctionPath,
BashCompletionInstalled = ProbeBashCompletion,
IsWindows = OperatingSystem.IsWindows(),
LoginShell = Environment.GetEnvironmentVariable("SHELL"),
// pwsh exports PSModulePath; on Windows it is a machine-wide variable, so it only signals pwsh elsewhere.
RunningInPowerShell = !OperatingSystem.IsWindows()
&& Environment.GetEnvironmentVariable("PSModulePath") is { Length: > 0 },
// CI covers GitHub Actions, GitLab, CircleCI, Travis; TF_BUILD is Azure DevOps; JENKINS_URL is Jenkins.
IsContinuousIntegration = Environment.GetEnvironmentVariable("CI") is { Length: > 0 }
|| Environment.GetEnvironmentVariable("TF_BUILD") is { Length: > 0 }
|| Environment.GetEnvironmentVariable("JENKINS_URL") is { Length: > 0 },
};

private static readonly string[] BashCompletionMarkers =
Expand Down
23 changes: 23 additions & 0 deletions src/Wolfe.CommandLine/Completions/Models/Shells/Shell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ public static Shell Parse(string name) =>
All.FirstOrDefault(shell => shell.Name == name)
?? throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown shell.");

/// <summary>
/// The shell the user is running in, or null when it cannot be told or is unsupported.
/// </summary>
/// <remarks>
/// PowerShell is detected from the session (the login shell still names the Unix default there);
/// otherwise the login shell's basename decides.
/// </remarks>
public static Shell? DetectCurrent(CompletionEnvironment environment)
{
if (environment.IsWindows || environment.RunningInPowerShell)
{
return Pwsh;
}

if (environment.LoginShell is not { Length: > 0 } login)
{
return null;
}

var name = Path.GetFileName(login);
return All.FirstOrDefault(shell => shell.Name == name);
}

/// <summary>
/// The completion script for <paramref name="command"/>: registers a completer that, on tab, calls
/// <c>command [suggest:&lt;cursor&gt;] "&lt;line&gt;"</c> (System.CommandLine's suggest directive) and feeds
Expand Down
2 changes: 1 addition & 1 deletion src/Wolfe.CommandLine/Wolfe.CommandLine.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Version>0.1.0</Version>
<Version>0.2.0</Version>
<AssemblyVersion>$(Version.Split('-')[0])</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0])</FileVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
Loading