- 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>
309 lines
8.1 KiB
Bash
Executable File
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
|