# ----------------------------------------------------------------------------- # Grace installer for Windows 10/11 (x64 and ARM64) https://parsimic.com/download # # irm https://parsimic.com/install.ps1 | iex (in PowerShell, not as Administrator) # # What it does - it asks before it installs anything: # 1. Checks this computer: Windows, 64-bit, Claude Code. # 2. Signs this computer in to your Grace account: it shows a code, you confirm it at # parsimic.com/geraet. The sign-in is kept encrypted for your Windows account (DPAPI), # so this happens once. A sign-in belongs to this computer: the installer sends a # one-way ID (a SHA-256 of the sign-in and the Windows MachineGuid, never the GUID itself). # 3. Checks that the account has an active Grace subscription or a free month (one free # month per computer - for that it also sends a SHA-256 of the MachineGuid alone). # Without either it stops here and downloads nothing. # 4. Offers to install what Grace needs: Node.js and, if it is missing, the Microsoft # Visual C++ runtime, using winget. # 5. Downloads Grace, verifies its size and SHA-256, registers it with Claude Code as # the plugin "grace", and checks that Grace sees the sign-in. # 6. Asks where your Claude projects are (optional), so Grace builds new ones there. # # Run the same command again at any time to update Grace. The installer never uninstalls # or deletes anything of yours, never asks for Administrator rights and never prints your # device key. Windows counterpart of install.sh (same server routes, same steps). # # Deutsch: Installiert Grace fuer Claude Code unter Windows. Fragt vor jeder Installation. # Ohne aktives Abo oder Gratistag wird nichts geladen. Erneut ausfuehren = aktualisieren. # # ASCII only on purpose: Windows PowerShell 5.1 decodes a download without charset as ANSI. # ----------------------------------------------------------------------------- & { Set-StrictMode -Version 2.0 # 'Continue', not 'Stop': in Windows PowerShell 5.1 'Stop' turns every stderr line of a native program (claude, node, # tar, winget) into a terminating error. Failures are checked explicitly; Stirb throws in any case. $ErrorActionPreference = 'Continue' $ProgressPreference = 'SilentlyContinue' # -- Fixed settings ----------------------------------------------------------- $GRACE_SEITE_FEST = 'https://parsimic.com' $INSTALLER_FASSUNG = 'Grace installer 3 (windows)' $PLATTFORM = 'win32' # Must match the plugin (WebAI-dev packages/security/src/plattform-ablage.ts, DpapiSecretStore): # file \grace\WebAI-grace.token.dpapi = "dpapi1:" + base64(DPAPI(CurrentUser, entropy "WebAI:grace.token")). $KC_DIENST = 'WebAI:grace.token' $CLAUDE_INSTALL_SEITE = 'https://claude.com/claude-code' # Must match src/lib/gratistag.ts -> GRATISTAG_TAGE / ENTFERNEN_NACH_STUNDEN. $GRATISTAG_TAGE = 30 $ENTFERNEN_NACH_STUNDEN = 24 $S = @{ NodeMin = '22.13' Seite = $GRACE_SEITE_FEST; Test = $false; M = ''; Mac = ''; TestMac = ''; Gratistag = $false; GratistagBis = '' Konto = ''; Ziel = ''; ClaudeBin = ''; Installiert = ''; DownloadPflicht = $false; Version = ''; Groesse = 0; Sha = '' NodeOk = $false; NeuesFenster = $false; MpAktion = 'add'; PlDa = $false; PlAn = ''; PlPfad = ''; Tmp = '' LaufzeitOk = $false; VcBefehl = '' } # -- Output ------------------------------------------------------------------- function Sag([string]$t) { Write-Host $t } function Schritt([string]$t) { Write-Host ''; Write-Host "==> $t" -ForegroundColor White } function Ok([string]$t) { Write-Host " OK $t" -ForegroundColor Green } function Warn([string]$t) { Write-Host " ! $t" -ForegroundColor Yellow } function Leise([string]$t) { Write-Host " $t" -ForegroundColor DarkGray } # Stops the installer. Inside "irm | iex" an "exit" would close the PowerShell window - so it throws instead. function Stirb { # All arguments are lines: Stirb 'what happened' 'what to do' 'more'. $e = New-Object System.Exception(([string]::Join("`n", [string[]]@($args)))) $e.Data['grace'] = $true throw $e } # Text from the server, without control characters. function Sauber($t) { if ($null -eq $t) { return '' } ; return ([string]$t -replace '[\x00-\x08\x0B-\x1F\x7F]', '') } function Frage([string]$text, [string]$vorgabe) { $hinweis = if ($vorgabe -eq 'Y') { '[Y/n]' } else { '[y/N]' } for ($i = 0; $i -lt 3; $i++) { $a = Read-Host "$text $hinweis" if ($null -eq $a) { return $false } switch -Regex ($a.Trim()) { '^(y|yes|j|ja)$' { return $true } '^(n|no|nein)$' { return $false } '^$' { return ($vorgabe -eq 'Y') } } Sag ' Please answer y or n.' } return $false } # -- Test mode (Grace team only; talks to a local test server) ---------------- function Seite-Festlegen { if ($env:GRACE_INSTALLER_TEST -eq '1') { if ($env:GRACE_TEST_SEITE -notmatch '^http://(127\.0\.0\.1|localhost):[0-9]+$') { Stirb 'GRACE_INSTALLER_TEST=1 needs GRACE_TEST_SEITE=http://127.0.0.1:.' } $S.Seite = $env:GRACE_TEST_SEITE $S.Test = $true Write-Host "!!! TEST MODE - talking to $($S.Seite), not parsimic.com. Never use this for a real install. !!!" -ForegroundColor Red } } # -- HTTP + JSON ---------------------------------------------------------------- Add-Type -AssemblyName System.Net.Http Add-Type -AssemblyName System.Security # Windows PowerShell 5.1 (.NET Framework) may still default to TLS 1.0. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } $HTTP = New-Object System.Net.Http.HttpClient $HTTP.Timeout = [TimeSpan]::FromSeconds(900) $HTTP.DefaultRequestHeaders.UserAgent.ParseAdd('Grace-installer/3') # POST -> @{ Code; Body; Kopf } (JSON body; the device key never appears on a command line). function Post-Json([string]$pfad, [hashtable]$rumpf, [string]$datei = '') { $inhalt = New-Object System.Net.Http.StringContent(($rumpf | ConvertTo-Json -Compress), [Text.Encoding]::UTF8, 'application/json') try { $r = $HTTP.PostAsync("$($S.Seite)$pfad", $inhalt).GetAwaiter().GetResult() } catch { return $null } $code = [int]$r.StatusCode $kopf = '' $vals = $null if ($r.Headers.TryGetValues('x-grace-gratistag-bis', [ref]$vals)) { $kopf = [string]($vals | Select-Object -First 1) } if ($datei -ne '' -and $code -eq 200) { $fs = [IO.File]::Create($datei) # [void]: CopyToAsync returns Task at run time - without [void] its result ended up in the return # value (Post-Json returned an array and $r.Code failed under StrictMode; Windows test 05.10.2026). try { [void]$r.Content.CopyToAsync($fs).GetAwaiter().GetResult() } finally { $fs.Dispose() } return @{ Code = $code; Body = ''; Kopf = $kopf } } $text = $r.Content.ReadAsStringAsync().GetAwaiter().GetResult() return @{ Code = $code; Body = $text; Kopf = $kopf } } function Json($text) { try { return ($text | ConvertFrom-Json) } catch { return $null } } function Wert($obj, [string]$name) { if ($null -eq $obj) { return $null } $p = $obj.PSObject.Properties[$name] if ($null -eq $p) { return $null } return $p.Value } function Fehlertext($j) { $f = Sauber (Wert $j 'fehler'); if ($f -ne '') { return " - $f" } ; return '' } function Sicherer-Link($l) { $l = Sauber $l; if ($l.StartsWith("$($S.Seite)/")) { return $l } ; return "$($S.Seite)/pricing" } function Ist-Merkmal($w) { return ([string]$w -cmatch '^[0-9a-f]{64}$') } function Sha256Hex([string]$t) { $h = [Security.Cryptography.SHA256]::Create() try { return (-join ($h.ComputeHash([Text.Encoding]::UTF8.GetBytes($t)) | ForEach-Object { $_.ToString('x2') })) } finally { $h.Dispose() } } # -- Sign-in storage (DPAPI, same file and format as the plugin) ---------------- function Konfig { if ($env:CLAUDE_CONFIG_DIR) { return $env:CLAUDE_CONFIG_DIR } ; return (Join-Path $env:USERPROFILE '.claude') } function Ablage-Datei { return (Join-Path (Join-Path (Konfig) 'grace') 'WebAI-grace.token.dpapi') } function Kc-Lesen { $d = Ablage-Datei if (-not (Test-Path -LiteralPath $d)) { return '' } try { $roh = ([IO.File]::ReadAllText($d)).Trim() if (-not $roh.StartsWith('dpapi1:')) { return '' } $b = [Convert]::FromBase64String($roh.Substring(7)) $e = [Text.Encoding]::UTF8.GetBytes($KC_DIENST) $p = [Security.Cryptography.ProtectedData]::Unprotect($b, $e, [Security.Cryptography.DataProtectionScope]::CurrentUser) return [Text.Encoding]::UTF8.GetString($p) } catch { return '' } } function Kc-Schreiben([string]$wert) { $d = Ablage-Datei $ordner = Split-Path -Parent $d if (-not (Test-Path -LiteralPath $ordner)) { New-Item -ItemType Directory -Path $ordner -Force -ErrorAction Stop | Out-Null } $e = [Text.Encoding]::UTF8.GetBytes($KC_DIENST) $b = [Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($wert), $e, [Security.Cryptography.DataProtectionScope]::CurrentUser) $neu = "$d.neu" [IO.File]::WriteAllText($neu, "dpapi1:$([Convert]::ToBase64String($b))`n", (New-Object Text.UTF8Encoding($false))) Move-Item -LiteralPath $neu -Destination $d -Force -ErrorAction Stop } # The computer's one-way ID for the device key M: sha256("grace-geraet-v1:windows::"). # Same formula as the plugin (WebAI-dev apps/cli/src/gehirn/plattform.ts). 64-bit registry view on purpose. function Mac-Kennung { $guid = '' try { $k = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64) $guid = [string]$k.OpenSubKey('SOFTWARE\Microsoft\Cryptography').GetValue('MachineGuid') } catch { $guid = '' } $guid = $guid.Trim().ToLowerInvariant() if ($guid -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { Stirb "Could not read this computer's ID (MachineGuid)." 'Please contact support.' } $S.Mac = Sha256Hex "grace-geraet-v1:windows:$($S.M):$guid" # Free month: one per computer, across accounts - not salted with the sign-in (never the GUID itself). $S.TestMac = Sha256Hex "grace-test-v1:windows:$guid" $guid = '' } function Ortszeit($iso) { $t = Sauber $iso try { return ([DateTime]::Parse($t, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal)).ToLocalTime().ToString('ddd dd MMM yyyy, HH:mm', [Globalization.CultureInfo]::InvariantCulture) } catch { return "$t UTC" } } # -- 1. This computer ----------------------------------------------------------- function Claude-Finden { $c = Get-Command claude -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $c) { $S.ClaudeBin = $c.Source; return $true } foreach ($p in @((Join-Path $env:USERPROFILE '.local\bin\claude.exe'), (Join-Path $env:APPDATA 'npm\claude.cmd'))) { if (Test-Path -LiteralPath $p) { $S.ClaudeBin = $p; return $true } } return $false } # Runs claude with the given arguments; returns @{ Code; Out }. function Claude-Aufruf([string[]]$args2) { $out = & $S.ClaudeBin @args2 2>&1 | Out-String return @{ Code = $LASTEXITCODE; Out = $out } } function Vorabpruefung { Schritt 'Checking this computer' $admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if ($admin) { Stirb 'Please do not run the installer as Administrator.' 'Open a normal PowerShell window (not "Run as administrator") and run the command again.' } $v = [Environment]::OSVersion.Version if ($v.Major -lt 10 -or ($v.Major -eq 10 -and $v.Build -lt 17763)) { Stirb 'Grace needs Windows 10 (version 1809) or newer.' } if (-not [Environment]::Is64BitOperatingSystem) { Stirb 'Grace needs 64-bit Windows (x64 or ARM64).' } $arch = $env:PROCESSOR_ARCHITECTURE if ($env:PROCESSOR_ARCHITEW6432) { $arch = $env:PROCESSOR_ARCHITEW6432 } # OSVersion reports Major 10 on Windows 11 too - Windows 11 is build 22000 and newer. $name = if ($v.Major -eq 10 -and $v.Build -ge 22000) { '11' } else { [string]$v.Major } Ok "Windows $name (build $($v.Build), $arch)" if (-not (Get-Command tar.exe -ErrorAction SilentlyContinue)) { Stirb "The Windows tool 'tar.exe' is missing (part of Windows 10 1803 and newer)." } if (-not (Claude-Finden)) { Stirb "Claude Code is not installed (the command 'claude' was not found)." "Install it first: $CLAUDE_INSTALL_SEITE" 'Then open a new PowerShell window and run this command again.' } $r = Claude-Aufruf @('plugin', 'marketplace', 'list', '--json') if ($r.Code -ne 0) { Stirb 'This Claude Code cannot install plugins yet.' 'Update it with: claude update' 'Then run this command again.' } Ok "Claude Code ($($S.ClaudeBin))" } # -- 2. Sign in (device code, confirmed in the browser) ---------------------------- function Geraet-Anmelden { Schritt 'Signing in this computer' $name = Sauber $env:COMPUTERNAME if ($name -eq '') { $name = 'Windows' } $name = ($name -replace '["\\<>]', '') if ($name.Length -gt 120) { $name = $name.Substring(0, 120) } $r = Post-Json '/api/geraet/start' @{ geraet = $name; fassung = $INSTALLER_FASSUNG } if ($null -eq $r) { Stirb "Could not reach $($S.Seite)." 'Check your internet connection, then run the command again.' } $j = Json $r.Body if ($r.Code -ne 200) { Stirb "The sign-in service answered with an error (HTTP $($r.Code))$(Fehlertext $j)." 'Please try again in a few minutes.' } $code = [string](Wert $j 'geraetecode'); $nutzer = [string](Wert $j 'nutzercode') $laeuft = 600; $abstand = 5 try { $laeuft = [int](Wert $j 'laeuftAbIn') } catch { } try { $abstand = [int](Wert $j 'abstand') } catch { } if (-not (Ist-Merkmal $code) -or $nutzer -cnotmatch '^[A-Z0-9]{4}-[A-Z0-9]{4}$') { Stirb 'The sign-in service sent an unexpected answer.' 'Please try again in a few minutes.' } if ($laeuft -le 0 -or $laeuft -gt 3600) { $laeuft = 600 } if ($abstand -lt 1 -or $abstand -gt 60) { $abstand = 5 } $link = "$($S.Seite)/geraet?code=$nutzer" Sag '' Sag ' Confirm this computer in your browser, signed in to your Grace account:' Sag '' Write-Host " $link" -ForegroundColor White Write-Host " Code: $nutzer" -ForegroundColor White Sag '' if (Frage ' Open the page in your browser now? (Only confirm a code that you see here.)' 'Y') { try { Start-Process $link | Out-Null } catch { Warn 'Could not open the browser. Please open the link above yourself.' } } Sag ' Waiting for you to confirm the code in the browser...' $ende = (Get-Date).AddSeconds($laeuft) $fehl = 0 while ($true) { Start-Sleep -Seconds $abstand if ((Get-Date) -gt $ende) { Stirb 'The code expired before it was confirmed.' 'Run the command again for a new code.' } $a = Post-Json '/api/geraet/abfrage' @{ geraetecode = $code } if ($null -eq $a -or ($a.Code -ne 200 -and $a.Code -ne 429)) { $fehl++ if ($fehl -ge 6) { Stirb "Lost the connection to $($S.Seite)." 'Check your internet connection, then run the command again.' } continue } if ($a.Code -eq 429) { Stirb 'Too many checks for this code.' 'Run the command again for a new code.' } $fehl = 0 $ja = Json $a.Body switch ([string](Wert $ja 'stand')) { 'fertig' { $S.M = [string](Wert $ja 'merkmal') if (-not (Ist-Merkmal $S.M)) { Stirb 'The sign-in service sent an unexpected answer.' 'Please run the command again.' } break } 'abgelehnt' { Stirb 'The sign-in was declined in the browser.' 'Nothing was changed. Run the command again if you want to sign in.' } 'abgelaufen' { Stirb 'The code expired or is not valid anymore.' 'Run the command again for a new code.' } default { } } if ($S.M -ne '') { break } } $code = '' try { Kc-Schreiben $S.M } catch { } if ((Kc-Lesen) -ne $S.M) { Stirb 'Could not save the sign-in for your Windows account.' 'Your computer is already confirmed; the next run will ask for a new code once.' } Ok 'This computer is signed in (kept encrypted for your Windows account).' } # -- 3. Subscription -------------------------------------------------------------- function Abo-Pruefen { $neu = $false $S.M = Kc-Lesen if ($S.M -ne '' -and -not (Ist-Merkmal $S.M)) { $S.M = '' } while ($true) { if ($S.M -eq '') { Geraet-Anmelden; $neu = $true } Mac-Kennung Schritt 'Checking your Grace subscription' $r = Post-Json '/api/download' @{ merkmal = $S.M; mac = $S.Mac; test_mac = $S.TestMac; fassung = $INSTALLER_FASSUNG; plattform = $PLATTFORM } if ($null -eq $r) { Stirb "Could not reach $($S.Seite)." 'Check your internet connection, then run the command again.' } $j = Json $r.Body switch ($r.Code) { 200 { } 400 { if (-not $neu -and (Wert $j 'anmelden') -eq $true) { Warn 'The saved sign-in is not valid anymore - signing in again.'; $S.M = ''; continue } Stirb "The Grace server did not accept this request$(Fehlertext $j)." "Copy the command from $GRACE_SEITE_FEST/download and run it again." } 429 { Stirb "Too many requests$(Fehlertext $j)." 'Wait a few minutes, then run the command again.' } 503 { Stirb "Grace downloads are not available right now$(Fehlertext $j)." 'Please try again later.' } default { Stirb "The Grace server answered with an error (HTTP $($r.Code))$(Fehlertext $j)." 'Please try again later.' } } if ($r.Code -eq 400) { continue } $status = [string](Wert $j 'status') $S.Konto = Sauber (Wert $j 'konto') if ($S.Konto -notmatch '^[A-Za-z0-9*@._-]*$') { $S.Konto = '' } if ((Wert $j 'aktiv') -eq $true) { break } if (-not $neu -and @('unbekannt', 'entzogen', 'geraet_abgelaufen', 'anderer_mac') -contains $status) { if ($status -eq 'anderer_mac') { Warn 'The saved sign-in belongs to another computer - signing in this computer separately.' } else { Warn "This computer's saved sign-in is not valid anymore - signing in again." } $S.M = '' continue } $satz = Sauber (Wert $j 'satz') $link = Sicherer-Link (Wert $j 'link') if ($satz -eq '') { $satz = 'This Grace account has no active subscription, so nothing was downloaded.' } Sag '' Write-Host " $satz" -ForegroundColor White if ($S.Konto -ne '') { Sag " Grace account: $($S.Konto)" } Sag '' Sag " $link" Sag '' if (-not $neu -and @('kein', 'abgelaufen', 'gekuendigt', 'test_vorbei', 'gratistag_anfordern', 'gratistag_mac_schon', 'gratistag_anderer_mac', 'gratistag_nicht_bereit') -contains $status) { if (Frage ' Is that the wrong account? Sign this computer in with a different Grace account?' 'N') { $S.M = ''; continue } } if ($status -eq 'gratistag_anfordern') { if (Frage ' Open the page to start your free month there with one click?' 'Y') { try { Start-Process $link | Out-Null } catch { Warn 'Could not open the browser. Please open the link above yourself.' } Read-Host ' Press Enter once your free month is started' | Out-Null continue } } Sag ' Nothing was downloaded or installed. This computer stays signed in: once this' Sag ' is sorted out, just run the same command again.' $S.M = '' Stirb 'Stopped.' } $paket = Wert $j 'paket' $S.Version = [string](Wert $paket 'version') $S.DownloadPflicht = ((Wert $j 'download_pflicht') -eq $true) $S.Gratistag = ($status -eq 'test' -or (Wert $j 'gratistag') -eq $true) $S.GratistagBis = if ($S.Gratistag) { Sauber (Wert $j 'bis') } else { '' } try { $S.Groesse = [long](Wert $paket 'groesse') } catch { $S.Groesse = 0 } $S.Sha = [string](Wert $paket 'sha256') $nm = [string](Wert $paket 'node') if ($S.Version -notmatch '^[0-9A-Za-z.-]+$') { Stirb 'The Grace server sent an unexpected answer (version).' 'Please try again later.' } if ($S.Groesse -le 0) { Stirb 'The Grace server sent an unexpected answer (size).' 'Please try again later.' } if ($S.Sha -cnotmatch '^[0-9a-f]{64}$') { Stirb 'The Grace server sent an unexpected answer (checksum).' 'Please try again later.' } if ($nm -match '^\d{1,3}(\.\d{1,3}){0,2}$' -and (Fassung-Ab $nm $S.NodeMin)) { $S.NodeMin = $nm } $wer = if ($S.Konto -ne '') { " ($($S.Konto))" } else { '' } if ($S.Gratistag) { Ok "Free month$wer - Grace $($S.Version) is available." if ($S.GratistagBis -eq '') { Sag " Your free month ($GRATISTAG_TAGE days) starts with this download. No payment details and no" Sag ' subscription: when it ends, Grace stops working and shows you how to subscribe.' } else { Sag " Your free month runs until $(Ortszeit $S.GratistagBis)." } } else { Ok "Subscription active$wer - Grace $($S.Version) is available." } if ($S.DownloadPflicht -and -not $S.Gratistag) { Sag ' This download activates Grace for your subscription: Grace works once it has been' Sag ' downloaded for this subscription, also where it is already installed.' } } # -- 4. Plan ---------------------------------------------------------------------- function Fassung-Ab([string]$a, [string]$b) { try { return ([version](($a.Split('.') + @('0', '0'))[0..2] -join '.') -ge [version](($b.Split('.') + @('0', '0'))[0..2] -join '.')) } catch { return $false } } function Node-Version { $n = Get-Command node -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $n) { return '' } try { return ([string](& $n.Source -p 'process.versions.node' 2>$null)).Trim() } catch { return '' } } function Node-Ok { $v = Node-Version if ($v -notmatch '^\d+\.\d+') { return $false } $h = [int]$v.Split('.')[0] $boden = if ($h -eq 22) { '22.13' } elseif ($h -eq 23) { '23.4' } else { '24' } return ((Fassung-Ab $v $boden) -and (Fassung-Ab $v $S.NodeMin)) } function Node-Verwalter { $n = Get-Command node -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $n) { return '' } $p = $n.Source if ($env:NVM_HOME -and $env:NVM_SYMLINK -and $p.StartsWith($env:NVM_SYMLINK, [StringComparison]::OrdinalIgnoreCase)) { return 'nvm-windows' } if ($p -match '\\Volta\\') { return 'volta' } if ($env:FNM_DIR -or $p -match 'fnm_multishells') { return 'fnm' } return '' } function Node-Verwalter-Befehl([string]$v) { switch ($v) { 'nvm-windows' { return 'nvm install lts ; nvm use lts' } 'volta' { return 'volta install node@24' } 'fnm' { return 'fnm install 24 ; fnm default 24' } default { return 'winget install --id OpenJS.NodeJS.LTS -e' } } } function Plugin-Stand { $S.PlDa = $false; $S.PlAn = ''; $S.PlPfad = '' $r = Claude-Aufruf @('plugin', 'list', '--json') $l = Json $r.Out foreach ($x in @($l)) { if ($null -ne $x -and (Wert $x 'id') -eq 'grace@grace' -and ((Wert $x 'scope') -eq 'user' -or $null -eq (Wert $x 'scope'))) { $S.PlDa = $true; $S.PlAn = [string](Wert $x 'enabled'); $S.PlPfad = [string](Wert $x 'installPath'); return } } } function Plan-Zeigen { Schritt 'Plan' $S.Ziel = Join-Path (Join-Path (Konfig) 'grace') 'marktplatz' if ($S.Ziel -match '\s') { # install.sh stops here on macOS. On Windows a user folder with a space is common; Grace's hooks and server # start without a shell (exec form), so a space is expected to work - to be confirmed in the Windows test. Warn "The install folder contains a space: $($S.Ziel)" } $S.Installiert = '' $pj = Join-Path $S.Ziel 'grace\.claude-plugin\plugin.json' if (Test-Path -LiteralPath $pj) { $S.Installiert = [string](Wert (Json ([IO.File]::ReadAllText($pj))) 'version') } $r = Claude-Aufruf @('plugin', 'marketplace', 'list', '--json') if ($r.Code -ne 0) { Stirb "Could not read Claude Code's plugin settings." 'Update Claude Code (claude update), then run this command again.' } $S.MpAktion = 'add' foreach ($x in @(Json $r.Out)) { if ($null -ne $x -and (Wert $x 'name') -eq 'grace') { $quelle = [string](Wert $x 'source'); $pfad = [string](Wert $x 'path') if ($quelle -eq 'directory' -and $pfad -ne '' -and ([IO.Path]::GetFullPath($pfad).TrimEnd('\') -ieq [IO.Path]::GetFullPath($S.Ziel).TrimEnd('\'))) { $S.MpAktion = 'update' } else { Warn "Claude Code already has a plugin source named `"grace`": $(Sauber $quelle) $(Sauber $pfad)" Sag ' If you answer yes, Claude Code''s "grace" then points to this install; plugins' Sag ' installed from the old source update from the new one. Nothing else changes.' if (-not (Frage " Point `"grace`" to this Grace install ($($S.Ziel)) instead?" 'N')) { Stirb 'Stopped so that nothing of yours is changed. Nothing was downloaded.' 'To switch, run this command again and answer yes to that question.' } } } } if ($S.Installiert -eq '') { $g = "install $($S.Version)" } elseif ($S.Installiert -eq $S.Version -and $S.DownloadPflicht) { $g = "$($S.Installiert) (download again - $(if ($S.Gratistag) { 'your free month starts with the download' } else { 'this activates Grace for your subscription' }))" } elseif ($S.Installiert -eq $S.Version) { $g = "$($S.Installiert) (up to date)" } else { $g = "update $($S.Installiert) -> $($S.Version)" } if (Node-Ok) { $n = "v$(Node-Version) OK" } elseif ((Node-Verwalter) -ne '') { $n = "needs Node.js $($S.NodeMin) or newer - from $(Node-Verwalter), see below" } else { $n = "needs Node.js $($S.NodeMin) or newer - will ask to install" } $frei = '?' try { $frei = [string][math]::Floor((Get-PSDrive -Name ($env:USERPROFILE.Substring(0, 1))).Free / 1GB) } catch { } Sag (' {0,-15} {1}' -f 'Grace', $g) Sag (' {0,-15} {1}' -f 'Node.js', $n) Sag (' {0,-15} {1}' -f 'Install folder', $S.Ziel) Sag (' {0,-15} {1}' -f 'Free disk', "$frei GB") Sag '' if (-not (Frage 'Continue?' 'Y')) { Sag 'Stopped. Nothing was installed.'; Stirb 'Stopped.' } } # -- 5. Node.js --------------------------------------------------------------------- function Pfad-Auffrischen { # After winget: this window does not see the new PATH yet - read it again from the registry. $m = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $u = [Environment]::GetEnvironmentVariable('Path', 'User') $env:Path = (@($m, $u) | Where-Object { $_ }) -join ';' } function Node-Sicherstellen { Schritt 'Node.js (runs Grace inside Claude Code)' if (Node-Ok) { $S.NodeOk = $true; Ok "Node.js v$(Node-Version)"; return } $v = Node-Version if ($v -ne '') { Warn "Node.js $v is too old. This Grace version needs $($S.NodeMin) or newer." } else { Warn 'Node.js is not installed.' } $verwalter = Node-Verwalter if ($verwalter -ne '') { Warn "Your node comes from $verwalter. Update it there:" Leise (Node-Verwalter-Befehl $verwalter) Warn 'Then open a new PowerShell window and run this command again.' return } if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Warn "winget (App Installer) is missing. Install Node.js LTS from https://nodejs.org - Grace cannot start without it." return } if (Frage ' Install Node.js LTS with winget (winget install OpenJS.NodeJS.LTS)?' 'Y') { $S.NeuesFenster = $true & winget install --id OpenJS.NodeJS.LTS -e --source winget --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { Warn 'winget install did not finish.' } Pfad-Auffrischen } if (Node-Ok) { $S.NodeOk = $true; Ok "Node.js v$(Node-Version)" } else { Warn "Node.js $($S.NodeMin) or newer is still missing. Install it from https://nodejs.org - Grace cannot start without it." } } # -- 6. Grace package ----------------------------------------------------------------- function Paket-Installieren { Schritt "Grace $($S.Version)" if (-not $S.DownloadPflicht -and $S.Installiert -eq $S.Version -and (Test-Path -LiteralPath (Join-Path $S.Ziel '.claude-plugin\marketplace.json'))) { Ok "Grace $($S.Version) is already installed - nothing to download." return } $datei = Join-Path $S.Tmp 'grace.tgz' $versuch = 0 while ($true) { Sag " Downloading Grace $($S.Version) ($([math]::Floor($S.Groesse / 1MB) + 1) MB)..." if (Test-Path -LiteralPath $datei) { Remove-Item -LiteralPath $datei -Force } $r = Post-Json '/api/download/paket' @{ merkmal = $S.M; mac = $S.Mac; test_mac = $S.TestMac; version = $S.Version; fassung = $INSTALLER_FASSUNG; plattform = $PLATTFORM } $datei if ($null -eq $r) { Stirb 'The download failed.' 'Check your internet connection, then run the command again.' } if ($r.Code -ne 409) { break } if ($versuch -gt 0) { Stirb 'A new Grace version was published a moment ago.' 'Run the command again.' } $versuch = 1 Abo-Pruefen if (-not $S.DownloadPflicht -and $S.Installiert -eq $S.Version) { Ok "Grace $($S.Version) is already installed."; return } } if ($r.Code -ne 200) { $j = Json $r.Body switch ($r.Code) { 403 { $satz = Sauber (Wert $j 'satz'); if ($satz -eq '') { $satz = 'The subscription is not active.' } ; Stirb $satz (Sicherer-Link (Wert $j 'link')) } 400 { Stirb "The Grace server did not accept this computer's sign-in$(Fehlertext $j)." 'Run the command again.' } 429 { Stirb "Too many downloads$(Fehlertext $j)" } 503 { Stirb "Grace downloads are not available right now$(Fehlertext $j)." 'Please try again later.' } default { Stirb "The download failed (HTTP $($r.Code))$(Fehlertext $j)." 'Please try again later.' } } } $groesse = (Get-Item -LiteralPath $datei).Length $sha = (Get-FileHash -LiteralPath $datei -Algorithm SHA256).Hash.ToLowerInvariant() if ($groesse -ne $S.Groesse -or $sha -ne $S.Sha) { Stirb 'The downloaded file does not match its checksum (damaged or tampered with).' 'Nothing was installed. Please run the command again.' } Ok "Download verified (SHA-256 $($S.Sha.Substring(0, 16))...)" if ($S.Gratistag -and $r.Kopf -match '^[0-9TZ:.-]+$') { $S.GratistagBis = $r.Kopf; Ok "Your free month runs until $(Ortszeit $S.GratistagBis)." } $liste = & tar.exe -tzf $datei 2>$null if ($LASTEXITCODE -ne 0) { Stirb 'The downloaded file cannot be unpacked.' 'Nothing was installed. Please run the command again.' } foreach ($e in $liste) { if ($e -match '^(/|[A-Za-z]:)|(^|[\\/])\.\.([\\/]|$)') { Stirb 'The package contains an unsafe path. Nothing was installed.' } if ($e -notmatch '^(\./)?(\.claude-plugin|grace)(/|$)' -and $e -ne './') { Stirb 'The package contains an unexpected file. Nothing was installed.' } } $eltern = Split-Path -Parent $S.Ziel if (-not (Test-Path -LiteralPath $eltern)) { New-Item -ItemType Directory -Path $eltern -Force | Out-Null } $neu = "$($S.Ziel).neu" if (Test-Path -LiteralPath $neu) { Remove-Item -LiteralPath $neu -Recurse -Force } New-Item -ItemType Directory -Path $neu | Out-Null & tar.exe -xzf $datei -C $neu 2>$null if ($LASTEXITCODE -ne 0) { Remove-Item -LiteralPath $neu -Recurse -Force; Stirb 'The package could not be unpacked. Nothing was installed.' } $name = [string](Wert (Json ([IO.File]::ReadAllText((Join-Path $neu '.claude-plugin\marketplace.json')))) 'name') $version = [string](Wert (Json ([IO.File]::ReadAllText((Join-Path $neu 'grace\.claude-plugin\plugin.json')))) 'version') if ($name -ne 'grace' -or $version -ne $S.Version -or -not (Test-Path -LiteralPath (Join-Path $neu 'grace\dist\start.mjs'))) { Remove-Item -LiteralPath $neu -Recurse -Force Stirb 'The package is incomplete. Nothing was installed.' 'Please run the command again.' } # Swap: the current copy becomes marktplatz.vorher (one previous copy is kept). if (Test-Path -LiteralPath $S.Ziel) { if (Test-Path -LiteralPath "$($S.Ziel).vorher") { Remove-Item -LiteralPath "$($S.Ziel).vorher" -Recurse -Force } Move-Item -LiteralPath $S.Ziel -Destination "$($S.Ziel).vorher" -ErrorAction Stop } try { Move-Item -LiteralPath $neu -Destination $S.Ziel -ErrorAction Stop } catch { if ((Test-Path -LiteralPath "$($S.Ziel).vorher") -and -not (Test-Path -LiteralPath $S.Ziel)) { Move-Item -LiteralPath "$($S.Ziel).vorher" -Destination $S.Ziel } Stirb "Could not move Grace into $($S.Ziel)." } Ok "Grace $($S.Version) unpacked to $($S.Ziel)" } # -- 6b. Microsoft Visual C++ runtime ------------------------------------------------------ # Grace's code search is a native Node module (@ast-grep/napi). On Windows it needs the Microsoft Visual C++ runtime # (VCRUNTIME140.dll). Many PCs have it (other programs bring it), a fresh Windows does not - then Grace's server does # not start in Claude Code ("Cannot find native binding"). Checked by loading the module with the installed node; # missing -> ask, then winget (Microsoft.VCRedist.2015+.arm64 / .x64, matching node's architecture). Windows test # 05.10.2026: in the test VM the DLL was only reachable through Python's folder in PATH. function Napi-Laedt { $modul = Join-Path $S.Ziel 'grace\node_modules\@ast-grep\napi' if (-not (Test-Path -LiteralPath $modul)) { return $true } $null = & node -e 'require(process.argv[1])' $modul 2>$null return ($LASTEXITCODE -eq 0) } function Laufzeit-Sicherstellen { if (-not $S.NodeOk) { return } if (Napi-Laedt) { $S.LaufzeitOk = $true; return } Schritt 'Microsoft Visual C++ runtime (Grace needs it on Windows)' $arch = Sauber ((& node -p 'process.arch' 2>$null) | Select-Object -First 1) $vc = if ($arch -eq 'arm64') { 'arm64' } else { 'x64' } $id = "Microsoft.VCRedist.2015+.$vc" $S.VcBefehl = "winget install --id $id -e" Warn 'The Microsoft Visual C++ runtime is missing (a free Windows component from Microsoft).' if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Warn "winget (App Installer) is missing. Install the runtime from https://aka.ms/vs/17/release/vc_redist.$vc.exe - Grace cannot start without it." return } if (Frage " Install it with winget ($($S.VcBefehl))? Windows may ask for permission." 'Y') { & winget install --id $id -e --source winget --silent --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { Warn 'winget install did not finish.' } } if (Napi-Laedt) { $S.LaufzeitOk = $true; Ok 'Microsoft Visual C++ runtime installed' } else { Warn "Grace cannot start without the Microsoft Visual C++ runtime. Install it with: $($S.VcBefehl)" } } # -- 7. Claude Code --------------------------------------------------------------------- function Claude-Lauf([string[]]$a) { $r = Claude-Aufruf $a if ($r.Code -ne 0) { ($r.Out -split "`n") | ForEach-Object { Write-Host " $_" } Stirb "Claude Code could not run: claude $($a -join ' ')" 'Fix the problem above, then run this command again.' } } function Registrieren { Schritt 'Registering Grace with Claude Code' if ($S.MpAktion -eq 'update') { Claude-Lauf @('plugin', 'marketplace', 'update', 'grace') } else { Claude-Lauf @('plugin', 'marketplace', 'add', $S.Ziel) } Plugin-Stand if ($S.PlDa) { Claude-Lauf @('plugin', 'update', 'grace@grace'); Ok 'Plugin grace@grace updated' } else { Claude-Lauf @('plugin', 'install', 'grace@grace'); Ok 'Plugin grace@grace installed' } Plugin-Stand if ($S.PlAn -eq 'False' -or $S.PlAn -eq 'false') { Warn 'Grace is installed but switched off in Claude Code. Switch it on with: claude plugin enable grace@grace' } } # Asks Grace itself whether it sees the sign-in (same DPAPI file, same check as in Claude Code). function Lizenz-Abgleichen { Schritt 'Checking that Grace sees the sign-in' $ordner = $S.PlPfad if ($ordner -eq '' -or -not (Test-Path -LiteralPath (Join-Path $ordner 'dist\grace-lizenz.mjs'))) { $ordner = Join-Path $S.Ziel 'grace' } $skript = Join-Path $ordner 'dist\grace-lizenz.mjs' if (-not $S.NodeOk -or -not (Test-Path -LiteralPath $skript)) { $lj = Join-Path (Join-Path (Konfig) 'grace') 'lizenz.json' if (Test-Path -LiteralPath $lj) { Remove-Item -LiteralPath $lj -Force } Leise 'Grace checks the subscription when it first starts in Claude Code.' return } $zeile = Sauber ((& node $skript status 2>$null | Select-Object -First 1)) if ($zeile -like 'FREI*') { if ($S.Gratistag) { Ok 'Grace sees the sign-in and your free month.' } else { Ok 'Grace sees the sign-in and the subscription.' } } elseif ($zeile -like 'OFFLINE*') { Warn "Grace could not reach $GRACE_SEITE_FEST just now. It checks again when Claude Code starts." } elseif ($zeile -like 'NICHT_ANGEMELDET*') { Warn 'Grace cannot read the sign-in. Run this command again; if it stays, contact support.' } elseif ($zeile -eq '') { Warn "Grace's own check did not answer. In Claude Code, /grace:grace-einrichten shows the state." } else { Warn "Grace's own check says:"; Leise ($zeile -replace '^\S+\s', '') } } # -- 8. Your Claude projects -------------------------------------------------------------- function Projekte-Einrichten { $ordner = $S.PlPfad if ($ordner -eq '' -or -not (Test-Path -LiteralPath (Join-Path $ordner 'hooks\projekte.mjs'))) { $ordner = Join-Path $S.Ziel 'grace' } $skript = Join-Path $ordner 'hooks\projekte.mjs' if (-not $S.NodeOk -or -not (Test-Path -LiteralPath $skript)) { return } Schritt 'Your Claude projects' $datei = Join-Path (Join-Path (Konfig) 'grace') 'projektordner.json' if (Test-Path -LiteralPath $datei) { $alt = [string](Wert (Json ([IO.File]::ReadAllText($datei))) 'pfad') if ($alt -ne '' -and (Test-Path -LiteralPath $alt -PathType Container)) { $aus = & node $skript einlesen $alt 2>&1 | Out-String if ($LASTEXITCODE -eq 0) { Ok "$((Sauber $aus).Trim()) ($alt)" } else { Warn "Could not read $alt again." } return } } Sag ' Grace can look at your Claude projects: the name, a short description (README or' Sag ' package.json) and the language of each one - never .env files, keys or code. The list' Sag ' stays on this computer. Then Grace knows whether a task is a new project or work on an' Sag ' existing one, and builds new projects on its own right there, next to your other projects.' if (-not (Frage ' Look at your projects folder?' 'Y')) { Leise 'Skipped. Later: /grace:grace-einrichten in Claude Code.'; return } $vorschlag = Sauber ((& node $skript vorschlag 2>$null | Select-Object -First 1)) $pfad = Read-Host " Where are your Claude projects?$(if ($vorschlag -ne '') { " [$vorschlag]" })" $pfad = Sauber $pfad if ($pfad -eq '') { $pfad = $vorschlag } if ($pfad -like '~*') { $pfad = $env:USERPROFILE + $pfad.Substring(1) } if ($pfad -eq '' -or -not (Test-Path -LiteralPath $pfad -PathType Container)) { Warn "No such folder$(if ($pfad -ne '') { ": $pfad" }). Later: /grace:grace-einrichten in Claude Code."; return } $aus = & node $skript einlesen $pfad 2>&1 | Out-String if ($LASTEXITCODE -eq 0) { Ok ((Sauber $aus).Trim()) } else { Warn "Could not read the projects: $((Sauber $aus).Trim())" } } # -- 9. Done ---------------------------------------------------------------------------------- function Abschluss { Schritt 'Done' Sag " Grace $($S.Version) is installed." if (-not $S.NodeOk) { Warn "Still needed: Node.js $($S.NodeMin) or newer ($(Node-Verwalter-Befehl (Node-Verwalter)))." } elseif (-not $S.LaufzeitOk -and $S.VcBefehl -ne '') { Warn "Still needed: the Microsoft Visual C++ runtime ($($S.VcBefehl))." } Sag '' Sag ' Next:' if ($S.NeuesFenster) { Sag ' 1. Quit Claude Code completely. Then open a NEW PowerShell or Terminal window (this one' Sag ' does not see the newly installed programs) and start Claude Code there.' } else { Sag ' 1. Quit Claude Code completely and start it again, so it loads Grace.' } Sag ' 2. In Claude Code, run once: /grace:grace-einrichten' Sag ' 3. Then just type your task. New projects Grace builds on its own.' Sag '' if ($S.Gratistag) { if ($S.GratistagBis -ne '') { Sag " Free month: Grace works until $(Ortszeit $S.GratistagBis)." } else { Sag " Free month: Grace works for $GRATISTAG_TAGE days from the download." } Sag " To keep it afterwards, subscribe: $($S.Seite)/pricing" Sag ' Without a subscription Grace stops when the free month ends and removes itself' Sag " $ENTFERNEN_NACH_STUNDEN hours later at the earliest, at the latest the next time Claude Code starts" Sag ' with an internet connection. Your projects stay as they are.' Sag '' } Sag ' To update Grace later, run the same command again.' } # -- Main --------------------------------------------------------------------------------------- $alteKodierung = [Console]::OutputEncoding try { # Claude Code and node print UTF-8 (JSON with paths) - read them as UTF-8, not in the OEM code page. [Console]::OutputEncoding = New-Object Text.UTF8Encoding($false) Write-Host "Grace installer - $GRACE_SEITE_FEST/download" -ForegroundColor White Seite-Festlegen $S.Tmp = Join-Path ([IO.Path]::GetTempPath()) ("grace-install." + [Guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $S.Tmp | Out-Null Vorabpruefung Abo-Pruefen Plan-Zeigen Node-Sicherstellen Paket-Installieren Laufzeit-Sicherstellen Registrieren Lizenz-Abgleichen Projekte-Einrichten Abschluss } catch { if ($_.Exception.Data['grace'] -eq $true) { $zeilen = $_.Exception.Message -split "`n" Write-Host '' Write-Host "Stopped: $($zeilen[0])" -ForegroundColor Red foreach ($z in ($zeilen | Select-Object -Skip 1)) { Write-Host " $z" -ForegroundColor Red } } else { Write-Host '' Write-Host "Stopped: unexpected error - $($_.Exception.Message)" -ForegroundColor Red Write-Host ' Please run the command again; if it stays, contact support.' -ForegroundColor Red } } finally { $S.M = ''; $S.Mac = '' [Console]::OutputEncoding = $alteKodierung if ($S.Tmp -ne '' -and (Test-Path -LiteralPath $S.Tmp)) { Remove-Item -LiteralPath $S.Tmp -Recurse -Force -ErrorAction SilentlyContinue } try { $HTTP.Dispose() } catch { } } }