<# .SYNOPSIS Installs plcsense on Windows: Scoop, the private Zeugwerk bucket, plcsense itself. .DESCRIPTION Early-access installer for plcsense (https://plcsense.com). Safe to re-run: it updates an existing install instead of failing, and it keeps any other Scoop private_hosts entries you already have. The bucket token and the licence key are in your early-access mail. You set the token before running this; it is written to Scoop's config so that downloads from api.zeugwerk.dev carry an Authorization header, which is how the private bucket works. .PARAMETER Token Token for the Zeugwerk Scoop bucket, required. Defaults to $env:PLCSENSE_TOKEN. Deliberately not prompted for, so early access stays something you were given rather than something a pasted one-liner walks you into. .PARAMETER LicenseKey Licence key. Defaults to $env:PLCSENSE_LICENSE_KEY. Only used when the machine is not licensed yet: if it already is, nothing is activated and nothing is asked. Otherwise it is prompted for, and skipping the prompt installs anyway. .PARAMETER SkipScoopInstall Do not install Scoop even if it is missing. Use when Scoop is managed for you. .PARAMETER AcceptPrivacy Accepts the privacy notice (step 1) without prompting. Defaults to $env:PLCSENSE_ACCEPT_PRIVACY (1/true/y/yes all work). An interactive session without this set is prompted instead; a non-interactive one without it stops, same shape as the Token check. .EXAMPLE # The normal case: a licence key is asked for only if this machine needs one, # and the privacy notice is accepted by pressing y $env:PLCSENSE_TOKEN = 'your-token' irm https://plcsense.com/install.ps1 | iex .EXAMPLE # Unattended: no prompt at all $env:PLCSENSE_TOKEN = 'your-token' $env:PLCSENSE_LICENSE_KEY = 'your-licence-key' $env:PLCSENSE_ACCEPT_PRIVACY = '1' irm https://plcsense.com/install.ps1 | iex .EXAMPLE & ([scriptblock]::Create((irm https://plcsense.com/install.ps1))) -Token 'your-token' -LicenseKey 'your-key' -AcceptPrivacy .LINK https://plcsense.com #> param( [string] $Token = $env:PLCSENSE_TOKEN, [string] $LicenseKey = $env:PLCSENSE_LICENSE_KEY, [switch] $SkipScoopInstall, [switch] $AcceptPrivacy ) # Deliberately ASCII only: this file is piped straight into a shell, and Windows # PowerShell guesses ISO-8859-1 for a text/* response with no charset. $ErrorActionPreference = 'Stop' function Write-Step { param([string] $Text) Write-Host "==> $Text" -ForegroundColor Cyan } function Write-Info { param([string] $Text) Write-Host " $Text" } function Write-Ok { param([string] $Text) Write-Host " $Text" -ForegroundColor Green } # Scoop's entry point on PATH is a .ps1, so Scoop runs inside this session, and # PowerShell turns whatever a native command writes to stderr into error records. # Either would be fatal under $ErrorActionPreference = 'Stop', so run these loose # and judge them by $LASTEXITCODE or by what ended up on disk. function Invoke-Tool { param( [Parameter(Mandatory)] [string] $Command, [Parameter(ValueFromRemainingArguments)] [string[]] $Arguments ) $ErrorActionPreference = 'Continue' & $Command @Arguments } # Installers write Machine/User PATH (and DOTNET_ROOT) for future processes. # This session still has the old copy unless we rebuild it. function Update-SessionPath { $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') $user = [Environment]::GetEnvironmentVariable('Path', 'User') $env:Path = (@($machine, $user) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' $shims = Join-Path $env:USERPROFILE 'scoop\shims' if ((Test-Path $shims) -and ($env:Path -notlike "*$shims*")) { $env:Path = "$shims;$env:Path" } foreach ($scope in @('Machine', 'User')) { $root = [Environment]::GetEnvironmentVariable('DOTNET_ROOT', $scope) if (-not [string]::IsNullOrWhiteSpace($root)) { $env:DOTNET_ROOT = $root } } } $bucketName = 'zeugwerk' $bucketUrl = 'https://github.com/Zeugwerk/scoop-bucket' $authMatch = 'https://api.zeugwerk.dev/*' if ($env:OS -ne 'Windows_NT') { throw 'This installer is for Windows. plcsense itself does not need Windows, but the packaged build we ship today does. Write to hello@plcsense.com if you need another platform.' } # Masked so the key stays out of the shell history that -LicenseKey and $env: land in. function Read-Secret { param([string] $Prompt) $secure = Read-Host $Prompt -AsSecureString $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } # Nothing can be downloaded without the token, so stop here rather than prompt: a # passer-by who runs this one-liner learns that early access is a thing, not what # to type next. The licence is dealt with in step 7, once plcsense itself can say # whether this machine needs a key at all. if ([string]::IsNullOrWhiteSpace($Token)) { throw 'plcsense is in early access. Set $env:PLCSENSE_TOKEN to the bucket token from your early-access mail (or pass -Token) and run this again. Write to hello@plcsense.com to get in.' } # --- 1. Privacy ---------------------------------------------------------------- # Told once, before anything is installed, so this is read before an agent ever # sends plcsense a query. Mirrors the network ledger on plcsense.com/#local: # plcsense itself only makes the three calls listed below. The line about the # agent's own model is not a plcsense network call, it is what already happens # to every tool result an agent acts on, cloud model or not, and it belongs here # precisely because the rest of this notice could otherwise read as "nothing # leaves this machine", which is not the whole picture. Write-Step 'Privacy' Write-Info 'plcsense reads your solution and referenced libraries locally. Nothing' Write-Info 'from your project is uploaded for analysis.' Write-Info 'The only network calls plcsense itself makes:' Write-Info ' - a licence check to api.zeugwerk.dev (licence key, machine identity)' Write-Info ' - opt-out usage counters (which tool, how often); off with PLCSENSE_TELEMETRY=0' Write-Info ' - opt-in issue reports, only when started with --enable-feedback' Write-Info 'Separately: your coding agent hands the query and the tool result to its' Write-Info 'own model to act on. If that model is cloud-hosted, your library and source' Write-Info 'content goes there too. That is your agent and its model, not plcsense.' Write-Info 'Details: https://plcsense.com/#local' Write-Host '' if ($env:PLCSENSE_ACCEPT_PRIVACY -match '^(1|true|y|yes)$') { $AcceptPrivacy = $true } if ($AcceptPrivacy) { Write-Ok 'accepted (PLCSENSE_ACCEPT_PRIVACY / -AcceptPrivacy)' } elseif ([Environment]::UserInteractive) { $accepted = Read-Host 'Continue? [y/N]' if ($accepted -notmatch '^(y|yes)$') { throw 'Not accepted, stopping. Run again and press y, or set $env:PLCSENSE_ACCEPT_PRIVACY = "1" to accept non-interactively.' } Write-Ok 'accepted' } else { throw 'Non-interactive session: set $env:PLCSENSE_ACCEPT_PRIVACY = "1" (or pass -AcceptPrivacy) to accept the privacy notice and continue.' } # --- 2. Scoop ---------------------------------------------------------------- Write-Step 'Checking Scoop' $identity = [Security.Principal.WindowsIdentity]::GetCurrent() if ((New-Object Security.Principal.WindowsPrincipal $identity).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Info 'This shell is elevated. Scoop expects a normal user shell and may refuse.' } # irm|iex runs this file in memory and bypasses execution policy, but Scoop's # entry point is a .ps1 on disk. Restricted (Windows client default) then fails # with "running scripts is disabled" on the first scoop call. $policy = Get-ExecutionPolicy if ($policy -notin @('Bypass', 'RemoteSigned', 'Unrestricted')) { Write-Info "execution policy is $policy; setting CurrentUser to RemoteSigned so Scoop can run" try { Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force } catch { throw "PowerShell will not run scripts (execution policy: $policy). Open PowerShell and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser" } if ((Get-ExecutionPolicy) -in @('Restricted', 'AllSigned', 'Undefined')) { throw 'PowerShell will not run scripts. Open PowerShell and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser' } } if (Get-Command scoop -ErrorAction SilentlyContinue) { Write-Ok 'already installed' } elseif ($SkipScoopInstall) { throw 'Scoop is not on PATH and -SkipScoopInstall was given.' } else { Write-Info 'installing from get.scoop.sh' Invoke-RestMethod get.scoop.sh | Invoke-Expression Update-SessionPath if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) { throw 'Scoop installed but is not on PATH. Open a new PowerShell window and run this again.' } Write-Ok 'installed' } # --- 3. Token for the private download host ---------------------------------- # Scoop attaches these headers to matching downloads, so the token lives in one # place instead of in every pipeline script. Write-Step 'Registering the bucket token with Scoop' $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME '.config' } $configFile = Join-Path $configHome 'scoop\config.json' New-Item (Split-Path $configFile) -ItemType Directory -Force | Out-Null $config = if (Test-Path $configFile) { Get-Content $configFile -Raw | ConvertFrom-Json } else { [PSCustomObject]@{} } $entry = [PSCustomObject]@{ match = $authMatch headers = "Authorization=Bearer $Token" } # Replace our own entry, keep everyone else's. $newHosts = @($config.private_hosts | Where-Object { $_ -and $_.match -ne $entry.match }) + $entry $config | Add-Member -MemberType NoteProperty -Name private_hosts -Value $newHosts -Force $config | ConvertTo-Json -Depth 10 | Set-Content $configFile Write-Ok "written to $configFile" # --- 4. Bucket --------------------------------------------------------------- Write-Step 'Adding the Zeugwerk bucket' # Scoop clones buckets with its own git helper, which takes its own git app or a # git.exe on PATH. Something named git that is an alias, a function or a script # passes a plain Get-Command but leaves that helper empty, and Scoop then invokes # the empty string: "The term '' is not recognized as the name of a cmdlet". if (-not (Get-Command git -CommandType Application -ErrorAction SilentlyContinue)) { Write-Info 'installing git, which Scoop needs for buckets' Invoke-Tool scoop install git Update-SessionPath } # A folder test rather than "scoop bucket list", which reads every bucket through # git only to answer whether one exists. $scoopRoot = @($env:SCOOP, $config.root_path, (Join-Path $env:USERPROFILE 'scoop')) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 $bucketDir = Join-Path $scoopRoot "buckets\$bucketName" if (Test-Path $bucketDir) { Write-Ok 'already added' } else { Invoke-Tool scoop bucket add $bucketName $bucketUrl if (-not (Test-Path $bucketDir)) { throw "Could not add the $bucketName bucket, see the Scoop output above. If Scoop looks broken rather than unhappy, reinstall it: remove $scoopRoot and run this again." } Write-Ok 'added' } # --- 5. .NET 8 runtime --------------------------------------------------------- # plcsense needs the .NET 8 runtime. Microsoft's install script is used instead # of a Scoop package: it needs no admin, and Scoop has no portable runtime-only # manifest that matches what we need. PATH and DOTNET_ROOT are set for this # session and for the user so a new shell finds dotnet without a reboot. Write-Step 'Checking .NET 8 runtime' $dotnetInstalled = $false try { $dotnetInfo = & dotnet --list-runtimes 2>$null if ($LASTEXITCODE -eq 0 -and ($dotnetInfo -match 'Microsoft\.NETCore\.App 8\.')) { $dotnetInstalled = $true } } catch { } if ($dotnetInstalled) { Write-Ok 'already installed' } else { Write-Info 'installing .NET 8 runtime (dotnet-install.ps1)' $dotnetRoot = Join-Path $env:LOCALAPPDATA 'Microsoft\dotnet' $dotnetInstall = [scriptblock]::Create((Invoke-RestMethod 'https://dot.net/v1/dotnet-install.ps1')) $ErrorActionPreference = 'Continue' & $dotnetInstall -Channel 8.0 -Runtime dotnet -InstallDir $dotnetRoot $ErrorActionPreference = 'Stop' $env:DOTNET_ROOT = $dotnetRoot if ($env:Path -notlike "*$dotnetRoot*") { $env:Path = "$dotnetRoot;$env:Path" } [Environment]::SetEnvironmentVariable('DOTNET_ROOT', $dotnetRoot, 'User') $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') if ([string]::IsNullOrWhiteSpace($userPath)) { [Environment]::SetEnvironmentVariable('Path', $dotnetRoot, 'User') } elseif ($userPath -notlike "*$dotnetRoot*") { [Environment]::SetEnvironmentVariable('Path', "$dotnetRoot;$userPath", 'User') } Update-SessionPath if ($env:Path -notlike "*$dotnetRoot*") { $env:Path = "$dotnetRoot;$env:Path" } $env:DOTNET_ROOT = $dotnetRoot $dotnetInfo = $null try { $dotnetInfo = & dotnet --list-runtimes 2>$null } catch { } if (-not ($dotnetInfo -match 'Microsoft\.NETCore\.App 8\.')) { throw '.NET 8 runtime was not installed, see the output above.' } Write-Ok 'installed' } # --- 6. plcsense ------------------------------------------------------------- Write-Step 'Installing plcsense' Invoke-Tool scoop update if (Get-Command plcsense -CommandType Application -ErrorAction SilentlyContinue) { Invoke-Tool scoop update plcsense Write-Ok 'updated' } else { Invoke-Tool scoop install "$bucketName/plcsense" Update-SessionPath if (-not (Get-Command plcsense -CommandType Application -ErrorAction SilentlyContinue)) { throw 'plcsense was not installed, see the Scoop output above.' } Write-Ok 'installed' } # --- 7. Licence -------------------------------------------------------------- # "license status" exits 0 only when this machine has a valid plcsense licence. Write-Step 'Checking the licence' Invoke-Tool plcsense license status *> $null if ($LASTEXITCODE -eq 0) { Write-Ok 'already active' } else { if ([string]::IsNullOrWhiteSpace($LicenseKey) -and [Environment]::UserInteractive) { Write-Info 'Not licensed yet. Get a key via https://plcsense.com' $LicenseKey = Read-Secret 'Licence key (press Enter to skip and activate later)' } if (-not [string]::IsNullOrWhiteSpace($LicenseKey)) { Invoke-Tool plcsense license activate $LicenseKey } else { Write-Info 'not activated, run: plcsense license activate your-key' } } Write-Host '' Write-Ok 'Done. From your TwinCAT project folder: plcsense setup cursor' Write-Info 'Or add MCP via npm: npx -y @zeugwerk/plcsense mcp (needs PLCSENSE_TOKEN)' Write-Info 'Docs: https://plcsense.com'