#!/bin/sh
# Seamless one-command installer.
#
#   curl -fsSL https://thereisnospoon.org/install | sh
#
# This file IS the published artifact. The site is served from docs/ on GitHub
# Pages, so docs/install lands at https://thereisnospoon.org/install verbatim.
# It is not generated (docsgen owns docs/docs/ only) and there is deliberately
# no second copy under scripts/: a shell script living at two paths is a shell
# script that drifts.
#
# What it does: fetch this platform's release archive from GitHub, verify its
# checksum, install seamlessd + seam into ~/.local/bin, wire the detected agent
# clients -- hooks + MCP + maintained skills for Claude Code/Codex, and the MCP
# bridge for the Claude app chat surface (macOS; it has no hooks or skills) --
# generating the bearer key on first run, then run the daemon as a per-user
# service: launchd on macOS, systemd --user on Linux. Codex gets the
# secret-preserving stdio proxy by default; direct HTTP remains a supported
# manual Codex configuration. Re-running it upgrades
# in place -- the service restarts on the new build before the clients are
# rewired -- and the config and ~/.seamless are never touched. A machine whose
# config already says `role: client` stays a client of its server_url. Uninstall
# anytime with `seamlessd uninstall` (add --purge to also delete the config and
# data).
#
# Overrides:
#   SEAMLESS_VERSION            version to install (default: latest release)
#   SEAMLESS_INSTALL_DIR        where the binaries go (default: ~/.local/bin)
#   SEAMLESS_CHECKSUMS_SHA256   pin the release's checksums.txt to this SHA-256
#                               (64 hex digits) and refuse any other. It is for
#                               a caller that verified the manifest's signature
#                               itself (seamlessd's updater), so without cosign
#                               the signature step reports "verified by
#                               seamlessd" instead of warning.
#   SEAMLESS_CLIENT             claude|claude-desktop|codex|all, or a comma list
#                               of targets (default: the detected clients;
#                               prompts on the terminal when several or none are
#                               found, and aborts when none is found and no
#                               terminal is attached -- never a silent default)
#   SEAMLESS_SERVER_URL         install as a CLIENT of a Seamless server running
#                               somewhere else: wire this machine's agent
#                               clients to that URL, install no service, and
#                               keep no data dir here. Requires
#                               SEAMLESS_MCP_API_KEY. Wins over the role and
#                               server_url an existing config names.
#   SEAMLESS_MCP_API_KEY        the server's bearer key. Required with
#                               SEAMLESS_SERVER_URL; `seamlessd client-config`
#                               on the server prints the whole command.
#   SEAMLESS_NO_HOOKS=1         skip agent hooks, MCP registration, and skills,
#                               and the client detection that would pick them
#   SEAMLESS_NO_ONBOARD_SKILL=1 skip the one-shot seam-onboard skill
#   SEAMLESS_NO_RESEARCH_SKILL=1 skip the recurring seam-research skill
#   SEAMLESS_NO_SERVICE=1       skip the service; install the binaries and stop
#   SEAMLESS_ALLOW_ROOT=1       permit running as root (containers)
set -eu

REPO=arctop/seamless
LABEL=org.thereisnospoon.seamless
DOCS=https://thereisnospoon.org/docs/

# The canonical config path: the one EnsureAPIKey writes on first run, and the
# one the service points SEAMLESS_CONFIG at. The installer owns this path only
# -- a config somewhere else is a hand-managed setup, so say so rather than
# generating a key in one place and a service that reads another.
CONFIG=$HOME/.config/seamless/seamless.yaml
LOG=$HOME/.seamless/seamlessd.log

# The bind address lives in the config; this is only the pre-config fallback for
# the health poll, and mirrors config.Defaults().
DEFAULT_ADDR=127.0.0.1:8081

# Set by the service steps that actually started something. The health poll is
# conditional on it: a run that deliberately skipped the service (no systemd
# user session, SEAMLESS_NO_SERVICE) must not then fail for the absence of a
# daemon nobody started.
SERVICE_STARTED=0

AGENT_CLIENT=

# Non-empty when this is a client install -- SEAMLESS_SERVER_URL asked for one,
# or the existing config already says role: client -- so this machine runs no
# daemon at all. It wires its agent clients to that URL, installs no service,
# reads no local addr, and keeps no data dir -- the config the binary writes is
# role: client + server_url + mcp.api_key and nothing else. main() sets it
# before anything reads it, together with the key that pairs with it.
#
# SERVER_KEY is set only alongside SEAMLESS_SERVER_URL. A client install read
# back from the config passes neither to install-hooks: that file already holds
# both, install-hooks reads them there, and the key never rides in argv just to
# be written back where it already is.
SERVER_URL=
SERVER_KEY=

# The lower-cased SHA-256 that SEAMLESS_CHECKSUMS_SHA256 pins checksums.txt to;
# empty when nothing is pinned. read_checksums_pin sets it before any download.
CHECKSUMS_PIN=

say()  { printf '  %s\n' "$*"; }
step() { printf '  %-12s %s\n' "$1" "$2"; }
warn() { printf '  warning: %s\n' "$*" >&2; }
die()  { printf '\nerror: %s\n' "$*" >&2; exit 1; }

have() { command -v "$1" >/dev/null 2>&1; }

# Choose the same target set that install-hooks would offer interactively: the
# two hook clients plus, on macOS, the Claude app chat surface (MCP bridge
# only). A curl|sh install has non-terminal stdin (the pipe carries this
# script), so prompts read /dev/tty instead: with a terminal attached, several
# targets detected asks which to wire (default: the detected set) and none
# detected asks whether to install at all (default no, then which target -- no
# default). Headless, several resolves to the detected set and none aborts with
# guidance; there is deliberately no silent Claude Code fallback. The resolved
# comma list is passed explicitly to install-hooks, and the embedded skills
# follow the hook clients in that choice.

