#!/usr/bin/env bash
# claude-restart-check: one green restart line for a replaced binary, or a
# newer-copy notice when another installed version is newer. Nothing otherwise.
#
# Installed on PATH by `openRepoTools --install`, for workBenches' shared
# status line to call.
#
# Why it exists (opensoft/workBenches#119, Brett Heap's added scope of
# 2026-09-29): a running session keeps the binary it loaded. When a launch's
# update or the CLI's own auto-updater replaces that binary, every session
# already running must say so at its prompt, the way the CLI's own update
# notice does. The CLI's native line cannot be written from outside, so the
# shared status line prints this command's line at the top of its panel. It
# WARNS AND NEVER ACTS: no kill and no automatic /ctx, because a working
# session is never interrupted (lane-collision-protocol Amendment 18).
#
# USAGE
#   claude-restart-check [--running <version>] [--pid <pid>]
#   claude-restart-check --help
#
#   --running <version>  the version the session reports, from the status
#                        line's JSON `version` field
#   --pid <pid>          where the walk to the claude process starts (default:
#                        this command's parent). The status line passes its own
#                        $PPID, the `/bin/sh -c` that Claude starts it under.
#
# HOW IT DECIDES, cheaply, because it runs at every status line render
#   - Walks at most three processes, from --pid up through its parents, to
#     the first one whose /proc/<pid>/exe is a Claude Code binary: a native
#     version file (.../claude/versions/<x.y.z>, or <x.y.z> directly in the
#     native versions directory, compared by its canonical path because the
#     kernel reports exe with every symlink resolved) or the npm package's
#     .../node_modules/@anthropic-ai/<dir>/bin/claude.exe.
#   - The binary was REPLACED when that exe link ends in " (deleted)". npm
#     renames the old package directory before it deletes it, so the exe path
#     cannot be used to read the running version.
#   - The INSTALLED version is read from disk, as the highest of: the running
#     install's own (the highest x.y.z name beside a native version file, or
#     `version` in the npm package's package.json at its canonical path), the
#     native versions directory ($CLAUDE_CURRENT_NATIVE_DIR, default
#     ~/.local/share/claude/versions), and the package in the user npm prefix,
#     found the way claude-current finds it. So a session on the npm copy is
#     told about a newer native install. The next launch checks npm and may
#     prefer a candidate equal to npm over that newer installed copy.
#   - It warns when the binary was replaced, or when the installed version is
#     newer than the running one (--running, else the native file's name).
#   It reads /proc, a directory listing and a few small files. It reaches no
#   network and runs no binary, so the render is not slowed by a
#   `claude --version`.
#
# OUTPUT
#   Nothing, or exactly one line:
#     RESTART NEEDED: running <old>, installed <new>; /ctx at your next breakpoint
#   or, when only the version differs and the binary was not replaced:
#     NEWER COPY INSTALLED: running <old>, installed <new>; /ctx checks npm before selecting a version
#   in green, or plain when NO_COLOR is set.
#
# EXIT
#   0 always, including a host with no /proc such as macOS, where it prints
#   nothing, because a status line must never break on this. 64 only for an
#   argument it does not know.
#
# ENVIRONMENT
#   CLAUDE_RESTART_CHECK_PROC  the process table to read (default /proc), the
#                              test seam
#   CLAUDE_CURRENT_NATIVE_DIR, CLAUDE_CURRENT_NPM_PREFIX, NPM_CONFIG_PREFIX
#                              where the installs are, read as claude-current
#                              reads them
#   NO_COLOR                   plain text, no ANSI colour
#
# `set -u` and NOT `set -e`, deliberately, for lane-handoff's reason one file
# over: every read here may fail (a process gone mid-walk, a file unreadable),
# and each failure must reach this command's own "print nothing, exit 0"
# rather than end the status line's render with a non-zero status.

set -u

prog="claude-restart-check"

usage() {
	cat <<'USAGE'
claude-restart-check [--running <version>] [--pid <pid>]
claude-restart-check --help

Print a green RESTART NEEDED line for a binary replaced on disk, or a
NEWER COPY INSTALLED notice when only the installed version is newer. That
notice does not promise /ctx will select the newer copy: the next launch
checks npm first. Print nothing otherwise. Always exits 0 (64 for an unknown argument). Reads /proc
(CLAUDE_RESTART_CHECK_PROC) and never acts on the session.
USAGE
}

running=""
start_pid=""
while [ $# -gt 0 ]; do
	case "$1" in
		--running)
			[ $# -ge 2 ] || { usage >&2; exit 64; }
			running="$2"
			shift 2 ;;
		--running=*)
			running="${1#--running=}"
			shift ;;
		--pid)
			[ $# -ge 2 ] || { usage >&2; exit 64; }
			start_pid="$2"
			shift 2 ;;
		--pid=*)
			start_pid="${1#--pid=}"
			shift ;;
		-h | --help)
			usage
			exit 0 ;;
		*)
			printf '%s: unknown argument: %s\n' "$prog" "$1" >&2
			usage >&2
			exit 64 ;;
	esac
done

