From 0364eef6fa2af4327eb282252a0156905f07d50e Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Sun, 20 Sep 2026 13:28:08 +0200 Subject: [PATCH] install.sh: support Linux, not just Homebrew scripts/install.sh is advertised in the README as THE one-line install, but its prereq layer only knew brew: on any Linux box it told the user to "Install Homebrew first", which is nonsense advice. There is a real Ubuntu device (VTA) that needs this today, and every Linux cloner of this public repo hits the same wall. Prereq layer: - Detect the platform with `uname -s` and pick a package manager: brew on Darwin, apt-get / dnf / pacman / zypper on Linux. An unrecognised `uname -s` now exits non-zero naming the platform and listing the manual steps, instead of falling through. - No package manager, or no root and no sudo, prints the exact package names plus the manual step and exits non-zero. Never a silent fall-through and never a claim of success. - Every install is printed before it runs, and sudo is called out explicitly. Under `curl | bash` there is no TTY to confirm on, so the installer refuses to run an unattended sudo install: it prints the commands and exits 1. IAK_ASSUME_YES=1 is the documented opt-in. - After installing, re-verify node/npm/git/tmux rather than assuming the package manager did what was wanted. node 20+ on Debian/Ubuntu: the distro `nodejs` package is below our floor (Ubuntu 24.04's candidate is 18.19.1, measured), so apt systems default to NodeSource's official apt repo. That pipes a third-party script into a root shell, so the installer says so out loud before doing it and points at nvm and IAK_NODE_SOURCE=distro as alternatives. With IAK_NODE_SOURCE=distro the re-verify step still refuses to continue on node 18. Other macOS-only assumptions fixed in the same pass, since a fix in one place is not a fix: - The hook-wiring step shelled out to python3. A stock ubuntu:24.04 has no python3, so the installer needed a second language runtime it never declared as a prereq. Rewritten in node, which is already a hard prereq by that point. Verified byte-identical to the python it replaces against a settings.json with pre-existing unrelated hooks, and it now reports malformed JSON instead of throwing a traceback. - `ipconfig getifaddr en0` silently fell back to 127.0.0.1 on Linux, which is useless when the whole point is a LAN URL for the phone. Linux now asks the routing table (`ip -4 route get`), with `hostname -I` as backup, and if it really cannot tell it says the URL will not work and how to find the real one. - The `brew install qrencode` tip is now per-manager. - The macOS Accessibility / osascript block printed unconditionally. It is Darwin-only now; Linux gets a note that the portable Stop-hook resume path is what delivers messages there. - IAK_INSTALL_WATCHDOG installed a launchd LaunchAgent unconditionally. It is Darwin-gated now; on Linux it says the supervisor is launchd-only and points at running team-watchdog.mjs directly. A systemd unit is NOT part of this change. Two bugs found while testing in the container, both of which produced a half-done install that exited 0: - apt's debconf prompted on tzdata and read from stdin. Under `curl | bash` stdin IS the remaining script, so apt ate the rest of the installer and bash exited 0 having only installed packages: no clone, no config, no hooks, and a success exit code. Plan commands now run with stdin closed, plus DEBIAN_FRONTEND=noninteractive. - The final report printed "Daemon: tmux session 'iak-mcp'" and a listener URL even when the daemon had already exited, which it always does on a fresh install because the starter config has no channel configured. It now checks the session and says plainly when the daemon is not running. Also adds IAK_DRY_RUN=1, which prints the platform and prereq plan and changes nothing. Tested in stock ubuntu:24.04 containers (no node/git/tmux/python3/curl/sudo preinstalled): fresh install end to end, a second run for idempotence ("Hooks already present", no package work), and a no-package-manager box. Negative controls: a fake `uname` reporting Plan9 exits 1; forcing the distro node (18.19.1) is rejected by the re-verify step; and the new daemon check was shown reporting both NOT running and running. The Darwin path was exercised on macOS via IAK_DRY_RUN and a PATH sandbox with a fake brew that would have failed loudly if called; this machine's own prereqs, config, hooks and daemon were never touched. Co-Authored-By: Claude Opus 5 --- README.md | 39 +++- scripts/install.sh | 541 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 486 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 767b7e6..8a6c1b1 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,39 @@ Built for [OpenClaw](https://openclaw.dev) workflows. Local-first. No external s Multi-agent coordination toolkit for IDE AIs (Claude Code, Codex, Cursor, VS Code agents, local LLM assistants). Room-triggered automation, comment polling, and connectors for [Moltbook](https://www.moltbook.com), GitHub, and [GroupMind](https://groupmind.one) chat rooms. -**One-shot install (macOS):** +**One-shot install (macOS and Linux):** ```bash curl -fsSL https://raw.githubusercontent.com/ThinkOffApp/ide-agent-kit/main/scripts/install.sh | bash ``` -Idempotent. Installs prereqs via brew, clones the repo, writes a starter -config, wires UserPromptSubmit + Stop hooks, starts the daemon, prints the -LAN URL to paste into CodeWatch — the mobile + watch companion (Play listing in review). +Idempotent. Checks prereqs (node 20+, npm, git, tmux), clones the repo, writes a +starter config, wires UserPromptSubmit + Stop + SessionStart hooks, starts the +daemon, prints the LAN URL to paste into CodeWatch — the mobile + watch companion +(Play listing in review). + +Prereqs are installed via `brew` on macOS and `apt-get` / `dnf` / `pacman` / +`zypper` on Linux. The installer always prints the exact commands first, and +because `curl | bash` leaves no TTY to confirm on, it will **not** run a sudo +install unattended: it prints what to run and exits non-zero. To let it install +for you, download it, read it, and re-run with consent: + +```bash +curl -fsSL https://raw.githubusercontent.com/ThinkOffApp/ide-agent-kit/main/scripts/install.sh -o iak-install.sh +``` +```bash +IAK_ASSUME_YES=1 bash iak-install.sh +``` + +Useful env vars: `IAK_DRY_RUN=1` (print the platform + prereq plan and change +nothing), `IAK_INSTALL_DIR`, `IAK_NODE_SOURCE=distro`, `IAK_NODE_MAJOR`. + +**Note on Node.js on Debian/Ubuntu:** the distro `nodejs` package is below our +floor (Ubuntu 24.04 ships 18.19.1; we need 20+), so on `apt-get` systems the +installer uses [NodeSource](https://github.com/nodesource/distributions)' +official apt repo and says so before running it. If you would rather not pipe a +third-party setup script to root, install Node 20+ yourself (e.g. via `nvm`) and +re-run — the installer will detect it and skip that step. `IAK_NODE_SOURCE=distro` +forces the distro package instead, and the installer still refuses to continue if +the resulting node is too old. **Manual install:** `npm install -g ide-agent-kit` **ClawHub:** https://clawhub.ai/ThinkOffApp/ide-agent-kit @@ -612,7 +638,10 @@ launchctl load ~/Library/LaunchAgents/com.thinkoff.iak-team-watchdog.plist ``` Or let the installer do it: `IAK_INSTALL_WATCHDOG=1` makes `scripts/install.sh` -install the LaunchAgent (only if a roster exists). On a laptop that sleeps, prefer +install the LaunchAgent (only if a roster exists). This part is macOS-only — the +supervisor is a launchd LaunchAgent and there is no systemd unit yet. On Linux +`IAK_INSTALL_WATCHDOG=1` is reported as skipped and you run +`node scripts/team-watchdog.mjs` under your own supervisor. On a laptop that sleeps, prefer the one-shot variant documented in the plist header (`ONCE=1` + `StartInterval`), since the in-process interval timer stalls across sleep. Tunables (all env, all optional): `STALE_MIN`, `COOLDOWN_MIN`, `INTERVAL_MIN`, `MAX_NUDGES`, `ROOM`, diff --git a/scripts/install.sh b/scripts/install.sh index 5c8c8d0..350197d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,21 +1,24 @@ #!/usr/bin/env bash -# IAK one-shot installer for macOS. +# IAK one-shot installer for macOS and Linux. # # Run from a fresh terminal: # curl -fsSL https://raw.githubusercontent.com/ThinkOffApp/ide-agent-kit/main/scripts/install.sh | bash # -# Idempotent — safe to re-run. Prompts before any destructive action. +# Idempotent — safe to re-run. Never installs a package without printing the +# exact command first, and never runs an unattended sudo install when there is +# no TTY (i.e. under `curl | bash`): it prints what to run and exits non-zero. # # What it does: -# 1. Verifies prereqs (node 20+, git, tmux). Installs missing ones via brew if available. +# 1. Detects the platform (uname -s) and a package manager (brew on macOS; +# apt-get / dnf / pacman / zypper on Linux), then verifies prereqs +# (node 20+, npm, git, tmux) and offers to install the missing ones. # 2. Clones the repo to ~/ide-agent-kit (or pulls latest if already there). # 3. npm install. # 4. Writes a starter config to ide-agent-kit.json with sensible defaults # (PORT 8788, host 0.0.0.0 so phone on LAN can reach it). Skips if # config already exists. -# 5. Installs ~/.claude/scripts/check-rooms-hook.sh + claudemb-poll/wake -# shims and registers the UserPromptSubmit + Stop + SessionStart hooks -# in ~/.claude/settings.json (backed up to settings.json.bak before any +# 5. Installs the check-rooms / stop-resume / session-bootstrap hooks into +# ~/.claude/settings.json (backed up to settings.json.bak before any # change). Skips registrations already present. # 6. Starts the daemon in a tmux session named "iak-mcp". # 7. Prints the LAN URL the user should paste into CodeWatch. @@ -24,14 +27,33 @@ # - Generate any signing keys. # - Touch macOS Accessibility permissions (user must grant manually # for the osascript wake to work — the script prints the System -# Settings deep-link). +# Settings deep-link). The osascript GUI-wake path is macOS-only; +# on Linux the portable Stop-hook resume path is used instead. # - Install Claude Code itself. +# +# Environment overrides: +# IAK_INSTALL_DIR where to clone (default ~/ide-agent-kit) +# IAK_TMUX_SESSION daemon tmux session name (default iak-mcp) +# IAK_DRY_RUN=1 print the platform + prereq plan and exit 0, touching +# nothing. Use this to inspect what would happen. +# IAK_ASSUME_YES=1 allow package installs without a TTY confirmation. +# Required for `curl | bash` to install anything. +# IAK_NODE_MAJOR node major to install from NodeSource (default 22) +# IAK_NODE_SOURCE on Debian/Ubuntu: "nodesource" (default) or "distro". +# The distro `nodejs` package is too old on current +# Ubuntu (24.04 ships 18.x; we need 20+), so the +# default routes through NodeSource. See below. +# IAK_INSTALL_WATCHDOG=1 install the peer-wake watchdog (macOS only) set -euo pipefail REPO="https://github.com/ThinkOffApp/ide-agent-kit.git" INSTALL_DIR="${IAK_INSTALL_DIR:-$HOME/ide-agent-kit}" TMUX_SESSION="${IAK_TMUX_SESSION:-iak-mcp}" +NODE_MIN_MAJOR=20 +NODE_INSTALL_MAJOR="${IAK_NODE_MAJOR:-22}" +DRY_RUN="${IAK_DRY_RUN:-0}" +ASSUME_YES="${IAK_ASSUME_YES:-0}" bold() { printf "\033[1m%s\033[0m\n" "$*"; } green() { printf "\033[32m%s\033[0m\n" "$*"; } @@ -41,29 +63,279 @@ red() { printf "\033[31m%s\033[0m\n" "$*" >&2; } bold "ide-agent-kit one-shot installer" echo -# 1. prereqs -need_brew=() -command -v node >/dev/null || need_brew+=(node) -command -v git >/dev/null || need_brew+=(git) -command -v tmux >/dev/null || need_brew+=(tmux) +# --------------------------------------------------------------------------- +# 1. platform + package manager detection +# --------------------------------------------------------------------------- +OS="$(uname -s 2>/dev/null || echo unknown)" +PKG="" + +case "$OS" in + Darwin) + PLATFORM_LABEL="macOS (Darwin)" + if command -v brew >/dev/null 2>&1; then PKG="brew"; fi + ;; + Linux) + PLATFORM_LABEL="Linux" + for m in apt-get dnf pacman zypper; do + if command -v "$m" >/dev/null 2>&1; then PKG="$m"; break; fi + done + ;; + *) + red "Unsupported platform: 'uname -s' reported '$OS'." + red "This installer supports macOS (Darwin) and Linux only." + red "" + red "Install these yourself and then run the daemon by hand:" + red " node ${NODE_MIN_MAJOR}+, npm, git, tmux" + red " git clone $REPO $INSTALL_DIR" + red " cd $INSTALL_DIR && npm install && node bin/iak-mcp-daemon.mjs" + exit 1 + ;; +esac -if [ "${#need_brew[@]}" -gt 0 ]; then - if command -v brew >/dev/null; then - yellow "Installing missing prereqs via brew: ${need_brew[*]}" - brew install "${need_brew[@]}" +echo "Platform: $PLATFORM_LABEL" +if [ -n "$PKG" ]; then + echo "Package manager: $PKG" +else + echo "Package manager: none detected" +fi + +# Are we able to prompt? `curl | bash` gives us no TTY on stdin, which is +# exactly when an unattended sudo install would be least welcome. +if [ -t 0 ]; then INTERACTIVE=1; else INTERACTIVE=0; fi + +# sudo is needed for every Linux package manager unless we are already root. +SUDO="" +HAVE_PRIVS=1 +if [ "$OS" != "Darwin" ] && [ "$(id -u)" -ne 0 ]; then + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo " else - red "Missing: ${need_brew[*]}. Install Homebrew (https://brew.sh) first, then re-run." + HAVE_PRIVS=0 + fi +fi +# Piping into a root shell: "sudo -E bash -" as a user, plain "bash -" as root. +# ${SUDO}-E would expand to a bare "-E" when SUDO is empty. +if [ -n "$SUDO" ]; then SUDO_PIPE="sudo -E bash -"; else SUDO_PIPE="bash -"; fi +# apt's debconf will prompt (tzdata, etc.). Under `curl | bash` stdin IS the +# script, so a prompting child silently swallows the rest of the installer and +# we exit 0 having done half the job. Belt and braces: DEBIAN_FRONTEND, plus +# every plan command runs with stdin closed (see the exec loop below). +APT="${SUDO}env DEBIAN_FRONTEND=noninteractive apt-get" + +# --------------------------------------------------------------------------- +# 2. prereq detection +# --------------------------------------------------------------------------- +# Package names differ per manager. Keep this table honest: an entry that is a +# guess is worse than no entry, because the install fails halfway. +pkg_name() { + case "$1:$2" in + brew:git) echo git ;; + brew:tmux) echo tmux ;; + brew:qrencode) echo qrencode ;; + apt-get:git) echo git ;; + apt-get:tmux) echo tmux ;; + apt-get:qrencode) echo qrencode ;; + dnf:git) echo git ;; + dnf:tmux) echo tmux ;; + dnf:qrencode) echo qrencode ;; + pacman:git) echo git ;; + pacman:tmux) echo tmux ;; + pacman:qrencode) echo qrencode ;; + zypper:git) echo git ;; + zypper:tmux) echo tmux ;; + zypper:qrencode) echo qrencode ;; + *) echo "$2" ;; + esac +} + +# What WE run (may carry the noninteractive env wrapper). +# pkg_install_hint below is what we TELL the user to type. +pkg_install_prefix() { + case "$1" in + brew) echo "brew install" ;; + apt-get) echo "$APT install -y" ;; + dnf) echo "${SUDO}dnf install -y" ;; + pacman) echo "${SUDO}pacman -S --needed --noconfirm" ;; + zypper) echo "${SUDO}zypper --non-interactive install" ;; + *) echo "" ;; + esac +} + +pkg_install_hint() { + case "$1" in + brew) echo "brew install" ;; + apt-get) echo "${SUDO}apt-get install -y" ;; + dnf) echo "${SUDO}dnf install -y" ;; + pacman) echo "${SUDO}pacman -S --needed" ;; + zypper) echo "${SUDO}zypper install" ;; + *) echo "" ;; + esac +} + +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + local major + major="$(node -v 2>/dev/null | sed 's/^v//; s/\..*//')" + case "$major" in ''|*[!0-9]*) return 1 ;; esac + [ "$major" -ge "$NODE_MIN_MAJOR" ] +} + +NEED_NODE=0 +NEED_TOOLS=() +node_ok || NEED_NODE=1 +command -v npm >/dev/null 2>&1 || NEED_NODE=1 +command -v git >/dev/null 2>&1 || NEED_TOOLS+=(git) +command -v tmux >/dev/null 2>&1 || NEED_TOOLS+=(tmux) + +# Build the exact command list we would run, so we can show it before running. +PLAN=() +if [ "$NEED_NODE" = 1 ] || [ "${#NEED_TOOLS[@]}" -gt 0 ]; then + if [ -z "$PKG" ]; then + red "Missing prereqs and no supported package manager was found." + red "" + if [ "$OS" = "Darwin" ]; then + red "Install Homebrew (https://brew.sh), then re-run this installer." + else + red "Looked for: apt-get, dnf, pacman, zypper — none are on PATH." + fi + red "" + red "Install these by hand, then re-run:" + [ "$NEED_NODE" = 1 ] && red " node ${NODE_MIN_MAJOR}+ and npm (https://nodejs.org/en/download)" + for t in "${NEED_TOOLS[@]:-}"; do [ -n "$t" ] && red " $t"; done exit 1 fi + + if [ "$HAVE_PRIVS" = 0 ]; then + red "Missing prereqs need a package install, but this user is not root and" + red "sudo is not available." + red "" + red "Run as root, or install by hand:" + [ "$NEED_NODE" = 1 ] && red " node ${NODE_MIN_MAJOR}+ and npm" + for t in "${NEED_TOOLS[@]:-}"; do [ -n "$t" ] && red " $t"; done + exit 1 + fi + + if [ "$PKG" = "apt-get" ]; then + PLAN+=("$APT update") + fi + + if [ "${#NEED_TOOLS[@]}" -gt 0 ]; then + names="" + for t in "${NEED_TOOLS[@]}"; do names="$names $(pkg_name "$PKG" "$t")"; done + PLAN+=("$(pkg_install_prefix "$PKG")$names") + fi + + if [ "$NEED_NODE" = 1 ]; then + case "$PKG" in + brew) + PLAN+=("brew install node") + ;; + apt-get) + # Debian/Ubuntu's own `nodejs` package is routinely far below our + # floor — Ubuntu 24.04's candidate is 18.19.1, and we need 20+. So + # the default is NodeSource's official apt repo, which is a + # third-party script we pipe to a root shell. We say so out loud + # rather than doing it quietly. Override with IAK_NODE_SOURCE=distro + # if your apt really does carry node 20+ (e.g. Debian trixie). + if [ "${IAK_NODE_SOURCE:-nodesource}" = "distro" ]; then + PLAN+=("$APT install -y nodejs npm") + else + NODE_VIA_NODESOURCE=1 + PLAN+=("$APT install -y ca-certificates curl gnupg") + PLAN+=("curl -fsSL https://deb.nodesource.com/setup_${NODE_INSTALL_MAJOR}.x | $SUDO_PIPE") + PLAN+=("$APT install -y nodejs") + fi + ;; + dnf) + PLAN+=("${SUDO}dnf install -y nodejs npm") + ;; + pacman) + PLAN+=("${SUDO}pacman -S --needed --noconfirm nodejs npm") + ;; + zypper) + PLAN+=("${SUDO}zypper --non-interactive install nodejs${NODE_INSTALL_MAJOR} npm${NODE_INSTALL_MAJOR}") + ;; + esac + fi +fi + +if [ "${#PLAN[@]}" -eq 0 ]; then + green "Prereqs OK: node $(node -v), npm $(npm -v), git, tmux." +else + yellow "Missing prereqs. This installer wants to run:" + echo + for cmd in "${PLAN[@]}"; do echo " $cmd"; done + echo + if [ -n "$SUDO" ]; then + yellow "Those lines use sudo — they change system packages and will ask for" + yellow "your password." + fi + if [ "${NODE_VIA_NODESOURCE:-0}" = 1 ]; then + yellow "Heads up: one of those lines pipes a script from deb.nodesource.com" + yellow "into a root shell. That is NodeSource's official Node.js apt setup." + yellow "If you would rather not, install node ${NODE_MIN_MAJOR}+ yourself (nvm, or" + yellow "IAK_NODE_SOURCE=distro if your distro carries a new enough node) and" + yellow "re-run this installer." + fi + echo +fi + +if [ "$DRY_RUN" = "1" ]; then + echo + bold "IAK_DRY_RUN=1 — stopping here. Nothing was installed or changed." + echo "Would clone to: $INSTALL_DIR" + echo "Would wire hooks: $HOME/.claude/settings.json" + echo "Would start tmux: $TMUX_SESSION" + exit 0 +fi + +if [ "${#PLAN[@]}" -gt 0 ]; then + if [ "$ASSUME_YES" != "1" ]; then + if [ "$INTERACTIVE" != "1" ]; then + red "Not running those: there is no TTY to confirm on (you piped this" + red "script into bash, so stdin is the script itself)." + red "" + red "Run the lines above yourself, then re-run the installer. Or, if you" + red "have read them and want this installer to run them unattended:" + red "" + red " curl -fsSL ${REPO%.git}/raw/main/scripts/install.sh -o iak-install.sh" + red " less iak-install.sh # read it" + red " IAK_ASSUME_YES=1 bash iak-install.sh" + exit 1 + fi + printf "Run them now? [y/N] " + read -r reply + case "$reply" in + [yY]|[yY][eE][sS]) ;; + *) red "Aborted. Nothing was installed."; exit 1 ;; + esac + fi + for cmd in "${PLAN[@]}"; do + yellow "+ $cmd" + # /dev/null 2>&1; then red " found: $(node -v)"; fi + red "Install node ${NODE_MIN_MAJOR}+ manually (https://nodejs.org/en/download) and re-run." exit 1 fi +for t in npm git tmux; do + if ! command -v "$t" >/dev/null 2>&1; then + red "$t is still missing after the install step. Install it and re-run." + exit 1 + fi +done -# 2. clone or pull +# --------------------------------------------------------------------------- +# 3. clone or pull +# --------------------------------------------------------------------------- if [ -d "$INSTALL_DIR/.git" ]; then yellow "$INSTALL_DIR exists; pulling latest" (cd "$INSTALL_DIR" && git pull --ff-only) @@ -72,10 +344,14 @@ else git clone "$REPO" "$INSTALL_DIR" fi -# 3. npm install +# --------------------------------------------------------------------------- +# 4. npm install +# --------------------------------------------------------------------------- (cd "$INSTALL_DIR" && npm install --silent) -# 4. starter config +# --------------------------------------------------------------------------- +# 5. starter config +# --------------------------------------------------------------------------- CONFIG="$INSTALL_DIR/ide-agent-kit.json" if [ ! -f "$CONFIG" ]; then yellow "Writing starter config to $CONFIG" @@ -112,7 +388,9 @@ EOF echo fi -# 5. Claude Code hook wiring +# --------------------------------------------------------------------------- +# 6. Claude Code hook wiring +# --------------------------------------------------------------------------- SETTINGS="$HOME/.claude/settings.json" SCRIPTS_DIR="$INSTALL_DIR/scripts" # First-time installs may predate Claude Code ever writing settings.json - @@ -123,42 +401,58 @@ if [ ! -f "$SETTINGS" ]; then printf '{}\n' > "$SETTINGS" yellow "Created minimal $SETTINGS (did not exist yet)" fi -if [ -f "$SETTINGS" ]; then - yellow "Wiring UserPromptSubmit + Stop + SessionStart hooks in $SETTINGS" - python3 - "$SETTINGS" "$SCRIPTS_DIR" <<'PY' -import json, shutil, sys, os -settings_path, scripts_dir = sys.argv[1], sys.argv[2] -data = json.load(open(settings_path)) -data.setdefault("hooks", {}) -def ensure_hook(event, cmd, timeout=None): - arr = data["hooks"].setdefault(event, []) - for entry in arr: - for h in entry.get("hooks", []): - if h.get("command") == cmd: return False - hook = {"type":"command","command":cmd} - if timeout is not None: hook["timeout"] = timeout - arr.append({"matcher":"","hooks":[hook]}) - return True -changed = False -changed |= ensure_hook("UserPromptSubmit", f"bash {scripts_dir}/check-rooms-hook.sh") -changed |= ensure_hook("Stop", f"bash {scripts_dir}/claudecode-stop-resume.sh") -# Self-arming room agent: on every session start (startup/resume/compact) the -# hook injects instructions to re-arm the notification-file Monitor, read any -# backlog, and keep the self-paced room loop running — no manual -# "/loop check rooms" needed after a restart. -changed |= ensure_hook("SessionStart", f"bash {scripts_dir}/session-bootstrap.sh", timeout=10) -if changed: - shutil.copyfile(settings_path, settings_path + ".bak") - json.dump(data, open(settings_path,"w"), indent=2) - print("Hooks installed.") -else: - print("Hooks already present.") -PY -else - yellow "No ~/.claude/settings.json yet — skipping hook wiring. Re-run after first Claude Code launch." -fi +yellow "Wiring UserPromptSubmit + Stop + SessionStart hooks in $SETTINGS" +# This used to be a python3 heredoc. A stock ubuntu:24.04 has no python3, so +# that made the installer need a second language runtime it never declared as +# a prereq. node 20+ is already guaranteed by this point, so use it. +HOOK_JS="$(mktemp "${TMPDIR:-/tmp}/iak-hooks.XXXXXX")" +cat > "$HOOK_JS" <<'JS' +const fs = require('fs'); +const [settingsPath, scriptsDir] = process.argv.slice(2); +let data; +try { + const raw = fs.readFileSync(settingsPath, 'utf8').trim(); + data = raw ? JSON.parse(raw) : {}; +} catch (err) { + console.error('Could not parse ' + settingsPath + ': ' + err.message); + console.error('Fix or move that file, then re-run the installer.'); + process.exit(1); +} +if (data === null || typeof data !== 'object' || Array.isArray(data)) data = {}; +if (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)) data.hooks = {}; +let changed = false; +function ensureHook(event, command, timeout) { + if (!Array.isArray(data.hooks[event])) data.hooks[event] = []; + for (const entry of data.hooks[event]) { + for (const hook of (entry && entry.hooks) || []) { + if (hook && hook.command === command) return; + } + } + const hook = { type: 'command', command }; + if (timeout !== undefined) hook.timeout = timeout; + data.hooks[event].push({ matcher: '', hooks: [hook] }); + changed = true; +} +ensureHook('UserPromptSubmit', `bash ${scriptsDir}/check-rooms-hook.sh`); +ensureHook('Stop', `bash ${scriptsDir}/claudecode-stop-resume.sh`); +// Self-arming room agent: on every session start (startup/resume/compact) the +// hook injects instructions to re-arm the notification-file Monitor, read any +// backlog, and keep the self-paced room loop running. +ensureHook('SessionStart', `bash ${scriptsDir}/session-bootstrap.sh`, 10); +if (changed) { + fs.copyFileSync(settingsPath, settingsPath + '.bak'); + fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n'); + console.log('Hooks installed.'); +} else { + console.log('Hooks already present.'); +} +JS +node "$HOOK_JS" "$SETTINGS" "$SCRIPTS_DIR" +rm -f "$HOOK_JS" -# 6. start daemon in tmux +# --------------------------------------------------------------------------- +# 7. start daemon in tmux +# --------------------------------------------------------------------------- if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then yellow "Restarting daemon in tmux session $TMUX_SESSION" tmux kill-session -t "$TMUX_SESSION" @@ -166,62 +460,131 @@ fi tmux new-session -d -s "$TMUX_SESSION" \ "cd $INSTALL_DIR && node bin/iak-mcp-daemon.mjs" sleep 2 +# The daemon exits immediately if mcp.confirmations has no room configured, +# which is exactly the state of a freshly written starter config. Check +# instead of printing "Daemon: tmux session 'iak-mcp'" over an empty tmux. +DAEMON_UP=0 +tmux has-session -t "$TMUX_SESSION" 2>/dev/null && DAEMON_UP=1 -# 6b. Optional: peer-wake team-watchdog (opt-in; needs a roster). +# 7b. Optional: peer-wake team-watchdog (opt-in; needs a roster). # OFF by default — enable with IAK_INSTALL_WATCHDOG=1. Revives sleeping # colleagues whose IDEs run on THIS machine; see README "Peer wake". if [ "${IAK_INSTALL_WATCHDOG:-0}" = "1" ]; then - ROSTER_FILE="$INSTALL_DIR/config/watchdog-roster.json" - if [ ! -f "$ROSTER_FILE" ]; then - yellow "IAK_INSTALL_WATCHDOG=1 but no roster at $ROSTER_FILE — skipping." - echo " Create one from the example, then re-run with IAK_INSTALL_WATCHDOG=1:" - echo " sed \"s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g\" \\" - echo " $INSTALL_DIR/config/watchdog-roster.example.json > $ROSTER_FILE" - echo " # then edit handles/paths for the agents whose IDEs run on THIS Mac" + if [ "$OS" != "Darwin" ]; then + # The watchdog itself (scripts/team-watchdog.mjs) is plain node, but the + # only supervisor wired up here is a launchd LaunchAgent, which does not + # exist off macOS. A systemd unit is not written yet — say so instead of + # pretending. + yellow "IAK_INSTALL_WATCHDOG=1 ignored: the watchdog supervisor is a launchd" + yellow "LaunchAgent and this is not macOS. Run it under your own supervisor:" + echo " node $INSTALL_DIR/scripts/team-watchdog.mjs" + echo " (roster: $INSTALL_DIR/config/watchdog-roster.json)" else - LA_DIR="$HOME/Library/LaunchAgents" - PLIST="$LA_DIR/com.thinkoff.iak-team-watchdog.plist" - mkdir -p "$LA_DIR" - sed "s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g" \ - "$INSTALL_DIR/examples/team-watchdog-launchd.plist" > "$PLIST" - yellow "Installed peer-wake watchdog LaunchAgent → $PLIST" - if launchctl list 2>/dev/null | grep -q com.thinkoff.iak-team-watchdog; then - echo " Already loaded. Reload after edits:" - echo " launchctl unload \"$PLIST\" && launchctl load \"$PLIST\"" - elif launchctl load "$PLIST" 2>/dev/null; then - green " Loaded (KeepAlive; reads $ROSTER_FILE)." + ROSTER_FILE="$INSTALL_DIR/config/watchdog-roster.json" + if [ ! -f "$ROSTER_FILE" ]; then + yellow "IAK_INSTALL_WATCHDOG=1 but no roster at $ROSTER_FILE — skipping." + echo " Create one from the example, then re-run with IAK_INSTALL_WATCHDOG=1:" + echo " sed \"s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g\" \\" + echo " $INSTALL_DIR/config/watchdog-roster.example.json > $ROSTER_FILE" + echo " # then edit handles/paths for the agents whose IDEs run on THIS Mac" else - yellow " Load it manually: launchctl load \"$PLIST\"" + LA_DIR="$HOME/Library/LaunchAgents" + PLIST="$LA_DIR/com.thinkoff.iak-team-watchdog.plist" + mkdir -p "$LA_DIR" + sed "s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g" \ + "$INSTALL_DIR/examples/team-watchdog-launchd.plist" > "$PLIST" + yellow "Installed peer-wake watchdog LaunchAgent → $PLIST" + if launchctl list 2>/dev/null | grep -q com.thinkoff.iak-team-watchdog; then + echo " Already loaded. Reload after edits:" + echo " launchctl unload \"$PLIST\" && launchctl load \"$PLIST\"" + elif launchctl load "$PLIST" 2>/dev/null; then + green " Loaded (KeepAlive; reads $ROSTER_FILE)." + else + yellow " Load it manually: launchctl load \"$PLIST\"" + fi fi fi fi -# 7. report -LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1") +# --------------------------------------------------------------------------- +# 8. report +# --------------------------------------------------------------------------- +lan_ip() { + if [ "$OS" = "Darwin" ]; then + ipconfig getifaddr en0 2>/dev/null && return 0 + ipconfig getifaddr en1 2>/dev/null && return 0 + else + # Ask the routing table which source address would be used to reach the + # outside world. Works without any interface being named en0. + if command -v ip >/dev/null 2>&1; then + ip -4 route get 1.1.1.1 2>/dev/null \ + | awk '{for (i = 1; i < NF; i++) if ($i == "src") { print $(i+1); exit }}' \ + | grep . && return 0 + fi + if command -v hostname >/dev/null 2>&1; then + hostname -I 2>/dev/null | awk '{print $1}' | grep . && return 0 + fi + fi + echo "127.0.0.1" +} +LAN_IP="$(lan_ip)" LAN_URL="http://${LAN_IP}:8788" + echo green "Installed." echo -bold "Daemon: tmux session '$TMUX_SESSION'" -echo " - logs: tmux attach -t $TMUX_SESSION" -echo " - listener: $LAN_URL" +if [ "$DAEMON_UP" = 1 ]; then + bold "Daemon: running in tmux session '$TMUX_SESSION'" + echo " - logs: tmux attach -t $TMUX_SESSION" + echo " - listener: $LAN_URL" +else + yellow "Daemon: NOT running." + echo " It exited straight away, which is what happens when the config has no" + echo " channel configured yet. Fill these in, then re-run this installer" + echo " (or: tmux new -d -s $TMUX_SESSION 'cd $INSTALL_DIR && node bin/iak-mcp-daemon.mjs'):" + echo " - $CONFIG → poller.api_key" + echo " - $CONFIG → mcp.confirmations.room" + echo " To see why it exited:" + echo " cd $INSTALL_DIR && node bin/iak-mcp-daemon.mjs" + echo " Once running it listens on: $LAN_URL" +fi +if [ "$LAN_IP" = "127.0.0.1" ]; then + echo + yellow "Could not work out this machine's LAN IP, so the URL above is loopback" + yellow "and your phone will NOT reach it. Find the real address and use that:" + if [ "$OS" = "Darwin" ]; then + echo " ipconfig getifaddr en0" + else + echo " ip -4 addr show scope global" + fi +fi echo bold "CodeWatch on phone — pair via QR or manual paste:" echo " - URL: $LAN_URL" echo -# Print a scannable QR if qrencode is available. Install with: brew install qrencode if command -v qrencode >/dev/null 2>&1; then echo "Scan from CodeWatch (Account tab → Scan Pairing QR):" echo qrencode -t UTF8 "$LAN_URL" echo else - yellow "Tip: brew install qrencode → re-run installer to get a scannable QR for CodeWatch pairing." + if [ -n "$PKG" ]; then + yellow "Tip: $(pkg_install_hint "$PKG") $(pkg_name "$PKG" qrencode) → re-run installer to get a scannable QR for CodeWatch pairing." + else + yellow "Tip: install qrencode → re-run installer to get a scannable QR for CodeWatch pairing." + fi fi echo -bold "macOS Accessibility (one-time, for osascript-based desktop-app wake):" -echo " - System Settings → Privacy & Security → Accessibility" -echo " - Add: /usr/bin/osascript (or whatever process runs the daemon — usually iTerm/Terminal/tmux)" +if [ "$OS" = "Darwin" ]; then + bold "macOS Accessibility (one-time, for osascript-based desktop-app wake):" + echo " - System Settings → Privacy & Security → Accessibility" + echo " - Add: /usr/bin/osascript (or whatever process runs the daemon — usually iTerm/Terminal/tmux)" +else + bold "Wake path on Linux:" + echo " - The osascript GUI-wake scripts are macOS-only and do nothing here." + echo " - The Stop-hook resume path (claudecode-stop-resume.sh, wired above)" + echo " is portable and is what delivers room messages on this box." +fi echo bold "Edit your config:" echo " - $CONFIG"