# tty_available: a readable+writable controlling terminal exists. The explicit
# redirection probe matters: /dev/tty can exist yet fail to open (CI, cron).
tty_available() { { : </dev/tty; } 2>/dev/null && { : >/dev/tty; } 2>/dev/null; }

# ask_tty <prompt>: print the prompt to the terminal and read REPLY from it.
# Returns non-zero on EOF so callers can treat a vanished tty as "no answer".
ask_tty() {
	printf '%s' "$1" >/dev/tty
	IFS= read -r REPLY </dev/tty
}

# client_has <list> <target>: comma-safe membership test -- a substring match
# would find "claude" inside "claude-desktop".
client_has() { case ",$1," in *,"$2",*) return 0 ;; *) return 1 ;; esac }

# all_targets: what "all" and menu choice 4 expand to on this platform. There
# is no Linux build of the Claude app, so its chat surface only exists on
# darwin; keep in step with seamlessd's detectedTargets.allTargets.
all_targets() {
	if [ "$OS" = darwin ]; then echo 'claude,claude-desktop,codex'; else echo 'claude,codex'; fi
}

# compose_clients <claude01> <codex01> <desktop01>: canonical comma list in the
# stable install order -- the Claude surfaces first, then Codex -- empty when
# nothing is selected. The arguments keep their detection order; only the list
# is ordered by install order, which the menu numbering follows.
compose_clients() {
	CC_LIST=
	if [ "$1" = 1 ]; then CC_LIST=claude; fi
	if [ "$3" = 1 ]; then CC_LIST=${CC_LIST:+$CC_LIST,}claude-desktop; fi
	if [ "$2" = 1 ]; then CC_LIST=${CC_LIST:+$CC_LIST,}codex; fi
	echo "$CC_LIST"
}

# parse_client_choice <answer>: map a menu/env answer -- numbers or target
# words, singly or comma-separated -- to AGENT_CLIENT (canonical comma list).
# Returns non-zero on any unrecognized token so callers can re-prompt or die.
parse_client_choice() {
	PC_CLAUDE=0
	PC_CODEX=0
	PC_DESKTOP=0
	PC_ANY=0
	PC_IN=$(printf '%s' "$1" | tr 'A-Z' 'a-z' | tr ', ' '\n\n')
	[ -n "$PC_IN" ] || return 1
	for PC_TOK in $PC_IN; do
		case $PC_TOK in
		1 | claude | claude-code | cc) PC_CLAUDE=1 ;;
		2 | claude-desktop | desktop) PC_DESKTOP=1 ;;
		3 | codex | cx) PC_CODEX=1 ;;
		4 | all)
			PC_CLAUDE=1
			PC_CODEX=1
			if client_has "$(all_targets)" claude-desktop; then PC_DESKTOP=1; fi
			;;
		both)
			PC_CLAUDE=1
			PC_CODEX=1
			;;
		*) return 1 ;;
		esac
		PC_ANY=1
	done
	[ "$PC_ANY" = 1 ] || return 1
	AGENT_CLIENT=$(compose_clients "$PC_CLAUDE" "$PC_CODEX" "$PC_DESKTOP")
}

# prompt_client_choice <default|""> : the install-hooks target menu over
# /dev/tty; multi-select, so an answer is one or more numbers/names separated
# by commas. An empty default (nothing detected) requires an explicit answer;
# EOF aborts, since there is no detected set to fall back on. Keep the entry
# labels in step with install_hooks.go and install.ps1 (a Go test pins them).
prompt_client_choice() {
	PCC_DEF=$1
	{
		printf 'Wire Seamless to which agent client?\n'
		printf '  [1] Claude Code %s\n' "$([ "$SELECT_CLAUDE" = 1 ] && echo '(detected)' || echo '(not detected)')"
		if [ "$OS" = darwin ]; then
			printf '  [2] Claude app (chat) %s\n' "$([ "$SELECT_DESKTOP" = 1 ] && echo '(detected)' || echo '(not detected)')"
		else
			printf '  [2] Claude app (chat) (not supported on %s)\n' "$OS"
		fi
		printf '  [3] Codex (app/CLI/IDE) %s\n' "$([ "$SELECT_CODEX" = 1 ] && echo '(detected)' || echo '(not detected)')"
		printf '  [4] All\n'
	} >/dev/tty
	while :; do
		if [ -n "$PCC_DEF" ]; then
			ask_tty "Enter choices like 1 or 1,3 [$PCC_DEF]: " || REPLY=$PCC_DEF
		else
			ask_tty 'Enter choices like 1 or 1,3: ' || die 'aborted: no agent client selected'
		fi
		[ -n "$REPLY" ] || REPLY=$PCC_DEF
		if parse_client_choice "$REPLY"; then return 0; fi
		printf '  please enter numbers 1-4, comma-separated for several\n' >/dev/tty
	done
}

select_agent_client() {
	# SEAMLESS_NO_HOOKS wires no client, so there is nothing to select: skip the
	# detection and the prompt along with the wiring. Otherwise a headless run
	# (the updater's) on a machine with no agent client dies below over a choice
	# it would never use. AGENT_CLIENT stays empty, so the closing advice names
	# no client either.
	if [ -n "${SEAMLESS_NO_HOOKS:-}" ]; then
		return 0
	fi
	if [ -n "${SEAMLESS_CLIENT:-}" ]; then
		parse_client_choice "$SEAMLESS_CLIENT" ||
			die "invalid SEAMLESS_CLIENT=$SEAMLESS_CLIENT (valid values: claude, claude-desktop, codex, all, or a comma list)"
		return 0
	fi

	SELECT_CLAUDE=0
	SELECT_CODEX=0
	SELECT_DESKTOP=0
	if have claude || [ -d "$HOME/.claude" ]; then SELECT_CLAUDE=1; fi
	SELECT_CODEX_HOME=${CODEX_HOME:-$HOME/.codex}
	if have codex || [ -d "$SELECT_CODEX_HOME" ]; then SELECT_CODEX=1; fi
	# The chat surface: the app bundle, or an existing desktop config (the same
	# gating seamlessd's claudeDesktopSurfaceDetected uses).
	if [ "$OS" = darwin ]; then
		if [ -d /Applications/Claude.app ] || [ -f "$HOME/Library/Application Support/Claude/claude_desktop_config.json" ]; then
			SELECT_DESKTOP=1
		fi
	fi
	DETECTED=$(compose_clients "$SELECT_CLAUDE" "$SELECT_CODEX" "$SELECT_DESKTOP")
	case $DETECTED in
	'')
		tty_available || die 'no agent client was detected on this machine (Claude Code, the Claude app, or Codex)
