Skip to content

Commit 1e94311

Browse files
Add robust CLI version detection and handling
Introduce Get-CommandVersion (private) to reliably obtain CLI version output via System.Diagnostics.Process with stdin closed, redirected IO, timeout, and special handling for .cmd/.bat. Replace many direct & '--version' invocations with Get-CommandVersion across Test-Command, Install-AITool, Get-AITool, Update-AITool and other flows to avoid interactive prompts, hangs, and inaccurate detection. Switch several property accesses to safe dictionary-style access (e.g. $tool['IsWrapper']) and centralize/Get-Command usage to safely obtain Source (avoiding null Path access). Add timeouts, exit-code checks, verbose diagnostics, and process disposal to make command checks and install/uninstall logic more robust. Also fix a small note lookup bug in Initialize-AIToolDefault.
1 parent 3a68893 commit 1e94311

File tree

7 files changed

+173
-40
lines changed

7 files changed

+173
-40
lines changed

private/Get-CommandVersion.ps1

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
function Get-CommandVersion {
2+
<#
3+
.SYNOPSIS
4+
Gets the version output of a CLI command using process-based execution.
5+
6+
.DESCRIPTION
7+
Runs '<command> --version' using System.Diagnostics.Process with stdin redirected
8+
and immediately closed. This prevents CLIs (like Claude Code) from detecting piped
9+
stdin and switching to pipe/print mode, which causes errors when using PowerShell's
10+
& operator with 2>&1 redirection.
11+
12+
.PARAMETER Command
13+
The command name to get the version for.
14+
15+
.OUTPUTS
16+
System.String - The first non-empty line of version output, or $null on failure.
17+
#>
18+
param(
19+
[Parameter(Mandatory)]
20+
[string]$Command
21+
)
22+
23+
# Prefer Application type (actual exe) over Functions/Aliases that install scripts may create
24+
$cmd = Get-Command $Command -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
25+
if ($null -eq $cmd) {
26+
$cmd = Get-Command $Command -ErrorAction SilentlyContinue
27+
}
28+
if ($null -eq $cmd) {
29+
return $null
30+
}
31+
32+
$exePath = $cmd.Source
33+
if ([string]::IsNullOrWhiteSpace($exePath)) {
34+
return $null
35+
}
36+
37+
$process = $null
38+
try {
39+
$psi = New-Object System.Diagnostics.ProcessStartInfo
40+
$psi.UseShellExecute = $false
41+
$psi.CreateNoWindow = $true
42+
$psi.RedirectStandardOutput = $true
43+
$psi.RedirectStandardError = $true
44+
$psi.RedirectStandardInput = $true
45+
46+
# .cmd/.bat files on Windows must run via cmd.exe
47+
$extension = [System.IO.Path]::GetExtension($exePath).ToLowerInvariant()
48+
if ($extension -in '.cmd', '.bat') {
49+
$psi.FileName = 'cmd.exe'
50+
$psi.Arguments = "/c `"$exePath`" --version"
51+
} else {
52+
$psi.FileName = $exePath
53+
$psi.Arguments = '--version'
54+
}
55+
56+
$process = New-Object System.Diagnostics.Process
57+
$process.StartInfo = $psi
58+
$process.Start() | Out-Null
59+
60+
# Close stdin immediately to prevent interactive prompts
61+
$process.StandardInput.Close()
62+
63+
$stdout = $process.StandardOutput.ReadToEnd()
64+
$stderr = $process.StandardError.ReadToEnd()
65+
66+
if (-not $process.WaitForExit(10000)) {
67+
try { $process.Kill() } catch { }
68+
return $null
69+
}
70+
71+
# Check stdout first, fall back to stderr (some CLIs output version to stderr)
72+
$result = ($stdout -split "`r?`n" | Where-Object { $_.Trim() } | Select-Object -First 1)
73+
if ([string]::IsNullOrWhiteSpace($result)) {
74+
$result = ($stderr -split "`r?`n" | Where-Object { $_.Trim() } | Select-Object -First 1)
75+
}
76+
77+
if ([string]::IsNullOrWhiteSpace($result)) {
78+
return $null
79+
}
80+
81+
return $result
82+
} catch {
83+
return $null
84+
} finally {
85+
if ($null -ne $process) {
86+
try { $process.Dispose() } catch { }
87+
}
88+
}
89+
}

private/Initialize-AIToolDefault.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function Initialize-AIToolDefault {
3232

3333
$index = 1
3434
foreach ($tool in $sortedTools) {
35-
$note = if ($tool.Value.Note) { " - $($tool.Value.Note)" } else { "" }
35+
$note = if ($tool.Value['Note']) { " - $($tool.Value['Note'])" } else { "" }
3636
Write-PSFMessage -Level Verbose -Message "$index. $($tool.Key)$note"
3737
$index++
3838
}

private/Test-Command.ps1

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function Test-Command {
1515
# (Special handling for wrapper modules like PSOpenAI)
1616
if (-not $IsModule) {
1717
$matchingTool = $script:ToolDefinitions.GetEnumerator() | Where-Object {
18-
$_.Value.Command -eq $Command -and $_.Value.IsWrapper
18+
$_.Value.Command -eq $Command -and $_.Value['IsWrapper']
1919
} | Select-Object -First 1
2020

2121
if ($matchingTool) {
@@ -45,11 +45,63 @@ function Test-Command {
4545

4646
# For script/batch files, verify they can actually execute
4747
# by checking their dependencies (like node for npm-installed tools)
48+
# Uses System.Diagnostics.Process with redirected stdin to prevent interactive prompts
49+
# from shim commands (e.g. gh copilot alias) that detect missing tools and prompt to install
4850
if ($cmd.CommandType -in 'Application', 'ExternalScript') {
49-
# Try to get version or help to verify it works
50-
# Use timeout to prevent hanging
51+
$process = $null
5152
try {
52-
$result = & $Command --version 2>&1 | Select-Object -First 1
53+
$exePath = $cmd.Source
54+
if ([string]::IsNullOrWhiteSpace($exePath)) {
55+
Write-PSFMessage -Level Verbose -Message "Command '$Command' has no Source path, cannot verify"
56+
return $false
57+
}
58+
59+
$psi = New-Object System.Diagnostics.ProcessStartInfo
60+
$psi.UseShellExecute = $false
61+
$psi.CreateNoWindow = $true
62+
$psi.RedirectStandardOutput = $true
63+
$psi.RedirectStandardError = $true
64+
$psi.RedirectStandardInput = $true
65+
66+
# .cmd/.bat files on Windows must run via cmd.exe with UseShellExecute=$false
67+
$extension = [System.IO.Path]::GetExtension($exePath).ToLowerInvariant()
68+
if ($extension -in '.cmd', '.bat') {
69+
$psi.FileName = 'cmd.exe'
70+
$psi.Arguments = "/c `"$exePath`" --version"
71+
} else {
72+
$psi.FileName = $exePath
73+
$psi.Arguments = '--version'
74+
}
75+
76+
$process = New-Object System.Diagnostics.Process
77+
$process.StartInfo = $psi
78+
$process.Start() | Out-Null
79+
80+
# Close stdin immediately to prevent interactive prompts
81+
$process.StandardInput.Close()
82+
83+
# Read output before WaitForExit to avoid deadlock
84+
$versionOutput = $process.StandardOutput.ReadToEnd()
85+
$null = $process.StandardError.ReadToEnd()
86+
87+
if (-not $process.WaitForExit(10000)) {
88+
Write-PSFMessage -Level Verbose -Message "Command '$Command' timed out after 10 seconds"
89+
try { $process.Kill() } catch { }
90+
return $false
91+
}
92+
93+
if ($process.ExitCode -ne 0) {
94+
Write-PSFMessage -Level Verbose -Message "Command '$Command' exited with code $($process.ExitCode)"
95+
return $false
96+
}
97+
98+
$result = ($versionOutput -split "`r?`n" | Where-Object { $_.Trim() } | Select-Object -First 1)
99+
100+
if ([string]::IsNullOrWhiteSpace($result)) {
101+
Write-PSFMessage -Level Verbose -Message "Command '$Command' produced no output"
102+
return $false
103+
}
104+
53105
Write-PSFMessage -Level Verbose -Message "Command '$Command' version check: $($result.Substring(0, [Math]::Min(100, $result.Length)))"
54106

55107
# Check for common error patterns
@@ -63,6 +115,10 @@ function Test-Command {
63115
} catch {
64116
Write-PSFMessage -Level Verbose -Message "Command '$Command' exists but failed to execute: $_"
65117
return $false
118+
} finally {
119+
if ($null -ne $process) {
120+
try { $process.Dispose() } catch { }
121+
}
66122
}
67123
}
68124

public/Get-AITool.ps1

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ function Get-AITool {
7070

7171
# Get version information differently for PowerShell modules vs CLIs
7272
try {
73-
if ($toolDef.IsWrapper) {
73+
if ($toolDef['IsWrapper']) {
7474
$module = Get-Module -ListAvailable -Name $toolDef.Command | Sort-Object Version -Descending | Select-Object -First 1
7575
$version = $module.Version.ToString()
7676
$commandPath = $module.Path
@@ -84,10 +84,8 @@ function Get-AITool {
8484
}
8585

8686
# Get the command path
87-
$commandPath = (Get-Command $toolDef.Command -ErrorAction SilentlyContinue).Source
88-
if (-not $commandPath) {
89-
$commandPath = (Get-Command $toolDef.Command -ErrorAction SilentlyContinue).Path
90-
}
87+
$commandInfo = Get-Command $toolDef.Command -ErrorAction SilentlyContinue
88+
$commandPath = if ($commandInfo) { $commandInfo.Source } else { $null }
9189
}
9290

9391
Write-PSFMessage -Level Verbose -Message "Version: $version"

public/Install-AITool.ps1

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ function Install-AITool {
116116
$existingInstallations = @()
117117
if (Test-Command -Command $tool.Command) {
118118
# Get all installed versions
119-
if ($tool.IsWrapper) {
119+
if ($tool['IsWrapper']) {
120120
$modules = Get-Module -ListAvailable -Name $tool.Command | Sort-Object Version -Descending
121121
foreach ($module in $modules) {
122122
$existingInstallations += [PSCustomObject]@{
@@ -126,11 +126,9 @@ function Install-AITool {
126126
}
127127
} else {
128128
# For CLI tools, we can only detect the currently active version
129-
$installedVersion = & $tool.Command --version 2>&1 | Select-Object -First 1
130-
$commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Source
131-
if (-not $commandPath) {
132-
$commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Path
133-
}
129+
$installedVersion = Get-CommandVersion -Command $tool.Command
130+
$commandInfo = Get-Command $tool.Command -ErrorAction SilentlyContinue
131+
$commandPath = if ($commandInfo) { $commandInfo.Source } else { $null }
134132
$existingInstallations += [PSCustomObject]@{
135133
Version = ($installedVersion -replace '^.*?(\d+\.\d+\.\d+).*$', '$1').Trim()
136134
Path = $commandPath
@@ -378,7 +376,7 @@ function Install-AITool {
378376
}
379377

380378
if ($useWingetFallback) {
381-
$wingetFallbackCmd = if ($tool.FallbackInstallCommands) { $tool.FallbackInstallCommands[$os] } else { $null }
379+
$wingetFallbackCmd = if ($tool['FallbackInstallCommands']) { $tool['FallbackInstallCommands'][$os] } else { $null }
382380
if ($wingetFallbackCmd) {
383381
if ($wingetFallbackCmd -isnot [array]) { $wingetFallbackCmd = @($wingetFallbackCmd) }
384382
$installCmd = $wingetFallbackCmd
@@ -650,7 +648,7 @@ function Install-AITool {
650648

651649
# Check if this is a PowerShell cmdlet (for wrapper modules like PSOpenAI)
652650
# PowerShell cmdlets must be executed via Invoke-Expression, not Start-Process
653-
$isPowerShellCmdlet = $tool.IsWrapper -or $cmd -match '^(Install-Module|Uninstall-Module|Update-Module|Import-Module)'
651+
$isPowerShellCmdlet = $tool['IsWrapper'] -or $cmd -match '^(Install-Module|Uninstall-Module|Update-Module|Import-Module)'
654652

655653
# Check if command contains shell operators (pipes, redirects, semicolons, etc.)
656654
# These require shell execution and can't be handled by Start-Process
@@ -700,6 +698,8 @@ function Install-AITool {
700698
Invoke-Expression $cmd
701699
$exitCode = $LASTEXITCODE
702700
if (-not $exitCode) { $exitCode = 0 }
701+
$stdout = ''
702+
$stderr = ''
703703
} else {
704704
# Use cmd.exe for other shell operators
705705
Write-PSFMessage -Level Verbose -Message "Executing via cmd.exe"
@@ -802,9 +802,6 @@ function Install-AITool {
802802

803803
if ($selectedCommand) {
804804
$resolvedExecutable = $selectedCommand.Source
805-
if (-not $resolvedExecutable) {
806-
$resolvedExecutable = $selectedCommand.Path
807-
}
808805
Write-PSFMessage -Level Verbose -Message "Resolved executable: $resolvedExecutable"
809806
} else {
810807
# Fallback to original executable name
@@ -969,9 +966,9 @@ function Install-AITool {
969966
}
970967

971968
# Generic fallback: try FallbackInstallCommands if primary install failed
972-
$hasFallback = $tool.FallbackInstallCommands -and $tool.FallbackInstallCommands[$os]
969+
$hasFallback = $tool['FallbackInstallCommands'] -and $tool['FallbackInstallCommands'][$os]
973970
if ($exitCode -ne 0 -and $hasFallback) {
974-
$fallbackCmds = $tool.FallbackInstallCommands[$os]
971+
$fallbackCmds = $tool['FallbackInstallCommands'][$os]
975972
if ($fallbackCmds -isnot [array]) { $fallbackCmds = @($fallbackCmds) }
976973

977974
# Only try fallback if we're not already executing a fallback command
@@ -1215,17 +1212,15 @@ function Install-AITool {
12151212
Write-PSFMessage -Level Verbose -Message "$currentToolName installed successfully!"
12161213

12171214
# Get version differently for PowerShell modules vs CLIs
1218-
if ($tool.IsWrapper) {
1215+
if ($tool['IsWrapper']) {
12191216
$module = Get-Module -ListAvailable -Name $tool.Command | Sort-Object Version -Descending | Select-Object -First 1
12201217
$version = $module.Version.ToString()
12211218
$commandPath = $module.Path
12221219
} else {
1223-
$version = & $tool.Command --version 2>&1 | Select-Object -First 1
1220+
$version = Get-CommandVersion -Command $tool.Command
12241221
# Get the full path to the command
1225-
$commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Source
1226-
if (-not $commandPath) {
1227-
$commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Path
1228-
}
1222+
$commandInfo = Get-Command $tool.Command -ErrorAction SilentlyContinue
1223+
$commandPath = if ($commandInfo) { $commandInfo.Source } else { $null }
12291224
}
12301225

12311226
Write-PSFMessage -Level Verbose -Message "Version: $version"

public/Uninstall-AITool.ps1

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,7 @@ function Uninstall-AITool {
8383

8484
# Detect installation method by checking where the command is located
8585
$commandInfo = Get-Command $tool.Command -ErrorAction SilentlyContinue
86-
$commandPath = $commandInfo.Source
87-
if (-not $commandPath) {
88-
$commandPath = $commandInfo.Path
89-
}
86+
$commandPath = if ($commandInfo) { $commandInfo.Source } else { $null }
9087

9188
Write-PSFMessage -Level Verbose -Message "Command location: $commandPath"
9289

@@ -138,7 +135,7 @@ function Uninstall-AITool {
138135
Write-PSFMessage -Level Verbose -Message "Executing uninstall command"
139136

140137
# Check if this is a PowerShell cmdlet (for wrapper modules like PSOpenAI)
141-
$isPowerShellCmdlet = $tool.IsWrapper -or $uninstallCmd -match '^(Install-Module|Uninstall-Module|Update-Module|Import-Module)'
138+
$isPowerShellCmdlet = $tool['IsWrapper'] -or $uninstallCmd -match '^(Install-Module|Uninstall-Module|Update-Module|Import-Module)'
142139

143140
try {
144141
# Handle PowerShell cmdlets directly
@@ -201,10 +198,8 @@ function Uninstall-AITool {
201198
Write-PSFMessage -Level Verbose -Message "Arguments: $arguments"
202199

203200
# Resolve the full path to the executable to avoid PATH issues with UseShellExecute = $false
204-
$executablePath = (Get-Command $executable -ErrorAction SilentlyContinue).Source
205-
if (-not $executablePath) {
206-
$executablePath = (Get-Command $executable -ErrorAction SilentlyContinue).Path
207-
}
201+
$execInfo = Get-Command $executable -ErrorAction SilentlyContinue
202+
$executablePath = if ($execInfo) { $execInfo.Source } else { $null }
208203
if (-not $executablePath) {
209204
# If we still can't find it, use the executable as-is and hope for the best
210205
$executablePath = $executable

public/Update-AITool.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,13 @@ function Update-AITool {
9393
$oldVersion = $null
9494
if (Test-Command -Command $tool.Command) {
9595
# Get version differently for PowerShell modules vs CLIs
96-
if ($tool.IsWrapper) {
96+
if ($tool['IsWrapper']) {
9797
$module = Get-Module -ListAvailable -Name $tool.Command | Sort-Object Version -Descending | Select-Object -First 1
9898
if ($module) {
9999
$oldVersion = $module.Version.ToString()
100100
}
101101
} else {
102-
$oldVersion = & $tool.Command --version 2>&1 | Select-Object -First 1
102+
$oldVersion = Get-CommandVersion -Command $tool.Command
103103
$oldVersion = ($oldVersion -replace '^.*?(\d+\.\d+\.\d+).*$', '$1').Trim()
104104
}
105105
Write-PSFMessage -Level Verbose -Message "Current version: $oldVersion"

0 commit comments

Comments
 (0)