1- # =============================================================================
1+ # =============================================================================
22# X-Tester MCP installer
33#
44# Delivery model:
@@ -38,14 +38,33 @@ param(
3838
3939 [switch ]$Force ,
4040
41- [switch ]$SkipClientInstall
41+ [switch ]$SkipClientInstall ,
42+
43+ [switch ]$Help
4244)
4345
4446$ErrorActionPreference = " Stop"
4547
46- $Script :InstallerVersion = " 1.12 .1"
48+ $Script :InstallerVersion = " 1.13 .1"
4749$Script :AgencyXTesterPluginPrompted = $false
4850
51+ if ($Help ) {
52+ @"
53+ XTester installer
54+
55+ Parameters:
56+ -Source <private-feed|local> Install source. Default: private-feed
57+ -Version <version> Optional XTester package version
58+ -Client <name[]> copilot, claude-desktop, claude-code, vscode, or all
59+ -VenvPath <path> Managed MCP virtual environment
60+ -LocalPath <path> Package root for -Source local
61+ -Force Recreate the managed environment
62+ -SkipClientInstall Do not install missing client CLIs
63+ -Help Show this help without changing local state
64+ "@
65+ return
66+ }
67+
4968try {
5069 [Console ]::OutputEncoding = [System.Text.Encoding ]::UTF8
5170 $OutputEncoding = [System.Text.Encoding ]::UTF8
@@ -83,6 +102,21 @@ function Invoke-External([string]$FilePath, [string[]]$Arguments) {
83102 }
84103}
85104
105+ function Get-XTesterReadinessStatePath {
106+ if ([string ]::IsNullOrWhiteSpace($env: LOCALAPPDATA )) {
107+ throw " LOCALAPPDATA is required to locate XTester readiness state."
108+ }
109+ return Join-Path $env: LOCALAPPDATA " XTester\state\readiness-v1.json"
110+ }
111+
112+ function Invalidate-XTesterReadiness {
113+ $statePath = Get-XTesterReadinessStatePath
114+ if (Test-Path - LiteralPath $statePath ) {
115+ Remove-Item - LiteralPath $statePath - Force - ErrorAction Stop
116+ Info " Invalidated previous readiness state: $statePath "
117+ }
118+ }
119+
86120# winget renders its download spinner with `\r` redraws. When PowerShell wraps
87121# stdout (any `2>&1 | ...` pipeline counts as a non-TTY consumer) every frame
88122# is flushed with a newline, producing a vertical column of `-`,`\\`,`|`,`/`
@@ -279,7 +313,7 @@ function Write-JsonObject([string]$Path, $Value) {
279313
280314function Resolve-LocalPackagePath {
281315 if (-not $LocalPath ) {
282- throw " -Source local requires -LocalPath pointing at the XTester package directory (e.g. -LocalPath C:\path\to\DeepTest\XTester )."
316+ throw " -Source local requires -LocalPath pointing at the XTester package directory (e.g. -LocalPath C:\path\to\xtester )."
283317 }
284318 if (-not (Test-Path - LiteralPath $LocalPath )) {
285319 throw " Local package path does not exist: $LocalPath "
@@ -749,6 +783,69 @@ function Get-LatestXTesterVersionFromFeed([string]$VenvPython) {
749783 return $null
750784}
751785
786+ function Get-XTesterLauncherRepairVersion ([string ]$VenvPython ) {
787+ # This probe must also work with older installed XTester versions, so it
788+ # uses Python's metadata rather than importing a new package helper. Single
789+ # quotes in Python survive Windows PowerShell 5.1 native argument passing.
790+ $probe = @'
791+ import os
792+ import re
793+ import sys
794+ import sysconfig
795+ from importlib.metadata import distribution
796+ from pathlib import Path
797+
798+ dist = distribution('xtester')
799+ version = dist.version
800+ if not re.fullmatch(r'[0-9][A-Za-z0-9.!+_-]*', version):
801+ raise SystemExit('Invalid installed XTester version metadata')
802+ names = {entry.name for entry in dist.entry_points if entry.group == 'console_scripts'}
803+ if not {'xtester', 'xtester-mcp'} <= names:
804+ raise SystemExit('XTester metadata is missing required console_scripts')
805+ if any(not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', name) or name.endswith('.') for name in names):
806+ raise SystemExit('Invalid XTester console_scripts name')
807+ scripts = Path(sysconfig.get_path('scripts')).resolve()
808+ suffix = '.exe' if os.name == 'nt' else ''
809+ missing = []
810+ for name in sorted(names):
811+ launcher = scripts / (name + suffix)
812+ if launcher.resolve().parent != scripts or not launcher.is_file() or not os.access(launcher, os.X_OK):
813+ missing.append(name)
814+ if missing:
815+ print('Missing XTester launchers: ' + ', '.join(missing), file=sys.stderr)
816+ print(version)
817+ '@
818+ $repairVersion = & $VenvPython - I - c $probe
819+ if ($LASTEXITCODE -ne 0 ) {
820+ throw " Could not validate managed XTester launcher metadata (exit code $LASTEXITCODE ). Client registration stopped."
821+ }
822+ return $repairVersion
823+ }
824+
825+ function Ensure-XTesterLaunchers ([string ]$VenvPython , [string ]$WheelPath = " " ) {
826+ $repairVersion = Get-XTesterLauncherRepairVersion $VenvPython
827+ if ([string ]::IsNullOrWhiteSpace($repairVersion )) { return }
828+
829+ Warn " Repairing missing XTester launchers at installed version $repairVersion without changing dependencies."
830+ $repairArgs = @ (
831+ " -m" , " pip" , " install" , " --progress-bar" , " off" , " --force-reinstall" , " --no-deps" ,
832+ " xtester==$repairVersion "
833+ )
834+ if ($WheelPath ) {
835+ $repairArgs += $WheelPath
836+ } else {
837+ $repairArgs += @ (" --index-url" , $FeedUrl , " --extra-index-url" , $ExtraIndexUrl )
838+ }
839+ # Do not include feed URLs in an exception: they may contain credentials.
840+ & $VenvPython @repairArgs
841+ if ($LASTEXITCODE -ne 0 ) {
842+ throw " XTester launcher repair failed (exit code $LASTEXITCODE ). Client registration stopped."
843+ }
844+ if (Get-XTesterLauncherRepairVersion $VenvPython ) {
845+ throw " XTester launchers are still missing after one repair attempt. Client registration stopped."
846+ }
847+ }
848+
752849function Install-XTesterIntoVenv ([string ]$VenvPython ) {
753850 Invoke-WithCleanPythonPath {
754851 Invoke-Step " Upgrading pip and installing private-feed auth helpers" {
@@ -758,12 +855,8 @@ function Install-XTesterIntoVenv([string]$VenvPython) {
758855
759856 if ($Source -eq " local" ) {
760857 $path = Resolve-LocalPackagePath
761- # Build a wheel first, then pip install the wheel. Editable installs
762- # (`pip install -e`) skip setup.py's build_py cmdclass, which means the
763- # bundled_skills/ tree (daily-test-report and friends) never gets staged
764- # into site-packages, and xtester-install-skills SKIPs the tree-skill
765- # with "not staged; dev source?". Building the wheel first matches what
766- # the private-feed path installs and keeps -Source local honest.
858+ # Build a wheel first so the local path exercises the same package-data
859+ # behavior as the private-feed installation.
767860 $distDir = Join-Path $path " dist"
768861 $buildDir = Join-Path $path " build"
769862 Invoke-Step " Building X-Tester wheel from local source: $path " {
@@ -783,6 +876,7 @@ function Install-XTesterIntoVenv([string]$VenvPython) {
783876 Invoke-External $VenvPython @ (" -m" , " pip" , " install" , " --progress-bar" , " off" , " --force-reinstall" , " --no-deps" , $wheel.FullName )
784877 Invoke-External $VenvPython @ (" -m" , " pip" , " install" , " --progress-bar" , " off" , " -U" , $wheel.FullName )
785878 }
879+ Ensure- XTesterLaunchers $VenvPython $wheel.FullName
786880 return
787881 }
788882
@@ -857,27 +951,21 @@ function Install-XTesterIntoVenv([string]$VenvPython) {
857951 Success " Install succeeded with fallback extra index: $fallbackExtraIndexUrl "
858952 }
859953 }
954+ Ensure- XTesterLaunchers $VenvPython
860955 }
861956}
862957
863958function Resolve-XTesterMcpFromVenv {
864959 $exe = Join-Path $VenvPath " Scripts\xtester-mcp.exe"
865- if (-not (Test-Path - LiteralPath $exe )) {
960+ if (-not (Test-Path - LiteralPath $exe - PathType Leaf )) {
866961 throw " xtester-mcp.exe was not installed in the managed venv: $exe "
867962 }
868963 return (Resolve-Path - LiteralPath $exe ).Path
869964}
870965
871- function Resolve-XTesterMcpDoctorFromVenv {
872- $exe = Join-Path $VenvPath " Scripts\xtester-mcp-doctor.exe"
873- if (-not (Test-Path - LiteralPath $exe )) {
874- throw " xtester-mcp-doctor.exe was not installed in the managed venv: $exe "
875- }
876- return (Resolve-Path - LiteralPath $exe ).Path
877- }
878-
879- function Test-XTesterMcpProtocol ([string ]$DoctorPath , [string ]$CommandPath ) {
880- Invoke-External $DoctorPath @ (" --command" , $CommandPath , " --timeout-seconds" , " 10" )
966+ function Test-XTesterMcpProtocol ([string ]$VenvPython , [string ]$CommandPath ) {
967+ # Ignore checkout modules and PYTHONPATH when loading the installed Doctor.
968+ Invoke-External $VenvPython @ (" -I" , " -m" , " xtester.mcp.doctor" , " --command" , $CommandPath , " --timeout-seconds" , " 10" )
881969}
882970
883971function Ensure-XTesterCliPath ([string ]$VenvPython ) {
@@ -995,21 +1083,13 @@ function Offer-AgencyXTesterPluginUpdate {
9951083function Register-ClaudeCode ([string ]$CommandPath ) {
9961084 $claude = Get-Command " claude" - ErrorAction SilentlyContinue
9971085 if (-not $claude ) {
998- Warn " claude CLI was not found; skipping Claude Code registration."
999- Warn " Use Claude Desktop registration or install Claude Code, then re-run with -Client claude-code."
1000- return
1086+ throw " claude CLI was not found; install Claude Code or select another client."
10011087 }
10021088
10031089 Offer- AgencyXTesterPluginUpdate
10041090
1005- try {
1006- Invoke-Step " Registering X-Tester MCP with Claude Code" {
1007- Invoke-External $claude.Source @ (" mcp" , " add" , $ServerName , $CommandPath )
1008- }
1009- }
1010- catch {
1011- Warn " claude mcp add failed: $ ( $_.Exception.Message ) "
1012- Warn " Run 'claude mcp --help' to confirm this Claude Code version supports MCP registration."
1091+ Invoke-Step " Registering X-Tester MCP with Claude Code" {
1092+ Invoke-External $claude.Source @ (" mcp" , " add" , $ServerName , $CommandPath )
10131093 }
10141094}
10151095
@@ -1067,8 +1147,7 @@ function Register-VSCode([string]$CommandPath) {
10671147 Success " Rewrote VS Code user MCP config: $configPath "
10681148 }
10691149 catch {
1070- Warn " Skipped VS Code registration: $ ( $_.Exception.Message ) "
1071- Info " Re-run with -Client vscode after fixing $configPath to add it later."
1150+ throw " VS Code registration failed after config recovery: $ ( $_.Exception.Message ) "
10721151 }
10731152 }
10741153}
@@ -1080,8 +1159,8 @@ function Expand-Clients {
10801159 return $Client | Select-Object - Unique
10811160}
10821161
1083- function Register-XTesterClients ([string ]$DoctorPath , [string ]$CommandPath , [string []]$Targets ) {
1084- Test-XTesterMcpProtocol $DoctorPath $CommandPath
1162+ function Register-XTesterClients ([string ]$VenvPython , [string ]$CommandPath , [string []]$Targets ) {
1163+ Test-XTesterMcpProtocol $VenvPython $CommandPath
10851164 foreach ($target in $Targets ) {
10861165 switch ($target ) {
10871166 " copilot" { Register-Copilot $CommandPath }
@@ -1103,6 +1182,8 @@ Write-Host ""
11031182Write-Host " X-Tester MCP installer v$Script :InstallerVersion " - ForegroundColor Magenta
11041183Write-Host " "
11051184
1185+ Invalidate- XTesterReadiness
1186+
11061187$pythonInfo = Resolve-Python
11071188$pythonInvocation = @ ($pythonInfo.FilePath ) + $pythonInfo.Arguments
11081189Success " Using Python: $ ( $pythonInfo.Version ) ($ ( $pythonInvocation -join ' ' ) )"
@@ -1144,17 +1225,17 @@ $venvPython = New-ManagedVenv $pythonInfo
11441225Install-XTesterIntoVenv $venvPython
11451226Ensure- XTesterCliPath $venvPython
11461227$xtesterMcp = Resolve-XTesterMcpFromVenv
1147- $xtesterMcpDoctor = Resolve-XTesterMcpDoctorFromVenv
11481228Success " Using xtester-mcp: $xtesterMcp "
11491229
1230+ $selectedClients = @ (Expand-Clients )
11501231Invoke-Step " Checking protocol and registering X-Tester MCP clients" {
1151- Register-XTesterClients $xtesterMcpDoctor $xtesterMcp ( Expand-Clients )
1232+ Register-XTesterClients $venvPython $xtesterMcp $selectedClients
11521233}
1234+ $registeredClients = @ ($selectedClients )
11531235
11541236# Opportunistic: if the user installed for copilot only and the Claude Code CLI
11551237# is on PATH, offer to register there too. Skipped non-interactively (no host
11561238# UI) and when the user already asked for claude-code or -SkipClientInstall.
1157- $selectedClients = Expand-Clients
11581239if (-not $SkipClientInstall `
11591240 -and ($selectedClients -contains ' copilot' ) `
11601241 -and (-not ($selectedClients -contains ' claude-code' )) `
@@ -1171,6 +1252,7 @@ if (-not $SkipClientInstall `
11711252 }
11721253 elseif ([string ]::IsNullOrWhiteSpace($answer ) -or $answer -match ' ^(y|yes)$' ) {
11731254 Register-ClaudeCode $xtesterMcp
1255+ $registeredClients += " claude-code"
11741256 } else {
11751257 Info " Skipping Claude Code registration. Re-run with -Client claude-code to add it later."
11761258 }
@@ -1195,6 +1277,7 @@ if (-not $SkipClientInstall `
11951277 }
11961278 elseif ([string ]::IsNullOrWhiteSpace($answer ) -or $answer -match ' ^(y|yes)$' ) {
11971279 Register-VSCode $xtesterMcp
1280+ $registeredClients += " vscode"
11981281 } else {
11991282 Info " Skipping VS Code registration. Re-run with -Client vscode to add it later."
12001283 }
@@ -1203,16 +1286,30 @@ if (-not $SkipClientInstall `
12031286# ---------------------------------------------------------------------------
12041287# Bundled Copilot CLI skills -> ~/.copilot/skills/
12051288# Only runs when copilot is among the selected clients (other hosts use their
1206- # own skill mechanisms). The helper overwrites the four xtester-* slots .
1289+ # own skill mechanisms). The helper atomically replaces the unified slot .
12071290# ---------------------------------------------------------------------------
12081291if ($Client -contains " copilot" -or $Client -contains " all" ) {
1292+ $skillsRoot = & $venvPython - I - c " import os; from pathlib import Path; print(Path(os.environ.get('XTESTER_SKILLS_DEST') or Path.home() / '.copilot' / 'skills').expanduser().absolute())"
1293+ if ($LASTEXITCODE -ne 0 ) { throw " Could not resolve the Copilot skills destination." }
1294+ $retiredSkills = @ (
1295+ foreach ($name in @ (" xtester-setup" , " xtester-create" , " xtester-run" , " xtester-report" )) {
1296+ $path = Join-Path $skillsRoot $name
1297+ if (Test-Path - LiteralPath $path - PathType Container) { $path }
1298+ }
1299+ )
1300+ $skillPrompt = " Install the bundled xtester Copilot CLI skill into ~/.copilot/skills/? [Y/n]"
1301+ if ($retiredSkills.Count -gt 0 ) {
1302+ Warn " These retired skills were replaced by the unified xtester skill:"
1303+ foreach ($path in $retiredSkills ) { Info $path }
1304+ Warn " They no longer match the current MCP interface and may lead to incorrect calls. We strongly recommend Yes."
1305+ $skillPrompt = " Install the unified xtester skill and remove these retired skills? [Y/n]"
1306+ }
1307+ $proceed = $false
12091308 $cliSkillsBin = Join-Path $VenvPath " Scripts\xtester-install-skills.exe"
12101309 if (Test-Path - LiteralPath $cliSkillsBin ) {
1211- $proceed = $false
12121310 Write-Host " "
1213- $answer = Read-OptionalPrompt `
1214- " Install bundled xtester-* Copilot CLI skills into ~/.copilot/skills/? [Y/n]" `
1215- " Non-interactive host; skipping bundled xtester-* skill install (re-run interactively to install)."
1311+ $answer = Read-OptionalPrompt $skillPrompt `
1312+ " Non-interactive host; skipping bundled xtester skill install (re-run interactively to install)."
12161313 if ($null -ne $answer ) {
12171314 if ([string ]::IsNullOrWhiteSpace($answer ) -or $answer -match ' ^(y|yes)$' ) {
12181315 $proceed = $true
@@ -1222,23 +1319,34 @@ if ($Client -contains "copilot" -or $Client -contains "all") {
12221319 }
12231320 }
12241321 if ($proceed ) {
1225- Invoke-Step " Installing bundled xtester-* Copilot CLI skills into ~/.copilot/skills/" {
1226- try {
1227- Invoke-External $cliSkillsBin @ ()
1228- }
1229- catch {
1230- Warn " xtester-install-skills failed: $ ( $_.Exception.Message ) "
1231- }
1322+ Invoke-Step " Installing bundled xtester Copilot CLI skill into $skillsRoot " {
1323+ Invoke-External $cliSkillsBin @ ()
12321324 }
12331325 }
12341326 }
12351327 else {
12361328 Warn " xtester-install-skills not found at $cliSkillsBin ; skipping CLI skill install."
12371329 }
1330+ if ($retiredSkills.Count -gt 0 -and -not $proceed ) {
1331+ Warn " Skill migration incomplete; retired skills were not removed. Re-run interactively and choose Yes to migrate."
1332+ }
1333+ }
1334+
1335+ $readinessArguments = @ (
1336+ " -m" , " xtester.readiness" , " mark-ready" ,
1337+ " --installer-version" , $Script :InstallerVersion ,
1338+ " --mcp-executable" , $xtesterMcp
1339+ )
1340+ foreach ($registeredClient in ($registeredClients | Sort-Object - Unique)) {
1341+ $readinessArguments += @ (" --client" , $registeredClient )
1342+ }
1343+ Invoke-Step " Recording validated XTester readiness" {
1344+ Invoke-External $venvPython $readinessArguments
12381345}
12391346
12401347Write-Host " "
12411348Success " X-Tester MCP installation complete."
12421349Info " Server name: $ServerName "
12431350Info " Managed venv: $VenvPath "
12441351Info " MCP command: $xtesterMcp "
1352+ Info " Reconnect or restart registered clients to refresh MCP and skill discovery."
0 commit comments