set SEAMLESS_CLIENT=claude|claude-desktop|codex|all to install anyway'
		warn 'no agent client was detected on this machine (Claude Code, the Claude app, or Codex)'
		ask_tty 'Install anyway? [y/N]: ' || REPLY=
		case $REPLY in
		y | Y | yes | YES) prompt_client_choice '' ;;
		*) die 'aborted: no agent client detected (set SEAMLESS_CLIENT=claude|claude-desktop|codex|all to force)' ;;
		esac
		;;
	*,*)
		if tty_available; then
			# The menu default is the detected set as numbers, collapsed to 4
			# (All) when everything this platform can host was detected.
			if [ "$DETECTED" = "$(all_targets)" ]; then
				DEF=4
			else
				DEF=$(printf '%s' "$DETECTED" | sed 's/claude-desktop/2/;s/claude/1/;s/codex/3/')
			fi
			prompt_client_choice "$DEF"
		else
			AGENT_CLIENT=$DETECTED
		fi
		;;
	*) AGENT_CLIENT=$DETECTED ;;
	esac
}

# One fetcher for the whole script: the entrypoint is curl, but `wget -qO- |
# sh` is a real way people run these, and then curl may not be installed at all.
fetch() {
	if have curl; then curl -fsSL "$1"
	elif have wget; then wget -qO- "$1"
	else die "need curl or wget"
	fi
}

fetch_to() {
	if have curl; then curl -fsSL -o "$2" "$1"
	elif have wget; then
		# not -q, which would eat the failure reason: capture the chatter and
		# surface only the tail (where wget puts the error) on failure
		out=$(wget -O "$2" "$1" 2>&1) || {
			rc=$?
			printf '%s\n' "$out" | tail -3 >&2
			return $rc
		}
	else die "need curl or wget"
	fi
}

# Only a real 404 means the asset does not exist; anything else (proxy, TLS
# inspection, a firewall that allows github.com but blocks the asset CDN on
# *.githubusercontent.com) must surface as itself, not masquerade as a missing
# release -- that misdiagnosis has cost real support time. The fetcher already
# printed the underlying error just above this message.
download_die() {
	code=000
	if have curl; then
		code=$(curl -sILo /dev/null -w '%{http_code}' --max-time 15 "$1" 2>/dev/null) || code=000
	fi
	[ "$code" != 404 ] ||
		die "no such release asset: $1
check the version at https://github.com/$REPO/releases"
	die "download failed: $1
the message above shows why; if the asset exists on the releases page, a proxy
or firewall that allows github.com but blocks *.githubusercontent.com (the
release asset CDN) is the usual cause"
}

http_ok() {
	if have curl; then curl -sf --max-time 1 -o /dev/null "$1"
	elif have wget; then wget -q --timeout=1 -O /dev/null "$1"
	else return 1
	fi
}

# Liveness probe for a daemon on host:port whose scheme this script does not
# know. Plaintext first, then TLS with verification disabled; on success
# HEALTH_URL names the URL that answered, so the caller can report which, and
# HEALTH_BODY holds what it answered.
#
# Once tls.cert_file and tls.key_file are set the listener refuses http
# entirely, so an http-only probe reports a healthy daemon as absent and fails
# an install that actually worked. Reading the tls keys back out of the config
# to choose a scheme would be a second copy of the config parsing that this
# script already warns about; probing both reaches the same answer without it.
#
# Verification is skipped deliberately: no credential is sent, the one thing
# read back is the version the daemon reports (wait_healthy holds it to the
# release just installed), so this asks only "what is serving here", and the
# certificate covers the host of server_url rather than the bind address being
# dialed. Same call that serverReachable makes in cmd/seamlessd/console.go.
health_ok() {
	HEALTH_URL=http://$1/healthz
	if have curl; then
		HEALTH_BODY=$(curl -sf --max-time 1 "$HEALTH_URL" 2>/dev/null) && return 0
		HEALTH_URL=https://$1/healthz
		HEALTH_BODY=$(curl -sfk --max-time 1 "$HEALTH_URL" 2>/dev/null) && return 0
	elif have wget; then
		HEALTH_BODY=$(wget -q --timeout=1 -O - "$HEALTH_URL" 2>/dev/null) && return 0
		HEALTH_URL=https://$1/healthz
		HEALTH_BODY=$(wget -q --timeout=1 --no-check-certificate -O - "$HEALTH_URL" 2>/dev/null) && return 0
	fi
	return 1
}

# served_version: the release behind the HEALTH_BODY that health_ok read -- the
# /healthz version field with its build suffix stripped, since a release build
# serves "0.8.0+1a2b3c4" (version + commit). Every release serves that field, so
# empty means whatever answered is not a daemon this script could have installed.
served_version() {
	printf '%s\n' "${HEALTH_BODY:-}" |
		sed -n 's/.*"version":[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 | sed 's/+.*//'
}

sha256_of() {
	if have sha256sum; then sha256sum "$1" | cut -d' ' -f1
	elif have shasum; then shasum -a 256 "$1" | cut -d' ' -f1
	else die "need sha256sum or shasum to verify the download"
	fi
}

