param( [string]$BinDir = "", [string]$Metadata = "", [switch]$NoModifyPath, [switch]$NoSetup, [switch]$NoSkill, [string[]]$SkillAgent = @(), [switch]$AllSkillAgents, [string]$SetupProgress = "", [switch]$DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Fail([string]$Message) { throw "install.ps1: $Message" } if (-not [System.Environment]::Is64BitOperatingSystem) { Fail "only 64-bit Windows hosts are supported" } if ([string]::IsNullOrWhiteSpace($BinDir)) { $BinDir = Join-Path $HOME ".local\bin" } $functionsBase = if ([string]::IsNullOrWhiteSpace($env:CTX_FUNCTIONS_BASE)) { "https://cli.ctx.rs/functions/v1" } else { $env:CTX_FUNCTIONS_BASE.TrimEnd("/") } $channel = if ([string]::IsNullOrWhiteSpace($env:CTX_CHANNEL)) { "stable" } else { $env:CTX_CHANNEL } $installAttemptId = if ([string]::IsNullOrWhiteSpace($env:CTX_INSTALL_ATTEMPT_ID)) { "ia_nnrmgeSlfcooAd8NoMgGpVOr" } else { $env:CTX_INSTALL_ATTEMPT_ID } if ([string]::IsNullOrWhiteSpace($Metadata)) { if ([string]::IsNullOrWhiteSpace($env:CTX_RELEASE_METADATA_URL)) { $Metadata = "$functionsBase/releases/$channel/ctx-release-metadata.env" } else { $Metadata = $env:CTX_RELEASE_METADATA_URL } } $metadataSignature = if ([string]::IsNullOrWhiteSpace($env:CTX_RELEASE_METADATA_SIGNATURE_URL)) { "$Metadata.sig" } else { $env:CTX_RELEASE_METADATA_SIGNATURE_URL } function Read-Metadata([string]$Source, [string]$Destination) { if ($Source -match '^https://') { Invoke-WebRequest -Uri $Source -OutFile $Destination -UseBasicParsing return } if ($Source -match '^http://') { Fail "refusing insecure metadata URL: $Source" } if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { Fail "metadata file not found: $Source" } Copy-Item -LiteralPath $Source -Destination $Destination } function Read-DetachedSignature([string]$Source, [string]$Destination) { Read-Metadata -Source $Source -Destination $Destination } function ConvertFrom-Base64Url([string]$Value) { $base64 = $Value.Replace("-", "+").Replace("_", "/") switch ($base64.Length % 4) { 0 { } 2 { $base64 += "==" } 3 { $base64 += "=" } default { Fail "invalid base64url value in metadata public key" } } try { return ,([System.Convert]::FromBase64String($base64)) } catch { Fail "invalid base64url value in metadata public key" } } function Get-MetadataSignaturePublicKeyParameters() { $parameters = [System.Security.Cryptography.RSAParameters]::new() $parameters.Modulus = [byte[]](ConvertFrom-Base64Url "yBPNIx3H_NwWlN9CPHY5kOEe9kQEshOJEMpv3Atq086H1FWqliTm3BCWiO4s_89wNMn11Pla2JetCWNiWsbxm3BIxCd1o6cq8y9ur6Zk1RGOQBLQgqhFm5BpcTTavhtlc3FdV2KSm2UU1IEJAiFXJyMlbgmf3tXfO8Cji_3mG11rWCXfnEzXJmig5_WWA21ZgsafPJGH9ow7FsLok5G1kvOeVDXcv0gzmxWH-2O40kCGWo7BK7P_2DPD2GbXc81Mf6S7vWi7CeFiBeGH8EGZ6MgBM0UnAFEqtx_WvY47O-LHzFrGlJTpss3xlxsSQOTmXDJdOzmQVi04GkbOtBEl-dIyYsxZGusLBMGDqkZekO4Z5LvqA8zHt4JAElZCs8SGTlV70MSlnyZb5_rkKx9kMvb7YjuYbY6vnN5Pp3P7gMhOKehP-62U80cgyj1m6Sk5bByrs54ne2mM-cwNXXgKp5UntmkefDcfKP7MmISy93U_kg3fWojE_a-X6TNV_k5f") $parameters.Exponent = [byte[]](ConvertFrom-Base64Url "AQAB") return $parameters } function Verify-MetadataSignature([string]$MetadataPath, [string]$SignaturePath) { $signatureB64 = (Get-Content -LiteralPath $SignaturePath -Raw).Trim() try { [byte[]]$signatureBytes = [System.Convert]::FromBase64String($signatureB64) } catch { Fail "metadata signature is not base64-encoded RSA-SHA256 bytes" } if ($signatureBytes.Length -eq 0) { Fail "metadata signature is empty" } [byte[]]$metadataBytes = [System.IO.File]::ReadAllBytes($MetadataPath) $rsa = [System.Security.Cryptography.RSA]::Create() try { $rsa.ImportParameters((Get-MetadataSignaturePublicKeyParameters)) $verified = $rsa.VerifyData( $metadataBytes, $signatureBytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 ) } finally { $rsa.Dispose() } if (-not $verified) { Fail "metadata signature verification failed" } } function Get-MetadataValue([hashtable]$Values, [string]$Key) { if (-not $Values.ContainsKey($Key)) { Fail "metadata missing $Key" } return [string]$Values[$Key] } function Get-MetadataValueOrDefault([hashtable]$Values, [string]$Key, [string]$Default) { if (-not $Values.ContainsKey($Key)) { return $Default } return [string]$Values[$Key] } function Assert-SafeArtifactName([string]$Value) { if ($Value.Contains("..") -or $Value.Contains("/") -or $Value.Contains("\")) { Fail "unsafe artifact name: $Value" } } function Expand-GzipFile([string]$Source, [string]$Destination) { $inputStream = [System.IO.File]::OpenRead($Source) $outputStream = $null $gzipStream = $null try { $outputStream = [System.IO.File]::Create($Destination) $gzipStream = [System.IO.Compression.GZipStream]::new( $inputStream, [System.IO.Compression.CompressionMode]::Decompress ) $gzipStream.CopyTo($outputStream) } finally { if ($null -ne $gzipStream) { $gzipStream.Dispose() } if ($null -ne $outputStream) { $outputStream.Dispose() } $inputStream.Dispose() } } function Assert-AllowedBaseUrl([string]$Value) { if ($Value -match '^https://cli\.ctx\.rs/storage/v1/object/public/releases/artifacts/') { return } if ($env:CTX_ALLOW_CUSTOM_RELEASE_BASE_URL -eq "1") { return } Fail "metadata base URL must be under https://cli.ctx.rs/storage/v1/object/public/releases/artifacts/" } function Normalize-PathEntry([string]$Path) { if ([string]::IsNullOrWhiteSpace($Path)) { return "" } return $Path.Trim().Trim('"').TrimEnd("\", "/") } function Test-PathContainsDirectory([string]$PathValue, [string]$Directory) { $needle = Normalize-PathEntry $Directory if ([string]::IsNullOrWhiteSpace($needle)) { return $false } foreach ($entry in ($PathValue -split [regex]::Escape([System.IO.Path]::PathSeparator))) { if ((Normalize-PathEntry $entry).Equals($needle, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } return $false } function Format-CurrentPathCommand([string]$Directory) { $escaped = $Directory.Replace('`', '``').Replace('"', '`"') return "`$env:Path = `"$escaped;`$env:Path`"" } function Write-CurrentPathCommand([string]$Directory) { Write-Host "For this PowerShell session, run:" Write-Host (" " + (Format-CurrentPathCommand $Directory)) } function Add-InstallDirToPathIfNeeded([string]$Directory, [bool]$ModifyPath) { $dir = $Directory.TrimEnd("\", "/") if (Test-PathContainsDirectory -PathValue $env:Path -Directory $dir) { return } if (-not $ModifyPath) { Write-Host "" Write-Host "$dir is not on PATH; user PATH update skipped." Write-CurrentPathCommand $dir return } if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_PATH)) { Add-Content -LiteralPath $env:GITHUB_PATH -Value $dir if (-not (Test-PathContainsDirectory -PathValue $env:Path -Directory $dir)) { $env:Path = "$dir$([System.IO.Path]::PathSeparator)$env:Path" } Write-Host "" Write-Host "Added $dir to GITHUB_PATH for later GitHub Actions steps." return } if ($env:CI -eq "1" -or $env:CI -eq "true") { $env:Path = "$dir$([System.IO.Path]::PathSeparator)$env:Path" Write-Host "" Write-Host "$dir is not on PATH; CI detected, not editing the user PATH." Write-CurrentPathCommand $dir return } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") Write-Host "" if (Test-PathContainsDirectory -PathValue $userPath -Directory $dir) { Write-Host "Found existing user PATH setup for $dir." } else { if ([string]::IsNullOrWhiteSpace($userPath)) { $newUserPath = $dir } else { $newUserPath = "$dir$([System.IO.Path]::PathSeparator)$userPath" } try { [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") Write-Host "Added $dir to the user PATH." } catch { Write-Warning "could not update the user PATH: $($_.Exception.Message)" } } $updatedCurrentPath = $false if (-not (Test-PathContainsDirectory -PathValue $env:Path -Directory $dir)) { $env:Path = "$dir$([System.IO.Path]::PathSeparator)$env:Path" $updatedCurrentPath = $true } if ($updatedCurrentPath) { Write-Host "$dir was not on PATH at startup; this PowerShell session has been updated." } Write-Host "Open a new PowerShell window or run:" Write-Host (" " + (Format-CurrentPathCommand $dir)) Write-Host "Then verify with:" Write-Host " ctx status" } function Send-InstallStage([string]$Stage, [string]$Status, [string]$ErrorKind = "") { if ($env:CTX_ANALYTICS_OFF -eq "1" -or $env:CTX_INSTALL_DIAGNOSTICS_OFF -eq "1") { return } if ($DryRun) { return } if ($functionsBase -notmatch '^https://') { return } $body = [ordered]@{ install_attempt_id = $installAttemptId stage = $Stage status = $Status error_kind = $ErrorKind platform = "windows-x64" channel = $releaseChannel version = $version } | ConvertTo-Json -Compress try { Invoke-WebRequest -Uri ($functionsBase.TrimEnd("/") + "/install-attempt") -Method POST -ContentType "application/json" -Body $body -UseBasicParsing -TimeoutSec 2 | Out-Null } catch { } } $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ctx-install-" + [System.Guid]::NewGuid().ToString("n")) New-Item -ItemType Directory -Path $tempRoot | Out-Null $releaseChannel = $channel $version = "" $installerCompleted = $false $installerErrorKind = "exception" try { Send-InstallStage -Stage "script_started" -Status "started" $metadataFile = Join-Path $tempRoot "metadata.env" $metadataSignatureFile = Join-Path $tempRoot "metadata.env.sig" Read-Metadata -Source $Metadata -Destination $metadataFile Read-DetachedSignature -Source $metadataSignature -Destination $metadataSignatureFile Verify-MetadataSignature -MetadataPath $metadataFile -SignaturePath $metadataSignatureFile $metadataText = Get-Content -LiteralPath $metadataFile | Where-Object { $_ -notmatch '^\s*#' -and $_ -match '=' } $metadataValues = ConvertFrom-StringData -StringData ($metadataText -join "`n") $schemaVersion = Get-MetadataValue $metadataValues "CTX_RELEASE_SCHEMA_VERSION" $version = Get-MetadataValue $metadataValues "CTX_RELEASE_VERSION" $baseUrl = Get-MetadataValue $metadataValues "CTX_RELEASE_BASE_URL" $artifact = Get-MetadataValue $metadataValues "CTX_RELEASE_ARTIFACT_windows_x64" $checksum = Get-MetadataValue $metadataValues "CTX_RELEASE_SHA256_windows_x64" $releaseChannel = Get-MetadataValueOrDefault $metadataValues "CTX_RELEASE_CHANNEL" $channel $sourceCommit = Get-MetadataValueOrDefault $metadataValues "CTX_RELEASE_SOURCE_COMMIT" "" $publishedAt = Get-MetadataValueOrDefault $metadataValues "CTX_RELEASE_PUBLISHED_AT" "" if ($schemaVersion -ne "1") { Fail "unsupported metadata schema: $schemaVersion" } if ($releaseChannel -cne $channel) { Fail "metadata channel $releaseChannel does not match requested channel $channel" } if ($baseUrl -notmatch '^https://') { Fail "metadata base URL must be HTTPS" } Assert-AllowedBaseUrl $baseUrl if ($checksum -notmatch '^[0-9a-fA-F]{64}$') { Fail "checksum for windows-x64 is not a SHA-256 hex digest" } if ($checksum -eq "0000000000000000000000000000000000000000000000000000000000000000") { Fail "checksum for windows-x64 is a placeholder" } Assert-SafeArtifactName $artifact $artifactUrl = $baseUrl.TrimEnd("/") + "/" + $artifact $downloadPath = Join-Path $tempRoot $artifact $compressedArtifactUrl = $artifactUrl + ".gz" $compressedDownloadPath = $downloadPath + ".gz" $installPath = Join-Path $BinDir "ctx.exe" $skillAgents = @() foreach ($agent in $SkillAgent) { $trimmed = $agent.Trim() if (-not [string]::IsNullOrWhiteSpace($trimmed)) { $skillAgents += $trimmed } } $allSkillAgentsRequested = [bool]$AllSkillAgents $explicitSkillRequest = $allSkillAgentsRequested -or $skillAgents.Count -gt 0 if ($env:CTX_INSTALL_ALL_SKILL_AGENTS -eq "1") { $allSkillAgentsRequested = $true $explicitSkillRequest = $true } if (-not [string]::IsNullOrWhiteSpace($env:CTX_INSTALL_SKILL_AGENTS)) { foreach ($agent in ($env:CTX_INSTALL_SKILL_AGENTS -split ",")) { $trimmed = $agent.Trim() if (-not [string]::IsNullOrWhiteSpace($trimmed)) { $skillAgents += $trimmed $explicitSkillRequest = $true } } } $noSkillRequested = [bool]$NoSkill -or $env:CTX_INSTALL_NO_SKILL -eq "1" if ($noSkillRequested -and $explicitSkillRequest) { Fail "cannot combine -NoSkill or CTX_INSTALL_NO_SKILL=1 with skill agent options" } if ($allSkillAgentsRequested -and $skillAgents.Count -gt 0) { Fail "cannot combine -AllSkillAgents with -SkillAgent or CTX_INSTALL_SKILL_AGENTS" } $runSetup = -not $NoSetup -and $env:CTX_INSTALL_NO_SETUP -ne "1" $runSkill = -not $noSkillRequested if (-not $runSetup -and -not $explicitSkillRequest) { $runSkill = $false } $modifyPath = -not $NoModifyPath -and $env:CTX_INSTALL_NO_MODIFY_PATH -ne "1" if ($DryRun) { Write-Host "Dry run: would install ctx $version (windows-x64)" } else { Write-Host "Installing ctx $version (windows-x64)" } Write-Host " binary: $installPath" if ($runSkill) { if ($allSkillAgentsRequested) { Write-Host " skill: all supported agents" } elseif ($skillAgents.Count -gt 0) { Write-Host (" skill: " + ($skillAgents -join ",")) } else { Write-Host " skill: universal + detected agent folders" } } else { Write-Host " skill: skipped" } if ($runSetup) { Write-Host " history: index discovered sessions" } else { Write-Host " history: skipped" } if ($DryRun) { $installerCompleted = $true exit 0 } Send-InstallStage -Stage "artifact_download_started" -Status "started" $compressedArtifactDownloaded = $false try { Invoke-WebRequest -Uri $compressedArtifactUrl -OutFile $compressedDownloadPath -UseBasicParsing $compressedArtifactDownloaded = $true } catch { Remove-Item -LiteralPath $compressedDownloadPath -Force -ErrorAction SilentlyContinue } if ($compressedArtifactDownloaded) { $installerErrorKind = "decompression_failed" Expand-GzipFile -Source $compressedDownloadPath -Destination $downloadPath $installerErrorKind = "exception" Write-Host "Downloaded gzip-compressed artifact." } else { Invoke-WebRequest -Uri $artifactUrl -OutFile $downloadPath -UseBasicParsing } Send-InstallStage -Stage "artifact_download_completed" -Status "completed" $actualChecksum = (Get-FileHash -Algorithm SHA256 -LiteralPath $downloadPath).Hash.ToLowerInvariant() if ($actualChecksum -ne $checksum.ToLowerInvariant()) { $installerErrorKind = "checksum_mismatch" Fail "checksum mismatch for ${artifact}: expected $checksum, got $actualChecksum" } New-Item -ItemType Directory -Path $BinDir -Force | Out-Null Copy-Item -LiteralPath $downloadPath -Destination $installPath -Force $markerPath = "$installPath.install.json" $marker = [ordered]@{ schema_version = 1 manager = "ctx-hosted-installer" install_attempt_id = $installAttemptId install_path = $installPath platform = "windows-x64" channel = $releaseChannel version = $version sha256 = $actualChecksum metadata_url = $Metadata artifact_url = $artifactUrl source_commit = $sourceCommit published_at = $publishedAt installed_at = ([DateTime]::UtcNow.ToString("o")) } $markerJson = $marker | ConvertTo-Json -Depth 4 $utf8NoBom = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($markerPath, $markerJson + [Environment]::NewLine, $utf8NoBom) Send-InstallStage -Stage "binary_installed" -Status "completed" Write-Host "" Write-Host "Installed ctx binary." if ($runSkill) { $skillArgs = @("integrations", "install", "skills") if ($allSkillAgentsRequested) { $skillArgs += "--all-agents" } else { foreach ($agent in $skillAgents) { $skillArgs += @("--agent", $agent) } } Write-Host "" Send-InstallStage -Stage "skill_launched" -Status "started" & $installPath @skillArgs if ($LASTEXITCODE -ne 0) { Send-InstallStage -Stage "skill_exited" -Status "failed" -ErrorKind "skill_failed" Write-Warning "ctx integrations install skills failed after install; run $installPath integrations install skills to retry" } else { Send-InstallStage -Stage "skill_exited" -Status "completed" } } else { Write-Host "" Write-Host "Agent skill skipped. Run $installPath integrations install skills to install it later." Send-InstallStage -Stage "skill_skipped" -Status "skipped" } $setupStatus = 0 if ($runSetup) { if ([string]::IsNullOrWhiteSpace($SetupProgress)) { if ([string]::IsNullOrWhiteSpace($env:CTX_SETUP_PROGRESS)) { $SetupProgress = "auto" } else { $SetupProgress = $env:CTX_SETUP_PROGRESS } } Write-Host "" Write-Host "Indexing local agent history..." Send-InstallStage -Stage "setup_launched" -Status "started" & $installPath setup --progress $SetupProgress if ($LASTEXITCODE -ne 0) { $setupStatus = $LASTEXITCODE $installerErrorKind = "setup_failed" Send-InstallStage -Stage "setup_exited" -Status "failed" -ErrorKind "setup_failed" Write-Warning "ctx setup failed after install; run $installPath setup --progress $SetupProgress to retry" } else { Send-InstallStage -Stage "setup_exited" -Status "completed" } } else { Write-Host "" Write-Host "Setup skipped. Run $installPath setup to index local history." Send-InstallStage -Stage "setup_skipped" -Status "skipped" } Add-InstallDirToPathIfNeeded -Directory $BinDir -ModifyPath $modifyPath if ($setupStatus -ne 0) { $installerCompleted = $true exit $setupStatus } $installerCompleted = $true } catch { if (-not $installerCompleted) { Send-InstallStage -Stage "installer_failed" -Status "failed" -ErrorKind $installerErrorKind } throw } finally { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue }