Skip to content

Commit 80da46a

Browse files
JetoPistolaclaude
andcommitted
[OPIK-6998] [INFRA] feat: keep agent configs in sync across main and worktrees
Add `make agent-configs` plus git post-merge/post-checkout hooks so opted-in agent-config surfaces (Claude/Cursor/Codex) re-sync from .agents/ after pulls and branch switches, and worktrees share the main checkout's config via symlinks instead of starting empty. - make agent-configs: refresh only surfaces the user opted into (detected by .claude/ / .cursor / .codex presence); relink in worktrees, regenerate in main - post-merge: re-sync in the main checkout after pull/merge - post-checkout: re-sync on branch switch (main) or relink (worktree), guarded on git's branch-checkout flag - sync-worktree-claude.sh: symlink .claude/{skills,commands,agents} to main; .claude/rules stays branch-local - convert-mcp.sh: never override an existing .mcp.json (generate only on first setup); entity-aware MCP reconciliation deferred to a follow-up - clean-agents backs up .mcp.json before removal; ignore token backup/temp files Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8428b81 commit 80da46a

6 files changed

Lines changed: 271 additions & 4 deletions

File tree

.hooks/post-checkout

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/sh
2+
# Runs after `git checkout`, `git switch`, and `git worktree add`.
3+
#
4+
# git passes: $1 = previous HEAD, $2 = new HEAD, $3 = flag (1 = branch checkout,
5+
# 0 = file checkout). Only act on branch checkouts so `git checkout -- <file>`
6+
# does not trigger a re-sync.
7+
#
8+
# Delegates to `make agent-configs`, which:
9+
# - in the MAIN checkout, re-generates every opted-in surface (Claude/Cursor/
10+
# Codex) for the branch just checked out -- so e.g. checking out main gives
11+
# main's skills immediately instead of a feature branch's stale ones;
12+
# - in a WORKTREE, relinks the shared config symlinks to the main checkout.
13+
set -e
14+
15+
# $3 is empty when invoked outside git's normal hook path; treat that as "act".
16+
flag="${3:-1}"
17+
[ "$flag" = "1" ] || exit 0
18+
19+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
20+
cd "$REPO_ROOT" || exit 0
21+
22+
if command -v make >/dev/null 2>&1; then
23+
make agent-configs || true
24+
fi

.hooks/post-merge

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/sh
2+
# Runs after `git merge` and `git pull` (pull = fetch + merge).
3+
#
4+
# Re-syncs the agent-config surfaces the user has opted into (Claude/Cursor/
5+
# Codex) from .agents/, so they never go stale after pulling teammates' changes.
6+
# Delegates to `make agent-configs`, which is the single source of truth for
7+
# main-vs-worktree detection: in the main checkout it regenerates only the
8+
# surfaces that already exist (a no-op for users who set none up); in a worktree
9+
# it relinks the shared config. `|| true` so a pull is never blocked.
10+
#
11+
# NOTE: this intentionally does NOT sync .mcp.json -- an existing .mcp.json is
12+
# never overridden (see scripts/convert-mcp.sh). MCP reconciliation is a
13+
# follow-up.
14+
set -e
15+
16+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
17+
cd "$REPO_ROOT" || exit 0
18+
19+
if command -v make >/dev/null 2>&1; then
20+
make agent-configs || true
21+
fi

Makefile

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
.PHONY: cursor codex claude clean-agents help hooks hooks-remove precommit precommit-all
1+
.PHONY: cursor codex claude agent-configs clean-agents help hooks hooks-remove agent-hooks agent-hooks-remove precommit precommit-all
22

33
AI_DIR := .agents
44
CURSOR_DIR := .cursor
55
CLAUDE_DIR := .claude
6+
HOOKS_SRC := .hooks
7+
# Anchor hook paths to this Makefile's directory so `make agent-hooks` resolves
8+
# to this repo regardless of where make was invoked from (subdir, -f from another
9+
# repo, etc.), and works correctly inside a worktree.
10+
MAKEFILE_DIR := $(patsubst %/,%,$(dir $(realpath $(firstword $(MAKEFILE_LIST)))))
11+
GIT_COMMON_DIR := $(shell cd "$(MAKEFILE_DIR)" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null)
12+
HOOKS_DEST := $(if $(GIT_COMMON_DIR),$(GIT_COMMON_DIR)/hooks)
613