# SEAMLESS_CHECKSUMS_SHA256 names the one checksums.txt this run may install
# from. A caller that sets it has verified that manifest's signature itself
# (seamlessd's updater does, in-process), so download() refuses any other.
# Validated before anything is fetched, so a mangled pin fails fast: exactly 64
# hex digits in either case (sha256sum prints lower case, Windows' Get-FileHash
# upper). Present but anything else is an error, never "no pin".
read_checksums_pin() {
	CHECKSUMS_PIN=${SEAMLESS_CHECKSUMS_SHA256:-}
	[ -n "$CHECKSUMS_PIN" ] || return 0
	bad_pin="invalid SEAMLESS_CHECKSUMS_SHA256=$CHECKSUMS_PIN (want the 64 hex digits of the SHA-256 of the release's checksums.txt)"
	case $CHECKSUMS_PIN in
	*[![:xdigit:]]*) die "$bad_pin" ;;
	esac
	[ "${#CHECKSUMS_PIN}" -eq 64 ] || die "$bad_pin"
	CHECKSUMS_PIN=$(printf '%s' "$CHECKSUMS_PIN" | tr '[:upper:]' '[:lower:]')
}

# Map uname's vocabulary onto goreleaser's. darwin + linux only: the daemon's
# service integration is launchd/systemd and nothing is tested on Windows.
detect_platform() {
	OS=$(uname -s | tr '[:upper:]' '[:lower:]')
	case $OS in
	darwin | linux) ;;
	*) die "unsupported OS: $OS (Seamless ships macOS and Linux builds; build from source with Go 1.26+)" ;;
	esac

	ARCH=$(uname -m)
	case $ARCH in
	x86_64 | amd64) ARCH=amd64 ;;
	arm64 | aarch64) ARCH=arm64 ;;
	*) die "unsupported architecture: $ARCH (amd64 and arm64 only)" ;;
	esac
}

