#!/usr/bin/env bash # Install technical-doc-flow for Claude Code, Codex, and Gemini CLI. set -euo pipefail PRODUCT="technical-doc-flow" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_SOURCE="$REPO_DIR/skills/$PRODUCT" AGENTS_SOURCE="$REPO_DIR/agents" CLAUDE_ROOT="${TECH_DOC_FLOW_CLAUDE_HOME:-${CLAUDE_HOME:-$HOME/.claude}}" CODEX_ROOT="${TECH_DOC_FLOW_CODEX_HOME:-${CODEX_HOME:-$HOME/.codex}}" PYTHON_BIN="${TECH_DOC_FLOW_PYTHON:-python3}" MODE="symlink" CLAUDE_MODE="auto" CODEX_MODE="auto" GEMINI_MODE="auto" ONLY_PLATFORM="" FORCE=0 DRY_RUN=0 BACKUP_STAMP="$(date +%Y%m%d-%H%M%S)" usage() { cat <<'EOF' Usage: ./install.sh [options] With no platform option, installed CLIs or their configuration directories are detected automatically. Claude and Codex install the exact same source directory: skills/technical-doc-flow. Options: --copy Copy files instead of creating symlinks (Claude/Codex). --claude-only Install Claude skill and root agents only. --codex-only Install Codex skill only. --gemini-only Link the Gemini extension only. --all Select all three platforms explicitly. --no-claude Skip Claude. --no-codex Skip Codex. --no-gemini Skip Gemini. --force Back up a conflicting target as .bak. first. --dry-run Print operations without changing files. -h, --help Show this help. Optional location overrides: TECH_DOC_FLOW_CLAUDE_HOME, TECH_DOC_FLOW_CODEX_HOME Gemini always uses `gemini extensions link`; --copy never changes it. EOF } die() { echo "error: $*" >&2 exit 2 } select_only() { local platform="$1" if [[ -n "$ONLY_PLATFORM" && "$ONLY_PLATFORM" != "$platform" ]]; then die "only one of --claude-only, --codex-only, --gemini-only may be used" fi ONLY_PLATFORM="$platform" CLAUDE_MODE="no" CODEX_MODE="no" GEMINI_MODE="no" case "$platform" in claude) CLAUDE_MODE="yes" ;; codex) CODEX_MODE="yes" ;; gemini) GEMINI_MODE="yes" ;; esac } while (($#)); do case "$1" in --copy) MODE="copy" ;; --claude-only) select_only claude ;; --codex-only) select_only codex ;; --gemini-only) select_only gemini ;; --all) [[ -z "$ONLY_PLATFORM" ]] || die "--all cannot be combined with --*-only" CLAUDE_MODE="yes" CODEX_MODE="yes" GEMINI_MODE="yes" ;; --no-claude) CLAUDE_MODE="no" ;; --no-codex) CODEX_MODE="no" ;; --no-gemini) GEMINI_MODE="no" ;; --force) FORCE=1 ;; --dry-run) DRY_RUN=1 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1" ;; esac shift done [[ -f "$SKILL_SOURCE/SKILL.md" ]] \ || die "canonical skill not found: $SKILL_SOURCE/SKILL.md" shopt -s nullglob AGENT_FILES=("$AGENTS_SOURCE"/*.md) OBSOLETE_CLAUDE_AGENT_NAMES=("doc-visual-planner.md") if [[ "$MODE" == "copy" && "$CLAUDE_MODE" == "no" \ && "$CODEX_MODE" == "no" && "$GEMINI_MODE" != "no" ]]; then echo "note: --copy does not apply to Gemini; using extension link" fi run() { printf '+' printf ' %q' "$@" printf '\n' if ((DRY_RUN == 0)); then "$@" fi } next_backup_path() { local destination="$1" local candidate="$destination.bak.$BACKUP_STAMP" local suffix=1 while [[ -e "$candidate" || -L "$candidate" ]]; do candidate="$destination.bak.$BACKUP_STAMP.$suffix" ((suffix += 1)) done printf '%s\n' "$candidate" } # Rename a staged or existing entry only when the destination name is still # unused. A preflight path check cannot provide that guarantee because another # process may create the destination before the rename. # # Return codes: # 0: renamed # 3: destination is occupied # 4: this platform/filesystem cannot provide atomic no-clobber rename # 5: another rename error occurred atomic_rename_noreplace() { local source="$1" destination="$2" rc=0 printf '+ atomic-mv-noreplace %q %q\n' "$source" "$destination" if ((DRY_RUN)); then return 0 fi "$PYTHON_BIN" - "$source" "$destination" <<'PY' || rc=$? import ctypes import errno import os import sys source = os.fsencode(sys.argv[1]) destination = os.fsencode(sys.argv[2]) libc = ctypes.CDLL(None, use_errno=True) if sys.platform.startswith("linux"): try: renameat2 = libc.renameat2 except AttributeError: raise SystemExit(4) renameat2.argtypes = [ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint, ] renameat2.restype = ctypes.c_int result = renameat2(-100, source, -100, destination, 1) # RENAME_NOREPLACE elif sys.platform == "darwin": try: renamex_np = libc.renamex_np except AttributeError: raise SystemExit(4) renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] renamex_np.restype = ctypes.c_int result = renamex_np(source, destination, 0x00000004) # RENAME_EXCL else: raise SystemExit(4) if result == 0: raise SystemExit(0) error_number = ctypes.get_errno() if error_number in (errno.EEXIST, errno.ENOTEMPTY): raise SystemExit(3) if error_number in { errno.EINVAL, errno.ENOSYS, getattr(errno, "ENOTSUP", errno.EINVAL), getattr(errno, "EOPNOTSUPP", errno.EINVAL), }: raise SystemExit(4) print( f"atomic no-clobber rename failed: {os.strerror(error_number)}", file=sys.stderr, ) raise SystemExit(5) PY return "$rc" } backup_destination_noreplace() { local destination="$1" backup="$2" rc if atomic_rename_noreplace "$destination" "$backup"; then return 0 else rc=$? fi case "$rc" in 3) die "backup target became occupied after preflight: $backup; $destination was not moved" ;; 4) die "atomic no-clobber backup is unsupported on this platform/filesystem; $destination was not moved" ;; *) die "could not back up $destination without replacing $backup" ;; esac } publish_copy_noreplace() { local source="$1" destination="$2" parent base stage_root stage rc parent="$(dirname "$destination")" base="$(basename "$destination")" if ((DRY_RUN)); then printf '+ cp -RL %q %q\n' "$source" "" printf '+ atomic-mv-noreplace %q %q\n' \ "" "$destination" return 0 fi stage_root="$(mktemp -d "$parent/.${base}.install.XXXXXXXX")" \ || die "could not create a staging directory beside $destination" stage="$stage_root/payload" printf '+ cp -RL %q %q\n' "$source" "$stage" if ! cp -RL -- "$source" "$stage"; then rm -rf -- "$stage_root" die "could not stage copy for $destination" fi if atomic_rename_noreplace "$stage" "$destination"; then rmdir -- "$stage_root" return 0 else rc=$? fi rm -rf -- "$stage_root" case "$rc" in 3) die "target appeared after preflight: $destination; the competing entry was preserved" ;; 4) die "atomic no-clobber copy publish is unsupported on this platform/filesystem" ;; *) die "could not publish staged copy without replacing $destination" ;; esac } publish_symlink_noreplace() { local source="$1" destination="$2" parent base stage_root stage rc parent="$(dirname "$destination")" base="$(basename "$destination")" if ((DRY_RUN)); then printf '+ ln -s -- %q %q\n' "$source" "" printf '+ atomic-mv-noreplace %q %q\n' \ "" "$destination" return 0 fi stage_root="$(mktemp -d "$parent/.${base}.install.XXXXXXXX")" \ || die "could not create a staging directory beside $destination" stage="$stage_root/payload" printf '+ ln -s -- %q %q\n' "$source" "$stage" if ! ln -s -- "$source" "$stage"; then rmdir -- "$stage_root" die "could not stage symlink for $destination" fi if atomic_rename_noreplace "$stage" "$destination"; then rmdir -- "$stage_root" return 0 else rc=$? fi rm -- "$stage" rmdir -- "$stage_root" case "$rc" in 3) die "target appeared after preflight: $destination; the competing entry was preserved" ;; 4) die "atomic no-clobber symlink publish is unsupported on this platform/filesystem" ;; *) die "could not publish staged symlink without replacing $destination" ;; esac } # Installation is planned in memory first. No mkdir, backup, symlink, or copy # may happen until every selected local target and required command passes this # preflight. PLAN_PLATFORMS=() PLAN_SOURCES=() PLAN_DESTINATIONS=() PLAN_ACTIONS=() PLAN_BACKUPS=() MIGRATION_PLAN_ACTIONS=() MIGRATION_PLAN_DESTINATIONS=() MIGRATION_PLAN_SOURCES=() MIGRATION_PLAN_MESSAGES=() preflight_parent() { local destination="$1" cursor cursor="$(dirname "$destination")" while [[ "$cursor" != "/" && ! -e "$cursor" && ! -L "$cursor" ]]; do cursor="$(dirname "$cursor")" done [[ -d "$cursor" ]] \ || die "destination parent is not a directory: $cursor (for $destination)" } plan_target() { local platform="$1" source="$2" destination="$3" local action="install" backup="" existing [[ -e "$source" || -L "$source" ]] \ || die "installation source not found: $source" preflight_parent "$destination" for existing in "${PLAN_DESTINATIONS[@]}"; do [[ "$existing" != "$destination" ]] \ || die "duplicate installation destination: $destination" done if [[ -L "$destination" && "$(readlink "$destination")" == "$source" ]]; then if [[ "$MODE" == "symlink" ]]; then action="skip" elif ((FORCE == 0)); then die "$destination is currently symlinked; use --copy --force to convert it" fi fi if [[ "$action" != "skip" && ( -e "$destination" || -L "$destination" ) ]]; then if ((FORCE == 0)); then die "target already exists: $destination (use --force to back it up)" fi backup="$(next_backup_path "$destination")" fi PLAN_PLATFORMS+=("$platform") PLAN_SOURCES+=("$source") PLAN_DESTINATIONS+=("$destination") PLAN_ACTIONS+=("$action") PLAN_BACKUPS+=("$backup") } append_migration_plan() { MIGRATION_PLAN_ACTIONS+=("$1") MIGRATION_PLAN_DESTINATIONS+=("$2") MIGRATION_PLAN_SOURCES+=("$3") MIGRATION_PLAN_MESSAGES+=("$4") } plan_obsolete_claude_agent() { local name="$1" destination source target destination="$CLAUDE_ROOT/agents/$name" source="$AGENTS_SOURCE/$name" # If a future release restores this source name, it is no longer obsolete # and the normal installation plan owns it. if [[ -e "$source" || -L "$source" ]]; then return 0 fi if [[ -L "$destination" ]]; then if ! target="$(readlink "$destination")"; then die "could not inspect obsolete Claude agent candidate: $destination" fi if [[ "$target" == "$source" ]]; then append_migration_plan remove "$destination" "$source" "" else append_migration_plan preserve "$destination" "" \ "preserve (obsolete agent path has a different symlink target): $destination -> $target" fi elif [[ -e "$destination" ]]; then append_migration_plan preserve "$destination" "" \ "preserve (obsolete agent copy or user-managed entry): $destination" fi } revalidate_migration_removal() { local destination="$1" source="$2" target if [[ ! -L "$destination" ]]; then die "obsolete managed agent changed type or disappeared: $destination; nothing at this path was removed" fi if ! target="$(readlink "$destination")"; then die "could not re-read obsolete managed agent symlink: $destination; nothing at this path was removed" fi if [[ "$target" != "$source" ]]; then die "obsolete managed agent changed target: $destination -> $target; expected $source" fi } revalidate_migration_plan() { local index for index in "${!MIGRATION_PLAN_DESTINATIONS[@]}"; do [[ "${MIGRATION_PLAN_ACTIONS[$index]}" == "remove" ]] || continue revalidate_migration_removal \ "${MIGRATION_PLAN_DESTINATIONS[$index]}" \ "${MIGRATION_PLAN_SOURCES[$index]}" done } execute_migration_plan() { local index destination source for index in "${!MIGRATION_PLAN_DESTINATIONS[@]}"; do if [[ "${MIGRATION_PLAN_ACTIONS[$index]}" == "preserve" ]]; then echo "${MIGRATION_PLAN_MESSAGES[$index]}" continue fi destination="${MIGRATION_PLAN_DESTINATIONS[$index]}" source="${MIGRATION_PLAN_SOURCES[$index]}" printf '+ rm -- %q\n' "$destination" if ((DRY_RUN == 0)); then revalidate_migration_removal "$destination" "$source" rm -- "$destination" echo "removed obsolete managed agent: $destination" fi done } install_planned_platform() { local platform="$1" index source destination action backup for index in "${!PLAN_DESTINATIONS[@]}"; do [[ "${PLAN_PLATFORMS[$index]}" == "$platform" ]] || continue source="${PLAN_SOURCES[$index]}" destination="${PLAN_DESTINATIONS[$index]}" action="${PLAN_ACTIONS[$index]}" backup="${PLAN_BACKUPS[$index]}" if [[ "$action" == "skip" ]]; then echo "ok (already linked): $destination" continue fi run mkdir -p -- "$(dirname "$destination")" if [[ -n "$backup" ]]; then backup_destination_noreplace "$destination" "$backup" fi if [[ "$MODE" == "symlink" ]]; then publish_symlink_noreplace "$source" "$destination" else publish_copy_noreplace "$source" "$destination" fi echo "installed: $destination" done } is_selected() { local mode="$1" command_name="$2" config_root="$3" case "$mode" in yes) return 0 ;; no) return 1 ;; auto) command -v "$command_name" >/dev/null 2>&1 || [[ -d "$config_root" ]] ;; esac } CLAUDE_SELECTED=0 CODEX_SELECTED=0 GEMINI_SELECTED=0 is_selected "$CLAUDE_MODE" claude "$CLAUDE_ROOT" && CLAUDE_SELECTED=1 is_selected "$CODEX_MODE" codex "$CODEX_ROOT" && CODEX_SELECTED=1 is_selected "$GEMINI_MODE" gemini "${TECH_DOC_FLOW_GEMINI_HOME:-$HOME/.gemini}" \ && GEMINI_SELECTED=1 if ((CLAUDE_SELECTED)); then ((${#AGENT_FILES[@]} > 0)) \ || die "Claude agent definitions not found: $AGENTS_SOURCE/*.md" plan_target claude "$SKILL_SOURCE" "$CLAUDE_ROOT/skills/$PRODUCT" for agent in "${AGENT_FILES[@]}"; do plan_target claude "$agent" "$CLAUDE_ROOT/agents/$(basename "$agent")" done for obsolete_name in "${OBSOLETE_CLAUDE_AGENT_NAMES[@]}"; do plan_obsolete_claude_agent "$obsolete_name" done fi if ((CODEX_SELECTED)); then plan_target codex "$SKILL_SOURCE" "$CODEX_ROOT/skills/$PRODUCT" fi if ((GEMINI_SELECTED && DRY_RUN == 0)) && ! command -v gemini >/dev/null 2>&1; then die "gemini command not found; install Gemini CLI or skip with --no-gemini" fi ATOMIC_RENAME_REQUIRED=0 for index in "${!PLAN_DESTINATIONS[@]}"; do [[ "${PLAN_ACTIONS[$index]}" == "skip" ]] && continue ATOMIC_RENAME_REQUIRED=1 break done if ((ATOMIC_RENAME_REQUIRED && DRY_RUN == 0)) \ && ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then die "$PYTHON_BIN is required for atomic no-clobber install operations" fi # Revalidate every proven obsolete symlink after all selected targets and # command prerequisites pass, but before the first installation mutation. revalidate_migration_plan # All predictable collisions and command prerequisites have passed. Only now # may the installer mutate any destination. if ((CLAUDE_SELECTED)); then echo "== Claude Code ==" execute_migration_plan install_planned_platform claude else echo "== Claude Code: skipped ==" fi if ((CODEX_SELECTED)); then echo "== Codex ==" install_planned_platform codex else echo "== Codex: skipped ==" fi if ((GEMINI_SELECTED)); then echo "== Gemini CLI ==" if ((DRY_RUN)); then printf '+ gemini extensions link %q\n' "$REPO_DIR" elif ! printf 'Y\n' | gemini extensions link "$REPO_DIR"; then die "Gemini extension link failed" else echo "installed: Gemini extension ($PRODUCT)" fi else echo "== Gemini CLI: skipped ==" fi echo "installation complete (claude_codex_mode=$MODE, gemini_mode=extension-link, dry_run=$DRY_RUN)"