A manifest declares repo path -> target, and the script walks it. Symlinks, not copies, so an installed file edits the repo and one commit reaches every other machine. Idempotent — a correct link is a no-op, safe after every pull. A real file where a link should go is moved to .bak-<timestamp> and reported, because on a new machine that may be the only copy of something written by hand. Conditional targets are what make it portable: officer.dev/CLAUDE.md and the officer-chat-events hook both name ~/officer-dev/platform, so a laptop with no Officer install gets neither instead of getting a document that lies. mask-secret is a command now rather than a path only true on this machine, and user/CLAUDE.md says so. It is a generated wrapper, not a symlink, and that is forced: mask-secret.py resolves its own path to find mask-hooks.d, so a symlink would reach back into the repo and run every machine's hooks. The wrapper sets MASK_HOOKS_DIR to the ones this machine installed and still runs the script from the repo, so no code is copied. Verified against a scratch HOME: re-run is all ok, the hand-written file survives as .bak, and a machine without Officer skips both conditional lines.
100 lines
3.2 KiB
Bash
Executable File
100 lines
3.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Put every file in this repo where the tooling expects it, on any machine.
|
|
#
|
|
# git clone git@gitea.officer.dev:pastilhas/claude-mds.git ~/projects/claude-mds
|
|
# ~/projects/claude-mds/install.sh
|
|
#
|
|
# Symlinks, never copies. Two copies drift, and the point of the repo is that an edit lands in git.
|
|
# Safe to re-run after every pull: a correct link is a no-op. A real file in the way is backed up,
|
|
# never overwritten in silence.
|
|
#
|
|
# --dry-run say what would happen, change nothing
|
|
#
|
|
set -euo pipefail
|
|
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
MANIFEST="$REPO/manifest"
|
|
CLAUDE_SCRIPTS="${CLAUDE_SCRIPTS:-$HOME/.local/claude-scripts}"
|
|
HOOKS_DIR="$CLAUDE_SCRIPTS/mask-hooks.d"
|
|
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
|
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
|
|
|
DRY=0
|
|
[[ ${1:-} == --dry-run ]] && DRY=1
|
|
|
|
say() { printf ' %-7s %s\n' "$1" "$2"; }
|
|
run() { (( DRY )) || "$@"; }
|
|
|
|
# Symlink $2 -> $1, refusing to destroy anything that is not already our link.
|
|
link() {
|
|
local src=$1 dst=$2
|
|
if [[ ! -e $src ]]; then say "MISSING" "$src — not in the repo"; return 1; fi
|
|
if [[ -L $dst && "$(readlink -f "$dst")" == "$(readlink -f "$src")" ]]; then
|
|
say "ok" "$dst"; return 0
|
|
fi
|
|
# A real file, or a link somewhere else. It may be the only copy of something hand-written here.
|
|
if [[ -e $dst || -L $dst ]]; then
|
|
say "backup" "$dst -> $dst.bak-$STAMP"
|
|
run mv "$dst" "$dst.bak-$STAMP"
|
|
fi
|
|
run mkdir -p "$(dirname "$dst")"
|
|
run ln -sfn "$src" "$dst"
|
|
say "link" "$dst"
|
|
}
|
|
|
|
# `mask-secret` as a COMMAND, not a path — so user/CLAUDE.md can name it and be true everywhere.
|
|
#
|
|
# A wrapper rather than a symlink, and that is forced: mask-secret.py finds its hooks at
|
|
# `Path(__file__).resolve().parent/mask-hooks.d`, and .resolve() follows a symlink back into the repo
|
|
# — which holds EVERY machine's hooks. The wrapper points MASK_HOOKS_DIR at the ones this machine
|
|
# actually installed, and still executes the script from the repo, so the code is never copied.
|
|
install_command() {
|
|
local script="$REPO/tools/mask-secret.py"
|
|
[[ -e $script ]] || { say "MISSING" "$script"; return 1; }
|
|
run mkdir -p "$BIN_DIR" "$HOOKS_DIR"
|
|
if (( ! DRY )); then
|
|
cat > "$BIN_DIR/mask-secret" <<WRAPPER
|
|
#!/bin/sh
|
|
# Generated by claude-mds/install.sh — edit install.sh, not this.
|
|
export MASK_HOOKS_DIR="$HOOKS_DIR"
|
|
exec "$script" "\$@"
|
|
WRAPPER
|
|
chmod +x "$BIN_DIR/mask-secret"
|
|
fi
|
|
say "cmd" "$BIN_DIR/mask-secret"
|
|
}
|
|
|
|
main() {
|
|
(( DRY )) && echo "dry run — nothing will change"
|
|
echo "repo: $REPO"
|
|
echo
|
|
|
|
local kind src dst cond
|
|
while read -r kind src dst cond || [[ -n ${kind:-} ]]; do
|
|
[[ -z ${kind:-} || $kind == \#* ]] && continue
|
|
|
|
# A `hook` line has no target column: its destination is derived, so the condition shifts left.
|
|
if [[ $kind == hook ]]; then cond=$dst; dst="$HOOKS_DIR/$(basename "$src")"; fi
|
|
|
|
dst=${dst/#\~/$HOME}
|
|
cond=${cond:-}; cond=${cond/#\~/$HOME}
|
|
|
|
if [[ -n $cond && ! -e $cond ]]; then
|
|
say "skip" "$dst — no $cond on this machine"
|
|
continue
|
|
fi
|
|
link "$REPO/$src" "$dst" || true
|
|
done < "$MANIFEST"
|
|
|
|
echo
|
|
install_command
|
|
|
|
case ":$PATH:" in
|
|
*":$BIN_DIR:"*) ;;
|
|
*) echo; echo " note $BIN_DIR is not on PATH — add it, or mask-secret is not a command yet" ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|