# The GitHub API rather than parsing the /releases/latest redirect: it is one
# request either way, and a rate-limited API answers with JSON we can detect
# instead of a 302 we would silently misread.
resolve_version() {
	VERSION=${SEAMLESS_VERSION:-}
	if [ -z "$VERSION" ]; then
		VERSION=$(fetch "https://api.github.com/repos/$REPO/releases/latest" |
			sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -1) || true
	fi
	[ -n "$VERSION" ] ||
		die "could not resolve the latest release (GitHub API rate limit?); pin one instead:
  curl -fsSL https://thereisnospoon.org/install | SEAMLESS_VERSION=0.3.0 sh"
	VERSION=${VERSION#v}
}

download() {
	base="https://github.com/$REPO/releases/download/v$VERSION"
	TARBALL="seamless_${VERSION}_${OS}_${ARCH}.tar.gz"

	step downloading "$TARBALL"
	fetch_to "$base/$TARBALL" "$TMP/$TARBALL" ||
		download_die "$base/$TARBALL"
	fetch_to "$base/checksums.txt" "$TMP/checksums.txt" ||
		download_die "$base/checksums.txt"

	# The pin is checked before anything is read OUT of the manifest: a
	# checksums.txt other than the one the caller verified vouches for nothing.
	if [ -n "$CHECKSUMS_PIN" ]; then
		pinned=$(sha256_of "$TMP/checksums.txt")
		[ "$pinned" = "$CHECKSUMS_PIN" ] || die "checksums.txt does not match SEAMLESS_CHECKSUMS_SHA256
  expected $CHECKSUMS_PIN
  got      $pinned
this is not the release manifest the caller verified; do not install"
	fi

	# Every archive is listed in one checksums.txt, so match the filename
	# exactly -- a substring match would happily verify amd64 against arm64.
	want=$(awk -v f="$TARBALL" '$2 == f { print $1 }' "$TMP/checksums.txt")
	[ -n "$want" ] || die "$TARBALL is not listed in checksums.txt"
	got=$(sha256_of "$TMP/$TARBALL")
	[ "$want" = "$got" ] || die "checksum mismatch for $TARBALL
  expected $want
  got      $got"
	if [ -n "$CHECKSUMS_PIN" ]; then
		step checksum "ok (checksums.txt matches SEAMLESS_CHECKSUMS_SHA256)"
	else
		step checksum ok
	fi

	verify_signature "$base"
}

# A matching checksum proves the archive is the one checksums.txt describes. It
# does NOT prove checksums.txt came from us: it is fetched from the same origin
# as the archive, so whoever could tamper with one could tamper with both. The
# signature is what closes that (audit M3).
#
# Keyless Sigstore, so the check that matters is the IDENTITY, not merely "a
# valid signature exists" -- anyone can sign anything with their own identity.
# Pinning to this repo's release workflow on a v* tag is what makes the
# signature mean "built and published by the Seamless release pipeline".
#
# cosign is not a prerequisite of installing Seamless, so its absence warns
# rather than fails: requiring users to install a signing tool first would be
# real friction, and this first install is trust-on-first-use over TLS either
# way. But if cosign IS present, a failed verification is fatal -- at that point
# we have positive evidence something is wrong.
#
# A pinned manifest (SEAMLESS_CHECKSUMS_SHA256, matched in download()) had its
# signature checked before this script ran, by the seamlessd that pinned it, so
# a missing cosign -- the usual case under launchd's PATH -- loses nothing and
# says so rather than warning. A present cosign still verifies it here.
verify_signature() {
	base=$1
	if ! have cosign; then
		if [ -n "$CHECKSUMS_PIN" ]; then
			step signature "verified by seamlessd"
			return 0
		fi
		warn "cosign not found -- archive verified by checksum only.
    For signature verification: https://docs.sigstore.dev/system_config/installation/"
		return 0
	fi
	# Missing signature files are the expected case for releases cut before
	# signing existed, so the fetcher's own 404 noise is suppressed -- the
	# warning below is the message worth showing.
	fetch_to "$base/checksums.txt.sig" "$TMP/checksums.txt.sig" 2>/dev/null &&
		fetch_to "$base/checksums.txt.pem" "$TMP/checksums.txt.pem" 2>/dev/null || {
		warn "this release predates artifact signing -- checksum only"
		return 0
	}
	# 0spoon = releases before the 2026 move to arctop (plan:move-to-arctop).
	# A pinned SEAMLESS_VERSION from before the move carries that signer, so
	# both orgs stay accepted for good; anything else is still foreign.
	cosign verify-blob "$TMP/checksums.txt" \
		--signature "$TMP/checksums.txt.sig" \
		--certificate "$TMP/checksums.txt.pem" \
		--certificate-identity-regexp '^https://github.com/(0spoon|arctop)/seamless/\.github/workflows/release\.yml@refs/tags/v' \
		--certificate-oidc-issuer https://token.actions.githubusercontent.com \
		>/dev/null 2>&1 ||
		die "SIGNATURE VERIFICATION FAILED for checksums.txt.
  The release artifacts are not signed by the $REPO release workflow.
  Do not install. Please report this at https://github.com/$REPO/security"
	step signature "ok (sigstore)"
}

# Install via a temp name + rename: a plain copy over the live seamlessd fails
# with ETXTBSY while the service is running, and re-running this script over a
# running install is the upgrade path. rename swaps the directory entry, so the
# old daemon keeps its inode until the service restart below replaces it.
install_binary() {
	install -m 0755 "$TMP/$1" "$INSTALL_DIR/.$1.new"
	mv -f "$INSTALL_DIR/.$1.new" "$INSTALL_DIR/$1"
}

unpack() {
	tar -xzf "$TMP/$TARBALL" -C "$TMP" ||
		die "could not extract $TARBALL"
	mkdir -p "$INSTALL_DIR"
	install_binary seamlessd
	install_binary seam
	step installed "$INSTALL_DIR/seamlessd, $INSTALL_DIR/seam"
}

# install-hooks does the config bootstrap too: it calls config.EnsureAPIKey,
# which generates the bearer key into $CONFIG when no config file exists. So on
# a first install it must run BEFORE the service starts -- the service hardcodes
# SEAMLESS_CONFIG, and a SEAMLESS_CONFIG pointing at a missing file is a hard
# error that KeepAlive would turn into a respawn loop. Once $CONFIG exists it
# runs AFTER the restart instead; main() says why.
#
# The cd is not decoration: ./seamless.yaml is the last entry in the config
# search path, so running from a clone would otherwise bind the install to
# whatever config happened to be in the user's cwd.
# install-hooks --client first shipped in v0.3.3, but this script is always
# fetched from main and can install any pinned SEAMLESS_VERSION. Ask the binary
# we just unpacked rather than assuming: an unknown flag fails flag parsing, and
# under `set -eu` that aborts main() before the service is ever registered --
# leaving new binaries with no config, no key, no hooks, and no daemon.
# -h parses flags and returns before any config load, so the probe is side-effect free.
supports_client_flag() {
	probe=$("$INSTALL_DIR/seamlessd" install-hooks -h 2>&1 || true)
	# A probe that printed nothing failed to run; it did not prove the flag is
	# absent. Assume the modern binary rather than silently downgrading a current
	# install to Claude-Code-only.
	[ -n "$probe" ] || return 0
	printf '%s' "$probe" | grep -q -- '-client'
}

# The embedded seam-onboard/seam-research skills shipped together with the
# --skills flag: a pinned v0.3.3-v0.3.6 binary accepts --client but bundles no
# skills, so the closing /seam-onboard advice would name a skill that was never
# installed. Warn instead of silently under-delivering.
supports_skills_flag() {
	probe=$("$INSTALL_DIR/seamlessd" install-hooks -h 2>&1 || true)
	[ -n "$probe" ] || return 0
	printf '%s' "$probe" | grep -q -- '-skills'
}

# The claude-desktop target and comma-separated --client lists shipped in the
# same release, so one probe covers both: a help text that names claude-desktop
# is a binary that also parses lists.
supports_desktop_client() {
	probe=$("$INSTALL_DIR/seamlessd" install-hooks -h 2>&1 || true)
	[ -n "$probe" ] || return 0
	printf '%s' "$probe" | grep -q -- 'claude-desktop'
}

# --server-url (with --api-key) is the client-install path, and it is newer than
# all of the above. Same probe, same reason: a pinned SEAMLESS_VERSION that
# predates it would fail flag parsing and abort the install after the binaries
# are already in place. An empty probe failed to run and proves nothing, so it
# assumes the modern binary, exactly like the probes above.
supports_server_url_flag() {
	probe=$("$INSTALL_DIR/seamlessd" install-hooks -h 2>&1 || true)
	[ -n "$probe" ] || return 0
	printf '%s' "$probe" | grep -q -- '-server-url'
}

wire_hooks() {
	if [ -n "${SEAMLESS_NO_HOOKS:-}" ]; then
		step hooks "skipped (SEAMLESS_NO_HOOKS)"
		return 0
	fi
	if [ -n "$SERVER_URL" ] && ! supports_server_url_flag; then
		die "seamless $VERSION predates --server-url and cannot install as a client of $SERVER_URL; drop SEAMLESS_VERSION to get the latest"
	fi
	if ! supports_desktop_client && client_has "$AGENT_CLIENT" claude-desktop; then
		# A pinned pre-chat-surface binary: drop the desktop target visibly (the
		# closing advice must not then mention the app, so AGENT_CLIENT itself
		# shrinks) and fall back to that binary's vocabulary for what remains.
		warn "seamless $VERSION predates the Claude app chat surface; skipping claude-desktop (drop SEAMLESS_VERSION to get the latest)"
		AGENT_CLIENT=$(printf '%s' "$AGENT_CLIENT" | sed 's/claude-desktop//;s/,,/,/;s/^,//;s/,$//')
		[ -n "$AGENT_CLIENT" ] ||
			die "seamless $VERSION cannot wire the Claude app chat surface; rerun with SEAMLESS_CLIENT=claude|codex|all, or drop SEAMLESS_VERSION to get the latest"
	fi
	CLIENT_VALUE=$AGENT_CLIENT
	if ! supports_desktop_client; then
		# Pre-list binaries only understand single words and "all".
		case $CLIENT_VALUE in claude,codex) CLIENT_VALUE=all ;; esac
	fi
	# Unquoted on purpose: this must split into two words, and CLIENT_VALUE is a
	# validated comma list of targets, never free text.
	CLIENT_ARGS="--client $CLIENT_VALUE"
	if ! supports_client_flag; then
		[ "$AGENT_CLIENT" = claude ] ||
			die "seamless $VERSION predates --client and cannot wire $AGENT_CLIENT; rerun with SEAMLESS_CLIENT=claude, or drop SEAMLESS_VERSION to get the latest"
		# Pre-0.3.3 is Claude-Code-only and bundles no skills; the old installer
		# passed no --client here, so this is byte-for-byte its behavior.
		warn "seamless $VERSION predates --client and the bundled skills; wiring Claude Code only"
		CLIENT_ARGS=""
	fi
	if [ -n "$CLIENT_ARGS" ] && ! supports_skills_flag; then
		warn "seamless $VERSION predates the bundled skills; the seam-onboard/seam-research skills will not be installed (drop SEAMLESS_VERSION to get the latest)"
	fi
	# The tail of the argument list rides in the positional parameters (local to
	# this function) rather than in another string: the API key is user input
	# and may carry whitespace, and "$@" is the only expansion that keeps it one
	# word. $CLIENT_ARGS stays unquoted on purpose -- see above.
	set -- --seam "$INSTALL_DIR/seam"
	if [ -n "$SERVER_KEY" ]; then
		# The client pair: the URL to dial and the key to dial it with. The
		# binary writes role: client + server_url + mcp.api_key from these --
		# no data dir, because a client stores nothing locally. Only for
		# SEAMLESS_SERVER_URL: a client install read back from $CONFIG passes
		# neither, and install-hooks wires against that file as it stands.
		set -- --server-url "$SERVER_URL" --api-key "$SERVER_KEY" "$@"
	fi
	if [ -f "$CONFIG" ]; then
		# shellcheck disable=SC2086
		(cd "$TMP" && SEAMLESS_CONFIG="$CONFIG" "$INSTALL_DIR/seamlessd" install-hooks $CLIENT_ARGS "$@")
	else
		# Unset, not pointed at $CONFIG: the file does not exist yet, and
		# EnsureAPIKey only writes it when the config resolves to nothing.
		# shellcheck disable=SC2086
		(cd "$TMP" && unset SEAMLESS_CONFIG && "$INSTALL_DIR/seamlessd" install-hooks $CLIENT_ARGS "$@")
	fi
}

