#!/usr/bin/env bash set -euo pipefail # Claude Project Knowledge sync tool. # Keeps .claude-sync/ aligned with documents listed in .claude-sync.conf # so the bundle can be uploaded to Claude Chat Project Knowledge. err() { printf '%s\n' "$*" >&2; } REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [ -z "${REPO_ROOT}" ]; then err "❌ Not inside a git repository." exit 1 fi cd "${REPO_ROOT}" CONF_FILE=".claude-sync.conf" OUT_DIR=".claude-sync" MANIFEST="${OUT_DIR}/SYNC_MANIFEST.md" sha256() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else err "❌ Neither shasum nor sha256sum is available." exit 1 fi } iso_now() { # ISO-8601 with timezone offset; portable across macOS (BSD) and Linux (GNU). date +"%Y-%m-%dT%H:%M:%S%z" } file_mtime() { # Returns "YYYY-MM-DD HH:MM:SS" for the given file. local f="$1" if stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$f" >/dev/null 2>&1; then stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$f" else stat -c "%y" "$f" | cut -d'.' -f1 fi } read_conf_paths() { # Emits configured paths, skipping blank lines and # comments; trims whitespace. if [ ! -f "${CONF_FILE}" ]; then err "❌ Config file not found: ${CONF_FILE}" exit 1 fi awk ' { sub(/#.*/, "") gsub(/^[[:space:]]+|[[:space:]]+$/, "") if (length($0) > 0) print } ' "${CONF_FILE}" } cmd_sync() { local paths=() while IFS= read -r line; do paths+=("${line}") done < <(read_conf_paths) if [ ${#paths[@]} -eq 0 ]; then err "❌ No paths configured in ${CONF_FILE}." exit 1 fi # Verify every listed file exists. local missing=() local p for p in "${paths[@]}"; do if [ ! -f "${p}" ]; then missing+=("${p}") fi done if [ ${#missing[@]} -gt 0 ]; then err "❌ Missing source files listed in ${CONF_FILE}:" for p in "${missing[@]}"; do err " ${p}" done exit 1 fi # Detect filename collisions (e.g. two README.md under different folders). local seen_basenames="" for p in "${paths[@]}"; do local bn bn="$(basename "${p}")" # Find any prior path with the same basename. local other="" local q for q in "${paths[@]}"; do if [ "${q}" = "${p}" ]; then continue; fi if [ "$(basename "${q}")" = "${bn}" ]; then other="${q}" break fi done if [ -n "${other}" ]; then case "${seen_basenames}" in *"|${bn}|"*) ;; *) err "❌ Filename collision in ${CONF_FILE}: basename '${bn}' used by:" err " ${p}" err " ${other}" exit 1 ;; esac seen_basenames="${seen_basenames}|${bn}|" fi done rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" local git_full_sha git_short_sha branch synced_at git_full_sha="$(git rev-parse HEAD)" git_short_sha="$(git rev-parse --short HEAD)" branch="$(git rev-parse --abbrev-ref HEAD)" synced_at="$(iso_now)" # Build manifest rows as we go. local manifest_rows="" local count=0 for p in "${paths[@]}"; do local bn hash mtime dest bn="$(basename "${p}")" hash="$(sha256 "${p}")" mtime="$(file_mtime "${p}")" dest="${OUT_DIR}/${bn}" { printf '%s\n' "" printf '\n' } > "${dest}" cat "${p}" >> "${dest}" manifest_rows="${manifest_rows}| ${bn} | ${p} | ${hash} | ${mtime} |"$'\n' count=$((count + 1)) done { printf '%s\n' "# Crewli Project Knowledge Sync Manifest" printf '\n' printf '**Generated:** %s\n' "${synced_at}" printf '**Git SHA:** %s (short: %s)\n' "${git_full_sha}" "${git_short_sha}" printf '**Branch:** %s\n' "${branch}" printf '**Files synced:** %d\n' "${count}" printf '\n' printf '%s\n' "## Upload instructions" printf '\n' printf '%s\n' "Upload every file in \`.claude-sync/\` — including this manifest — to the" printf '%s\n' "Crewli Claude Project Knowledge, replacing existing versions. The manifest" printf '%s\n' "is what Claude Chat uses for drift detection against the current HEAD." printf '\n' printf '%s\n' "## Drift-detection rule" printf '\n' printf '%s\n' "Re-sync is required when:" printf '%s\n' "1. Current git HEAD SHA differs from this manifest's git SHA, AND" printf '%s\n' "2. Any listed file's current SHA256 differs from the hash recorded below." printf '\n' printf '%s\n' "## File manifest" printf '\n' printf '%s\n' "| File | Source path | SHA256 | Source mtime |" printf '%s\n' "|------|-------------|--------|--------------|" printf '%s' "${manifest_rows}" printf '\n' printf '%s\n' "## Workflow note for Claude Chat" printf '\n' printf '%s\n' "When the user reports work that touched any listed file, verify the" printf '%s\n' "manifest SHA above matches the latest HEAD they reference. If not," printf '%s\n' "require a re-sync before generating new prompts." } > "${MANIFEST}" err "✅ Claude sync complete — ${count} files ready in ${OUT_DIR}/" err "📤 Upload to Claude Project Knowledge (replace existing):" for p in "${paths[@]}"; do err " ${OUT_DIR}/$(basename "${p}")" done err " ${MANIFEST}" } cmd_check() { if [ ! -f "${MANIFEST}" ]; then err "❌ No sync manifest found. Run: bash scripts/sync-claude-docs.sh sync" exit 1 fi local paths=() while IFS= read -r line; do paths+=("${line}") done < <(read_conf_paths) local drift=() local missing=() local p for p in "${paths[@]}"; do if [ ! -f "${p}" ]; then missing+=("${p}") continue fi # Extract recorded SHA from manifest row whose "Source path" column matches p. local recorded recorded="$(awk -F' *\\| *' -v src="${p}" ' $0 ~ /^\|/ && $3 == src { print $4; exit } ' "${MANIFEST}")" if [ -z "${recorded}" ]; then drift+=("${p} (not recorded in manifest)") continue fi local current current="$(sha256 "${p}")" if [ "${current}" != "${recorded}" ]; then drift+=("${p} (source changed since last sync)") fi done if [ ${#missing[@]} -gt 0 ]; then err "⚠️ Out of sync:" for p in "${missing[@]}"; do err " ${p} (source file missing)" done fi if [ ${#drift[@]} -gt 0 ]; then if [ ${#missing[@]} -eq 0 ]; then err "⚠️ Out of sync:" fi for p in "${drift[@]}"; do err " ${p}" done err "Run: bash scripts/sync-claude-docs.sh sync" exit 1 fi if [ ${#missing[@]} -gt 0 ]; then err "Run: bash scripts/sync-claude-docs.sh sync" exit 1 fi printf '%s\n' "✅ .claude-sync/ matches current dev-docs" } cmd_list() { local paths=() while IFS= read -r line; do paths+=("${line}") done < <(read_conf_paths) local p marker for p in "${paths[@]}"; do if [ -f "${p}" ]; then marker="✓" else marker="✗" fi printf ' %s %s\n' "${marker}" "${p}" done } cmd_help() { cat <<'EOF' sync-claude-docs.sh — Crewli → Claude Project Knowledge sync Usage: bash scripts/sync-claude-docs.sh [subcommand] Subcommands: sync (default) Rebuild .claude-sync/ from .claude-sync.conf and write SYNC_MANIFEST.md check Verify .claude-sync/ matches current source files; non-zero exit on drift list Print configured source paths with existence markers (✓ / ✗) help Show this help text Examples: bash scripts/sync-claude-docs.sh bash scripts/sync-claude-docs.sh sync bash scripts/sync-claude-docs.sh check bash scripts/sync-claude-docs.sh list Config: .claude-sync.conf (one path per line; # comments and blank lines OK) Output: .claude-sync/ (git-ignored; regenerated on each sync) EOF } sub="${1:-sync}" case "${sub}" in sync) cmd_sync ;; check) cmd_check ;; list) cmd_list ;; help|-h|--help) cmd_help ;; *) err "❌ Unknown subcommand: ${sub}" err "" cmd_help >&2 exit 1 ;; esac