714
define link_agent_config
815
@if [ ! -d "$(AI_DIR)" ]; then \
@@ -39,13 +46,16 @@ help:
3946
@echo ""
4047
@echo " make cursor - Ensure .cursor symlink points to .agents/"
4148
@echo " make codex - Ensure .codex symlink + generate Codex AGENTS.override.md from .agents/rules/*.mdc"
42-
@echo " make claude - Sync .agents/ to .claude/ + generate .mcp.json (preserves local files)"
49+
@echo " make claude - Sync .agents/ to .claude/ (generates .mcp.json only on first setup)"
50+
@echo " make agent-configs - Refresh every opted-in surface (Claude/Cursor/Codex); relinks in worktrees"
4351
@echo " make clean-agents - Remove synced files from .claude/ and local Codex artifacts"
4452
@echo ""
4553
@echo "Git Hooks (pre-commit framework — install once per clone)"
4654
@echo ""
4755
@echo " make hooks - Install the pre-commit framework hook"
4856
@echo " make hooks-remove - Uninstall the pre-commit framework hook"
57+
@echo " make agent-hooks - Install local post-merge/post-checkout hooks that keep agent configs in sync"
58+
@echo " make agent-hooks-remove - Remove the local agent-config hooks"
4959
@echo ""
5060
@echo "Lint Checks (root .pre-commit-config.yaml is the single source of truth)"
5161
@echo " make precommit - Run hooks on changed files (vs origin/main)"
@@ -113,6 +123,14 @@ claude:
113123
fi
114124
@echo "Claude ready! Files in $(CLAUDE_DIR)/ and .mcp.json"
115125

126+
# Refresh every agent-config surface the user has already opted into (Claude,
127+
# Cursor, Codex) from .agents/. Detects opt-in by folder presence, so it never
128+
# creates a surface the user has not set up. In a worktree it relinks the shared
129+
# config from the main checkout instead of regenerating. This is the target the
130+
# git hooks call.
131+
agent-configs:
132+
@./scripts/sync-agent-configs.sh
133+
116134
# Clean generated files (preserves .agents/ and local customizations in .claude/)
117135
# Only deletes files that have a corresponding source in .agents/
118136
clean-agents:
@@ -128,7 +146,9 @@ clean-agents:
128146
$(call clean_synced_files,agents,-name "*.md",:;,agents)
129147
@# Clean up empty directories
130148
@find $(CLAUDE_DIR) -type d -empty -delete 2>/dev/null || true
131-
@[ -f ".mcp.json" ] && rm -f .mcp.json && echo "Removed .mcp.json" || true
149+
@# .mcp.json is intentionally NOT removed: it may hold personal tokens that
150+
@# exist nowhere else, and clean-agents must never destroy them.
151+
@[ -f ".mcp.json" ] && echo "Left .mcp.json in place (may contain personal tokens)." || true
132152
@echo "Done! Local customizations preserved."
133153

134154
# Install the pre-commit framework hook (writes .git/hooks/pre-commit).
@@ -155,6 +175,7 @@ hooks:
155175
@# Clear any legacy hook a prior non-force install left behind.
156176
@rm -f "$$(git rev-parse --git-path hooks)/pre-commit.legacy"
157177
@echo "pre-commit hook installed."
178+
@echo "Tip: if you use Claude/Cursor/Codex, run 'make agent-hooks' to keep their configs in sync on pull/checkout."
158179

159180
# Uninstall the pre-commit framework hook.
160181
hooks-remove:
@@ -165,6 +186,50 @@ hooks-remove:
165186
@pre-commit uninstall
166187
@echo "pre-commit hook removed."
167188

189+
# Install the LOCAL agent-config hooks (post-merge, post-checkout). These are
190+
# developer ergonomics only -- they keep .claude/.cursor/.codex in sync with
191+
# .agents/ after pulls and branch switches -- and are unrelated to lint/CI. They
192+
# are deliberately separate from `make hooks` (the pre-commit framework, which
193+
# owns .git/hooks/pre-commit and also runs in CI): agent-config sync must never
194+
# run in CI. Only post-merge/post-checkout are written, so the pre-commit
195+
# framework's hook is never touched.
196+
agent-hooks:
197+
@if [ -z "$(HOOKS_DEST)" ]; then \
198+
echo "Error: $(MAKEFILE_DIR) is not in a git repository."; \
199+
exit 1; \
200+
fi
201+
@# Git ignores .git/hooks entirely when core.hooksPath is set, so hooks copied
202+
@# there would silently never fire. Detect and explain rather than pretend to
203+
@# install (mirrors the guard in the `hooks` target).
204+
@hp=$$(git config --get core.hooksPath || true); \
205+
if [ -n "$$hp" ]; then \
206+
echo "Error: core.hooksPath is set to '$$hp'; hooks in .git/hooks would be ignored."; \
207+
echo " Clear it, then re-run 'make agent-hooks':"; \
208+
echo " git config --unset core.hooksPath"; \
209+
exit 1; \
210+
fi
211+
@cd "$(MAKEFILE_DIR)" && \
212+
if [ ! -d "$(HOOKS_SRC)" ]; then echo "Error: $(MAKEFILE_DIR)/$(HOOKS_SRC)/ does not exist."; exit 1; fi && \
213+
if [ ! -d "$(HOOKS_DEST)" ]; then echo "Error: $(HOOKS_DEST)/ does not exist."; exit 1; fi && \
214+
for h in post-checkout post-merge; do \
215+
cp "$(HOOKS_SRC)/$$h" "$(HOOKS_DEST)/$$h" && \
216+
chmod +x "$(HOOKS_DEST)/$$h" && \
217+
echo "$$h hook installed (make agent-configs)."; \
218+
done
219+
220+
# Remove the local agent-config hooks (leaves the pre-commit framework alone).
221+
agent-hooks-remove:
222+
@if [ -z "$(HOOKS_DEST)" ]; then \
223+
echo "Error: $(MAKEFILE_DIR) is not in a git repository."; \
224+
exit 1; \
225+
fi
226+
@cd "$(MAKEFILE_DIR)" && \
227+
for h in post-checkout post-merge; do \
228+
if [ -f "$(HOOKS_DEST)/$$h" ]; then \
229+
rm -f "$(HOOKS_DEST)/$$h" && echo "$$h hook removed."; \
230+
fi; \
231+
done
232+
168233
# Run all hooks on files changed vs origin/main (the same hooks a commit runs,
169234
# but over the branch diff). Mirrors how CI lints a PR.
170235
precommit:

scripts/convert-mcp.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ if ! jq empty "$input" 2>/dev/null; then
3838
exit 1
3939
fi
4040

41+
# Never override an existing .mcp.json. It may carry user-added servers and live
42+
# tokens that exist nowhere else, and overwriting would force the user to
43+
# reconfigure what they already have. .mcp.json also changes far less often than
44+
# skills, so auto-syncing it is low value here. We only generate it on FIRST
45+
# setup (when it does not yet exist); if it is already present we skip entirely,
46+
# before doing any conversion work. Entity-aware reconciliation of new upstream
47+
# servers is a follow-up (OPIK-7271).
48+
if [[ -f "$output" ]]; then
49+
echo " Existing $output left untouched (not overriding local MCP config)."
50+
exit 0
51+
fi
52+
4153
# Helper function to parse env file into JSON object
4254
# Handles: missing files, empty files, comment-only files, special characters
4355
parse_env_file() {
@@ -185,4 +197,4 @@ else
185197
end
186198
' "$input" > "$output"
187199

188-
echo " Converted MCP config: $input -> $output"
200+
echo " Converted MCP config (first-time setup): $input -> $output"

scripts/sync-agent-configs.sh

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env bash
2+
# Refresh the agent-config surfaces (Claude, Cursor, Codex) that this user has
3+
# already opted into, from .agents/. Invoked by `make agent-configs` and by the
4+
# git hooks.
5+
#
6+
# Opt-in is detected by which config folders/artifacts already exist -- we never
7+
# create a surface the user has not set up themselves:
8+
# .claude/ -> Claude (make claude)
9+
# .cursor (symlink) -> Cursor (make cursor)
10+
# .codex / AGENTS.override.md -> Codex (make codex)
11+
#
12+
# Behaviour differs by checkout type:
13+
# - Main checkout: regenerate each opted-in surface from .agents/ (make <t>).
14+
# - Worktree: surfaces are shared from the main checkout via symlinks, so
15+
# we only relink (scripts/sync-worktree-claude.sh) and do NOT
16+
# regenerate.
17+
#
18+
# Safe to run repeatedly and when nothing is opted in (then it is a no-op).
19+
20+
set -euo pipefail
21+
22+
git_common_dir="$(git rev-parse --git-common-dir 2>/dev/null || true)"
23+
if [[ -z "$git_common_dir" ]]; then
24+
echo "Not in a git repository; skipping agent-config sync." >&2
25+
exit 0
26+
fi
27+
git_common_dir="$(cd "$(dirname "$git_common_dir")" && pwd)/$(basename "$git_common_dir")"
28+
main_checkout="$(dirname "$git_common_dir")"
29+
repo_root="$(git rev-parse --show-toplevel)"
30+
31+
if [[ "$repo_root" != "$main_checkout" ]]; then
32+
# Worktree: share the main checkout's surfaces via symlink; never regenerate.
33+
if [[ -x "$repo_root/scripts/sync-worktree-claude.sh" ]]; then
34+
"$repo_root/scripts/sync-worktree-claude.sh"
35+
else
36+
# Worktrees created off a branch predating this script get nothing;
37+
# say so rather than exit silently with no skills/MCP.
38+
echo "Worktree has no scripts/sync-worktree-claude.sh (older branch); skipping. Run 'make agent-configs' from a branch that has it, or rebase." >&2
39+
fi
40+
exit 0
41+
fi
42+
43+
cd "$main_checkout"
44+
45+
ran_any=0
46+
47+
# Each surface is an independent opt-in: a failure in one must not stop the
48+
# others from syncing. Guard each so `set -e` cannot abort the whole run.
49+
# Claude: opted in when .claude/ exists.
50+
if [[ -d ".claude" ]]; then
51+
echo "Detected .claude/ -> make claude"
52+
make claude || echo "WARN: make claude failed; other surfaces still attempted." >&2
53+
ran_any=1
54+
fi
55+
56+
# Cursor: opted in when the .cursor symlink exists.
57+
if [[ -L ".cursor" ]]; then
58+
echo "Detected .cursor -> make cursor"
59+
make cursor || echo "WARN: make cursor failed." >&2
60+
ran_any=1
61+
fi
62+
63+
# Codex: opted in when the .codex symlink or a generated AGENTS.override.md exists.
64+
if [[ -L ".codex" || -f "AGENTS.override.md" ]]; then
65+
echo "Detected Codex config -> make codex"
66+
make codex || echo "WARN: make codex failed." >&2
67+
ran_any=1
68+
fi
69+
70+
if [[ "$ran_any" -eq 0 ]]; then
71+
echo "No agent-config surfaces opted in (.claude/, .cursor, .codex); nothing to sync."
72+
fi

scripts/sync-worktree-claude.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env bash
2+
# Link a git worktree's machine-local Claude config to the main checkout.
3+
#
4+
# Claude Code resolves .claude/skills, .claude/commands, .claude/agents and
5+
# .mcp.json from the directory it is launched in and does not walk up to a
6+
# parent repository. These paths are gitignored, so a freshly created worktree
7+
# has none of them and would load zero skills and zero MCP servers.
8+
#
9+
# Rather than regenerate them per worktree (duplicated, drift-prone state), we
10+
# symlink them to the main checkout so every worktree shares one source of
11+
# truth: the skills you see in the root folder and the .mcp.json holding your
12+
# personal tokens. .claude/rules is intentionally NOT handled here -- rules are
13+
# versioned and branch-specific (the tracked .md files come from the branch's
14+
# own checkout), and rule content is on track to migrate into Skills, so we do
15+
# not render or symlink rules per worktree.
16+
#
17+
# Safe to run repeatedly. A no-op when run in the main checkout itself.
18+
19+
set -euo pipefail
20+
21+
git_common_dir="$(git rev-parse --git-common-dir 2>/dev/null || true)"
22+
if [[ -z "$git_common_dir" ]]; then
23+
echo "Not in a git repository; skipping worktree Claude sync." >&2
24+
exit 0
25+
fi
26+
27+
# The main checkout is the parent of the shared .git directory. Worktrees have a
28+
# .git *file* pointing into <main>/.git/worktrees/<name>, so their common-dir
29+
# still resolves to the main checkout's .git.
30+
git_common_dir="$(cd "$(dirname "$git_common_dir")" && pwd)/$(basename "$git_common_dir")"
31+
main_checkout="$(dirname "$git_common_dir")"
32+
worktree_root="$(git rev-parse --show-toplevel)"
33+
34+
if [[ "$worktree_root" == "$main_checkout" ]]; then
35+
# Running in the main checkout, nothing to link.
36+
exit 0
37+
fi
38+
39+
link_to_main() {
40+
local rel="$1"
41+
local target="$main_checkout/$rel"
42+
local link="$worktree_root/$rel"
43+
44+
if [[ ! -e "$target" ]]; then
45+
echo " skip $rel (not present in main checkout)"
46+
return
47+
fi
48+
49+
# Already the correct symlink.
50+
if [[ -L "$link" ]] && [[ "$(readlink "$link")" == "$target" ]]; then
51+
echo " ok $rel -> $target"
52+
return
53+
fi
54+
55+
# A real (non-symlink) file/dir here is worktree-local content we must not
56+
# clobber -- bail loudly instead of destroying it.
57+
if [[ -e "$link" ]] && [[ ! -L "$link" ]]; then
58+
echo " WARN $rel exists as a real path in the worktree; not replacing it." >&2
59+
return
60+
fi
61+
62+
mkdir -p "$(dirname "$link")"
63+
rm -f "$link"
64+
ln -s "$target" "$link"
65+
echo " link $rel -> $target"
66+
}
67+
68+
echo "Linking worktree Claude config to main checkout ($main_checkout)..."
69+
link_to_main ".claude/skills"
70+
link_to_main ".claude/commands"
71+
link_to_main ".claude/agents"
72+
link_to_main ".mcp.json"
73+
echo "Worktree Claude config linked. Skills, commands, agents and MCP servers are shared with the main checkout."

0 commit comments

Comments
 (0)