PROC="${CLAUDE_RESTART_CHECK_PROC:-/proc}"
[ -n "$start_pid" ] || start_pid="$PPID"
case "$start_pid" in
	'' | *[!0-9]*) exit 0 ;;
esac
[ -d "$PROC" ] || exit 0

version_re='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$'
core_re='^[0-9]+\.[0-9]+\.[0-9]+$'
is_version() { [[ "${1:-}" =~ $version_re ]]; }

# numcmp, precmp and vercmp are claude-current's, byte for byte, so the two
# commands agree on which version is newer.
numcmp() {
	local x="$1" y="$2"
	x="${x#"${x%%[!0]*}"}"
	y="${y#"${y%%[!0]*}"}"
	x="${x:-0}"
	y="${y:-0}"
	if [ "${#x}" -ne "${#y}" ]; then
		if [ "${#x}" -gt "${#y}" ]; then echo 1; else echo -1; fi
		return 0
	fi
	if [ "$x" = "$y" ]; then echo 0; return 0; fi
	if [[ "$x" > "$y" ]]; then echo 1; else echo -1; fi
}

# precmp <a> <b>: -1, 0 or 1 for two pre-release tags, by SemVer 2.0.0 rule 11:
# dot-separated identifiers from the left; digits-only ones compare as numbers
# and sort before the others, which compare in byte order (LC_ALL=C); when
# every shared identifier is equal, the tag with fewer identifiers is lower.
# So beta.9 < beta.10, and alpha < alpha.1 < alpha.beta.
precmp() {
	local a="$1" b="$2" x y xn yn c
	while [ -n "$a" ] || [ -n "$b" ]; do
		[ -n "$a" ] || { echo -1; return 0; }
		[ -n "$b" ] || { echo 1; return 0; }
		x="${a%%.*}"
		y="${b%%.*}"
		case "$a" in *.*) a="${a#*.}" ;; *) a="" ;; esac
		case "$b" in *.*) b="${b#*.}" ;; *) b="" ;; esac
		xn=1
		yn=1
		case "$x" in '' | *[!0-9]*) xn=0 ;; esac
		case "$y" in '' | *[!0-9]*) yn=0 ;; esac
		if [ "$xn$yn" = 11 ]; then
			c="$(numcmp "$x" "$y")"
		elif [ "$xn$yn" = 10 ]; then
			c=-1
		elif [ "$xn$yn" = 01 ]; then
			c=1
		elif [ "$x" = "$y" ]; then
			c=0
		elif (LC_ALL=C; [[ "$x" < "$y" ]]); then
			c=-1
		else
			c=1
		fi
		[ "$c" = 0 ] || { echo "$c"; return 0; }
	done
	echo 0
}

vercmp() {
	local a="${1%%+*}" b="${2%%+*}" ac bc ap="" bp="" ar br i c
	ac="${a%%-*}"
	bc="${b%%-*}"
	case "$a" in *-*) ap="${a#*-}" ;; esac
	case "$b" in *-*) bp="${b#*-}" ;; esac
	for i in 1 2 3; do
		ar="${ac%%.*}"
		br="${bc%%.*}"
		c="$(numcmp "$ar" "$br")"
		[ "$c" = 0 ] || { echo "$c"; return 0; }
		ac="${ac#*.}"
		bc="${bc#*.}"
	done
	if [ "$ap" = "$bp" ]; then echo 0; return 0; fi
	if [ -z "$ap" ]; then echo 1; return 0; fi
	if [ -z "$bp" ]; then echo -1; return 0; fi
	precmp "$ap" "$bp"
}

# The native versions directory, as claude-current reads it ($HOME unset leaves
# only a configured one), and its canonical path when it is there:
# /proc/<pid>/exe names a file by its real path, so a directory that is (or
# sits below) a symlink, configured or the default, is compared by where it
# leads. `cd -P`, `pwd -P` and no `readlink -f`, for bash 3.2 and a BSD
# `readlink`.
native_dir="${CLAUDE_CURRENT_NATIVE_DIR:-${HOME:+$HOME/.local/share/claude/versions}}"
native_real=""
if [ -n "$native_dir" ]; then
	native_real="$(cd -P -- "$native_dir" 2>/dev/null && pwd -P)" || native_real=""
fi

# is_native_binary <exe target>: a native version file, in any directory named
# .../claude/versions or as <x.y.z> directly in the native versions directory.
is_native_binary() {
	case "$1" in
		*/claude/versions/*) return 0 ;;
	esac
	[ -n "$native_real" ] && [ "${1%/*}" = "${native_real%/}" ] && is_version "${1##*/}"
}

is_claude_binary() { # <exe target, " (deleted)" already removed>
	is_native_binary "$1" && return 0
	case "$1" in
		*/node_modules/@anthropic-ai/*/bin/claude.exe) return 0 ;;
		*/node_modules/@anthropic-ai/*/bin/claude) return 0 ;;
	esac
	return 1
}

parent_of() {
	awk '/^PPid:/ { print $2; exit }' "$PROC/$1/status" 2>/dev/null
}

# The highest x.y.z name among the executable regular files of one directory.
highest_in() {
	local f name best=""
	for f in "$1"/*; do
		[ -f "$f" ] && [ -x "$f" ] && [ ! -L "$f" ] || continue
		name="${f##*/}"
		[[ "$name" =~ $core_re ]] || continue
		if [ -z "$best" ] || [ "$(vercmp "$name" "$best")" = 1 ]; then
			best="$name"
		fi
	done
	printf '%s' "$best"
}

