554 lines
16 KiB
Bash
Executable File
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)"
|