#!/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 config and ~/.seamless are never touched. 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_CLIENT             claude|codex|claude-desktop|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_NO_HOOKS=1         skip agent hooks, MCP registration, and skills
#   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=0spoon/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=

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,codex,claude-desktop'; else echo 'claude,codex'; fi
}

# compose_clients <claude01> <codex01> <desktop01>: canonical comma list in the
# stable install order, empty when nothing is selected.
compose_clients() {
	CC_LIST=
	if [ "$1" = 1 ]; then CC_LIST=claude; fi
	if [ "$2" = 1 ]; then CC_LIST=${CC_LIST:+$CC_LIST,}codex; fi
	if [ "$3" = 1 ]; then CC_LIST=${CC_LIST:+$CC_LIST,}claude-desktop; 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 | codex | cx) PC_CODEX=1 ;;
		3 | claude-desktop | desktop) PC_DESKTOP=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)')"
		printf '  [2] Codex (app/CLI/IDE) %s\n' "$([ "$SELECT_CODEX" = 1 ] && echo '(detected)' || echo '(not detected)')"
		if [ "$OS" = darwin ]; then
			printf '  [3] Claude app (chat) %s\n' "$([ "$SELECT_DESKTOP" = 1 ] && echo '(detected)' || echo '(not detected)')"
		else
			printf '  [3] Claude app (chat) (not supported on %s)\n' "$OS"
		fi
		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() {
	if [ -n "${SEAMLESS_CLIENT:-}" ]; then
		parse_client_choice "$SEAMLESS_CLIENT" ||
			die "invalid SEAMLESS_CLIENT=$SEAMLESS_CLIENT (valid values: claude, codex, claude-desktop, 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, Codex, or the Claude app)
set SEAMLESS_CLIENT=claude|codex|claude-desktop|all to install anyway'
		warn 'no agent client was detected on this machine (Claude Code, Codex, or the Claude app)'
		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|codex|claude-desktop|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/3/;s/claude/1/;s/codex/2/')
			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 wget -qO "$2" "$1"
	else die "need curl or wget"
	fi
}

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
}

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
}

# 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.25+)" ;;
	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" ||
		die "no such release asset: $base/$TARBALL
check the version at https://github.com/$REPO/releases"
	fetch_to "$base/checksums.txt" "$TMP/checksums.txt" ||
		die "could not fetch $base/checksums.txt"

	# 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"
	step checksum ok

	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.
verify_signature() {
	base=$1
	if ! have cosign; then
		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
	}
	cosign verify-blob "$TMP/checksums.txt" \
		--signature "$TMP/checksums.txt.sig" \
		--certificate "$TMP/checksums.txt.pem" \
		--certificate-identity-regexp "^https://github.com/$REPO/\.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 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.
#
# 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'
}

wire_hooks() {
	if [ -n "${SEAMLESS_NO_HOOKS:-}" ]; then
		step hooks "skipped (SEAMLESS_NO_HOOKS)"
		return 0
	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
	if [ -f "$CONFIG" ]; then
		# shellcheck disable=SC2086
		(cd "$TMP" && SEAMLESS_CONFIG="$CONFIG" "$INSTALL_DIR/seamlessd" install-hooks $CLIENT_ARGS --seam "$INSTALL_DIR/seam")
	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 --seam "$INSTALL_DIR/seam")
	fi
}

# 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=$(sed -n 's/^addr:[[:space:]]*"\{0,1\}\([^"[:space:]]*\).*/\1/p' "$CONFIG" 2>/dev/null | head -1) || 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 >"$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

	# bootout is asynchronous: the label lingers for a moment and bootstrapping
	# too soon fails with "Bootstrap failed: 5: Input/output error". Retry.
	uid=$(id -u)
	launchctl bootout "gui/$uid/$LABEL" 2>/dev/null || true
	i=0
	while [ "$i" -lt 10 ]; do
		launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null && break
		i=$((i + 1))
		sleep 1
	done
	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"
	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

[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.
	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. Poll until it actually
# answers, so a green install means it is serving rather than racing a listener
# that is not up.
wait_healthy() {
	i=0
	while [ "$i" -lt 50 ]; do
		if http_ok "http://$ADDR/healthz"; then
			step healthz "ok -- http://$ADDR"
			return 0
		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
	die "no /healthz from $ADDR after 10s; $where"
}

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

	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
	wire_hooks
	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

	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 "$@"
