#!/usr/bin/env bash #ddev-generated # Read-only sibling-repo mirrors, declared in .ddev/claude-refs.txt. # # refs-sync.sh throttled sync (skips if the last good sync is recent) # refs-sync.sh --force sync now # refs-sync.sh --setup-only credentials, permissions and MCP registration; no network # # Mirrors are throwaway: existing ones are hard-reset, never merged. Does nothing # at all when the project has no manifest. set -uo pipefail MANIFEST="${CLAUDE_REFS_MANIFEST:-${DDEV_APPROOT}/.ddev/claude-refs.txt}" PERSIST_HOME="${CLAUDE_REFS_HOME:-${DDEV_APPROOT}/.ddev/claude-code/home}" REFS="${PERSIST_HOME}/refs" STAMP="${REFS}/.last-sync" MAX_AGE="${CLAUDE_REFS_MAX_AGE:-21600}" # seconds; 6h NET_TIMEOUT="${CLAUDE_REFS_TIMEOUT:-120}" # seconds per clone/fetch SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" [ -f "${MANIFEST}" ] || exit 0 MODE=sync case "${1:-}" in '') ;; --force) MODE=force ;; --setup-only) MODE=setup ;; *) echo "usage: $(basename "$0") [--force|--setup-only]" >&2; exit 2 ;; esac export HOME="${PERSIST_HOME}" export GIT_TERMINAL_PROMPT=0 # --- manifest --------------------------------------------------------------- host=""; user=""; token="~/.gitea-ro-token"; depth=50; mcp_name=""; mcp_url="" names=(); repos=(); descs=() while IFS= read -r line || [ -n "${line}" ]; do line="${line%$'\r'}" case "${line}" in ''|'#'*) continue ;; esac read -r key val rest <<<"${line}" case "${key}" in @host) host="${val}" ;; @user) user="${val}" ;; @token) token="${val}" ;; @depth) depth="${val}" ;; @mcp) mcp_name="${val}"; mcp_url="${rest}" ;; @*) echo "claude-refs: unknown setting ${key}, ignored" >&2 ;; *) names+=("${key}"); repos+=("${val}"); descs+=("${rest}") ;; esac done <"${MANIFEST}" token="${token/#\~/${HOME}}" if [ -z "${host}" ]; then echo "claude-refs: no @host in ${MANIFEST}" >&2 exit 1 fi # --- credentials (every run, so a renamed bot account heals itself) -------- # Host-scoped on purpose: a global helper would hand the project's own HTTPS # pushes the bot's pull-only credentials. creds="${HOME}/.git-credentials-${host}" if [ -n "${user}" ] && [ -r "${token}" ]; then (umask 077; printf 'https://%s:%s@%s\n' "${user}" "$(tr -d '[:space:]' <"${token}")" "${host}" >"${creds}") git config --global "credential.https://${host}.helper" "store --file=${creds}" git config --global "credential.https://${host}.username" "${user}" elif [ -n "${user}" ]; then echo "claude-refs: token ${token} not readable; relying on existing git credentials" >&2 fi # --- setup: permissions and MCP (no network) -------------------------------- if [ "${MODE}" = setup ]; then mkdir -p "${REFS}" "${HOME}/.claude" if ! command -v jq >/dev/null 2>&1; then echo "claude-refs: jq missing, skipping permission and MCP seeding" >&2 exit 0 fi settings="${HOME}/.claude/settings.json" [ -s "${settings}" ] || echo '{}' >"${settings}" want=("Bash(git -C ${REFS}/*)" "Bash(${SELF})" "Bash(${SELF} --force)") [ -n "${mcp_name}" ] && want+=("mcp__${mcp_name}") tmp="$(mktemp)" # With --args every trailing word is an argument, so the file comes in on stdin. if jq ' .permissions.allow = reduce $ARGS.positional[] as $e (.permissions.allow // []; if any(.[]; . == $e) then . else . + [$e] end) ' --args "${want[@]}" <"${settings}" >"${tmp}"; then cat "${tmp}" >"${settings}" fi rm -f "${tmp}" # Checked against ~/.claude.json rather than "claude mcp get", which connects. if [ -n "${mcp_name}" ] && [ -n "${mcp_url}" ] && [ -r "${token}" ] \ && ! jq -e --arg n "${mcp_name}" '.mcpServers[$n]' "${HOME}/.claude.json" >/dev/null 2>&1; then claude mcp add --transport http --scope user "${mcp_name}" "${mcp_url}" \ --header "Authorization: Bearer $(tr -d '[:space:]' <"${token}")" fi exit 0 fi # --- sync ------------------------------------------------------------------- mkdir -p "${REFS}" exec 9>"${REFS}/.sync.lock" if ! flock -n 9; then echo "claude-refs: another sync is running" exit 0 fi if [ "${MODE}" = sync ] && [ -f "${STAMP}" ] \ && [ $(( $(date +%s) - $(stat -c %Y "${STAMP}") )) -lt "${MAX_AGE}" ]; then exit 0 fi # Detached runs (ddev start, ddev claude) keep only the latest real sync's output. [ -n "${CLAUDE_REFS_DETACHED:-}" ] && exec >"${REFS}/.sync.log" 2>&1 date '+claude-refs sync %F %T' if ! curl -sS -o /dev/null --max-time 10 "https://${host}/" 2>/dev/null; then echo "claude-refs: ${host} unreachable (off-LAN?), mirrors left as they are" exit 0 fi # git@h:o/r.git, https://u@h/o/r.git -> h/o/r (lowercase), to spot the project itself normalize() { local u="$1" u="${u#*://}"; u="${u#*@}"; u="${u/://}"; u="${u%/}"; u="${u%.git}" printf '%s' "${u,,}" } self="$(normalize "$(git -C "${DDEV_APPROOT}" remote get-url origin 2>/dev/null)")" declare -A listed=() rows=() failed=0 for i in "${!names[@]}"; do name="${names[$i]}"; repo="${repos[$i]}"; dir="${REFS}/${name}" url="https://${host}/${repo}.git" if [ -n "${self}" ] && [ "$(normalize "${url}")" = "${self}" ]; then printf '== %-16s skipped: this is the project itself, read it live\n' "${name}" continue fi listed[${name}]=1 rows+=("| \`${name}/\` | \`${repo}\` | ${descs[$i]//|/\\|} |") printf '== %-16s ' "${name}" if [ -d "${dir}/.git" ]; then if ! timeout "${NET_TIMEOUT}" git -C "${dir}" fetch --depth "${depth}" --quiet origin \ || ! git -C "${dir}" reset --hard --quiet '@{u}'; then echo "FAILED (fetch)"; failed=1; continue fi elif [ -e "${dir}" ]; then echo "FAILED (exists but is not a git checkout)"; failed=1; continue elif ! timeout "${NET_TIMEOUT}" git clone --depth "${depth}" --single-branch --quiet "${url}" "${dir}"; then rm -rf "${dir}" # did not exist before this run echo "FAILED (clone)"; failed=1; continue fi git -C "${dir}" log -1 --format='%ad %s' --date=short done for dir in "${REFS}"/*/; do [ -d "${dir}" ] || continue name="$(basename "${dir}")" [ -n "${listed[${name}]:-}" ] || echo "!! ${name}/ is not in the manifest; left in place, remove it by hand" done { cat < Read-only shallow clones of sibling projects on \`${host}\`, kept here so cross-project questions ("how did we solve X over there?") can be answered by grepping real code instead of guessing or re-deriving it. **These are reference material, not working copies.** Never commit, never push, never edit. The account behind them (\`${user:-unknown}\`) should have pull access only, but treat them as read-only regardless. To change one of these projects, open its real checkout. Clones are \`--depth ${depth} --single-branch\` off the default branch. They sync themselves in the background on \`ddev start\` and \`ddev claude\`, at most every $(( MAX_AGE / 3600 ))h. Run \`${SELF} --force\` before trusting anything time-sensitive. Syncing hard-resets, so local changes are lost. The last run's output is in \`.sync.log\`. | Directory | Repo | What it is | |---|---|---| EOF printf '%s\n' "${rows[@]}" } >"${REFS}/README.md" if [ "${failed}" -eq 0 ]; then touch "${STAMP}" fi exit "${failed}"