feat(agents-cleanup): reverse an agents-init scaffold and opt a project out #183

Merged
rootiest merged 11 commits from feat/agents-cleanup into main 2026-09-30 21:46:51 +00:00
2 changed files with 199 additions and 44 deletions
Showing only changes of commit 5e4d0fa4c8 - Show all commits
+70 -17
View File
@@ -22,8 +22,10 @@
# Every symlink that resolves into AGENTS/ is replaced by the real file
# or directory it points to. When two links share a target (docs/plans
# and docs/superpowers/plans), the shallower one receives the content
# and the other is removed; a link to a target holding only .gitkeep is
# removed with nothing put in its place. An AGENTS.md that is exactly
# and the other is removed; a link to a target holding only .gitkeep, a
# dangling link, or a link to AGENTS/ itself is removed with nothing put
# in its place. Dangling links are removed even when AGENTS/ is already
# gone. An AGENTS.md that is exactly
# the stub agents-init writes is deleted; any other AGENTS.md loses only
# the SYSTEM DIRECTIVE blockquote that pointed agents at AGENTS/AGENTS.md.
# No CLAUDE.md is recreated.
@@ -38,14 +40,17 @@
# Files inside AGENTS/ that no project symlink points to -- other than
# agents-init's own .version, .agents-tools/ and .gitkeep files -- stop
# the cleanup before anything changes. They are listed; --drop-extras
# discards them instead.
# discards them instead. A nested git repository inside AGENTS/ is always
# refused, --drop-extras or not: the bundle keeps only a pointer to it, so
# move it out first.
#
# The disabled marker is the per-clone git config key
# agents-init.disabled, set on every run. --marker-file also writes
# .agents-disabled in the project root, which agents-init honors too and
# which may be committed to opt every clone out; it is the only marker
# available outside a git repository. In a project with no AGENTS/, only
# the marker is set -- a pre-emptive opt-out. agents-init --enable
# available outside a git repository, where the project root is taken to
# be the current directory -- run it from there. In a project with no
# AGENTS/, only the marker is set -- a pre-emptive opt-out. agents-init --enable
# clears the git key again.
#
# Re-running is safe: an interrupted cleanup resumes where it stopped,
@@ -63,7 +68,8 @@
# EXIT STATUS
# 0 Cleanup finished, or nothing was left to do
# 1 Refused (outside git without --marker-file, unresolved rebase in
# AGENTS/, unlinked files in AGENTS/) or a step failed
# AGENTS/, unlinked files or a nested repository in AGENTS/) or a step
# failed
#
# EXAMPLE
# agents-cleanup --dry-run
@@ -154,7 +160,12 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
set -l keep # links to materialize
set -l keep_tgt # their resolved targets, index-aligned with $keep
set -l drop # links removed with nothing put in their place
if test $has_agents -eq 1
# Also runs with no AGENTS/ (deleted by hand): every such link then
# dangles and is dropped. Not outside git, though: there (pwd) is only a
# guess at the project root, so without an AGENTS/ in it the walk could
# cover an arbitrary tree.
if test $has_agents -eq 1; or test $in_git -eq 1
test $has_agents -eq 1; or set agents_dir (realpath -m -- "$agents_dir")
set -l agents_re '^'(string escape --style=regex -- "$agents_dir")'(/|$)'
set -l links
for l in (_agents_init_find "$root" -type l -print)
@@ -168,6 +179,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
set -l t (realpath -m -- "$l")
if not test -e "$t"
set -a drop "$l" # dangling
else if test "$t" = "$agents_dir"
# A link to AGENTS/ itself: materializing it would move the
# whole repository. Drop-only, like any link with nothing to restore.
set -a drop "$l"
else if contains -- "$t" $keep_tgt
set -a drop "$l" # duplicate of a shallower link
else if test -d "$t"; and test -z "$(find "$t" ! -type d ! -name .gitkeep -print -quit)"
@@ -183,12 +198,26 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
# own tooling. Refused by default: nothing says where they belong.
set -l extras
set -l extras_ignored
set -l nested
if test $has_agents -eq 1
set -l cover_re
for t in $keep_tgt
set -a cover_re '^'(string escape --style=regex -- "$t")'(/|$)'
end
for f in (find "$agents_dir" -name .git -prune -o ! -type d -print)
# A nested repository is recorded by `git add -A` as a bare gitlink,
# so neither the bundle nor --drop-extras treats it as files: it is
# listed on its own and never walked into.
for f in (find "$agents_dir" -mindepth 1 -name .git -prune -o -type d -exec test -e '{}/.git' \; -print -prune)
set -l covered 0
for re in $cover_re
if string match -qr -- $re "$f"
set covered 1
break
end
end
test $covered -eq 1; or set -a nested (string replace -- "$agents_dir/" "" "$f")
end
for f in (find "$agents_dir" -mindepth 1 \( -name .git -o -type d -exec test -e '{}/.git' \; \) -prune -o ! -type d -print)
set -l rel (string replace -- "$agents_dir/" "" "$f")
switch "$rel"
case .version '.agents-tools/*' .gitkeep '*/.gitkeep'
@@ -209,6 +238,15 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
end
end
if set -q nested[1]
echo "$c_err""Error: AGENTS/ holds nested git repositories; the history bundle records only a pointer to them, so they would be lost:$c_reset" >&2
for n in $nested
echo " AGENTS/$n/ (nested repository -- not in bundle)" >&2
end
echo "Move them out of AGENTS/ first; --drop-extras does not cover them." >&2
return 1
end
if set -q extras[1]; and not set -q _flag_drop_extras
echo "$c_err""Error: AGENTS/ holds files no project symlink points to:$c_reset" >&2
for e in $extras
@@ -230,7 +268,14 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
set -l state_dir $XDG_STATE_HOME
test -n "$state_dir"; or set state_dir "$HOME/.local/state"
set -l bundle "$state_dir/agents-cleanup/"(_agents_repo_slug "$root")"-"(date +%Y%m%d-%H%M%S)".bundle"
set -l bundle_base "$state_dir/agents-cleanup/"(_agents_repo_slug "$root")"-"(date +%Y%m%d-%H%M%S)
# `git bundle create` overwrites: never reuse a name from the same second.
set -l bundle "$bundle_base.bundle"
set -l n 0
while test -e "$bundle"
set n (math $n + 1)
set bundle "$bundle_base-$fish_pid-$n.bundle"
end
set -l gitignore "$root/.gitignore"
if set -q _flag_dry_run
@@ -343,8 +388,13 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
if not command mv "$keep_tgt[$i]" "$link"
# Put the link back: without it the target looks like an unlinked
# extra and a re-run would refuse.
ln -s -- "$was" "$link"
if ln -s -- "$was" "$link"
echo "$c_err""Error: could not restore $rel from AGENTS/; link left in place$c_reset" >&2
else
set -l where "AGENTS/"(string replace -- "$agents_dir/" "" "$keep_tgt[$i]")
test -n "$archived"; and set where "$where and in the bundle"
echo "$c_err""Error: could not restore $rel and could not put its link back; the content is still at $where$c_reset" >&2
end
return 1
end
set changed 1
@@ -414,7 +464,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
end
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Removed AGENTS/$c_reset"
end
# agents-init creates these; drop them only if nothing else lives there.
# Also after dropping links with no AGENTS/ left, which empties them too.
if test $has_agents -eq 1; or set -q drop[1]
for d in "$root/docs/superpowers" "$root/docs"
if test -d "$d"; and rmdir "$d" 2>/dev/null
test $verbose -eq 1; and echo "$c_ok→ Removed empty "(string replace -- "$root/" "" "$d")"/$c_reset"
@@ -428,10 +481,10 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
# footer before the next header is left in place (and reported).
if test -f "$gitignore"; and grep -q 'Added by agents-init' "$gitignore"
# The unterminated-block warning is verbose-only, so a quiet or silent
# re-run stays silent.
set -l awk_err /dev/stderr
test $verbose -eq 1; or set awk_err /dev/null
set -l kept (awk '
# re-run stays silent. awk writes /dev/stderr itself: a fish-level
# 2>/dev/stderr reopens the path, which truncates a log file when
# stderr is one (`agents-cleanup &>log`).
set -l kept (awk -v warn=$verbose '
{ line[NR] = $0 }
END {
n = 0
@@ -446,12 +499,12 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
i = j
continue
}
print "agents-cleanup: unterminated agents-init block in .gitignore left in place" > "/dev/stderr"
if (warn) print "agents-cleanup: unterminated agents-init block in .gitignore left in place" > "/dev/stderr"
}
out[++n] = line[i]
}
for (i = 1; i <= n; i++) print out[i]
}' "$gitignore" 2>$awk_err)
}' "$gitignore")
if test $status -ne 0
echo "$c_err""Error: could not rewrite .gitignore$c_reset" >&2
return 1
@@ -486,7 +539,7 @@ function agents-cleanup --description 'undo agents-init: restore real files, arc
test $verbose -eq 1; and echo "$c_dim→ Nothing to clean up; agents-init is already disabled here$c_reset"
else if test $quiet -eq 1
if test -n "$archived"
echo "$c_ok→ Cleaned up AGENTS scaffolding (history: $archived)$c_reset"
echo "$c_ok→ Cleaned up AGENTS scaffolding (history: $archived; restore with: git clone $archived AGENTS)$c_reset"
else
echo "$c_ok→ Cleaned up AGENTS scaffolding$c_reset"
end
+127 -25
View File
@@ -19,6 +19,10 @@ 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
# No user or system config: a developer's global hooks, signing or
# init.defaultBranch must not reach these repos.
set -gx GIT_CONFIG_GLOBAL /dev/null
set -gx GIT_CONFIG_NOSYSTEM 1
set -gx GIT_CONFIG_COUNT 2
set -gx GIT_CONFIG_KEY_0 commit.gpgsign
set -gx GIT_CONFIG_VALUE_0 false
@@ -295,34 +299,42 @@ check "rejected commit: no bundle" 0 (count $fb)
section "agents-cleanup: failed move keeps the link, re-run resumes"
fresh_state
set -l w1 (scaffolded_repo)
chmod a-w $w1/AGENTS/functions
pushd $w1 >/dev/null
set -l w1rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
check "failed mv: exits 1" 1 "$w1rc"
check "failed mv: link still a symlink" true (test -L $w1/functions/AGENTS.md; and echo true; or echo false)
chmod u+w $w1/AGENTS/functions
pushd $w1 >/dev/null
set -l w2rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
check "failed mv re-run: exits 0" 0 "$w2rc"
check "failed mv re-run: real file, content kept" user-scoped (test -L $w1/functions/AGENTS.md; or string collect <$w1/functions/AGENTS.md)
if test (id -u) -eq 0
echo " SKIP failed move: chmod cannot block root"
else
set -l w1 (scaffolded_repo)
chmod a-w $w1/AGENTS/functions
pushd $w1 >/dev/null
set -l w1rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
check "failed mv: exits 1" 1 "$w1rc"
check "failed mv: link still a symlink" true (test -L $w1/functions/AGENTS.md; and echo true; or echo false)
chmod u+w $w1/AGENTS/functions
pushd $w1 >/dev/null
set -l w2rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
check "failed mv re-run: exits 0" 0 "$w2rc"
check "failed mv re-run: real file, content kept" user-scoped (test -L $w1/functions/AGENTS.md; or string collect <$w1/functions/AGENTS.md)
end
section "agents-cleanup: failed directive rewrite keeps AGENTS/"
fresh_state
set -l k1 (new_repo)
printf '%s\n' '# Title' '' '> **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' '> x' '' body >$k1/AGENTS.md
pushd $k1 >/dev/null
agents-init --silent 2>/dev/null
popd >/dev/null
chmod a-w $k1/AGENTS/AGENTS.md
pushd $k1 >/dev/null
set -l k1rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
chmod u+w $k1/AGENTS/AGENTS.md $k1/AGENTS.md 2>/dev/null
check "failed rewrite: exits 1" 1 "$k1rc"
check "failed rewrite: AGENTS/ kept" true (test -d $k1/AGENTS; and echo true; or echo false)
if test (id -u) -eq 0
echo " SKIP failed rewrite: chmod cannot block root"
else
set -l k1 (new_repo)
printf '%s\n' '# Title' '' '> **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' '> x' '' body >$k1/AGENTS.md
pushd $k1 >/dev/null
agents-init --silent 2>/dev/null
popd >/dev/null
chmod a-w $k1/AGENTS/AGENTS.md
pushd $k1 >/dev/null
set -l k1rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
chmod u+w $k1/AGENTS/AGENTS.md $k1/AGENTS.md 2>/dev/null
check "failed rewrite: exits 1" 1 "$k1rc"
check "failed rewrite: AGENTS/ kept" true (test -d $k1/AGENTS; and echo true; or echo false)
end
section "agents-cleanup: unterminated .gitignore block is left alone, quietly"
fresh_state
@@ -393,5 +405,95 @@ check "invalid .git: outer repo untouched" "$i1before" (git -C $i1 rev-list --al
check "invalid .git: no bundle" 0 (count $XDG_STATE_HOME/agents-cleanup/*.bundle 2>/dev/null)
check "invalid .git: AGENTS/ removed" false (test -e $i1/AGENTS; and echo true; or echo false)
section "agents-cleanup: verbose output survives a log-file stderr"
fresh_state
set -l o1 (scaffolded_repo)
set -l o1log (mktemp)
set -a TMPDIRS $o1log
# A subshell: only a shell whose own stderr is the file reproduces the
# truncation (an in-process redirect is emulated and never reopens it).
fish -c "set -p fish_function_path $repo_root/functions; cd $o1; agents-cleanup" >$o1log 2>&1
check "log file: archive line kept" true (string match -q -- '*Archived AGENTS/ history*' (string collect <$o1log); and echo true; or echo false)
check "log file: restore hint kept" true (string match -q -- '*restore with: git clone*' (string collect <$o1log); and echo true; or echo false)
section "agents-cleanup: nested repository under AGENTS/ is never dropped"
fresh_state
set -l q1 (scaffolded_repo)
mkdir -p $q1/AGENTS/scratch/tool
git -C $q1/AGENTS/scratch/tool init -q
echo precious >$q1/AGENTS/scratch/tool/file.txt
set -l q1before (tree_hash $q1)
pushd $q1 >/dev/null
set -l q1err (agents-cleanup --drop-extras 2>&1 >/dev/null)
set -l q1rc $status
popd >/dev/null
check "nested repo: exits 1 even with --drop-extras" 1 "$q1rc"
check "nested repo: tree unchanged" "$q1before" (tree_hash $q1)
check "nested repo: file survives" precious (string collect <$q1/AGENTS/scratch/tool/file.txt)
check "nested repo: message names it" true (string match -q -- '*AGENTS/scratch/tool/*nested repository*' "$q1err"; and echo true; or echo false)
check "nested repo: git key not set" 1 (git -C $q1 config --get agents-init.disabled >/dev/null; echo $status)
section "agents-cleanup: a link to AGENTS/ itself is dropped, not moved"
fresh_state
set -l z1 (scaffolded_repo)
ln -s ../AGENTS $z1/docs/agents
pushd $z1 >/dev/null
set -l z1rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
check "self link: exits 0" 0 "$z1rc"
check "self link: docs/agents gone" false (test -e $z1/docs/agents -o -L $z1/docs/agents; and echo true; or echo false)
check "self link: docs/plans restored real" a-plan (test -L $z1/docs/plans; or string collect <$z1/docs/plans/p.md)
section "agents-cleanup: AGENTS/ deleted by hand, dangling links still go"
fresh_state
set -l h1 (scaffolded_repo)
rm -rf $h1/AGENTS
pushd $h1 >/dev/null
set -l h1dry (agents-cleanup --dry-run 2>/dev/null)
set -l h1rc (agents-cleanup --silent 2>/dev/null; echo $status)
popd >/dev/null
check "no AGENTS/, dangling: dry-run plans removing links" true (string match -q -- '*remove link AGENTS.md*' "$h1dry"; and echo true; or echo false)
check "no AGENTS/, dangling: exits 0" 0 "$h1rc"
check "no AGENTS/, dangling: AGENTS.md link gone" false (test -e $h1/AGENTS.md -o -L $h1/AGENTS.md; and echo true; or echo false)
check "no AGENTS/, dangling: docs/superpowers gone" false (test -e $h1/docs/superpowers -o -L $h1/docs/superpowers; and echo true; or echo false)
check "no AGENTS/, dangling: git key set" true (git -C $h1 config --type=bool --get agents-init.disabled)
section "agents-cleanup: outside git, no AGENTS/ here: links are not inventoried"
fresh_state
set -l b1 (mktemp -d)
set -a TMPDIRS $b1
mkdir -p $b1/sub
ln -s ../AGENTS/x $b1/sub/dangling
pushd $b1 >/dev/null
set -l b1rc (set -lx GIT_CEILING_DIRECTORIES (path dirname $b1); agents-cleanup --marker-file --silent 2>/dev/null; echo $status)
popd >/dev/null
check "non-git no AGENTS/: exits 0" 0 "$b1rc"
check "non-git no AGENTS/: stray link untouched" true (test -L $b1/sub/dangling; and echo true; or echo false)
section "agents-cleanup: quiet summary carries the restore command"
fresh_state
set -l y1 (scaffolded_repo)
pushd $y1 >/dev/null
set -l y1out (agents-cleanup --quiet 2>&1)
popd >/dev/null
check "quiet: names the restore command" true (string match -q -- '*git clone *.bundle AGENTS*' "$y1out"; and echo true; or echo false)
section "agents-cleanup: user .gitignore lines around two agents-init blocks"
fresh_state
set -l w9 (new_repo)
pushd $w9 >/dev/null
agents-init --silent 2>/dev/null
popd >/dev/null
set -l blk (string collect <$w9/.gitignore)
printf '%s\n' before1 before2 >$w9/.gitignore
printf '%s\n' "$blk" >>$w9/.gitignore
printf '%s\n' between >>$w9/.gitignore
printf '%s\n' "$blk" >>$w9/.gitignore
printf '%s\n' after1 after2 >>$w9/.gitignore
pushd $w9 >/dev/null
agents-cleanup --silent 2>/dev/null
popd >/dev/null
check "gitignore: exactly the user lines remain, in order" (printf '%s\n' before1 before2 between after1 after2 | string collect) (string collect <$w9/.gitignore)
cleanup
report