Skip to content

Latest commit

 

History

History
335 lines (262 loc) · 13.3 KB

File metadata and controls

335 lines (262 loc) · 13.3 KB

Shell Completion (Bash + PowerShell + Zsh + Fish + Nushell)

This page describes shell completion support in Repl Toolkit, including setup modes and install commands.

Overview

Completion resolution is done by a bridge command:

completion __complete --shell <bash|powershell|zsh|fish|nu> --line <input> --cursor <position>

The shell passes current line + cursor, and Repl returns candidates on stdout (one per line). completion __complete is mapped in the regular command graph through the shell-completion module (CLI channel only). The bridge route is marked as protocol passthrough, so repl suppresses banners and routes framework diagnostics to stderr. The bridge handler writes candidates through IReplIoContext.Output, which remains bound to the protocol stream (stdout) in local CLI passthrough.

IReplIoContext is optional in general protocol commands:

  • optional for local CLI commands that already use Console.* directly
  • recommended when handlers need explicit stream injection, deterministic tests, or hosted-session compatibility

The module exposes a real completion context scope:

  • completion install
  • completion uninstall
  • completion status
  • completion detect-shell
  • completion __complete (protocol bridge; hidden)

Runtime setup modes

Shell completion behavior is configured through ReplOptions.ShellCompletion:

  • Enabled (default: true)
  • SetupMode (default: Manual)
  • PreferredShell (optional override)
  • PromptOnce (default: true)
  • StateFilePath (optional)
  • BashProfilePath / PowerShellProfilePath / ZshProfilePath / FishProfilePath / NuProfilePath (optional overrides)

SetupMode values:

  • Manual: no automatic profile mutation. User runs install/uninstall commands.
  • Prompt: interactive startup can propose installation once.
  • Auto: interactive startup installs automatically when a supported shell is confidently detected.

Prompt and Auto apply only when entering interactive mode. Terminal one-shot commands never auto-install.

User commands

The management surface is:

completion install [--shell bash|powershell|zsh|fish|nu] [--force] [--silent]
completion uninstall [--shell bash|powershell|zsh|fish|nu] [--silent]
completion status
completion detect-shell

These commands are CLI-only (they are not available in interactive mode or hosted session mode). They also require invoking the app through its own executable command head (the running process must match the app binary).

Structured output is supported via global output flags:

  • completion status --json
  • completion detect-shell --output:json
  • completion install --json
  • completion uninstall --json

Notes:

  • completion install writes a managed block in the shell profile.
  • completion uninstall removes only the managed block.
  • completion status prints mode, detection, profile paths, profile existence, and install status.
  • completion detect-shell prints detected shell and detection reason.
  • for Nushell, a shared global dispatcher block is managed in addition to per-app blocks. If another app already manages it, use --force to merge.

Status output (anonymized example)

Human output:

Enabled                : True
Setup mode             : Manual
Detected shell         : powershell (env suggests PowerShell; parent process chain: <process-a> -> <process-b>)
Bash profile           : <home>/.bashrc
Bash profile exists    : True
Bash installed         : False
PowerShell profile     : <documents>/PowerShell/Microsoft.PowerShell_profile.ps1
PowerShell profile exists: True
PowerShell installed   : False
Zsh profile            : <home>/.zshrc
Zsh profile exists     : False
Zsh installed          : False
Fish profile           : <config>/fish/config.fish
Fish profile exists    : False
Fish installed         : False
Nushell profile        : <config>/nushell/config.nu
Nushell profile exists : False
Nushell installed      : False

JSON output includes these per-shell fields:

  • bashProfilePath, bashProfileExists, bashInstalled
  • powerShellProfilePath, powerShellProfileExists, powerShellInstalled
  • zshProfilePath, zshProfileExists, zshInstalled
  • fishProfilePath, fishProfileExists, fishInstalled
  • nuProfilePath, nuProfileExists, nuInstalled

Detection strategy

Shell detection is best-effort and uses weighted signals:

  1. PreferredShell override (highest priority).
  2. Environment variables (for example BASH_VERSION, SHELL, PSModulePath).
  3. Parent/grand-parent process names as validation (bash, zsh, fish, nu, nushell, pwsh, powershell).

