feat(agents-init): add per-directory AGENTS.md/CLAUDE.md sync helper
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
# CLASSIFICATION
|
||||
# self-limiting(rm,mkdir), bypasses-shadow(mv)
|
||||
#
|
||||
# SYNOPSIS
|
||||
# _agents_init_sync_instructions <root> <agents_dir> <rel>
|
||||
#
|
||||
# DESCRIPTION
|
||||
# Normalizes one directory's agent instruction file(s) into the
|
||||
# AGENTS.md-only shape: <root>/<rel>/AGENTS.md becomes a symlink to the
|
||||
# real file at <agents_dir>/<rel>/AGENTS.md (or, for the root itself,
|
||||
# <agents_dir>/AGENTS.md directly), and no CLAUDE.md survives anywhere
|
||||
# for that directory -- neither at the project level nor inside the
|
||||
# mirror.
|
||||
#
|
||||
# Four states of <rel> are handled, in order, so later steps only ever
|
||||
# see a settled mirror:
|
||||
#
|
||||
# 1. The mirror itself is inverted (CLAUDE.md real, AGENTS.md symlinked
|
||||
# to it). Flipped in place: same bytes, new name.
|
||||
# 2. The mirror has no real AGENTS.md yet, and the project directory
|
||||
# has one or both files. A lone real file (either name) is adopted
|
||||
# as the mirror's AGENTS.md -- a lone CLAUDE.md is renamed, never
|
||||
# preserved under its own name. Both real and byte-identical: the
|
||||
# AGENTS.md side is adopted and the duplicate CLAUDE.md is dropped.
|
||||
# Both real and different: neither is touched and a warning is
|
||||
# printed to stderr -- this function has no way to know which side
|
||||
# is authoritative, and silently keeping one would silently discard
|
||||
# the other.
|
||||
# 3. Any CLAUDE.md still left in the mirror once AGENTS.md is settled
|
||||
# (belt-and-suspenders past step 1) is removed.
|
||||
# 4. The project-level AGENTS.md symlink is (re)created if missing or
|
||||
# stale, and any CLAUDE.md left at the project level is removed.
|
||||
#
|
||||
# ARGUMENTS
|
||||
# root Absolute path to the project root
|
||||
# agents_dir Absolute path to the project's AGENTS/ sub-repo
|
||||
# rel Path of the directory being synced, relative to root
|
||||
# ("." for the root itself)
|
||||
#
|
||||
# EXIT STATUS
|
||||
# 0 <rel> is settled (including the both-real-and-different skip, which
|
||||
# is not a failure of this function)
|
||||
# 1 A filesystem operation (mkdir/mv/rm/ln) failed
|
||||
#
|
||||
# RETURNS
|
||||
# One "→ ..." line per change made, on stdout; nothing when <rel> was
|
||||
# already settled. A skip warning goes to stderr, never stdout, so it is
|
||||
# never mistaken for a change.
|
||||
#
|
||||
# EXAMPLE
|
||||
# _agents_init_sync_instructions /path/to/project /path/to/project/AGENTS .
|
||||
# _agents_init_sync_instructions /path/to/project /path/to/project/AGENTS functions
|
||||
function _agents_init_sync_instructions --argument-names root agents_dir rel
|
||||
test -n "$root" -a -n "$agents_dir" -a -n "$rel"; or return 1
|
||||
|
||||
set -l proj_dir "$root"
|
||||
set -l mirror_dir "$agents_dir"
|
||||
if test "$rel" != "."
|
||||
set proj_dir "$root/$rel"
|
||||
set mirror_dir "$agents_dir/$rel"
|
||||
end
|
||||
|
||||
set -l proj_agents "$proj_dir/AGENTS.md"
|
||||
set -l proj_claude "$proj_dir/CLAUDE.md"
|
||||
set -l mirror_agents "$mirror_dir/AGENTS.md"
|
||||
set -l mirror_claude "$mirror_dir/CLAUDE.md"
|
||||
|
||||
# Display names for progress lines: bare at the root, "<rel>/..." below it.
|
||||
set -l disp_agents AGENTS.md
|
||||
set -l disp_claude CLAUDE.md
|
||||
set -l mirror_rel AGENTS
|
||||
if test "$rel" != "."
|
||||
set disp_agents "$rel/AGENTS.md"
|
||||
set disp_claude "$rel/CLAUDE.md"
|
||||
set mirror_rel "AGENTS/$rel"
|
||||
end
|
||||
|
||||
mkdir -p "$mirror_dir"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not create $mirror_dir" >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
# ── 1: an inverted mirror (CLAUDE.md real, AGENTS.md symlinked to it) ──
|
||||
if test -f "$mirror_claude"; and not test -L "$mirror_claude"
|
||||
if test -L "$mirror_agents"
|
||||
rm -f "$mirror_agents"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not remove $mirror_agents" >&2
|
||||
return 1
|
||||
end
|
||||
end
|
||||
if not test -e "$mirror_agents"
|
||||
command mv "$mirror_claude" "$mirror_agents"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not rename $mirror_claude" >&2
|
||||
return 1
|
||||
end
|
||||
echo "→ Renamed $mirror_rel/CLAUDE.md → AGENTS.md"
|
||||
end
|
||||
end
|
||||
|
||||
# ── 2: adopt real project-level files, only if the mirror has none yet ──
|
||||
if not test -f "$mirror_agents"
|
||||
set -l has_agents 0
|
||||
set -l has_claude 0
|
||||
test -f "$proj_agents"; and not test -L "$proj_agents"; and set has_agents 1
|
||||
test -f "$proj_claude"; and not test -L "$proj_claude"; and set has_claude 1
|
||||
|
||||
if test $has_agents -eq 1; and test $has_claude -eq 1
|
||||
if command diff -q "$proj_agents" "$proj_claude" >/dev/null 2>&1
|
||||
command mv "$proj_agents" "$mirror_agents"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not move $proj_agents" >&2
|
||||
return 1
|
||||
end
|
||||
rm -f "$proj_claude"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not remove $proj_claude" >&2
|
||||
return 1
|
||||
end
|
||||
echo "→ Moved $disp_agents → $mirror_rel/AGENTS.md (dropped identical CLAUDE.md)"
|
||||
else
|
||||
echo "_agents_init_sync_instructions: $proj_agents and $proj_claude differ; leaving both, resolve by hand" >&2
|
||||
return 0
|
||||
end
|
||||
else if test $has_agents -eq 1
|
||||
command mv "$proj_agents" "$mirror_agents"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not move $proj_agents" >&2
|
||||
return 1
|
||||
end
|
||||
echo "→ Moved $disp_agents → $mirror_rel/AGENTS.md"
|
||||
else if test $has_claude -eq 1
|
||||
command mv "$proj_claude" "$mirror_agents"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not move $proj_claude" >&2
|
||||
return 1
|
||||
end
|
||||
echo "→ Moved $disp_claude → $mirror_rel/AGENTS.md"
|
||||
else if test "$rel" = "."
|
||||
printf '%s\n' \
|
||||
'# AGENTS.md' \
|
||||
'' \
|
||||
'> ⚠️ **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' \
|
||||
'> You may be reading this file via a symlink (`AGENTS.md`) in' \
|
||||
'> the root of the project. Your environment'\''s file-editing tools cannot write' \
|
||||
'> through symlinks and will throw an error.' \
|
||||
'>' \
|
||||
'> **DO NOT** attempt to write to or edit `AGENTS.md` in the' \
|
||||
'> project root. If you need to update these instructions, you **MUST write' \
|
||||
'> directly to `AGENTS/AGENTS.md`**.' >"$mirror_agents"
|
||||
echo "→ Created AGENTS/AGENTS.md with agent directive"
|
||||
end
|
||||
end
|
||||
|
||||
# ── 3: the mirror never carries a CLAUDE.md once AGENTS.md is settled ──
|
||||
if test -f "$mirror_agents"; and test -e "$mirror_claude" -o -L "$mirror_claude"
|
||||
rm -f "$mirror_claude"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not remove $mirror_claude" >&2
|
||||
return 1
|
||||
end
|
||||
echo "→ Removed $mirror_rel/CLAUDE.md"
|
||||
end
|
||||
|
||||
# Nothing more to do for a conflicted or still-empty directory.
|
||||
test -f "$mirror_agents"; or return 0
|
||||
|
||||
# ── 4: ensure the project-level AGENTS.md symlink, drop project CLAUDE.md ──
|
||||
set -l target "AGENTS/AGENTS.md"
|
||||
if test "$rel" != "."
|
||||
set -l up (string repeat -n (count (string split / -- $rel)) "../")
|
||||
set target "$up""AGENTS/$rel/AGENTS.md"
|
||||
end
|
||||
set -l need_link 1
|
||||
if test -L "$proj_agents"
|
||||
test (readlink "$proj_agents") = "$target"; and set need_link 0
|
||||
end
|
||||
if test $need_link -eq 1
|
||||
rm -f "$proj_agents"
|
||||
ln -s "$target" "$proj_agents"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not link $proj_agents" >&2
|
||||
return 1
|
||||
end
|
||||
echo "→ Linked $disp_agents → $target"
|
||||
end
|
||||
|
||||
if test -e "$proj_claude" -o -L "$proj_claude"
|
||||
rm -f "$proj_claude"
|
||||
or begin
|
||||
echo "_agents_init_sync_instructions: could not remove $proj_claude" >&2
|
||||
return 1
|
||||
end
|
||||
echo "→ Removed $disp_claude"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env fish
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Hermetic tests for agents-init's AGENTS.md/CLAUDE.md handling: the
|
||||
# per-directory sync helper (_agents_init_sync_instructions) and the
|
||||
# repo-wide discovery loop in agents-init that drives it. Every test
|
||||
# builds its own throwaway git repo under mktemp; nothing touches this
|
||||
# checkout.
|
||||
#
|
||||
# Runs isolated (no `# MODE:` marker, which means isolated).
|
||||
#
|
||||
# Usage: fish tests/test-agents-init.fish
|
||||
|
||||
source (realpath (dirname (status filename)))/lib.fish
|
||||
set -p fish_function_path $repo_root/functions
|
||||
|
||||
set -gx GIT_AUTHOR_NAME t
|
||||
set -gx GIT_AUTHOR_EMAIL t@t
|
||||
set -gx GIT_COMMITTER_NAME t
|
||||
set -gx GIT_COMMITTER_EMAIL t@t
|
||||
set -gx GIT_CONFIG_COUNT 2
|
||||
set -gx GIT_CONFIG_KEY_0 commit.gpgsign
|
||||
set -gx GIT_CONFIG_VALUE_0 false
|
||||
set -gx GIT_CONFIG_KEY_1 init.defaultBranch
|
||||
set -gx GIT_CONFIG_VALUE_1 main
|
||||
|
||||
set -g TMPDIRS
|
||||
|
||||
function new_repo
|
||||
set -l d (mktemp -d)
|
||||
set -ga TMPDIRS $d
|
||||
git -C $d init -q
|
||||
git -C $d config user.email t@t
|
||||
git -C $d config user.name t
|
||||
git -C $d config commit.gpgsign false
|
||||
git -C $d config core.hooksPath /dev/null
|
||||
printf '%s\n' $d
|
||||
end
|
||||
|
||||
function cleanup
|
||||
for d in $TMPDIRS
|
||||
test -n "$d"; and rm -rf $d
|
||||
end
|
||||
end
|
||||
|
||||
echo "== _agents_init_sync_instructions: fresh root =="
|
||||
|
||||
set -l r1 (new_repo)
|
||||
mkdir -p $r1/AGENTS
|
||||
set -l out1 (_agents_init_sync_instructions $r1 $r1/AGENTS .)
|
||||
set -l rc1 $status
|
||||
check "fresh root: exits 0" 0 "$rc1"
|
||||
check "fresh root: mirror AGENTS.md created" true (test -f $r1/AGENTS/AGENTS.md; and echo true; or echo false)
|
||||
check "fresh root: no mirror CLAUDE.md" false (test -e $r1/AGENTS/CLAUDE.md; and echo true; or echo false)
|
||||
check "fresh root: project AGENTS.md links to mirror" AGENTS/AGENTS.md (readlink $r1/AGENTS.md)
|
||||
check "fresh root: no project CLAUDE.md" false (test -e $r1/CLAUDE.md; and echo true; or echo false)
|
||||
|
||||
echo ""
|
||||
echo "== _agents_init_sync_instructions: idempotent second run =="
|
||||
|
||||
set -l out1b (_agents_init_sync_instructions $r1 $r1/AGENTS .)
|
||||
check "idempotent: second call prints nothing" "" "$out1b"
|
||||
check "idempotent: still linked" true (test -L $r1/AGENTS.md; and echo true; or echo false)
|
||||
|
||||
echo ""
|
||||
echo "== _agents_init_sync_instructions: root collapse (today's repo shape) =="
|
||||
|
||||
set -l r2 (new_repo)
|
||||
mkdir -p $r2/AGENTS
|
||||
echo hello >$r2/AGENTS/AGENTS.md
|
||||
ln -s AGENTS.md $r2/AGENTS/CLAUDE.md
|
||||
ln -s AGENTS/AGENTS.md $r2/AGENTS.md
|
||||
ln -s AGENTS/CLAUDE.md $r2/CLAUDE.md
|
||||
set -l out2 (_agents_init_sync_instructions $r2 $r2/AGENTS .)
|
||||
set -l rc2 $status
|
||||
check "root collapse: exits 0" 0 "$rc2"
|
||||
check "root collapse: mirror CLAUDE.md gone" false (test -e $r2/AGENTS/CLAUDE.md; and echo true; or echo false)
|
||||
check "root collapse: project CLAUDE.md gone" false (test -e $r2/CLAUDE.md; and echo true; or echo false)
|
||||
check "root collapse: project AGENTS.md still links correctly" AGENTS/AGENTS.md (readlink $r2/AGENTS.md)
|
||||
check "root collapse: mirror content preserved" hello (cat $r2/AGENTS/AGENTS.md)
|
||||
|
||||
echo ""
|
||||
echo "== _agents_init_sync_instructions: subdir with only a real CLAUDE.md =="
|
||||
|
||||
set -l r3 (new_repo)
|
||||
mkdir -p $r3/AGENTS $r3/functions
|
||||
echo scoped >$r3/functions/CLAUDE.md
|
||||
set -l out3 (_agents_init_sync_instructions $r3 $r3/AGENTS functions)
|
||||
set -l rc3 $status
|
||||
check "subdir lone CLAUDE.md: exits 0" 0 "$rc3"
|
||||
check "subdir lone CLAUDE.md: mirror AGENTS.md created" scoped (cat $r3/AGENTS/functions/AGENTS.md)
|
||||
check "subdir lone CLAUDE.md: no mirror CLAUDE.md" false (test -e $r3/AGENTS/functions/CLAUDE.md; and echo true; or echo false)
|
||||
check "subdir lone CLAUDE.md: project AGENTS.md links to mirror" ../AGENTS/functions/AGENTS.md (readlink $r3/functions/AGENTS.md)
|
||||
check "subdir lone CLAUDE.md: no project CLAUDE.md" false (test -e $r3/functions/CLAUDE.md; and echo true; or echo false)
|
||||
|
||||
echo ""
|
||||
echo "== _agents_init_sync_instructions: inverted mirror (docs/, functions/ today) =="
|
||||
|
||||
set -l r4 (new_repo)
|
||||
mkdir -p $r4/AGENTS/docs $r4/docs
|
||||
echo docsreal >$r4/AGENTS/docs/CLAUDE.md
|
||||
ln -s CLAUDE.md $r4/AGENTS/docs/AGENTS.md
|
||||
ln -s CLAUDE.md $r4/docs/AGENTS.md
|
||||
ln -s ../AGENTS/docs/CLAUDE.md $r4/docs/CLAUDE.md
|
||||
set -l out4 (_agents_init_sync_instructions $r4 $r4/AGENTS docs)
|
||||
set -l rc4 $status
|
||||
check "inverted mirror: exits 0" 0 "$rc4"
|
||||
check "inverted mirror: mirror AGENTS.md real" docsreal (cat $r4/AGENTS/docs/AGENTS.md)
|
||||
check "inverted mirror: mirror CLAUDE.md gone" false (test -e $r4/AGENTS/docs/CLAUDE.md; and echo true; or echo false)
|
||||
check "inverted mirror: project AGENTS.md relinked directly" ../AGENTS/docs/AGENTS.md (readlink $r4/docs/AGENTS.md)
|
||||
check "inverted mirror: project CLAUDE.md gone" false (test -e $r4/docs/CLAUDE.md; and echo true; or echo false)
|
||||
|
||||
echo ""
|
||||
echo "== _agents_init_sync_instructions: both real, different content =="
|
||||
|
||||
set -l r5 (new_repo)
|
||||
mkdir -p $r5/AGENTS $r5/conflict
|
||||
echo agents-version >$r5/conflict/AGENTS.md
|
||||
echo claude-version >$r5/conflict/CLAUDE.md
|
||||
set -l err5 (mktemp)
|
||||
set -ga TMPDIRS $err5
|
||||
_agents_init_sync_instructions $r5 $r5/AGENTS conflict 2>$err5
|
||||
set -l rc5 $status
|
||||
check "conflict: exits 0 (non-fatal skip)" 0 "$rc5"
|
||||
check "conflict: warns to stderr" true (string match -q '*differ*' -- (cat $err5); and echo true; or echo false)
|
||||
check "conflict: project AGENTS.md untouched" agents-version (cat $r5/conflict/AGENTS.md)
|
||||
check "conflict: project CLAUDE.md untouched" claude-version (cat $r5/conflict/CLAUDE.md)
|
||||
check "conflict: nothing mirrored" false (test -e $r5/AGENTS/conflict/AGENTS.md; and echo true; or echo false)
|
||||
|
||||
echo ""
|
||||
echo "== _agents_init_sync_instructions: both real, identical content =="
|
||||
|
||||
set -l r6 (new_repo)
|
||||
mkdir -p $r6/AGENTS $r6/dup
|
||||
echo same >$r6/dup/AGENTS.md
|
||||
echo same >$r6/dup/CLAUDE.md
|
||||
set -l out6 (_agents_init_sync_instructions $r6 $r6/AGENTS dup)
|
||||
set -l rc6 $status
|
||||
check "duplicate: exits 0" 0 "$rc6"
|
||||
check "duplicate: mirrored" same (cat $r6/AGENTS/dup/AGENTS.md)
|
||||
check "duplicate: project CLAUDE.md dropped" false (test -e $r6/dup/CLAUDE.md; and echo true; or echo false)
|
||||
check "duplicate: project AGENTS.md links to mirror" ../AGENTS/dup/AGENTS.md (readlink $r6/dup/AGENTS.md)
|
||||
|
||||
cleanup
|
||||
report
|
||||
Reference in New Issue
Block a user