diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish new file mode 100644 index 0000000..86675b7 --- /dev/null +++ b/functions/_agents_init_sync_instructions.fish @@ -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 +# +# DESCRIPTION +# Normalizes one directory's agent instruction file(s) into the +# AGENTS.md-only shape: //AGENTS.md becomes a symlink to the +# real file at //AGENTS.md (or, for the root itself, +# /AGENTS.md directly), and no CLAUDE.md survives anywhere +# for that directory -- neither at the project level nor inside the +# mirror. +# +# Four states of 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 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 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, "/..." 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 diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish new file mode 100644 index 0000000..b71369b --- /dev/null +++ b/tests/test-agents-init.fish @@ -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