<# .SYNOPSIS ClaudeTools Windows bootstrap - rebuild a workstation after a clean OS reset. .DESCRIPTION Installs every tool ClaudeTools needs, restores secrets + identity from the recovery bundle, clones the repos, wires up scheduled tasks, and verifies. Designed to be run top-to-bottom on a fresh Windows 11 install. Idempotent: re-running skips anything already present. ORDER OF OPERATIONS (each phase depends on the previous): 0. Preflight - winget, execution policy, UTF-8 1. Core tooling - git, node, python, rust, vscode, ollama, jq, sops, age, gh, op 2. PATH refresh - make freshly-installed tools callable this session 3. AI CLIs - claude (native), gemini (npm), grok (git-bash installer) 4. Restore secrets - age key, ssh, tool auth, git config, PS profile [home group] 5. Clone repos - claudetools + vault + submodules 6. Restore identity - identity.json, settings.local, .mcp.json, state [repo group] 7. Python deps - pip installs for MCP servers / scripts 8. Ollama models - pull qwen/codestral/nomic (optional, large) 9. Scheduled tasks - GrepAI watcher, orphan detector, smartbadge 10. Large data - restore client data from bundle (optional) 11. Verify - onboarding diagnostic .PARAMETER BundlePath Recovery bundle root (folder containing 'secrets'/'identity'). Auto-detect F:\ then E:\. .PARAMETER SkipModels Skip the multi-GB ollama model pulls. .PARAMETER RestoreData Also restore the large client data from \data. .PARAMETER GiteaHost Gitea base URL. Default git.azcomputerguru.com (use 172.16.3.20:3000 on-network). .PARAMETER OnlyPhases Comma list of phase numbers to run (e.g. "1,2,3"). Default: all. .EXAMPLE # full rebuild, skip giant model downloads for now .\windows-bootstrap.ps1 -SkipModels .NOTES Run from an elevated PowerShell for cleanest winget machine-scope installs, though most packages also install at user scope without admin. #> [CmdletBinding()] param( [string]$BundlePath, [switch]$SkipModels, [switch]$RestoreData, [string]$GiteaHost = 'https://git.azcomputerguru.com', [string]$ClaudeToolsRoot = 'D:\claudetools', [string]$VaultRoot = 'D:\vault', [string]$OnlyPhases ) $ErrorActionPreference = 'Stop' $here = Split-Path -Parent $MyInvocation.MyCommand.Path function Phase($n,$title){ if ($OnlyPhases -and ($OnlyPhases -split ',').Trim() -notcontains "$n") { return $false }; Write-Host "`n========== PHASE $n : $title ==========" -ForegroundColor Cyan; return $true } function Info($m){ Write-Host "[INFO] $m" } function Ok($m){ Write-Host "[OK] $m" -ForegroundColor Green } function Warn($m){ Write-Host "[WARN] $m" -ForegroundColor Yellow } function Have($cmd){ [bool](Get-Command $cmd -ErrorAction SilentlyContinue) } function Refresh-Path { $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') } function Find-Bundle { if ($BundlePath -and (Test-Path "$BundlePath\secrets")) { return $BundlePath } foreach ($d in 'F:','E:','D:') { if (Test-Path "$d\claudetools-recovery\secrets") { return "$d\claudetools-recovery" } } return $null } # ============================================================ PHASE 0 if (Phase 0 'Preflight') { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force } catch {} if (-not (Have winget)) { throw "winget not found. Install 'App Installer' from the Microsoft Store, then re-run." } Ok "winget present: $((Get-Command winget).Source)" $script:Bundle = Find-Bundle if ($script:Bundle) { Ok "recovery bundle: $script:Bundle" } else { Warn "no recovery bundle found - secret/identity restore phases will be skipped" } } # ============================================================ PHASE 1 if (Phase 1 'Core tooling (winget)') { $pkgs = @( @{id='Git.Git'; cmd='git'}, @{id='OpenJS.NodeJS.LTS'; cmd='node'}, @{id='Python.Python.3.14'; cmd='py'}, @{id='Rustlang.Rustup'; cmd='cargo'}, @{id='Microsoft.VisualStudioCode'; cmd='code'}, @{id='Ollama.Ollama'; cmd='ollama'}, @{id='jqlang.jq'; cmd='jq'}, @{id='SecretsOPerationS.SOPS'; cmd='sops'}, @{id='FiloSottile.age'; cmd='age'}, @{id='GitHub.cli'; cmd='gh'}, @{id='AgileBits.1Password.CLI'; cmd='op'}, @{id='Microsoft.DotNet.SDK.8'; cmd='dotnet'}, # MSI builds / wix @{id='Google.Protobuf'; cmd='protoc'}, # gururmm prost builds (PROTOC env) @{id='oschwartz10612.Poppler'; cmd='pdftoppm'}, # dataforth datasheet PDF pipeline @{id='Tailscale.Tailscale'; cmd='tailscale'} # fleet connectivity (100.x mesh) ) foreach ($p in $pkgs) { if (Have $p.cmd) { Ok "$($p.cmd) already installed"; continue } Info "installing $($p.id) ..." winget install --id $p.id --exact --silent --accept-package-agreements --accept-source-agreements --disable-interactivity if ($LASTEXITCODE -ne 0) { Warn "winget returned $LASTEXITCODE for $($p.id) (may already be installed or need elevation)" } } Refresh-Path } # ============================================================ PHASE 2 if (Phase 2 'PATH refresh') { Refresh-Path foreach ($c in 'git','node','npm','py','cargo','jq','sops','age','gh','op','ollama','code','dotnet','protoc','tailscale') { if (Have $c) { Ok "$c -> $((Get-Command $c).Source)" } else { Warn "$c still not on PATH (open a new shell after install)" } } # PROTOC env var for Rust prost builds (path is version-specific, so resolve it live) $protoc = (Get-Command protoc -ErrorAction SilentlyContinue).Source if ($protoc) { [Environment]::SetEnvironmentVariable('PROTOC',$protoc,'User'); $env:PROTOC=$protoc; Ok "PROTOC=$protoc" } } # ============================================================ PHASE 3 if (Phase 3 'AI CLIs') { # Claude Code - official native installer -> %USERPROFILE%\.local\bin\claude.exe if (Have claude) { Ok "claude already installed" } else { Info "installing Claude Code (native installer)" try { irm https://claude.ai/install.ps1 | iex } catch { Warn "claude install failed: $_ (manual: irm https://claude.ai/install.ps1 | iex)" } } # Gemini CLI - npm global if (Have gemini) { Ok "gemini already installed" } else { Info "installing @google/gemini-cli" npm install -g @google/gemini-cli } # Grok CLI - xAI installer (bash; needs Git Bash from Phase 1) if (Have grok) { Ok "grok already installed" } else { $bash = 'C:\Program Files\Git\bin\bash.exe' if (Test-Path $bash) { Info "installing grok via $bash"; & $bash -lc "curl -fsSL https://x.ai/cli/install.sh | bash" } else { Warn "Git Bash not found; install Git first, then: bash -c 'curl -fsSL https://x.ai/cli/install.sh | bash'" } } Refresh-Path $env:Path += ";$env:USERPROFILE\.local\bin;$env:USERPROFILE\.grok\bin;$env:APPDATA\npm" } # ============================================================ PHASE 4 if (Phase 4 'Restore home secrets + machine config') { if ($script:Bundle) { & "$here\restore-secrets.ps1" -BundlePath $script:Bundle -Group home # Stable machine env vars (NOT a blanket reg import - the saved PATH has stale # version-pinned winget paths. user-environment.reg is kept as reference only.) [Environment]::SetEnvironmentVariable('OLLAMA_MODELS','D:\OllamaModels','User'); $env:OLLAMA_MODELS='D:\OllamaModels' [Environment]::SetEnvironmentVariable('OLLAMA_HOST','0.0.0.0:11434','User'); $env:OLLAMA_HOST='0.0.0.0:11434' Ok "set OLLAMA_MODELS=D:\OllamaModels, OLLAMA_HOST=0.0.0.0:11434" # Windows Terminal settings $wtDst = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json" if (Test-Path "$script:Bundle\config\windows-terminal-settings.json") { $p = Split-Path $wtDst -Parent if (Test-Path $p) { Copy-Item "$script:Bundle\config\windows-terminal-settings.json" $wtDst -Force; Ok "Windows Terminal settings restored" } else { Warn "Windows Terminal not installed yet - restore its settings.json later from config\" } } # hosts file (fleet Tailscale MagicDNS entries) - needs admin; merge note only if (Test-Path "$script:Bundle\config\hosts") { Warn "fleet hosts entries are in config\hosts - merge into $env:WINDIR\System32\drivers\etc\hosts as admin if Tailscale MagicDNS isn't resolving" } } else { Warn "no bundle - skipping. Restore the SOPS age key + SSH keys manually or the vault will not decrypt." } } # ============================================================ PHASE 5 if (Phase 5 'Clone repos') { if (-not (Test-Path "$ClaudeToolsRoot\.git")) { Info "cloning claudetools -> $ClaudeToolsRoot" git clone "$GiteaHost/azcomputerguru/claudetools.git" $ClaudeToolsRoot Push-Location $ClaudeToolsRoot Info "initializing submodules (gururmm / guruconnect)" git submodule update --init --recursive Pop-Location } else { Ok "claudetools repo already present" } if (-not (Test-Path "$VaultRoot\.git")) { Info "cloning vault -> $VaultRoot" git clone "$GiteaHost/azcomputerguru/vault.git" $VaultRoot } else { Ok "vault repo already present" } # safe.directory entries (mirror the prior machine) foreach ($d in $ClaudeToolsRoot,$VaultRoot,"$ClaudeToolsRoot/projects/msp-tools/guru-rmm") { git config --global --add safe.directory ($d -replace '\\','/') 2>$null } } # ============================================================ PHASE 6 if (Phase 6 'Restore repo-local identity + at-risk WIP') { if ($script:Bundle) { & "$here\restore-secrets.ps1" -BundlePath $script:Bundle -Group repo -ClaudeToolsRoot $ClaudeToolsRoot # Recreate local-only WIP (guru-rmm stashes, guru-connect untracked diff) that # would otherwise have been lost - faithfully puts the stashes back as stashes. & "$here\restore-at-risk-work.ps1" -BundlePath $script:Bundle -ClaudeToolsRoot $ClaudeToolsRoot } else { Warn "no bundle - you must hand-create .claude/identity.json (see CLAUDE.md multi-user section)" } } # ============================================================ PHASE 7 if (Phase 7 'Python deps + .NET tools') { # WiX toolset (MSI builds, e.g. gururmm agent) - dotnet global tool if (Have dotnet) { if (dotnet tool list --global 2>$null | Select-String '\bwix\b') { Ok "wix tool already installed" } else { Info "installing wix dotnet tool"; dotnet tool install --global wix 2>$null } } if (Have py) { py -m pip install --upgrade pip 2>$null $reqs = Get-ChildItem $ClaudeToolsRoot -Recurse -Filter 'requirements*.txt' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '\\(node_modules|\.venv|venv|target)\\' } foreach ($r in $reqs) { Info "pip install -r $($r.FullName)"; py -m pip install -r $r.FullName 2>$null } # baseline libs used by helper scripts / MCP py -m pip install requests paramiko mcp 2>$null Ok "python deps installed (best-effort)" } else { Warn "py launcher missing - skip" } } # ============================================================ PHASE 8 if (Phase 8 'Ollama models') { # Proper model set for THIS machine (Legion Pro 7, RTX 5070 Ti = 12 GB VRAM): # nomic-embed-text - REQUIRED for GrepAI semantic search (embeddings) # qwen3:8b - configured prose_model (identity.json) # The heavier models (qwen3:14b 9GB, codestral:22b 12GB, qwen3.6:latest 23GB) # over-saturate 12 GB VRAM on a laptop. Pull them by hand only if needed: # ollama pull qwen3:14b # codestral:22b # qwen3.6:latest $models = @('nomic-embed-text:latest','qwen3:8b') if ($SkipModels) { Warn "-SkipModels set, skipping model pulls" } elseif (Have ollama) { # Models live on D:\OllamaModels. If D: survived the reset they are already here. if (-not $env:OLLAMA_MODELS) { [Environment]::SetEnvironmentVariable('OLLAMA_MODELS','D:\OllamaModels','User'); $env:OLLAMA_MODELS='D:\OllamaModels' } $have = (ollama list 2>$null | Out-String) foreach ($m in $models) { $short = $m -replace ':latest$','' if ($have -match [regex]::Escape($short)) { Ok "$m already present on D:\OllamaModels (no download)" } else { Info "ollama pull $m"; ollama pull $m } } } else { Warn "ollama missing - skip" } } # ============================================================ PHASE 9 if (Phase 9 'Scheduled tasks') { $tdir = "$script:Bundle\manifests\scheduled-tasks" if ($script:Bundle -and (Test-Path $tdir)) { Get-ChildItem $tdir -Filter *.xml | ForEach-Object { $name = ($_.BaseName -replace '_',' ') try { $xml = Get-Content $_.FullName -Raw Register-ScheduledTask -TaskName $name -Xml $xml -Force -ErrorAction Stop | Out-Null Ok "registered task: $name" } catch { Warn "task '$name' import failed: $($_.Exception.Message) (paths/user may differ - re-create manually)" } } } else { Warn "no exported tasks in bundle - skip (see manifests\scheduled-tasks)" } } # ============================================================ PHASE 10 if (Phase 10 'Large client data (optional)') { if ($RestoreData -and $script:Bundle -and (Test-Path "$script:Bundle\data")) { Info "restoring large data $script:Bundle\data -> $ClaudeToolsRoot" robocopy "$script:Bundle\data" $ClaudeToolsRoot /E /R:1 /W:1 /NFL /NDL /NP | Out-Null Ok "large data restored" } else { Warn "skipped (pass -RestoreData to restore client data clusters)" } } # ============================================================ PHASE 11 if (Phase 11 'Verify') { $diag = "$ClaudeToolsRoot\.claude\scripts\onboarding-diagnostic.ps1" if (Test-Path $diag) { Info "running onboarding diagnostic"; & $diag } else { Warn "diagnostic not found - run '/self-check' inside Claude Code to verify wiring" } Write-Host "`n[NEXT] Interactive logins that may need a refresh (tokens expire):" -ForegroundColor Cyan Write-Host " claude (if .credentials.json expired: run 'claude' and /login)" Write-Host " gh auth login op signin gemini (browser) grok login" Write-Host " Verify vault: bash $ClaudeToolsRoot/.claude/scripts/vault.sh list" } Write-Host "`n[DONE] windows-bootstrap.ps1 complete." -ForegroundColor Green