# Squirrelly skill installer. PowerShell 5.1 and 7; no administrator required. # Run the public launcher or save this script and invoke it with -Client codex. [CmdletBinding()] param( [ValidateSet('codex', 'claude-code')][string]$Client = 'codex', [string]$SkillsDir, [switch]$Update ) & { param($Client, $SkillsDir, $Update) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $maxZipBytes = 5MB $maxExpandedBytes = 8MB $paths = @('squirrelly/SKILL.md', 'squirrelly/INSTALL.md', 'squirrelly/agents/openai.yaml', 'squirrelly/assets/avatar.jpg', 'squirrelly/references/tool-contract.md', 'squirrelly/scripts/amount.mjs') function Assert-NoReparsePath([string]$Path) { $current = [IO.Path]::GetFullPath($Path) while ($current) { try { $attributes = [IO.File]::GetAttributes($current) if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing symlink or reparse path: $current" } } catch [IO.FileNotFoundException] { } catch [IO.DirectoryNotFoundException] { } $parent = [IO.Path]::GetDirectoryName($current) if ($parent -eq $current) { break } $current = $parent } } function New-PrivateStage([string]$Path) { $directory = [IO.Directory]::CreateDirectory($Path) if ([IO.Path]::DirectorySeparatorChar -eq [char]92) { # Strip inherited access before putting any downloaded data in here. $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User $security = New-Object Security.AccessControl.DirectorySecurity $security.SetAccessRuleProtection($true, $false) $security.SetOwner($sid) $rule = New-Object Security.AccessControl.FileSystemAccessRule($sid, [Security.AccessControl.FileSystemRights]::FullControl, ([Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit), [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow) $security.AddAccessRule($rule) if ($PSVersionTable.PSVersion.Major -le 5) { $directory.SetAccessControl($security) } else { [IO.FileSystemAclExtensions]::SetAccessControl($directory, $security) } } } function Save-Download([string]$Uri, [string]$OutputPath, [long]$MaxBytes) { # Inspect the fixed HTTPS resource before downloading. Requiring a declared # bounded length also avoids unbounded chunked responses from this endpoint. $head = Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing ` -TimeoutSec 20 -MaximumRedirection 0 -ErrorAction Stop $declared = 0L if (-not [long]::TryParse([string]$head.Headers['Content-Length'], [ref]$declared) -or $declared -le 0 -or $declared -gt $MaxBytes) { throw "Download size is unavailable or exceeds limit: $Uri" } # Range is a restricted HttpWebRequest header on Windows PowerShell 5.1; # use ordinary bounded-time requests so both supported runtimes work. $null = Invoke-WebRequest -Uri $Uri -UseBasicParsing -OutFile $OutputPath ` -TimeoutSec 60 -MaximumRedirection 0 -ErrorAction Stop $length = (Get-Item -LiteralPath $OutputPath -Force).Length if ($length -ne $declared -or $length -gt $MaxBytes) { throw "Download size exceeds limit or is empty: $Uri" } } function Get-ExpectedCrcs([byte[]]$Bytes) { # Read the central directory ourselves: ZipArchive alone may omit CRC checks. # ZIP64, encrypted, multipart, and non-store/deflate files are not accepted. $end = -1 $minimum = [Math]::Max(0, $Bytes.Length - 65557) for ($i = $Bytes.Length - 22; $i -ge $minimum; $i--) { if ([BitConverter]::ToUInt32($Bytes, $i) -eq 0x06054b50 -and ($i + 22 + [BitConverter]::ToUInt16($Bytes, $i + 20)) -eq $Bytes.Length) { $end = $i; break } } if ($end -lt 0) { throw 'Invalid ZIP end record.' } $count = [BitConverter]::ToUInt16($Bytes, $end + 10) $centralSize = [BitConverter]::ToUInt32($Bytes, $end + 12) $offset = [long][BitConverter]::ToUInt32($Bytes, $end + 16) if ([BitConverter]::ToUInt16($Bytes, $end + 4) -ne 0 -or [BitConverter]::ToUInt16($Bytes, $end + 6) -ne 0 -or [BitConverter]::ToUInt16($Bytes, $end + 8) -ne $count -or $count -ne $paths.Count -or $offset + $centralSize -ne $end) { throw 'Unsupported ZIP archive layout or entry count.' } $records = New-Object 'System.Collections.Generic.Dictionary[string,uint32]' ([StringComparer]::Ordinal) $allowed = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) foreach ($p in $paths) { $null = $allowed.Add($p) } for ($n = 0; $n -lt $count; $n++) { if ($offset + 46 -gt $end -or [BitConverter]::ToUInt32($Bytes, [int]$offset) -ne 0x02014b50) { throw 'Invalid ZIP central entry.' } $flags = [BitConverter]::ToUInt16($Bytes, [int]$offset + 8) $method = [BitConverter]::ToUInt16($Bytes, [int]$offset + 10) $crc = [BitConverter]::ToUInt32($Bytes, [int]$offset + 16) $compressed = [BitConverter]::ToUInt32($Bytes, [int]$offset + 20) $expanded = [BitConverter]::ToUInt32($Bytes, [int]$offset + 24) $nameLength = [BitConverter]::ToUInt16($Bytes, [int]$offset + 28) $extraLength = [BitConverter]::ToUInt16($Bytes, [int]$offset + 30) $commentLength = [BitConverter]::ToUInt16($Bytes, [int]$offset + 32) $attributes = [BitConverter]::ToUInt32($Bytes, [int]$offset + 38) $local = [long][BitConverter]::ToUInt32($Bytes, [int]$offset + 42) $next = $offset + 46 + $nameLength + $extraLength + $commentLength if ($next -gt $end) { throw 'Truncated ZIP entry.' } $name = [Text.Encoding]::UTF8.GetString($Bytes, [int]$offset + 46, $nameLength) if (-not $allowed.Contains($name) -or $records.ContainsKey($name)) { throw "Unexpected or duplicate ZIP entry path: $name" } $unixType = ($attributes -shr 16) -band 0xF000 if (($unixType -ne 0 -and $unixType -ne 0x8000) -or ($attributes -band 0x410) -ne 0) { throw "ZIP symlink, reparse, or directory entry rejected: $name" } if (($flags -band 1) -ne 0 -or ($method -ne 0 -and $method -ne 8) -or $expanded -gt $maxExpandedBytes -or [BitConverter]::ToUInt16($Bytes, [int]$offset + 34) -ne 0) { throw "Unsupported ZIP entry or expanded size: $name" } if ($local + 30 -gt $offset -or [BitConverter]::ToUInt32($Bytes, [int]$local) -ne 0x04034b50) { throw 'Invalid ZIP local entry.' } $localNameLength = [BitConverter]::ToUInt16($Bytes, [int]$local + 26) $localExtraLength = [BitConverter]::ToUInt16($Bytes, [int]$local + 28) $dataOffset = $local + 30 + $localNameLength + $localExtraLength if ($dataOffset + $compressed -gt $end -or $dataOffset -gt $Bytes.Length -or [Text.Encoding]::UTF8.GetString($Bytes, [int]$local + 30, $localNameLength) -cne $name -or [BitConverter]::ToUInt16($Bytes, [int]$local + 6) -ne $flags -or [BitConverter]::ToUInt16($Bytes, [int]$local + 8) -ne $method) { throw 'ZIP local and central entry disagree.' } $records.Add($name, $crc) $offset = $next } if ($offset -ne $end) { throw 'Unexpected ZIP central directory data.' } return ,$records } function Test-SameSkill([string]$Existing, [string]$Staged) { if (-not [IO.Directory]::Exists($Existing)) { return $false } $expectedFiles = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) $prefix = 'squirrelly/' foreach ($path in $paths) { $null = $expectedFiles.Add($path.Substring($prefix.Length)) } $expectedDirs = @('agents', 'assets', 'references', 'scripts') $queue = New-Object 'System.Collections.Generic.Queue[string]' $queue.Enqueue($Existing) $count = 0 while ($queue.Count -gt 0) { foreach ($item in @(Get-ChildItem -LiteralPath $queue.Dequeue() -Force)) { if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Existing skill contains a symlink or reparse path: $($item.FullName)" } $relative = $item.FullName.Substring($Existing.Length + 1).Replace('\', '/') if ($item.PSIsContainer) { if ($expectedDirs -cnotcontains $relative) { return $false } $queue.Enqueue($item.FullName) } else { if (-not $expectedFiles.Contains($relative)) { return $false } $other = Join-Path $Staged $relative if ((Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash -cne (Get-FileHash -LiteralPath $other -Algorithm SHA256).Hash) { return $false } $count++ } } } return ($count -eq $paths.Count) } if ([string]::IsNullOrWhiteSpace($SkillsDir)) { if ($Client -eq 'codex' -and -not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { $SkillsDir = Join-Path $env:CODEX_HOME 'skills' } else { $profileDir = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) if ([string]::IsNullOrWhiteSpace($profileDir)) { throw 'User profile directory is unavailable; use -SkillsDir.' } if ($Client -eq 'codex') { $SkillsDir = Join-Path $profileDir '.agents/skills' } else { $SkillsDir = Join-Path $profileDir '.claude/skills' } } } $SkillsDir = [IO.Path]::GetFullPath($SkillsDir).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) if (-not [IO.Path]::GetDirectoryName($SkillsDir)) { throw 'SkillsDir must not be a filesystem root.' } $destination = Join-Path $SkillsDir 'squirrelly' $stage = $null $lock = $null $backup = $null $oldTls = [Net.ServicePointManager]::SecurityProtocol try { # Windows PowerShell 5.1 may default to TLS 1.0. Enable TLS 1.2 for this # invocation, retain system certificate validation, and restore on exit. [Net.ServicePointManager]::SecurityProtocol = $oldTls -bor [Net.SecurityProtocolType]::Tls12 Assert-NoReparsePath $destination $null = [IO.Directory]::CreateDirectory($SkillsDir) $lockPath = Join-Path $SkillsDir '.squirrelly-install.lock' Assert-NoReparsePath $lockPath try { $lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } catch { throw 'Another Squirrelly installation holds the lock. Retry after it finishes.' } # The zero-byte lock file remains to avoid a lock-delete race between installers. Assert-NoReparsePath $destination $stage = Join-Path $SkillsDir ('.squirrelly-stage-' + [guid]::NewGuid().ToString('N')) New-PrivateStage $stage $zipPath = Join-Path $stage 'download.zip' $hashPath = Join-Path $stage 'download.sha256' Save-Download 'https://mcp.squirrelly.ai/downloads/squirrelly-skill.zip.sha256' $hashPath 1024 $checksum = [IO.File]::ReadAllText($hashPath).Trim() if ($checksum -notmatch '\A([a-fA-F0-9]{64})(?:[ \t]+\*?squirrelly-skill\.zip)?\z') { throw 'Invalid SHA256 checksum file.' } $expectedHash = $Matches[1] Save-Download 'https://mcp.squirrelly.ai/downloads/squirrelly-skill.zip' $zipPath $maxZipBytes if ((Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash -ine $expectedHash) { throw 'SHA256 checksum mismatch; no skill was changed.' } $crcs = Get-ExpectedCrcs ([IO.File]::ReadAllBytes($zipPath)) Add-Type -AssemblyName System.IO.Compression if (-not ('SquirrellyInstallerCrc32' -as [type])) { Add-Type -TypeDefinition @' public static class SquirrellyInstallerCrc32 { public static uint Update(uint crc, byte[] data, int count) { for (int n = 0; n < count; n++) { crc ^= data[n]; for (int bit = 0; bit < 8; bit++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xedb88320u : crc >> 1; } return crc; } } '@ } $zipStream = [IO.File]::OpenRead($zipPath) try { $zip = New-Object IO.Compression.ZipArchive($zipStream, [IO.Compression.ZipArchiveMode]::Read) try { if ($zip.Entries.Count -ne $paths.Count) { throw 'Unexpected ZIP entry count.' } $total = 0L $buffer = New-Object byte[] 65536 foreach ($entry in $zip.Entries) { if (-not $crcs.ContainsKey($entry.FullName)) { throw 'Unexpected ZIP entry path.' } $total += $entry.Length if ($total -gt $maxExpandedBytes) { throw 'ZIP expanded size exceeds limit.' } $output = Join-Path $stage $entry.FullName $null = [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($output)) $input = $entry.Open() try { $target = [IO.File]::Open($output, [IO.FileMode]::CreateNew) try { $readTotal = 0L $crc = [uint32]::MaxValue while (($read = $input.Read($buffer, 0, $buffer.Length)) -gt 0) { $readTotal += $read if ($readTotal -gt $entry.Length -or $readTotal -gt $maxExpandedBytes) { throw 'ZIP expanded stream size exceeds limit.' } $target.Write($buffer, 0, $read) $crc = [SquirrellyInstallerCrc32]::Update($crc, $buffer, $read) } if ($readTotal -ne $entry.Length -or ($crc -bxor [uint32]::MaxValue) -ne $crcs[$entry.FullName]) { throw 'ZIP CRC or expanded size mismatch.' } } finally { $target.Dispose() } } finally { $input.Dispose() } } } finally { $zip.Dispose() } } finally { $zipStream.Dispose() } $stagedSkill = Join-Path $stage 'squirrelly' Assert-NoReparsePath $destination if (Test-Path -LiteralPath $destination) { if (Test-SameSkill $destination $stagedSkill) { Write-Output "Squirrelly is already installed at: $destination" Write-Output 'Restart or reload your client to discover the skill. MCP access still requires client registration and OAuth authorization.' return } if (-not $Update) { throw 'Existing Squirrelly skill differs; it was preserved. Use -Update to back it up and replace it.' } $backupRoot = Join-Path ([IO.Path]::GetDirectoryName($SkillsDir)) 'bak' Assert-NoReparsePath $backupRoot $null = [IO.Directory]::CreateDirectory($backupRoot) $backup = Join-Path $backupRoot ('squirrelly-' + $Client + '-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::Move($destination, $backup) } try { [IO.Directory]::Move($stagedSkill, $destination) } catch { if ($backup) { if (Test-Path -LiteralPath $destination) { throw "Replacement failed because the destination appeared during installation. Your previous skill is preserved at: $backup" } try { [IO.Directory]::Move($backup, $destination) } catch { throw "Replacement failed and automatic restore failed. Your previous skill is preserved at: $backup. $($_.Exception.Message)" } } throw } Write-Output "Installed Squirrelly at: $destination" if ($backup) { Write-Output "Previous skill backup: $backup" } Write-Output 'Restart or reload your client to discover the skill. MCP access still requires client registration and OAuth authorization.' } finally { try { if ($stage -and [IO.Directory]::Exists($stage)) { Remove-Item -LiteralPath $stage -Recurse -Force } } finally { if ($lock) { $lock.Dispose() } [Net.ServicePointManager]::SecurityProtocol = $oldTls } } } $Client $SkillsDir $Update