[CmdletBinding()] param( [ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')] [string]$Configuration = 'ReleaseFast', [ValidateNotNullOrEmpty()] [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'), [string]$CodexHome, [switch]$NoPathUpdate, [switch]$NoCodexSkillInstall, [switch]$SkipBuild, [switch]$SkipSlimBinaryRebuild ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot 'toolbox-commands.ps1') function Invoke-StrictNative { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$ArgumentList ) & $FilePath @ArgumentList if ($LASTEXITCODE -ne 0) { throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" } } function Get-RequiredCommandPath { param( [Parameter(Mandatory = $true)] [string]$Name ) $command = Get-Command -Name $Name -ErrorAction SilentlyContinue if ($null -eq $command) { throw "Required command not found on PATH: $Name" } return $command.Source } 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 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" } } 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 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 { param( [Parameter(Mandatory = $true)] [string]$Configuration ) $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' $suffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) return "$stamp-$($Configuration.ToLowerInvariant())-$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)" } } } $workspaceRoot = Split-Path -Parent $PSScriptRoot $profileName = Resolve-ToolboxProfileName -Configuration $Configuration $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 -Configuration $Configuration) $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-CodexSkillInstallAllowed -SkillRoot $codexSkillRoot Write-Host "Mercury Toolbox installer" Write-Host "workspace: $workspaceRoot" 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" } Write-Host "configuration: $Configuration" 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 } } if (-not $SkipBuild) { $cargoPath = Get-RequiredCommandPath -Name 'cargo' Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration -SkipSlimBinaryRebuild:$SkipSlimBinaryRebuild } $promptScript = Join-Path $PSScriptRoot 'generate-ai-prompt.ps1' & $promptScript -Configuration $Configuration -SkipBuild if ($LASTEXITCODE -ne 0) { throw "generate-ai-prompt.ps1 exited with $LASTEXITCODE" } $skillScript = Join-Path $PSScriptRoot 'generate-ai-skill.ps1' & $skillScript -Configuration $Configuration -SkipBuild if ($LASTEXITCODE -ne 0) { throw "generate-ai-skill.ps1 exited with $LASTEXITCODE" } $installed = [System.Collections.Generic.List[string]]::new() foreach ($commandName in Get-ToolboxCommandNames) { $sourcePath = Join-Path $workspaceRoot "target\$profileName\$commandName.exe" if (-not (Test-Path -LiteralPath $sourcePath)) { throw "Expected binary not found: $sourcePath" } $destinationPath = Join-Path $stagingBinDir "$commandName.exe" Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force $installed.Add($commandName) } $duckdbDllPath = Join-Path $workspaceRoot "target\$profileName\deps\duckdb.dll" if (Test-Path -LiteralPath $duckdbDllPath) { Copy-Item -LiteralPath $duckdbDllPath -Destination (Join-Path $stagingBinDir 'duckdb.dll') -Force } Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'docs\ai') -DestinationPath (Join-Path $shareRoot 'docs\ai') Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath (Join-Path $shareRoot 'skills\mercury-toolbox') if (Test-Path -LiteralPath (Join-Path $workspaceRoot 'support')) { Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'support') -DestinationPath (Join-Path $shareRoot 'support') } Copy-OptionalFile -SourcePath (Join-Path $workspaceRoot 'README.md') -DestinationPath (Join-Path $shareRoot 'README.md') if ($null -ne $codexSkillRoot) { Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath $codexSkillRoot Write-CodexSkillOwnershipMarker -SkillRoot $codexSkillRoot -InstallKind 'workspace' } 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 "Command catalog: $(Join-Path $shareRoot 'skills\mercury-toolbox\references\command-catalog.md')" Write-Host "Shared skill copy: $(Join-Path $shareRoot 'skills\mercury-toolbox\SKILL.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'