qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
#requires -Version 5.1
[CmdletBinding()]
param(
    [string] $InstallDirectory = (Join-Path $env:LOCALAPPDATA 'Programs\qq-cli'),
    [switch] $NoPath
)

function Get-QqCliRelease {
    $release = Invoke-RestMethod -Uri 'https://api.github.com/repos/noctisynth/qq-cli/releases/latest' -Headers @{ 'User-Agent' = 'qq-cli-installer'; Accept = 'application/vnd.github+json' } -TimeoutSec 60 -ErrorAction Stop
    if ($release.draft -or $release.prerelease) { throw 'Expected a published stable release.' }
    $archives = @($release.assets | Where-Object { $_.name -cmatch '^qq-cli-v\d+\.\d+\.\d+-windows-x86_64\.zip$' })
    $checksums = @($release.assets | Where-Object { $_.name -ceq 'SHA256SUMS' })
    if ($archives.Count -ne 1 -or $checksums.Count -ne 1) { throw 'Latest release is missing unique Windows x64 assets or SHA256SUMS.' }
    foreach ($asset in @($archives[0], $checksums[0])) {
        $uri = [Uri] $asset.browser_download_url
        if ($uri.Scheme -cne 'https' -or $uri.Host -cne 'github.com' -or
            -not $uri.AbsolutePath.StartsWith('/noctisynth/qq-cli/releases/download/', [StringComparison]::Ordinal)) {
            throw 'Unexpected release asset origin.'
        }
    }
    [pscustomobject]@{ Archive = $archives[0]; Checksums = $checksums[0] }
}

function Merge-QqCliPath {
    param([AllowEmptyString()][string] $Existing, [string] $Directory)
    $entries = @($Existing.Split(';') | Where-Object { $_.Trim().Length -gt 0 })
    $others = @($entries | Where-Object {
        [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')).TrimEnd('\') -ine $Directory.TrimEnd('\')
    })
    (@($Directory) + $others) -join ';'
}

function Assert-QqCliInstallPath {
    param([string] $Path)
    $current = [IO.Path]::GetFullPath($Path)
    while ($current) {
        if (Test-Path -LiteralPath $current) {
            $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop
            if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Installation path must not contain symbolic links or junctions.' }
        }
        $current = [IO.Path]::GetDirectoryName($current)
    }
}

function Install-QqCliArchive {
    param([string] $Archive, [string] $Checksums, [string] $Directory)
    $ErrorActionPreference = 'Stop'
    $name = [IO.Path]::GetFileName($Archive)
    $lines = @([IO.File]::ReadAllLines($Checksums) | Where-Object { $_ -match ('^[a-fA-F0-9]{64}\s+\*?' + [regex]::Escape($name) + '$') })
    if ($lines.Count -ne 1) { throw 'Missing or duplicate archive checksum.' }
    $expected = ($lines[0] -split '\s+')[0]
    if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ine $expected) { throw 'SHA256 verification failed; existing installation was not changed.' }
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $zip = [IO.Compression.ZipFile]::OpenRead($Archive)
    $stage = $null
    try {
        $executables = @($zip.Entries | Where-Object { $_.FullName -ceq 'qq-cli.exe' })
        if ($executables.Count -ne 1 -or $executables[0].Length -lt 64 -or $executables[0].Length -gt 256MB) {
            throw 'Release must contain one bounded qq-cli.exe.'
        }
        # Extract only the exact executable entry, never archive-provided paths.
        $Directory = [IO.Path]::GetFullPath($Directory)
        $target = Join-Path $Directory 'qq-cli.exe'
        Assert-QqCliInstallPath $target
        $null = [IO.Directory]::CreateDirectory($Directory)
        $stage = Join-Path $Directory ('.qq-cli-' + [Guid]::NewGuid().ToString('N') + '.tmp')
        $sourceStream = $executables[0].Open()
        try {
            $destinationStream = [IO.File]::Open($stage, [IO.FileMode]::CreateNew)
            try { $sourceStream.CopyTo($destinationStream) } finally { $destinationStream.Dispose() }
        } finally { $sourceStream.Dispose() }
        $pe = [IO.File]::ReadAllBytes($stage)
        if ($pe.Length -lt 64 -or $pe[0] -ne 0x4d -or $pe[1] -ne 0x5a) { throw 'Invalid Windows executable.' }
        $offset = [BitConverter]::ToUInt32($pe, 0x3c)
        if ($offset -gt $pe.Length - 6 -or [BitConverter]::ToUInt32($pe, $offset) -ne 0x4550 -or
            [BitConverter]::ToUInt16($pe, $offset + 4) -ne 0x8664) { throw 'Expected a Windows x64 executable.' }
        foreach ($licenseName in @('LICENSE-MIT', 'LICENSE-APACHE')) {
            $licenses = @($zip.Entries | Where-Object { $_.FullName -ceq $licenseName })
            if ($licenses.Count -ne 1 -or $licenses[0].Length -gt 1MB) { throw 'Missing or invalid release license.' }
        }
        foreach ($licenseName in @('LICENSE-MIT', 'LICENSE-APACHE')) {
            $licensePath = Join-Path $Directory $licenseName
            Assert-QqCliInstallPath $licensePath
            $reader = [IO.StreamReader]::new($zip.GetEntry($licenseName).Open())
            try { [IO.File]::WriteAllText($licensePath, $reader.ReadToEnd(), [Text.UTF8Encoding]::new($false)) }
            finally { $reader.Dispose() }
        }
        if (Test-Path -LiteralPath $target) {
            if ((Get-FileHash -LiteralPath $target).Hash -ne (Get-FileHash -LiteralPath $stage).Hash) {
                [IO.File]::Replace($stage, $target, [System.Management.Automation.Language.NullString]::Value)
            }
        } else { [IO.File]::Move($stage, $target) }
        return $target
    } finally {
        $zip.Dispose()
        if ($stage -and [IO.File]::Exists($stage)) { [IO.File]::Delete($stage) }
    }
}