# config_value <key>: a top-level scalar out of the existing $CONFIG, read the
# way the Makefile reads addr:, role: and server_url: -- an optional double
# quote, no trailing comment. Empty when the file or the key is absent.
config_value() {
	sed -n "s/^$1:[[:space:]]*\"\{0,1\}\([^\"[:space:]]*\).*/\1/p" "$CONFIG" 2>/dev/null | head -1
}

# Read the port back out of the config the way the Makefile does, so someone who
# edited addr: gets a health poll and a console URL that follow their change
# instead of an install that claims to have failed.
read_addr() {
	ADDR=$(config_value addr) || true
	[ -n "${ADDR:-}" ] || ADDR=$DEFAULT_ADDR
}

# Keep in step with deploy/launchd/org.thereisnospoon.seamless.plist, which is
# what `make install` renders from a clone. The duplication is deliberate: this
# script is fetched on its own, with no repo and no checkout to read a template
# out of, and inlining beats shipping a template only future releases would
# carry.
service_launchd() {
	plist=$HOME/Library/LaunchAgents/$LABEL.plist
	mkdir -p "$HOME/Library/LaunchAgents" "$HOME/.seamless"
	cat >"$TMP/$LABEL.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Written by https://thereisnospoon.org/install. Re-run it to update. -->
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>$LABEL</string>
  <key>ProgramArguments</key>
  <array>
    <string>$INSTALL_DIR/seamlessd</string>
    <string>serve</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>SEAMLESS_CONFIG</key>
    <string>$CONFIG</string>
  </dict>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>StandardOutPath</key>
  <string>$LOG</string>
  <key>StandardErrorPath</key>
  <string>$LOG</string>
</dict>
</plist>
EOF
	# Rewrite the plist only when its bytes changed: macOS posts a Background
	# Items notice for every rewritten LaunchAgent, and an update that changed
	# nothing in it has nothing to announce. The reload below runs regardless,
	# because the binary underneath did change.
	cmp -s "$TMP/$LABEL.plist" "$plist" 2>/dev/null || cat "$TMP/$LABEL.plist" >"$plist"

	# bootout is asynchronous: it returns before the old daemon has exited, the
	# label lingers until it has, and bootstrapping too soon fails with
	# "Bootstrap failed: 5: Input/output error". Retry every 0.1s, so a daemon
	# that takes a few dozen ms to drain costs one retry instead of a whole
	# second on every install and update. The 100 tries (10s and up) outlast
	# launchd's 5s exit timeout, after which a daemon that ignored SIGTERM is
	# SIGKILLed and its label freed.
	#
	# The plist sets RunAtLoad, so a bootstrap that succeeded has already
	# started the new binary. Kicking it anyway kills the process launchd
	# spawned a moment earlier, and KeepAlive holds the replacement for the 10s
	# throttle interval -- about 10s added to every update. kickstart is only
	# the fallback for a job no bootstrap could replace (a wedged one bootout
	# failed to evict), and a poor one: re-execing a job in place after its
	# binary was swapped runs against the code-signing identity launchd cached,
	# which macOS 26 kills. Same rule as the Makefile's _reload-service, which
	# has the full story.
	uid=$(id -u)
	launchctl bootout "gui/$uid/$LABEL" 2>/dev/null || true
	booted=0
	i=0
	while [ "$i" -lt 100 ]; do
		if launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null; then
			booted=1
			break
		fi
		i=$((i + 1))
		sleep 0.1
	done
	[ "$booted" = 1 ] || launchctl kickstart -k "gui/$uid/$LABEL" 2>/dev/null || true
	launchctl print "gui/$uid/$LABEL" >/dev/null 2>&1 ||
		die "$LABEL failed to bootstrap; check $LOG"
	SERVICE_STARTED=1
	step service "$LABEL (launchd)"
}