If signals are conflicting or weak, result is unknown (no auto-install in Auto mode).

What completion returns (current scope)

  • Command literals from the mapped graph.
  • Static command options from handler parameters (resolved terminal routes).
  • Static global options (--help, --interactive, --no-interactive, --no-logo, output aliases, --output:<format>, --answer:<name>, and the result-flow flags --result:page-size, --result:cursor, --result:pager, --result:all).
  • Enum member names for a pending enum-typed route option.
  • WithCompletion(...) provider values — opt-in per provider (see below).

Not included:

  • Dynamic data values for contexts.

Value providers are opt-in

Every shell Tab spawns a new process and blocks the user's shell until the bridge answers, so a slow completion provider (network, database) must never run there implicitly. A provider only serves shell completion when its registration opts in:

app.Map("contact inspect {clientId}", (string clientId) => Inspect(clientId))
   .WithCompletion(
       "clientId",
       (ctx, input, ct) => LookupClientIdsAsync(input, ct),
       CompletionProviderScope.InteractiveAndShell);

The default scope (CompletionProviderScope.Interactive) keeps the provider on in-process surfaces only: the interactive Tab menu and the complete ambient command. Opt in only when the provider is fast enough for a blocking shell Tab, such as in-memory or local lookups. Opted-in providers complete both the positional value being typed and a pending route option's value, with the same binding rules as the interactive menu.

