#!/bin/sh set -e REPO="SvetimFM/cludcode-releases" INSTALL_DIR="$HOME/.cludcode" BIN_DIR="$INSTALL_DIR/bin" # Latest version resolved SERVER-SIDE at request time (see resolveLatestCached # in @/lib/releaseResolver) and baked in here, so the client makes ZERO # GitHub-API calls in the common case — fixing the rate-limit/CDN failure of # client-side resolution. Empty only if the server couldn't resolve (then the # client-side pagination below is the fallback). RS_PINNED_VERSION="0.12.596" # The TypeScript agent is retired; this script installs only the Rust agent. # AGENT_VARIANT is read for one reason: an explicit request for the TypeScript # agent is refused by name, never answered with the Rust agent. case "${AGENT_VARIANT:-rs}" in [Rr][Ss]) ;; [Tt][Ss]) printf '%s\n' 'The TypeScript agent is retired and no longer distributed: this installer installs only the Rust agent.' 'Install the Rust agent instead. A machine running the TypeScript agent is migrated:' ' curl -fsSL https://cluterm.com/install | sh' >&2 printf '%s\n' 'Unset AGENT_VARIANT before you run it.' >&2 exit 1 ;; *) printf "Unknown AGENT_VARIANT '%s' — this installer installs only the Rust agent; unset AGENT_VARIANT\n" "$AGENT_VARIANT" >&2 exit 1 ;; esac RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # Whether a terminal is attached that sudo could ask a password on. The CluTerm # app runs this script with none (stdin, stdout and stderr are all pipes); this # script reads no input, and the one thing that would want a terminal, the sudo # fallback in _remove_stale, is skipped without one. stdin is no guide — `curl # … | sh` makes it a pipe in a terminal too — so ask for the controlling # terminal itself. if (true /dev/null; then HAS_TTY=true; else HAS_TTY=false; fi printf "\n" printf " ${BOLD}CluTerm installer${NC}\n\n" TARGET="${1:-latest}" IS_WSL=false detect_platform() { local os arch case "$(uname -s)" in Darwin) os="darwin" ;; Linux) os="linux" # Detect WSL if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true printf " ${CYAN}Windows Subsystem for Linux detected${NC}\n" >&2 fi ;; MINGW*|MSYS*|CYGWIN*) printf "${YELLOW}This is the POSIX (sh) installer; you're on native Windows.${NC}\n" printf "Install the native Windows agent from PowerShell:\n" printf " ${BOLD}irm https://cluterm.com/install.ps1 | iex${NC}\n" printf "\n" printf "Or run the Linux agent under WSL:\n" printf " ${CYAN}1.${NC} Open PowerShell as Administrator\n" printf " ${CYAN}2.${NC} Run: ${BOLD}wsl --install${NC}\n" printf " ${CYAN}3.${NC} Open Ubuntu, then re-run this installer inside WSL\n" exit 1 ;; *) printf "${RED}Unsupported OS: %s${NC}\n" "$(uname -s)"; exit 1 ;; esac case "$(uname -m)" in x86_64|amd64) arch="x64" ;; arm64|aarch64) arch="arm64" ;; *) printf "${RED}Unsupported arch${NC}\n"; exit 1 ;; esac printf "%s" "${os}-${arch}" } download() { local url="$1" output="$2" if command -v curl >/dev/null 2>&1; then if [ -n "$output" ]; then curl -fsSL -o "$output" "$url" 2>/dev/null || curl -fSL -o "$output" "$url" else curl -fsSL "$url" 2>/dev/null || curl -fSL "$url" fi elif command -v wget >/dev/null 2>&1; then if [ -n "$output" ]; then wget -q -O "$output" "$url" else wget -q -O - "$url" fi else printf "${RED}curl or wget required${NC}\n"; exit 1 fi } PLATFORM=$(detect_platform) printf " Platform: ${CYAN}%s${NC}\n" "${PLATFORM}" # Release coordinates. The Rust agent (agent-rs) ships from # `SvetimFM/cludcode-releases` under `agent-rs-v*` tags, each with its own # `rust-manifest.json`. The same repo holds the desktop app's `v*-desktop` # tags and the retired TypeScript agent's `v0.*` tags, which this script never # installs. `TAG_SED` extracts the version portion from a `"tag_name"` line in # the GitHub releases API response — must be silent (`-n`) and only print on # match (`p`). See .github/workflows/release-agent-rs.yml. TAG_PREFIX="agent-rs-v" MANIFEST_FILE="rust-manifest.json" BINARY_PREFIX="cluterm-rs-" TAG_SED='s/.*"agent-rs-v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' if [ "$TARGET" = "latest" ]; then if [ -n "$RS_PINNED_VERSION" ]; then # Server-resolved at request time — no GitHub-API call, no rate limit. VERSION="$RS_PINNED_VERSION" else printf " Fetching latest version...\n" # The releases repo intermixes desktop (`v*-desktop`), TS (`v*`) and rs # (`agent-rs-v*`) tags that all share one created_at, so the GitHub API # scatters the rs tags across pages in no useful order. Page through ALL # releases (bounded), collect the rs versions, then pick the highest # semver. The old single page-1 scan never saw the rs tags → "No Rust agent # release found". (GitHub's "Latest" pointer is no fallback: rs releases # ship --latest=false, so it never names one.) ALL_TAGS="" page=1 while [ "$page" -le 20 ]; do PAGE_TAGS=$(download "https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}" 2>/dev/null | grep '"tag_name"') [ -z "$PAGE_TAGS" ] && break # past the last page → stop ALL_TAGS="${ALL_TAGS} ${PAGE_TAGS}" page=$((page + 1)) done VERSION=$(printf '%s\n' "$ALL_TAGS" | sed -n "${TAG_SED}" | sort -V -r | head -1 | tr -d '[:space:]') if [ -z "$VERSION" ]; then printf "${RED}✗ No Rust agent release found (looked for tags matching agent-rs-v*)${NC}\n" printf " Try again in a few minutes, or see https://cluterm.com/docs\n" exit 1 fi fi else VERSION="$TARGET" fi printf " Version: ${CYAN}v%s${NC}\n" "${VERSION}" mkdir -p "$BIN_DIR" BINARY_NAME="${BINARY_PREFIX}${PLATFORM}" BINARY_URL="https://github.com/${REPO}/releases/download/${TAG_PREFIX}${VERSION}/${BINARY_NAME}" # Layout: the Rust binary at BIN_DIR/clud-rs, which handles supervised mode # itself (sets its own env, exits 75 directly), and three symlinks to it on # PATH, written below: `cluterm`, `clu` and the back-compat `cludcode`. BINARY_PATH="$BIN_DIR/clud-rs" UNIFIED_LINK="$BIN_DIR/cluterm" # The download lands here, never at $BINARY_PATH directly: every check below # (size, checksum, codesign, version) runs against this file, which nothing # on PATH or any symlink ever reaches, before an atomic mv -f makes it live. # $$ keys it to this run; the trap below removes it on every exit, success or # failure (mv -f already moved it away on success, so that's a no-op then). TMP_BINARY_PATH="$BIN_DIR/.clud-rs.new.$$" # Fetch manifest from the same release tag as the binary — to a FILE, not a # shell variable: the Ed25519 signature below covers the manifest's EXACT raw # bytes, and command substitution strips trailing newlines. SIG_TMP=$(mktemp -d 2>/dev/null || printf '/tmp/cludcode-install.%s' "$$") mkdir -p "$SIG_TMP" trap 'rm -rf "$SIG_TMP"; rm -f "$TMP_BINARY_PATH"' EXIT MANIFEST_PATH="${SIG_TMP}/${MANIFEST_FILE}" download "https://github.com/${REPO}/releases/download/${TAG_PREFIX}${VERSION}/${MANIFEST_FILE}" "$MANIFEST_PATH" 2>/dev/null || true if [ ! -s "$MANIFEST_PATH" ]; then printf " ${RED}✗ Failed to download ${MANIFEST_FILE} — cannot verify binary${NC}\n" exit 1 fi # ── Manifest authenticity: detached Ed25519 signature ── # The SHA-256 checksum below only guards corruption — a compromised release # repo/CDN can serve a malicious binary WITH a matching checksum. Authenticity # comes from verifying ${MANIFEST_FILE}.sig against the release-signing public # keys baked here — the same list the agent's updater pins # (BAKED_RELEASE_SIGNING_KEYS in clud-updater). The owner signs every manifest # offline; the signature is genuine if ANY listed key verifies it, which is # what lets that key rotate. One key per line from a function and read back # with `while read` — never an unquoted `for k in $LIST`, which zsh (curl | # zsh) does not word-split and would try as one bogus key. # # POSIX sh can't do Ed25519 natively, so this uses openssl (1.1.1 or newer). # We SELF-TEST the toolchain with a local sign/verify roundtrip first: only a # toolchain that demonstrably CAN verify is allowed to fail the install on a # bad signature ("openssl too old" must not read as "release compromised"). # Once the probe has passed, every later failure REFUSES the install — a listed # key that will not load is a defect, not a missing capability. # # With no capable openssl on PATH, what happens depends on what else can vouch # for the binary: # - macOS: its openssl is LibreSSL, which has no Ed25519, and nothing else a # stock Mac ships verifies a raw Ed25519 signature. There the agent binary # is held to CluTerm's Apple Developer ID signature (below), a check that # needs nothing from the manifest. It is the only download the manifest # vouches for: no helper is installed (clud-pipe is no longer shipped). # - Linux: the manifest signature is the ONLY thing that vouches for the # binary, so the install stops. # CLUDCODE_SKIP_SIG_VERIFY=1 is the one way to install on an unverified # manifest's word (on macOS the Apple signature is still required). release_signing_pubkeys() { cat <<'CLUTERM_RELEASE_KEYS' xHAkcumcfqFgBIbtGXTcqgYV6OpIYEeW1YZi9+UElMI= CLUTERM_RELEASE_KEYS } verify_manifest_signature() { command -v openssl >/dev/null 2>&1 || { printf "no-openssl"; return 0; } # Capability probe: ephemeral Ed25519 sign/verify roundtrip. if ! openssl genpkey -algorithm ed25519 -out "${SIG_TMP}/probe.pem" >/dev/null 2>&1; then printf "no-ed25519"; return 0 fi printf 'probe' > "${SIG_TMP}/probe.msg" openssl pkeyutl -sign -rawin -inkey "${SIG_TMP}/probe.pem" -in "${SIG_TMP}/probe.msg" -out "${SIG_TMP}/probe.sig" >/dev/null 2>&1 || { printf "no-ed25519"; return 0; } openssl pkey -in "${SIG_TMP}/probe.pem" -pubout -out "${SIG_TMP}/probe.pub" >/dev/null 2>&1 || { printf "no-ed25519"; return 0; } openssl pkeyutl -verify -rawin -pubin -inkey "${SIG_TMP}/probe.pub" -in "${SIG_TMP}/probe.msg" -sigfile "${SIG_TMP}/probe.sig" >/dev/null 2>&1 || { printf "no-ed25519"; return 0; } # Toolchain is capable — from here on, failures are REAL. Fetch the sig. download "https://github.com/${REPO}/releases/download/${TAG_PREFIX}${VERSION}/${MANIFEST_FILE}.sig" "${SIG_TMP}/manifest.sig.b64" 2>/dev/null || true [ -s "${SIG_TMP}/manifest.sig.b64" ] || { printf "missing-sig"; return 0; } openssl base64 -d -A < "${SIG_TMP}/manifest.sig.b64" > "${SIG_TMP}/manifest.sig" 2>/dev/null release_signing_pubkeys > "${SIG_TMP}/keys" while IFS= read -r SIGNING_KEY; do [ -n "$SIGNING_KEY" ] || continue # Rebuild the key's SPKI DER: 12-byte Ed25519 header + the raw 32B key # (same ED25519_SPKI_DER_PREFIX Node uses in cosign.ts). printf '\060\052\060\005\006\003\053\145\160\003\041\000' > "${SIG_TMP}/pub.der" printf '%s' "$SIGNING_KEY" | openssl base64 -d -A >> "${SIG_TMP}/pub.der" openssl pkey -pubin -inform DER -in "${SIG_TMP}/pub.der" -out "${SIG_TMP}/pub.pem" >/dev/null 2>&1 || { printf "bad-sig"; return 0; } if openssl pkeyutl -verify -rawin -pubin -inkey "${SIG_TMP}/pub.pem" -in "$MANIFEST_PATH" -sigfile "${SIG_TMP}/manifest.sig" >/dev/null 2>&1; then printf "ok"; return 0 fi done < "${SIG_TMP}/keys" printf "bad-sig" } # verified | skipped (CLUDCODE_SKIP_SIG_VERIFY=1) | unverified (macOS with no # capable openssl: the one case that carries on with neither a verified # manifest nor an explicit opt-out). MANIFEST_SIG=unverified if [ "${CLUDCODE_SKIP_SIG_VERIFY:-0}" = "1" ]; then MANIFEST_SIG=skipped printf " ${YELLOW}!${NC} Manifest signature check skipped (CLUDCODE_SKIP_SIG_VERIFY=1)\n" else SIG_RESULT=$(verify_manifest_signature) case "$SIG_RESULT" in ok) MANIFEST_SIG=verified printf " ${GREEN}✓${NC} Manifest signature verified\n" ;; bad-sig) printf " ${RED}✗ Manifest signature verification FAILED — possible release-repo/CDN compromise or MITM. Not installing.${NC}\n" exit 1 ;; missing-sig) # Hint first, verdict last: the CluTerm app shows only the last # line of a failed install, so that line must be the verdict. printf " (Pre-signing releases predate v0.9.63; install a current version.)\n" printf " ${RED}✗ Manifest signature missing from release — refusing unauthenticated install.${NC}\n" exit 1 ;; no-openssl|no-ed25519) case "$PLATFORM" in darwin-*) printf " - Manifest signature not checked: no openssl here can verify Ed25519 (macOS ships LibreSSL).\n" printf " The agent is verified by its Apple Developer ID signature instead (below).\n" ;; *) # Hint lines first: the CluTerm app shows only the last line # of a failed install, so that line is the verdict. printf " Verifying the release needs OpenSSL 1.1.1 or newer, and this machine's openssl is missing or older.\n" printf " Install it (e.g. apt install openssl, dnf install openssl, apk add openssl) and run the installer again,\n" printf " or set CLUDCODE_SKIP_SIG_VERIFY=1 to install without verifying the release.\n" printf " ${RED}✗ Cannot verify the release signature on this machine (no openssl with Ed25519) — not installing.${NC}\n" exit 1 ;; esac ;; *) printf " ${RED}✗ Manifest signature check did not complete (%s) — not installing.${NC}\n" "$SIG_RESULT" exit 1 ;; esac fi # rust-manifest.json carries one compact-JSON line per platform # (scripts/ci/gen-rust-manifest.py): the agent's checksum is taken from this # platform's line with grep + sed — a stock Mac has no JSON parser that works # without the Command Line Tools. Anything else on the line (the clud-pipe # entry releases up to 0.12.594 signed) is not read. PLATFORM_LINE=$(grep "\"${PLATFORM}\"" "$MANIFEST_PATH") EXPECTED=$(printf "%s" "$PLATFORM_LINE" | sed -n 's/.*"checksum":[[:space:]]*"\([a-f0-9]*\)".*/\1/p') if [ -z "$EXPECTED" ]; then printf " ${RED}✗ No checksum in ${MANIFEST_FILE} for %s${NC}\n" "${PLATFORM}" exit 1 fi # A genuinely-signed manifest proves WHO signed it, never WHICH release it # describes — a release repo or CDN that swaps an older release's signed # manifest (and binary) in under a newer tag passes the signature check above # with a version the signer never meant for this tag. Refuse that rollback or # substitution outright. Skipped when the signature itself was never checked # (CLUDCODE_SKIP_SIG_VERIFY=1, or macOS with no Ed25519-capable openssl): the # manifest's own word proves nothing there, so there is no version claim # worth checking — those paths rely on the Apple Developer ID signature and # its own version check instead (below). if [ "$MANIFEST_SIG" = verified ]; then MANIFEST_VERSION=$(grep '"version"' "$MANIFEST_PATH" | head -1 | sed -n 's/.*"version":[[:space:]]*"\([^"]*\)".*/\1/p') if [ "$MANIFEST_VERSION" != "$VERSION" ]; then printf " ${RED}✗ Manifest reports version %s, expected %s — refusing (rollback or substitution?)${NC}\n" "${MANIFEST_VERSION:-missing}" "${VERSION}" exit 1 fi fi # A machine still on the retired TypeScript agent: its launcher script (or, on # the oldest installs, its binary) is a FILE at BIN_DIR/cludcode, where an rs # install keeps a symlink, and its binary is BIN_DIR/cludcode-bin. Both are # replaced below. Note it now, and whether that agent is still running, so the # closing message can say what happened: a running TypeScript agent keeps # running its old build after the swap and never moves to the Rust one itself. TS_AGENT_FOUND=false TS_AGENT_PIDS="" if [ -e "$BIN_DIR/cludcode-bin" ] || { [ -f "$BIN_DIR/cludcode" ] && [ ! -L "$BIN_DIR/cludcode" ]; }; then TS_AGENT_FOUND=true if command -v pgrep >/dev/null 2>&1; then TS_AGENT_PIDS=$(pgrep -f "$BIN_DIR/cludcode" 2>/dev/null | tr '\n' ' ' | sed 's/ *$//' || true) fi printf " Replacing the retired TypeScript agent with the Rust agent\n" fi # Check installed version AND checksum before skipping download. # Version alone isn't enough — an arm64 binary works under Rosetta but # doesn't match the x64 checksum, so we must verify the platform matches. SKIP_DOWNLOAD=false if [ -f "$BINARY_PATH" ]; then INSTALLED=$("$BINARY_PATH" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || printf "") if [ "$INSTALLED" = "$VERSION" ]; then ACTUAL_PRE=$(command -v shasum >/dev/null && shasum -a 256 "$BINARY_PATH" | cut -d' ' -f1 || sha256sum "$BINARY_PATH" | cut -d' ' -f1) if [ "$ACTUAL_PRE" = "$EXPECTED" ]; then printf " ${GREEN}✓${NC} Already up to date (v%s)\n" "${VERSION}" SKIP_DOWNLOAD=true else printf " Binary platform mismatch — re-downloading for %s...\n" "${PLATFORM}" fi else if [ -n "$INSTALLED" ]; then printf " Updating from v%s to v%s...\n" "${INSTALLED}" "${VERSION}" else printf " Updating to v%s...\n" "${VERSION}" fi fi else printf " Downloading...\n" fi # CluTerm's Apple Developer team: the APPLE_TEAM_ID that release-agent-rs.yml # signs the darwin agent with, plus THIS platform's exact bundle identifier # (cluterm-rs-, e.g. cluterm-rs-darwin-arm64) — binding identifier # closes a gap a team-only requirement leaves open: codesign only proves WHO # signed a Mach-O, never WHICH release or platform it is, so a genuine # team-signed binary for a DIFFERENT platform (or the wrong arch under # Rosetta) would otherwise also pass. Verified against the real release: the # correct platform's binary still passes with this clause added, and the # other platform's binary is refused. Identifier alone still doesn't bind # VERSION — an older genuine binary for THIS platform keeps the same # identifier — hence the version check further below. APPLE_TEAM_ID='U7ZH28L48F' APPLE_DEVELOPER_ID_REQUIREMENT='anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "U7ZH28L48F"'" and identifier \"$BINARY_NAME\"" if [ "$SKIP_DOWNLOAD" = false ]; then # Everything below runs against TMP_BINARY_PATH — $BINARY_PATH (the live, # symlinked, possibly-running binary) is never touched until the final # mv -f, so a refusal here (or a transient download failure) leaves # whatever was already installed in place, executable, untouched. The # old binary is also no longer deleted first: a rename replaces the # directory entry atomically, which is what used to need the `rm -f` # ("Text file busy" on Linux if the agent is running) — mv -f gets that # for free and never leaves a machine with no agent at all. download "$BINARY_URL" "$TMP_BINARY_PATH" || { printf "${RED}Download failed${NC}\n"; exit 1; } chmod +x "$TMP_BINARY_PATH" # Verify checksum (MANIFEST and EXPECTED already fetched above). # A truncation floor first — the real integrity check is the SHA-256 below; # this guard is only here to catch a zero-byte / HTML-error-page download # before we hash it. MIN_BYTES=500000 # 500 KB — far below any Rust agent release FILESIZE=$(wc -c < "$TMP_BINARY_PATH" | tr -d ' ') if [ "$FILESIZE" -lt "$MIN_BYTES" ]; then printf " ${RED}✗ Download incomplete (%s bytes) — please retry${NC}\n" "${FILESIZE}" exit 1 fi ACTUAL=$(command -v shasum >/dev/null && shasum -a 256 "$TMP_BINARY_PATH" | cut -d' ' -f1 || sha256sum "$TMP_BINARY_PATH" | cut -d' ' -f1) [ "$ACTUAL" != "$EXPECTED" ] && { printf "${RED}Checksum mismatch!${NC}\n"; exit 1; } printf " ${GREEN}✓${NC} Checksum verified\n" # macOS: the agent must carry CluTerm's Apple Developer ID signature, or it # is not installed. This is what vouches for the binary on a Mac whether or # not the manifest signature above could be checked: codesign verifies the # signature against the binary's bytes, and the requirement says who must # have made it (Apple's chain, a Developer ID Application certificate, # CluTerm's team, THIS platform's bundle identifier). A release repo # serving some other binary, under whatever checksum it likes, cannot get # it past this. There is no unsigned fallback: every agent-rs release is # Developer ID signed (release-agent-rs.yml will not build without the # certificate). `-R=`: codesign reads a requirement that starts with # "=" as requirement text rather than a file. Checked against the TEMP # file, so a refusal here never touches the previously-installed binary. case "$PLATFORM" in darwin-*) if CODESIGN_ERR=$(codesign --verify --strict -R="$APPLE_DEVELOPER_ID_REQUIREMENT" "$TMP_BINARY_PATH" 2>&1); then printf " ${GREEN}✓${NC} Apple Developer ID signature verified (team %s)\n" "$APPLE_TEAM_ID" else printf '%s\n' "$CODESIGN_ERR" | sed 's/^/ codesign: /' printf " ${RED}✗ The agent binary is not signed by CluTerm's Apple Developer ID (team %s) — not installing.${NC}\n" "$APPLE_TEAM_ID" exit 1 fi # Team + identifier still doesn't bind VERSION: a genuine OLDER # release for this exact platform carries the same team signature # and the same identifier, so it passes everything above. Running # the binary is safe now — the signature just proved it is # CluTerm's own code — and this is the only authenticator left on # a Mac whose manifest signature could not be checked (above). TMP_VERSION=$("$TMP_BINARY_PATH" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || printf "") if [ "$TMP_VERSION" != "$VERSION" ]; then printf " ${RED}✗ The signed binary reports v%s, expected v%s — refusing (rollback or substitution?)${NC}\n" "${TMP_VERSION:-unknown}" "${VERSION}" exit 1 fi ;; esac mv -f "$TMP_BINARY_PATH" "$BINARY_PATH" fi # Publish the agent as `cluterm`, a symlink to clud-rs. ln -sfn writes to a # tmp name and renames into place, so the symlink swap is atomic — a concurrent # invocation never observes a missing or half-written link. ln -sfn "$BINARY_PATH" "$UNIFIED_LINK" printf " ${GREEN}✓${NC} ${CYAN}cluterm${NC} → ${CYAN}%s${NC}\n" "${BINARY_PATH##*/}" # Short-form alias: `clu` — the canonical spelling in user-facing hints # ("run 'clu voice setup'"). Same atomic ln -sfn; clud-rs is argv[0]-agnostic. ln -sfn "$BINARY_PATH" "$BIN_DIR/clu" printf " ${GREEN}✓${NC} ${CYAN}clu${NC} → ${CYAN}%s${NC}\n" "${BINARY_PATH##*/}" # Back-compat + TS→RS migration: make `cludcode` resolve to the Rust agent # too. Fresh install → just adds the alias. Coming from the TypeScript agent → # replaces its `cludcode` launcher script with a symlink → clud-rs and removes # the now-orphaned TypeScript binary (`cludcode-bin`, ~60-100 MB). clud-rs is # argv[0]-agnostic, so every name behaves identically. `rm -f` is a no-op when # the leftover is absent. A still-running TypeScript agent keeps its open inode # (no "Text file busy" on Linux) — and its old build; see TS_AGENT_FOUND. ln -sfn "$BINARY_PATH" "$BIN_DIR/cludcode" rm -f "$BIN_DIR/cludcode-bin" printf " ${GREEN}✓${NC} ${CYAN}cludcode${NC} → ${CYAN}clud-rs${NC} (back-compat alias)\n" # No helper binaries are installed. rathole (the reverse-tunnel helper) is # retired: the agent defaults to the native WS reverse-tunnel # (CLUDCODE_NATIVE_WS_TUNNEL, on by default), which needs no external process — # the relay serves /ws/agent-control and /ws/agent-data directly. Users who opt # back into the legacy tunnel with CLUDCODE_NATIVE_WS_TUNNEL=0 install rathole # themselves (cargo install rathole) and put it, or a symlink to it, at # /bin/rathole — ~/.cludcode/bin/rathole by default (the agent's config # dir, not CLUDCODE_BIN_DIR). The agent runs exactly that path and never # searches PATH (agent-rs clud-bin services_runtime.rs). tmux and clud-pipe, # the two helpers earlier installs placed in BIN_DIR, are removed below. # tmux is NOT bundled, and the Rust agent does not use it: its terminals run # in sessiond, its own multiplexer. BIN_DIR is on the user's interactive PATH # (the rc line below prepends it), so the tmux earlier installs placed here # became THEIRS: every `tmux` they typed ran our pinned build, which cannot # talk to a server started by their own tmux (cross-protocol refusal) and # ignores the terminfo/plugins their config expects — installing the agent # broke existing zsh + tmux setups. Remove that copy. if [ -f "$BIN_DIR/tmux" ] || [ -L "$BIN_DIR/tmux" ]; then rm -f "$BIN_DIR/tmux" printf " ${GREEN}✓${NC} removed the tmux earlier installs bundled (yours is untouched)\n" fi # clud-pipe — the tailcat shim behind `cluterm share` / `cluterm connect` — is # no longer shipped (since 0.12.595): the tailscale.com its pipe/go.mod pins # reads a DERP server's non-101 reply with no bound (Tailworm C4). Installs up # to 0.12.594 put it here, in BIN_DIR, so remove that copy. It is inert until # CLUDCODE_PIPE=1, but it is our build on the user's PATH that nothing updates. # BIN_DIR only: a clud-pipe elsewhere on PATH is one the user built themselves # (pipe/build.sh --local), and the agent still runs it for share/connect. if [ -f "$BIN_DIR/clud-pipe" ] || [ -L "$BIN_DIR/clud-pipe" ]; then rm -f "$BIN_DIR/clud-pipe" printf " ${GREEN}✓${NC} removed clud-pipe, which CluTerm no longer ships — for 'cluterm share', build it from pipe/ (pipe/build.sh --local) and put it on your PATH\n" fi # Container runtime (optional — for container features) printf " Container runtime...\n" if [ "$PLATFORM" = "darwin-arm64" ] || [ "$PLATFORM" = "darwin-x64" ]; then # Apple Containers only on macOS if command -v container >/dev/null 2>&1; then printf " ${GREEN}✓${NC} Apple Containers (native)\n" elif command -v podman >/dev/null 2>&1; then printf " ${GREEN}✓${NC} podman\n" elif command -v docker >/dev/null 2>&1; then printf " ${GREEN}✓${NC} docker\n" else printf " ${YELLOW}!${NC} No container runtime found (optional)\n" printf " Install one of: podman, docker, or container (macOS 26+)\n" printf " brew install podman\n" fi elif command -v podman >/dev/null 2>&1; then printf " ${GREEN}✓${NC} podman\n" elif command -v docker >/dev/null 2>&1; then printf " ${GREEN}✓${NC} docker\n" else printf " ${YELLOW}!${NC} No container runtime found (optional)\n" printf " Install one of: podman, docker\n" if [ -f /etc/debian_version ]; then printf " sudo apt-get install podman\n" elif [ -f /etc/redhat-release ]; then printf " sudo dnf install podman\n" fi fi # PATH setup SHELL_RC="" case "$SHELL" in */zsh) SHELL_RC="$HOME/.zshrc" ;; */bash) SHELL_RC="$HOME/.bashrc" ;; esac PATH_ADDED=false if [ -n "$SHELL_RC" ]; then [ ! -f "$SHELL_RC" ] && touch "$SHELL_RC" grep -q '.cludcode/bin' "$SHELL_RC" 2>/dev/null || { printf "\n# Clu\nexport PATH=\"\$HOME/.cludcode/bin:\$PATH\"\n" >> "$SHELL_RC" PATH_ADDED=true } fi # Detect a stale `cluterm` BEFORE updating PATH for this session. A # hand-placed `cludcode` elsewhere on PATH is left alone. EXISTING_CLUTERM=$(command -v cluterm 2>/dev/null || true) # Export PATH for current session so subsequent commands work immediately export PATH="$BIN_DIR:$PATH" printf "\n" printf " ${GREEN}${BOLD}✓ Installed!${NC}\n" printf "\n" # Remove a stale binary from PATH. Skip the rm if the resolved path is the # symlink we just wrote (i.e. $BIN_DIR was already on PATH from a previous # install). Compared by FILE (`-ef`: same device and inode, symlinks # followed), never by spelling: dash and zsh join a PATH entry and the name # verbatim, so a `~/.cludcode/bin/` entry resolves to `…/bin//cluterm`, which # no string test equates with `…/bin/cluterm` — and the rm then deleted the # symlink this run had just written. A cluterm elsewhere that resolves to # clud-rs too is not stale either, so it stays. _remove_stale() { local existing="$1" expected="$2" if [ -n "$existing" ] && ! [ "$existing" -ef "$expected" ]; then printf " Removing old binary at %s\n" "${existing}" rm -f "${existing}" 2>/dev/null || { [ "$HAS_TTY" = true ] && sudo rm -f "${existing}" 2>/dev/null; } || { printf " ${YELLOW}!${NC} Could not remove %s (permission denied)\n" "${existing}" printf " Run manually: ${CYAN}sudo rm %s${NC}\n" "${existing}" } fi } # NB: `cluterm` lives at UNIFIED_LINK ($BIN_DIR/cluterm) — the symlink we # (re)wrote above — NOT at BINARY_PATH (clud-rs). Comparing against the binary # made a re-install delete the very cluterm symlink it had just created → # "cluterm: command not found". Only remove a cluterm that is a DIFFERENT file # (a stale shadow elsewhere). _remove_stale "$EXISTING_CLUTERM" "$UNIFIED_LINK" # Remove legacy Node.js agent (pre-Bun era) [ -f "$INSTALL_DIR/agent.js" ] && rm -f "$INSTALL_DIR/agent.js" && printf " Removed legacy agent.js\n" # Restart a running Rust agent so it picks up the new binary: on SIGUSR1 it # shuts down gracefully and re-execs itself in place, supervisor or not # (agent-rs clud-bin restart.rs). A TypeScript agent is not signalled: its # binary is gone, so it could not come back (see TS_AGENT_FOUND). if command -v pgrep >/dev/null 2>&1; then RUNNING_PIDS=$(pgrep -f "${BINARY_PATH}" 2>/dev/null || true) if [ -n "$RUNNING_PIDS" ]; then printf " Restarting running agent...\n" for pid in $RUNNING_PIDS; do kill -USR1 "$pid" 2>/dev/null || true done fi fi # PATH hint: only needed if `cluterm` isn't already on the user's PATH. If # the resolved `cluterm` is the symlink at UNIFIED_LINK (the same file — see # _remove_stale for why never the same string), the user's shell already # resolves it correctly — printing "export PATH=…" is just noise. if [ -z "$EXISTING_CLUTERM" ] || ! [ "$EXISTING_CLUTERM" -ef "$UNIFIED_LINK" ]; then printf " Run this now:\n" printf " ${CYAN}export PATH=\"\$HOME/.cludcode/bin:\$PATH\"${NC}\n" printf "\n" fi if [ "$IS_WSL" = true ]; then printf " ${YELLOW}${BOLD}WSL Notes:${NC}\n" # No tmux note: the Rust agent's terminals run in sessiond, its own # multiplexer. if command -v docker.exe >/dev/null 2>&1 || command -v docker >/dev/null 2>&1; then printf " ${GREEN}✓${NC} Docker available (containers will work)\n" else printf " ${YELLOW}!${NC} For container features, install Docker Desktop for Windows\n" printf " and enable WSL integration in Docker Desktop settings\n" fi printf "\n" fi # What to do next. A machine that is already paired (a reinstall or an update) # needs nothing; otherwise pairing is one command with a code from the # dashboard. It is spelled with the installer's full path because the PATH # entry above only reaches shells started after this one. `pair` asks for a # sync password, then (macOS and Linux only) asks whether to keep CluTerm # running in the background and start it at login — default yes. Declining # runs the agent in that terminal instead, same as plain `cluterm start`: # nothing then restarts it when the terminal closes, so say so. ALREADY_PAIRED=false # A cloud device id is a UUID, so it starts with a hex digit; the account-free # `local-…` ids that `cluterm share` writes do not, and still have pairing # ahead. Keep that exclusion if this pattern changes. if [ -f "$INSTALL_DIR/config.json" ] && grep -Eq '"deviceId"[[:space:]]*:[[:space:]]*"[0-9a-fA-F]' "$INSTALL_DIR/config.json" 2>/dev/null; then ALREADY_PAIRED=true fi if [ "$TS_AGENT_FOUND" = true ]; then # The closing lines for a machine that ran the TypeScript agent. The # "moves to the new version once it is idle" line below would be false for # it: nothing in the TypeScript agent knows about clud-rs. printf " ${BOLD}This machine ran the retired TypeScript agent.${NC} ${CYAN}cluterm${NC} and ${CYAN}cludcode${NC} now run the Rust agent.\n" if [ -n "$TS_AGENT_PIDS" ]; then printf " ${YELLOW}!${NC} The TypeScript agent is still running (pid %s) on its old build and will not switch by itself.\n" "$TS_AGENT_PIDS" printf " Stop it, then start the Rust agent with ${CYAN}cluterm start${NC}.\n" elif [ "$ALREADY_PAIRED" = true ]; then printf " This machine is already paired: start the Rust agent with ${CYAN}cluterm start${NC}.\n" fi fi # The retired TypeScript agent's background service, if its `service install` # ever ran: a launchd job (KeepAlive) or a systemd user unit (Restart=always) # under the TypeScript agent's own name — the Rust agent's are com.cluterm.agent # and cluterm-agent.service — that nothing above removes. Looked for whether or # not this run found the TypeScript binaries, because no earlier migration # removed the service either. Reported only: the installer prints the commands # and never runs them — it stops no service and signals no process. if [ -f "$HOME/Library/LaunchAgents/com.cludcode.agent.plist" ]; then printf " ${YELLOW}!${NC} The retired TypeScript agent's launchd service is still installed. Remove it with:\n" printf ' %s\n' 'launchctl bootout gui/$(id -u)/com.cludcode.agent' 'rm ~/Library/LaunchAgents/com.cludcode.agent.plist' fi if [ -f "$HOME/.config/systemd/user/cludcode.service" ]; then printf " ${YELLOW}!${NC} The retired TypeScript agent's systemd service is still installed. Remove it with:\n" printf ' %s\n' 'systemctl --user disable --now cludcode.service' 'rm ~/.config/systemd/user/cludcode.service' 'systemctl --user daemon-reload' fi if [ "$ALREADY_PAIRED" = true ]; then if [ "$TS_AGENT_FOUND" = false ]; then printf " This machine is already paired. A running agent moves to the new version once it is idle;\n" printf " if none is running, start one with ${CYAN}cluterm start${NC}.\n" fi else printf " ${BOLD}Next: pair this machine with your account${NC}\n" printf " 1. Open ${CYAN}https://cluterm.com/dashboard${NC} and sign in. It shows a 6-character pairing code.\n" printf " 2. Run ${CYAN}~/.cludcode/bin/cluterm pair ${NC}\n" printf " It asks you to choose a sync password. The dashboard asks for the same one.\n" printf " Pairing then asks whether to keep CluTerm running in the background and\n" printf " start it at login (default yes); say no and it runs in that terminal instead\n" printf " — leave it open, or run ${CYAN}cluterm start${NC} again later.\n" fi printf "\n"