[CmdletBinding()] param( [ValidateNotNullOrEmpty()] [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'), [string]$CodexHome, [switch]$NoCodexSkillInstall, [switch]$NoPathUpdate ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot 'toolbox-commands.ps1') function Normalize-PathValue { param( [AllowNull()] [string]$PathValue ) if ([string]::IsNullOrWhiteSpace($PathValue)) { return '' } return $PathValue.Replace('/', '\').Trim().TrimEnd('\').ToLowerInvariant() } function Split-PathEntries { param( [AllowNull()] [string]$PathValue ) if ([string]::IsNullOrWhiteSpace($PathValue)) { return @() } return $PathValue.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) } function Add-PathEntry { param( [AllowNull()] [string]$ExistingPath, [Parameter(Mandatory = $true)] [string]$Entry ) $normalizedEntry = Normalize-PathValue -PathValue $Entry $entries = Split-PathEntries -PathValue $ExistingPath foreach ($existing in $entries) { if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { return @{ Changed = $false Value = $ExistingPath } } } $newEntries = @($entries + $Entry) return @{ Changed = $true Value = ($newEntries -join ';') } } function Remove-PathEntry { param( [AllowNull()] [string]$ExistingPath, [Parameter(Mandatory = $true)] [string]$Entry ) $normalizedEntry = Normalize-PathValue -PathValue $Entry $remaining = [System.Collections.Generic.List[string]]::new() $changed = $false foreach ($existing in (Split-PathEntries -PathValue $ExistingPath)) { if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) { $changed = $true continue } $remaining.Add($existing) } return @{ Changed = $changed Value = ($remaining -join ';') } } function Copy-OptionalFile { param( [Parameter(Mandatory = $true)] [string]$SourcePath, [Parameter(Mandatory = $true)] [string]$DestinationPath ) if (Test-Path -LiteralPath $SourcePath) { $destinationDirectory = Split-Path -Parent $DestinationPath if (-not [string]::IsNullOrWhiteSpace($destinationDirectory)) { New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null } Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Force } } function Assert-ExistingPathNotReparsePoint { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Description ) $resolvedPath = [System.IO.Path]::GetFullPath($Path) if (-not (Test-Path -LiteralPath $resolvedPath)) { return } $item = Get-Item -LiteralPath $resolvedPath -Force if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$Description must not be a reparse point: $resolvedPath" } } function Assert-ExistingTreeHasNoReparsePoints { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [string]$Description ) $resolvedPath = [System.IO.Path]::GetFullPath($Path) if (-not (Test-Path -LiteralPath $resolvedPath)) { return } $reparsePoints = @(Get-ChildItem -LiteralPath $resolvedPath -Force -Recurse -Attributes ReparsePoint) if ($reparsePoints.Count -gt 0) { throw "$Description must not contain reparse points: $($reparsePoints[0].FullName)" } } function Copy-DirectoryTree { param( [Parameter(Mandatory = $true)] [string]$SourcePath, [Parameter(Mandatory = $true)] [string]$DestinationPath ) if (-not (Test-Path -LiteralPath $SourcePath)) { throw "Required directory not found: $SourcePath" } $sourceRoot = Get-Item -LiteralPath $SourcePath -Force if (($sourceRoot.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to copy reparse-point directory: $SourcePath" } $reparsePoints = @(Get-ChildItem -LiteralPath $SourcePath -Force -Recurse -Attributes ReparsePoint) if ($reparsePoints.Count -gt 0) { throw "Refusing to copy directory tree containing reparse points: $($reparsePoints[0].FullName)" } if (Test-Path -LiteralPath $DestinationPath) { Remove-Item -LiteralPath $DestinationPath -Recurse -Force } $destinationParent = Split-Path -Parent $DestinationPath if (-not [string]::IsNullOrWhiteSpace($destinationParent)) { New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null } Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Recurse -Force } function Get-PathWithTrailingSeparator { param( [Parameter(Mandatory = $true)] [string]$Path ) $trimmed = $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) return $trimmed + [System.IO.Path]::DirectorySeparatorChar } function Assert-PackageSha256Sums { param( [Parameter(Mandatory = $true)] [string]$PackageRoot, [Parameter(Mandatory = $true)] [string]$HashPath ) if (-not (Test-Path -LiteralPath $HashPath)) { throw "Package checksum file not found: $HashPath" } $packageRootItem = Get-Item -LiteralPath $PackageRoot -Force if (($packageRootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Package root must not be a reparse point: $PackageRoot" } $packageReparsePoints = @(Get-ChildItem -LiteralPath $PackageRoot -Force -Recurse -Attributes ReparsePoint) if ($packageReparsePoints.Count -gt 0) { throw "Package tree contains reparse points: $($packageReparsePoints[0].FullName)" } $resolvedPackageRoot = [System.IO.Path]::GetFullPath($PackageRoot) $packageRootPrefix = Get-PathWithTrailingSeparator -Path $resolvedPackageRoot $listedFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $lineNumber = 0 $entryCount = 0 foreach ($line in Get-Content -LiteralPath $HashPath) { $lineNumber++ if ([string]::IsNullOrWhiteSpace($line)) { continue } if ($line -notmatch '^(?[A-Fa-f0-9]{64})\s+\*?(?.+)$') { throw "Invalid checksum entry at ${HashPath}:$lineNumber" } $relativePath = $Matches.path.Trim() if ([string]::IsNullOrWhiteSpace($relativePath) -or [System.IO.Path]::IsPathRooted($relativePath)) { throw "Invalid checksum path at ${HashPath}:$lineNumber" } $filePath = [System.IO.Path]::GetFullPath((Join-Path $resolvedPackageRoot $relativePath)) if (-not $filePath.StartsWith($packageRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Checksum path escapes package root at ${HashPath}:$lineNumber" } if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { throw "Package checksum listed file is missing: $relativePath" } $normalizedRelativePath = [System.IO.Path]::GetRelativePath($resolvedPackageRoot, $filePath) if (-not $listedFiles.Add($normalizedRelativePath)) { throw "Duplicate checksum entry for package file: $normalizedRelativePath" } $expectedHash = $Matches.hash.ToLowerInvariant() $actualHash = (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $expectedHash) { throw "Package checksum mismatch for ${relativePath}: expected $expectedHash, got $actualHash" } $entryCount++ } if ($entryCount -eq 0) { throw "Package checksum file contains no entries: $HashPath" } foreach ($file in Get-ChildItem -LiteralPath $PackageRoot -Force -Recurse -File) { if ($file.FullName -eq $HashPath) { continue } $relativePath = [System.IO.Path]::GetRelativePath($resolvedPackageRoot, $file.FullName) if (-not $listedFiles.Contains($relativePath)) { throw "Package contains file missing from SHA256SUMS: $relativePath" } } } function Remove-PathItem { param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { return } $item = Get-Item -LiteralPath $Path -Force if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { Remove-Item -LiteralPath $Path -Force return } if ($item.PSIsContainer) { Remove-Item -LiteralPath $Path -Recurse -Force return } Remove-Item -LiteralPath $Path -Force } function New-VersionDirectoryName { $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' $suffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) return "$stamp-package-$suffix" } function Set-CurrentInstallLink { param( [Parameter(Mandatory = $true)] [string]$CurrentLinkPath, [Parameter(Mandatory = $true)] [string]$TargetPath ) $currentParent = Split-Path -Parent $CurrentLinkPath if (-not [string]::IsNullOrWhiteSpace($currentParent)) { New-Item -ItemType Directory -Force -Path $currentParent | Out-Null } if (Test-Path -LiteralPath $CurrentLinkPath) { Remove-PathItem -Path $CurrentLinkPath } try { New-Item -ItemType Junction -Path $CurrentLinkPath -Target $TargetPath | Out-Null } catch { throw "Failed to create current install junction '$CurrentLinkPath' -> '$TargetPath'. Ensure this is running on Windows with a local NTFS-compatible install root. $($_.Exception.Message)" } if (-not (Test-Path -LiteralPath $CurrentLinkPath)) { throw "Current install junction was not created: $CurrentLinkPath" } } function Remove-ObsoleteVersionDirectories { param( [Parameter(Mandatory = $true)] [string]$VersionsRoot, [AllowNull()] [string]$CurrentVersionRoot ) if (-not (Test-Path -LiteralPath $VersionsRoot)) { return } foreach ($directory in Get-ChildItem -LiteralPath $VersionsRoot -Directory -Force) { if (-not [string]::IsNullOrWhiteSpace($CurrentVersionRoot) -and $directory.FullName -eq $CurrentVersionRoot) { continue } try { Remove-Item -LiteralPath $directory.FullName -Recurse -Force } catch { Write-Warning "Leaving stale toolbox version in place because it is still in use: $($directory.FullName)" } } } function Resolve-CodexHomePath { param( [AllowNull()] [string]$ExplicitCodexHome ) if (-not [string]::IsNullOrWhiteSpace($ExplicitCodexHome)) { return $ExplicitCodexHome } if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { return $env:CODEX_HOME } return (Join-Path $env:USERPROFILE '.codex') } function Write-CodexSkillOwnershipMarker { param( [Parameter(Mandatory = $true)] [string]$SkillRoot, [Parameter(Mandatory = $true)] [string]$InstallKind ) $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' $payload = [ordered]@{ owner = 'mercury-toolbox' install_kind = $InstallKind installed_utc = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress Set-Content -LiteralPath $markerPath -Value $payload -Encoding utf8NoBOM } function Assert-CodexSkillInstallAllowed { param( [AllowNull()] [string]$SkillRoot ) if ([string]::IsNullOrWhiteSpace($SkillRoot) -or -not (Test-Path -LiteralPath $SkillRoot)) { return } $markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json' if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { throw "Refusing to overwrite unmanaged Codex skill directory: $SkillRoot" } try { $marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json } catch { throw "Refusing to overwrite Codex skill directory with invalid ownership marker: $SkillRoot" } if ($marker.owner -ne 'mercury-toolbox') { throw "Refusing to overwrite Codex skill directory owned by '$($marker.owner)': $SkillRoot" } } $packageRoot = Split-Path -Parent $PSScriptRoot $binSource = Join-Path $packageRoot 'bin' $docsSource = Join-Path $packageRoot 'docs' $skillsSource = Join-Path $packageRoot 'skills' $supportSource = Join-Path $packageRoot 'support' $manifestSource = Join-Path $packageRoot 'mercury-toolbox-package.json' $hashSource = Join-Path $packageRoot 'SHA256SUMS.txt' if (-not (Test-Path -LiteralPath $binSource)) { throw "Package bin directory not found: $binSource" } if (-not (Test-Path -LiteralPath $docsSource)) { throw "Package docs directory not found: $docsSource" } if (-not (Test-Path -LiteralPath $skillsSource)) { throw "Package skills directory not found: $skillsSource" } $legacyBinDir = Join-Path $InstallRoot 'bin' $versionsRoot = Join-Path $InstallRoot 'versions' $currentRoot = Join-Path $InstallRoot 'current' $activeBinDir = Join-Path $currentRoot 'bin' $versionRoot = Join-Path $versionsRoot (New-VersionDirectoryName) $stagingBinDir = Join-Path $versionRoot 'bin' $shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox' $codexHomeRoot = if ($NoCodexSkillInstall) { $null } else { Resolve-CodexHomePath -ExplicitCodexHome $CodexHome } $codexSkillRoot = if ([string]::IsNullOrWhiteSpace([string]$codexHomeRoot)) { $null } else { Join-Path $codexHomeRoot 'skills\mercury-toolbox' } $legacyCommandNames = @('context', 'waitfor') Assert-ExistingPathNotReparsePoint -Path $InstallRoot -Description 'InstallRoot' Assert-ExistingPathNotReparsePoint -Path $versionsRoot -Description 'versions root' Assert-ExistingPathNotReparsePoint -Path $shareRoot -Description 'share root' Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root' if ($null -ne $codexSkillRoot) { Assert-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root' Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root' } Assert-PackageSha256Sums -PackageRoot $packageRoot -HashPath $hashSource Assert-CodexSkillInstallAllowed -SkillRoot $codexSkillRoot Write-Host "Mercury Toolbox package installer" Write-Host "package root: $packageRoot" Write-Host "install root: $InstallRoot" Write-Host "active bin dir: $activeBinDir" Write-Host "versions root: $versionsRoot" Write-Host "share dir: $shareRoot" if ($null -ne $codexSkillRoot) { Write-Host "Codex skill dir: $codexSkillRoot" } New-Item -ItemType Directory -Force -Path $stagingBinDir, $shareRoot | Out-Null foreach ($legacyName in $legacyCommandNames) { $legacyPath = Join-Path $legacyBinDir "$legacyName.exe" if (Test-Path -LiteralPath $legacyPath) { Remove-Item -LiteralPath $legacyPath -Force } } $installed = [System.Collections.Generic.List[string]]::new() foreach ($commandName in Get-ToolboxCommandNames) { $sourcePath = Join-Path $binSource "$commandName.exe" if (-not (Test-Path -LiteralPath $sourcePath)) { throw "Expected packaged binary not found: $sourcePath" } Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $stagingBinDir "$commandName.exe") -Force $installed.Add($commandName) } foreach ($runtimeFile in Get-ChildItem -LiteralPath $binSource -Filter '*.dll' -File -ErrorAction SilentlyContinue) { Copy-Item -LiteralPath $runtimeFile.FullName -Destination (Join-Path $stagingBinDir $runtimeFile.Name) -Force } Copy-DirectoryTree -SourcePath $docsSource -DestinationPath (Join-Path $shareRoot 'docs') Copy-DirectoryTree -SourcePath $skillsSource -DestinationPath (Join-Path $shareRoot 'skills') if (Test-Path -LiteralPath $supportSource) { Copy-DirectoryTree -SourcePath $supportSource -DestinationPath (Join-Path $shareRoot 'support') } Copy-OptionalFile -SourcePath $manifestSource -DestinationPath (Join-Path $shareRoot 'mercury-toolbox-package.json') Copy-OptionalFile -SourcePath $hashSource -DestinationPath (Join-Path $shareRoot 'SHA256SUMS.txt') if ($null -ne $codexSkillRoot) { $packagedSkillRoot = Join-Path $skillsSource 'mercury-toolbox' if (Test-Path -LiteralPath $packagedSkillRoot) { Copy-DirectoryTree -SourcePath $packagedSkillRoot -DestinationPath $codexSkillRoot Write-CodexSkillOwnershipMarker -SkillRoot $codexSkillRoot -InstallKind 'package' } } Set-CurrentInstallLink -CurrentLinkPath $currentRoot -TargetPath $versionRoot Remove-ObsoleteVersionDirectories -VersionsRoot $versionsRoot -CurrentVersionRoot $versionRoot $pathStatus = 'skipped' if (-not $NoPathUpdate) { $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $userPathChanged = $false $userPathRemoval = Remove-PathEntry -ExistingPath $userPath -Entry $legacyBinDir if ($userPathRemoval.Changed) { $userPath = $userPathRemoval.Value $userPathChanged = $true } $userPathAdd = Add-PathEntry -ExistingPath $userPath -Entry $activeBinDir if ($userPathAdd.Changed) { $userPath = $userPathAdd.Value $userPathChanged = $true } if ($userPathChanged) { [Environment]::SetEnvironmentVariable('Path', $userPath, 'User') $pathStatus = 'updated' } else { $pathStatus = 'already_present' } $sessionPath = $env:Path $sessionRemoval = Remove-PathEntry -ExistingPath $sessionPath -Entry $legacyBinDir if ($sessionRemoval.Changed) { $sessionPath = $sessionRemoval.Value } $sessionAdd = Add-PathEntry -ExistingPath $sessionPath -Entry $activeBinDir if ($sessionAdd.Changed) { $sessionPath = $sessionAdd.Value } $env:Path = $sessionPath } Write-Host '' Write-Host "Installed commands ($($installed.Count)):" foreach ($commandName in $installed) { Write-Host " - $commandName" } Write-Host "PATH status: $pathStatus" Write-Host "AI prompt: $(Join-Path $shareRoot 'docs\ai\mercury-toolbox-ai-prompt.md')" Write-Host "Shared skill copy: $(Join-Path $shareRoot 'skills\mercury-toolbox\SKILL.md')" Write-Host "Command catalog: $(Join-Path $shareRoot 'skills\mercury-toolbox\references\command-catalog.md')" if ($null -ne $codexSkillRoot) { Write-Host "Codex skill: $(Join-Path $codexSkillRoot 'SKILL.md')" } if (-not $NoPathUpdate) { Write-Host "Shell note: future shells pick up the new PATH automatically." Write-Host "Shell note: if you launched install via 'pwsh -File', reopen this shell or run:" Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path','User') + ';' + [Environment]::GetEnvironmentVariable('Path','Machine')" } Write-Host 'Verification: pathshadow msudo' Write-Host 'Verification: msudo status --json' Write-Host 'Verification: msudo --help'