Bridge-side guarantees for provider values:

  • Each provider invocation is bounded by ShellCompletionOptions.ProviderTimeout (default: 1 second) — a stalled provider is abandoned and completion degrades to the static candidates instead of blocking the user's shell.
  • The bridge protocol is line-delimited plain text, so values containing control characters (newlines, ANSI/OSC sequences) are rejected whole rather than forwarded to the shell's completion UI.
  • Values are emitted as literal shell data — a value needing quoting is single-quoted (New York'New York', $(cmd)'$(cmd)', never a form the shell would interpolate). A value containing an apostrophe is dropped (no single-quote literal can hold one without a shell-specific escape the bridge can't re-parse on the next Tab); a backslash is fine except on fish, whose single quotes escape it. The provider itself receives the decoded value prefix.
  • Completion requested from inside an already-open quote (e.g. contact "Ne) yields no provider values, since the bridge cannot safely reshape the user's opening quote.

Managed profile blocks

Install/uninstall is idempotent through namespaced markers:

  • # >>> repl completion [appId=<app-id>;shell=<bash|powershell|zsh|fish|nu>] >>>
  • # <<< repl completion [appId=<app-id>;shell=<bash|powershell|zsh|fish|nu>] <<<

Update/remove targets only the block matching the current app and shell.

Updating after an upgrade

The installed profile block is a thin shim: it forwards the command line to <app> completion __complete and renders the result, so all completion logic lives in the app binary and is picked up automatically the next time you run the upgraded binary. The block itself changes only rarely (e.g. a shell adapter fix). When it does, re-run completion install — it idempotently rewrites the managed block between the markers. Auto/Prompt setup does not rewrite an already-installed block (it only checks for the markers, which carry no version), so a block-level fix requires this manual re-install. The bridge protocol and marker format are stable, so an old block keeps working with a new binary and vice versa.

Manual setup snippets

Bash:

_myapp_complete() {
  local line cursor
  local candidate
  line="$COMP_LINE"
  cursor="$COMP_POINT"
  COMPREPLY=()
  while IFS= read -r candidate; do
    COMPREPLY[${#COMPREPLY[@]}]="$candidate"
  done < <(myapp completion __complete --shell bash --line "$line" --cursor "$cursor" --no-interactive --no-logo)
}

complete -F _myapp_complete myapp

PowerShell:

$__replCompletionCommandNames = @('myapp')
$__replCompleter = {
    param($wordToComplete, $commandAst, $cursorPosition)

    $invokedCommand = if ($commandAst.CommandElements.Count -gt 0 -and $commandAst.CommandElements[0] -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
        $commandAst.CommandElements[0].Value
    } else {
        'myapp'
    }

    # Rebuild the line and pad it to the cursor: $commandAst drops trailing
    # whitespace, so an empty value position ('myapp deploy ') would otherwise be
    # analyzed as the previous token.
    $replLine = $commandAst.Extent.Text
    $replCursor = $cursorPosition - $commandAst.Extent.StartOffset
    if ($replCursor -lt 0) { $replCursor = 0 }
    if ($replLine.Length -lt $replCursor) { $replLine = $replLine.PadRight($replCursor) }

    & $invokedCommand completion __complete --shell powershell --line $replLine --cursor $replCursor --no-interactive --no-logo |
        ForEach-Object {
            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
        }
}
if ((Get-Command Register-ArgumentCompleter).Parameters.ContainsKey('Native')) {
    Register-ArgumentCompleter -Native -CommandName $__replCompletionCommandNames -ScriptBlock $__replCompleter
} else {
    Register-ArgumentCompleter -CommandName $__replCompletionCommandNames -ScriptBlock $__replCompleter
}

Zsh:

_myapp_complete() {
  local line cursor
  local candidate
  local -a reply
  line="$BUFFER"
  cursor=$((CURSOR > 0 ? CURSOR - 1 : 0))
  reply=()
  while IFS= read -r candidate; do
    reply+=("$candidate")
  done < <(myapp completion __complete --shell zsh --line "$line" --cursor "$cursor" --no-interactive --no-logo)
  if (( ${#reply[@]} > 0 )); then
    # -Q suppresses zsh's own quoting: the bridge already returns shell-literal syntax.
    compadd -Q -- "${reply[@]}"
  fi
}

compdef _myapp_complete myapp

Fish:

function _myapp_complete
  set -l line (commandline -p)
  set -l cursor (commandline -C)
  myapp completion __complete --shell fish --line "$line" --cursor "$cursor" --no-interactive --no-logo
end

complete -c myapp -f -a "(_myapp_complete)"

Nushell:

const __repl_completion_entries = [
  { appId: "myapp", command: "myapp" }
]
def _repl_nu_dispatch_completion [spans: list<string>] {
  if (($spans | length) == 0) {
    return []
  }

  let head = ($spans | get 0)
  let matches = ($__repl_completion_entries | where { |item| $item.command == $head })
  if (($matches | length) == 0) {
    return []
  }

  let entry = ($matches | get 0)
  let line = ($spans | str join ' ')
  let cursor = ($line | str length)
  (
    ^$entry.command completion __complete --shell nu --line $line --cursor $cursor --no-interactive --no-logo
    | lines
    | each { |line| { value: $line, description: "" } }
  )
}

$env.config = (
  $env.config
  | upsert completions.external.enable true
)
$env.config.completions.external.completer = { |spans| _repl_nu_dispatch_completion $spans }

Compatibility notes

  • PowerShell 7+ is recommended. In Windows PowerShell 5.1, native completion registration for external executables is limited and may not trigger reliably.
  • Nushell uses one global completions.external.completer; Repl manages a shared dispatcher block to route completions per app command head.

Relationship with interactive autocomplete

Shell completion and the interactive REPL autocomplete draw option-name candidates from the same source, normalize prior tokens through the same parser profile (option values consumed, POSIX -- honored, response files never expanded), and use the same option-prefix gate — a single dash already surfaces short aliases such as -f, while signed numeric literals (-42) stay positional. Both also complete enum values for a pending option (its member names, under the parameter's effective case sensitivity), and both run WithCompletion value providers — while the value is being typed for a positional parameter, and for a pending option's value (provider first, enum fallback second). Both stop offering provider values once the position can no longer bind at execution (a bound value, or the route's trailing option region). Two deliberate differences remain:

  • On an empty token after a complete command, shell completion lists option names (a dump-style list is cheap there), while the interactive menu shows parameter placeholders — options appear from the first typed -.
  • The interactive menu runs every registered provider; the shell bridge only runs providers registered with CompletionProviderScope.InteractiveAndShell, because each shell Tab spawns a blocking process.