Files
agent-standups-install/install.ps1
T
dsql 419852e259 Codex support, and plainer docs
install.sh / install.ps1 synced from the source repo: they now wire the Codex
runtime where codex is on PATH, with --no-codex / -NoCodex to opt out.

README: say what gets wired and name the Windows flag spellings; drop the
stiffer wording in the licence and service sections; add a table of where the
binary, the data and the service definition actually live, since ~/.standup is
the thing people need to know about for backups and for STANDUP_HOME.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-15 11:47:21 -04:00

313 lines
14 KiB
PowerShell

# agent-standups installer for Windows.
#
# irm https://git.rethinkstudios.io/rethink-software/agent-standups/raw/tag/v0.1.0/install.ps1 | iex
#
# Flags (when invoked as a file): -SkillOnly -NoNode -NoCodex -Registry <url> -Yes
# Through `irm | iex` the defaults apply; download it to pass flags.
#
# Idempotent: on an installed box it prints status and changes nothing.
# Elevation is requested only by winget, and only when Node must be installed.
[CmdletBinding()]
param(
[switch]$SkillOnly,
[switch]$NoNode,
[switch]$NoCodex,
[string]$Registry = 'https://git.rethinkstudios.io/api/packages/rethink-software/npm/',
[switch]$Yes
)
# The body runs in a scriptblock of its own (R196c). Under `irm | iex` the
# script runs IN the user's session: an `exit` closed their PowerShell window,
# and `$ErrorActionPreference = 'Stop'` and the helpers below stayed defined in
# it afterwards. Inside `& { }` they are the block's own and go with it. So
# nothing in the body may `exit`: the already-installed path `return`s from the
# block, and Die throws -- an error at the prompt under iex, exit 1 when the
# script is run as a file.
& {
$ErrorActionPreference = 'Stop'
$MinNode = 20
$DaemonFloor = '22.13' # node:sqlite without a flag; see Test-DaemonNode
$Pkg = 'agent-standups'
$Problems = New-Object 'System.Collections.Generic.List[string]'
function Say { param($m) Write-Host " $m" }
function Step { param($m) Write-Host "`n== $m" }
function Warn { param($m) Write-Warning $m; $Problems.Add($m) }
function Die { param($m) throw $m }
function Have { param($n) [bool](Get-Command $n -ErrorAction SilentlyContinue) }
function Get-NodeMajor {
param($exe = 'node')
try { [int](& $exe -p 'Number(process.versions.node.split(".")[0])' 2>$null) } catch { 0 }
}
# Can this node run the daemon? Asked of the node itself, not read off its
# major: node:sqlite loads WITHOUT --experimental-sqlite only from 22.13.0 and
# 23.4.0, and nothing passes that flag, so a major-only test accepted
# 22.0-22.12 and 23.0-23.3, where the daemon dies on import (CF-62).
# `--no-warnings` and a local 'Continue': the ExperimentalWarning a good node
# prints on stderr would otherwise be a terminating NativeCommandError under
# this script's 'Stop', and read as a node that cannot run the daemon.
function Test-DaemonNode {
param($exe = 'node')
$ErrorActionPreference = 'Continue'
try {
& $exe --no-warnings -e 'require("node:sqlite")' 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch { return $false }
}
# Refresh PATH from the registry so a winget install is visible without a new
# shell -- the whole point of doing it in-session.
function Update-PathFromRegistry {
$machine = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$user = [Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = (@($machine, $user) | Where-Object { $_ }) -join ';'
}
# ------------------------------------------------------------- 1. detect ----
Step 'detect'
Say "os : Windows $([Environment]::OSVersion.Version)"
$inRepo = $false
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { '' }
if ($scriptDir -and (Test-Path (Join-Path $scriptDir 'package.json'))) {
if ((Get-Content (Join-Path $scriptDir 'package.json') -Raw) -match "`"name`"\s*:\s*`"$Pkg`"") {
$inRepo = $true
}
}
Say "source : $(if ($inRepo) { "repo checkout ($scriptDir)" } else { "standalone (registry $Registry)" })"
$nodeMajor = if (Have node) { Get-NodeMajor } else { 0 }
Say "node : $(if (Have node) { (& node --version) } else { 'not installed' })"
Say "winget : $(if (Have winget) { 'present' } else { 'not available' })"
$standupBin = if (Have standup) { (Get-Command standup).Source } else { '' }
Say "standup : $(if ($standupBin) { $standupBin } else { 'not installed' })"
$codexBin = (Get-Command codex -ErrorAction SilentlyContinue).Source
Say "codex : $(if ($codexBin) { "$codexBin$(if ($NoCodex) { ' (-NoCodex)' })" } else { 'not installed' })"
$standupHome = if ($env:STANDUP_HOME) { $env:STANDUP_HOME } else { Join-Path $HOME '.standup' }
Say "config : $(if (Test-Path (Join-Path $standupHome 'config.toml')) { 'exists' } else { 'absent' })"
# ------------------------------------------------- 2. choose the target path -
$wantDaemon = -not $SkillOnly
$daemonOk = $wantDaemon -and (Have node) -and (Test-DaemonNode)
# ------------------------------------------------------- 3. provision Node ---
function Install-Node {
Step "installing Node LTS via winget"
if (-not (Have winget)) {
Die "winget is not available and Node $DaemonFloor+ is required.
Install Node manually from https://nodejs.org/ and re-run, or pass -SkillOnly."
}
# Elevation may be prompted by winget itself; we never elevate the script.
$args = @('install', '--id', 'OpenJS.NodeJS.LTS', '--source', 'winget',
'--accept-package-agreements', '--accept-source-agreements')
if ($Yes) { $args += '--silent' }
& winget @args
if ($LASTEXITCODE -ne 0) { Die "winget install failed (exit $LASTEXITCODE)" }
Update-PathFromRegistry
if (-not (Have node)) {
Die 'Node was installed but is still not on PATH. Open a new terminal and re-run.'
}
Say "installed : $(& node --version) at $((Get-Command node).Source)"
}
if ($wantDaemon -and -not $daemonOk) {
if ($NoNode) {
Warn "no Node >= $DaemonFloor and -NoNode was given: installing skill-only (no daemon)."
$wantDaemon = $false
} else {
Install-Node
$nodeMajor = Get-NodeMajor
$daemonOk = Test-DaemonNode
if (-not $daemonOk) { Warn "Node $(& node --version) cannot load node:sqlite (needs $DaemonFloor+); continuing skill-only."; $wantDaemon = $false }
}
}
if ($nodeMajor -lt $MinNode) {
if ($NoNode) { Die "Node is below the floor of $MinNode and -NoNode was given." }
Install-Node
$nodeMajor = Get-NodeMajor
}
if (-not (Have npm)) { Die 'npm is not installed.' }
Step 'plan'
Say "running node: $(& node --version) ($((Get-Command node).Source))"
Say "daemon : $(if ($wantDaemon) { 'scheduled task at logon' } else { 'skill-only -- no daemon, search or managed agents' })"
# --------------------------------------------------- 4. already installed? ---
# With a daemon the task is part of the install too (C82-34, as install.sh):
# Ctrl-C at step 7 left skill and crossSessionInbound green and the task never
# started, and the re-run printed "Nothing changed". A box whose task is not
# running now goes straight to step 7; steps 5-6 are done and are not redone.
$serviceOnly = $false
if ($standupBin) {
$doctorJson = (& standup doctor --json 2>$null) | Out-String
if ($LASTEXITCODE -eq 0 -or $doctorJson.Trim().StartsWith('[')) {
try {
$checks = $doctorJson | ConvertFrom-Json
$skill = $checks | Where-Object { $_.check -eq 'skill' }
$cross = $checks | Where-Object { $_.check -eq 'crossSessionInbound' }
$serviceUp = $true
if ($skill.ok -and $cross.ok -and $wantDaemon) {
& standup service status | Out-Null
$serviceUp = ($LASTEXITCODE -eq 0)
}
if ($skill.ok -and $cross.ok -and -not $serviceUp) {
Step 'already installed, but the daemon task is not running -- finishing the service step'
$serviceOnly = $true
} elseif ($skill.ok -and $cross.ok) {
Step 'already installed -- status only'
Say "standup $(& standup version 2>$null) at $standupBin"
& standup doctor
if ($wantDaemon) { Write-Host ''; & standup service status }
Write-Host "`nNothing changed. Individual steps:"
# `service install` is operator-only once a code exists (R204#12).
Say 'standup install --runtime claude # skill, hooks'
Say 'standup service install --code <operator code> # daemon task'
Say 'standup doctor # verify'
return
}
} catch { }
}
}
# ------------------------------------------------------- 5. install the CLI --
# Steps 5 and 6 are skipped when step 4 found them done and only the task left.
if (-not $serviceOnly) {
if ($inRepo) {
Step 'building from source'
Push-Location $scriptDir
try {
if (Test-Path 'package-lock.json') { & npm ci --silent } else { & npm install --silent }
if ($LASTEXITCODE -ne 0) { Die 'npm install failed' }
& npm run build --silent
if ($LASTEXITCODE -ne 0) { Die 'build failed' }
& npm link | Out-Null
if ($LASTEXITCODE -ne 0) { Die 'npm link failed' }
} finally { Pop-Location }
} else {
Step "installing $Pkg"
# No `latest` dist-tag while only release candidates are published, so a bare
# name resolves nothing; ask for `next` in that case.
$spec = $Pkg
if ($env:STANDUP_VERSION) {
$spec = "$Pkg@$($env:STANDUP_VERSION)"
} else {
$latest = (& npm view $Pkg --registry $Registry version 2>$null | Out-String).Trim()
if (-not $latest) { $spec = "$Pkg@next"; Say "installing $spec (no stable release yet)" }
}
& npm install -g $spec --registry $Registry | Out-Null
if ($LASTEXITCODE -ne 0) { Die 'npm install -g failed' }
}
Update-PathFromRegistry
if (-not (Have standup)) {
$npmBin = (& npm prefix -g 2>$null)
Die "standup is not on PATH after install. Add $npmBin to PATH and re-run."
}
Say "standup $(& standup version 2>$null) at $((Get-Command standup).Source)"
}
# ------------------------------------------------- 6. runtime: skill + hooks -
# As on Unix, the runtime install's last step posts a synthetic Stop, which
# needs a server answering -- start a throwaway one so the check is real.
if (-not $serviceOnly) {
Step 'installing the Claude runtime'
$probeDir = Join-Path ([IO.Path]::GetTempPath()) ("standup-probe-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $probeDir -Force | Out-Null
$probe = $null
try {
# `--no-git`, as install.sh has it (R06): the probe dir is a bare temp
# directory and `create` refuses outside a git work tree, so without it
# nothing was created, `serve` had no project, and the synthetic-Stop check
# failed on every Windows install (CF-56). Never `--yes`, which would
# `git init` a temp directory. And `serve` only when `create` succeeded.
# `--no-register`, as install.sh has it (C71-49, C72-57): the directory is
# deleted below, and with a daemon already up a registered throwaway stayed
# in its registry and dashboard as a detached `install-probe` project after
# every (re)install.
Push-Location $probeDir
& standup create --no-git --no-register --name install-probe 2>$null | Out-Null
$created = ($LASTEXITCODE -eq 0)
Pop-Location
if ($created) {
$probe = Start-Process -FilePath 'standup' -ArgumentList 'serve' -WorkingDirectory $probeDir `
-WindowStyle Hidden -PassThru
for ($i = 0; $i -lt 12; $i++) {
if (((& standup doctor --json 2>$null) | Out-String) -match '"answering on') { break }
Start-Sleep -Milliseconds 300
}
}
} catch { }
& standup install --runtime claude
if ($LASTEXITCODE -ne 0) { Warn 'runtime install reported a problem; see doctor below' }
# Codex only where its binary exists: `--runtime codex` writes to
# $CODEX_HOME/hooks.json, ~/.agents/skills/ and the project's AGENTS.md, and
# leaving marked blocks for a tool that is not installed is worse than
# skipping. -NoCodex opts out on a box that does have it.
if (-not $NoCodex -and $codexBin) {
& standup install --runtime codex
if ($LASTEXITCODE -ne 0) { Warn 'codex runtime install reported a problem; see doctor below' }
}
if ($probe -and -not $probe.HasExited) { Stop-Process -Id $probe.Id -Force -ErrorAction SilentlyContinue }
Remove-Item -Recurse -Force $probeDir -ErrorAction SilentlyContinue
}
# ---------------------------------------------------------- 7. the service ---
if ($wantDaemon) {
Step 'installing the standupd scheduled task'
# Once an operator code exists, `service install` and `service start` are
# operator-only (R203#14, R203#15) -- and a plain `uninstall` keeps the home,
# code included, so a reinstall over it was refused here. This is the
# operator's own installer: it reads the code the way the CLI lets the
# operator read it locally and hands it to these two calls only, in their
# environment (never argv), restoring the session's variable afterwards.
# Whether one is SET is asked first, because `standup code` mints one when
# there is none; a first install has none and passes nothing. As install.sh.
$opCode = $null
try {
$codeStatus = (& standup expose --status --json 2>$null | Out-String) | ConvertFrom-Json
if ($codeStatus.operatorCode -eq 'set') {
$opCode = ((& standup code --json 2>$null | Out-String) | ConvertFrom-Json).code
}
} catch { $opCode = $null }
$hadCode = Test-Path Env:STANDUP_OPERATOR_CODE
$prevCode = $env:STANDUP_OPERATOR_CODE
if ($opCode) { $env:STANDUP_OPERATOR_CODE = $opCode }
try {
& standup service install
if ($LASTEXITCODE -eq 0) {
& standup service start | Out-Null
if ($LASTEXITCODE -ne 0) { Warn "task registered but did not start; see 'standup service status'" }
} else {
Warn "service install failed; single mode still works. See 'standup service status'."
}
} finally {
if ($opCode) {
if ($hadCode) { $env:STANDUP_OPERATOR_CODE = $prevCode }
else { Remove-Item Env:STANDUP_OPERATOR_CODE -ErrorAction SilentlyContinue }
}
}
}
# ------------------------------------------------------------ 8. hand over ---
Step 'verifying'
& standup doctor
if ($LASTEXITCODE -ne 0) { Warn 'some checks need attention (above)' }
if ($Problems.Count -eq 0) { Step 'done' } else {
Step 'done, with things to know'
foreach ($p in $Problems) { Say "- $p" }
Write-Host ''
}
$port = if ($env:STANDUP_PORT) { $env:STANDUP_PORT } else { '7335' }
Say "dashboard : http://127.0.0.1:$port"
Write-Host ''
& standup code
Write-Host ''
Write-Host 'Next: cd <your project>; standup create'
}