Files
document-haness/install.sh
T

554 lines
16 KiB
Bash
Executable File

#!/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.<timestamp> 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" "<staged beside $destination>"
printf '+ atomic-mv-noreplace %q %q\n' \
"<staged beside $destination>" "$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" "<staged beside $destination>"
printf '+ atomic-mv-noreplace %q %q\n' \
"<staged beside $destination>" "$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)"