# SpecStory CLI for Windows. Run in PowerShell: # irm https://specstory.com/install.ps1 | iex # Optional environment variables: SPECSTORY_INSTALL_DIR, SPECSTORY_VERSION, # SPECSTORY_NO_MODIFY_PATH=1 (skip adding the install directory to PATH). & { $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $downloadDir = $null $stagedBinary = $null $previousTls = [Net.ServicePointManager]::SecurityProtocol function Get-ReleaseFile { param([string]$Uri, [string]$OutFile, [int]$TimeoutSec = 60) for ($attempt = 1; $attempt -le 3; $attempt++) { try { if ($OutFile) { Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec } else { Invoke-RestMethod -Uri $Uri -Headers @{ 'User-Agent' = 'SpecStory-Installer'; 'Accept' = 'application/vnd.github+json' } -TimeoutSec $TimeoutSec } return } catch { $cause = $_.Exception $isDns = $false $isTransient = $false while ($cause) { if ($cause -is [Net.WebException]) { $isDns = $isDns -or $cause.Status -in @([Net.WebExceptionStatus]::NameResolutionFailure, [Net.WebExceptionStatus]::ProxyNameResolutionFailure) $isTransient = $isTransient -or $cause.Status -in @([Net.WebExceptionStatus]::NameResolutionFailure, [Net.WebExceptionStatus]::ProxyNameResolutionFailure, [Net.WebExceptionStatus]::ConnectFailure, [Net.WebExceptionStatus]::Timeout, [Net.WebExceptionStatus]::ConnectionClosed) } if ($cause -is [Net.Sockets.SocketException]) { $isDns = $isDns -or $cause.SocketErrorCode -in @([Net.Sockets.SocketError]::HostNotFound, [Net.Sockets.SocketError]::TryAgain, [Net.Sockets.SocketError]::NoData) $isTransient = $true } $cause = $cause.InnerException } $downloadHost = ([Uri]$Uri).DnsSafeHost if ($isTransient -and $attempt -lt 3) { Write-Host " Could not reach $downloadHost. Retrying ($attempt/2)..." Start-Sleep -Seconds $attempt continue } $advice = if ($isDns) { "Windows could not resolve a download host. Run 'Resolve-DnsName $downloadHost' and check your network, VPN, proxy, or DNS settings." } else { 'Check your connection and whether your network allows GitHub downloads (github.com, api.github.com, and release-assets.githubusercontent.com).' } throw "Could not download $Uri. $advice Your existing installation was not changed. Details: $($_.Exception.Message)" } } } try { if ($env:OS -ne 'Windows_NT') { throw 'This installer is for Windows. On macOS or Linux, use https://specstory.com/install.sh.' } if ($PSVersionTable.PSVersion.Major -lt 5) { throw 'Use Windows PowerShell 5.1 or PowerShell 7 or later.' } # PROCESSOR_ARCHITEW6432 identifies the native CPU from an emulated shell. $nativeArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } $architecture = switch ($nativeArch) { 'AMD64' { 'x86_64' } 'ARM64' { 'arm64' } default { throw "Unsupported processor: $nativeArch. SpecStory supports x64 and ARM64 Windows." } } $installDir = if ($env:SPECSTORY_INSTALL_DIR) { $env:SPECSTORY_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'SpecStory\bin' } if (-not [IO.Path]::IsPathRooted($installDir)) { throw 'SPECSTORY_INSTALL_DIR must be an absolute path.' } $destination = Join-Path $installDir 'specstory.exe' if (Test-Path -LiteralPath $destination) { $existing = Get-Item -LiteralPath $destination -Force if ($existing.PSIsContainer -or ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Refusing to replace a directory or link at $destination. Use your package manager to update, or choose another SPECSTORY_INSTALL_DIR." } } [Net.ServicePointManager]::SecurityProtocol = $previousTls -bor [Net.SecurityProtocolType]::Tls12 if ($env:SPECSTORY_VERSION) { $version = $env:SPECSTORY_VERSION -replace '^v', '' } else { Write-Host 'Finding the latest SpecStory CLI release...' $release = Get-ReleaseFile -Uri 'https://api.github.com/repos/specstoryai/getspecstory/releases/latest' -TimeoutSec 30 $version = $release.tag_name -replace '^v', '' } if ($version -notmatch '^\d+\.\d+\.\d+$') { throw 'Expected a stable release version such as 2.11.0.' } $archiveName = "SpecStoryCLI_Windows_$architecture.zip" $releaseUrl = "https://github.com/specstoryai/getspecstory/releases/download/v$version" $downloadDir = Join-Path ([IO.Path]::GetTempPath()) ("specstory-install-" + [Guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($downloadDir) | Out-Null $archive = Join-Path $downloadDir $archiveName $checksums = Join-Path $downloadDir 'checksums.txt' Write-Host "`nSpecStory CLI - $version`n`n 1/3 Downloading for Windows $architecture..." Get-ReleaseFile -Uri "$releaseUrl/$archiveName" -OutFile $archive -TimeoutSec 300 Get-ReleaseFile -Uri "$releaseUrl/SpecStoryCLI_${version}_checksums.txt" -OutFile $checksums Write-Host ' 2/3 Verifying SHA-256 checksum...' $pattern = '^([a-fA-F0-9]{64})\s+\*?' + [regex]::Escape($archiveName) + '$' $expected = @(Get-Content -LiteralPath $checksums | ForEach-Object { if ($_ -match $pattern) { $Matches[1] } }) if ($expected.Count -ne 1) { throw 'The release must contain exactly one SHA-256 checksum for this archive.' } $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash if ($actual -ne $expected[0]) { throw 'Checksum mismatch. Nothing was installed. Retry the download or report the release to SpecStory.' } # Extract only the exact executable entry, ignoring all other ZIP paths. Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($archive) try { $entries = @($zip.Entries | Where-Object { $_.FullName -ceq 'specstory.exe' }) if ($entries.Count -ne 1) { throw 'The archive must contain exactly one specstory.exe binary.' } $extracted = Join-Path $downloadDir 'specstory.exe' [IO.Compression.ZipFileExtensions]::ExtractToFile($entries[0], $extracted) } finally { $zip.Dispose() } Write-Host ' 3/3 Installing...' [IO.Directory]::CreateDirectory($installDir) | Out-Null $stagedBinary = Join-Path $installDir ('.specstory-' + [Guid]::NewGuid().ToString('N') + '.tmp') [IO.File]::Copy($extracted, $stagedBinary) # Stage on the same volume and replace atomically; a running/locked EXE # fails without deleting the existing installation. try { if ([IO.File]::Exists($destination)) { [IO.File]::Replace($stagedBinary, $destination, [NullString]::Value) } else { [IO.File]::Move($stagedBinary, $destination) } } catch { $cause = $_.Exception while ($cause.InnerException) { $cause = $cause.InnerException } if (($cause.HResult -band 0xFFFF) -in @(32, 33)) { throw "The SpecStory executable is in use. Close running SpecStory processes, then retry. Your existing installation was not changed." } throw "Could not write $destination. Check that you have permission to update this file. Details: $($_.Exception.Message)" } $stagedBinary = $null Write-Host "`nInstalled SpecStory $version to $destination" if ($env:SPECSTORY_NO_MODIFY_PATH -ne '1') { $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $userEntries = @($userPath -split ';' | Where-Object { $_ -and $_.TrimEnd('\') -ine $installDir.TrimEnd('\') }) try { [Environment]::SetEnvironmentVariable('Path', (@($installDir) + $userEntries -join ';'), 'User') } catch { Write-Warning "Installed successfully, but could not save your user PATH. Add $installDir to your user PATH in Windows Settings." } # This also makes specstory available immediately when piped to iex. $currentEntries = @($env:Path -split ';' | Where-Object { $_ -and $_.TrimEnd('\') -ine $installDir.TrimEnd('\') }) $env:Path = @($installDir) + $currentEntries -join ';' Write-Host 'Ready in this PowerShell session. Open a new terminal to use SpecStory in other windows.' } else { Write-Host 'PATH was not changed (SPECSTORY_NO_MODIFY_PATH=1).' } Write-Host "`nNext, check your installed coding agents:`n specstory check`n`nFrom your project folder, start a session:`n specstory run claude`n # Or: specstory run codex`n`nSetup help: https://docs.specstory.com/integrations/terminal-coding-agents" } catch { throw "SpecStory installation failed: $($_.Exception.Message)" } finally { [Net.ServicePointManager]::SecurityProtocol = $previousTls if ($stagedBinary -and (Test-Path -LiteralPath $stagedBinary)) { Remove-Item -LiteralPath $stagedBinary -Force -ErrorAction SilentlyContinue } if ($downloadDir -and (Test-Path -LiteralPath $downloadDir)) { Remove-Item -LiteralPath $downloadDir -Recurse -Force -ErrorAction SilentlyContinue } } }