aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/install-ai.sh29
-rwxr-xr-xscripts/sweep-gitignore-tooling.sh118
-rw-r--r--scripts/tests/install-ai.bats21
-rw-r--r--scripts/tests/sweep-gitignore-tooling.bats111
4 files changed, 271 insertions, 8 deletions
diff --git a/scripts/install-ai.sh b/scripts/install-ai.sh
index a5b38a8..7007eed 100755
--- a/scripts/install-ai.sh
+++ b/scripts/install-ai.sh
@@ -30,8 +30,11 @@ Bootstrap .ai/ in PROJECT_PATH from canonical content at $CANONICAL.
--track Track .ai/ in the project's git history (with .gitkeep
files inside otherwise-empty sessions/, references/,
retrospectives/).
- --gitignore Append .ai/ to the project's .gitignore so session
- records stay local.
+ --gitignore Gitignore the project's personal tooling (.ai/, .claude/,
+ CLAUDE.md, AGENTS.md) so it stays local. .claude/ is
+ rulesets-owned and re-synced each startup, so git isn't how
+ it travels; ignoring it also keeps private rule copies out
+ of the repo.
If neither flag is given, the script prompts interactively.
If PROJECT_PATH is omitted, fzf-picks from ~/code/* + ~/projects/*
@@ -147,14 +150,24 @@ case "$track_mode" in
touch "$project/.ai/retrospectives/.gitkeep"
;;
gitignore)
- if [ -f "$project/.gitignore" ] && grep -qFx '.ai/' "$project/.gitignore"; then
- : # already present
- else
+ # Personal-tooling files that stay out of git in gitignore mode. .ai/
+ # holds session records; .claude/ is rulesets-owned bundle content
+ # (re-synced each startup, and it carries private rule copies); CLAUDE.md
+ # and AGENTS.md are personal tooling, project-owned but never committed.
+ # Append only the lines not already present, so a project that already
+ # ignores some of them isn't duplicated.
+ gi="$project/.gitignore"
+ needed=()
+ for pat in '.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md'; do
+ if [ -f "$gi" ] && grep -qFx "$pat" "$gi"; then continue; fi
+ needed+=("$pat")
+ done
+ if [ "${#needed[@]}" -gt 0 ]; then
{
- [ -s "$project/.gitignore" ] && echo ""
+ [ -s "$gi" ] && echo ""
echo "# Claude Code per-project tooling"
- echo ".ai/"
- } >> "$project/.gitignore"
+ printf '%s\n' "${needed[@]}"
+ } >> "$gi"
fi
;;
esac
diff --git a/scripts/sweep-gitignore-tooling.sh b/scripts/sweep-gitignore-tooling.sh
new file mode 100755
index 0000000..63fc066
--- /dev/null
+++ b/scripts/sweep-gitignore-tooling.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# sweep-gitignore-tooling.sh — backfill the personal-tooling gitignore set
+# across existing gitignore-mode AI projects.
+#
+# install-ai.sh sets up the ignore set at bootstrap, but a project bootstrapped
+# before the set grew (or one that only ever ignored .ai/) is left exposed — an
+# accidental `git add` or a /codify run can commit a personal CLAUDE.md, the
+# private rule copies under .claude/, or an AGENTS.md. This sweep backfills the
+# full set into every gitignore-mode project that's behind.
+#
+# For each AI project (a directory with .ai/protocols.org) under the search
+# roots, if it's a git checkout in gitignore mode (.ai/ already appears in its
+# .gitignore), ensure .ai/, .claude/, CLAUDE.md, and AGENTS.md are all ignored.
+# Append only the missing lines, so a re-run is a no-op.
+#
+# Track-mode projects (.ai/ NOT in .gitignore) are skipped by design: they
+# track their tooling on purpose — team repos sharing config with teammates who
+# don't run rulesets, or private-remote personal repos where the history IS the
+# project.
+#
+# A line added here only stops *future* commits. If a target path is already
+# tracked, the ignore has no effect until it's untracked; the sweep warns so
+# the path can be `git rm --cached`-ed by hand.
+#
+# Usage: sweep-gitignore-tooling.sh [--dry-run] [SEARCH_ROOT ...]
+# --dry-run Report what would change without writing.
+# SEARCH_ROOT ... Directories to scan (default: ~/code ~/projects ~/.emacs.d).
+
+set -euo pipefail
+
+IGNORE_SET=('.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md')
+
+dry_run=0
+roots=()
+for arg in "$@"; do
+ case "$arg" in
+ --dry-run) dry_run=1 ;;
+ -h|--help)
+ sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ -*)
+ echo "ERROR: unknown flag: $arg (use --help for usage)" >&2
+ exit 2
+ ;;
+ *) roots+=("$arg") ;;
+ esac
+done
+
+if [ "${#roots[@]}" -eq 0 ]; then
+ roots=("$HOME/code" "$HOME/projects" "$HOME/.emacs.d")
+fi
+
+# Discover AI projects: a directory holding .ai/protocols.org.
+mapfile -t projects < <(
+ for root in "${roots[@]}"; do
+ [ -d "$root" ] || continue
+ find "$root" -maxdepth 3 -type f -path '*/.ai/protocols.org' 2>/dev/null \
+ | sed 's|/\.ai/protocols\.org$||'
+ done | sort -u
+)
+
+swept=0 skipped=0 complete=0
+
+for project in "${projects[@]}"; do
+ name="$(basename "$project")"
+ gi="$project/.gitignore"
+
+ if [ ! -d "$project/.git" ]; then
+ echo "skip $name — not a git checkout"
+ skipped=$((skipped + 1))
+ continue
+ fi
+
+ # Gitignore mode iff .ai/ is already ignored. Otherwise track-mode: leave it.
+ if [ ! -f "$gi" ] || ! grep -qFx '.ai/' "$gi"; then
+ echo "skip $name — track-mode (.ai/ not gitignored)"
+ skipped=$((skipped + 1))
+ continue
+ fi
+
+ needed=()
+ for pat in "${IGNORE_SET[@]}"; do
+ grep -qFx "$pat" "$gi" || needed+=("$pat")
+ done
+
+ if [ "${#needed[@]}" -eq 0 ]; then
+ echo "ok $name — already complete"
+ complete=$((complete + 1))
+ continue
+ fi
+
+ if [ "$dry_run" -eq 1 ]; then
+ echo "DRY $name — would add: ${needed[*]}"
+ else
+ {
+ echo ""
+ echo "# Claude Code per-project tooling (swept $(date +%Y-%m-%d))"
+ printf '%s\n' "${needed[@]}"
+ } >> "$gi"
+ echo "swept $name — added: ${needed[*]}"
+ fi
+ swept=$((swept + 1))
+
+ # Warn on any newly-ignored path that's already tracked — the ignore won't
+ # untrack it.
+ for pat in "${needed[@]}"; do
+ path="${pat%/}"
+ if git -C "$project" ls-files --error-unmatch "$path" >/dev/null 2>&1; then
+ echo " WARN $name: $path is currently tracked — 'git -C $project rm --cached -r $path' to untrack"
+ fi
+ done
+done
+
+echo
+echo "Summary: $swept swept, $complete already complete, $skipped skipped (of ${#projects[@]} projects)."
+[ "$dry_run" -eq 1 ] && echo "(dry-run — no files written)"
+exit 0
diff --git a/scripts/tests/install-ai.bats b/scripts/tests/install-ai.bats
index dca70ea..8e91770 100644
--- a/scripts/tests/install-ai.bats
+++ b/scripts/tests/install-ai.bats
@@ -36,12 +36,33 @@ teardown() {
[ -d "$TEST_HOME/code/fresh/.ai/retrospectives" ]
[ -f "$TEST_HOME/code/fresh/.ai/protocols.org" ]
[ -f "$TEST_HOME/code/fresh/.ai/notes.org" ]
+ # Gitignore mode ignores the whole personal-tooling set, not just .ai/.
grep -qFx ".ai/" "$TEST_HOME/code/fresh/.gitignore"
+ grep -qFx ".claude/" "$TEST_HOME/code/fresh/.gitignore"
+ grep -qFx "CLAUDE.md" "$TEST_HOME/code/fresh/.gitignore"
+ grep -qFx "AGENTS.md" "$TEST_HOME/code/fresh/.gitignore"
# Top-level inbox/ is created so the project is an inbox-send target.
[ -d "$TEST_HOME/code/fresh/inbox" ]
[ -f "$TEST_HOME/code/fresh/inbox/.gitkeep" ]
}
+@test "install-ai --gitignore: appends only the missing tooling lines" {
+ mkdir -p "$TEST_HOME/code/partial"
+ (cd "$TEST_HOME/code/partial" && git init -q)
+ # Project already ignores .ai/ and CLAUDE.md from a prior convention.
+ printf '# Personal tooling\n.ai/\nCLAUDE.md\n' > "$TEST_HOME/code/partial/.gitignore"
+
+ run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/partial"
+
+ [ "$status" -eq 0 ]
+ # The two already-present lines are not duplicated.
+ [ "$(grep -cFx '.ai/' "$TEST_HOME/code/partial/.gitignore")" -eq 1 ]
+ [ "$(grep -cFx 'CLAUDE.md' "$TEST_HOME/code/partial/.gitignore")" -eq 1 ]
+ # The two missing lines were added.
+ grep -qFx ".claude/" "$TEST_HOME/code/partial/.gitignore"
+ grep -qFx "AGENTS.md" "$TEST_HOME/code/partial/.gitignore"
+}
+
@test "install-ai: creates top-level inbox/ in --track mode too" {
mkdir -p "$TEST_HOME/code/inbox-track"
(cd "$TEST_HOME/code/inbox-track" && git init -q)
diff --git a/scripts/tests/sweep-gitignore-tooling.bats b/scripts/tests/sweep-gitignore-tooling.bats
new file mode 100644
index 0000000..a28087e
--- /dev/null
+++ b/scripts/tests/sweep-gitignore-tooling.bats
@@ -0,0 +1,111 @@
+#!/usr/bin/env bats
+#
+# Tests for scripts/sweep-gitignore-tooling.sh — backfill the personal-tooling
+# gitignore set across existing gitignore-mode AI projects.
+#
+# Strategy: scaffold fake AI projects (a .ai/protocols.org marker + a git repo)
+# under a temp root, run the sweep against that root, and assert on each
+# project's .gitignore.
+
+REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+SWEEP="$REAL_REPO/scripts/sweep-gitignore-tooling.sh"
+
+setup() {
+ ROOT="$(mktemp -d -t sweep-bats.XXXXXX)"
+}
+
+teardown() {
+ rm -rf "$ROOT"
+}
+
+# Scaffold a project: $1 = name, $2 = initial .gitignore content (may be empty),
+# $3 = "git" to init a repo (default), "nogit" to skip.
+make_project() {
+ local name="$1" gitignore="${2-}" git="${3-git}"
+ mkdir -p "$ROOT/$name/.ai"
+ echo "marker" > "$ROOT/$name/.ai/protocols.org"
+ [ -n "$gitignore" ] && printf '%s' "$gitignore" > "$ROOT/$name/.gitignore"
+ [ "$git" = "git" ] && (cd "$ROOT/$name" && git init -q)
+ return 0
+}
+
+@test "sweep: gitignore-mode project missing all three gets them appended" {
+ make_project gimode $'# tooling\n.ai/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ grep -qFx ".claude/" "$ROOT/gimode/.gitignore"
+ grep -qFx "CLAUDE.md" "$ROOT/gimode/.gitignore"
+ grep -qFx "AGENTS.md" "$ROOT/gimode/.gitignore"
+}
+
+@test "sweep: is idempotent — a second run adds nothing" {
+ make_project gimode $'.ai/\n'
+ bash "$SWEEP" "$ROOT" >/dev/null
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"already complete"* ]]
+ # No line is duplicated.
+ [ "$(grep -cFx '.claude/' "$ROOT/gimode/.gitignore")" -eq 1 ]
+ [ "$(grep -cFx 'CLAUDE.md' "$ROOT/gimode/.gitignore")" -eq 1 ]
+ [ "$(grep -cFx 'AGENTS.md' "$ROOT/gimode/.gitignore")" -eq 1 ]
+}
+
+@test "sweep: track-mode project (.ai/ not ignored) is skipped untouched" {
+ make_project trackmode $'# build\nout/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"track-mode"* ]]
+ # .gitignore is untouched — none of the tooling lines were added.
+ ! grep -qFx ".claude/" "$ROOT/trackmode/.gitignore"
+ ! grep -qFx "CLAUDE.md" "$ROOT/trackmode/.gitignore"
+}
+
+@test "sweep: partial project gets only the missing lines" {
+ make_project partial $'.ai/\n.claude/\n'
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [ "$(grep -cFx '.claude/' "$ROOT/partial/.gitignore")" -eq 1 ]
+ grep -qFx "CLAUDE.md" "$ROOT/partial/.gitignore"
+ grep -qFx "AGENTS.md" "$ROOT/partial/.gitignore"
+}
+
+@test "sweep: --dry-run reports without writing" {
+ make_project gimode $'.ai/\n'
+
+ run bash "$SWEEP" --dry-run "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"would add"* ]]
+ [[ "$output" == *"no files written"* ]]
+ # Nothing was actually written.
+ ! grep -qFx ".claude/" "$ROOT/gimode/.gitignore"
+}
+
+@test "sweep: warns when a now-ignored path is already tracked" {
+ make_project tracked $'.ai/\n'
+ echo "# project rules" > "$ROOT/tracked/CLAUDE.md"
+ (cd "$ROOT/tracked" && git add CLAUDE.md && git -c user.email=t@t -c user.name=t commit -qm seed)
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"WARN"* ]]
+ [[ "$output" == *"CLAUDE.md is currently tracked"* ]]
+}
+
+@test "sweep: non-git project with .ai/ marker is skipped" {
+ make_project nogit $'.ai/\n' "" nogit
+
+ run bash "$SWEEP" "$ROOT"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"not a git checkout"* ]]
+}