Public/Tools/Enable-Zoxide.ps1

function Enable-Zoxide {
    <#
    .SYNOPSIS
        Installs (if necessary) and activates zoxide directory jumping for the session.
 
    .DESCRIPTION
        Runs two nested Invoke-Step substeps:
          - Install: if zoxide.exe isn't on PATH, installs it with winget
            (ajeetdsouza.zoxide, a portable package) and patches the current session's PATH
            so the Initialize substep can see it immediately.
          - Initialize: runs `zoxide init powershell --hook none` and invokes the emitted script,
            which defines the global __zoxide_* jump helpers and the cd/cdi aliases (but NOT a
            prompt wrapper), then registers a LocationChangedAction hook that records each
            directory you change into (via `zoxide add`).
 
        The `--hook none` + LocationChangedAction design replaces zoxide's default prompt-wrapping
        hook because that wrap is fragile: oh-my-posh defines `prompt` inside a global dynamic
        module it removes and re-adds on every init, and zoxide guards its wrap with a one-shot
        flag — so a profile reload (or anything that redefines `prompt` after startup) silently
        drops zoxide's prompt hook and directories stop being tracked. PowerShell's
        $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction (6.2+) fires after *any*
        location change (cd, z/cdi, Set-Location, Push-Location, .., …) and is immune to prompt
        re-definition. The hook chains any pre-existing LocationChangedAction and is guarded against
        re-registering on reload — the same mechanism Enable-FastNodeManager uses, so the two
        compose (both fire on every change).
 
        If the install doesn't produce zoxide.exe on PATH, a warning is emitted (with winget's
        captured output) and Initialize is skipped (guarded by Get-Command) so profile startup
        continues.
 
    .PARAMETER Command
        The command name zoxide binds for jumping, passed as `--cmd`. Defaults to 'cd',
        which replaces the built-in cd (and adds cdi for interactive selection).
 
    .EXAMPLE
        Enable-Zoxide
 
    .EXAMPLE
        Enable-Zoxide -Command z
 
    .NOTES
        Independent of oh-my-posh and of call order: directory tracking is a LocationChangedAction,
        not a wrap of the prompt, so it survives a profile reload and any later prompt redefinition.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Command = 'cd'
    )

    Invoke-Step "Install" {
        # zoxide is a winget portable: its exe lands in the default Links dir.
        Install-WingetPackageSafe -Id 'ajeetdsouza.zoxide' -Exe 'zoxide.exe' -CallerName 'Enable-Zoxide'
    }

    Invoke-Step "Initialize" {
        if (Get-Command zoxide.exe -ErrorAction SilentlyContinue) {
            # Run in the global scope (not this module's) so the emitted __zoxide_* helpers and
            # cd/cdi aliases aren't tagged to the module — see Private/Invoke-InGlobalScope.ps1.
            # `--hook none` skips zoxide's default prompt wrapper (we track directories via
            # LocationChangedAction below instead — the prompt wrap is wiped by oh-my-posh's
            # remove/re-add of its prompt module on reload, so directories silently stop tracking).
            Invoke-InGlobalScope (zoxide init powershell --cmd $Command --hook none | Out-String)

            # Record each directory you change into via PowerShell's LocationChangedAction (fires for
            # cd, z/cdi, Set-Location, Push-Location, .., etc.), which is immune to prompt redefinition
            # — unlike zoxide's prompt hook. Run in the global scope so the handler and its
            # $global:__zoxide_loc_base capture aren't tagged to the module and resolve when the hook
            # fires later from the prompt.
            #
            # Capture any pre-existing handler ONCE (guarded by $global:__zoxide_loc_hooked) so a
            # profile reload doesn't re-capture our own wrapper and stack zoxide add calls. But always
            # (re)install the wrapper, so reloading the profile in a live session repairs the hook
            # rather than leaving a stale one frozen behind the guard. This composes with
            # Enable-FastNodeManager's LocationChangedAction (each captures the other as its base and
            # both fire); Enable-Zoxide runs before Enable-FastNodeManager, so zoxide's base is the
            # pre-existing handler (usually $null) and fnm chains onto zoxide's wrapper.
            Invoke-InGlobalScope @'
if (-not (Get-Variable -Name __zoxide_loc_hooked -Scope Global -ErrorAction SilentlyContinue)) {
    $global:__zoxide_loc_base = $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction
    $global:__zoxide_loc_hooked = $true
}
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = {
    param($source, $eventArgs)
    # The captured base is an EventHandler delegate (the property's type), so call .Invoke.
    if ($null -ne $global:__zoxide_loc_base) { $global:__zoxide_loc_base.Invoke($source, $eventArgs) }
    # Only record real filesystem directories: guard on the FileSystem provider so cd into
    # Registry:/Cert: is a no-op. The event fires only on actual location changes, so zoxide add's
    # natural dedup (by path) is all we need. zoxide add prints nothing, so no Out-Host is required.
    $new = $eventArgs.NewPath
    if ($new -and $new.Provider.Name -eq 'FileSystem') {
        zoxide add "--" $new.ProviderPath
    }
}
'@

        }
    }
}