private/Test-Command.ps1

function Test-Command {
    param(
        [string]$Command,
        [switch]$IsModule
    )

    if ([string]::IsNullOrWhiteSpace($Command)) {
        Write-PSFMessage -Level Verbose -Message "Command parameter is null or empty"
        return $false
    }

    Write-PSFMessage -Level Verbose -Message "Testing if command exists: $Command"

    # Auto-detect if this is a PowerShell module by checking ToolDefinitions
    # (Special handling for wrapper modules like PSOpenAI)
    if (-not $IsModule) {
        $matchingTool = $script:ToolDefinitions.GetEnumerator() | Where-Object {
            $_.Value.Command -eq $Command -and $_.Value['IsWrapper']
        } | Select-Object -First 1

        if ($matchingTool) {
            Write-PSFMessage -Level Verbose -Message "Detected that '$Command' is a PowerShell module wrapper"
            $IsModule = $true
        }
    }

    # Special handling for PowerShell modules (like PSOpenAI)
    if ($IsModule) {
        Write-PSFMessage -Level Verbose -Message "Testing PowerShell module: $Command"
        $module = Get-Module -ListAvailable -Name $Command -ErrorAction SilentlyContinue
        if ($module) {
            Write-PSFMessage -Level Verbose -Message "Module '$Command' is installed"
            return $true
        } else {
            Write-PSFMessage -Level Verbose -Message "Module '$Command' not found"
            return $false
        }
    }

    $cmd = Get-Command $Command -ErrorAction SilentlyContinue
    if ($null -eq $cmd) {
        Write-PSFMessage -Level Verbose -Message "Command '$Command' not found"
        return $false
    }

    # For script/batch files, verify they can actually execute
    # by checking their dependencies (like node for npm-installed tools)
    # Uses System.Diagnostics.Process with redirected stdin to prevent interactive prompts
    # from shim commands (e.g. gh copilot alias) that detect missing tools and prompt to install
    if ($cmd.CommandType -in 'Application', 'ExternalScript') {
        $process = $null
        try {
            $exePath = $cmd.Source
            if ([string]::IsNullOrWhiteSpace($exePath)) {
                Write-PSFMessage -Level Verbose -Message "Command '$Command' has no Source path, cannot verify"
                return $false
            }

            $psi = New-Object System.Diagnostics.ProcessStartInfo
            $psi.UseShellExecute = $false
            $psi.CreateNoWindow = $true
            $psi.RedirectStandardOutput = $true
            $psi.RedirectStandardError = $true
            $psi.RedirectStandardInput = $true

            # .cmd/.bat files on Windows must run via cmd.exe with UseShellExecute=$false
            # .ps1 files must run via powershell.exe
            $extension = [System.IO.Path]::GetExtension($exePath).ToLowerInvariant()
            if ($extension -in '.cmd', '.bat') {
                $psi.FileName = 'cmd.exe'
                $psi.Arguments = "/c `"$exePath`" --version"
            } elseif ($extension -eq '.ps1') {
                # npm global installs create .ps1 shims that can't be executed directly
                # Prefer the .cmd version if available (same directory, same base name)
                $cmdPath = [System.IO.Path]::ChangeExtension($exePath, '.cmd')
                if (Test-Path $cmdPath) {
                    $psi.FileName = 'cmd.exe'
                    $psi.Arguments = "/c `"$cmdPath`" --version"
                    Write-PSFMessage -Level Verbose -Message "Using .cmd shim instead of .ps1: $cmdPath"
                } else {
                    $psi.FileName = 'powershell.exe'
                    $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$exePath`" --version"
                }
            } else {
                $psi.FileName = $exePath
                $psi.Arguments = '--version'
            }

            $process = New-Object System.Diagnostics.Process
            $process.StartInfo = $psi
            $process.Start() | Out-Null

            # Close stdin immediately to prevent interactive prompts
            $process.StandardInput.Close()

            # Read output before WaitForExit to avoid deadlock
            $versionOutput = $process.StandardOutput.ReadToEnd()
            $null = $process.StandardError.ReadToEnd()

            if (-not $process.WaitForExit(10000)) {
                Write-PSFMessage -Level Verbose -Message "Command '$Command' timed out after 10 seconds"
                try { $process.Kill() } catch { }
                return $false
            }

            if ($process.ExitCode -ne 0) {
                Write-PSFMessage -Level Verbose -Message "Command '$Command' exited with code $($process.ExitCode)"
                return $false
            }

            $result = ($versionOutput -split "`r?`n" | Where-Object { $_.Trim() } | Select-Object -First 1)

            if ([string]::IsNullOrWhiteSpace($result)) {
                Write-PSFMessage -Level Verbose -Message "Command '$Command' produced no output"
                return $false
            }

            Write-PSFMessage -Level Verbose -Message "Command '$Command' version check: $($result.Substring(0, [Math]::Min(100, $result.Length)))"

            # Check for common error patterns
            if ($result -match 'not found|command not found|cannot find|no such file') {
                Write-PSFMessage -Level Verbose -Message "Command '$Command' exists but has missing dependencies"
                return $false
            }

            Write-PSFMessage -Level Verbose -Message "Command '$Command' exists and is functional"
            return $true
        } catch {
            Write-PSFMessage -Level Verbose -Message "Command '$Command' exists but failed to execute: $_"
            return $false
        } finally {
            if ($null -ne $process) {
                try { $process.Dispose() } catch { }
            }
        }
    }

    Write-PSFMessage -Level Verbose -Message "Command '$Command' exists: $true"
    return $true
}