#!/usr/bin/env bash # Personal content: the things that should be identical on every machine one # person owns. # # Panama is meant to be installable by anybody, and it is also somebody's # actual dotfiles. Those two goals only conflict if the personal half is mixed # into the shared half, so it lives in one directory with one manifest, and # this stage links it -- but only on a machine that said yes. # # A stranger who clones Panama gets user/ in their checkout and nothing linked # from it. Their own content replaces it, or they delete it; either way their # agent instructions are their own and their skills are their own. That is what # makes tracking somebody's personal files in a public repository defensible. # # The answer comes from the interview as PANAMA_USER_CONTENT. Running this # stage by hand outside an install honours the recorded answer instead, so # `panama upgrade` on an already-configured machine does not need re-asking. set -euo pipefail log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" USER_DIR="$PANAMA_PATH/user" MANIFEST="$USER_DIR/manifest" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama" DECISION="$STATE_DIR/user-content" PANAMA_OLD="$PANAMA_PATH/config/old" [[ -r "$MANIFEST" ]] || { log "No personal content manifest; nothing to link."; exit 0; } # The interview's answer wins when there is one, and is remembered so a later # run without it behaves the same way. A machine that has never been asked and # is not being asked now links nothing, which is the safe direction: the cost # of guessing yes is somebody else's agent instructions on your machine. mkdir -p "$STATE_DIR" if [[ -n "${PANAMA_USER_CONTENT:-}" ]]; then printf '%s\n' "$PANAMA_USER_CONTENT" >"$DECISION" fi decision="$( [[ -r "$DECISION" ]] && cat "$DECISION" || printf 'no' )" if [[ "$decision" != "yes" ]]; then log "Personal content is not enabled on this machine; nothing linked." log "Enable it by re-running ./install and answering yes, or: echo yes > $DECISION" exit 0 fi mkdir -p "$PANAMA_OLD" # Moves whatever is already at a destination out of the way, once. A real file # somebody has is never deleted: it goes to config/old/ under a name that says # where it came from, which is the same promise link-dotfiles makes. displace() { local destination="$1" backup if [[ -L "$destination" ]]; then rm -f "$destination" return 0 fi [[ -e "$destination" ]] || return 0 backup="$PANAMA_OLD/user-$(printf '%s' "${destination#"$HOME"/}" | tr '/' '-')" if [[ -e "$backup" ]]; then backup="$backup.$(date +%s)" fi mv "$destination" "$backup" log "Moved existing $destination to $backup" } linked=0 copied=0 while read -r kind source destination; do [[ -n "${kind:-}" ]] || continue [[ "$kind" == \#* ]] && continue src="$USER_DIR/$source" dst="${destination/#\~/$HOME}" if [[ ! -e "$src" ]]; then log "Skipping $source: it is not in user/" continue fi parent="$(dirname "$dst")" mkdir -p "$parent" # ssh refuses to read a config out of a directory anyone else can write, # and the default umask here would have created one. [[ "$parent" == "$HOME/.ssh" ]] && chmod 700 "$parent" case "$kind" in link) displace "$dst" ln -s "$src" "$dst" log "Linked $source → $dst" linked=$(( linked + 1 )) ;; linkdir) # A destination that has to hold more than this one source. The # only one today is ~/.claude/skills, which now carries Panama's # own shipped skills as well as these -- so the directory itself # cannot be a symlink, and each child is linked into it instead. # # Whatever link-skills put there is kept; only same-named entries # are displaced, which is how a personal skill deliberately # shadows a shipped one. This stage runs last for that reason. if [[ -L "$dst" ]]; then rm -f "$dst" elif [[ -e "$dst" && ! -d "$dst" ]]; then displace "$dst" fi mkdir -p "$dst" for child in "$src"/*; do [[ -e "$child" ]] || continue child_name="$(basename "$child")" displace "$dst/$child_name" ln -s "$child" "$dst/$child_name" log "Linked $source/$child_name → $dst/$child_name" linked=$(( linked + 1 )) done ;; copy) if [[ -e "$dst" ]]; then log "Keeping existing $dst" else cp -r "$src" "$dst" log "Copied $source → $dst" copied=$(( copied + 1 )) fi ;; *) log "Skipping unknown manifest kind: $kind" ;; esac done < <(grep -vE '^\s*(#|$)' "$MANIFEST") log "Personal content: $linked linked, $copied copied."