# package_version <package.json>: its first "version" value, however the file
# is laid out. npm writes it one key to a line; a minified one has every key
# on one line, so the file is cut at each brace and comma first.
package_version() {
	[ -r "$1" ] || return 0
	tr '{},' '\n\n\n' 2>/dev/null <"$1" |
		sed -n -e 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n 1
}

# The user npm prefix, found the way claude-current finds it (byte for byte),
# because that is the prefix a launch updates and starts from.
npm_user_prefix() {
	local p=""
	if [ -n "${CLAUDE_CURRENT_NPM_PREFIX:-}" ]; then
		p="$CLAUDE_CURRENT_NPM_PREFIX"
	elif [ -n "${NPM_CONFIG_PREFIX:-}" ]; then
		p="$NPM_CONFIG_PREFIX"
	elif [ -n "${npm_config_prefix:-}" ]; then
		p="$npm_config_prefix"
	elif [ -r "$HOME/.npmrc" ]; then
		p="$(sed -n -e 's/^[[:space:]]*prefix[[:space:]]*=[[:space:]]*//p' "$HOME/.npmrc" | tail -n 1)"
		p="${p%"${p##*[![:space:]]}"}"
		p="${p%\"}"
		p="${p#\"}"
	fi
	case "$p" in
		'~') p="$HOME" ;;
		'~/'*) p="$HOME/${p#'~/'}" ;;
		'${HOME}'*) p="$HOME${p#'${HOME}'}" ;;
		'$HOME'*) p="$HOME${p#'$HOME'}" ;;
	esac
	p="${p%/}"
	[ -n "$p" ] || p="$HOME/.npm-global"
	# A relative prefix is anchored at $HOME, so the path npm installs into is
	# the path read back, and the same one whichever directory asks.
	case "$p" in
		/*) ;;
		*) p="$HOME/$p" ;;
	esac
	printf '%s\n' "$p"
}

pid="$start_pid"
exe=""
target=""
found=0
steps=0
while [ "$steps" -lt 3 ]; do
	case "$pid" in
		'' | 0 | *[!0-9]*) break ;;
	esac
	exe="$(readlink -- "$PROC/$pid/exe" 2>/dev/null)" || exe=""
	target="${exe% (deleted)}"
	if [ -n "$target" ] && is_claude_binary "$target"; then
		found=1
		break
	fi
	pid="$(parent_of "$pid")" || pid=""
	steps=$((steps + 1))
done
[ "$found" = 1 ] || exit 0

replaced=0
[ "$exe" = "$target" ] || replaced=1

# THE INSTALLED VERSION IS THE HIGHEST ONE A LAUNCH COULD START, from every
# install claude-current reads that has its version on disk: the running
# binary's own, the native versions directory and the user npm prefix's
# package. A session on the npm copy is behind a newer native install, which
# may be considered at the next launch. This read has no registry information,
# so it cannot predict the resolver's preference for a copy equal to npm.
installed=""
from_path=""
consider() { # <version>: kept when it is higher than the one in hand
	is_version "${1:-}" || return 0
	if [ -z "$installed" ] || [ "$(vercmp "$1" "$installed")" = 1 ]; then
		installed="$1"
	fi
}
if is_native_binary "$target"; then
	consider "$(highest_in "${target%/*}")"
	from_path="${target##*/}"
else
	case "$target" in
		*/node_modules/@anthropic-ai/*)
			consider "$(package_version "${target%%/node_modules/@anthropic-ai/*}/node_modules/@anthropic-ai/claude-code/package.json")"
			if [ "$replaced" = 0 ]; then
				from_path="$(package_version "${target%/bin/*}/package.json")"
			fi ;;
	esac
fi
if [ -n "${HOME:-}" ]; then
	consider "$(highest_in "$native_dir")"
	consider "$(package_version "$(npm_user_prefix)/lib/node_modules/@anthropic-ai/claude-code/package.json")"
fi

is_version "$running" || running="$from_path"
is_version "$running" || running=""
is_version "$installed" || installed=""

newer=0
if [ -n "$installed" ] && [ -n "$running" ] && [ "$(vercmp "$installed" "$running")" = 1 ]; then
	newer=1
fi
[ "$replaced" = 1 ] || [ "$newer" = 1 ] || exit 0

if [ "$replaced" = 1 ]; then
	line="RESTART NEEDED: running ${running:-an older build}, installed ${installed:-a newer build}; /ctx at your next breakpoint"
else
	line="NEWER COPY INSTALLED: running $running, installed $installed; /ctx checks npm before selecting a version"
fi
if [ -n "${NO_COLOR:-}" ]; then
	printf '%s\n' "$line"
else
	printf '\033[32m%s\033[0m\n' "$line"
fi
exit 0
