Files
crewli/scripts/sync-claude-docs.sh
bert.hausmans 79189a64e5 chore: add Claude Project Knowledge sync tooling
- scripts/sync-claude-docs.sh with sync/check/list/help subcommands
- scripts/install-claude-sync-hooks.sh for one-time hook setup
- .githooks/post-commit auto-syncs on dev-doc changes
- .githooks/pre-push warns (non-blocking) on stale sync
- .claude-sync.conf lists 11 synced documents
- SYNC_MANIFEST.md provides drift-detection anchor for Claude Chat
- package.json: npm run sync:docs | sync:check
- .gitignore excludes .claude-sync/ output directory

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:38:26 +02:00

309 lines
8.1 KiB
Bash
Executable File

#!/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' "<!-- CREWLI-SYNC"
printf 'source: %s\n' "${p}"
printf 'sha256: %s\n' "${hash}"
printf 'git-sha: %s\n' "${git_short_sha}"
printf 'synced-at: %s\n' "${synced_at}"
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