param( [switch]$Help ) $ErrorActionPreference = 'Stop' function Initialize-InstallerPowerShellSession { try { Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force } catch {} try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::InputEncoding = [System.Text.Encoding]::UTF8 $script:OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} } function Show-InstallHelp { @' Lucky native Windows installer Install: irm https://bridge.annealing.cn/install.ps1 | iex Preview/channel install: $env:THKJ_CHANNEL="dev"; irm https://bridge.annealing.cn/install.ps1 | iex Useful overrides: THKJ_BIN_NAME Command name to create. Defaults to lucky or lucky-. THKJ_BIN_DIR Directory for lucky.cmd and lucky.ps1. THKJ_INSTALL_DIR npm global prefix used for the installed payload. THKJ_INSTALL_BASE_URL Bridge installer base URL. THKJ_SKIP_PATH_UPDATE=1 Do not add THKJ_BIN_DIR to the user PATH. THKJ_SKIP_DEPS_INSTALL=1 Do not install missing Node.js, Git Bash, or optional dependencies. THKJ_SKIP_RIPGREP_INSTALL=1 Legacy alias for skipping ripgrep install. THKJ_DEPS_BASE_URL Dependency cache base URL for ripgrep. Defaults to /deps. '@ } if ($Help) { Show-InstallHelp exit 0 } Initialize-InstallerPowerShellSession function Get-EnvValue { param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '') $value = [Environment]::GetEnvironmentVariable($Name, 'Process') if ([string]::IsNullOrWhiteSpace($value)) { return $Default } return $value } function Assert-SafeToken { param([Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$Value) if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9._-]+$') { throw "Invalid ${Name}: $Value. Use letters, numbers, dot, underscore, or dash only." } } function ConvertTo-PSLiteral { param([AllowNull()][string]$Value) if ($null -eq $Value) { return "''" } return "'" + $Value.Replace("'", "''") + "'" } function Test-IsWindows { return [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT } function Join-Url { param([Parameter(Mandatory = $true)][string]$Base, [Parameter(Mandatory = $true)][string]$Path) return $Base.TrimEnd('/') + '/' + $Path.TrimStart('/') } function Resolve-DependencyUrl { param([Parameter(Mandatory = $true)][string]$Base, [Parameter(Mandatory = $true)][string]$Url) if ($Url -match '^https?://') { return $Url } return Join-Url $Base $Url } function Get-DefaultDepsBase { param( [Parameter(Mandatory = $true)][string]$BridgeBase, [Parameter(Mandatory = $true)][string]$Channel ) $base = $BridgeBase.TrimEnd('/') $previewSuffix = "/previews/$Channel" if ($Channel -ne 'stable' -and $base.EndsWith($previewSuffix, [StringComparison]::OrdinalIgnoreCase)) { $base = $base.Substring(0, $base.Length - $previewSuffix.Length) } return Join-Url $base 'deps' } function New-DependencyResult { param( [Parameter(Mandatory = $true)][string]$Source, [string]$Path = '' ) return [pscustomobject]@{ Source = $Source Path = $Path } } function Add-UserPathEntry { param([Parameter(Mandatory = $true)][string]$Entry) if (-not (Test-IsWindows)) { return } if ((Get-EnvValue 'THKJ_SKIP_PATH_UPDATE' '0') -eq '1') { return } $normalizedEntry = $Entry.TrimEnd('\') $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $parts = @() if (-not [string]::IsNullOrWhiteSpace($userPath)) { $parts = $userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } } $alreadyPresent = $false foreach ($part in $parts) { if ($part.TrimEnd('\').Equals($normalizedEntry, [StringComparison]::OrdinalIgnoreCase)) { $alreadyPresent = $true break } } if (-not $alreadyPresent) { $newPath = if ([string]::IsNullOrWhiteSpace($userPath)) { $Entry } else { "$userPath;$Entry" } [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') } $processParts = ($env:Path -split ';') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $processHasEntry = $false foreach ($part in $processParts) { if ($part.TrimEnd('\').Equals($normalizedEntry, [StringComparison]::OrdinalIgnoreCase)) { $processHasEntry = $true break } } if (-not $processHasEntry) { $env:Path = "$Entry;$env:Path" } } function Update-ProcessPathFromRegistry { if (-not (Test-IsWindows)) { return } $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $paths = @($machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($paths.Count -gt 0) { $env:Path = ($paths -join ';') } } function Invoke-WingetInstall { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$Id ) $winget = Get-Command winget -ErrorAction SilentlyContinue if (-not $winget) { throw "$Name is required but was not found, and winget is unavailable for automatic installation." } Write-Host "Installing $Name with winget..." & $winget.Source install ` --id $Id ` --exact ` --source winget ` --silent ` --accept-package-agreements ` --accept-source-agreements ` --disable-interactivity if ($LASTEXITCODE -ne 0) { Write-Warning "winget install with --disable-interactivity failed for $Name; retrying without that flag." & $winget.Source install ` --id $Id ` --exact ` --source winget ` --silent ` --accept-package-agreements ` --accept-source-agreements } if ($LASTEXITCODE -ne 0) { throw "winget could not install $Name. Install it manually and re-run this installer." } Update-ProcessPathFromRegistry } function Get-NodeMajorVersion { param([Parameter(Mandatory = $true)]$NodeCommand) try { $rawVersion = (& $NodeCommand.Source --version 2>$null | Out-String).Trim() if ($rawVersion -match '^v?([0-9]+)') { return [int]$Matches[1] } } catch {} return 0 } $minimumNodeMajor = [int](Get-EnvValue 'LUCKY_MIN_NODE_MAJOR' '24') function Test-NodeRuntime { $nodeCommand = Get-Command node -ErrorAction SilentlyContinue $npmCommand = Get-Command npm -ErrorAction SilentlyContinue if (-not $nodeCommand -or -not $npmCommand) { return $false } return (Get-NodeMajorVersion $nodeCommand) -ge $minimumNodeMajor } function Ensure-NodeRuntime { if (Test-NodeRuntime) { return } if ((Get-EnvValue 'THKJ_SKIP_DEPS_INSTALL' '0') -eq '1') { throw "Missing required Node.js $minimumNodeMajor+ runtime or npm. Install Node.js $minimumNodeMajor or newer before installing lucky." } Write-Warning "Node.js $minimumNodeMajor+ and npm were not found. The installer will try to install Node.js LTS." Invoke-WingetInstall -Name 'Node.js LTS' -Id 'OpenJS.NodeJS.LTS' if (-not (Test-NodeRuntime)) { throw 'Node.js LTS was installed but node/npm are not visible in this PowerShell session. Restart PowerShell and re-run the lucky installer.' } } function Find-GitBash { $candidates = @() if (-not [string]::IsNullOrWhiteSpace($env:CLAUDE_CODE_GIT_BASH_PATH)) { $candidates += $env:CLAUDE_CODE_GIT_BASH_PATH } $bashCommand = Get-Command bash -ErrorAction SilentlyContinue if ($bashCommand -and (Test-GitBashCandidate $bashCommand.Source)) { $candidates += $bashCommand.Source } try { $whereBash = (& where.exe bash 2>$null) foreach ($bashPath in $whereBash) { if (Test-GitBashCandidate $bashPath) { $candidates += $bashPath } } } catch {} $programFilesX86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)') if (-not [string]::IsNullOrWhiteSpace($env:ProgramFiles)) { $candidates += (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') } if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { $candidates += (Join-Path $programFilesX86 'Git\bin\bash.exe') } if (-not [string]::IsNullOrWhiteSpace($env:LocalAppData)) { $candidates += (Join-Path $env:LocalAppData 'Programs\Git\bin\bash.exe') } try { $whereGit = (& where.exe git 2>$null) foreach ($gitPath in $whereGit) { if ([string]::IsNullOrWhiteSpace($gitPath)) { continue } $gitDir = Split-Path -Parent $gitPath $gitRoot = Split-Path -Parent $gitDir if (-not [string]::IsNullOrWhiteSpace($gitDir)) { $candidates += (Join-Path $gitDir 'bash.exe') } if (-not [string]::IsNullOrWhiteSpace($gitRoot)) { $candidates += (Join-Path $gitRoot 'bin\bash.exe') $candidates += (Join-Path $gitRoot 'usr\bin\bash.exe') } } } catch {} $uninstallKeys = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Git_is1', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Git_is1', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Git_is1' ) foreach ($key in $uninstallKeys) { try { $installLocation = (Get-ItemProperty -Path $key -ErrorAction SilentlyContinue).InstallLocation if (-not [string]::IsNullOrWhiteSpace($installLocation)) { $candidates += (Join-Path $installLocation 'bin\bash.exe') $candidates += (Join-Path $installLocation 'usr\bin\bash.exe') } } catch {} } foreach ($candidate in ($candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) { if ((Test-Path $candidate) -and (Test-GitBashCandidate $candidate)) { return $candidate } } return '' } function Test-GitBashCandidate { param([AllowNull()][string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } if ($Path -match '\\Windows\\System32\\bash\.exe$') { return $false } if ($Path -match '\\Microsoft\\WindowsApps\\bash\.exe$') { return $false } return $true } function Ensure-GitBash { $bashPath = Find-GitBash if ([string]::IsNullOrWhiteSpace($bashPath)) { if ((Get-EnvValue 'THKJ_SKIP_DEPS_INSTALL' '0') -eq '1') { throw 'Missing required Git Bash. Install Git for Windows before installing lucky.' } Write-Warning 'Git Bash was not found. The installer will try to install Git for Windows.' Invoke-WingetInstall -Name 'Git for Windows' -Id 'Git.Git' $bashPath = Find-GitBash } if ([string]::IsNullOrWhiteSpace($bashPath)) { throw 'Git for Windows was installed but Git Bash is not visible yet. Restart PowerShell and re-run the lucky installer.' } try { [Environment]::SetEnvironmentVariable('CLAUDE_CODE_GIT_BASH_PATH', $bashPath, 'User') } catch {} $env:CLAUDE_CODE_GIT_BASH_PATH = $bashPath } function Try-InstallRipgrep { param( [Parameter(Mandatory = $true)][string]$ToolsBinDir, [Parameter(Mandatory = $true)][string]$DownloadDir, [string]$DepsBaseUrl = '' ) $systemRg = Get-Command rg -ErrorAction SilentlyContinue if ($systemRg) { return New-DependencyResult 'system' $systemRg.Source } if ((Get-EnvValue 'THKJ_SKIP_DEPS_INSTALL' '0') -eq '1' -or (Get-EnvValue 'THKJ_SKIP_RIPGREP_INSTALL' '0') -eq '1') { return New-DependencyResult 'not found' } if (-not [string]::IsNullOrWhiteSpace($DepsBaseUrl)) { try { New-Item -ItemType Directory -Force -Path $ToolsBinDir, $DownloadDir | Out-Null $manifestPath = Join-Path $DownloadDir 'manifest.json' $manifestUrl = Join-Url $DepsBaseUrl 'windows/manifest.json' Invoke-WebRequest -Uri $manifestUrl -OutFile $manifestPath -UseBasicParsing $manifest = Get-Content -Raw -Path $manifestPath | ConvertFrom-Json $ripgrep = $manifest.ripgrep if ($ripgrep -and $ripgrep.url -and $ripgrep.sha256) { $archivePath = Join-Path $DownloadDir 'ripgrep.zip' $ripgrepUrl = Resolve-DependencyUrl $DepsBaseUrl ([string]$ripgrep.url) Invoke-WebRequest -Uri $ripgrepUrl -OutFile $archivePath -UseBasicParsing $expectedSha256 = ([string]$ripgrep.sha256).ToLowerInvariant() $actualSha256 = (Get-FileHash -Algorithm SHA256 -Path $archivePath).Hash.ToLowerInvariant() if ($actualSha256 -ne $expectedSha256) { throw "ripgrep dependency checksum mismatch. expected: $expectedSha256 actual: $actualSha256" } $extractDir = Join-Path $DownloadDir 'ripgrep' New-Item -ItemType Directory -Force -Path $extractDir | Out-Null Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force $rgBinName = if ($ripgrep.bin) { [string]$ripgrep.bin } else { 'rg.exe' } $rgSource = Get-ChildItem -Path $extractDir -Recurse -File -Filter $rgBinName | Select-Object -First 1 if (-not $rgSource) { throw "ripgrep dependency archive did not contain $rgBinName" } $rgTarget = Join-Path $ToolsBinDir 'rg.exe' Copy-Item -Force -Path $rgSource.FullName -Destination $rgTarget Write-Host "Installed ripgrep from THKJ dependency cache: $rgTarget" return New-DependencyResult 'cache' $rgTarget } Write-Warning 'THKJ dependency cache manifest did not include ripgrep url and sha256.' } catch { Write-Warning "THKJ dependency cache could not provide ripgrep: $($_.Exception.Message)" } } return New-DependencyResult 'not found' } function Remove-StaleNpmPackageArtifacts { param( [Parameter(Mandatory = $true)][string]$InstallDir, [switch]$IncludePackage, [switch]$PreserveLegacy ) foreach ($scopeDir in @( (Join-Path $InstallDir 'node_modules\@thkj'), (Join-Path $InstallDir 'lib\node_modules\@thkj') )) { if (-not (Test-Path $scopeDir -PathType Container)) { continue } if ($IncludePackage) { Remove-Item -Recurse -Force (Join-Path $scopeDir 'lucky-code') -ErrorAction SilentlyContinue if (-not $PreserveLegacy) { Remove-Item -Recurse -Force (Join-Path $scopeDir 'claude-code') -ErrorAction SilentlyContinue } } foreach ($pattern in @('.claude-code-*', '.lucky-code-*')) { Get-ChildItem -Force -Path $scopeDir -Directory -Filter $pattern -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } } if ($IncludePackage) { foreach ($name in @('lucky', 'thkj', 'claude')) { foreach ($relativePath in @("bin\$name", "bin\$name.cmd", "bin\$name.ps1", "$name.cmd", "$name.ps1")) { Remove-Item -Force (Join-Path $InstallDir $relativePath) -ErrorAction SilentlyContinue } } } } function Invoke-NpmPackageInstall { param( [Parameter(Mandatory = $true)]$NpmCommand, [Parameter(Mandatory = $true)][string]$InstallDir, [Parameter(Mandatory = $true)][string]$Tarball ) $newPackageCandidates = @( (Join-Path $InstallDir 'node_modules\@thkj\lucky-code'), (Join-Path $InstallDir 'lib\node_modules\@thkj\lucky-code') ) $legacyPackageCandidates = @( (Join-Path $InstallDir 'node_modules\@thkj\claude-code'), (Join-Path $InstallDir 'lib\node_modules\@thkj\claude-code') ) $migratingLegacy = ($legacyPackageCandidates | Where-Object { Test-Path (Join-Path $_ 'package.json') } | Select-Object -First 1) -and -not ($newPackageCandidates | Where-Object { Test-Path (Join-Path $_ 'package.json') } | Select-Object -First 1) $partialPackage = @($newPackageCandidates + $legacyPackageCandidates) | Where-Object { (Test-Path $_ -PathType Container) -and -not (Test-Path (Join-Path $_ 'package.json')) } | Select-Object -First 1 if ($partialPackage) { Write-Warning 'Found a partial Lucky package installation; cleaning it before retrying npm install.' Remove-StaleNpmPackageArtifacts -InstallDir $InstallDir -IncludePackage -PreserveLegacy:$migratingLegacy } else { Remove-StaleNpmPackageArtifacts -InstallDir $InstallDir } $baseArgs = @('install', '--global', '--prefix', $InstallDir, '--no-audit', '--no-fund', '--silent') if ($migratingLegacy) { $baseArgs += '--force' } $attempts = @( @{ Label = 'with optional dependencies'; Args = $baseArgs + @($Tarball) }, @{ Label = 'retry with optional dependencies'; Args = $baseArgs + @($Tarball) }, @{ Label = 'without optional dependencies'; Args = $baseArgs + @('--omit=optional', $Tarball) } ) $lastCode = 0 for ($i = 0; $i -lt $attempts.Count; $i++) { $attempt = $attempts[$i] if ($i -gt 0) { Write-Warning "npm install failed with exit code $lastCode; retrying $($attempt.Label)." Start-Sleep -Seconds 2 } $npmArgs = $attempt.Args & $NpmCommand.Source @npmArgs $lastCode = $LASTEXITCODE if ($lastCode -eq 0) { Remove-StaleNpmPackageArtifacts -InstallDir $InstallDir if ($newPackageCandidates | Where-Object { Test-Path (Join-Path $_ 'package.json') } | Select-Object -First 1) { $legacyPackageCandidates | ForEach-Object { Remove-Item -Recurse -Force $_ -ErrorAction SilentlyContinue } } if ($i -eq 2) { Write-Warning 'Installed Lucky without optional dependencies. Lucky web can still start, but local terminal sessions may be unavailable until optional dependencies can be installed.' } return } if ($i -eq 0) { Write-Warning 'Cleaning partial Lucky package files before retrying npm install.' Remove-StaleNpmPackageArtifacts -InstallDir $InstallDir -IncludePackage -PreserveLegacy:$migratingLegacy } } throw "npm install failed with exit code $lastCode" } function Test-InstalledNodePty { param([Parameter(Mandatory = $true)][string]$PackageDir) if (-not (Test-Path $PackageDir -PathType Container)) { return $false } $probe = @' const { createRequire } = require('module') const path = require('path') const packageDir = process.argv[1] const requireFromPackage = createRequire(path.join(packageDir, 'package.json')) const pty = requireFromPackage('node-pty') if (!pty || typeof pty.spawn !== 'function') process.exit(2) '@ $previousErrorActionPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' & node -e $probe $PackageDir *> $null return $LASTEXITCODE -eq 0 } catch { return $false } finally { $ErrorActionPreference = $previousErrorActionPreference } } function Repair-InstalledNodePty { param( [Parameter(Mandatory = $true)]$NpmCommand, [Parameter(Mandatory = $true)][string]$PackageDir ) if ((Get-EnvValue 'THKJ_SKIP_NODE_PTY_REPAIR' '0') -eq '1') { return } if (-not (Test-Path $PackageDir -PathType Container)) { return } if (Test-InstalledNodePty -PackageDir $PackageDir) { Write-Host 'Verified Lucky web terminal dependency: node-pty' return } Write-Warning 'Lucky web terminal dependency node-pty did not load; rebuilding native addon...' Push-Location $PackageDir try { & $NpmCommand.Source rebuild node-pty --build-from-source --no-audit --no-fund --silent } finally { Pop-Location } if (Test-InstalledNodePty -PackageDir $PackageDir) { Write-Host 'Rebuilt Lucky web terminal dependency: node-pty' return } Write-Warning 'node-pty rebuild did not produce a loadable native addon; reinstalling optional dependency...' Push-Location $PackageDir try { & $NpmCommand.Source install --no-save --include=optional --build-from-source --no-audit --no-fund --silent 'node-pty@^1.1.0' } finally { Pop-Location } if (Test-InstalledNodePty -PackageDir $PackageDir) { Write-Host 'Installed Lucky web terminal dependency: node-pty' return } Write-Warning "Lucky web terminal native dependency node-pty could not be prepared. Lucky web will still run, but local terminal sessions may be unavailable on this machine. Install Python and C++ build tools, then run: cd `"$PackageDir`"; npm rebuild node-pty --build-from-source" } $packageUrl = Get-EnvValue 'THKJ_PACKAGE_URL' $bridgeBaseExplicit = -not [string]::IsNullOrWhiteSpace((Get-EnvValue 'THKJ_BRIDGE_API_BASE_URL')) $bridgeBase = Get-EnvValue 'THKJ_BRIDGE_API_BASE_URL' 'https://bridge.annealing.cn' $channel = Get-EnvValue 'THKJ_CHANNEL' 'stable' Assert-SafeToken 'THKJ_CHANNEL' $channel $defaultBinName = if ($channel -eq 'stable') { 'lucky' } else { "lucky-$channel" } $binName = Get-EnvValue 'THKJ_BIN_NAME' $defaultBinName Assert-SafeToken 'THKJ_BIN_NAME' $binName $localAppData = [Environment]::GetFolderPath('LocalApplicationData') if ([string]::IsNullOrWhiteSpace($localAppData)) { $localAppData = Join-Path $HOME 'AppData\Local' } $binDir = Get-EnvValue 'THKJ_BIN_DIR' (Join-Path $localAppData 'Programs\Lucky\bin') $installDir = Get-EnvValue 'THKJ_INSTALL_DIR' (Join-Path (Join-Path $localAppData 'Programs\Lucky\channels') $channel) $tmpBase = Get-EnvValue 'THKJ_TMP_DIR' (Join-Path ([IO.Path]::GetTempPath()) 'lucky-install') $version = Get-EnvValue 'THKJ_VERSION' '2.1.97' $versionUrl = Get-EnvValue 'THKJ_VERSION_URL' if ([string]::IsNullOrWhiteSpace($bridgeBase)) { $installBase = Get-EnvValue 'THKJ_INSTALL_BASE_URL' if ([string]::IsNullOrWhiteSpace($installBase)) { $bridgeBase = 'http://127.0.0.1:8787' } else { $bridgeBase = $installBase.TrimEnd('/') } } if (-not $bridgeBaseExplicit -and $channel -ne 'stable' -and (Get-EnvValue 'THKJ_USE_PREVIEW_BRIDGE' '1') -ne '0') { if (-not $bridgeBase.EndsWith("/previews/$channel", [StringComparison]::OrdinalIgnoreCase)) { $bridgeBase = $bridgeBase.TrimEnd('/') + "/previews/$channel" } } $depsBase = Get-EnvValue 'THKJ_DEPS_BASE_URL' if ([string]::IsNullOrWhiteSpace($depsBase)) { $depsBase = Get-DefaultDepsBase -BridgeBase $bridgeBase -Channel $channel } if ([string]::IsNullOrWhiteSpace($versionUrl)) { if ($channel -eq 'stable') { $versionUrl = $bridgeBase.TrimEnd('/') + '/version' } else { $versionUrl = $bridgeBase.TrimEnd('/') + "/channels/$channel/version.json" } } if ([string]::IsNullOrWhiteSpace($packageUrl)) { if ($channel -eq 'stable') { $packageUrl = $bridgeBase.TrimEnd('/') + '/download/thkj-claude-code.tgz' } else { $packageUrl = $bridgeBase.TrimEnd('/') + "/channels/$channel/thkj-claude-code.tgz" } } Ensure-NodeRuntime Ensure-GitBash $npm = Get-Command npm -ErrorAction Stop New-Item -ItemType Directory -Force -Path $binDir, $installDir, $tmpBase | Out-Null $toolsBinDir = Join-Path $installDir 'tools\bin' $depsDownloadDir = Join-Path $tmpBase 'deps' $ripgrepInstall = Try-InstallRipgrep -ToolsBinDir $toolsBinDir -DownloadDir $depsDownloadDir -DepsBaseUrl $depsBase $tmpDir = Join-Path $tmpBase ('thkj-install.' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null try { $tarball = Join-Path $tmpDir 'thkj-claude-code.tgz' $versionJson = Join-Path $tmpDir 'version.json' Invoke-WebRequest -Uri $packageUrl -OutFile $tarball -UseBasicParsing try { Invoke-WebRequest -Uri $versionUrl -OutFile $versionJson -UseBasicParsing } catch {} $expectedSha256 = '' $metadataGitSha = '' if (Test-Path $versionJson) { try { $metadata = Get-Content -Raw -Path $versionJson | ConvertFrom-Json if ($metadata.version) { $version = [string]$metadata.version } if ($metadata.artifact -and $metadata.artifact.sha256) { $expectedSha256 = ([string]$metadata.artifact.sha256).ToLowerInvariant() } if ($metadata.gitSha) { $metadataGitSha = [string]$metadata.gitSha } } catch {} } if (-not [string]::IsNullOrWhiteSpace($expectedSha256)) { $actualSha256 = (Get-FileHash -Algorithm SHA256 -Path $tarball).Hash.ToLowerInvariant() if ($actualSha256 -ne $expectedSha256) { throw "Package checksum mismatch. expected: $expectedSha256 actual: $actualSha256" } Write-Host "Verified package checksum: $actualSha256" } Invoke-NpmPackageInstall -NpmCommand $npm -InstallDir $installDir -Tarball $tarball $packageJsonCandidates = @( (Join-Path $installDir 'node_modules\@thkj\lucky-code\package.json'), (Join-Path $installDir 'lib\node_modules\@thkj\lucky-code\package.json'), (Join-Path $installDir 'node_modules\@thkj\claude-code\package.json'), (Join-Path $installDir 'lib\node_modules\@thkj\claude-code\package.json') ) $packageJson = $packageJsonCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $packageJson) { throw 'Package did not install @thkj/lucky-code package metadata.' } $packageDir = Split-Path -Parent $packageJson Repair-InstalledNodePty -NpmCommand $npm -PackageDir $packageDir $payloadName = '' try { $payloadPackage = Get-Content -Raw -Path $packageJson | ConvertFrom-Json foreach ($candidate in @('lucky', 'thkj', 'claude')) { if ($payloadPackage.bin -and ($payloadPackage.bin.PSObject.Properties.Name -contains $candidate)) { $payloadName = $candidate break } } } catch { $payloadName = '' } $payloadCandidates = @() if (-not [string]::IsNullOrWhiteSpace($payloadName)) { $payloadCandidates += @((Join-Path $installDir "$payloadName.cmd"), (Join-Path $installDir "$payloadName.ps1"), (Join-Path $installDir "bin\$payloadName.cmd"), (Join-Path $installDir "bin\$payloadName")) } foreach ($candidate in @('lucky', 'thkj', 'claude')) { $payloadCandidates += @((Join-Path $installDir "$candidate.cmd"), (Join-Path $installDir "$candidate.ps1"), (Join-Path $installDir "bin\$candidate.cmd"), (Join-Path $installDir "bin\$candidate")) } $payloadBin = $payloadCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $payloadBin) { throw 'Package did not install a supported CLI executable.' } Add-UserPathEntry $binDir $payloadLiteral = ConvertTo-PSLiteral $payloadBin $bridgeLiteral = ConvertTo-PSLiteral $bridgeBase $channelLiteral = ConvertTo-PSLiteral $channel $binNameLiteral = ConvertTo-PSLiteral $binName $binDirLiteral = ConvertTo-PSLiteral $binDir $installDirLiteral = ConvertTo-PSLiteral $installDir $versionLiteral = ConvertTo-PSLiteral $version $metadataGitShaLiteral = ConvertTo-PSLiteral $metadataGitSha $expectedSha256Literal = ConvertTo-PSLiteral $expectedSha256 $toolsBinDirLiteral = ConvertTo-PSLiteral $toolsBinDir $ripgrepSourceLiteral = ConvertTo-PSLiteral $ripgrepInstall.Source $wrapperPs1 = Join-Path $binDir "$binName.ps1" $wrapperCmd = Join-Path $binDir "$binName.cmd" $wrapperBody = @" param( [Parameter(ValueFromRemainingArguments = `$true)][string[]]`$LuckyArgs ) `$PayloadBin = $payloadLiteral `$DefaultBridgeBase = $bridgeLiteral `$DefaultChannel = $channelLiteral `$DefaultBinName = $binNameLiteral `$DefaultBinDir = $binDirLiteral `$DefaultInstallDir = $installDirLiteral `$DefaultVersion = $versionLiteral `$DefaultGitSha = $metadataGitShaLiteral `$DefaultArtifactSha256 = $expectedSha256Literal `$DefaultToolsBinDir = $toolsBinDirLiteral `$DefaultRgSource = $ripgrepSourceLiteral if (-not `$env:THKJ_BRIDGE_MODE) { `$env:THKJ_BRIDGE_MODE = '1' } if (-not `$env:THKJ_BRIDGE_API_BASE_URL) { `$env:THKJ_BRIDGE_API_BASE_URL = `$DefaultBridgeBase } # NewAPI / LuckyAPI upstream for direct-mode auth and account calls. This must # stay distinct from THKJ_BRIDGE_API_BASE_URL; otherwise direct login falls back # to the bridge control plane and cannot receive NewAPI session credentials. if (-not `$env:THKJ_UPSTREAM_API_BASE_URL) { `$env:THKJ_UPSTREAM_API_BASE_URL = 'https://api.luckyapi.chat' } if (-not `$env:THKJ_CHANNEL) { `$env:THKJ_CHANNEL = `$DefaultChannel } if (-not `$env:THKJ_PRODUCT_NAME) { `$env:THKJ_PRODUCT_NAME = 'Lucky Code' } if (-not `$env:CLAUDE_CODE_TMPDIR) { `$env:CLAUDE_CODE_TMPDIR = Join-Path `$env:TEMP 'lucky-tmp' } if (-not `$env:TMPDIR) { `$env:TMPDIR = `$env:CLAUDE_CODE_TMPDIR } if (-not `$env:USE_BUILTIN_RIPGREP) { `$env:USE_BUILTIN_RIPGREP = '0' } # Isolate lucky's config / credentials / sessions from the official ``claude`` # binary. Upstream CC honors CLAUDE_CONFIG_DIR natively (envUtils.ts:7 + the # macOS keychain helper appends a sha256(dir) suffix), so this single # assignment covers the whole config tree. Issue #225 Part B. # User-overridable: anything set in the parent shell wins. if (-not `$env:CLAUDE_CONFIG_DIR) { `$env:CLAUDE_CONFIG_DIR = Join-Path `$env:USERPROFILE '.lucky' } New-Item -ItemType Directory -Force -Path `$env:CLAUDE_CODE_TMPDIR | Out-Null New-Item -ItemType Directory -Force -Path `$env:CLAUDE_CONFIG_DIR | Out-Null # Bridge non-credential user-global config to the official `claude` home # (~/.claude) so REPL history, settings, memory, agents, and plugins stay in # sync across both binaries. Credentials, projects/, sessions/, and the # lucky-only .claude-thkj-bridge.json stay strictly under CLAUDE_CONFIG_DIR. # Files use HardLink (user-mode, no admin); directories use Junction (also # user-mode, no SeCreateSymbolicLinkPrivilege needed). Idempotent: skip when # destination already exists. Issue #225 follow-up. `$ClaudeDefaultHome = Join-Path `$env:USERPROFILE '.claude' if ((Test-Path `$ClaudeDefaultHome -PathType Container) -and (`$env:CLAUDE_CONFIG_DIR -ne `$ClaudeDefaultHome)) { foreach (`$item in @('history.jsonl', 'settings.json', 'CLAUDE.md')) { `$src = Join-Path `$ClaudeDefaultHome `$item `$dst = Join-Path `$env:CLAUDE_CONFIG_DIR `$item if ((Test-Path `$src -PathType Leaf) -and (-not (Test-Path `$dst))) { try { New-Item -ItemType HardLink -Path `$dst -Target `$src -ErrorAction Stop | Out-Null } catch {} } } foreach (`$item in @('agents', 'plugins', 'projects')) { `$src = Join-Path `$ClaudeDefaultHome `$item `$dst = Join-Path `$env:CLAUDE_CONFIG_DIR `$item if ((Test-Path `$src -PathType Container) -and (-not (Test-Path `$dst))) { try { New-Item -ItemType Junction -Path `$dst -Target `$src -ErrorAction Stop | Out-Null } catch {} } } } if (Test-Path (Join-Path `$DefaultToolsBinDir 'rg.exe')) { `$pathParts = (`$env:Path -split ';') | Where-Object { -not [string]::IsNullOrWhiteSpace(`$_) } `$toolsPathPresent = `$false foreach (`$part in `$pathParts) { if (`$part.TrimEnd('\').Equals(`$DefaultToolsBinDir.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { `$toolsPathPresent = `$true break } } if (-not `$toolsPathPresent) { `$env:Path = "`$DefaultToolsBinDir;`$env:Path" } } `$firstArg = if (`$LuckyArgs.Count -gt 0) { `$LuckyArgs[0] } else { '' } `$LuckyUpdateTelemetryUrl = if (`$env:LUCKY_UPDATE_TELEMETRY_URL) { `$env:LUCKY_UPDATE_TELEMETRY_URL } else { 'https://claude-zh.cn/api/update-event' } `$LuckyWrapperTelemetryUrl = if (`$env:LUCKY_WRAPPER_TELEMETRY_URL) { `$env:LUCKY_WRAPPER_TELEMETRY_URL } else { 'https://claude-zh.cn/api/wrapper-event' } `$LuckyInstallIdFile = Join-Path `$DefaultInstallDir '.install-id' `$LuckyFirstRunFile = Join-Path `$DefaultInstallDir '.first-run-done' function Get-LuckyArch { switch (`$env:PROCESSOR_ARCHITECTURE) { 'AMD64' { return 'x64' } 'ARM64' { return 'arm64' } 'x86' { return 'x86' } default { return 'unknown' } } } function Get-LuckySafeToken { param([string]`$Value) if ([string]::IsNullOrEmpty(`$Value)) { return '' } return (`$Value -replace '[^A-Za-z0-9._-]', '_') } function Get-LuckyInstallId { if (`$env:LUCKY_DISABLE_TELEMETRY -eq '1') { return '' } if (Test-Path `$LuckyInstallIdFile) { try { `$existing = (Get-Content -Raw -Path `$LuckyInstallIdFile -ErrorAction Stop).Trim() if (`$existing -match '^[a-zA-Z0-9-]{8,64}`$') { return `$existing } } catch {} } `$new = [Guid]::NewGuid().ToString() try { New-Item -ItemType Directory -Force -Path (Split-Path -Parent `$LuckyInstallIdFile) | Out-Null Set-Content -Path `$LuckyInstallIdFile -Value `$new -Encoding ASCII -NoNewline -ErrorAction Stop } catch {} return `$new } function Send-LuckyUpdateEvent { param( [string]`$Event, [string]`$Reason = '', [int]`$DurationMs = 0, [string]`$LatestVersion = '', [string]`$LatestGitSha = '' ) if (`$env:LUCKY_DISABLE_TELEMETRY -eq '1') { return } if ([string]::IsNullOrWhiteSpace(`$LuckyUpdateTelemetryUrl)) { return } try { `$arch = Get-LuckyArch `$ch = Get-LuckySafeToken `$env:THKJ_CHANNEL `$iid = if (`$env:LUCKY_INSTALL_ID -match '^[a-zA-Z0-9-]{8,64}`$') { `$env:LUCKY_INSTALL_ID } else { Get-LuckyInstallId } `$base = `$LuckyUpdateTelemetryUrl.TrimEnd('/') `$lv = Get-LuckySafeToken `$LatestVersion `$lgs = Get-LuckySafeToken `$LatestGitSha `$rsn = Get-LuckySafeToken `$Reason `$cv = Get-LuckySafeToken `$DefaultVersion `$cgs = Get-LuckySafeToken `$DefaultGitSha `$url = "`$base/windows_wrapper/windows/`$arch/`$Event" `$query = "channel=`$ch¤t_version=`$cv¤t_git_sha=`$cgs&latest_version=`$lv&latest_git_sha=`$lgs&reason=`$rsn&duration_ms=`$DurationMs&install_id=`$iid" Invoke-WebRequest -Uri "`${url}?`$query" -TimeoutSec 2 -UseBasicParsing -Method Get -ErrorAction SilentlyContinue | Out-Null } catch {} } function Send-LuckyWrapperEvent { param([string]`$Event, [string]`$Command = '') if (`$env:LUCKY_DISABLE_TELEMETRY -eq '1') { return } if ([string]::IsNullOrWhiteSpace(`$LuckyWrapperTelemetryUrl)) { return } try { `$arch = Get-LuckyArch `$ch = Get-LuckySafeToken `$env:THKJ_CHANNEL `$iid = if (`$env:LUCKY_INSTALL_ID -match '^[a-zA-Z0-9-]{8,64}`$') { `$env:LUCKY_INSTALL_ID } else { Get-LuckyInstallId } `$base = `$LuckyWrapperTelemetryUrl.TrimEnd('/') `$cmd = Get-LuckySafeToken `$Command `$cv = Get-LuckySafeToken `$DefaultVersion `$cgs = Get-LuckySafeToken `$DefaultGitSha `$url = "`$base/windows_wrapper/windows/`$arch/`$Event" `$query = "channel=`$ch&installed_version=`$cv&installed_git_sha=`$cgs&command=`$cmd&install_id=`$iid" Invoke-WebRequest -Uri "`${url}?`$query" -TimeoutSec 2 -UseBasicParsing -Method Get -ErrorAction SilentlyContinue | Out-Null } catch {} } function Send-LuckyWrapperLifecycleEvents { param([string]`$CommandName) if (`$CommandName -in @('--version', '-v', '-V', 'doctor', 'update', 'uninstall')) { return } Send-LuckyWrapperEvent -Event 'lucky_wrapper_launch' -Command `$CommandName if (-not (Test-Path `$LuckyFirstRunFile)) { Send-LuckyWrapperEvent -Event 'lucky_wrapper_first_run' -Command `$CommandName try { New-Item -ItemType Directory -Force -Path (Split-Path -Parent `$LuckyFirstRunFile) | Out-Null Set-Content -Path `$LuckyFirstRunFile -Value '' -Encoding ASCII -NoNewline -ErrorAction Stop } catch {} } } function Invoke-ThkjAutoUpdateCheck { param([string]`$CommandName) if (`$env:THKJ_AUTO_UPDATE -in @('0', 'false', 'FALSE', 'no', 'NO', 'off', 'OFF')) { return } if (`$env:THKJ_AUTO_UPDATE_SKIP -eq '1') { return } if (`$CommandName -in @('--version', '-v', '-V', 'doctor', 'update', 'uninstall')) { return } # Check on every normal launch unless an operator configures a positive interval. `$interval = 0 if (`$env:THKJ_AUTO_UPDATE_INTERVAL_SECONDS -match '^[0-9]+$') { `$interval = [int]`$env:THKJ_AUTO_UPDATE_INTERVAL_SECONDS } `$stateFile = Join-Path `$DefaultInstallDir '.auto-update-check' `$now = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() `$last = 0 if (Test-Path `$stateFile) { try { `$text = (Get-Content -Raw -Path `$stateFile).Trim() if (`$text -match '^[0-9]+$') { `$last = [int]`$text } } catch {} } if (`$interval -gt 0 -and `$last -gt 0 -and (`$now - `$last) -lt `$interval) { return } try { Set-Content -Path `$stateFile -Value `$now -Encoding ASCII } catch {} Send-LuckyUpdateEvent -Event 'lucky_auto_update_check' `$versionEndpoint = `$env:THKJ_BRIDGE_API_BASE_URL.TrimEnd('/') + '/version' if (`$env:THKJ_CHANNEL -ne 'stable') { `$versionEndpoint = `$env:THKJ_BRIDGE_API_BASE_URL.TrimEnd('/') + '/channels/' + `$env:THKJ_CHANNEL + '/version.json' } try { `$metadata = Invoke-RestMethod -Uri `$versionEndpoint -TimeoutSec 10 } catch { Send-LuckyUpdateEvent -Event 'lucky_auto_update_check_failed' -Reason 'metadata_unavailable' return } `$latestVersion = [string]`$metadata.version `$latestGitSha = if (`$metadata.gitSha) { [string]`$metadata.gitSha } else { '' } `$latestArtifactSha256 = if (`$metadata.artifact -and `$metadata.artifact.sha256) { ([string]`$metadata.artifact.sha256).ToLowerInvariant() } else { '' } if ([string]::IsNullOrWhiteSpace(`$latestVersion)) { Send-LuckyUpdateEvent -Event 'lucky_auto_update_check_failed' -Reason 'version_missing' return } `$sameGitSha = [string]::IsNullOrWhiteSpace(`$latestGitSha) -or [string]::IsNullOrWhiteSpace(`$DefaultGitSha) -or `$latestGitSha -eq `$DefaultGitSha `$sameArtifact = [string]::IsNullOrWhiteSpace(`$latestArtifactSha256) -or [string]::IsNullOrWhiteSpace(`$DefaultArtifactSha256) -or `$latestArtifactSha256 -eq `$DefaultArtifactSha256 if (`$latestVersion -eq `$DefaultVersion -and `$sameGitSha -and `$sameArtifact) { Send-LuckyUpdateEvent -Event 'lucky_auto_update_up_to_date' -LatestVersion `$latestVersion -LatestGitSha `$latestGitSha return } Send-LuckyUpdateEvent -Event 'lucky_auto_update_available' -LatestVersion `$latestVersion -LatestGitSha `$latestGitSha `$env:THKJ_CHANNEL = `$DefaultChannel `$env:THKJ_BIN_NAME = `$DefaultBinName `$env:THKJ_BIN_DIR = `$DefaultBinDir `$env:THKJ_INSTALL_DIR = `$DefaultInstallDir `$env:THKJ_AUTO_UPDATE_SKIP = '1' Send-LuckyUpdateEvent -Event 'lucky_auto_update_start' -LatestVersion `$latestVersion -LatestGitSha `$latestGitSha Write-Host "[lucky] Update available: installing `$latestVersion before startup..." # Use Stopwatch to avoid Int32 overflow from epoch ms. `$updateStopwatch = [System.Diagnostics.Stopwatch]::StartNew() `$updateSuccess = `$false `$updateReason = '' try { `$installUrl = `$env:THKJ_BRIDGE_API_BASE_URL.TrimEnd('/') + '/install.ps1' Invoke-Expression (Invoke-RestMethod -Uri `$installUrl) `$updateSuccess = `$true } catch { `$updateReason = 'installer_error' } `$updateStopwatch.Stop() `$updateDurMs = [int][Math]::Min(`$updateStopwatch.ElapsedMilliseconds, 600000) if (`$updateSuccess) { Send-LuckyUpdateEvent -Event 'lucky_auto_update_success' -DurationMs `$updateDurMs -LatestVersion `$latestVersion -LatestGitSha `$latestGitSha Write-Host '[lucky] Update completed. Continuing startup...' } else { Send-LuckyUpdateEvent -Event 'lucky_auto_update_failed' -Reason `$updateReason -DurationMs `$updateDurMs -LatestVersion `$latestVersion -LatestGitSha `$latestGitSha Write-Host "[lucky] Update failed (`$updateReason). Continuing with installed version..." } } function Invoke-LuckyUninstall { param([string[]]`$Args) `$yes = `$false `$removeConfig = `$false foreach (`$arg in `$Args) { switch (`$arg) { '--yes' { `$yes = `$true; continue } '-y' { `$yes = `$true; continue } '--remove-config' { `$removeConfig = `$true; continue } '--remove-data' { `$removeConfig = `$true; continue } '--keep-config' { `$removeConfig = `$false; continue } '--keep-data' { `$removeConfig = `$false; continue } '--help' { Write-Output 'Usage: lucky uninstall [--yes] [--remove-config|--keep-config]' Write-Output '' Write-Output 'Removes the Lucky Code launcher and installed program files. By default,' Write-Output 'chat records and user configuration are kept. Use --remove-config to also' Write-Output 'delete Lucky user data.' exit 0 } '-h' { Write-Output 'Usage: lucky uninstall [--yes] [--remove-config|--keep-config]' exit 0 } default { Write-Error "Unknown uninstall option: `$arg" exit 2 } } } `$env:LUCKY_INSTALL_ID = Get-LuckyInstallId Send-LuckyWrapperEvent -Event 'lucky_wrapper_uninstall_start' -Command 'uninstall' `$userDataDir = Join-Path `$env:USERPROFILE '.lucky' `$webDataDir = Join-Path `$env:USERPROFILE '.lucky-code' `$cmdLauncher = Join-Path `$DefaultBinDir (`$DefaultBinName + '.cmd') `$psLauncher = Join-Path `$DefaultBinDir (`$DefaultBinName + '.ps1') Write-Output 'Lucky Code uninstall' Write-Output '' Write-Output "Program files: `$DefaultInstallDir" Write-Output "Launcher: `$cmdLauncher" Write-Output "PowerShell: `$psLauncher" Write-Output "User data: `$userDataDir" Write-Output "Web data: `$webDataDir" Write-Output '' if (-not `$yes) { `$confirm = Read-Host 'Remove Lucky Code program files? [y/N]' if (`$confirm -notmatch '^(y|yes)`$') { Send-LuckyWrapperEvent -Event 'lucky_wrapper_uninstall_cancelled' -Command 'uninstall' Write-Output 'Uninstall cancelled.' exit 1 } `$configConfirm = Read-Host 'Also remove Lucky chat records and user configuration? [y/N]' `$removeConfig = `$configConfirm -match '^(y|yes)`$' } `$failed = `$false foreach (`$path in @(`$cmdLauncher, `$psLauncher, `$DefaultInstallDir)) { if (Test-Path `$path) { try { Remove-Item -Recurse -Force -Path `$path -ErrorAction Stop } catch { `$failed = `$true } } } if (`$removeConfig) { foreach (`$path in @(`$userDataDir, `$webDataDir)) { if (Test-Path `$path) { try { Remove-Item -Recurse -Force -Path `$path -ErrorAction Stop } catch { `$failed = `$true } } } } if (-not `$failed) { Send-LuckyWrapperEvent -Event 'lucky_wrapper_uninstall_success' -Command 'uninstall' Write-Output 'Lucky Code has been uninstalled.' if (`$removeConfig) { Write-Output 'Lucky chat records and user configuration were removed.' } else { Write-Output 'Lucky chat records and user configuration were kept.' } exit 0 } Send-LuckyWrapperEvent -Event 'lucky_wrapper_uninstall_failed' -Command 'uninstall' Write-Error 'Lucky Code uninstall failed. Check file permissions and try again.' exit 1 } if (`$firstArg -in @('--version', '-v', '-V')) { `$resolvedVersion = `$DefaultVersion if (Test-Path `$PayloadBin) { try { `$rawVersion = (& `$PayloadBin --version 2>`$null | Out-String) if (`$rawVersion -match '(?:Claude Code v)?([0-9]+\.[0-9]+\.[0-9][^\s)]*)') { `$resolvedVersion = `$Matches[1] } } catch {} } Write-Output "`$resolvedVersion (THKJ Lucky Code)" exit 0 } if (`$firstArg -eq 'update') { `$env:THKJ_CHANNEL = `$DefaultChannel `$env:THKJ_BIN_NAME = `$DefaultBinName `$env:THKJ_BIN_DIR = `$DefaultBinDir `$env:THKJ_INSTALL_DIR = `$DefaultInstallDir `$env:THKJ_AUTO_UPDATE_SKIP = '1' `$installUrl = `$env:THKJ_BRIDGE_API_BASE_URL.TrimEnd('/') + '/install.ps1' Invoke-Expression (Invoke-RestMethod -Uri `$installUrl) exit `$LASTEXITCODE } if (`$firstArg -eq 'uninstall') { `$uninstallArgs = @() if (`$LuckyArgs.Count -gt 1) { `$uninstallArgs = `$LuckyArgs[1..(`$LuckyArgs.Count - 1)] } Invoke-LuckyUninstall -Args `$uninstallArgs } if (`$firstArg -eq '__lucky_auto_update__') { Invoke-ThkjAutoUpdateCheck -CommandName '' exit 0 } if (`$firstArg -eq 'doctor') { Write-Output 'Lucky Code doctor' Write-Output '' Write-Output "Channel: `$env:THKJ_CHANNEL" Write-Output "lucky: `$env:THKJ_BRIDGE_API_BASE_URL" Write-Output "newapi: `$env:THKJ_UPSTREAM_API_BASE_URL" Write-Output "Temp: `$env:CLAUDE_CODE_TMPDIR" Write-Output "`${DefaultBinName}: `$DefaultBinDir\`$DefaultBinName.cmd" Write-Output "payload: `$PayloadBin" `$nodeCommand = Get-Command node -ErrorAction SilentlyContinue `$npmCommand = Get-Command npm -ErrorAction SilentlyContinue `$rgCommand = Get-Command rg -ErrorAction SilentlyContinue if (`$nodeCommand) { Write-Output "node: `$(`$nodeCommand.Source)" } else { Write-Output 'node: not found' } if (`$npmCommand) { Write-Output "npm: `$(`$npmCommand.Source)" } else { Write-Output 'npm: not found' } `$resolvedRgSource = `$DefaultRgSource if (`$rgCommand -and `$rgCommand.Source.StartsWith(`$DefaultToolsBinDir, [StringComparison]::OrdinalIgnoreCase)) { `$resolvedRgSource = 'cache' } elseif (`$rgCommand -and `$resolvedRgSource -eq 'not found') { `$resolvedRgSource = 'system' } if (`$rgCommand) { Write-Output "rg: `$(`$rgCommand.Source) (`$resolvedRgSource)" } else { Write-Output "rg: not found (`$resolvedRgSource)" } if (Test-Path `$PayloadBin) { try { Write-Output "version: `$(& `$PayloadBin --version 2>`$null)" } catch { Write-Output 'version: failed' } } else { Write-Output "version: installed CLI not found at `$PayloadBin" } try { `$hello = Invoke-RestMethod -Uri (`$env:THKJ_BRIDGE_API_BASE_URL.TrimEnd('/') + '/api/hello') -TimeoutSec 10 Write-Output 'lucky health: ok' if (`$hello.channel) { Write-Output "lucky bridge channel: `$(`$hello.channel)" } if (`$null -ne `$hello.preview) { Write-Output "lucky bridge preview: `$(`$hello.preview)" } if (`$hello.public_base_url) { Write-Output "lucky bridge base: `$(`$hello.public_base_url)" } } catch { Write-Output 'lucky health: failed' } exit 0 } Send-LuckyWrapperLifecycleEvents -CommandName `$firstArg try { Start-Process -FilePath 'powershell.exe' -ArgumentList @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', `$PSCommandPath, '__lucky_auto_update__' ) -WindowStyle Hidden | Out-Null } catch {} & `$PayloadBin @LuckyArgs exit `$LASTEXITCODE "@ Set-Content -Path $wrapperPs1 -Value $wrapperBody -Encoding UTF8 $cmdBody = @" @echo off setlocal powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0$binName.ps1" %* exit /b %ERRORLEVEL% "@ Set-Content -Path $wrapperCmd -Value $cmdBody -Encoding ASCII # install_id continuity (telemetry-only, fire-and-forget). # The outer shell (install-lucky.ps1) owns the install-event beacons + A/B # variant and exports $env:LUCKY_INSTALL_ID (its own stable, persisted id). # Write that id to \.install-id so the wrapper's Get-LuckyInstallId # reuses the SAME id at runtime and wrapper/cc_auth/payment events join the # install-time (variant-tagged) row. # # Overwrite, NOT seed-if-absent: the outer shell id is authoritative. A stale # .install-id from a pre-shell install (or a diverged store) would otherwise # leave the wrapper on a different id and silently break the funnel join. # Only runs when the shell provided an id (bare payload runs leave it alone). try { if ($env:LUCKY_INSTALL_ID -match '^[a-zA-Z0-9-]{8,64}$') { $installIdFile = Join-Path $installDir '.install-id' New-Item -ItemType Directory -Force -Path $installDir | Out-Null Set-Content -Path $installIdFile -Value $env:LUCKY_INSTALL_ID -Encoding ASCII -NoNewline -ErrorAction Stop } } catch {} Write-Output '' Write-Output 'Lucky Code installed.' Write-Output '' Write-Output "Version: $version" Write-Output "Channel: $channel" Write-Output "Binary: $wrapperCmd" Write-Output "PowerShell: $wrapperPs1" Write-Output "Payload: $payloadBin" if (-not [string]::IsNullOrWhiteSpace($env:CLAUDE_CODE_GIT_BASH_PATH)) { Write-Output "Git Bash: $env:CLAUDE_CODE_GIT_BASH_PATH" } Write-Output "lucky: $bridgeBase" Write-Output "rg: $($ripgrepInstall.Source)" Write-Output '' Write-Output 'Next steps:' Write-Output " $binName" Write-Output ' Then type /login in the interactive session.' Write-Output '' Write-Output 'Update later:' Write-Output " $binName update" Write-Output '' Write-Output 'Uninstall:' Write-Output " $binName uninstall" Write-Output '' Write-Output '交流 / 反馈 · QQ 群: 936493004' } finally { Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue }