#!/bin/sh
# Install, preview or undo nobrainer-tech-flow for one client, without Python.
#
# Same flags, locations, conflict rule and setup record as scripts/install.py, so
# either one can undo what the other did. Every run previews until --apply. The
# skills are linked to this checkout (never copied), one managed instruction block
# is added. Its only standing authorization is the default daily update of Flow itself;
# an existing block keeps notifying unless it already held that line. A managed block that is
# already there is replaced by this version's text when it differs, and everything
# outside the two markers stays byte for byte, as install.py does. One difference: a
# block that carries options (a standing authorization, a wiki root or a preference)
# is left exactly as it is; install.py rewrites it to the current text and keeps them.
#
# POSIX sh: dash, bash, busybox and zsh emulating sh. On Windows run it in Git Bash.

set -u
# With CDPATH set, cd can pick another folder and print its name.
unset CDPATH

# The eighteen skills, and the names earlier layouts used. Tests keep these lists
# equal to the ones in scripts/install_skills.py.
SKILLS='nobrainer-auto-fine-tune nobrainer-autoimprove nobrainer-browser nobrainer-build
nobrainer-context nobrainer-decide nobrainer-dispatcher nobrainer-rca
nobrainer-research nobrainer-review nobrainer-security nobrainer-sessions
nobrainer-skill-doctor nobrainer-spec-driven-development nobrainer-team
nobrainer-tech-flow nobrainer-wiki nobrainer-writing'
LEGACY='add-gitleaks agent-browser agents-restraint code-autoresearch codex-in-claude-code
deep-audit deep-autoresearch deep-autoreview deep-bugs-finder deep-code-review
deep-decide deep-rca dispatching-parallel-agents engineering-standards
karpathy-auto-improver karpathy-llm-wiki llm-wiki nb-add nb-dispatcher nb-flow nb-get
nb-multi nb-tidy nb-workflow nb-write nobrainer-autopilot nobrainer-capture-lesson
nobrainer-codex-context nobrainer-continuous-improvement nobrainer-fast-audit nobrainer-human-like
nobrainer-memory nobrainer-memory-memsearch nobrainer-npm-secure nobrainer-reddit
nobrainer-simplifier nobrainer-skill-browser nobrainer-starter nobrainer-style
nobrainer-team-builder nobrainer-ultra nobrainer-ultracode-workflow nobrainer-wiki-add
nobrainer-wiki-get nobrainer-wiki-tidy playwright-cli security-review session-handoff
wiki-add wiki-get wiki-tidy'
RETIRED='nobrainer-codex-context nobrainer-reddit'
START='<!-- NOBRAINER-TECH-FLOW:START -->'
END='<!-- NOBRAINER-TECH-FLOW:END -->'
LEGACY_TEXT='(^|[^[:alnum:]_])nobrainer-ultra([^[:alnum:]_]|$)|^[[:space:]]*#{1,6}[[:space:]]+nobrainer([ .]?tech)?[[:space:]]+flow([^[:alnum:]_]|$)'
# The characters a bare shell word may hold, listed rather than given as ranges:
# bash 3.2, the sh of macOS, matches [A-Z] by the locale's collation.
SAFE='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_./:=@%+-'
# U+0080 to U+009F, and U+2028 and U+2029, in UTF-8.
C1=$(printf '\302[\200-\237]')
SEPARATORS=$(printf '\342\200[\250\251]')
TAB=$(printf '\t')
CR=$(printf '\r')

# The update line of the block: on by default; an existing block that only notifies
# (written with --no-auto-update, or the default before 2.6.0) keeps notifying.
AUTO_UPDATE_LINE='- Once a day on first use, update nobrainer-tech-flow to its latest verified stable release from the canonical source, with a backup; change nothing else and ask if unsure.'
NOTIFY_UPDATE_LINE='- Once a day on first use, tell the owner when a newer stable nobrainer-tech-flow release exists; do not update it yourself.'
update_line=$AUTO_UPDATE_LINE

# The block scripts/install_personalization.py writes without options, byte for byte
# (a test compares them).
block() {
	cat <<'EOF'
<!-- NOBRAINER-TECH-FLOW:START -->
## nobrainer-tech-flow

- Use the `nobrainer-tech-flow` module as the task entrypoint when this client supports skill invocation, by the exact name this client lists for it. In Codex that is `$nobrainer-tech-flow:nobrainer-tech-flow` when modules are listed under the plugin name, or `$nobrainer-tech-flow` when they are listed plainly. If unavailable, check the documented installation path and report that limitation honestly.
- On first setup, load `nobrainer-auto-fine-tune` for a read-only capability audit; preserve MAIN model and effort, and mark unverified runtime values `UNKNOWN`.
- Preserve the user's selected MAIN model and effort. Inspect the host's actual subagent models, capabilities, concurrency and quota; work in one MAIN loop and delegate only units that gain from parallel work, context isolation, independent review or specialization. Keep coordination in MAIN. Never assume a model is available or silently substitute one.
- Split substantial work into bounded tasks with clear outputs, exclusive write scope, dependencies, and verification. Keep integration and acceptance in MAIN; avoid duplicate or filler tasks.
- Derive short-term goals from the user's long-term direction (LDD) and define observable completion criteria. At project start, inspect the existing structure and layers, then recommend a fitting approach.
- At project start, discover whether a relevant wiki exists. If one exists, read its `WIKI.md`, locate relevant `index.md` entries with a targeted search, then read only the relevant pages. Ask before creating a wiki unless project rules or prior authorization already settle it.
- Assess context and checkpoint at appropriate milestones. Recommend session rotation when it would help; do not restart or archive automatically. When rotation is authorized, use the supported lifecycle, verify exact successor takeover by ID/readback, and archive the old session only after that readback. Do not create recursive visible workers or claim a restart when unsupported.
EOF
	printf '%s\n' "$update_line"
	cat <<'EOF'
- Use `nobrainer-writing` for material writing when available; make technical documentation concrete, source-backed, and technically verified. Preserve facts, use the user's language, and verify at the actual delivery layer.
<!-- NOBRAINER-TECH-FLOW:END -->
EOF
}

usage() {
	cat <<'EOF'
usage: sh scripts/install.sh --client {claude,codex,opencode,copilot} [--home DIR] [--apply] [--undo]

Link every nobrainer-tech-flow skill and add the managed instruction block for one
client, without Python. Without --apply this only previews.

  --client  the client to set up: claude, codex, opencode or copilot
  --home    profile home for the documented default locations; when omitted,
            CLAUDE_CONFIG_DIR, CODEX_HOME and XDG_CONFIG_HOME are honoured
  --apply   write the changes; the default is a preview
  --undo    reverse the setup this script, scripts/install.py or the guided
            setup recorded (add --apply to do it)

Subsets, copy installs, the shared agents folder and other clients: docs/INSTALL.md.
EOF
}

say() { printf '%s\n' "$*"; }
err() { printf '%s\n' "$*" >&2; }

# die CODE MESSAGE [HINT]
die() {
	err "ERROR: $2"
	if [ $# -ge 3 ]; then err "HINT: $3"; fi
	exit "$1"
}

usage_error() {
	usage >&2
	err "install.sh: error: $1"
	exit 2
}

# path_problem PATH: say why the Python scripts would refuse PATH, or fail when they
# would take it. The setup record must stay readable to them, and a line break or a
# control character in a path breaks the one-line output both print.
path_problem() {
	# tr reads the octal escapes itself, so no control byte ever sits in a pattern
	# (bash keeps \001 and \177 for its own quoting).
	if [ "$(printf '%s' "$1" | LC_ALL=C tr -d '\001-\037\177')" != "$1" ]; then
		printf 'contains a control character'
		return 0
	fi
	if printf '%s\n' "$1" | LC_ALL=C grep -q -e "$C1" -e "$SEPARATORS"; then
		printf 'contains a control character or a line separator'
		return 0
	fi
	if command -v iconv >/dev/null 2>&1 && ! printf '%s' "$1" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1; then
		printf 'is not valid UTF-8'
		return 0
	fi
	return 1
}

reject_control() {
	if _q_problem=$(path_problem "$1"); then die 2 "$2 $_q_problem"; fi
}

# Windows spellings (C:\... under Git Bash or Cygwin) to the POSIX form the shell uses.
to_posix() {
	if [ "$windows" = 1 ]; then
		case $1 in ?:* | *\\*) cygpath -u "$1"; return ;; esac
	fi
	printf '%s\n' "$1"
}

# The spelling the Python scripts read back from the setup record on this system.
to_native() {
	if [ "$windows" = 1 ]; then cygpath -w "$1"; else printf '%s\n' "$1"; fi
}

# clean MODE PATH: drop empty and "." parts of an absolute path, as pathlib does.
# MODE abs also resolves ".." (os.path.abspath); MODE keep leaves it (pathlib).
clean() {
	_c_rest=$2 _c_out=
	while [ -n "$_c_rest" ]; do
		case $_c_rest in
			*/*) _c_part=${_c_rest%%/*} _c_rest=${_c_rest#*/} ;;
			*) _c_part=$_c_rest _c_rest= ;;
		esac
		case $_c_part in
			'' | .) ;;
			..) if [ "$1" = abs ]; then _c_out=${_c_out%/*}; else _c_out=$_c_out/..; fi ;;
			*) _c_out=$_c_out/$_c_part ;;
		esac
	done
	printf '%s\n' "${_c_out:-/}"
}

# physical PATH: resolve the symbolic links in the part of PATH that exists, as
# pathlib's resolve() does, so two spellings of one folder compare equal.
physical() {
	_p_head=$1 _p_tail=
	while [ ! -d "$_p_head" ]; do
		_p_tail=/${_p_head##*/}$_p_tail
		_p_head=${_p_head%/*}
		if [ -z "$_p_head" ]; then
			printf '%s\n' "$1"
			return
		fi
	done
	# The slash keeps a trailing line break of a folder name across the substitution.
	_p_head=$(cd -P "$_p_head" 2>/dev/null && printf '%s/' "$PWD") || _p_head=$1/ _p_tail=
	_p_head=${_p_head%/}
	printf '%s%s\n' "${_p_head%/}" "$_p_tail"
}

quote() {
	case $1 in
		'' | *[!$SAFE]*) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;;
		*) printf '%s' "$1" ;;
	esac
}

# The command that continues this run with more flags, keeping an explicit --home.
command_for() {
	printf 'sh %s --client %s' "$(quote "$here/install.sh")" "$client"
	for _flag in "$@"; do printf ' %s' "$_flag"; done
	if [ "$home_given" = 1 ]; then printf ' --home %s' "$(quote "$home")"; fi
	printf '\n'
}

# The Python undo, for a record or a file this script does not handle.
python_undo() {
	if [ "$windows" = 1 ]; then printf 'py -3'; else printf 'python3'; fi
	printf ' %s --client %s --undo --apply' "$(quote "$here/install.py")" "$client"
	if [ "$home_given" = 1 ]; then printf ' --home %s' "$(quote "$home")"; fi
	printf '\n'
}

# config_dir VARIABLE STRICT: set config_value to the directory VARIABLE names, or to
# nothing when it is unset. An empty CODEX_HOME or XDG_CONFIG_HOME counts as unset,
# as it does for Codex and in the XDG specification. Any other value is used exactly
# as written; one that is not absolute (an empty CLAUDE_CONFIG_DIR included) is an
# error when STRICT is yes: the client resolves it against its own working
# directory, and this script cannot know which one that is.
config_dir() {
	eval "_d_set=\${$1+set} _d_value=\${$1-}"
	config_value=
	if [ -z "$_d_set" ]; then return 0; fi
	if [ -z "$_d_value" ] && [ "$1" != CLAUDE_CONFIG_DIR ]; then return 0; fi
	if [ "$2" = yes ]; then
		reject_control "$_d_value" "$1"
	elif path_problem "$_d_value" >/dev/null; then
		# Another client's variable: unusable counts as unset.
		return 0
	fi
	_d_path=$(to_posix "$_d_value")
	case $_d_path in
		/*) config_value=$(clean keep "$_d_path") ;;
		*) if [ "$2" = yes ]; then die 2 "$1 must be an absolute path, not '$_d_value'"; fi ;;
	esac
}

# locate CLIENT MODE: set skills_dir, target and label. MODE env honours the variable
# the client reads, plain uses the documented defaults under $home, and lenient
# honours the variable but falls back to the default when it is unusable.
locate() {
	_l_base=
	case $1 in
		claude) _l_var=CLAUDE_CONFIG_DIR ;;
		codex) _l_var=CODEX_HOME ;;
		opencode) _l_var=XDG_CONFIG_HOME ;;
		*) _l_var= ;;
	esac
	if [ -n "$_l_var" ] && [ "$2" != plain ]; then
		if [ "$2" = env ]; then config_dir "$_l_var" yes; else config_dir "$_l_var" no; fi
		_l_base=$config_value
	fi
	case $1 in
		claude)
			_l_base=${_l_base:-$home/.claude}
			skills_dir=$_l_base/skills target=$_l_base/CLAUDE.md label='Claude Code' ;;
		codex)
			skills_dir=$home/.agents/skills
			target=${_l_base:-$home/.codex}/AGENTS.md label=Codex ;;
		opencode)
			_l_base=${_l_base:-$home/.config}
			skills_dir=$_l_base/opencode/skills target=$_l_base/opencode/AGENTS.md label=OpenCode ;;
		copilot)
			skills_dir=$home/.copilot/skills
			target=$home/.copilot/copilot-instructions.md label='GitHub Copilot CLI' ;;
	esac
}

# Whether the Claude file is nothing but an @ import of the Codex global file that
# already holds the block. Only that form counts: whether an import inside a longer
# Markdown file is live depends on how Claude Code parses the whole file, and a
# second copy of the block is harmless where a missing one is not.
inherits_codex() {
	[ "$client" = claude ] && [ "$file_status" != create ] || return 1
	if [ "$home_given" = 1 ]; then _h_mode=plain; else _h_mode=lenient; fi
	_h_skills=$skills_dir _h_target=$target _h_label=$label
	locate codex "$_h_mode"
	codex_file=$(clean keep "$target")
	skills_dir=$_h_skills target=$_h_target label=$_h_label
	# A NUL byte becomes a control character: never an import, and never lost by the
	# command substitution.
	_h_lines=$(tr '\r\000' '\n\001' <"$target" | grep -a -v "^[ $TAB]*\$")
	case $_h_lines in *"
"*) return 1 ;; esac
	_h_line=$(printf '%s\n' "$_h_lines" | sed "s/[ $TAB]*\$//")
	case $codex_file in
		"$home"/*) _h_short="@~/${codex_file#"$home"/}" ;;
		*) _h_short= ;;
	esac
	[ "$_h_line" = "@$codex_file" ] || { [ -n "$_h_short" ] && [ "$_h_line" = "$_h_short" ]; } || return 1
	grep -q -F -e "$START" "$codex_file" 2>/dev/null && grep -q -F -e "$END" "$codex_file" 2>/dev/null
}

# is_our_link LINK SOURCE: LINK is a symbolic link that resolves to SOURCE.
is_our_link() {
	[ -L "$1" ] || return 1
	_o_resolved=$(cd -P "$1" 2>/dev/null && pwd -P) || return 1
	[ "$_o_resolved" = "$2" ]
}

# Put a claimed entry ($1) back at its name ($2) unless something new stands there; the
# private folder it was moved into goes too. A blocked restore keeps the claim and says where.
unclaim() {
	# Restore through the same native no-replace primitive as the Python installer.
	# Normal shell installs need no Python; without it, a recovery claim stays intact.
	if command -v python3 >/dev/null 2>&1 && python3 - "$root/scripts" "$1" "$2" <<'PYTHON'
import sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
from install_skills import restore_claim
try:
    restore_claim(Path(sys.argv[3]), Path(sys.argv[2]))
except (OSError, RuntimeError) as exc:
    print(exc, file=sys.stderr)
    sys.exit(1)
PYTHON
	then
		return 0
	fi
	err "PRESERVED: atomic restore unavailable or blocked; could not put $2 back; it is kept at $1"
	return 1
}

# A recorded skill link this checkout made: $1 is the link, $2 the skill name. The link of
# a retired module dangles, so it is compared as written.
owns_link() {
	case " $RETIRED " in
		*" $2 "*) is_exact_legacy_link "$1" "$2" ;;
		*) is_our_link "$1" "$root/skills/$2" ;;
	esac
}

# Resolve links before "..", including dangling final links. A trailing slash
# preserves a pathname's trailing newlines across command substitution.
resolve_legacy_path() (
	_r_rest=$1 _r_resolved=
	shift
	while [ -n "$_r_rest" ]; do
		case $_r_rest in
			*/*) _r_part=${_r_rest%%/*} _r_rest=${_r_rest#*/} ;;
			*) _r_part=$_r_rest _r_rest= ;;
		esac
		case $_r_part in
			'' | .) continue ;;
			..) _r_resolved=${_r_resolved%/*}; continue ;;
		esac
		_r_path=$_r_resolved/$_r_part
		if [ -L "$_r_path" ]; then
			for _r_seen do [ "$_r_seen" != "$_r_path" ] || exit 1; done
			# Raw readlink output plus a sentinel preserves every trailing byte.
			_r_link=$(readlink -n "$_r_path" 2>/dev/null && printf '.') || exit 1
			_r_link=${_r_link%.}
			if [ "$windows" = 1 ]; then
				_r_link=$(to_posix "$_r_link" && printf '.') || exit 1
				_r_link=${_r_link%.}
				_r_link=${_r_link%?}
			fi
			case $_r_link in /*) ;; *) _r_link=$_r_resolved/$_r_link ;; esac
			_r_resolved=$(resolve_legacy_path "$_r_link" "$@" "$_r_path") || exit 1
			_r_resolved=${_r_resolved%/}
		else
			_r_resolved=$_r_path
		fi
	done
	printf '%s/' "$_r_resolved"
)

# Match Python's exact old root-level or skills/ link, never just a checkout prefix.
is_exact_legacy_link() (
	[ -L "$1" ] || exit 1
	_e_before=$(ls -din "$1" 2>/dev/null) || exit 1
	_e_resolved=$(resolve_legacy_path "$1") || exit 1
	_e_after=$(ls -din "$1" 2>/dev/null) || exit 1
	[ "$_e_before" = "$_e_after" ] && [ -L "$1" ] || exit 1
	_e_root=$(resolve_legacy_path "$root/$2") || exit 1
	_e_skills=$(resolve_legacy_path "$root/skills/$2") || exit 1
	[ "$_e_resolved" = "$_e_root" ] || [ "$_e_resolved" = "$_e_skills" ]
)

make_link() {
	# Git Bash and Cygwin copy the tree for "ln -s" unless told to make a real link.
	# -n: when another run linked the name first, fail instead of following that link
	# and writing into this checkout.
	MSYS=winsymlinks:nativestrict CYGWIN=winsymlinks:nativestrict ln -s -n "$1" "$2"
}

remove_link() {
	rm -f "$1" 2>/dev/null || rmdir "$1" 2>/dev/null
	[ ! -e "$1" ] && [ ! -L "$1" ]
}

links_work() {
	_w_dir=$(mktemp -d 2>/dev/null) || return 1
	mkdir "$_w_dir/target" && make_link "$_w_dir/target" "$_w_dir/link" 2>/dev/null && [ -L "$_w_dir/link" ]
	_w_status=$?
	rm -rf "$_w_dir"
	return "$_w_status"
}

sha256() {
	if command -v sha256sum >/dev/null 2>&1; then
		sha256sum <"$1" | cut -d ' ' -f 1
	elif command -v shasum >/dev/null 2>&1; then
		shasum -a 256 <"$1" | cut -d ' ' -f 1
	elif command -v openssl >/dev/null 2>&1; then
		openssl dgst -sha256 <"$1" | sed 's/^.*= *//'
	else
		return 1
	fi
}

utf8_ok() {
	if command -v iconv >/dev/null 2>&1; then iconv -f UTF-8 -t UTF-8 "$1" >/dev/null 2>&1; fi
}

# The setup record is the JSON file scripts/install.py and the guided setup write.
# Only its own layout is read: one key per line, strings with \\ and \" escapes.
json_string() {
	printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
}

# json_field LINE: set json_value to the string value on LINE; fail on anything else.
json_field() {
	_j_raw=${1#*\": }
	_j_raw=${_j_raw%,}
	case $_j_raw in \"*\") ;; *) return 1 ;; esac
	_j_raw=${_j_raw#\"}
	_j_raw=${_j_raw%\"}
	json_value=
	while :; do
		case $_j_raw in
			*\\*)
				json_value=$json_value${_j_raw%%\\*}
				_j_raw=${_j_raw#*\\}
				case $_j_raw in
					\\*) json_value=$json_value\\ _j_raw=${_j_raw#?} ;;
					\"*) json_value=$json_value\" _j_raw=${_j_raw#?} ;;
					*) return 1 ;;
				esac ;;
			*)
				json_value=$json_value$_j_raw
				return 0 ;;
		esac
	done
}

read_state() {
	st_dest= st_created= st_target= st_backup= st_hash= st_dirs= st_has_dirs= st_bad_dirs= _r_in=
	while IFS= read -r _r_line || [ -n "$_r_line" ]; do
		# Python writes the record with CRLF line breaks on Windows.
		_r_line=${_r_line%"$CR"}
		case $_r_in in
			skills)
				case $_r_line in
					'  ]' | '  ],') _r_in= ;;
					*)
						json_field "\": ${_r_line#    }" || return 1
						# A record from an earlier release may hold a module since retired.
						case " $(printf '%s' "$SKILLS $RETIRED" | tr '\n' ' ') " in *" $json_value "*) ;; *) return 1 ;; esac
						case " $st_created " in *" $json_value "*) return 1 ;; esac
						st_created="$st_created $json_value" ;;
				esac
				continue ;;
			dirs)
				case $_r_line in
					'  ]' | '  ],') _r_in= ;;
					*)
						json_field "\": ${_r_line#    }" || return 1
						# cygpath drops a ".." on Windows, so it is looked for in the path as written.
						case /$(printf '%s' "$json_value" | tr '\\' /)/ in */../*) st_bad_dirs=1 ;; esac
						_r_dir=$(to_posix "$json_value")
						st_dirs="$st_dirs$_r_dir
" ;;
				esac
				continue ;;
			profile)
				case $_r_line in
					'  }' | '  },') _r_in= ;;
					'    "target": '*) json_field "$_r_line" || return 1; st_target=$json_value ;;
					'    "backup": null' | '    "backup": null,') st_backup= ;;
					'    "backup": '*) json_field "$_r_line" || return 1; st_backup=$json_value ;;
					'    "written_sha256": '*) json_field "$_r_line" || return 1; st_hash=$json_value ;;
					*) return 1 ;;
				esac
				continue ;;
		esac
		case $_r_line in
			'{' | '}') ;;
			'  "destination": '*) json_field "$_r_line" || return 1; st_dest=$json_value ;;
			'  "created_skills": []' | '  "created_skills": [],') ;;
			'  "created_skills": [') _r_in=skills ;;
			'  "created_dirs": []' | '  "created_dirs": [],') st_has_dirs=1 ;;
			'  "created_dirs": [') st_has_dirs=1 _r_in=dirs ;;
			'  "profile": {}' | '  "profile": {},') ;;
			'  "profile": {') _r_in=profile ;;
			*) return 1 ;;
		esac
	done <"$1"
	[ -n "$st_dest" ] && [ -z "$_r_in" ] || return 1
	st_dest=$(to_posix "$st_dest")
	if [ -n "$st_target" ]; then st_target=$(to_posix "$st_target"); fi
	if [ -n "$st_backup" ]; then st_backup=$(to_posix "$st_backup"); fi
	# The setup writes every path in full. A relative one is a damaged record: it would be
	# read against the current folder, and no folder of it can be dropped on the way up.
	for _r_path in "$st_dest" "$st_target" "$st_backup"; do
		case $_r_path in '' | /?*) ;; *) return 1 ;; esac
	done
	return 0
}

write_state() {
	mkdir -p "$home" || return 1
	_s_tmp=$(mktemp "$home/.nobrainer-flow-onboarding-$client.XXXXXX") || return 1
	# "profile" is followed by a comma only when the list of created folders comes next.
	if [ -n "$want_dirs" ]; then _s_more=,; else _s_more=; fi
	{
		printf '{\n  "destination": %s,\n' "$(json_string "$(to_native "$skills_dir")")"
		if [ -z "$new_created" ]; then
			printf '  "created_skills": [],\n'
		else
			printf '  "created_skills": [\n'
			_s_sep=
			for _s_name in $new_created; do
				printf '%s    "%s"' "$_s_sep" "$_s_name"
				_s_sep=',
'
			done
			printf '\n  ],\n'
		fi
		if [ -z "$new_target" ]; then
			printf '  "profile": {}%s\n' "$_s_more"
		else
			printf '  "profile": {\n    "target": %s,\n' "$(json_string "$(to_native "$new_target")")"
			if [ -n "$new_backup" ]; then
				printf '    "backup": %s,\n' "$(json_string "$(to_native "$new_backup")")"
			else
				printf '    "backup": null,\n'
			fi
			printf '    "written_sha256": "%s"\n  }%s\n' "$new_hash" "$_s_more"
		fi
		if [ -n "$new_dirs" ]; then
			printf '  "created_dirs": [\n'
			printf '%s\n' "$new_dirs" | {
				_s_sep=
				while IFS= read -r _s_dir; do
					printf '%s    %s' "$_s_sep" "$(json_string "$(to_native "$_s_dir")")"
					_s_sep=',
'
				done
			}
			printf '\n  ]\n'
		elif [ -n "$want_dirs" ]; then
			printf '  "created_dirs": []\n'
		fi
		printf '}\n'
	} >"$_s_tmp" && chmod 600 "$_s_tmp" && mv -f "$_s_tmp" "$state" && return 0
	rm -f "$_s_tmp"
	return 1
}

# note_dirs PATH: add to dirs_to_make the folders on the way to PATH that do not exist yet,
# one per line. A link counts as existing: it is never a folder this script made. So does
# a spelling with ".." that resolves to an existing folder: profile/missing/.. does not
# exist before missing is made, but it is profile, which was there already. The folders
# above such a spelling are still checked, as missing is.
note_dirs() {
	_n_path=$1
	while [ -n "$_n_path" ]; do
		if [ ! -e "$_n_path" ] && [ ! -L "$_n_path" ] && [ ! -e "$(clean abs "$(physical "$_n_path")")" ]; then
			dirs_to_make="$dirs_to_make$_n_path
"
		fi
		_n_next=${_n_path%/*}
		[ "$_n_next" != "$_n_path" ] || break
		_n_path=$_n_next
	done
}

# under_link PATH: a folder above PATH is a symbolic link. A record lists each folder with
# the links on the way resolved, so such a link means the profile was rearranged since
# the setup, and removing PATH would act on another tree.
under_link() {
	_z_up=${1%/*}
	while [ -n "$_z_up" ]; do
		[ ! -L "$_z_up" ] || return 0
		_z_up=${_z_up%/*}
	done
	return 1
}

# The folders a record lists must be ones the setup can have made: each an absolute,
# plain path listed once, that is, or lies above, the skills folder, the folder of the
# instruction file or the folder of the record. Anything else is a damaged record that an undo must not act on. A path
# may be spelled with or without the links on the way, so both spellings count. Two entries
# may still lead to one folder: a link put in place of one of them since the setup does that.
# The undo removes no folder of such a group.
above() {
	case "$2/" in "$1"/*) return 0 ;; esac
	return 1
}

# on_way DIR BASE: DIR is the real form of a folder on the way to BASE as written. A folder
# the setup made is listed as it really is, and a BASE spelled with ".." reaches it on the
# way without lying below it.
on_way() {
	_y_up=$2
	while [ -n "$_y_up" ]; do
		[ "$(clean abs "$(physical "$_y_up")")" != "$1" ] || return 0
		# A damaged record can hold a relative BASE, which has no folder left to drop.
		[ "${_y_up%/*}" != "$_y_up" ] || return 1
		_y_up=${_y_up%/*}
	done
	return 1
}

check_dirs() {
	[ -z "$st_bad_dirs" ] || return 1
	[ -n "$st_dirs" ] || return 0
	_k_dest=$(physical "$st_dest")
	_k_tdir= _k_tphys=
	if [ -n "$st_target" ]; then _k_tdir=${st_target%/*} _k_tphys=$(physical "${st_target%/*}"); fi
	_k_sdir=${state%/*} _k_sphys=$(physical "${state%/*}")
	printf '%s' "$st_dirs" | {
		_k_seen='
'
		while IFS= read -r _k_dir; do
			case $_k_dir in /?*) ;; *) exit 1 ;; esac
			[ "$(clean abs "$_k_dir")" = "$_k_dir" ] || exit 1
			_k_real=$(physical "$_k_dir")
			_k_ok=
			for _k_form in "$_k_dir" "$_k_real"; do
				if above "$_k_form" "$st_dest" || above "$_k_form" "$_k_dest"; then _k_ok=1; fi
				if above "$_k_form" "$_k_sdir" || above "$_k_form" "$_k_sphys"; then _k_ok=1; fi
				if [ -n "$_k_tdir" ]; then
					if above "$_k_form" "$_k_tdir" || above "$_k_form" "$_k_tphys"; then _k_ok=1; fi
				fi
			done
			if [ -z "$_k_ok" ]; then
				if on_way "$_k_real" "$st_dest" || on_way "$_k_real" "$_k_sdir"; then _k_ok=1; fi
				if [ -n "$_k_tdir" ] && on_way "$_k_real" "$_k_tdir"; then _k_ok=1; fi
			fi
			[ -n "$_k_ok" ] || exit 1
			case $_k_seen in *"
$_k_dir
"*) exit 1 ;; esac
			_k_seen="$_k_seen$_k_dir
"
		done
	}
}

# Refuse a destination that cannot hold skills before anything is planned.
check_destination() {
	_k_path=$skills_dir
	while :; do
		if [ -e "$_k_path" ]; then
			[ -d "$_k_path" ] || die 2 "destination is not a directory: $_k_path"
			return 0
		fi
		if [ -L "$_k_path" ]; then die 2 "destination is a dangling or looping link: $_k_path"; fi
		_k_parent=${_k_path%/*}
		if [ -z "$_k_parent" ] || [ "$_k_parent" = "$_k_path" ]; then return 0; fi
		_k_path=$_k_parent
	done
}

# The command that undoes the recorded setup, with the client's variable as it was
# for that setup: the variable, not the record, decides where an undo may write.
# Fails, after printing the plain command, when no variable could have chosen it.
record_undo() {
	_v_name=
	case $client:$st_dest in
		claude:*/skills)
			_v_name=CLAUDE_CONFIG_DIR _v_base=${st_dest%/skills} _v_default=$home/.claude
			_v_target=$_v_base/CLAUDE.md ;;
		opencode:*/opencode/skills)
			_v_name=XDG_CONFIG_HOME _v_base=${st_dest%/opencode/skills} _v_default=$home/.config
			_v_target=$_v_base/opencode/AGENTS.md ;;
	esac
	case $client:$st_target in
		codex:*/AGENTS.md)
			_v_name=CODEX_HOME _v_base=${st_target%/AGENTS.md} _v_default=$home/.codex
			_v_target=$st_target ;;
	esac
	# A record whose two places no single setting explains names no setting.
	if [ -n "$_v_name" ] && [ -n "$st_target" ] && [ "$(clean keep "$st_target")" != "$(clean keep "$_v_target")" ]; then
		_v_name=
	fi
	if [ -z "$_v_name" ]; then
		command_for --undo --apply
		return 1
	fi
	# The variables count only without --home, so the undo runs without it.
	_v_prefix=
	if [ "$home_given" = 1 ]; then
		if [ "$windows" = 1 ]; then _v_prefix="USERPROFILE=$(quote "$(to_native "$home")") "; else _v_prefix="HOME=$(quote "$home") "; fi
	fi
	if [ "$(clean keep "$_v_base")" = "$(clean keep "$_v_default")" ]; then
		_v_prefix="env -u $_v_name $_v_prefix"
	else
		_v_prefix="$_v_prefix$_v_name=$(quote "$(to_native "$_v_base")") "
	fi
	_v_given=$home_given
	home_given=0
	printf '%s%s\n' "$_v_prefix" "$(command_for --undo --apply)"
	home_given=$_v_given
}

# The setup already recorded for this client must be for the same places.
check_record() {
	record=0 st_dest= st_created= st_target= st_backup= st_hash= st_dirs= st_has_dirs=
	if [ ! -e "$state" ] && [ ! -L "$state" ]; then return 0; fi
	if [ -L "$state" ] || [ ! -f "$state" ]; then die 3 "setup state path is not a regular file: $state"; fi
	read_state "$state" || die 3 "cannot read the setup record $state" \
		"it holds characters or a layout this script does not read; undo it with $(python_undo)"
	check_dirs || die 3 "the setup record $state lists a folder this setup cannot have created" \
		"inspect the record; the guided undo refuses it as well"
	record=1
	if [ "$(physical "$st_dest")" != "$(physical "$skills_dir")" ]; then
		die 3 "existing setup state belongs to a different skills destination: $st_dest" \
			"undo that setup with $(record_undo), then run this again"
	fi
	if [ -n "$st_target" ] && [ "$(clean keep "$st_target")" != "$(clean keep "$target")" ]; then
		die 3 "existing setup state belongs to a different personalization target: $st_target" \
			"undo that setup with $(record_undo), then run this again"
	fi
}

plan_skills() {
	to_link= link_count=0 kept_count=0 problems= to_retire=
	for _p_name in $SKILLS; do
		_p_source=$root/skills/$_p_name
		[ -f "$_p_source/SKILL.md" ] || die 2 "this checkout is incomplete: $_p_source/SKILL.md is missing"
		_p_target=$skills_dir/$_p_name
		if is_our_link "$_p_target" "$_p_source"; then
			kept_count=$((kept_count + 1))
		elif [ -e "$_p_target" ] || [ -L "$_p_target" ]; then
			problems="$problems
CONFLICT: $_p_name: $_p_source -> $_p_target"
		else
			to_link="$to_link $_p_name"
			link_count=$((link_count + 1))
		fi
	done
	for _p_name in $LEGACY; do
		_p_target=$skills_dir/$_p_name
		if [ -e "$_p_target" ] || [ -L "$_p_target" ]; then
			case " $RETIRED " in
				*" $_p_name "*)
					if [ -L "$_p_target" ]; then
						command -v readlink >/dev/null 2>&1 && command -v ls >/dev/null 2>&1 ||
							die 2 "this script needs readlink and ls to inspect retired skill links"
					fi
					if ! is_exact_legacy_link "$_p_target" "$_p_name"; then
						err "PRESERVED_FOREIGN: $_p_name: retired from nobrainer-tech-flow; left unchanged: $_p_target"
						continue
					fi
					# This setup's record lists the link, as an undo would remove it.
					case " $st_created " in
						*" $_p_name "*)
							to_retire="$to_retire $_p_name"
							continue ;;
					esac ;;
			esac
			problems="$problems
LEGACY: $_p_name: $_p_target"
		fi
	done
	if [ -n "$problems" ]; then
		printf '%s\n' "$problems" | sed '1d' >&2
		err "ERROR: refusing to overwrite existing targets"
		case $problems in
			*LEGACY:*) err "HINT: an entry above comes from an earlier layout of these skills; move it away after checking it, or migrate it as docs/MIGRATION_TO_FLOW.md describes." ;;
			*) err "HINT: a target above exists and is not a link to this checkout. Inspect it and move it away if it is yours to remove, or install the other skills one by one with scripts/install_skills.py --skill (docs/INSTALL.md)." ;;
		esac
		exit 3
	fi
	if [ "$link_count" -gt 0 ] && ! links_work; then
		err "ERROR: this machine cannot create symbolic links, which this script needs."
		if [ "$windows" = 1 ]; then
			err "HINT: turn on Developer Mode (Settings > System > For developers) or run Git Bash as administrator, then rerun. To copy the skills instead, see docs/INSTALL.md (Windows)."
		else
			err "HINT: check that $skills_dir can hold symbolic links, or copy the skills as docs/INSTALL.md describes."
		fi
		exit 2
	fi
}

plan_instructions() {
	file_status=
	if [ "$client" = codex ]; then
		_i_override=${target%/*}/AGENTS.override.md
		if [ -f "$_i_override" ] && grep -q '[^[:space:]]' "$_i_override" 2>/dev/null; then
			die 3 "$_i_override takes precedence over AGENTS.md in Codex, so a block written to AGENTS.md would not load" \
				"remove or empty that file and rerun, or write to it on purpose with scripts/install_personalization.py --client codex --path <file>."
		fi
	fi
	if [ -L "$target" ]; then die 3 "target must be a regular non-symlink file: $target"; fi
	if [ ! -e "$target" ]; then
		file_status=create
		return 0
	fi
	[ -f "$target" ] || die 3 "target must be a regular non-symlink file: $target"
	_i_starts=$(grep -a -c -F -e "$START" "$target" 2>/dev/null)
	_i_ends=$(grep -a -c -F -e "$END" "$target" 2>/dev/null)
	if [ "${_i_starts:-0}" = 0 ] && [ "${_i_ends:-0}" = 0 ]; then
		if grep -E -i -q -e "$LEGACY_TEXT" "$target" 2>/dev/null; then
			die 3 "unmarked existing nobrainer-tech-flow instructions detected in $target; explicit migration is required before installing a managed block" \
				"see docs/MIGRATION_TO_FLOW.md"
		fi
		utf8_ok "$target" || die 3 "target is not UTF-8 text: $target"
		file_status=append
		return 0
	fi
	# -a: a file with a NUL byte is still text here, not a "binary file".
	_i_first=$(grep -a -n -F -e "$START" "$target" | cut -d : -f 1)
	_i_last=$(grep -a -n -F -e "$END" "$target" | cut -d : -f 1)
	if [ "$_i_starts" != 1 ] || [ "$_i_ends" != 1 ] || [ "$_i_first" -gt "$_i_last" ]; then
		die 3 "malformed or duplicated nobrainer-tech-flow managed markers in $target"
	fi
	file_status=present
	# An existing block keeps updating only when it holds the update line as its own line;
	# a notify-only, edited or unknown line keeps notifying, so no grant is ever inferred.
	if block_lines "$target" && ! head -n "$block_to" "$target" | tail -n "+$block_from" |
		LC_ALL=C sed 's/[[:space:]]*$//' | grep -a -q -x -F -e "$AUTO_UPDATE_LINE"; then
		update_line=$NOTIFY_UPDATE_LINE
	fi
	# A block of an earlier version is brought up to date, but only one that can be replaced
	# without losing anything: its markers stand on lines of their own, and it holds no option.
	if ! block_is_default && block_lines "$target" && ! block_has_options; then
		file_status=refresh
	fi
}

# line_of MARKER FILE: the numbers of the lines that hold MARKER and nothing else.
line_of() {
	grep -a -n -x -F -e "$1" -e "$1$CR" "$2" | cut -d : -f 1
}

# block_lines FILE: sets block_from and block_to to the lines of the one managed
# block in FILE whose markers stand on lines of their own, or fails.
block_lines() {
	block_from=$(line_of "$START" "$1") block_to=$(line_of "$END" "$1")
	case $block_from in '' | *[!0123456789]*) return 1 ;; esac
	case $block_to in '' | *[!0123456789]*) return 1 ;; esac
	[ "$block_from" -lt "$block_to" ]
}

update_status() {
	if [ "$update_line" = "$NOTIFY_UPDATE_LINE" ]; then echo CHECK_AND_NOTIFY; else echo AUTHORIZED_SAFE_VERIFIED_ONLY; fi
}

block_is_default() {
	block_lines "$target" || return 1
	_b_text=$(head -n "$block_to" "$target" | tail -n "+$block_from")
	# As scripts/install_personalization.py compares it: from the start marker to the end
	# marker, so a carriage return that ends that last line is not part of the block.
	[ "${_b_text%"$CR"}" = "$(block)" ]
}

# block_has_options: the block block_lines found in $target carries something the owner
# chose: a pre-2.6 standing authorization, a wiki root or a preference. Only install.py and
# scripts/install_personalization.py --keep-options carry those over to a new block.
block_has_options() {
	head -n "$block_to" "$target" | tail -n "+$block_from" |
		grep -a -q -F -e 'grants standing authorization' -e '- Relevant project wiki: read ' -e '- Owner-approved setup preference: '
}

variable_note() {
	if [ "$home_given" = 1 ]; then return 0; fi
	case $client in
		claude) _v_name=CLAUDE_CONFIG_DIR ;;
		codex) _v_name=CODEX_HOME ;;
		opencode) _v_name=XDG_CONFIG_HOME ;;
		*) return 0 ;;
	esac
	eval "_v_value=\${$_v_name-}"
	if [ -n "$_v_value" ]; then say "NOTE: this setup follows $_v_name=$_v_value; keep it set when you undo it."; fi
}

preview() {
	say "$source_line for $label"
	say "SKILLS: $link_count to link into $skills_dir ($kept_count already linked)"
	for _v_name in $to_retire; do
		say "REMOVE_RETIRED: $_v_name: made by this setup, now renamed: $skills_dir/$_v_name"
	done
	if [ "$inherit" = 1 ]; then
		say "INHERITS_CODEX: $target imports $codex_file; no duplicate block added"
	else
		say "TARGET: $target"
		if [ "$file_status" = present ]; then
			say "PRESENT: the managed block is already there and is left as it is"
			if ! block_is_default; then
				say "NOTE: it differs from this version's default block but holds an option, or its markers do not stand on lines of their own, so it is not replaced here; scripts/install_personalization.py --keep-options updates it and keeps its options"
			fi
		elif [ "$file_status" = refresh ]; then
			block_lines "$target"
			say "UPDATE: nobrainer-tech-flow personalization block"
			say "AUTO_UPDATE: $(update_status)"
			say "AUTO_SESSION_RESTART: ASSESS_CHECKPOINT_RECOMMEND"
			say "PRESERVED: content outside the managed block"
			say "DIFF:"
			head -n "$block_to" "$target" | tail -n "+$block_from" | sed 's/^/-/'
			block | sed 's/^/+/'
		else
			say "MISSING: nobrainer-tech-flow personalization block"
			say "AUTO_UPDATE: $(update_status)"
			say "AUTO_SESSION_RESTART: ASSESS_CHECKPOINT_RECOMMEND"
			say "DIFF:"
			block | sed 's/^/+/'
		fi
	fi
	variable_note
	say "NO_CHANGES: preview only; install with: $(command_for --apply)"
}

# restore_mode READONLY COPY: a copy of a file without owner write permission (READONLY
# is 1) gets that permission taken away again before it replaces the file.
restore_mode() {
	if [ "$1" = 1 ]; then chmod u-w "$2"; fi
}

# read_only FILE: the owner may not write FILE.
read_only() {
	case $(ls -ld "$1") in ??-*) return 0 ;; esac
	return 1
}

# Append the block the way scripts/install_personalization.py does: after a line
# break when the file does not end with one, and without a trailing one. A lone CR
# (old Mac line breaks) gets a line break too, so that the block starts on a line
# of its own, which the undo needs. A block that is there already is replaced from its
# start marker to its end marker and nothing else, so every byte around it stays; the
# file as it was is kept as a backup first.
write_instructions() {
	_w_dir=${target%/*}
	mkdir -p "$_w_dir" || return 1
	_w_tmp=$(mktemp "$_w_dir/.${target##*/}.XXXXXX") || return 1
	if [ "$file_status" = append ] || [ "$file_status" = refresh ]; then
		_w_stamp=$(date -u +%Y%m%dT%H%M%SZ)
		made_backup=$target.bak.$_w_stamp
		_w_index=1
		while [ -e "$made_backup" ] || [ -L "$made_backup" ]; do
			made_backup=$target.bak.$_w_stamp.$_w_index
			_w_index=$((_w_index + 1))
		done
		if ! cp -p "$target" "$made_backup" || ! cp -p "$target" "$_w_tmp"; then
			rm -f "$_w_tmp"
			return 1
		fi
		# The copy keeps the file's mode. A file without owner write permission is written
		# through a copy made writable and gets that permission taken away again before the
		# rename, so the file keeps exactly the mode it had.
		_w_readonly=0
		if read_only "$target"; then _w_readonly=1; fi
		chmod u+w "$_w_tmp" || { rm -f "$_w_tmp"; return 1; }
	else
		_w_readonly=0
	fi
	if [ "$file_status" = refresh ]; then
		block_lines "$target" || { rm -f "$_w_tmp"; return 1; }
		if {
			{ [ "$block_from" -eq 1 ] || head -n "$((block_from - 1))" "$target"; } &&
				printf '%s' "$(block)" &&
				tail -n "+$block_to" "$target" | tail -c "+$((${#END} + 1))"
		} >"$_w_tmp" && restore_mode "$_w_readonly" "$_w_tmp" && mv -f "$_w_tmp" "$target"; then return 0; fi
		rm -f "$_w_tmp"
		return 1
	fi
	if [ "$file_status" = append ]; then
		case $(tail -c 1 "$_w_tmp" | od -An -to1 | tr -d ' \n') in
			'' | 012) ;;
			*) printf '\n' >>"$_w_tmp" ;;
		esac
	fi
	if printf '%s' "$(block)" >>"$_w_tmp" && restore_mode "$_w_readonly" "$_w_tmp" && mv -f "$_w_tmp" "$target"; then return 0; fi
	rm -f "$_w_tmp"
	return 1
}

install() {
	# plan_skills needs the retired links the record lists; check_record judges the record
	# after the skills, as before, so a record that cannot be read only lists none here.
	st_created=
	if [ -f "$state" ] && [ ! -L "$state" ]; then read_state "$state" || st_created=; fi
	plan_skills
	plan_instructions
	check_record
	inherit=0
	if inherits_codex; then inherit=1; fi
	if [ "$apply" = 0 ]; then
		preview
		return 0
	fi
	sha256 /dev/null >/dev/null || die 2 "this script needs sha256sum, shasum or openssl to record the setup"

	created= dirs_to_make=
	# A folder this run makes is recorded as it really is, links resolved. A line break or a
	# control character in that form would leave a record no undo can read, so such a run
	# stops before anything is written.
	note_dirs "$home"
	if [ "$link_count" -gt 0 ]; then note_dirs "$skills_dir"; fi
	if [ "$file_status" != present ] && [ "$inherit" = 0 ]; then note_dirs "${target%/*}"; fi
	printf '%s' "$dirs_to_make" | while IFS= read -r _a_dir; do
		reject_control "$(physical "$_a_dir")" "the real path of $_a_dir"
	done || exit 2
	dirs_to_make=
	# The record's folder is made before anything else is written: when it cannot be made,
	# the run stops here instead of leaving links and a block that no record describes. It
	# counts among the folders the setup made: a home that does not exist yet is made for
	# the record alone when a client variable puts the skills and the instructions elsewhere.
	note_dirs "$home"
	made_home=$dirs_to_make
	mkdir -p "$home" || die 2 "cannot create $home, where the setup record goes"
	if [ "$link_count" -gt 0 ]; then
		note_dirs "$skills_dir"
		mkdir -p "$skills_dir" || die 2 "cannot create $skills_dir"
	fi
	for _a_name in $to_link; do
		_a_target=$skills_dir/$_a_name
		_a_source=$root/skills/$_a_name
		if [ ! -e "$_a_target" ] && [ ! -L "$_a_target" ] &&
			make_link "$_a_source" "$_a_target" 2>/dev/null && is_our_link "$_a_target" "$_a_source"; then
			created="$created $_a_name"
			continue
		fi
		# A directory that appeared since the plan would have received the link inside it.
		if is_our_link "$_a_target/$_a_name" "$_a_source"; then remove_link "$_a_target/$_a_name"; fi
		for _a_done in $created; do remove_link "$skills_dir/$_a_done"; done
		if [ "$windows" = 1 ]; then
			err "HINT: turn on Developer Mode (Settings > System > For developers) or run Git Bash as administrator, then rerun."
		fi
		die 4 "could not link $_a_name into $skills_dir; the links made by this run were removed"
	done

	made_backup= wrote=0
	if [ "$file_status" != present ] && [ "$inherit" = 0 ]; then
		note_dirs "${target%/*}"
		if ! write_instructions; then
			for _a_done in $created; do remove_link "$skills_dir/$_a_done"; done
			die 4 "could not write $target; the links made by this run were removed"
		fi
		wrote=1
	fi

	# Undo puts the block back as it was before the first setup, so a record that
	# already names this file keeps its first backup (or its absence).
	new_target= new_backup= new_hash=
	if [ "$inherit" = 1 ]; then
		# The Codex file carries the block for Claude now, so, like the guided setup,
		# the record drops this file: the undo leaves it as it is.
		:
	elif [ "$record" = 1 ] && [ -n "$st_target" ] && [ -e "$target" ]; then
		new_target=$target new_backup=$st_backup new_hash=$(sha256 "$target")
	elif [ "$wrote" = 1 ]; then
		new_target=$target new_backup=$made_backup new_hash=$(sha256 "$target")
	fi
	# The retired links the record lists go last, after every other write. Each is moved into
	# a private folder and checked there; one that changed after the check goes back as it is.
	# They come back if the record cannot be saved, and are deleted only once it is.
	retired_claims= retirement_error= retirement_restore_error=
	for _a_name in $to_retire; do
		_a_link=$skills_dir/$_a_name
		is_exact_legacy_link "$_a_link" "$_a_name" || continue
		_a_text=$(readlink "$_a_link") || continue
		if ! _a_claim=$(mktemp -d "$skills_dir/.nobrainer-retire-XXXXXX"); then
			retirement_error=1
			break
		fi
		if ! mv "$_a_link" "$_a_claim/$_a_name" 2>/dev/null; then
			rmdir "$_a_claim"
			continue
		fi
		if [ -L "$_a_claim/$_a_name" ] && [ "$(readlink "$_a_claim/$_a_name")" = "$_a_text" ]; then
			retired_claims="$retired_claims$_a_claim/$_a_name
"
		else
			if ! unclaim "$_a_claim/$_a_name" "$_a_link"; then
				retirement_error=1 retirement_restore_error=1
				break
			fi
			err "PRESERVED: $_a_link changed during the setup and is left as it is"
		fi
	done
	if [ -n "$created" ] || [ -n "$to_retire" ] || [ "$wrote" = 1 ] || { [ "$record" = 1 ] && [ "$new_hash" != "$st_hash" ]; }; then
		# A retired module's link is gone once migrated; then the record drops its name.
		_k_created=
		for _k_name in $st_created; do
			case " $RETIRED " in
				*" $_k_name "*) owns_link "$skills_dir/$_k_name" "$_k_name" || continue ;;
			esac
			_k_created="$_k_created $_k_name"
		done
		new_created=$(printf '%s\n' $_k_created $created | LC_ALL=C sort -u | tr '\n' ' ')
		new_created=${new_created# }
		new_created=${new_created% }
		# Of the folders this run meant to create, the ones that exist now, with the ones an
		# earlier run of this setup made. A record from before this field has none, so none
		# is claimed: they may have been there already.
		made_dirs=$(printf '%s' "$dirs_to_make" | while IFS= read -r _a_dir; do
			if [ -n "$_a_dir" ] && [ -d "$_a_dir" ] && [ ! -L "$_a_dir" ]; then printf '%s\n' "$_a_dir"; fi
		done)
		# A folder this run made is written the way it really is, links resolved, so that runs
		# from different installers and spellings of the same path list it once. The folders an
		# earlier run recorded stay as recorded: resolving them again would follow a link put in
		# their place since, and hand the folder it points to over to the undo.
		new_dirs=$({
			printf '%s' "$st_dirs"
			printf '%s\n' "$made_dirs" | while IFS= read -r _a_dir; do
				if [ -n "$_a_dir" ]; then physical "$_a_dir"; fi
			done
		} | grep -v '^$' | LC_ALL=C sort -u)
		# The record lists the folders this setup made, an empty list included, so that an undo
		# knows to touch no other. A record from before the list keeps its form until a run
		# makes a folder, and its undo keeps the rule it was made under.
		want_dirs=1
		if [ "$record" = 1 ] && [ -z "$st_has_dirs" ] && [ -z "$new_dirs" ]; then want_dirs=; fi
		if [ -n "$retirement_error" ] || ! write_state; then
			for _a_done in $created; do remove_link "$skills_dir/$_a_done"; done
			if [ "$wrote" = 1 ]; then
				# Through a copy beside it and a rename, so a read-only file comes back as well.
				if [ -n "$made_backup" ]; then
					_a_tmp=$(mktemp "${target%/*}/.${target##*/}.XXXXXX") &&
						{ cp -p "$made_backup" "$_a_tmp" && mv -f "$_a_tmp" "$target" || rm -f "$_a_tmp"; }
				else
					rm -f "$target"
				fi
			fi
			if ! printf '%s' "$retired_claims" | while IFS= read -r _a_claim; do
				unclaim "$_a_claim" "$skills_dir/${_a_claim##*/}" || exit 1
			done; then
				die 4 "could not save the setup record $state; rollback incomplete; recovery claims were preserved"
			fi
			if [ -n "$retirement_restore_error" ]; then
				die 4 "retired link restore failed; rollback incomplete; recovery claims were preserved"
			fi
			die 4 "could not save the setup record $state; the changes of this run were reversed"
		fi
	fi
	printf '%s' "$retired_claims" | while IFS= read -r _a_claim; do
		remove_link "$_a_claim" && rmdir "${_a_claim%/*}" 2>/dev/null
		say "REMOVED_RETIRED: $skills_dir/${_a_claim##*/}"
	done

	# A home made for a record that was not needed goes again.
	if [ ! -e "$state" ] && [ -n "$made_home" ]; then
		printf '%s' "$made_home" | while IFS= read -r _a_dir; do rmdir "$_a_dir" 2>/dev/null; done
	fi

	# New links and a written instruction file are what a client has to be restarted for.
	changed=0
	if [ "$link_count" -gt 0 ] || [ "$wrote" = 1 ] || [ -n "$retired_claims" ]; then changed=1; fi
	if [ "$changed" = 1 ]; then
		say "INSTALLED: $source_line for $label"
	else
		say "UP TO DATE: $source_line is already installed for $label; nothing changed"
	fi
	say "SKILLS: $((link_count + kept_count)) linked in $skills_dir ($link_count new)"
	if [ "$inherit" = 1 ]; then
		say "INSTRUCTIONS: inherited from the Codex global instructions; nothing written here"
	elif [ "$wrote" = 1 ] && [ "$file_status" = refresh ]; then
		say "INSTRUCTIONS: $target: managed block updated to this version's text; the rest of the file is kept as it is (backup: $made_backup)"
	elif [ "$wrote" = 1 ] && [ -n "$made_backup" ]; then
		say "INSTRUCTIONS: $target (backup: $made_backup)"
	elif [ "$wrote" = 1 ]; then
		say "INSTRUCTIONS: $target"
	else
		say "INSTRUCTIONS: $target already has the managed block; left as it is"
	fi
	if [ "$changed" = 0 ] && [ "$record" = 1 ] && [ -n "$new_hash" ] && [ "$new_hash" != "$st_hash" ]; then
		say "RECORD: updated to the instruction file as it is now, so an undo keeps your edits outside the block"
	fi
	if [ -e "$state" ]; then
		say "UNDO: $(command_for --undo --apply)"
	else
		say "UNDO: nothing was changed, so there is nothing to undo"
	fi
	variable_note
	if [ "$changed" = 1 ]; then
		say "NEXT: restart $label, then give it one small task with a checkable result (docs/TRY_IT.md)."
	fi
}

# restore_into CURRENT BACKUP OUT: CURRENT with its managed block replaced by the one
# BACKUP held, or removed when BACKUP had none. Everything outside the block stays as
# it is now; when nothing else changed, OUT is BACKUP byte for byte. head and tail
# copy bytes as they are, which awk does not do everywhere (Git Bash drops CRs).
restore_into() {
	block_lines "$1" || return 1
	_x_start=$block_from _x_end=$block_to _x_from= _x_to=
	if [ -n "$2" ] && grep -q -F -e "$START" "$2"; then
		block_lines "$2" || return 1
		_x_from=$block_from _x_to=$block_to
	fi
	# A block that ended BACKUP without a line break keeps the text now after it on
	# a line of its own.
	_x_glue=
	if [ -n "$_x_from" ] && [ "$_x_to" -gt "$(($(wc -l <"$2")))" ] &&
		[ -n "$(tail -n "+$((_x_end + 1))" "$1" | od -An -N1 -to1)" ]; then
		_x_glue=1
	fi
	{
		{ [ "$_x_start" -eq 1 ] || head -n "$((_x_start - 1))" "$1"; } &&
			{ [ -z "$_x_from" ] || head -n "$_x_to" "$2" | tail -n "+$_x_from"; } &&
			{ [ -z "$_x_glue" ] || printf '\n'; } &&
			tail -n "+$((_x_end + 1))" "$1"
	} >"$3" || return 1
	if [ -n "$2" ] && { cat "$2"; printf '\n'; } | cmp -s - "$3"; then
		# The setup added one line break before the block, and it goes too.
		cat "$2" >"$3"
	fi
}

# cannot_read FILE: stop the undo before it changes anything. A file that cannot be read
# cannot be told apart from one without the managed block, and taking it for one would
# remove the links and the record while the block stays.
cannot_read() {
	die 3 "cannot read $1, so the undo cannot tell what it holds; nothing was changed" \
		"make it readable for this account, then undo again"
}

# holds_marker FILE: FILE holds either managed marker. grep tells an error (2) from no
# match (1), and an error stops the undo.
holds_marker() {
	grep -a -q -F -e "$START" -e "$END" "$1"
	case $? in
		0) return 0 ;;
		1) return 1 ;;
	esac
	cannot_read "$1"
}

# What to do about an instruction file that changed after the setup. Running the setup
# again keeps the backup the record names, and the undo after it puts the block back from
# that backup. With the backup gone or unreadable that undo would stop, so only the way by
# hand is offered.
keep_hint() {
	if [ -n "$st_backup" ] && { [ -L "$st_backup" ] || [ ! -f "$st_backup" ] || [ ! -r "$st_backup" ]; }; then
		say "HINT: to take the managed block out as well, delete the lines from $START to $END yourself and undo again; your edits outside the block stay. Running the setup again would not help: the backup $st_backup it keeps in the record is gone or unreadable."
		return 0
	fi
	say "HINT: to take the managed block out as well, run $(command_for --apply), which records the file as it is now, then $(command_for --undo --apply); your edits outside the block stay. Or delete the lines from $START to $END yourself and undo again."
}

undo() {
	# A record behind a folder this account cannot search cannot be checked: [ -e ] fails for
	# it as for a missing one, but a record may be there, so the undo stops.
	_u_up=$state
	while [ -n "$_u_up" ] && [ ! -e "$_u_up" ] && [ ! -L "$_u_up" ]; do _u_up=${_u_up%/*}; done
	if [ -n "$_u_up" ] && [ "$_u_up" != "$state" ] && [ -d "$_u_up" ] && [ ! -x "$_u_up" ]; then
		die 3 "cannot check $state: Permission denied; nothing was changed" \
			"give this account access to the folders on that path, then undo again"
	fi
	if [ ! -e "$state" ] && [ ! -L "$state" ]; then
		# A link counts, even a dangling one: it is a damaged record to inspect, not no record.
		if [ -e "$home/.nobrainer-flow-onboarding.json" ] || [ -L "$home/.nobrainer-flow-onboarding.json" ]; then
			die 3 "only an older setup record exists: $home/.nobrainer-flow-onboarding.json" \
				"undo it with $(python_undo), which can tell which client it belongs to."
		fi
		# A second undo, or an undo before any setup: nothing is recorded, so nothing is
		# to be reversed, and nothing is written.
		say "NOTHING TO UNDO: no setup is recorded for $label (looked for $state); nothing was changed"
		return 0
	fi
	if [ -L "$state" ] || [ ! -f "$state" ]; then die 3 "rollback state must be a regular file: $state"; fi
	read_state "$state" || die 3 "cannot read the setup record $state" \
		"it holds characters or a layout this script does not read; undo it with $(python_undo)"

	check_dirs || die 3 "rollback state lists a folder this setup cannot have created" \
		"inspect the record; the guided undo refuses it as well"
	_u_keep= _u_no_block=
	if [ -n "$st_target" ]; then
		# The record may only name a documented instruction file of this home.
		_u_saved_skills=$skills_dir _u_saved_target=$target _u_saved_label=$label
		_u_allowed=0
		for _u_client in claude codex opencode copilot; do
			for _u_mode in lenient plain; do
				if [ "$home_given" = 1 ] && [ "$_u_mode" = lenient ]; then continue; fi
				locate "$_u_client" "$_u_mode"
				if [ "$(clean keep "$target")" = "$(clean keep "$st_target")" ]; then _u_allowed=1; fi
			done
		done
		skills_dir=$_u_saved_skills target=$_u_saved_target label=$_u_saved_label
		if [ "$_u_allowed" != 1 ]; then
			if _u_hint=$(record_undo); then
				die 3 "refusing unexpected personalization target: $st_target" \
					"if the setup went there, undo it with the variable it was made with: $_u_hint"
			fi
			die 3 "refusing unexpected personalization target: $st_target"
		fi
		case $st_hash in
			*[!0123456789abcdef]* | '') die 3 "invalid personalization readback hash in rollback state" ;;
		esac
		[ "${#st_hash}" = 64 ] || die 3 "invalid personalization readback hash in rollback state"
		if [ -n "$st_backup" ]; then
			case $st_backup in
				"$st_target".bak.*) ;;
				*) die 3 "refusing unexpected personalization backup: $st_backup" ;;
			esac
			case ${st_backup#"$st_target".bak.} in */*) die 3 "refusing unexpected personalization backup: $st_backup" ;; esac
			if [ ! -e "$st_target" ]; then
				say "PRESERVED: personalization target is missing: $st_target"
				exit 3
			fi
		fi
		if [ -e "$st_target" ]; then
			sha256 /dev/null >/dev/null || die 2 "this script needs sha256sum, shasum or openssl to check the setup"
			# A file that cannot be read hashes to nothing, which never matches the record.
			_u_sum=$(sha256 "$st_target" 2>/dev/null)
			[ "${#_u_sum}" = 64 ] || cannot_read "$st_target"
			if [ "$_u_sum" != "$st_hash" ]; then
				# Edited since the setup: the file stays exactly as it is. With its managed block
				# taken out by hand the undo is otherwise complete; with the block still there only
				# the links go. The backup is not needed either way.
				if holds_marker "$st_target"; then
					_u_keep=1
				else
					_u_no_block=1
				fi
			elif [ -n "$st_backup" ] && { [ -L "$st_backup" ] || [ ! -f "$st_backup" ]; }; then
				die 3 "personalization backup must be a regular file: $st_backup"
			elif [ -n "$st_backup" ] && ! cat "$st_backup" >/dev/null 2>&1; then
				cannot_read "$st_backup"
			# The line-based undo below needs each block on lines of its own.
			elif ! block_lines "$st_target" || { [ -n "$st_backup" ] && grep -q -F -e "$START" "$st_backup" && ! block_lines "$st_backup"; }; then
				die 3 "the managed block in $st_target does not stand on lines of its own" \
					"undo it with $(python_undo)"
			fi
		fi
	fi
	for _u_name in $st_created; do
		_u_link=$st_dest/$_u_name
		if [ -L "$_u_link" ]; then
			if ! owns_link "$_u_link" "$_u_name"; then
				say "PRESERVED: changed target $_u_link"
				exit 3
			fi
		elif [ -e "$_u_link" ]; then
			say "PRESERVED: changed target $_u_link"
			exit 3
		fi
	done

	_u_count=0
	for _u_name in $st_created; do _u_count=$((_u_count + 1)); done
	if [ "$apply" = 0 ]; then
		say "UNDO_PLAN: unlink $_u_count skills from $st_dest"
		if [ -n "$_u_keep" ]; then
			say "PRESERVED: personalization changed since setup: $st_target; it is left exactly as it is, managed block included"
			keep_hint
		elif [ -n "$_u_no_block" ]; then
			say "INSTRUCTIONS: $st_target no longer holds the managed block, so it is left as it is"
		elif [ -n "$st_target" ] && [ -n "$st_backup" ]; then
			say "INSTRUCTIONS: $st_target: put the managed block back as it was before the setup (from $st_backup); later edits outside the block stay"
		elif [ -n "$st_target" ]; then
			say "INSTRUCTIONS: $st_target: remove the managed block"
		fi
		if [ -n "$st_dirs" ] && [ -z "$_u_keep" ]; then
			printf '%s' "$st_dirs" | LC_ALL=C sort -r | while IFS= read -r _u_dir; do
				say "FOLDER: $_u_dir: removed if it is still empty; the setup created it"
			done
		fi
		variable_note
		say "NO_CHANGES: preview only; undo with: $(command_for --undo --apply)"
		return 0
	fi

	_u_removed=0
	for _u_name in $st_created; do
		_u_link=$st_dest/$_u_name
		if owns_link "$_u_link" "$_u_name"; then
			remove_link "$_u_link" || die 4 "could not remove $_u_link"
			_u_removed=$((_u_removed + 1))
		fi
	done
	if [ -n "$_u_keep" ]; then
		# The record stays, with no links left in it, so that the block can still be taken out
		# once the record knows the file as it is now.
		skills_dir=$st_dest new_created= new_target=$st_target new_backup=$st_backup new_hash=$st_hash
		new_dirs=$(printf '%s' "$st_dirs") want_dirs=$st_has_dirs
		write_state || die 4 "the links are gone, but the setup record $state could not be updated"
		say "PARTLY UNDONE: nobrainer-tech-flow for $label"
		say "SKILLS: $_u_removed unlinked from $st_dest"
		say "PRESERVED: personalization changed since setup: $st_target; it is left exactly as it is, managed block included"
		keep_hint
		say "NEXT: restart $label to drop the skills."
		exit 3
	fi
	_u_result=
	if [ -n "$_u_no_block" ]; then
		_u_result="INSTRUCTIONS: $st_target no longer holds the managed block, so it is left as it is"
	elif [ -n "$st_target" ] && [ -e "$st_target" ]; then
		_u_tmp=$(mktemp "${st_target%/*}/.${st_target##*/}.XXXXXX") || die 4 "cannot write next to $st_target"
		# As the setup does: a file without owner write permission is written through a copy
		# made writable, which gets that permission taken away again before the rename.
		_u_readonly=0
		if read_only "$st_target"; then _u_readonly=1; fi
		cp -p "$st_target" "$_u_tmp" && chmod u+w "$_u_tmp" && restore_into "$st_target" "$st_backup" "$_u_tmp" ||
			{ rm -f "$_u_tmp"; die 4 "cannot write next to $st_target"; }
		if [ -z "$st_backup" ] && ! grep -q '[^[:space:]]' "$_u_tmp"; then
			rm -f "$_u_tmp" "$st_target"
			_u_result="INSTRUCTIONS: $st_target removed; the setup had created it"
		else
			restore_mode "$_u_readonly" "$_u_tmp" && mv -f "$_u_tmp" "$st_target" ||
				{ rm -f "$_u_tmp"; die 4 "cannot write $st_target"; }
			if [ -n "$st_backup" ] && cmp -s "$st_target" "$st_backup"; then
				_u_result="INSTRUCTIONS: $st_target is back as it was before the setup (backup kept: $st_backup)"
			else
				_u_result="INSTRUCTIONS: managed block removed from $st_target; the rest of the file is kept as it is now"
			fi
		fi
	fi
	rm -f "$state"
	# The record is gone, so the folder it sat in may be empty now. A folder that was there
	# before the setup is not listed, and a folder that holds anything stays.
	# Two entries that lead to one folder cannot both be what the setup made, and the record
	# cannot say which one is, so no entry of such a group is removed. The groups are found
	# before any folder goes.
	_u_gone=
	if [ -n "$st_dirs" ]; then
		_u_dupes=$(printf '%s' "$st_dirs" | while IFS= read -r _u_dir; do physical "$_u_dir"; done | LC_ALL=C sort | uniq -d)
		_u_shared=
		if [ -n "$_u_dupes" ]; then
			_u_shared=$(printf '%s' "$st_dirs" | while IFS= read -r _u_dir; do
				if printf '%s\n' "$_u_dupes" | grep -q -x -F -e "$(physical "$_u_dir")"; then printf '%s\n' "$_u_dir"; fi
			done)
		fi
		_u_gone=$(printf '%s' "$st_dirs" | LC_ALL=C sort -r | while IFS= read -r _u_dir; do
			if [ -n "$_u_shared" ] && printf '%s\n' "$_u_shared" | grep -q -x -F -e "$_u_dir"; then
				printf 'PRESERVED: %s: another folder in the record leads to the same folder, so it is left as it is\n' "$_u_dir"
				continue
			fi
			if [ ! -d "$_u_dir" ] || [ -L "$_u_dir" ]; then continue; fi
			if under_link "$_u_dir"; then
				printf 'PRESERVED: %s: a folder above it is a link now, so it is left as it is\n' "$_u_dir"
			elif rmdir "$_u_dir" 2>/dev/null; then
				printf 'FOLDER: removed the empty folder %s\n' "$_u_dir"
			fi
		done)
	fi
	# A record from before the folder list still gets its skills folder removed when empty.
	# One with the list says which folders the setup made, so no other is touched.
	if [ -z "$st_has_dirs" ]; then rmdir "$st_dest" 2>/dev/null; fi
	say "UNDONE: nobrainer-tech-flow for $label"
	say "SKILLS: $_u_removed unlinked from $st_dest"
	if [ -n "$_u_result" ]; then say "$_u_result"; fi
	if [ -n "$_u_gone" ]; then say "$_u_gone"; fi
	say "NEXT: restart $label to drop the skills."
}

# ---------------------------------------------------------------------------------

case $(uname -s 2>/dev/null) in
	MINGW* | MSYS* | CYGWIN*) windows=1 ;;
	*) windows=0 ;;
esac

client= opt_home= home_given=0 apply=0 undo=0
while [ $# -gt 0 ]; do
	case $1 in
		--client)
			[ $# -ge 2 ] || usage_error "argument --client: expected one argument"
			client=$2
			shift 2 ;;
		--client=*) client=${1#*=}; shift ;;
		--home)
			[ $# -ge 2 ] || usage_error "argument --home: expected one argument"
			opt_home=$2 home_given=1
			shift 2 ;;
		--home=*) opt_home=${1#*=} home_given=1; shift ;;
		--apply) apply=1; shift ;;
		--undo) undo=1; shift ;;
		-h | --help) usage; exit 0 ;;
		*) usage_error "unrecognized arguments: $1" ;;
	esac
done
case $client in
	claude | codex | opencode | copilot) ;;
	'') usage_error "the following arguments are required: --client" ;;
	*) usage_error "argument --client: invalid choice: '$client' (choose from claude, codex, opencode, copilot)" ;;
esac

script=$0
if [ "$windows" = 1 ]; then script=$(to_posix "$script"); fi
case $script in */*) here=${script%/*} ;; *) here=. ;; esac
here=$(cd "$here" 2>/dev/null && pwd -P) || die 2 "cannot find the folder this script is in"
root=${here%/*}
if [ ! -f "$root/skills/nobrainer-tech-flow/SKILL.md" ] || [ ! -f "$root/scripts/install.sh" ]; then
	die 2 "run this script from a nobrainer-tech-flow checkout" \
		"git clone https://github.com/nobrainer-tech/nobrainer-tech-flow ~/.nobrainer-tech-flow, then sh ~/.nobrainer-tech-flow/scripts/install.sh --client $client"
fi

if [ "$windows" = 1 ] && [ -n "${USERPROFILE:-}" ]; then user_home=$(to_posix "$USERPROFILE"); else user_home=${HOME:-}; fi
if [ "$home_given" = 1 ]; then
	[ -n "$opt_home" ] || usage_error "argument --home: expected a directory"
	# A ~ the shell did not expand (--home=~/x, or quoted) is the home, as in install.py.
	case $opt_home in
		'~' | '~/'*)
			case $user_home in /*) ;; *) die 2 "cannot expand ~ in --home: the home directory is not an absolute path" ;; esac
			opt_home=$user_home${opt_home#'~'} ;;
		'~'*) usage_error "argument --home: write the full path instead of $opt_home" ;;
	esac
	reject_control "$opt_home" "--home"
	_home=$(to_posix "$opt_home")
	case $_home in /*) ;; *) _home=$(pwd -P)/$_home ;; esac
	home=$(clean abs "$_home")
	locate "$client" plain
else
	_home=$user_home
	reject_control "$_home" "the home directory"
	case $_home in
		/*) home=$(clean keep "$_home") ;;
		*) die 2 "the home directory must be an absolute path, not '$_home'" ;;
	esac
	locate "$client" env
fi
skills_dir=$(clean keep "$skills_dir")
target=$(clean keep "$target")
for _path in "$skills_dir" "$target" "$root"; do reject_control "$_path" "a path"; done
state=$home/.nobrainer-flow-onboarding-$client.json

source_line="nobrainer-tech-flow $(cat "$root/skills/nobrainer-tech-flow/VERSION" 2>/dev/null || printf unknown)"
if command -v git >/dev/null 2>&1 && _commit=$(git -C "$root" rev-parse --short HEAD 2>/dev/null); then
	if [ -n "$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null)" ]; then
		source_line="$source_line (commit $_commit, local changes)"
	else
		source_line="$source_line (commit $_commit)"
	fi
fi

if [ "$undo" = 1 ]; then
	undo
else
	check_destination
	install
fi