service_systemd() {
	if ! have systemctl || ! systemctl --user show-environment >/dev/null 2>&1; then
		step service "skipped (no systemd --user session)"
		say "start it yourself: SEAMLESS_CONFIG=$CONFIG $INSTALL_DIR/seamlessd serve"
		return 0
	fi
	unit=$HOME/.config/systemd/user/seamless.service
	mkdir -p "$HOME/.config/systemd/user" "$HOME/.seamless"
	# StartLimitIntervalSec=0 lifts systemd's start limit (5 starts in 10s).
	# Without it, a crash loop, this script's own restart or an updater's
	# rollback that runs into the limit leaves the service down for good:
	# Restart=always does not retry past it. With it, Restart=always keeps
	# trying every RestartSec, the way launchd's KeepAlive does.
	cat >"$unit" <<EOF
# Written by https://thereisnospoon.org/install. Re-run it to update.
[Unit]
Description=Seamless -- local-first memory and coordination substrate for AI agents
Documentation=$DOCS
After=network.target
StartLimitIntervalSec=0

[Service]
Type=simple
Environment=SEAMLESS_CONFIG=$CONFIG
ExecStart=$INSTALL_DIR/seamlessd serve
Restart=always
RestartSec=2

[Install]
WantedBy=default.target
EOF
	systemctl --user daemon-reload
	systemctl --user enable seamless.service >/dev/null 2>&1 ||
		die "systemctl --user enable seamless.service failed"
	# enable --now would leave an already-running old binary in place, which is
	# exactly the upgrade case. Enable, then restart unconditionally, after a
	# reset-failed: it clears a start limit already hit, should the rewritten
	# unit not have loaded (the daemon-reload above is unchecked).
	systemctl --user reset-failed seamless.service 2>/dev/null || true
	systemctl --user restart seamless.service ||
		die "systemctl --user restart seamless.service failed; check: journalctl --user -u seamless -n 50"

	# Without linger a user service dies at logout and never starts at boot --
	# the wrong shape for a daemon agents expect to find. Best-effort: on a
	# locked-down box it needs polkit or root, and a failure here is not fatal.
	if have loginctl && ! loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes'; then
		loginctl enable-linger "$(id -un)" >/dev/null 2>&1 ||
			warn "could not enable linger; the service will stop when you log out (sudo loginctl enable-linger $(id -un))"
	fi
	SERVICE_STARTED=1
	step service "seamless.service (systemd --user)"
}

# launchd and systemd both report success as soon as they have started the
# process, but the daemon binds its listener ~100ms later, and a launchd job
# that had to fall back to kickstart sits out a 10s throttle first. Poll for
# about 30s until it actually answers -- AS the release just installed. The
# daemon this run replaced can still be the one answering (exiting, or never
# restarted), and a green install must mean the new build is serving.
wait_healthy() {
	seen=
	i=0
	while [ "$i" -lt 150 ]; do
		if health_ok "$ADDR"; then
			served=$(served_version)
			if [ "$served" = "$VERSION" ]; then
				step healthz "ok -- $HEALTH_URL"
				return 0
			fi
			seen=${served:-no version}
		fi
		i=$((i + 1))
		sleep 0.2
	done
	# The daemon logs wherever its supervisor sends it, so point at the one
	# that exists on this platform rather than a path Linux never writes.
	case $OS in
	darwin) where="check the log: $LOG" ;;
	*) where="check the log: journalctl --user -u seamless -n 50" ;;
	esac
	[ -z "$seen" ] ||
		die "after 30s the daemon on $ADDR reports $seen rather than $VERSION; $where"
	die "no /healthz from $ADDR over http or https after 30s; $where"
}

# The client half of wait_healthy. Nothing was started here, so there is no
# listener race to wait out -- this asks the server whether it answers this
# machine, and a few tries absorb a slow link rather than a boot. A silent
# server is a warning and not a die: the clients are wired either way, the
# install is complete, and re-running this script is not how you retry a server
# that was merely asleep.
check_server() {
	i=0
	while [ "$i" -lt 5 ]; do
		if http_ok "$SERVER_URL/healthz"; then
			step healthz "ok -- $SERVER_URL"
			return 0
		fi
		i=$((i + 1))
		sleep 0.2
	done
	warn "no /healthz from $SERVER_URL -- this machine is wired to it anyway.
    Check that seamlessd is serving there, that its addr is not loopback-only,
    and that this machine can reach that host and port."
}