function Install-QqCli {
    param([string] $Directory, [switch] $SkipPath)
    $ErrorActionPreference = 'Stop'
    if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or
        ($env:PROCESSOR_ARCHITECTURE -ne 'AMD64' -and $env:PROCESSOR_ARCHITEW6432 -ne 'AMD64')) {
        throw 'QQ CLI requires Windows x64.'
    }
    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
    try { $release = Get-QqCliRelease } catch {
        throw "Cannot retrieve the latest QQ CLI release. Check https://github.com/noctisynth/qq-cli/releases and network/API limits. $($_.Exception.Message)"
    }
    $temp = Join-Path ([IO.Path]::GetTempPath()) ('qq-cli-install-' + [Guid]::NewGuid().ToString('N'))
    $null = [IO.Directory]::CreateDirectory($temp)
    $archive = Join-Path $temp $release.Archive.name
    $checksums = Join-Path $temp 'SHA256SUMS'
    try {
        Invoke-WebRequest -UseBasicParsing -Uri $release.Archive.browser_download_url -OutFile $archive -TimeoutSec 300 -ErrorAction Stop
        Invoke-WebRequest -UseBasicParsing -Uri $release.Checksums.browser_download_url -OutFile $checksums -TimeoutSec 60 -ErrorAction Stop
        $target = Install-QqCliArchive -Archive $archive -Checksums $checksums -Directory $Directory
        $installedDirectory = [IO.Path]::GetDirectoryName($target)
        if (-not $SkipPath) {
            $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
            $newPath = Merge-QqCliPath -Existing $userPath -Directory $installedDirectory
            if ($newPath -cne $userPath) { [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') }
            $env:Path = Merge-QqCliPath -Existing $env:Path -Directory $installedDirectory
        }
        [pscustomobject]@{ executable = $target; asset = $release.Archive.name; pathUpdated = (-not $SkipPath) }
        Write-Host 'QQ CLI installed. Run qq-cli --help. Existing agent processes may need their PATH refreshed or restarted.'
    } finally {
        # Delete only files created by this invocation; never recursively remove a supplied directory.
        foreach ($file in @($archive, $checksums)) { if ([IO.File]::Exists($file)) { [IO.File]::Delete($file) } }
        [IO.Directory]::Delete($temp)
    }
}

if ($MyInvocation.InvocationName -ne '.') {
    Install-QqCli -Directory $InstallDirectory -SkipPath:$NoPath
}