#!/bin/sh # agent-standups installer: detect, provision Node, install, start. # # curl -fsSL https://git.rethinkstudios.io/rethink-software/agent-standups/raw/tag/v0.1.0/install.sh | sh # # Flags: --skill-only --no-node --registry --yes # # POSIX sh on purpose: this is piped to `sh` on machines whose bash may be # ancient (macOS) or absent (minimal containers). # # Idempotent: on an installed box it prints status and changes nothing. # Never uses sudo on Linux/macOS, and never apt/brew: Node is provisioned # through nvm in the user's own home, so nothing outside $HOME is touched. set -eu MIN_NODE=20 # single mode / skill-only floor MULTI_NODE=22 # the major nvm provisions for the daemon DAEMON_FLOOR="22.13" # daemon floor: node:sqlite without a flag (see daemon_node_ok) NVM_VERSION="v0.40.1" NVM_SHA256="abdb525ee9f5b48b34d8ed9fc67c6013fb0f659712e401ecd88ab989b3af8f53" REGISTRY="https://git.rethinkstudios.io/api/packages/rethink-software/npm/" PKG="agent-standups" SKILL_ONLY=0 NO_NODE=0 ASSUME_YES=0 while [ $# -gt 0 ]; do case "$1" in --skill-only) SKILL_ONLY=1 ;; --no-node) NO_NODE=1 ;; --yes|-y) ASSUME_YES=1 ;; --registry) shift; REGISTRY="${1:-$REGISTRY}" ;; --registry=*) REGISTRY="${1#--registry=}" ;; -h|--help) cat <<'USAGE' agent-standups installer --skill-only install the skill and hooks only; no daemon, no service --no-node never provision Node; fall back to skill-only if too old --registry npm registry for a standalone install --yes no prompts; also sets the provisioned Node as your default USAGE exit 0 ;; *) printf 'unknown option: %s (try --help)\n' "$1" >&2; exit 2 ;; esac shift done PROBLEMS="" say() { printf '%s\n' "$*"; } step() { printf '\n== %s\n' "$*"; } warn() { printf 'warning: %s\n' "$*" >&2; PROBLEMS="${PROBLEMS} - $* "; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } major_of() { "$1" -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0; } # Can this node run the daemon? Asked of the node itself rather than read off # its major: node:sqlite loads WITHOUT --experimental-sqlite only from 22.13.0 # and 23.4.0, nothing passes that flag, and a major-only floor accepted # 22.0-22.12 and 23.0-23.3 -- a service baked onto one of those dies on its # first import (CF-62). Loading the module is the requirement, so that is the # test. daemon_node_ok() { "$1" -e 'require("node:sqlite")' >/dev/null 2>&1; } # ---------------------------------------------------------------- 1. detect -- step "detect" UNAME="$(uname -s 2>/dev/null || echo unknown)" case "$UNAME" in Linux*) OS=linux ;; Darwin*) OS=macos ;; MINGW*|MSYS*|CYGWIN*) OS=windows ;; *) OS=unknown ;; esac IS_WSL=0 if [ "$OS" = linux ] && grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=1; fi [ "$OS" = unknown ] && die "unsupported OS: $UNAME" if [ "$OS" = windows ]; then die "run the PowerShell installer on Windows: irm https://git.rethinkstudios.io/rethink-software/agent-standups/raw/tag/v0.1.0/install.ps1 | iex" fi say " os : $OS$([ "$IS_WSL" = 1 ] && echo ' (WSL2)')" # Inside the repo checkout, or standalone from a curl pipe? SCRIPT_DIR="" case "${0:-}" in */*) SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)" || SCRIPT_DIR="" ;; esac IN_REPO=0 if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/package.json" ] \ && grep -q "\"name\": *\"$PKG\"" "$SCRIPT_DIR/package.json" 2>/dev/null; then IN_REPO=1 fi say " source : $([ "$IN_REPO" = 1 ] && echo "repo checkout ($SCRIPT_DIR)" || echo "standalone (registry $REGISTRY)")" NODE_MAJOR=0 if have node; then NODE_MAJOR="$(major_of node)"; fi say " node : $(have node && node --version || echo 'not installed')" # nvm, and the newest node under it that can run the daemon. Sorted on every # version field, numerically: on the major alone v22.9 and v22.23 tied, and # either could be picked. NVM_DIR_RESOLVED="${NVM_DIR:-$HOME/.nvm}" NVM_PRESENT=0 [ -s "$NVM_DIR_RESOLVED/nvm.sh" ] && NVM_PRESENT=1 NVM_NODE="" if [ -d "$NVM_DIR_RESOLVED/versions/node" ]; then for candidate in $(ls -1 "$NVM_DIR_RESOLVED/versions/node" 2>/dev/null | sort -t. -k1.2,1nr -k2,2nr -k3,3nr); do bin="$NVM_DIR_RESOLVED/versions/node/$candidate/bin/node" [ -x "$bin" ] || continue if daemon_node_ok "$bin"; then NVM_NODE="$bin"; break; fi done fi say " nvm : $([ "$NVM_PRESENT" = 1 ] && echo "present ($NVM_DIR_RESOLVED)" || echo 'not installed')$([ -n "$NVM_NODE" ] && echo " with $("$NVM_NODE" --version)")" # Existing install. STANDUP_BIN="" have standup && STANDUP_BIN="$(command -v standup)" say " standup : ${STANDUP_BIN:-not installed}" say " config : $([ -f "${STANDUP_HOME:-$HOME/.standup}/config.toml" ] && echo exists || echo absent)" # ------------------------------------------------- 2. choose the target path -- # DAEMON_NODE is the node the service will run. Empty => skill-only. DAEMON_NODE="" DAEMON_NODE_SRC="" WANT_DAEMON=1 [ "$SKILL_ONLY" = 1 ] && WANT_DAEMON=0 if [ "$WANT_DAEMON" = 1 ]; then if have node && daemon_node_ok "$(command -v node)"; then DAEMON_NODE="$(command -v node)"; DAEMON_NODE_SRC="node on PATH" elif [ -n "$NVM_NODE" ]; then DAEMON_NODE="$NVM_NODE"; DAEMON_NODE_SRC="nvm" fi fi # ------------------------------------------------------- 3. provision Node --- provision_node() { step "provisioning Node $MULTI_NODE via nvm (no sudo, nothing outside \$HOME)" if [ "$NVM_PRESENT" = 0 ]; then have curl || die "curl is required to install nvm" tmp="$(mktemp)" curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh" -o "$tmp" \ || die "could not download the nvm installer" # Pinned version + checksum: this script is piped to sh, so it must not # execute whatever happens to be at that URL today. actual="$( (sha256sum "$tmp" 2>/dev/null || shasum -a 256 "$tmp") | awk '{print $1}')" if [ "$actual" != "$NVM_SHA256" ]; then rm -f "$tmp" die "nvm installer checksum mismatch expected $NVM_SHA256 got $actual" fi say " nvm $NVM_VERSION checksum ok" # nvm's installer refuses to run under plain sh ("the install instructions # explicitly say to pipe the install script to `bash`"), so it gets bash # even though this script is sh. PROFILE=/dev/null keeps it from editing # the user's shell rc -- we source nvm ourselves below. have bash || die "bash is required to install nvm (nvm's own installer refuses sh). Install bash, or install Node $DAEMON_FLOOR+ yourself and re-run." nvm_err="$(PROFILE=/dev/null bash "$tmp" 2>&1)" || { rm -f "$tmp" die "nvm install failed: $(printf '%s' "$nvm_err" | tail -3)" } rm -f "$tmp" NVM_DIR_RESOLVED="${NVM_DIR:-$HOME/.nvm}" fi # nvm.sh is a bash script: it reads $_ (unset under sh, fatal with `set -u`) # and uses bash-only syntax. Drive it in a bash subshell and take back only # the path it resolves, rather than sourcing it into this one. have bash || die "bash is required to drive nvm" alias_cmd="" [ "$ASSUME_YES" = 1 ] && alias_cmd="nvm alias default $MULTI_NODE >/dev/null 2>&1;" # Keep nvm's own stderr: "exit 4" alone sends you hunting, and the real # message ("grep: command not found", "Version '22' not found") is the answer. nvm_log="$(mktemp)" # `bash -c`, never `-lc`: a login shell sources /etc/profile, which on a # minimal box pulls in scripts that need tools we may not have, and their # noise would be indistinguishable from nvm failing. nvm_out="$(nvm_log="$nvm_log" bash -c ' set +u export NVM_DIR="'"$NVM_DIR_RESOLVED"'" . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || exit 3 nvm install '"$MULTI_NODE"' >/dev/null 2>"$nvm_log" || exit 4 '"$alias_cmd"' nvm which '"$MULTI_NODE"' 2>/dev/null ')" || die "nvm could not install Node $MULTI_NODE: $(tail -3 "$nvm_log" 2>/dev/null || echo 'no further detail')" rm -f "$nvm_log" DAEMON_NODE="$(printf '%s' "$nvm_out" | tail -1)" DAEMON_NODE_SRC="nvm (just installed)" if [ "$ASSUME_YES" = 1 ]; then say " default node set to $MULTI_NODE (--yes)" else say " your default node is unchanged (pass --yes to set it to $MULTI_NODE)" fi [ -x "$DAEMON_NODE" ] || die "nvm reported no node $MULTI_NODE after install" daemon_node_ok "$DAEMON_NODE" \ || die "nvm installed $("$DAEMON_NODE" --version), which cannot load node:sqlite (the daemon needs $DAEMON_FLOOR+)" say " installed : $("$DAEMON_NODE" --version) at $DAEMON_NODE" } if [ "$WANT_DAEMON" = 1 ] && [ -z "$DAEMON_NODE" ]; then if [ "$NO_NODE" = 1 ]; then warn "no Node >= $DAEMON_FLOOR and --no-node was given: installing skill-only (no daemon)." WANT_DAEMON=0 else provision_node fi fi # The node that runs npm and the CLI for the rest of this script. if [ -n "$DAEMON_NODE" ]; then NODE_BIN_DIR="$(dirname "$DAEMON_NODE")" PATH="$NODE_BIN_DIR:$PATH" export PATH elif [ "$NODE_MAJOR" -lt "$MIN_NODE" ]; then if [ "$NO_NODE" = 1 ]; then die "Node $(have node && node --version || echo 'is missing') is below the floor of $MIN_NODE and --no-node was given." fi provision_node NODE_BIN_DIR="$(dirname "$DAEMON_NODE")" PATH="$NODE_BIN_DIR:$PATH"; export PATH [ "$SKILL_ONLY" = 1 ] && DAEMON_NODE="" # provisioned only to meet the floor fi have node || die "node is still not on PATH" have npm || die "npm is not installed" step "plan" say " running node: $(node --version) ($(command -v node))" if [ -n "$DAEMON_NODE" ]; then say " daemon node : $("$DAEMON_NODE" --version) ($DAEMON_NODE_SRC)" else say " daemon : skill-only -- single mode, no daemon, search or managed agents" fi # npm's global bin goes on this run's PATH whenever it is missing, and BEFORE # step 4 looks for an installed `standup` (C82-23). Only step 5's not-writable # branch did this, so on a re-run the ~/.npm-global the first run chose was # writable, its bin never went on PATH, step 4 found no `standup`, npm # reinstalled and the run died "standup is not on PATH after install". prefix_bin_on_path() { case ":$PATH:" in *":$1/bin:"*) ;; *) PATH="$1/bin:$PATH"; export PATH warn "add $1/bin to your PATH (this run exported it for itself)" ;; esac } NPM_PREFIX="$(npm config get prefix 2>/dev/null || echo '')" if [ -n "$NPM_PREFIX" ]; then prefix_bin_on_path "$NPM_PREFIX" if [ -z "$STANDUP_BIN" ] && have standup; then STANDUP_BIN="$(command -v standup)" say " standup : $STANDUP_BIN (npm's global bin)" fi fi # --------------------------------------------------- 4. already installed? --- # Decided on the two checks an install provides, read from doctor's JSON # whatever doctor's exit code. Doctor exits 1 when ANY check is red -- a # delivery or hook check that a reinstall cannot fix -- and gating on that # exit code meant every rerun on such a box reinstalled everything (CF-57). # # With a daemon node the service is part of the install too (C82-34): Ctrl-C # at step 8 left skill and crossSessionInbound green and the service never # started, and the re-run -- the operator's recovery -- printed "Nothing # changed" and exited 0. A box whose service is not running now goes straight # to step 8. Steps 5-7 are done and are not redone, so a box that cannot run # the service (no systemd user bus) still does not reinstall everything on # every re-run. SERVICE_ONLY=0 if [ -n "$STANDUP_BIN" ]; then if { standup doctor --json 2>/dev/null || true; } | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const c=JSON.parse(d),f=n=>c.find(x=>x.check===n);process.exit(f("skill")?.ok&&f("crossSessionInbound")?.ok?0:1)}catch{process.exit(1)}})'; then if [ -n "$DAEMON_NODE" ] && ! standup service status >/dev/null 2>&1; then step "already installed, but the service is not running -- finishing the service step" SERVICE_ONLY=1 else step "already installed -- status only" say " standup $(standup version 2>/dev/null || echo '?') at $STANDUP_BIN" standup doctor || true [ -n "$DAEMON_NODE" ] && { say ""; standup service status || true; } say "" say "Nothing changed. Individual steps:" # `service install` is operator-only once a code exists (R204#12). say " standup install --runtime claude # skill, hooks, crossSessionInbound" say " standup service install --code # daemon service" say " standup doctor # verify" exit 0 fi fi fi # A signal ends the install (R193): trapped with the cleanup alone, Ctrl-C # removed the probe server and then went on to install and start the # service, exiting 0. Exiting from the handler runs the EXIT trap. Set here, # ahead of steps 5-7, so a run that goes straight to step 8 has them too. PROBE_DIR="" PROBE_PID="" cleanup() { [ -n "${PROBE_PID:-}" ] && kill "$PROBE_PID" 2>/dev/null || true [ -n "${PROBE_DIR:-}" ] && rm -rf "$PROBE_DIR" || true } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM # ------------------------------------------------------ 5. npm global prefix -- # A system Node puts the global prefix under /usr, where `npm i -g` and # `npm link` fail with EACCES. Never sudo: point npm at a user-owned prefix. # NPM_PREFIX was read before step 4. Steps 5-7 are skipped when step 4 found # them done and only the service step left (SERVICE_ONLY). # What is tested is where npm writes -- /lib/node_modules and # /bin -- not the prefix folder itself (#17). Intel-Mac Homebrew's # prefix is /usr/local, root-owned, with those two folders handed to the # user: the prefix read as unwritable and every install rewrote ~/.npmrc for # good. A folder npm would create counts as writable when its nearest # existing ancestor is. npm_can_write() { _d="$1" while [ ! -e "$_d" ]; do _d="$(dirname "$_d")"; done [ -d "$_d" ] && [ -w "$_d" ] } if [ "$SERVICE_ONLY" = 0 ] && { [ -z "$NPM_PREFIX" ] \ || ! npm_can_write "$NPM_PREFIX/lib/node_modules" || ! npm_can_write "$NPM_PREFIX/bin"; }; then USER_PREFIX="$HOME/.npm-global" step "npm prefix" say " '$NPM_PREFIX' is not writable for npm; using $USER_PREFIX (no sudo)" mkdir -p "$USER_PREFIX" npm config set prefix "$USER_PREFIX" >/dev/null prefix_bin_on_path "$USER_PREFIX" fi # ------------------------------------------------------- 6. install the CLI -- npm_err="$(mktemp)" if [ "$SERVICE_ONLY" = 1 ]; then : # step 4 found the CLI installed elif [ "$IN_REPO" = 1 ]; then step "building from source" # npm's own error is what says WHY. `--silent` plus a bare "build failed" # left an operator with three words and nothing to act on -- a missing # toolchain, a TypeScript error and a full disk all looked identical. The # output is captured and the tail printed, the way the `npm install -g` # branch below already does it. if ! ( cd "$SCRIPT_DIR" && { [ -f package-lock.json ] && npm ci || npm install; } \ && npm run build ) >"$npm_err" 2>&1; then die "build failed: $(tail -15 "$npm_err" 2>/dev/null) Full output: $npm_err" fi if ! ( cd "$SCRIPT_DIR" && npm link ) >"$npm_err" 2>&1; then die "npm link failed: $(tail -10 "$npm_err" 2>/dev/null) Full output: $npm_err" fi else step "installing $PKG" # The spec, in the order install.ps1 and `standup update` use (R30): a pinned # STANDUP_VERSION; else the stable build when a `latest` exists -- the plain # name resolves it; else `next` while every published build is a release # candidate (no `latest` tag, so the plain name resolves nothing); else the # plain name, which fails below with the registry's own answer. This asked # for `next` whenever that tag existed, so a registry with 0.6.0 stable and a # lingering rc installed the rc and said "no stable release yet". SPEC="$PKG" if [ -n "${STANDUP_VERSION:-}" ]; then SPEC="$PKG@$STANDUP_VERSION" elif LATEST_VERSION="$(npm view "$PKG" --registry "$REGISTRY" version 2>/dev/null)" \ && [ -n "$LATEST_VERSION" ]; then SPEC="$PKG" elif NEXT_VERSION="$(npm view "$PKG@next" --registry "$REGISTRY" version 2>/dev/null)" \ && [ -n "$NEXT_VERSION" ]; then SPEC="$PKG@next" fi if [ "$SPEC" = "$PKG@next" ]; then say " installing $SPEC (no stable release yet)" elif [ "$SPEC" != "$PKG" ]; then say " installing $SPEC"; fi if ! npm install -g "$SPEC" --registry "$REGISTRY" >/dev/null 2>"$npm_err"; then if grep -q 'E404' "$npm_err" 2>/dev/null; then die "$PKG is not published to $REGISTRY yet (404). Until it is, install from a checkout: git clone ssh://git@git.rethinkstudios.io/rethink-software/agent-standups.git cd agent-standups && sh install.sh Or point --registry at one that has it." fi die "npm install -g failed: $(tail -3 "$npm_err" 2>/dev/null)" fi fi rm -f "$npm_err" have standup || die "standup is not on PATH after install (check your npm global bin)" say " standup $(standup version 2>/dev/null || echo '?') at $(command -v standup)" # ------------------------------------------------- 7. runtime: skill + hooks -- # The runtime install ends with a synthetic Stop proving the hook is REACHABLE, # not merely present. That needs a server answering, so start a throwaway one. if [ "$SERVICE_ONLY" = 0 ]; then step "installing the Claude runtime" PROBE_DIR="$(mktemp -d)" # `--no-git`: the probe project is a throwaway in a bare mktemp dir, and # `create` requires a git work tree (spec 06 ยง3). Without this flag it exits # non-zero on every fresh install, the whole `if` is skipped, and the # installer reports `FAIL hook connect ECONNREFUSED` -- so its own # "verifies the hook with a synthetic Stop" step could never pass (R06). # Never `--yes` here: that would `git init` a repository in a temp directory, # which is a worse answer than not having one. # `--no-register`: 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-run (C71-49). if ( cd "$PROBE_DIR" && standup create --no-git --no-register --name install-probe >/dev/null 2>&1 ); then # exec: $! must be the server, not a subshell that merely spawned it, or the # kill below misses and `standup serve` keeps holding the port. ( cd "$PROBE_DIR" && exec standup serve >/dev/null 2>&1 ) & PROBE_PID=$! i=0 while [ "$i" -lt 12 ]; do standup doctor --json 2>/dev/null | grep -q '"answering on' && break i=$((i + 1)); sleep 0.3 done fi standup install --runtime claude || warn "runtime install reported a problem; see doctor below" [ -n "$PROBE_PID" ] && { kill "$PROBE_PID" 2>/dev/null || true; wait "$PROBE_PID" 2>/dev/null || true; PROBE_PID=""; } rm -rf "$PROBE_DIR"; PROBE_DIR="" fi # ---------------------------------------------------------- 8. the service --- if [ -n "$DAEMON_NODE" ]; then step "installing the standupd service" # The unit resolves node absolutely from whichever node runs `service # install`, so run it under the DAEMON node -- otherwise a Node 20 default # would bake a Node 20 ExecStart into the unit and the daemon could not start. # # 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, never exported to this shell). Whether one is SET # is asked first, because `standup code` mints one when there is none; a # first install has none and passes nothing. OP_CODE="" if standup expose --status --json 2>/dev/null \ | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.exit(JSON.parse(d).operatorCode==="set"?0:1)}catch{process.exit(1)}})'; then OP_CODE="$(standup code --json 2>/dev/null \ | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).code??""))}catch{}})')" \ || OP_CODE="" fi # Which `standup` this runs is resolved HERE, under the installer's own # PATH, and not inside the subshell below: `PATH=...` on its own line is in # effect for the next line's `command -v`, so the daemon node's bin # directory won -- and a global install left there by an earlier version # (nvm v22's bin held 0.6.0-rc2 while the fresh install was 0.7.0-rc1) was # what `service install` then baked into ExecStart via `binaryPath()`. An # upgrade brought the service up on the OLD package (audit 5 finding 2). STANDUP_JS="$(command -v standup)" service_as_operator() { ( if [ -n "$OP_CODE" ]; then STANDUP_OPERATOR_CODE="$OP_CODE"; export STANDUP_OPERATOR_CODE; fi PATH="$(dirname "$DAEMON_NODE"):$PATH" exec "$DAEMON_NODE" "$STANDUP_JS" service "$@" ) } SVC_OUT="$(service_as_operator install 2>&1)" && SVC_OK=1 || SVC_OK=0 say " $SVC_OUT" if [ "$SVC_OK" = 1 ]; then service_as_operator start >/dev/null 2>&1 \ || warn "service installed but did not start; see 'standup service status'" elif printf '%s' "$SVC_OUT" | grep -qi 'connect to bus'; then warn "no systemd --user bus here, so the unit was written but not enabled. If this is WSL2: add [boot] systemd=true to /etc/wsl.conf, then 'wsl --shutdown'. Until then run 'standup daemon' yourself; single mode needs nothing." else warn "service install failed; single mode still works. See 'standup service status'." fi fi # ------------------------------------------------------------- 9. hand over -- step "verifying" standup doctor || warn "some checks need attention (above)" if [ -z "$PROBLEMS" ]; then step "done"; else step "done, with things to know" printf '%s' "$PROBLEMS" say "" fi PORT="${STANDUP_PORT:-7335}" say " dashboard : http://127.0.0.1:$PORT" say "" standup code 2>/dev/null || say " (run 'standup code' for the operator code)" say "" say "Next: cd && standup create"