main() {
	INSTALL_DIR=${SEAMLESS_INSTALL_DIR:-$HOME/.local/bin}

	# launchd/systemd --user, ~/.config, ~/.seamless: every path here is per
	# user. Under `curl | sudo sh` all of it would land in root's home and the
	# agents that need it would never see it.
	if [ "$(id -u)" = 0 ] && [ -z "${SEAMLESS_ALLOW_ROOT:-}" ]; then
		die "run this as your own user, not root: Seamless installs a per-user service
into \$HOME (set SEAMLESS_ALLOW_ROOT=1 if you really are a single-user container)"
	fi
	have tar || die "need tar"

	# A SEAMLESS_CONFIG in the environment pointing at a file that does not
	# exist would make every seamlessd call below fail; and honouring one that
	# does exist while the service reads $CONFIG would install a split brain.
	if [ -n "${SEAMLESS_CONFIG:-}" ] && [ "$SEAMLESS_CONFIG" != "$CONFIG" ]; then
		die "SEAMLESS_CONFIG is set to $SEAMLESS_CONFIG, but this installer manages $CONFIG
unset it to install, or set the daemon up by hand: $DOCS"'install/'
	fi

	read_checksums_pin

	# Client mode. The trailing slash is stripped because server_url is a base
	# URL with no path, and "http://host:8081/" is a path as far as the config
	# validator is concerned -- a pasted browser URL should still work.
	#
	# Without SEAMLESS_SERVER_URL, a config that already says role: client
	# keeps this machine a client of its server_url, read the way the Makefile
	# reads it. A re-run that does not repeat the knob -- an update -- must not
	# hand a client the service `seamlessd serve` refuses under that role, which
	# KeepAlive would turn into a crash loop. An explicit SEAMLESS_SERVER_URL
	# wins, as a knob the user set always does; install-hooks then refuses a
	# config file that names a different server.
	SERVER_URL=${SEAMLESS_SERVER_URL:-}
	SERVER_URL=${SERVER_URL%/}
	if [ -n "$SERVER_URL" ]; then
		SERVER_KEY=${SEAMLESS_MCP_API_KEY:-}
		[ -n "$SERVER_KEY" ] ||
			die "SEAMLESS_SERVER_URL is set but SEAMLESS_MCP_API_KEY is not: a client needs the
server's bearer key as well as its URL. Run  seamlessd client-config  on the
server and use the line it prints."
	elif [ "$(config_value role)" = client ]; then
		SERVER_URL=$(config_value server_url)
		SERVER_URL=${SERVER_URL%/}
		[ -n "$SERVER_URL" ] ||
			die "$CONFIG says role: client but sets no server_url; add the URL of the server this machine dials (server_url: https://host:8081), then re-run"
	fi

	detect_platform
	select_agent_client
	resolve_version
	printf '\n  seamless %s  %s/%s\n' "$VERSION" "$OS" "$ARCH"

	TMP=$(mktemp -d)
	trap 'rm -rf "$TMP"' EXIT INT TERM

	download
	unpack

	# Restart the service and wait for it to answer as the new build BEFORE
	# rewiring the clients (make install restarts first too): a wiring failure
	# then exits non-zero with this release already serving, instead of leaving
	# the old daemon serving while everything else runs the new binaries. That
	# needs $CONFIG: on a first install install-hooks is what writes it, and the
	# service pins SEAMLESS_CONFIG to it, so a first install wires first. A
	# client install and SEAMLESS_NO_SERVICE start nothing, so they keep wiring
	# first as well.
	HOOKS_FIRST=1
	if [ -z "$SERVER_URL" ] && [ -z "${SEAMLESS_NO_SERVICE:-}" ] && [ -f "$CONFIG" ]; then
		HOOKS_FIRST=0
	fi
	if [ "$HOOKS_FIRST" = 1 ]; then
		wire_hooks
	fi

	if [ -n "$SERVER_URL" ]; then
		# No service and no read_addr: a client starts no daemon and binds no
		# port here, so there is no local addr to read and nothing to
		# supervise. The health of the install is the server's /healthz.
		step service "skipped (client of $SERVER_URL)"
		check_server
	else
		read_addr
		if [ -n "${SEAMLESS_NO_SERVICE:-}" ]; then
			step service "skipped (SEAMLESS_NO_SERVICE)"
			say "start it yourself: SEAMLESS_CONFIG=$CONFIG $INSTALL_DIR/seamlessd serve"
		else
			case $OS in
			darwin) service_launchd ;;
			linux) service_systemd ;;
			esac
		fi
		if [ "$SERVICE_STARTED" = 1 ]; then
			wait_healthy
		fi
	fi
	if [ "$HOOKS_FIRST" = 0 ]; then
		wire_hooks
	fi

	printf '\n'
	case ":$PATH:" in
	*":$INSTALL_DIR:"*) ;;
	*)
		say "$INSTALL_DIR is not on your PATH. Add it:"
		say "  export PATH=\"$INSTALL_DIR:\$PATH\""
		printf '\n'
		;;
	esac
	say 'next:'
	case ",$AGENT_CLIENT," in
	*,claude,*)
		case ",$AGENT_CLIENT," in
		*,codex,*) say '  open any git repo in Claude Code or the Codex app, CLI, or IDE -- Seamless maps it to a project on its own' ;;
		*) say '  open any git repo in Claude Code -- Seamless maps it to a project on its own' ;;
		esac
		;;
	*,codex,*) say '  open any git repo in the Codex app, CLI, or IDE -- Seamless maps it to a project on its own' ;;
	esac
	if client_has "$AGENT_CLIENT" claude-desktop; then
		say '  chat in the Claude app -- the seamless MCP tools load after an app restart'
	fi
	say "  seamlessd console-open   # open the console, already logged in"
	printf '\n'
	if client_has "$AGENT_CLIENT" claude && client_has "$AGENT_CLIENT" codex; then
		say 'restart both clients. Codex CLI users: review/approve Seamless in  /hooks.'
		say 'Codex desktop app users: hook trust is beta; confirm a <seam-briefing>.'
		say 'then run  /seam-onboard  in Claude Code or  $seam-onboard  in Codex.'
	elif client_has "$AGENT_CLIENT" claude; then
		say 'restart Claude Code, then run  /seam-onboard  once.'
	elif client_has "$AGENT_CLIENT" codex; then
		say 'restart Codex. CLI users: review/approve Seamless in  /hooks.'
		say 'desktop app users: hook trust is beta; confirm a <seam-briefing>, then run  $seam-onboard  once.'
	fi
	if client_has "$AGENT_CLIENT" claude-desktop; then
		say 'restart the Claude app to load the chat-surface MCP bridge (it reads its config at startup).'
	fi
	printf '\n'
	say "uninstall anytime:  $INSTALL_DIR/seamlessd uninstall"
	say "docs: $DOCS"
	printf '\n'
}

main "$@"
