#__NEXUS_PS1__ # NEXUS_AI - установщик в 1 клик для Windows (Windows PowerShell 5.1+). # # Что делает: # 1. проверяет ваш ключ и показывает баланс; # 2. находит Cursor / VS Code / Windsurf и терминальные агенты (Claude Code, Codex, Qwen Code, OpenCode, Kilo Code); # 3. прописывает в их настройки адрес шлюза и ваш ключ (в Cursor/VS Code ставит агент Roo Code, уже подключённый). # Перед каждым изменением копия файла сохраняется в %USERPROFILE%\.nexus-ai\backups\<время>\. # Откат последней установки: запустите установщик ещё раз и введите R (или NEXUS_ACTION=restore). # # Переменные: NEXUS_KEY (ключ), NEXUS_MODEL (модель по умолчанию), NEXUS_AUTO=1 (без вопросов), # NEXUS_ACTION=restore (откат), NEXUS_SITE (адрес шлюза), NEXUS_TEST_ROOT (песочница для тестов). $ErrorActionPreference = 'Continue' try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 } catch {} $global:NexusExit = 0 $Site = if ($env:NEXUS_SITE) { $env:NEXUS_SITE.TrimEnd('/') } else { 'https://nexusallin.store' } $OpenAiBase = "$Site/v1" $Utf8NoBom = New-Object System.Text.UTF8Encoding $false $Stamp = Get-Date -Format 'yyyyMMdd_HHmmss' $Auto = ($env:NEXUS_AUTO -eq '1') $TestRoot = $env:NEXUS_TEST_ROOT if ($TestRoot) { $UserHome = Join-Path $TestRoot 'home' $AppData = Join-Path $TestRoot 'AppData\Roaming' $LocalApp = Join-Path $TestRoot 'AppData\Local' } else { $UserHome = [Environment]::GetFolderPath('UserProfile') $AppData = [Environment]::GetFolderPath('ApplicationData') $LocalApp = [Environment]::GetFolderPath('LocalApplicationData') } $NexusDir = Join-Path $UserHome '.nexus-ai' $BackupRoot = Join-Path $NexusDir 'backups' $RunDir = Join-Path $BackupRoot $Stamp $n = 2 while (Test-Path $RunDir) { $RunDir = Join-Path $BackupRoot ("{0}_{1}" -f $Stamp, $n); $n++ } # каждый запуск - своя папка $LogFile = Join-Path $NexusDir 'install.log' # ── вывод ───────────────────────────────────────────────────────────────────── function Say([string]$Text = '', [string]$Color = 'Gray') { Write-Host $Text -ForegroundColor $Color } function Ok([string]$Text) { Write-Host ' [OK] ' -ForegroundColor Green -NoNewline; Write-Host $Text } function Warn([string]$Text) { Write-Host ' [!] ' -ForegroundColor Yellow -NoNewline; Write-Host $Text } function Fail([string]$Text) { Write-Host ' [X] ' -ForegroundColor Red -NoNewline; Write-Host $Text } function Step([string]$Text) { Write-Host ' -> ' -ForegroundColor DarkCyan -NoNewline; Write-Host $Text } function Log([string]$Text) { try { if (-not (Test-Path $NexusDir)) { New-Item -ItemType Directory -Force -Path $NexusDir | Out-Null } Add-Content -Path $LogFile -Value ("{0} {1}" -f (Get-Date -Format 's'), $Text) -Encoding UTF8 } catch {} } function Ask([string]$Prompt, [string]$Default = '') { if ($Auto) { return $Default } $a = Read-Host $Prompt if ($null -eq $a) { return $Default } $a = $a.Trim() if ($a -eq '') { return $Default } return $a } function Mask([string]$k) { if ($k.Length -gt 12) { return $k.Substring(0, 6) + '...' + $k.Substring($k.Length - 4) } return $k } function Fmt([object]$n) { try { return ([decimal]$n).ToString('#,0', [Globalization.CultureInfo]::InvariantCulture).Replace(',', ' ') } catch { return [string]$n } } # ── резервные копии и откат ─────────────────────────────────────────────────── $script:BackedUp = @{} function Backup-Once([string]$Path) { if ($script:BackedUp.ContainsKey($Path)) { return } $script:BackedUp[$Path] = $true if (-not (Test-Path $RunDir)) { New-Item -ItemType Directory -Force -Path $RunDir | Out-Null } $manifest = Join-Path $RunDir 'manifest.tsv' if (Test-Path -LiteralPath $Path) { $name = ('{0:D2}_' -f $script:BackedUp.Count) + (Split-Path $Path -Leaf) Copy-Item -LiteralPath $Path -Destination (Join-Path $RunDir $name) -Force Add-Content -Path $manifest -Value ("FILE`t$Path`t$name`t1") -Encoding UTF8 } else { Add-Content -Path $manifest -Value ("FILE`t$Path`t`t0") -Encoding UTF8 } } function Save-Text([string]$Path, [string]$Text) { Backup-Once $Path $dir = Split-Path $Path -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } [IO.File]::WriteAllText($Path, $Text, $Utf8NoBom) } function Set-UserEnv([string]$Name, [string]$Value) { $old = Get-UserEnv $Name if (-not (Test-Path $RunDir)) { New-Item -ItemType Directory -Force -Path $RunDir | Out-Null } $had = if ($null -ne $old) { '1' } else { '0' } Add-Content -Path (Join-Path $RunDir 'manifest.tsv') -Value ("ENV`t$Name`t$old`t$had") -Encoding UTF8 if ($TestRoot) { Add-Content -Path (Join-Path $TestRoot 'user-env.txt') -Value "$Name=$Value" -Encoding UTF8 } else { [Environment]::SetEnvironmentVariable($Name, $Value, 'User') } Set-Item -Path "Env:$Name" -Value $Value } function Get-UserEnv([string]$Name) { if ($TestRoot) { $f = Join-Path $TestRoot 'user-env-initial.txt' if (Test-Path $f) { foreach ($l in Get-Content $f) { if ($l -like "$Name=*") { return $l.Substring($Name.Length + 1) } } } return $null } return [Environment]::GetEnvironmentVariable($Name, 'User') } function Invoke-Restore { $runs = @() if (Test-Path $BackupRoot) { $runs = @(Get-ChildItem $BackupRoot -Directory | Where-Object { $_.Name -match '^\d{8}_\d{6}(_\d+)?$' -and (Test-Path (Join-Path $_.FullName 'manifest.tsv')) } | Sort-Object { $_.CreationTimeUtc }, Name -Descending) } if ($runs.Count -eq 0) { Warn 'Нечего откатывать: резервных копий нет.'; return } $run = $runs[0] Say (" Откатываю установку от {0}..." -f $run.Name) White foreach ($line in Get-Content (Join-Path $run.FullName 'manifest.tsv') -Encoding UTF8) { $p = $line -split "`t" if ($p.Count -lt 4) { continue } if ($p[0] -eq 'FILE') { if ($p[3] -eq '1') { Copy-Item -LiteralPath (Join-Path $run.FullName $p[2]) -Destination $p[1] -Force; Ok "восстановлен $($p[1])" } elseif (Test-Path -LiteralPath $p[1]) { Remove-Item -LiteralPath $p[1] -Force; Ok "удалён созданный файл $($p[1])" } } elseif ($p[0] -eq 'ENV') { $value = if ($p[3] -eq '1') { $p[2] } else { $null } if (-not $TestRoot) { [Environment]::SetEnvironmentVariable($p[1], $value, 'User') } Ok "переменная $($p[1]) возвращена" } } Rename-Item -LiteralPath $run.FullName -NewName ($run.Name + '.restored') Say '' Ok 'Готово. Установленные расширения (Roo Code) не удалялись - их можно удалить в редакторе.' Log "restore $($run.Name)" } # ── JSON / JSONC: читаем с комментариями, меняем только нужные ключи, остальной файл не трогаем ── $script:TokRe = [regex]'"(?:[^"\\]|\\.)*"|//[^\r\n]*|/\*[\s\S]*?\*/|[{}\[\]:,]|[^\s{}\[\]:,"/]+' function Get-JsonTokens([string]$Text) { $list = New-Object System.Collections.Generic.List[object] foreach ($m in $script:TokRe.Matches($Text)) { if ($m.Value.StartsWith('//') -or $m.Value.StartsWith('/*')) { continue } $list.Add($m) } return , $list } function ConvertFrom-Jsonc([string]$Text) { $toks = Get-JsonTokens $Text if ($toks.Count -eq 0) { return $null } $parts = New-Object System.Collections.Generic.List[string] for ($i = 0; $i -lt $toks.Count; $i++) { $v = $toks[$i].Value if ($v -eq ',' -and ($i + 1) -lt $toks.Count -and ($toks[$i + 1].Value -eq '}' -or $toks[$i + 1].Value -eq ']')) { continue } $parts.Add($v) } return (($parts -join ' ') | ConvertFrom-Json) } function Read-JsonFile([string]$Path) { # $null - файла нет или он пуст; исключение - файл есть, но это не JSON-объект (такие файлы не трогаем) if (-not (Test-Path -LiteralPath $Path)) { return $null } $text = [IO.File]::ReadAllText($Path) if (-not $text.Trim()) { return $null } $obj = ConvertFrom-Jsonc $text if ($null -eq $obj -or $obj.GetType().FullName -ne 'System.Management.Automation.PSCustomObject') { throw "в $Path не JSON-объект" } return $obj } function ConvertTo-JsonStr([string]$s) { $s = $s.Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n').Replace("`t", '\t') $s = [regex]::Replace($s, '[\x00-\x1f]', { param($m) '\u{0:x4}' -f [int][char]$m.Value }) return '"' + $s + '"' } function ConvertTo-NexusJson($Value, [int]$Level, [string]$Unit, [string]$NL) { if ($null -eq $Value) { return 'null' } if ($Value -is [bool]) { if ($Value) { return 'true' } else { return 'false' } } if ($Value -is [string] -or $Value -is [char]) { return (ConvertTo-JsonStr ([string]$Value)) } if ($Value -is [double] -or $Value -is [single]) { return $Value.ToString('R', [Globalization.CultureInfo]::InvariantCulture) } if ($Value -is [int] -or $Value -is [long] -or $Value -is [decimal] -or $Value -is [int16] -or $Value -is [byte] -or $Value -is [uint32] -or $Value -is [uint64]) { return ([System.Convert]::ToString($Value, [Globalization.CultureInfo]::InvariantCulture)) } $pad = $Unit * ($Level + 1) $end = $Unit * $Level $isDict = $Value -is [System.Collections.IDictionary] $isObj = $Value.GetType().FullName -eq 'System.Management.Automation.PSCustomObject' if ($isDict -or $isObj) { $items = New-Object System.Collections.Generic.List[string] if ($isDict) { foreach ($k in $Value.Keys) { $items.Add($pad + (ConvertTo-JsonStr ([string]$k)) + ': ' + (ConvertTo-NexusJson $Value[$k] ($Level + 1) $Unit $NL)) } } else { foreach ($p in $Value.PSObject.Properties) { $items.Add($pad + (ConvertTo-JsonStr $p.Name) + ': ' + (ConvertTo-NexusJson $p.Value ($Level + 1) $Unit $NL)) } } if ($items.Count -eq 0) { return '{}' } return '{' + $NL + ($items -join (',' + $NL)) + $NL + $end + '}' } if ($Value -is [System.Collections.IEnumerable]) { $items = New-Object System.Collections.Generic.List[string] foreach ($x in $Value) { $items.Add($pad + (ConvertTo-NexusJson $x ($Level + 1) $Unit $NL)) } if ($items.Count -eq 0) { return '[]' } return '[' + $NL + ($items -join (',' + $NL)) + $NL + $end + ']' } return (ConvertTo-JsonStr ([string]$Value)) } function Set-JsonTopLevel([string]$Text, [string]$Key, [string]$ValueJson, [string]$NL, [string]$Unit) { $toks = Get-JsonTokens $Text if ($toks.Count -eq 0 -or $toks[0].Value -ne '{') { throw 'корень файла - не объект' } $depth = 0 $close = $null $quoted = ConvertTo-JsonStr $Key for ($i = 0; $i -lt $toks.Count; $i++) { $v = $toks[$i].Value if ($v -eq '{' -or $v -eq '[') { $depth++; continue } if ($v -eq '}' -or $v -eq ']') { $depth--; if ($depth -eq 0) { $close = $i; break }; continue } if ($depth -eq 1 -and $v -ceq $quoted -and ($i + 2) -lt $toks.Count -and $toks[$i + 1].Value -eq ':') { $j = $i + 2 $start = $toks[$j].Index $stop = $toks[$j].Index + $toks[$j].Length if ($toks[$j].Value -eq '{' -or $toks[$j].Value -eq '[') { $d = 0 for ($k = $j; $k -lt $toks.Count; $k++) { $t = $toks[$k].Value if ($t -eq '{' -or $t -eq '[') { $d++ } elseif ($t -eq '}' -or $t -eq ']') { $d--; if ($d -eq 0) { $stop = $toks[$k].Index + 1; break } } } } return $Text.Substring(0, $start) + $ValueJson + $Text.Substring($stop) } } if ($null -eq $close) { throw 'не найден конец объекта' } $prev = $toks[$close - 1] $prop = $quoted + ': ' + $ValueJson if ($prev.Value -eq '{') { return $Text.Substring(0, $prev.Index + 1) + $NL + $Unit + $prop + $NL + $Text.Substring($toks[$close].Index) } if ($prev.Value -eq ',') { $at = $prev.Index + 1; $sep = $NL + $Unit } else { $at = $prev.Index + $prev.Length; $sep = ',' + $NL + $Unit } return $Text.Substring(0, $at) + $sep + $prop + $Text.Substring($at) } function Update-JsonFile([string]$Path, $Values) { # Values: упорядоченный словарь "ключ верхнего уровня" -> значение. Остальные ключи и комментарии сохраняются. $text = '' if (Test-Path -LiteralPath $Path) { $text = [IO.File]::ReadAllText($Path) } if (-not $text.Trim()) { $text = "{`n}" } $NL = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" } $m = [regex]::Match($text, '\n([ \t]+)"') $Unit = if ($m.Success) { $m.Groups[1].Value } else { ' ' } $null = ConvertFrom-Jsonc $text foreach ($k in $Values.Keys) { $json = ConvertTo-NexusJson $Values[$k] 1 $Unit $NL $text = Set-JsonTopLevel $text $k $json $NL $Unit } $null = ConvertFrom-Jsonc $text # результат обязан оставаться валидным, иначе ничего не пишем Save-Text $Path $text } function New-Obj { return New-Object PSObject } function Set-Prop($Obj, [string]$Name, $Value) { if ($Obj.PSObject.Properties[$Name]) { $Obj.$Name = $Value } else { $Obj | Add-Member -NotePropertyName $Name -NotePropertyValue $Value } } function Get-Prop($Obj, [string]$Name) { if ($null -ne $Obj -and $Obj.PSObject.Properties[$Name]) { return $Obj.$Name } return $null } function Get-ObjProp($Obj, [string]$Name) { $v = Get-Prop $Obj $Name if ($null -ne $v -and $v.GetType().FullName -eq 'System.Management.Automation.PSCustomObject') { return $v } return (New-Obj) } # ── поиск программ ──────────────────────────────────────────────────────────── function Find-Cli([string[]]$Names) { if ($TestRoot) { foreach ($n in $Names) { $p = Join-Path $TestRoot "bin\$n"; if (Test-Path $p) { return $p } } return $null } foreach ($n in $Names) { $c = Get-Command $n -ErrorAction SilentlyContinue | Select-Object -First 1 if ($c) { return $c.Source } } return $null } function Refresh-Path { if ($TestRoot) { return } $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') } $Editors = @( [pscustomobject]@{ Id = 'cursor'; Name = 'Cursor'; Cmd = 'cursor.cmd'; Paths = @("$LocalApp\Programs\cursor\resources\app\bin\cursor.cmd"); User = "$AppData\Cursor\User"; Ext = "$UserHome\.cursor\extensions"; Cli = $null; Found = $false }, [pscustomobject]@{ Id = 'vscode'; Name = 'VS Code'; Cmd = 'code.cmd'; Paths = @("$LocalApp\Programs\Microsoft VS Code\bin\code.cmd", "$env:ProgramFiles\Microsoft VS Code\bin\code.cmd"); User = "$AppData\Code\User"; Ext = "$UserHome\.vscode\extensions"; Cli = $null; Found = $false }, [pscustomobject]@{ Id = 'windsurf'; Name = 'Windsurf'; Cmd = 'windsurf.cmd'; Paths = @("$LocalApp\Programs\Windsurf\bin\windsurf.cmd"); User = "$AppData\Windsurf\User"; Ext = "$UserHome\.windsurf\extensions"; Cli = $null; Found = $false } ) function Detect-Editors { foreach ($e in $Editors) { $cli = $null if ($TestRoot) { $cli = Find-Cli @($e.Cmd) } else { foreach ($p in $e.Paths) { if ($p -and (Test-Path -LiteralPath $p)) { $cli = $p; break } } if (-not $cli) { $cli = Find-Cli @($e.Cmd) } } $e.Cli = $cli $e.Found = [bool]($cli -or (Test-Path -LiteralPath $e.User)) } } function Test-Extension($Editor, [string]$Prefix) { if (-not (Test-Path -LiteralPath $Editor.Ext)) { return $false } return [bool](Get-ChildItem -LiteralPath $Editor.Ext -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "$Prefix-*" } | Select-Object -First 1) } function Invoke-Tool([string]$File, [string[]]$Arguments, [int]$TimeoutSec = 300) { # запускает .cmd/.exe, ждёт с таймаутом, возвращает код выхода и вывод $out = [IO.Path]::GetTempFileName(); $err = [IO.Path]::GetTempFileName() try { $p = Start-Process -FilePath $File -ArgumentList $Arguments -NoNewWindow -PassThru -RedirectStandardOutput $out -RedirectStandardError $err if (-not $p.WaitForExit($TimeoutSec * 1000)) { try { $p.Kill() } catch {}; return @{ Code = -1; Text = 'таймаут' } } $text = ((Get-Content $out -Raw -ErrorAction SilentlyContinue) + "`n" + (Get-Content $err -Raw -ErrorAction SilentlyContinue)).Trim() return @{ Code = $p.ExitCode; Text = $text } } catch { return @{ Code = -1; Text = $_.Exception.Message } } finally { Remove-Item $out, $err -Force -ErrorAction SilentlyContinue } } function Install-Extension($Editor, [string]$Id) { if ($TestRoot) { New-Item -ItemType Directory -Force -Path (Join-Path $Editor.Ext ($Id.ToLower() + '-9.9.9')) | Out-Null return $true } if (-not $Editor.Cli) { return $false } $r = Invoke-Tool $Editor.Cli @('--install-extension', $Id, '--force') 240 if ($r.Code -eq 0) { return $true } Log "extension $Id in $($Editor.Name) failed: $($r.Text)" return $false } # ── ключ и каталог моделей ──────────────────────────────────────────────────── function Invoke-Api([string]$Path, [string]$Key) { $h = @{} if ($Key) { $h['Authorization'] = "Bearer $Key" } return Invoke-RestMethod -Uri ($OpenAiBase + $Path) -Headers $h -TimeoutSec 25 -UseBasicParsing } function Test-Key([string]$Key) { # возвращает 'ok' | 'bad' | 'offline', печатает баланс try { $b = Invoke-Api '/balance' $Key if ($b.is_unlimited) { Ok ("Ключ {0} активен - безлимит" -f (Mask $Key)) } else { $extra = '' if ($b.expires_at) { try { $extra = ' · действует до ' + ([datetime]$b.expires_at).ToString('dd.MM.yyyy') } catch {} } Ok ("Ключ {0} активен - баланс {1} токенов{2}" -f (Mask $Key), (Fmt $b.remaining_credits), $extra) } if ($b.remaining_credits -le 0 -and -not $b.is_unlimited) { Warn 'Баланс пуст - программы подключатся, но ответы пойдут после пополнения на FunPay.' } return 'ok' } catch { $code = $null try { $code = [int]$_.Exception.Response.StatusCode } catch {} if ($code -eq 401 -or $code -eq 403) { return 'bad' } Warn ("Не удалось проверить ключ ({0}). Продолжаю - проверьте интернет позже." -f $_.Exception.Message) return 'offline' } } $Curated = @( @{ Id = 'claude-sonnet-5.5'; Note = 'самая сильная для кода и агентов' }, @{ Id = 'gpt-6-luna'; Note = 'умная и заметно дешевле' }, @{ Id = 'deepseek-v4-pro'; Note = 'сильная, дешевле Sonnet' }, @{ Id = 'grok-4.7'; Note = 'быстрая, логика и математика' }, @{ Id = 'deepseek-v4.1-flash'; Note = 'быстрая' }, @{ Id = 'gemini-3.8-flash'; Note = 'быстрая' }, @{ Id = 'glm-5.3-flash'; Note = 'дешёвая' }, @{ Id = 'qwen3.7-flash'; Note = 'самая дешёвая, для простых задач' } ) $FastCandidates = @('claude-haiku-5.5', 'glm-5.3-flash', 'qwen3.7-flash', 'deepseek-v4.1-flash') function Get-Catalog { try { $r = Invoke-Api '/models' '' $map = @{} foreach ($m in $r.data) { $map[[string]$m.id] = $m.multiplier } return $map } catch { return $null } } # ── действия ────────────────────────────────────────────────────────────────── function Get-RooImportFile { return (Join-Path $NexusDir 'roo-code-settings.json') } function Write-RooImport { $cfg = [ordered]@{ providerProfiles = [ordered]@{ currentApiConfigName = 'NEXUS_AI' apiConfigs = [ordered]@{ 'NEXUS_AI' = [ordered]@{ apiProvider = 'openai' openAiBaseUrl = $OpenAiBase openAiApiKey = $script:Key openAiModelId = $script:Model openAiStreamingEnabled = $true id = 'nexus-ai' } } modeApiConfigs = [ordered]@{} } globalSettings = [ordered]@{} } if ($script:Economy) { # Экономный режим: агент отправляет на каждом шаге весь контекст, поэтому сжимаем его раньше # и не прикладываем к каждому запросу лишнее (вкладки, длинный список файлов, сотни ошибок). $cfg.globalSettings = [ordered]@{ autoCondenseContext = $true autoCondenseContextPercent = 60 maxOpenTabsContext = 5 maxWorkspaceFiles = 100 maxDiagnosticMessages = 20 } } Save-Text (Get-RooImportFile) (ConvertTo-NexusJson $cfg 0 ' ' "`n") } function Apply-EditorRoo($Editor) { Write-RooImport if (Test-Extension $Editor 'rooveterinaryinc.roo-cline') { Ok "$($Editor.Name): Roo Code уже установлен" } else { Step "$($Editor.Name): устанавливаю Roo Code (до 1-2 минут)..." if (Install-Extension $Editor 'RooVeterinaryInc.roo-cline') { Ok "$($Editor.Name): Roo Code установлен" } else { Warn "$($Editor.Name): не удалось поставить Roo Code автоматически. Поставьте вручную: Ctrl+Shift+X -> Roo Code -> Install. Настройки уже готовы и подхватятся сами." $script:Notes.Add("$($Editor.Name): установите расширение Roo Code (Ctrl+Shift+X -> Roo Code) - ключ подключится сам.") } } Update-JsonFile (Join-Path $Editor.User 'settings.json') ([ordered]@{ 'roo-cline.autoImportSettingsPath' = (Get-RooImportFile) }) Ok "$($Editor.Name): Roo Code подключён к NEXUS_AI (профиль NEXUS_AI, модель $($script:Model))" $script:Next.Add("$($Editor.Name): перезапустите редактор -> слева иконка Roo Code (кенгуру) -> пишите задачу.") } function Apply-EditorClaudeExt($Editor) { $path = Join-Path $Editor.User 'settings.json' $cur = Read-JsonFile $path $list = New-Object System.Collections.Generic.List[object] $ours = @('ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY') $existing = Get-Prop $cur 'claudeCode.environmentVariables' if ($existing) { foreach ($e in @($existing)) { if ($ours -notcontains [string](Get-Prop $e 'name')) { $list.Add($e) } } } $list.Add([ordered]@{ name = 'ANTHROPIC_BASE_URL'; value = $Site }) $list.Add([ordered]@{ name = 'ANTHROPIC_AUTH_TOKEN'; value = $script:Key }) $list.Add([ordered]@{ name = 'ANTHROPIC_MODEL'; value = $script:Model }) $list.Add([ordered]@{ name = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'; value = '1' }) Update-JsonFile $path ([ordered]@{ 'claudeCode.environmentVariables' = $list.ToArray(); 'claudeCode.disableLoginPrompt' = $true }) Ok "$($Editor.Name): расширение Claude Code подключено" } function Apply-ClaudeCode { $path = Join-Path $UserHome '.claude\settings.json' $cur = Read-JsonFile $path $envObj = Get-ObjProp $cur 'env' if ($envObj.PSObject.Properties['ANTHROPIC_API_KEY']) { $envObj.PSObject.Properties.Remove('ANTHROPIC_API_KEY') } Set-Prop $envObj 'ANTHROPIC_BASE_URL' $Site Set-Prop $envObj 'ANTHROPIC_AUTH_TOKEN' $script:Key Set-Prop $envObj 'ANTHROPIC_MODEL' $script:Model Set-Prop $envObj 'ANTHROPIC_DEFAULT_HAIKU_MODEL' $script:FastModel Set-Prop $envObj 'ANTHROPIC_SMALL_FAST_MODEL' $script:FastModel Set-Prop $envObj 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY' '1' Update-JsonFile $path ([ordered]@{ env = $envObj }) # первый запуск без экрана входа в аккаунт Anthropic $state = Join-Path $UserHome '.claude.json' if (-not (Test-Path -LiteralPath $state)) { Save-Text $state "{`n `"hasCompletedOnboarding`": true`n}`n" } else { $t = [IO.File]::ReadAllText($state) if ($t -match '"hasCompletedOnboarding"\s*:\s*false') { Save-Text $state ([regex]::Replace($t, '"hasCompletedOnboarding"\s*:\s*false', '"hasCompletedOnboarding": true', 1)) } elseif ($t -notmatch '"hasCompletedOnboarding"') { $i = $t.IndexOf('{') if ($i -ge 0) { Save-Text $state ($t.Substring(0, $i + 1) + "`n `"hasCompletedOnboarding`": true," + $t.Substring($i + 1)) } } } # старые постоянные переменные Windows перекрывают settings.json - обновляем их foreach ($pair in @(@('ANTHROPIC_BASE_URL', $Site), @('ANTHROPIC_AUTH_TOKEN', $script:Key), @('ANTHROPIC_MODEL', $script:Model))) { if ($null -ne (Get-UserEnv $pair[0])) { Set-UserEnv $pair[0] $pair[1]; Ok "обновлена переменная Windows $($pair[0])" } } $apiKey = Get-UserEnv 'ANTHROPIC_API_KEY' if ($apiKey) { Warn 'В Windows задана переменная ANTHROPIC_API_KEY (другой ключ). Если Claude Code спросит, использовать ли её, ответьте No.' } Ok "Claude Code подключён (модель $($script:Model), фоновая $($script:FastModel))" $script:Next.Add('Claude Code: откройте НОВОЕ окно терминала в папке проекта -> claude') } function Apply-Codex { $dir = if ($env:CODEX_HOME -and -not $TestRoot) { $env:CODEX_HOME } else { Join-Path $UserHome '.codex' } $path = Join-Path $dir 'config.toml' $lines = @() if (Test-Path -LiteralPath $path) { $lines = @([IO.File]::ReadAllLines($path)) } $top = New-Object System.Collections.Generic.List[string] $rest = New-Object System.Collections.Generic.List[string] $inTables = $false; $skip = $false foreach ($l in $lines) { if ($l -match '^\s*\[') { $inTables = $true $skip = ($l -match '^\s*\[\s*model_providers\s*\.\s*"?tokine"?\s*(\]|\.)') } if (-not $inTables) { if ($l -match '^\s*(model|model_provider)\s*=') { continue } $top.Add($l) } elseif (-not $skip) { $rest.Add($l) } } while ($top.Count -gt 0 -and -not $top[0].Trim()) { $top.RemoveAt(0) } while ($top.Count -gt 0 -and -not $top[$top.Count - 1].Trim()) { $top.RemoveAt($top.Count - 1) } while ($rest.Count -gt 0 -and -not $rest[$rest.Count - 1].Trim()) { $rest.RemoveAt($rest.Count - 1) } $out = New-Object System.Collections.Generic.List[string] $out.Add("model = `"$($script:Model)`"") $out.Add('model_provider = "tokine"') foreach ($l in $top) { $out.Add($l) } if ($rest.Count -gt 0) { $out.Add(''); foreach ($l in $rest) { $out.Add($l) } } $out.Add('') $out.Add('[model_providers.tokine]') $out.Add('name = "NEXUS_AI Gateway"') $out.Add("base_url = `"$OpenAiBase`"") $out.Add('wire_api = "responses"') $out.Add("experimental_bearer_token = `"$($script:Key)`"") Save-Text $path (($out -join "`n") + "`n") Ok "Codex CLI подключён (модель $($script:Model))" $script:Next.Add('Codex: откройте НОВОЕ окно терминала в папке проекта -> codex') } function Get-ModelEntries { $ids = New-Object System.Collections.Generic.List[string] $ids.Add($script:Model) foreach ($c in $Curated) { if (-not $ids.Contains($c.Id) -and ($null -eq $script:Catalog -or $script:Catalog.ContainsKey($c.Id))) { $ids.Add($c.Id) } } return , $ids } function Apply-Qwen { $path = Join-Path $UserHome '.qwen\settings.json' $cur = Read-JsonFile $path $providers = Get-ObjProp $cur 'modelProviders' $list = New-Object System.Collections.Generic.List[object] $existing = Get-Prop $providers 'openai' if ($existing) { foreach ($e in @($existing)) { if ([string](Get-Prop $e 'baseUrl') -notlike "$Site*") { $list.Add($e) } } } foreach ($id in (Get-ModelEntries)) { $list.Add([ordered]@{ id = $id; name = "$id (NEXUS_AI)"; baseUrl = $OpenAiBase; envKey = 'NEXUS_API_KEY' }) } Set-Prop $providers 'openai' $list.ToArray() $envObj = Get-ObjProp $cur 'env' Set-Prop $envObj 'NEXUS_API_KEY' $script:Key $security = Get-ObjProp $cur 'security' $auth = Get-ObjProp $security 'auth' Set-Prop $auth 'selectedType' 'openai' Set-Prop $security 'auth' $auth $modelObj = Get-ObjProp $cur 'model' Set-Prop $modelObj 'name' $script:Model Update-JsonFile $path ([ordered]@{ modelProviders = $providers; env = $envObj; security = $security; model = $modelObj }) Ok "Qwen Code подключён (модель $($script:Model))" $script:Next.Add('Qwen Code: откройте НОВОЕ окно терминала -> qwen') } function Get-OpenCodeProvider { $models = [ordered]@{} foreach ($id in (Get-ModelEntries)) { $models[$id] = [ordered]@{ name = $id } } return [ordered]@{ npm = '@ai-sdk/openai-compatible'; name = 'NEXUS_AI'; options = [ordered]@{ baseURL = $OpenAiBase; apiKey = $script:Key }; models = $models } } function Apply-OpenCodeLike([string]$Path, [string]$Title) { $cur = Read-JsonFile $Path $values = [ordered]@{} if (-not (Get-Prop $cur '$schema') -and $Title -eq 'OpenCode') { $values['$schema'] = 'https://opencode.ai/config.json' } $prov = Get-ObjProp $cur 'provider' Set-Prop $prov 'nexus' (Get-OpenCodeProvider) $values['provider'] = $prov $values['model'] = "nexus/$($script:Model)" if (-not (Get-Prop $cur 'small_model')) { $values['small_model'] = "nexus/$($script:FastModel)" } Update-JsonFile $Path $values Ok "$Title подключён (модель nexus/$($script:Model))" } function Apply-OpenCode { Apply-OpenCodeLike (Join-Path $UserHome '.config\opencode\opencode.json') 'OpenCode' $script:Next.Add('OpenCode: в папке проекта -> opencode (модели в /models, провайдер NEXUS_AI)') } function Apply-Kilo { Apply-OpenCodeLike (Join-Path $UserHome '.config\kilo\kilo.json') 'Kilo Code' $script:Next.Add('Kilo Code: перезапустите редактор - в списке моделей появится провайдер NEXUS_AI') } function Install-NpmTool([string]$Package, [string]$Title) { $npm = Find-Cli @('npm.cmd') if (-not $npm) { Fail "${Title}: нужен Node.js - сначала установите его (пункт Node.js в меню)"; return $false } if ($TestRoot) { return $true } Step "${Title}: устанавливаю $Package (до 2 минут)..." $r = Invoke-Tool $npm @('install', '-g', $Package) 420 if ($r.Code -ne 0) { Fail "${Title}: npm не смог установить пакет. $($r.Text.Split("`n") | Select-Object -Last 3)"; return $false } Ok "$Title установлен" return $true } function Install-Winget([string]$Id, [string]$Title) { if ($TestRoot) { return $true } $wg = Find-Cli @('winget.exe') if (-not $wg) { Fail "${Title}: не найден winget. Скачайте вручную: $(if ($Id -like '*Node*') { 'https://nodejs.org' } else { 'https://git-scm.com/download/win' })"; return $false } Step "${Title}: устанавливаю через winget (Windows может попросить подтверждение)..." $r = Invoke-Tool $wg @('install', '-e', '--id', $Id, '--silent', '--accept-source-agreements', '--accept-package-agreements') 900 Refresh-Path if ($r.Code -ne 0 -and $r.Text -notmatch 'already installed|уже установ') { Fail "${Title}: winget вернул ошибку ($($r.Code))."; return $false } Ok "$Title установлен" return $true } function Get-EffectivePolicy { try { $list = Get-ExecutionPolicy -List foreach ($scope in @('MachinePolicy', 'UserPolicy', 'CurrentUser', 'LocalMachine')) { $p = ($list | Where-Object { [string]$_.Scope -eq $scope }).ExecutionPolicy if ($p -and [string]$p -ne 'Undefined') { return @{ Scope = $scope; Policy = [string]$p } } } } catch {} return @{ Scope = 'Default'; Policy = 'Restricted' } } function Apply-ExecutionPolicy { if ($TestRoot) { Ok 'PowerShell: запуск npm-команд разрешён (тест)'; return } try { Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop Ok 'PowerShell: разрешён запуск claude / codex и других npm-команд (RemoteSigned для вашего пользователя)' } catch { Warn "PowerShell: не удалось изменить политику запуска ($($_.Exception.Message))" } } function Clear-KeyTraces { if ($TestRoot) { return } try { $mru = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU' if (Test-Path $mru) { $props = Get-ItemProperty $mru $order = [string]$props.MRUList foreach ($p in $props.PSObject.Properties) { if ($p.Name.Length -eq 1 -and [string]$p.Value -like '*NEXUS_KEY*') { Remove-ItemProperty -Path $mru -Name $p.Name -ErrorAction SilentlyContinue $order = $order.Replace($p.Name, '') } } Set-ItemProperty -Path $mru -Name MRUList -Value $order -ErrorAction SilentlyContinue } } catch {} try { $hist = Join-Path $AppData 'Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt' if (Test-Path $hist) { $all = Get-Content $hist $keep = @($all | Where-Object { $_ -notmatch 'NEXUS_KEY' }) if ($keep.Count -ne @($all).Count) { Set-Content -Path $hist -Value $keep -Encoding UTF8 } } } catch {} } # ── главный сценарий ────────────────────────────────────────────────────────── function Show-Banner { Say '' Say ' ==========================================================' DarkYellow Say ' NEXUS_AI - подключение программ в 1 клик' White Say ' ==========================================================' DarkYellow Say " Шлюз: $Site" DarkGray Say '' } function Read-ValidKey { $k = [string]$env:NEXUS_KEY $k = $k.Trim() for ($try = 0; $try -lt 4; $try++) { if (-not $k) { if ($Auto) { Fail 'Ключ не передан (NEXUS_KEY).'; return $null } $k = (Read-Host ' Вставьте ваш ключ sk-... (правый клик или Ctrl+V) и нажмите Enter').Trim() } if ($k -notmatch '^sk-[A-Za-z0-9_\-]{16,}$') { Fail 'Это не похоже на ключ NEXUS_AI (он начинается с sk-).'; $k = ''; continue } $state = Test-Key $k if ($state -eq 'bad') { Fail 'Ключ не найден или отключён. Проверьте, что скопировали его целиком.'; $k = ''; if ($Auto) { return $null }; continue } return $k } return $null } function Choose-Model { $script:Catalog = Get-Catalog $options = New-Object System.Collections.Generic.List[object] foreach ($c in $Curated) { if ($null -eq $script:Catalog -or $script:Catalog.ContainsKey($c.Id)) { $mult = if ($script:Catalog) { $script:Catalog[$c.Id] } else { $null } $options.Add([pscustomobject]@{ Id = $c.Id; Note = $c.Note; Mult = $mult }) } } $want = [string]$env:NEXUS_MODEL if ($want) { $script:Model = $want } else { Say ' Модель по умолчанию (её можно менять в любой момент в самой программе):' White for ($i = 0; $i -lt $options.Count; $i++) { $o = $options[$i] $m = if ($null -ne $o.Mult) { ('x{0}' -f ([double]$o.Mult).ToString('0.0#', [Globalization.CultureInfo]::InvariantCulture)) } else { '' } Say (' {0}) {1,-22} {2,-6} {3}' -f ($i + 1), $o.Id, $m, $o.Note) } Say ' xN - коэффициент списания: сколько токенов баланса уходит на 1 токен диалога.' DarkGray $a = Ask ' Номер модели [Enter = 1]' '1' $n = 0 if ([int]::TryParse($a, [ref]$n) -and $n -ge 1 -and $n -le $options.Count) { $script:Model = $options[$n - 1].Id } elseif ($a -match '^[a-z0-9][a-z0-9.\-:/]+$') { $script:Model = $a } else { $script:Model = $options[0].Id } } $script:FastModel = $script:Model foreach ($f in $FastCandidates) { if ($null -eq $script:Catalog -or $script:Catalog.ContainsKey($f)) { $script:FastModel = $f; break } } Ok "Модель: $($script:Model)" } function Build-Plan { $plan = New-Object System.Collections.Generic.List[object] $add = { param($Label, $Selected, $Action, $Arg) $plan.Add([pscustomobject]@{ Label = $Label; Selected = [bool]$Selected; Action = $Action; Arg = $Arg }) } if (@($Editors | Where-Object { $_.Found }).Count -gt 0) { & $add 'Экономный режим Roo Code: сжатие контекста на 60% и без лишних данных в запросах (тратит меньше токенов)' $true 'economy' $null } foreach ($e in $Editors) { if (-not $e.Found) { continue } & $add "$($e.Name): агент Roo Code (как Cline) - установить и подключить" $true 'roo' $e if (Test-Extension $e 'anthropic.claude-code') { & $add "$($e.Name): расширение Claude Code - подключить" $true 'claude-ext' $e } if (Test-Extension $e 'kilocode.kilo-code') { $script:HasKilo = $true } if (Test-Extension $e 'saoudrizwan.claude-dev') { $script:ClineIn.Add($e.Name) } } $node = Find-Cli @('node.exe', 'node') $git = Find-Cli @('git.exe', 'git') $claude = Find-Cli @('claude.cmd', 'claude.exe', 'claude') if (-not $claude -and -not $TestRoot) { $p = Join-Path $UserHome '.local\bin\claude.exe'; if (Test-Path $p) { $claude = $p } } $codex = Find-Cli @('codex.cmd', 'codex.exe', 'codex') $qwen = Find-Cli @('qwen.cmd', 'qwen') $oc = Find-Cli @('opencode.cmd', 'opencode.exe', 'opencode') $kilo = Find-Cli @('kilo.cmd', 'kilo.exe', 'kilo') if ($claude) { & $add 'Claude Code (терминал) - подключить' $true 'claude' $null } else { & $add 'Claude Code (терминал) - установить и подключить' $false 'claude-install' $null } if ($codex) { & $add 'Codex CLI - подключить' $true 'codex' $null } else { & $add 'Codex CLI - установить и подключить' $false 'codex-install' $null } if ($qwen) { & $add 'Qwen Code - подключить' $true 'qwen' $null } else { & $add 'Qwen Code - установить и подключить' $false 'qwen-install' $null } if ($oc -or (Test-Path (Join-Path $UserHome '.config\opencode'))) { & $add 'OpenCode - подключить' $true 'opencode' $null } else { & $add 'OpenCode - установить и подключить' $false 'opencode-install' $null } if ($kilo -or $script:HasKilo -or (Test-Path (Join-Path $UserHome '.config\kilo'))) { & $add 'Kilo Code - подключить' $true 'kilo' $null } if (-not $node) { & $add 'Node.js LTS - установить (нужен для установки CLI выше)' $false 'node' $null } if (-not $git) { & $add 'Git для Windows - установить (нужен Claude Code)' $false 'git' $null } $pol = Get-EffectivePolicy if (@('Restricted', 'AllSigned') -contains $pol.Policy -and @('MachinePolicy', 'UserPolicy') -notcontains $pol.Scope) { & $add 'PowerShell: разрешить запуск claude/codex (иначе ошибка "running scripts is disabled")' $true 'policy' $null } $script:Detected = @{ Node = $node; Git = $git; Claude = $claude; Codex = $codex; Qwen = $qwen; OpenCode = $oc } return , $plan } function Show-Detected { $found = @(); $missing = @() foreach ($e in $Editors) { if ($e.Found) { $found += $e.Name } else { $missing += $e.Name } } foreach ($k in @('Claude', 'Codex', 'Qwen', 'OpenCode', 'Node', 'Git')) { $title = @{ Claude = 'Claude Code'; Codex = 'Codex CLI'; Qwen = 'Qwen Code'; OpenCode = 'OpenCode'; Node = 'Node.js'; Git = 'Git' }[$k] if ($script:Detected[$k]) { $found += $title } else { $missing += $title } } Say (' Найдено: ' + $(if ($found.Count) { $found -join ', ' } else { '-' })) Green Say (' Не найдено: ' + $(if ($missing.Count) { $missing -join ', ' } else { '-' })) DarkGray Say '' } function Choose-Items($Plan) { if ($Auto) { return } while ($true) { Say ' Что настроить:' White for ($i = 0; $i -lt $Plan.Count; $i++) { $mark = if ($Plan[$i].Selected) { '[x]' } else { '[ ]' } $color = if ($Plan[$i].Selected) { 'White' } else { 'DarkGray' } Say (' {0} {1,2}. {2}' -f $mark, ($i + 1), $Plan[$i].Label) $color } Say ' Enter - выполнить отмеченное | номера через пробел - отметить/снять | A - всё | R - откатить прошлую установку | Q - выход' DarkGray $a = Read-Host ' >' if ($null -eq $a) { $a = '' } $a = $a.Trim().ToLower() if ($a -eq '') { return } if ($a -eq 'q' -or $a -eq '0' -or $a -eq 'й') { $script:Quit = $true; return } if ($a -eq 'r' -or $a -eq 'к') { $script:DoRestore = $true; return } if ($a -eq 'a' -or $a -eq 'ф') { foreach ($p in $Plan) { $p.Selected = $true }; continue } foreach ($tok in ($a -split '[\s,;]+')) { $n = 0 if ([int]::TryParse($tok, [ref]$n) -and $n -ge 1 -and $n -le $Plan.Count) { $Plan[$n - 1].Selected = -not $Plan[$n - 1].Selected } } Say '' } } function Run-Plan($Plan) { $sel = @($Plan | Where-Object { $_.Selected }) $actions = @($sel | ForEach-Object { $_.Action }) $script:Economy = $actions -contains 'economy' $needNode = (@($actions | Where-Object { $_ -like '*-install' }).Count -gt 0) -and -not $script:Detected.Node if ($needNode -and $actions -notcontains 'node') { $nodeItem = $Plan | Where-Object { $_.Action -eq 'node' } | Select-Object -First 1 if ($nodeItem) { $nodeItem.Selected = $true; $sel = @($Plan | Where-Object { $_.Selected }) } } $sel = @($sel | Where-Object { $_.Action -ne 'economy' }) # это флаг для пунктов Roo Code, а не отдельное действие # порядок: сначала системные зависимости, потом установки, потом настройка $order = @('policy', 'node', 'git', 'claude-install', 'codex-install', 'qwen-install', 'opencode-install', 'roo', 'claude-ext', 'claude', 'codex', 'qwen', 'opencode', 'kilo') $sel = @($sel | Sort-Object { [array]::IndexOf($order, $_.Action) }) foreach ($item in $sel) { try { switch ($item.Action) { 'policy' { Apply-ExecutionPolicy } 'node' { if (Install-Winget 'OpenJS.NodeJS.LTS' 'Node.js') { $script:Detected.Node = 'installed' } } 'git' { $null = Install-Winget 'Git.Git' 'Git' } 'claude-install' { if (Install-NpmTool '@anthropic-ai/claude-code' 'Claude Code') { Apply-ClaudeCode } } 'codex-install' { if (Install-NpmTool '@openai/codex' 'Codex CLI') { Apply-Codex } } 'qwen-install' { if (Install-NpmTool '@qwen-code/qwen-code' 'Qwen Code') { Apply-Qwen } } 'opencode-install' { if (Install-NpmTool 'opencode-ai' 'OpenCode') { Apply-OpenCode } } 'roo' { Apply-EditorRoo $item.Arg } 'claude-ext' { Apply-EditorClaudeExt $item.Arg } 'claude' { Apply-ClaudeCode } 'codex' { Apply-Codex } 'qwen' { Apply-Qwen } 'opencode' { Apply-OpenCode } 'kilo' { Apply-Kilo } } Log "done: $($item.Action)" } catch { Fail ("{0}: {1}" -f $item.Label, $_.Exception.Message) Log "failed: $($item.Action): $($_.Exception.Message)" $global:NexusExit = 1 } } return $sel.Count } function Main { Show-Banner if ($env:NEXUS_ACTION -eq 'restore') { Invoke-Restore; return } $script:Key = Read-ValidKey if (-not $script:Key) { Fail 'Без действующего ключа настроить нельзя. Ключ приходит в чат заказа FunPay.'; $global:NexusExit = 2; return } Say '' Choose-Model Say '' $script:Next = New-Object System.Collections.Generic.List[string] $script:Notes = New-Object System.Collections.Generic.List[string] $script:ClineIn = New-Object System.Collections.Generic.List[string] $script:HasKilo = $false Detect-Editors $plan = Build-Plan Show-Detected Choose-Items $plan if ($script:Quit) { Say ' Выход без изменений.'; $global:NexusExit = 3; return } if ($script:DoRestore) { Invoke-Restore; return } Say '' $n = Run-Plan $plan Clear-KeyTraces Say '' if ($n -eq 0) { Warn 'Ничего не выбрано - изменений нет.'; $global:NexusExit = 3; return } if ($script:ClineIn.Count -gt 0) { try { if (-not $TestRoot) { Set-Clipboard -Value $script:Key } } catch {} $script:Notes.Add("Cline ($($script:ClineIn -join ', ')) хранит ключ в защищённом хранилище редактора - его нельзя заполнить снаружи. Ключ скопирован в буфер: Cline -> шестерёнка -> OpenAI Compatible, Base URL $OpenAiBase, вставьте ключ. Или пользуйтесь Roo Code - он уже настроен.") } Say ' ==========================================================' DarkYellow if ($global:NexusExit -eq 0) { Say ' Готово! Что дальше:' Green } else { Say ' Готово с ошибками (см. [X] выше). Что дальше:' Yellow } foreach ($x in $script:Next) { Say " * $x" } foreach ($x in $script:Notes) { Say " ! $x" Yellow } Say " Баланс и расход: $Site/stats.html" DarkGray if (Test-Path $RunDir) { Say " Копии изменённых файлов: $RunDir (откат - запустите ещё раз и введите R)" DarkGray } Say ' ==========================================================' DarkYellow Log ("installed: model={0} items={1} exit={2}" -f $script:Model, $n, $global:NexusExit) } try { Main } catch { Fail $_.Exception.Message; Log "fatal: $($_.Exception.Message)"; $global:NexusExit = 1 } if (-not $env:NEXUS_BAT -and -not $Auto) { Say ''; $null = Read-Host ' Нажмите Enter, чтобы закрыть окно' }