36 Commits
Author SHA1 Message Date
rootiest ec700cece1 Merge pull request 'feat(agents-init): retire CLAUDE.md, discover and normalize AGENTS.md in every subdirectory' (#177) from docs/retire-claude-md-for-agents-md into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 2m31s
CI / docs (push) Failing after 4m10s
2026-09-24 04:56:01 +00:00
rootiest 86aea9d5ef Merge pull request 'feat(gi): bundled boilerplate fallback + -c/--custom template flag' (#179) from feat/gi-boilerplate-fallback-and-custom into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 2m26s
CI / docs (push) Failing after 4m8s
2026-09-24 04:54:37 +00:00
rootiest 1f3c8f41e7 Merge pull request 'fix(gi): stop --stdout from leaking to .gitignore, rework flags' (#178) from fix/gi-stdout-flag-rework into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 2m21s
CI / docs (push) Failing after 3m59s
2026-09-24 04:53:32 +00:00
rootiest 911c6e95ed feat(docs): add manual-section CLASSIFICATION tag, wire it into the build
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m26s
CI / docs (pull_request) Successful in 1m17s
A function with a dedicated manual section (docs/manual/16-agent-tooling.md,
so far) now carries manual-section(<slug>) in its own CLASSIFICATION line
instead of relying on a NOTES pointer nobody can grep for. Applied to
agents-init and agents-vault, both pointing at 16-agent-tooling.

docs/build-manual.py: _resolve_manual_section reads the target page's own
manTitle/title fresh at build time rather than duplicating a section
number into the tag, so a renumbered section (like this one, twice
already) never requires touching the tag -- only the slug (the filename)
does, and only if the page itself is renamed. render_entry and
render_entry_site both gained an optional root parameter and now emit a
'See also' line (plain text + relative path for the man page, a real
markdown link on the site) whenever the tag resolves; omitted silently
when it doesn't (a build isn't the place to fail on a bad slug).

docs/verify-manual.py: unit tests for the new resolver and both renderers,
plus a real-data scan (test_real_manual_section_tags_resolve) that fails
the suite if any function's manual-section(<slug>) tag points nowhere --
the actual enforcement half of the convention, since the build stays
silent about it.

docs/function-classification-schema.md, CONTRIBUTING.md: documents the
tag, and widens CLASSIFICATION's own framing from strictly hazard/shadow
tags to general-purpose (the user's call, not mine to make unilaterally --
scope-broadening an existing convention). The 'Dedicated manual sections'
subsection (added earlier this branch) now names the tag as the
machine-checked half of that convention, with NOTES demoted to a
nice-to-have for a header-only reader.
2026-09-24 00:50:36 -04:00
rootiest 8b08b4d5e5 feat(gi): bundled boilerplate fallback + -c/--custom template flag
CI / test (pull_request) Skipped
Boilerplate mode used to hard-error when $GITIGNORE_BOILERPLATE was unset,
so gi -b/-c only worked for users with a personal template configured
(typically via .user-dots). Add a resolution chain:

  1. -c/--custom PATH, if given
  2. $GITIGNORE_BOILERPLATE, if set
  3. bundled standard template (data/gi/boilerplate.gitignore)

The bundled template covers common OS junk, scratch/debug/temp dirs, and
AI tool session state (.claude*, .gemini*, .antigravity*, .agy*, .agents*,
.remember*) -- but deliberately does NOT ignore CLAUDE.md/AGENTS.md/
GEMINI.md/ANTIGRAVITY.md themselves, since committing those is increasingly
normal and agents-init already owns their placement.

-c implies boilerplate mode (like -b) so `gi -c template` works standalone.

Also fixes a latent bug in gi's final line: `test $needs_git -eq 1; and
gitignore-scrub` leaked its own boolean as the function's exit status
whenever needs_git was 0, so e.g. `gi -o python` returned 1 on success.
Same seam this change already touches; fixed with an explicit `return 0`.
2026-09-24 00:41:34 -04:00
rootiest 1d714a84d0 docs(agent-tooling): add scenario reference table, cross-reference from code
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m24s
CI / docs (pull_request) Successful in 1m37s
Adds a 'Scenario reference' subsection to docs/manual/16-agent-tooling.md:
two ruled tables covering every combination of what a directory can hold
(only AGENTS.md, only CLAUDE.md, both identical, both different, an
inverted mirror, an already-settled symlink) crossed with whether the
file is deliberately git-tracked, plus the settled-mirror/later-arrival
case separately. Verified against build-manual.py's actual table parser
and the generated Starlight site output, not just visual inspection.

Also closes the gap this section itself pointed out: nothing previously
linked a reader of agents-init.fish or agents-vault.fish's own doc-header
to this page, and nothing told a future contributor the page has to be
kept in sync. Adds:
- CONTRIBUTING.md: a new 'Dedicated manual sections for complex
  subsystems' subsection documenting the pattern in general (when to use
  one, and the update-it-in-the-same-change obligation verify-manual.py
  cannot check for you).
- functions/agents-init.fish, functions/agents-vault.fish: a NOTES
  pointer to the section from each function's own header, so a reader
  who only sees the header still finds the fuller page.
2026-09-23 22:10:36 -04:00
rootiest e01e83bd36 docs: add AI Agent Tooling manual section
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m26s
CI / docs (pull_request) Successful in 1m14s
Documents agents-init/agents-vault's concept, purpose, and complete
behavior as its own manual section (16), separate from the auto-generated
function reference: the AGENTS.md convention and CLAUDE.md retirement,
the AGENTS/ sub-repository (layout, versioning, hooks), per-directory
discovery and its four-state normalization, the two safety mechanisms
(discovery containment, deliberately-tracked-file protection), the
plans/specs/devlogs wiring, and the launch lifecycle.

Inserted before Attribution/License (now 17/18) rather than mid-document,
since that's the only placement that doesn't touch any of the manual's
prose cross-references to other section numbers. docs/fish-config.index
updated to match (new keywords, renumbered attribution/license entries;
agents-init and agy were already indexed to their own function-reference
entries and are left pointing there, not redirected to this new page).
2026-09-23 21:47:26 -04:00
rootiest 82bea2f539 fix(gi): stop --stdout from leaking to .gitignore, rework flags
CI / github-mirror (pull_request) Skipped
CI / docs (pull_request) Successful in 20s
CI / test (pull_request) Successful in 2m16s
--stdout silently fell through to the append path whenever gi ran in
its default (no-args) mode, since only the direct-target branch ever
checked it. A fresh-repo `gi --stdout` wrote boilerplate straight to
.gitignore instead of printing it.

Flags also get reworked for consistency:
- -s/--silent: suppress progress output only, errors and prompts still show
- -o/--stdout: print generated content to stdout instead of .gitignore (was -s)
- -f/--force: bypass the interactive prompt, proceed with no patterns

stdout mode also no longer requires a git repo, since it never
touches .gitignore.
2026-09-23 21:40:25 -04:00
rootiest de92277226 fix(agents-init): use --literal-pathspecs so glob characters in a path can't false-match tracked files
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m28s
CI / docs (pull_request) Successful in 1m21s
2026-09-23 21:07:57 -04:00
rootiest 2baa91f275 feat(agents-init): protect deliberately git-tracked instruction files from being replaced
A real AGENTS.md/CLAUDE.md that is in git's index, in a project with a
non-empty .gitignore, is now left in place (with a stderr warning) by
steps 2 and 4 of _agents_init_sync_instructions instead of being moved
into AGENTS/ and symlinked. Discovery also prunes build/, dist/, out/
and target/ outright.
2026-09-23 20:59:55 -04:00
rootiest 6a410a0fce fix(agents-init): contain discovery to the project tree, prevent silent overwrite of real files
CI / test (pull_request) Successful in 2m23s
CI / github-mirror (pull_request) Skipped
CI / docs (pull_request) Successful in 1m19s
2026-09-23 20:12:41 -04:00
rootiest c3f134b5b4 fix(agents-init): tag grep in CLASSIFICATION header 2026-09-23 20:00:52 -04:00
rootiest 4e5f0078ce docs: drop CLAUDE.md wording from wrapper and manual prose 2026-09-23 19:52:25 -04:00
rootiest 4240a04754 fix(agents-init): clean up stale anchored gitignore patterns on migration 2026-09-23 19:46:16 -04:00
rootiest ecfb93a818 feat(agents-init): discover and normalize AGENTS.md/CLAUDE.md in every subdirectory 2026-09-23 19:36:24 -04:00
rootiest a776d4d12d fix(agents-init): add missing branch test coverage and explicit terminal return 2026-09-23 19:32:36 -04:00
rootiest 731661b581 feat(agents-init): add per-directory AGENTS.md/CLAUDE.md sync helper 2026-09-23 19:25:01 -04:00
rootiest c48af2fa23 Merge pull request 'ci: push docs-regen commit with a real account token, not the default bot' (#176) from ci/bot-push-token into main
Reviewed-on: #176
2026-09-23 22:55:11 +00:00
rootiest 028478940f ci: push docs-regen commit with a real account token, not the default bot
CI / test (pull_request) Successful in 2m40s
CI / github-mirror (pull_request) Skipped
CI / docs (pull_request) Successful in 20s
The default `secrets.GITEA_TOKEN` is Gitea Actions' built-in synthetic
bot identity, not a whitelistable user account. main's branch protection
rejects its pushes outright regardless of retries (run 983, run 990) --
the retry/rebase loop in the next step was built for a non-fast-forward
race (run 976), not a bare permission rejection, so it can't recover
from this.

Point the docs job's checkout token at BOT_PUSH_TOKEN, a PAT on the
already-bypass-whitelisted rootiest account, so the later push
succeeds. Commit authorship and GPG signing (fishconfig-bot) are set
separately via git config a few steps later and are unaffected -- push
auth and commit identity are independent.
2026-09-23 18:50:30 -04:00
rootiest 27b6f5d263 Merge pull request 'docs: rename to Rootiest Fish Configuration in README and site title' (#175) from docs/rootiest-fish-configuration-title into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 2m28s
CI / docs (push) Failing after 4m5s
2026-09-23 21:14:18 +00:00
rootiest 59ace6fe6b docs: rename to Rootiest Fish Configuration in README and site title
CI / test (pull_request) Successful in 2m27s
CI / github-mirror (pull_request) Skipped
CI / docs (pull_request) Successful in 1m15s
Both said the generic 'Fish Shell Configuration' -- the site nav already
reads 'Rootiest Fish Config'. Reworded README's opening line to match
the sentiment, not just the name swap.
2026-09-23 16:35:37 -04:00
rootiest 344ee60acd Merge pull request 'docs: reword README's Documentation Site heading to Documentation Wiki' (#174) from docs/readme-documentation-wiki-wording into main 2026-09-23 20:30:12 +00:00
rootiest 1b9f1558c4 docs: reword README's Documentation Site heading to Documentation Wiki
CI / test (pull_request) Successful in 19s
CI / github-mirror (pull_request) Skipped
CI / docs (pull_request) Successful in 1m23s
Missed in the earlier site-to-wiki wording sweep -- line 8's Project Wiki
link already used the new term, this heading further down didn't.
2026-09-23 16:28:54 -04:00
rootiest ea1575f902 Merge pull request 'ci: gate docs job on PRs by label/path, gate test job by label too' (#173) from ci/label-and-path-gated-docs-and-tests into main
Reviewed-on: #173
2026-09-23 20:27:18 +00:00
rootiest 4797af85f3 ci: gate docs job on PRs by label/path, gate test job by label too
CI / test (pull_request) Successful in 2m34s
CI / github-mirror (pull_request) Skipped
CI / docs (pull_request) Successful in 24s
Adds scripts/** to the push path filter -- it was missing entirely, so
that directory never triggered CI regardless of what changed there.

pull_request no longer has a paths: filter (moved that check inside each
job, in shell, since a label-only PR with no relevant diff still needs
to trigger the workflow for the job-level label check to ever run).
Added labeled/unlabeled to pull_request types for the same reason.

Splits the old build-docs job into a docs job with two sections: doc
tests/build (generate concat, verify-manual.py, compile man page) run
whenever relevant on any event; publish (site build, Cloudflare deploy,
commit-back) is step-gated to push/dispatch only, as before. The docs
job now also runs on a PR when it's labeled Kind/Documentation or
Area/Docs, or its diff touches docs/manual/**, docs/build-manual.py,
docs/manualtools.py, docs/verify-manual.py, or docs/site/**.

test gains the same shape: also runs on a PR labeled Kind/Testing,
Area/Tests, Area/CI, or Area/Scripts, independent of what it touches.

docs no longer needs: test. main's branch protection already requires
test to pass before a PR merges, so by the time a push-to-main reaches
this job, test has necessarily already passed; re-checking it here
would be redundant. Leaves the same gap as
block_admin_merge_override=false on that rule: a direct admin push
bypasses it, an accepted trust boundary, not a new one.
2026-09-23 16:18:29 -04:00
rootiest 6074687a80 Merge pull request 'feat: add gitignore-scrub to catch tracked files newly matched by .gitignore' (#172) from feat/gitignore-scrub into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 2m31s
CI / build-docs (push) Failing after 4m20s
Reviewed-on: #172
2026-09-23 20:04:06 +00:00
rootiest 7e3d48ddac feat: add -r/--reset, -f/--force, -i/--individual to gitignore-scrub
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m15s
CI / build-docs (pull_request) Skipped
-r clears the repo's skip list first so declined files are reconsidered.
-f untracks every pending match immediately, no prompt. -i prompts once
per file instead of once for the whole group. -w, -f, -i are mutually
exclusive (argparse --exclusive), -r combines with any of them.
2026-09-23 15:52:01 -04:00
rootiest 37fea155b3 chore: untrack fisher-managed files left tracked since initial commit
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m36s
CI / build-docs (pull_request) Skipped
completions/fisher.fish and functions/fisher.fish match the Fisher-managed
ignore rule added in c77a52a but were never scrubbed from the index. Found
by gitignore-scrub itself (gi fish). Files remain on disk, untracked only.
2026-09-23 15:44:24 -04:00
rootiest 355688c134 feat: add gitignore-scrub to catch tracked files newly matched by .gitignore
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m26s
CI / build-docs (pull_request) Skipped
Standalone function, not gi-private: default mode prompts once for all
tracked-but-ignored files and offers git rm --cached, remembering a
decline per-path in the repo's local git config (gitignore-scrub.skip)
so the same file isn't re-asked. -w/--warn is read-only (prints Warning
lines, no prompt, no mutation) for non-interactive callers like a git
hook. Skips silently above $GITIGNORE_SCRUB_LIMIT tracked files (default
5000) to avoid latency on huge repos.

gi now calls gitignore-scrub at the end of any run that touched
.gitignore.
2026-09-23 14:59:58 -04:00
rootiest 9e8d29cc30 Merge pull request 'fix: guard and document the external tools PR #168 flagged as unguarded' (#170) from docs/guard-external-deps into main
CI / github-mirror (push) Skipped
CI / test (push) Failing after 53s
CI / build-docs (push) Skipped
2026-09-23 03:00:00 +00:00
rootiest af65092102 Merge pull request 'ci: retry the generated-docs push through a rebase on rejection' (#171) from ci/retry-docs-commit-push into main 2026-09-23 02:59:52 +00:00
rootiest 35a48ac868 ci: retry the generated-docs push through a rebase on rejection
build-docs's auto-commit step was seen rejected as non-fast-forward
(run 976, sha 3bbda31): npm ci + astro build + the Cloudflare Pages
deploy ahead of it can take several minutes, long enough for another
PR to merge into main first. A bare `git push` has no way to recover
from that -- the whole job just fails, even though every real step
(tests, manual verification, man page, site build, deploy) already
succeeded.

This commit only ever touches three generated files
(fish-config.md/.1, the component registry), so a rebase onto
whatever landed is always mechanical -- retry push up to 3 times,
rebasing onto origin/main between attempts. Ends on an explicit
`test "$pushed" -eq 1` rather than trailing off the for loop, so a
run that exhausts all three retries still fails loudly instead of
reporting success.
2026-09-22 22:54:47 -04:00
rootiest 8dcbc62359 fix: guard and document the external tools PR #168 flagged as unguarded
PR #168's Notes section named several functions with a real
external-tool dependency that no `type -q`/`command -q`/`command -v`/
`which` guard covers anywhere in the tree, deliberately left out of
DEPENDENCIES to avoid breaking test_dependencies_resolve. Adds the
guard each was missing, then declares the dependency now that it
resolves:

- bkg, detach: nohup
- gitui: gitui (self-shadow; type -q -f to skip the function itself)
- play-media: mpv, vlc -- already guarded via `type -q -f $p` in a
  loop, just never recognized as one (see next point)
- steam-dl: systemd-inhibit, steam
- wake-lock: systemd-inhibit
- split, spwin, tab: wezterm, konsole (kitty already declared)

docs/verify-manual.py's guard-detection regex only matched `type -q
<name>` immediately, so `type -q -f $p` (the `-f` flag excludes
functions from the match, needed wherever a wrapper shadows a binary
of its own name) was invisible to it -- both as a direct guard and
through the loop-variable indirection. Broadened both patterns to
skip over any flags between `-q` and the name/variable.

split/spwin/tab dispatch on $TERM/$TERM_PROGRAM/$KONSOLE_VERSION to
pick which terminal-specific binary to call, per this repo's C4
convention -- but those env vars only prove the terminal type, not
that its CLI binary is on $PATH: they propagate over ssh, so sshing
out from Kitty/WezTerm inherits the var on a remote host that never
installed the binary. Same latent gap in clone/clonet, whose
clone-in-kitty is a function Kitty's own shell integration injects,
not present on a remote shell that only inherited $TERM. All five now
check the actual thing they are about to call, not just the env var
that selects it.

Also guards and documents three more real, previously-undeclared
dependencies found by the same audit, unrelated to PR #168's named
list but the identical pattern: fast-cli (fast), lock (loginctl),
ports (lsof), screensleep (busctl).

docs/fish-config.md regenerated to match.
2026-09-22 22:40:33 -04:00
rootiest 17a95abebe Merge pull request 'ci: trigger CI on PR creation, gate build-docs to push/dispatch only' (#169) from ci/pr-trigger into main 2026-09-23 02:33:12 +00:00
rootiest b03ba7490e ci: trigger CI on PR creation, gate build-docs to push/dispatch only
Adds a pull_request trigger (same path filters, YAML anchor to share
them with push) so branches get CI feedback before merge instead of
only after. build-docs is excluded on pull_request: it auto-commits
generated docs straight to the checked-out ref and deploys the
Cloudflare Pages production site with --branch=main, neither of which
should run against PR content that is not main yet.
2026-09-22 22:32:11 -04:00
rootiest 3bbda31eff Merge pull request 'docs(functions): add DEPENDENCIES sections to doc headers' (#168) from claude/function-docs-dependencies-bfnnag into main
CI / github-mirror (push) Skipped
CI / test (push) Successful in 3m18s
CI / build-docs (push) Failing after 4m42s
Reviewed-on: #168
2026-09-23 02:31:09 +00:00
41 changed files with 2314 additions and 498 deletions
+136 -12
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
paths:
paths: &ci-paths
- "docs/manual/**"
- "docs/build-manual.py"
- "docs/manualtools.py"
@@ -16,6 +16,17 @@ on:
- "completions/**"
- "integrations/**"
- "tests/**"
- "scripts/**"
pull_request:
branches:
- main
# No `paths:` filter here (unlike push, above): a PR carrying
# Kind/Testing, Area/Docs, etc. must still trigger this workflow even
# when its diff touches nothing in ci-paths, or the label-based gates
# in the test/docs jobs below would never get a chance to evaluate.
# `labeled`/`unlabeled` cover a label added after the PR is already
# open, without a new commit.
types: [opened, synchronize, reopened, labeled, unlabeled]
workflow_dispatch:
inputs:
job:
@@ -26,7 +37,7 @@ on:
options:
- all
- test
- build-docs
- docs
jobs:
# This workflow file is mirrored to GitHub as-is, but the runner label
@@ -44,8 +55,41 @@ jobs:
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
fetch-depth: 0
# Runs unconditionally and always sets an output, so every step
# after it can gate on a single `steps.relevance.outputs.run`
# check instead of repeating the label/path OR-chain everywhere.
# push/workflow_dispatch are always relevant -- push is already
# path-filtered above, and a manual dispatch is explicit intent.
# A pull_request is relevant if it carries a testing-related label
# (independent of what it touches -- see the `on.pull_request`
# comment above) or if its diff touches a ci-paths pattern (the
# same list the push trigger above filters on; duplicated here in
# shell glob form since a PR event isn't pre-filtered by paths).
- name: Determine relevance
id: relevance
env:
PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
run: |
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "run=true" >>"$GITHUB_OUTPUT"
exit 0
fi
if printf '%s' "$PR_LABELS" | grep -qE '"name":[[:space:]]*"(Kind/Testing|Area/Tests|Area/CI|Area/Scripts)"'; then
echo "run=true" >>"$GITHUB_OUTPUT"
exit 0
fi
git fetch origin "${{ github.event.pull_request.base.ref }}"
if git diff --name-only "origin/${{ github.event.pull_request.base.ref }}...HEAD" \
| grep -qE '^(docs/manual/|docs/build-manual\.py$|docs/manualtools\.py$|docs/verify-manual\.py$|docs/site/|functions/|conf\.d/|config\.fish$|completions/|integrations/|tests/|scripts/)'; then
echo "run=true" >>"$GITHUB_OUTPUT"
else
echo "run=false" >>"$GITHUB_OUTPUT"
fi
- name: Install fish
if: steps.relevance.outputs.run == 'true'
run: |
sudo apt-get -o Acquire::Retries=3 update -qq
# apt-utils, so debconf has a target for the "delaying package
@@ -63,16 +107,27 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y fish
- name: Run fish config tests
if: steps.relevance.outputs.run == 'true'
run: fish tests/run-tests.fish
build-docs:
needs: test
# Documentation tests/build, and (push/dispatch only) publish. Split
# into two sections within one job rather than two jobs: the publish
# steps need the files the build steps just generated, and passing
# those between separate jobs would need upload/download-artifact for
# no real benefit here.
#
# Does NOT `need: test` (the old build-docs job did, gating publish
# on it). main now has branch protection requiring the test job to
# pass before a PR can merge, so by the time a push-to-main reaches
# this job, test has already passed as a condition of getting here --
# re-checking it in-workflow would be redundant. The one gap that
# leaves is a direct admin push bypassing the PR flow entirely; that's
# the same trust already extended by leaving block_admin_merge_override
# off on the branch protection rule, not a new hole.
docs:
if: |
github.server_url != 'https://github.com' &&
always() &&
(github.event.inputs.job == 'build-docs' ||
((github.event_name != 'workflow_dispatch' || github.event.inputs.job == 'all') &&
needs.test.result == 'success'))
(github.event_name != 'workflow_dispatch' || github.event.inputs.job == 'all' || github.event.inputs.job == 'docs')
runs-on: racknerd-mini
env:
# Silences Node's internal "punycode module is deprecated" notice
@@ -83,9 +138,43 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
# The default GITEA_TOKEN is Gitea Actions' built-in synthetic
# bot identity, not a real account -- main's branch protection
# rejects its pushes outright (run 983, run 990), and it can't
# be whitelisted because it isn't an addable user. BOT_PUSH_TOKEN
# is a PAT on the rootiest account (already bypass-whitelisted)
# used only so this job's later push succeeds; commit authorship
# and GPG signing below still use the fishconfig-bot identity,
# which is unrelated to push auth.
token: ${{ secrets.BOT_PUSH_TOKEN }}
fetch-depth: 0
# Same shape as the test job's identical step; see its comment.
# Only the label set and path patterns differ, narrowed to the
# docs-specific subset of ci-paths.
- name: Determine relevance
id: relevance
env:
PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
run: |
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "run=true" >>"$GITHUB_OUTPUT"
exit 0
fi
if printf '%s' "$PR_LABELS" | grep -qE '"name":[[:space:]]*"(Kind/Documentation|Area/Docs)"'; then
echo "run=true" >>"$GITHUB_OUTPUT"
exit 0
fi
git fetch origin "${{ github.event.pull_request.base.ref }}"
if git diff --name-only "origin/${{ github.event.pull_request.base.ref }}...HEAD" \
| grep -qE '^(docs/manual/|docs/build-manual\.py$|docs/manualtools\.py$|docs/verify-manual\.py$|docs/site/)'; then
echo "run=true" >>"$GITHUB_OUTPUT"
else
echo "run=false" >>"$GITHUB_OUTPUT"
fi
- name: Install dependencies
if: steps.relevance.outputs.run == 'true'
run: |
sudo apt-get -o Acquire::Retries=3 update -qq
# apt-utils: see the "Install fish" step's identical comment in
@@ -97,6 +186,7 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y pandoc python3-yaml fish
- name: Generate concatenated markdown
if: steps.relevance.outputs.run == 'true'
run: python3 docs/build-manual.py --concat -o docs/fish-config.md
# Regeneration MUST run before verification: verify-manual.py's
@@ -104,13 +194,19 @@ jobs:
# against docs/fish-config.md on disk. Before this step ran, that
# file was still the stale pre-push copy, so any ordinary edit under
# docs/manual/** failed the round-trip check before anything was
# regenerated. Do not reorder this back verification still gates
# regenerated. Do not reorder this back -- verification still gates
# pandoc and the auto-commit below, it just no longer requires a
# contributor to hand-sync the generated file before pushing.
#
# This is the "documentation tests" section: on a PR, it runs
# (and can fail the job) whenever relevant, without needing the
# publish steps below to run at all.
- name: Verify manual integrity
if: steps.relevance.outputs.run == 'true'
run: python3 docs/verify-manual.py
- name: Compile man page
if: steps.relevance.outputs.run == 'true'
run: |
pandoc --standalone \
--from markdown \
@@ -118,21 +214,30 @@ jobs:
docs/fish-config.md \
-o docs/fish-config.1
# ──────────────────────── Publish only ───────────────────────
# Everything below deploys the production site and commits
# generated files straight to the checked-out branch. Never runs
# from a pull_request -- PR content isn't main yet, and a
# fork/branch push shouldn't touch prod.
- name: Set up Node
if: github.event_name != 'pull_request'
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Generate site content
if: github.event_name != 'pull_request'
run: python3 docs/build-manual.py --site
- name: Build project wiki
if: github.event_name != 'pull_request'
working-directory: docs/site
run: |
npm ci --no-fund
npx astro build
- name: Deploy to Cloudflare Pages
if: github.event_name != 'pull_request'
working-directory: docs/site
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
@@ -144,6 +249,7 @@ jobs:
--commit-dirty=true
- name: Commit generated docs
if: github.event_name != 'pull_request'
env:
BOT_GPG_KEY: ${{ secrets.CI_GPG_PRIVATE_KEY }}
run: |
@@ -171,7 +277,25 @@ jobs:
git add docs/fish-config.md docs/fish-config.1 conf.d/__fish_config_op_registry.fish
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "chore(docs): regenerate manual, man page, and component registry"
git push
# npm ci + astro build + the Cloudflare deploy above can take
# several minutes, so main can move (another PR merges) before
# this push lands -- a bare `git push` was seen rejected as
# non-fast-forward for exactly that reason (run 976). This
# commit only ever touches generated files, so a rebase onto
# whatever landed is always mechanical; retry it a few times
# against a live race instead of failing the whole job.
pushed=0
for attempt in 1 2 3; do
if git push; then
pushed=1
break
fi
echo "push rejected (attempt $attempt/3), rebasing onto origin/main..." >&2
git fetch origin main
git rebase origin/main
done
test "$pushed" -eq 1
# Stand-in for the GitHub mirror so the commit gets a completed status
# instead of the real jobs above sitting queued forever for a
@@ -183,4 +307,4 @@ jobs:
- name: Note that CI runs on Gitea
run: |
echo "This repository mirrors from Gitea (git.rootiest.dev), where CI actually runs."
echo "See the commit's status on the Gitea instance for the real test/build-docs results."
echo "See the commit's status on the Gitea instance for the real test/docs results."
+43 -5
View File
@@ -391,7 +391,7 @@ all optional except where noted:
| `CATEGORY` | **Required to appear in the manual at all** — see below. |
| `COMPONENT` | Only for functions gated by the [opinionated-component system](#opinionated-components). |
| `DEPENDENCIES` | Other functions, and external CLI tools, this one needs for full functionality — required or optional-with-fallback alike. |
| `CLASSIFICATION` | Hazard/shadow-interaction tags — see below. |
| `CLASSIFICATION` | Hazard/shadow-interaction tags, plus a couple of general-purpose ones (`manual-section`) — see below. |
| `SYNOPSIS` | One-line usage form. |
| `DESCRIPTION` | Prose description; can span multiple paragraphs. |
| `ARGUMENTS` | Flags/positional args, one per line. |
@@ -447,13 +447,51 @@ If your function genuinely doesn't fit any of these, add a new
`docs/manual/05-functions/NN-your-category.md` stub (with frontmatter
matching its siblings) rather than force-fitting it into an existing one.
**`CLASSIFICATION` flags hazards and shadow interactions, optional and
omitted when nothing applies:** whether the function calls a
**`CLASSIFICATION` is the general-purpose tag field, optional and omitted
when nothing applies:** mostly hazards and shadow interactions — whether
the function calls a
[C1-shadowed command](docs/manual/08-components-reference/01-c1-command-shadows.md)
bare wanting the override (`uses-shadow(ls)`) or bypasses it deliberately
via `command`/`builtin` (`bypasses-shadow(cat)`), and general hazards —
`destructive`, `network`, `blocking-prompt`. Full tag definitions and
placement rule: [`docs/function-classification-schema.md`](docs/function-classification-schema.md).
`destructive`, `network`, `blocking-prompt` — but not exclusively: a
function with its own dedicated manual section (below) carries
`manual-section(<slug>)` here too, so that fact is grep-able without
reading every `NOTES` field. Full tag definitions and placement rule:
[`docs/function-classification-schema.md`](docs/function-classification-schema.md).
### Dedicated manual sections for complex subsystems
A doc-header's `DESCRIPTION` is for that one function's usage — it stops
being the right place once a subsystem spans several functions, has its
own file layout, or has enough behavior (a decision table, a safety
model) that cramming it into one function's header would make that
header useless as a quick reference. When that happens, give the
subsystem its own numbered top-level section under `docs/manual/`
(follow the sibling sections' frontmatter shape: `title`, `manTitle`,
`sidebar.order`, `helpKeywords`) instead of stretching the header.
`docs/manual/16-agent-tooling.md` (`agents-init`/`agents-vault`/the
`AGENTS/` sub-repository) is the existing example — its own doc-headers
stay short and point there for the full picture, the same way this
document points at other reference files rather than repeating them.
A function with a dedicated section carries `manual-section(<slug>)` in
its own `# CLASSIFICATION` (see `functions/agents-init.fish`; full tag
definition in
[`docs/function-classification-schema.md`](docs/function-classification-schema.md))
— that's what makes the section discoverable without reading every
function's `NOTES` by hand, and it's what `docs/build-manual.py` reads to
render the "See also" line on the function's generated entry.
`docs/verify-manual.py` fails the build if the slug doesn't resolve to a
real page, so a typo or a renamed file can't go unnoticed — but it cannot
check the *content* is current. A `# NOTES` line pointing at the same page
(see the existing example) is worth adding too, for a reader who only
reads the header text rather than the generated docs, but the tag is the
part something else actually verifies.
This is a genuine exception to "the doc-header is the single source of
truth" above. **Whenever you change what one of these functions does,
update its dedicated section in the same commit or pull request** — not
as a follow-up.
### Private/internal helper functions
+6 -5
View File
@@ -1,8 +1,9 @@
# Fish Shell Configuration
# Rootiest Fish Configuration
A feature-rich Fish shell configuration for CachyOS (Arch Linux),
built around a Catppuccin Mocha aesthetic with a curated set of modern
CLI tool integrations, smart shell functions, and a heavily customized
This isn't a generic Fish shell configuration — it's the Rootiest Fish
Configuration: a feature-rich setup for CachyOS (Arch Linux), built
around a Catppuccin Mocha aesthetic with a curated set of modern CLI
tool integrations, smart shell functions, and a heavily customized
abbreviation system for keyboard-driven workflows.
📖 **[Project Wiki](https://fish.rootiest.fyi/)**
@@ -132,7 +133,7 @@ silent until you enable logging.
## Documentation
### [📖 Documentation Site](https://fish.rootiest.fyi/)
### [📖 Documentation Wiki](https://fish.rootiest.fyi/)
A Starlight-powered site rebuilt on every push to `main`. It covers
configuration variables, key bindings, abbreviations, all functions, the
-7
View File
@@ -1,7 +0,0 @@
complete --command fisher --exclusive --long help --description "Print help"
complete --command fisher --exclusive --long version --description "Print version"
complete --command fisher --exclusive --condition __fish_use_subcommand --arguments install --description "Install plugins"
complete --command fisher --exclusive --condition __fish_use_subcommand --arguments update --description "Update installed plugins"
complete --command fisher --exclusive --condition __fish_use_subcommand --arguments remove --description "Remove installed plugins"
complete --command fisher --exclusive --condition __fish_use_subcommand --arguments list --description "List installed plugins matching regex"
complete --command fisher --exclusive --condition "__fish_seen_subcommand_from update remove" --arguments "(fisher list)"
+119
View File
@@ -0,0 +1,119 @@
# ╭──────────────────────────────────────────────────────────╮
# │ GitIgnore Boilerplate Template │
# ╰──────────────────────────────────────────────────────────╯
#
# ──────────────────── OS-Generated Files ────────────────────
# automatic backup files created by some editors (e.g., Vim, Emacs)
*~
# temporary files created if a process still has a handle to a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# MacOS junk
.DS_Store
Thumbs.db
# Linux trash folder which might appear on any partition or disk
.Trash-*
# files created when an open file is removed but is still being accessed
.nfs*
# ─────────────────── Debug/Temporary/Testing ────────────────
# Matches OLD / .OLD
[Oo][Ll][Dd]/
.[Oo][Ll][Dd]/
# Matches DISABLE / .DISABLE
[Dd][Ii][Ss][Aa][Bb][Ll][Ee]/
.[Dd][Ii][Ss][Aa][Bb][Ll][Ee]/
# Matches DISABLED / .DISABLED
[Dd][Ii][Ss][Aa][Bb][Ll][Ee][Dd]/
.[Dd][Ii][Ss][Aa][Bb][Ll][Ee][Dd]/
# Matches DEBUG / .DEBUG
[Dd][Ee][Bb][Uu][Gg]/
.[Dd][Ee][Bb][Uu][Gg]/
# Matches TMP / .TMP
[Tt][Mm][Pp]/
.[Tt][Mm][Pp]/
# Matches TEMP / .TEMP
[Tt][Ee][Mm][Pp]/
.[Tt][Ee][Mm][Pp]/
# Matches TEMPORARY / .TEMPORARY
[Tt][Ee][Mm][Pp][Oo][Rr][Aa][Rr][Yy]/
.[Tt][Ee][Mm][Pp][Oo][Rr][Aa][Rr][Yy]/
# Matches TESTING / .TESTING
[Tt][Ee][Ss][Tt][Ii][Nn][Gg]/
.[Tt][Ee][Ss][Tt][Ii][Nn][Gg]/
# ─────────────────── Scratchpad / Scratch Files ───────────────
# Root-only files/folders starting with scratch or .scratch
/[Ss][Cc][Rr][Aa][Tt][Cc][Hh]*
/.[Ss][Cc][Rr][Aa][Tt][Cc][Hh]*
# Matches SCRATCH / .SCRATCH anywhere (directory)
[Ss][Cc][Rr][Aa][Tt][Cc][Hh]/
.[Ss][Cc][Rr][Aa][Tt][Cc][Hh]/
# Matches SCRATCHPAD / .SCRATCHPAD anywhere (file or directory)
[Ss][Cc][Rr][Aa][Tt][Cc][Hh][Pp][Aa][Dd]
.[Ss][Cc][Rr][Aa][Tt][Cc][Hh][Pp][Aa][Dd]
# Matches any directory starting with .SCRATCH anywhere
.[Ss][Cc][Rr][Aa][Tt][Cc][Hh]*/
# ─────────────────── Dev Notes / Working Notes ────────────────
# Root-only files/folders starting with devnote or .devnote
/[Dd][Ee][Vv][Nn][Oo][Tt][Ee]*
/.[Dd][Ee][Vv][Nn][Oo][Tt][Ee]*
# Matches DEVNOTE / .DEVNOTE anywhere (file or directory)
[Dd][Ee][Vv][Nn][Oo][Tt][Ee]
.[Dd][Ee][Vv][Nn][Oo][Tt][Ee]
# Matches DEVNOTES / .DEVNOTES anywhere (file or directory)
[Dd][Ee][Vv][Nn][Oo][Tt][Ee][Ss]
.[Dd][Ee][Vv][Nn][Oo][Tt][Ee][Ss]
# Matches any directory starting with .DEVNOTE anywhere
.[Dd][Ee][Vv][Nn][Oo][Tt][Ee]*/
# ─────────────────── AI Sessions and Rules ──────────────────
# CLAUDE.md / AGENTS.md / GEMINI.md / ANTIGRAVITY.md are deliberately NOT
# ignored here: an increasing number of projects commit these instruction
# files on purpose. Only the tool-owned session/state dirs are ignored.
# Matches .claude* anywhere (files or directories)
.[Cc][Ll][Aa][Uu][Dd][Ee]*
# Matches .gemini* anywhere (files or directories)
.[Gg][Ee][Mm][Ii][Nn][Ii]*
# Matches .antigravity* anywhere (files or directories)
.[Aa][Nn][Tt][Ii][Gg][Rr][Aa][Vv][Ii][Tt][Yy]*
# Matches .AGY* anywhere (files or directories)
.[Aa][Gg][Yy]*
# Matches .agents* anywhere (files or directories)
.[Aa][Gg][Ee][Nn][Tt][Ss]*
# Matches .REMEMBER* anywhere (files or directories)
.[Rr][Ee][Mm][Ee][Mm][Bb][Ee][Rr]*
# ──────────────────── Planning Artifacts ───────────────────
# Catalog files generated by pre-implementation analysis passes
.superpowers
docs/superpowers
docs/specs
docs/devlogs
# ──────────────────────────────────────────────────────────────
+66 -8
View File
@@ -202,7 +202,7 @@ def build_concat(root: Path) -> str:
Only bodies are passed: the pandoc metadata block above is not prose
and must survive byte-for-byte.
"""
entries = build_entries(mt.parse_functions(FUNCTIONS))
entries = build_entries(mt.parse_functions(FUNCTIONS), root=root)
chunks: list[str] = []
pandoc_path = root / "_pandoc.yml"
if pandoc_path.exists():
@@ -726,7 +726,44 @@ def _classification_tags(raw: list[str]) -> list[str]:
return [t for t in tags if t]
def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str:
MANUAL_SECTION_RE = re.compile(r"^manual-section\(([\w./-]+)\)$")
def _manual_section_slug(tags: list[str]) -> str | None:
"""Pull the slug out of a `manual-section(<slug>)` CLASSIFICATION tag, if present."""
for tag in tags:
m = MANUAL_SECTION_RE.match(tag)
if m:
return m.group(1)
return None
def _resolve_manual_section(root: Path | None, slug: str) -> tuple[str, str, str] | None:
"""Resolve a manual-section(<slug>) tag to (display label, site link, doc-relative path).
Looks for <slug>.md (a top-level single-file section) or <slug>/index.md
(a directory-based section), matching the two shapes docs/manual/
actually uses. The display label is read fresh from the target's own
frontmatter (manTitle, falling back to title) rather than duplicated in
the tag, so a renumbered section never needs its tag updated -- only
the slug (the filename) does, and that only changes if the page itself
is renamed. Returns None -- silently, this is a build, not a check;
verify-manual.py is where a dangling slug is a real failure -- when
<root> is unset or neither candidate exists.
"""
if root is None:
return None
for relpath in (f"{slug}.md", f"{slug}/index.md"):
if (root / relpath).exists():
fm, _ = mt.parse(root / relpath)
label = fm.get("manTitle") or fm.get("title", slug)
return label, f"/{slug}/", relpath
return None
def render_entry(
fn: dict[str, list[str]], used_by: list[str], link=None, root: Path | None = None
) -> str:
"""Render one parsed function header as a manual entry body.
Emits the same man-page shape Section 5 was authored in — one 4-space
@@ -759,15 +796,22 @@ def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str
def names(raw: list[str]) -> list[str]:
return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n]
classification = _classification_tags(fn.get("CLASSIFICATION", []))
refs = []
for label, values in (
("Dependencies", names(fn.get("DEPENDENCIES", []))),
("Classification", _classification_tags(fn.get("CLASSIFICATION", []))),
("Classification", classification),
("Used by", sorted(used_by)),
):
if values:
rendered = ", ".join(link(v) if link else f"`{v}`" for v in values)
refs.append(f"**{label}:** {rendered}")
slug = _manual_section_slug(classification)
if slug:
resolved = _resolve_manual_section(root, slug)
if resolved:
label, _href, relpath = resolved
refs.append(f"**See also:** {label} (`docs/manual/{relpath}`)")
if refs:
block += "\n\n" + "\n\n".join(refs)
return block
@@ -871,7 +915,9 @@ SITE_SECTIONS = (
)
def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) -> str:
def render_entry_site(
fn: dict[str, list[str]], used_by: list[str], link=None, root: Path | None = None
) -> str:
"""Render one parsed function header as a manual entry body for the site.
Unlike `render_entry` (the single indented man-page block pandoc wants,
@@ -905,15 +951,22 @@ def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) -
def names(raw: list[str]) -> list[str]:
return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n]
classification = _classification_tags(fn.get("CLASSIFICATION", []))
refs = []
for label, values in (
("Dependencies", names(fn.get("DEPENDENCIES", []))),
("Classification", _classification_tags(fn.get("CLASSIFICATION", []))),
("Classification", classification),
("Used by", sorted(used_by)),
):
if values:
rendered = ", ".join(link(v) if link else f"`{v}`" for v in values)
refs.append(f"**{label}:** {rendered}")
slug = _manual_section_slug(classification)
if slug:
resolved = _resolve_manual_section(root, slug)
if resolved:
label, href, _relpath = resolved
refs.append(f"**See also:** [{label}]({href})")
if refs:
parts.append("\n\n".join(refs))
@@ -921,7 +974,7 @@ def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) -
def build_entries(
functions: dict[str, dict], link=None, site: bool = False
functions: dict[str, dict], link=None, site: bool = False, root: Path | None = None
) -> dict[str, list[tuple[str, str]]]:
"""Group rendered entries by category stem, ordered by function name.
@@ -929,6 +982,9 @@ def build_entries(
authored: a bidirectional link maintained by hand drifts the moment one
side is edited. `site` selects `render_entry_site` (headings + tables)
over `render_entry` (the man-page indented block `build_concat` needs).
`root` (docs/manual/) resolves any `manual-section(<slug>)`
CLASSIFICATION tag to that page's own title -- omitted, the default,
an entry with the tag just gets no "See also" line rather than failing.
"""
used_by: dict[str, list[str]] = {}
for name, fn in functions.items():
@@ -940,7 +996,7 @@ def build_entries(
out: dict[str, list[tuple[str, str]]] = {}
for name in sorted(functions):
fn = functions[name]
body = render(fn, used_by.get(name, []), link)
body = render(fn, used_by.get(name, []), link, root=root)
out.setdefault(fn["CATEGORY"][0], []).append((name, body))
return out
@@ -1041,7 +1097,9 @@ def build_site(root: Path, out: Path) -> list[dict]:
out.mkdir(parents=True)
functions = mt.parse_functions(FUNCTIONS)
entries = build_entries(functions, link=lambda n: _entry_link(n, functions), site=True)
entries = build_entries(
functions, link=lambda n: _entry_link(n, functions), site=True, root=root
)
sidebar: list[dict] = [{"label": "Home", "link": "/"}]
standard_groups: dict = {}
+15 -8
View File
@@ -423,14 +423,21 @@ contributing=# 15. CONTRIBUTING
contribute=# 15. CONTRIBUTING
forge=# 15. CONTRIBUTING
# ── Section 16: Attribution ───────────────────────────────────
attribution=# 16. ATTRIBUTION
credits=# 16. ATTRIBUTION
# ── Section 16: AI Agent Tooling ──────────────────────────────
agent=# 16. AI AGENT TOOLING
agent-tooling=# 16. AI AGENT TOOLING
agents.md=# 16. AI AGENT TOOLING
claude-code=# 16. AI AGENT TOOLING
antigravity=# 16. AI AGENT TOOLING
# ── Section 17: License ───────────────────────────────────────
license=# 17. LICENSE
licensing=# 17. LICENSE
agpl=# 17. LICENSE
copyright=# 17. LICENSE
# ── Section 17: Attribution ───────────────────────────────────
attribution=# 17. ATTRIBUTION
credits=# 17. ATTRIBUTION
# ── Section 18: License ────────────────────────────────────────
license=# 18. LICENSE
licensing=# 18. LICENSE
agpl=# 18. LICENSE
copyright=# 18. LICENSE
+65 -18
View File
@@ -1026,11 +1026,13 @@ functions). They are active in all interactive sessions.
Exit Status:
0 Repository cloned
1 Not running inside Kitty terminal
1 Not running inside Kitty terminal, or clone-in-kitty isn't available
Example:
clone https://github.com/user/repo.git
**Dependencies:** `clone-in-kitty`
### clonet
Synopsis: clonet [args...]
@@ -1043,11 +1045,13 @@ functions). They are active in all interactive sessions.
Exit Status:
0 Repository cloned
1 Not running inside Kitty terminal
1 Not running inside Kitty terminal, or clone-in-kitty isn't available
Example:
clonet https://github.com/user/repo.git
**Dependencies:** `clone-in-kitty`
## 5.3 Editors and Viewers
### edit
@@ -1268,34 +1272,43 @@ functions). They are active in all interactive sessions.
### gi
Synopsis: gi [-h] [-b] [-p] [-s] [-l] [targets...]
Synopsis: gi [-h] [-b] [-p] [-o] [-s] [-f] [-c TEMPLATE] [-l] [targets...]
Generates .gitignore content by querying the gitignore.io API. Appends
results to the repository's .gitignore with MD5-based deduplication —
patterns already present are not re-appended — or prints to stdout with
-s. Supports generic boilerplate and interactive prompt modes.
-o/--stdout. Boilerplate mode uses $GITIGNORE_BOILERPLATE if set, a
-c/--custom template if given, or falls back to the bundled standard
template (data/gi/boilerplate.gitignore) when neither is configured.
Supports generic boilerplate and interactive prompt modes.
Arguments:
-h, --help Show help message
-d, --description Show the function description
-l, --list List all supported targets from the API
-b, --boilerplate Append boilerplate from $GITIGNORE_BOILERPLATE
-b, --boilerplate Append boilerplate (implied by -c)
-p, --prompt Prompt for patterns to append
-s, --stdout Print API output to stdout instead of .gitignore
-o, --stdout Print generated content to stdout instead of .gitignore
-s, --silent Suppress progress output (errors and prompts still show)
-f, --force Bypass prompts, proceeding with the default action
-c, --custom PATH Use PATH as the boilerplate template instead of
$GITIGNORE_BOILERPLATE
targets Comma- or space-separated list of language/tool names
Exit Status:
0 Patterns appended, or resolved with -s/--stdout or -l/--list
0 Patterns appended, or resolved with -o/--stdout or -l/--list
1 Not in a git repository or API fetch failed
Returns:
With -s/--stdout, the fetched .gitignore pattern text, printed to stdout.
With -o/--stdout, the fetched .gitignore pattern text, printed to stdout.
With -l/--list, the supported target list, printed to stdout.
Example:
gi python,venv
gi -b -p
gi -s node > .gitignore
gi -o node > .gitignore
gi -f # skip prompt, proceed with no patterns
gi -c ~/my-template.gitignore
**Dependencies:** `curl`, `md5sum`, `md5`
@@ -1335,9 +1348,17 @@ functions). They are active in all interactive sessions.
Arguments:
args... Arguments forwarded to the gitui command
Exit Status:
1 gitui is not installed
* Exit status of gitui otherwise
Example:
gitui
**Dependencies:** `gitui`
**Used by:** `gitui`
### gitup
Synopsis: gitup [args...]
@@ -1758,11 +1779,14 @@ functions). They are active in all interactive sessions.
Locks the current desktop session using loginctl lock-session.
Exit Status:
Exit status of loginctl lock-session
1 loginctl is not installed
* Exit status of loginctl lock-session otherwise
Example:
lock
**Dependencies:** `loginctl`
### ports
Synopsis: ports
@@ -1771,11 +1795,14 @@ functions). They are active in all interactive sessions.
port numbers and addresses without hostname resolution.
Exit Status:
Exit status of lsof
1 lsof is not installed
* Exit status of lsof otherwise
Example:
ports
**Dependencies:** `lsof`
### sbver
Synopsis: sbver [--brief]
@@ -1808,11 +1835,14 @@ functions). They are active in all interactive sessions.
PowerDevil "Turn Off Screen" global shortcut via busctl.
Exit Status:
Exit status of busctl
1 busctl is not installed
* Exit status of busctl otherwise
Example:
screensleep
**Dependencies:** `busctl`
### sudo-toggle
Synopsis: sudo-toggle
@@ -1880,6 +1910,8 @@ functions). They are active in all interactive sessions.
Example:
bkg firefox
**Dependencies:** `nohup`
**Used by:** `md`
### detach
@@ -1903,6 +1935,8 @@ functions). They are active in all interactive sessions.
Example:
detach rsync -a ./data remote:/backup/
**Dependencies:** `nohup`
### fish_mode_prompt
Synopsis: fish_mode_prompt
@@ -2035,7 +2069,7 @@ functions). They are active in all interactive sessions.
split
split -v nvim README.md
**Dependencies:** `kitty`
**Dependencies:** `kitty`, `wezterm`
### spwin
@@ -2054,7 +2088,7 @@ functions). They are active in all interactive sessions.
Example:
spwin
**Dependencies:** `kitty`
**Dependencies:** `kitty`, `wezterm`
### ssh
@@ -2093,7 +2127,7 @@ functions). They are active in all interactive sessions.
Example:
tab
**Dependencies:** `kitty`
**Dependencies:** `kitty`, `wezterm`, `konsole`
## 5.9 Clipboard
@@ -2181,6 +2215,8 @@ functions). They are active in all interactive sessions.
Example:
fast
**Used by:** `fast-cli`
### fast-cli
Synopsis: fast-cli [args...]
@@ -2190,9 +2226,15 @@ functions). They are active in all interactive sessions.
Arguments:
args... Arguments forwarded to the fast command
Exit Status:
1 fast is not installed
* Exit status of fast otherwise
Example:
fast-cli
**Dependencies:** `fast`
### gip
Synopsis: gip
@@ -2992,7 +3034,7 @@ functions). They are active in all interactive sessions.
play-media
play-media --player mpv
**Dependencies:** `_fzf_preview_media`, `_fzf_wrapper`, `fd`, `fdfind`, `file`, `xdg-mime`
**Dependencies:** `_fzf_preview_media`, `_fzf_wrapper`, `fd`, `fdfind`, `file`, `xdg-mime`, `mpv`, `vlc`
### spark
@@ -3022,11 +3064,14 @@ functions). They are active in all interactive sessions.
or sleeping during active downloads.
Exit Status:
Exit status of steam (via systemd-inhibit)
1 systemd-inhibit or steam is not installed
* Exit status of steam (via systemd-inhibit) otherwise
Example:
steam-dl
**Dependencies:** `systemd-inhibit`, `steam`
### yt-dlp
Synopsis: yt-dlp [args...] URL [URL...]
@@ -3615,11 +3660,13 @@ functions). They are active in all interactive sessions.
Exit Status:
0 Command ran and completed
1 No command provided
1 No command provided, or systemd-inhibit is not installed
Example:
wake-lock rsync -avz src/ dest/
**Dependencies:** `systemd-inhibit`
# 6. DEPENDENCY CATALOG
`fish-deps` manages these tools. Run `fish-deps` to check status,
+24
View File
@@ -5,6 +5,14 @@ doc-header label. It's referenced from code comments and commit messages —
link here, not to anything under `AGENTS/` (that tree is git-ignored local
agent state, not part of the repo).
`CLASSIFICATION` isn't limited to command-safety hazards, even though most
of the closed set below is exactly that — it's the general-purpose place to
tag what a function touches or how it behaves, whenever that's worth
surfacing without reading the function's own body. `manual-section(<slug>)`
is the one tag in the set that isn't a hazard at all: it marks a function
that has its own dedicated manual section beyond this header (see
[Dedicated manual sections for complex subsystems](../CONTRIBUTING.md#dedicated-manual-sections-for-complex-subsystems)).
See [Public function documentation header](../CONTRIBUTING.md#public-function-documentation-header)
in `CONTRIBUTING.md` for where `CLASSIFICATION` fits among the other header
labels, and [C1 — Command Shadows](manual/08-components-reference/01-c1-command-shadows.md)
@@ -72,6 +80,22 @@ it empty as a placeholder.
handler, an fzf-driven picker) — the hazard this tag exists for is a
script or another function calling it unexpectedly, not a human running
it themselves.
- **`manual-section(<slug>)`** — this function has a dedicated manual
section beyond its own header; `<slug>` is that page's filename under
`docs/manual/` with the extension dropped (`16-agent-tooling` for
`docs/manual/16-agent-tooling.md`; a directory-based section like
`08-components-reference` uses its directory name the same way, resolved
against its `index.md`). `docs/build-manual.py` reads the target page's
own `manTitle`/`title` at build time and renders a **See also** line on
the function's generated entry — the tag only needs to keep pointing at
the right *file*; the displayed section number is never duplicated into
the tag, so it can't go stale on its own if the manual gets renumbered.
`docs/verify-manual.py` fails if the slug doesn't resolve to a real page.
Multiple functions may carry the same slug (`agents-init` and
`agents-vault` both point at `16-agent-tooling`, one section covering
both). See [Dedicated manual sections for complex subsystems](../CONTRIBUTING.md#dedicated-manual-sections-for-complex-subsystems)
in `CONTRIBUTING.md` for when a function's behavior has outgrown its
header and belongs in one of these instead.
## Placement
@@ -24,7 +24,7 @@ all of these commands.
grep/fgrep/egrep forced --color=auto system grep variants
dir / vdir forced --color=auto system dir / vdir
help config intercepts "help config" → config-help fish builtin help
claude auto-links AGENTS.md as CLAUDE.md before launch command claude
claude ensures AGENTS/ is scaffolded before launch command claude
edit multi-editor launcher (GUI/term + fallbacks) $EDITOR/nvim/nano/vi
When C1 is disabled, `rm` uses bare `command rm` with no wrapper — files
@@ -64,7 +64,7 @@ and the `help config` interception.
## dev-tools
`claude` (AGENTS.md/CLAUDE.md auto-linking) and `edit` (multi-editor
`claude` (AGENTS/ scaffolding) and `edit` (multi-editor
launcher), plus `agy`.
## For function authors
+312
View File
@@ -0,0 +1,312 @@
---
title: AI Agent Tooling
manTitle: 16. AI AGENT TOOLING
sidebar:
order: 20
helpKeywords:
- agent
- agents-init
- agents-vault
- AGENTS.md
- claude-code
- agy
- antigravity
---
This section explains the machinery behind AI coding agents (Claude Code,
Antigravity/agy) working in a project checked out from this configuration:
where their instructions live, how they get there, and the safety rules
that keep an agent's launch-time bookkeeping from touching a repository's
own tracked history. Command-line usage for the functions named here
(`agents-init`, `agents-vault`) is generated from their own doc headers —
see Section 5.
## The problem this solves
An AI coding agent needs a persistent, project-scoped place to keep
instructions, memory, and working notes. Committing that material directly
into a project's normal history mixes two concerns that change at
different rates and for different reasons: the project's own code, and an
agent's evolving working state. It also means every project accumulates
its own copy of agent tooling (hooks, version files, convention
documents) that has nothing to do with that project's actual purpose.
`agents-init` and `agents-vault` exist to keep that material out of the
main repository while still making it feel local: an agent reads and
writes `AGENTS.md` exactly where it would expect to find it, but the real
content and its history live in a separate, self-contained git repository
that the main project never tracks.
## The AGENTS.md convention
`AGENTS.md` is a plain-text file at a project's root (and, as this
configuration extends the idea, at the root of any subdirectory with its
own scoped conventions) that an AI agent reads for repository-specific
instructions. It has become a convention shared across coding agents, not
one tool's proprietary format.
Claude Code originally required its own `CLAUDE.md` filename specifically.
It now reads `AGENTS.md` natively whenever no `CLAUDE.md` is present, which
retired the need for this configuration to create, maintain, or symlink
`CLAUDE.md` at all. A project scaffolded by `agents-init` today carries
only `AGENTS.md` — at the root, and in any subdirectory that has grown its
own scoped conventions (`functions/`, `docs/`, and so on, in this
repository's own case). A leftover `CLAUDE.md` from before this change is
retired automatically the next time `agents-init` runs: renamed, not
preserved under its old name, so nothing is ever left tracking two copies
of the same instructions under two different filenames.
## The AGENTS/ sub-repository
`agents-init` scaffolds a directory named `AGENTS/` at a project's root.
It is a self-contained git repository — its own `.git`, its own commit
history, its own hooks — and it is gitignored from the project it lives
inside. The project's own `AGENTS.md` (and every subdirectory's) is a
symlink into it:
$PROJECT/AGENTS/
├── AGENTS.md Canonical root agent spec (real file)
├── functions/AGENTS.md Canonical spec for functions/, and likewise for any other scoped subdirectory
├── plans/ Superpowers implementation plans
├── specs/ Superpowers design specs
├── devlogs/ Agent development logs
├── .version MAJOR.MINOR.PATCH structure version
└── .agents-tools/ Version-bump script and git hook shims (committed)
An agent editing `$PROJECT/AGENTS.md` is, transparently, editing
`$PROJECT/AGENTS/AGENTS.md` — the file-editing tools most agents ship with
resolve a symlinked directory's contents normally, but they cannot write
*through* a symlinked file itself, which is why the seed content
`agents-init` writes for a brand-new project spells this out directly to
the agent reading it.
IMPORTANT: This means an agent must never try to write to a *symlink
named* `AGENTS.md` directly. The seed instructions `agents-init` writes
for a fresh project tell the agent this explicitly, pointing it at the
real file inside `AGENTS/`.
### Version tracking and hooks
Every `AGENTS/` repository carries a `.version` file (seeded `1.0.0`) and
a self-contained version bumper, wired through `core.hooksPath` rather
than the ordinary `.git/hooks/` directory:
- A **pre-commit** hook bumps `.version` on every commit: the MINOR
field moves when the set of tracked top-level directories changes
(a new subdirectory convention was adopted, or one was dropped), the
PATCH field otherwise. The MAJOR field is manual-only.
- A **prepare-commit-msg** hook appends `(vX.Y.Z)` to the commit
subject, so the version history is legible from `git log` alone.
Each hook shim then chains to whatever hook of the same name the
project's *global* or *system* `core.hooksPath` already points at — a
credential scanner like ggshield, Git LFS, or anything else already
wired in ahead of this. Pointing `core.hooksPath` at `.agents-tools/hooks`
locally does not shadow those; it runs both.
The `.agents-tools/` scripts themselves are copied in from this
configuration's own `scripts/agents-tools/` and refreshed automatically
whenever their version marker moves, so every project's `AGENTS/`
repository stays current with this configuration without any manual step.
Downstream tooling that wants to know whether a project's `AGENTS/`
*structure* changed — as opposed to just its content — can read the
`.version` file's MINOR field directly rather than diffing the tree.
## Per-directory discovery
The convention is not limited to a project's root. Any directory that
carries its own `AGENTS.md``functions/`, `docs/`, or a subdirectory of
a much larger project with genuinely distinct conventions of its own —
gets the identical treatment: a real file inside `AGENTS/<that path>/`,
and a symlink at the project location pointing back to it. `agents-init`
finds these automatically on every run, rather than working from a fixed
list, by walking the project tree for any file literally named
`AGENTS.md` or `CLAUDE.md`.
Each directory found is settled into exactly one of four states, in
order, so a later run only ever sees a directory that is already
consistent:
1. **An inverted mirror** (an older layout, where `CLAUDE.md` was the
real file inside `AGENTS/` and `AGENTS.md` was symlinked to it) is
flipped in place — same bytes, new name.
2. **A real file at the project level, with no real file inside
`AGENTS/` yet**, is adopted: a lone `AGENTS.md` moves in as-is; a
lone `CLAUDE.md` is renamed on the way in, never preserved under its
own name. When both `AGENTS.md` and `CLAUDE.md` are real files at
once, byte-identical content is deduplicated (the `AGENTS.md` side is
kept); different content is left exactly as it is, with a warning —
this function has no way to know which one is authoritative, and
guessing wrong would silently discard the other.
3. **A stray `CLAUDE.md` inside `AGENTS/`** left over once `AGENTS.md`
is settled there is removed — nothing named `CLAUDE.md` survives
inside the mirror.
4. **The project-level symlink** is created or repaired if missing or
stale, and any `CLAUDE.md` still at the project level is removed. A
real file that turns up here *after* the mirror already settled (for
instance, an agent's own `/init`-style command writing a fresh
`CLAUDE.md`) is held to the same identical-or-differ rule as step 2:
a duplicate is dropped, anything different is left alone with a
warning rather than silently overwritten.
## Safety: what discovery will never touch
Because discovery walks the whole project tree rather than a fixed list,
it deliberately prunes several classes of directory before it ever
considers what's inside them:
- **Anything outside the project entirely.** A directory that has no
git repository of its own, but happens to carry a lone `AGENTS.md` or
`CLAUDE.md` (a home directory scaffolded this way, for instance), is
synced at that single location only — no recursive walk runs at all.
Recursive discovery only ever runs inside a real git repository.
- **Nested repositories.** Any subdirectory that is itself a git
repository — a submodule, a nested clone, a plugin checked out inside
a tool's own state directory — belongs to a different project and is
never walked into.
- **Dot-directories.** Anything named starting with `.` (`.git`,
`.claude`, `.gemini`, `.github`, and so on) is a tool's own state or
configuration, not a project's own scoped convention, and is skipped
unconditionally.
- **Generated output.** `build/`, `dist/`, `out/`, and `target/`
directories are never inspected — nothing generated by a build step
is a source of hand-authored instructions.
- **`node_modules/`**, and any directory literally named `AGENTS` other
than the current project's own mirror.
## Safety: deliberately tracked files are left alone
Discovery can reach a directory whose `AGENTS.md` or `CLAUDE.md` is
already committed to the project's own history on purpose — a team's
shared conventions file in a monorepo subdirectory, for instance, tracked
long before this configuration's owner ever cloned it. Replacing that
file with a symlink would change it from an ordinary tracked file into a
link pointing outside the repository the moment `agents-init` next runs,
which is not a decision this tool should make unattended on someone
else's behalf.
A real file is left untouched, instead of adopted or replaced, whenever
**both** of the following hold:
- it is tracked in git's index — staged or committed, checked with
`git ls-files`. A file that has never been `git add`ed is not tracked
by this definition, even if it sits right next to files that are.
- the project's `.gitignore` actually exists and has content in it.
Neither condition alone is enough to protect a file. An untracked file is
always safe to adopt, regardless of what `.gitignore` says about it
(nothing has been committed yet, so nothing is lost). A tracked file in a
project with *no* established ignore conventions at all — no
`.gitignore`, or an empty one — is treated as the very first time this
convention has been applied to that project, rather than a deliberate
choice to keep tracking it: `agents-init` adopts it the same way it would
adopt any other real file, which is the same behavior this tool has
always had for a project's own root file.
NOTE: In practice, this means a mature project with an established
`.gitignore` will have any already-committed `AGENTS.md`/`CLAUDE.md` left
alone across the board — root included — and will only ever adopt one
during that project's first encounter with this convention, before a
`.gitignore` entry for it exists yet.
When a directory is skipped for this reason, `agents-init` prints a
warning naming the file and explaining why, rather than staying silent
about a directory it chose not to touch.
## Scenario reference
Every combination of what a directory can hold, laid out directly. "No"
in the tracked column also covers a tracked file in a project with no
populated `.gitignore` (the bootstrap case, above) — both behave the same
way. Whenever the tracked column reads "Yes", that reason always wins
over the identical-or-different comparison below it, and the warning
printed names the file as tracked rather than as differing — the outcome
(left alone) is the same either way, only the explanation differs.
Settling a directory for the first time — a real `AGENTS.md`, a real
`CLAUDE.md`, both, or neither, discovered fresh:
Found Deliberately tracked? Result
-------------------------------- --------------------- -----------------------------------------------
Only AGENTS.md (real) No Adopted into AGENTS/, symlinked back.
Only AGENTS.md (real) Yes Left exactly as it is; not adopted.
Only CLAUDE.md (real) No Adopted, renamed to AGENTS.md, symlinked back.
Only CLAUDE.md (real) Yes Left exactly as it is; not adopted or renamed.
Both, byte-identical No AGENTS.md adopted; duplicate CLAUDE.md dropped.
Both, byte-identical Yes (either) Left exactly as they are; neither touched.
Both, different content n/a Neither touched; warns, resolve by hand.
Mirror has CLAUDE.md (real) n/a Flipped in place: renamed, nothing lost.
Correct AGENTS.md symlink exists n/a Nothing happens -- already settled.
A new real file appearing after a directory's mirror has already settled
— an agent's own `/init`-style command, for instance, writing a fresh
`CLAUDE.md` where an `AGENTS.md` is already symlinked:
New file vs. mirror Deliberately tracked? Result
------------------- --------------------- --------------------------------------------------------
Byte-identical No Adopted as a duplicate; the new file is dropped.
Byte-identical Yes Left as it is; not adopted, even though content matches.
Different content No Left as it is; warns that it differs, resolve by hand.
Different content Yes Left as it is; warns that it's tracked, not adopted.
And whatever a directory holds, it never gets this far at all if
discovery pruned it outright — see the containment rules above: nested
repositories, dot-directories, `node_modules/`, generated-output
directories, and any directory literally named `AGENTS`.
## plans/, specs/, and devlogs/
`agents-init --plugins` (the second half of what a bare `agents-init` run
does) wires up three more directories inside `AGENTS/`: `plans/` and
`specs/` for the superpowers skills' implementation plans and design
documents, and `devlogs/` for agent-authored development notes. Real
content from every legacy location this configuration has ever used for
these (`docs/plans`, `docs/superpowers/plans`, and an older
`AGENTS/plugins/` layer from before the sub-repository consolidated them)
is merged into the canonical `AGENTS/plans` and `AGENTS/specs` on first
run, and the legacy locations are removed once merged.
`docs/superpowers/plans` and `docs/superpowers/specs` are always
symlinked to their `AGENTS/` counterparts, because the superpowers skills
expect to find them there by default. `docs/plans`, `docs/specs`, and
`docs/devlogs` are only created as symlinks when a project already had a
real directory by that name — nothing forces those paths to exist for a
project that never used them.
## The launch lifecycle
The `claude` and `agy` wrapper functions each run `agents-init --quiet`
(full setup: both the `AGENTS.md` symlink step and the plans/specs/devlogs
wiring) before launching the real CLI, on every invocation. This is what
makes the whole system self-healing: a project that has drifted from the
expected layout — a stale symlink, a newly-added subdirectory's
instructions not yet adopted, a leftover `CLAUDE.md` — is corrected
automatically the next time an agent is launched there, with no separate
setup step for a person to remember.
At the end of every `agents-init` run, any uncommitted change inside
`AGENTS/` is committed automatically, so whatever an agent wrote during
its session is captured without anyone needing to run `git add` on a
repository they were never meant to think about directly. That commit is
strictly local: `agents-init` never fetches or pushes, because a network
round trip running synchronously ahead of every agent launch would block
the launch itself for as long as an unreachable remote takes to time out.
A project's `AGENTS/` repository that has its own upstream is pulled and
pushed by hand, on its owner's own schedule.
`agents-vault` is a related but distinct tool: where `AGENTS/` holds
*one project's* agent state, `agents-vault` backs up curated agent memory
that lives *outside* any project tree entirely — `~/.claude/projects/*/memory`
and similar host-scoped locations — into its own host-scoped repository.
The `claude`/`agy` wrappers sync both on every launch. See
`__fish_agent_vault_autopush` in Section 7 for its one user-facing
configuration variable; command-line usage for both tools is in Section 5.
@@ -1,8 +1,8 @@
---
title: Attribution
manTitle: 16. ATTRIBUTION
manTitle: 17. ATTRIBUTION
sidebar:
order: 20
order: 21
helpKeywords:
- attribution
- credits
@@ -1,8 +1,8 @@
---
title: License
manTitle: 17. LICENSE
manTitle: 18. LICENSE
sidebar:
order: 21
order: 22
helpKeywords:
- license
- licensing
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: Fish Shell Configuration
title: Rootiest Fish Configuration
description: Reference manual for the rootiest fish configuration.
manTitle: DESCRIPTION
sidebar:
+116 -2
View File
@@ -211,12 +211,15 @@ def test_dependencies_resolve():
repo = Path(__file__).parent.parent
functions = _parsed_functions()
known = {p.stem for p in (repo / "functions").glob("*.fish")} | set(functions)
guard_re = re.compile(r"(?:type -q|command -q|command -v|which)\s+([\w.\-]+)")
# `type` takes its own flags (e.g. `-f` to exclude functions from the
# match, as in `type -q -f $p`) that can sit between `-q` and the name
# -- skip over any of those so the guard is still recognized.
guard_re = re.compile(r"(?:type -q(?:\s+-\w+)*|command -q|command -v|which)\s+([\w.\-]+)")
for path in list(repo.glob("conf.d/*.fish")) + list((repo / "functions").glob("*.fish")):
text = path.read_text(encoding="utf-8")
known |= set(guard_re.findall(text))
for var, names in re.findall(r"for\s+(\w+)\s+in\s+([^\n;]+)", text):
if re.search(rf"type -q\s+\$\{{?{re.escape(var)}\}}?\b", text):
if re.search(rf"type -q(?:\s+-\w+)*\s+\$\{{?{re.escape(var)}\}}?\b", text):
known |= set(names.split())
dangling = []
for name, fn in functions.items():
@@ -1671,6 +1674,117 @@ def test_concat_section_five_stays_verbatim():
assert not offenders, f"backticks inside verbatim entries: {offenders[:3]}"
def test_manual_section_slug_extracts_tag():
"""`manual-section(<slug>)` is found among other CLASSIFICATION tags, or not at all."""
import build_manual
assert build_manual._manual_section_slug(["destructive", "manual-section(foo-bar)"]) == "foo-bar"
assert build_manual._manual_section_slug(["network"]) is None
assert build_manual._manual_section_slug([]) is None
def test_resolve_manual_section_reads_target_frontmatter():
"""Resolves both page shapes (top-level file, directory index) and reports None cleanly."""
import build_manual
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "solo.md").write_text(
"---\ntitle: Solo\nmanTitle: 9. SOLO\n---\nbody\n"
)
(root / "grouped").mkdir()
(root / "grouped" / "index.md").write_text(
"---\ntitle: Grouped\nmanTitle: 10. GROUPED\n---\nbody\n"
)
label, href, relpath = build_manual._resolve_manual_section(root, "solo")
assert label == "9. SOLO", label
assert href == "/solo/", href
assert relpath == "solo.md", relpath
label, href, relpath = build_manual._resolve_manual_section(root, "grouped")
assert label == "10. GROUPED", label
assert href == "/grouped/", href
assert relpath == "grouped/index.md", relpath
assert build_manual._resolve_manual_section(root, "missing") is None
assert build_manual._resolve_manual_section(None, "solo") is None
def test_render_entry_see_also_appears_only_when_root_resolves():
"""The man-page See-also line needs both the tag and a root that resolves it."""
import build_manual
fn = {
"SYNOPSIS": ["thing"],
"DESCRIPTION": ["Does a thing."],
"CLASSIFICATION": ["manual-section(deep-dive)"],
}
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "deep-dive.md").write_text(
"---\ntitle: Deep Dive\nmanTitle: 20. DEEP DIVE\n---\nbody\n"
)
out = build_manual.render_entry(fn, [], root=root)
assert "**See also:** 20. DEEP DIVE (`docs/manual/deep-dive.md`)" in out, out
# No root at all -- same as every other existing caller that never
# passes one -- silently omits the line rather than raising.
out_no_root = build_manual.render_entry(fn, [])
assert "See also" not in out_no_root, out_no_root
# A root that exists but doesn't have the target page: also silent.
with tempfile.TemporaryDirectory() as empty:
out_missing = build_manual.render_entry(fn, [], root=Path(empty))
assert "See also" not in out_missing, out_missing
def test_render_entry_site_see_also_is_a_real_link():
"""The site's See-also line is a markdown link to the resolved page's site path."""
import build_manual
fn = {
"SYNOPSIS": ["thing"],
"DESCRIPTION": ["Does a thing."],
"CLASSIFICATION": ["manual-section(deep-dive)"],
}
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "deep-dive.md").write_text(
"---\ntitle: Deep Dive\nmanTitle: 20. DEEP DIVE\n---\nbody\n"
)
out = build_manual.render_entry_site(fn, [], root=root)
assert "**See also:** [20. DEEP DIVE](/deep-dive/)" in out, out
def test_real_manual_section_tags_resolve():
"""Every manual-section(<slug>) tag on a real function points at a real page.
This is the enforcement half of the convention: build-manual.py stays
silent about a dangling slug (it just skips the See-also line), so this
is the only thing that turns a typo'd or stale slug into a failure.
"""
import build_manual
functions = mt.parse_functions(build_manual.FUNCTIONS)
checked = 0
for name, fn in functions.items():
tags = build_manual._classification_tags(fn.get("CLASSIFICATION", []))
slug = build_manual._manual_section_slug(tags)
if slug is None:
continue
checked += 1
resolved = build_manual._resolve_manual_section(build_manual.MANUAL, slug)
assert resolved is not None, (
f"{name}'s manual-section({slug}) tag doesn't resolve to "
f"docs/manual/{slug}.md or docs/manual/{slug}/index.md"
)
assert checked > 0, "expected at least one real function to carry manual-section(...)"
TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
@@ -0,0 +1,45 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# _agents_init_path_is_protected <root> <path>
#
# DESCRIPTION
# Decides whether a real (non-symlink) file should be left alone rather
# than adopted into the AGENTS/ mirror or replaced with a symlink,
# because it looks like a deliberately tracked project file rather than
# an incidental one this project hasn't yet engaged agents-init's
# convention for.
#
# A file is protected only when BOTH are true:
# - it is tracked in git's index at <root> -- staged or committed, via
# `git ls-files`. A file that has never been `git add`ed (even if it
# sits right next to tracked files) is not tracked by this
# definition, and neither is one that is merely gitignored.
# - <root>/.gitignore exists and is non-empty -- a project with no
# ignore rules at all has never engaged with the convention this
# tool manages, so a tracked file there is more likely incidental
# (e.g. the very first agents-init run, before anyone thought to
# ignore it) than a deliberate choice to keep tracking it.
#
# Neither check alone is enough: an untracked file is always safe
# regardless of .gitignore state (nothing has been committed to protect),
# and a tracked file in a project with no established ignore
# conventions is treated as adoptable rather than deliberate.
#
# ARGUMENTS
# root Absolute path to the project root (may or may not be a git repo)
# path Absolute path to the file being considered
#
# EXIT STATUS
# 0 Protected -- leave this file alone
# 1 Not protected -- safe to adopt/replace
#
# EXAMPLE
# _agents_init_path_is_protected /path/to/project /path/to/project/functions/CLAUDE.md
function _agents_init_path_is_protected --argument-names root path
test -n "$root" -a -n "$path"; or return 1
git -C "$root" --literal-pathspecs ls-files --error-unmatch -- "$path" >/dev/null 2>&1; or return 1
test -s "$root/.gitignore"; or return 1
return 0
end
@@ -0,0 +1,251 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# DEPENDENCIES
# _agents_init_path_is_protected
#
# 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.
#
# The exception is a real file that is deliberately git-tracked -- in
# git's index, in a project whose .gitignore is non-empty (see
# _agents_init_path_is_protected). Such a file is never adopted,
# relinked, or removed: whenever steps 2 or 4 find one, they leave that
# directory's instruction files exactly as they are and warn on stderr.
#
# 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. If either real file is protected, both are
# left untouched, the mirror is not populated, and a warning naming
# the protected file(s) goes to stderr. Otherwise 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. A
# real project-level file found here (written after the mirror
# settled) is checked for protection first, as in 2 -- a protected
# one is left alone even if byte-identical to the mirror. An
# unprotected one is removed only if byte-identical to the mirror; if
# it differs, nothing is touched and a warning goes to stderr, as in 2.
#
# 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 and
# protected-file skips, which are not failures 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
# A deliberately git-tracked file is left alone -- and so is its
# sibling, since adopting one of a pair would still relink or drop
# the tracked one.
set -l protected
test $has_agents -eq 1; and _agents_init_path_is_protected "$root" "$proj_agents"; and set -a protected $disp_agents
test $has_claude -eq 1; and _agents_init_path_is_protected "$root" "$proj_claude"; and set -a protected $disp_claude
if set -q protected[1]
echo "_agents_init_sync_instructions: "(string join ', ' -- $protected)" tracked by git; leaving this directory's instruction files untouched" >&2
return 0
end
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
# A real (non-symlink) file here arrived after the mirror settled. Same
# rules as step 2. A deliberately git-tracked one is left alone first,
# even if byte-identical: turning a tracked regular file into a symlink
# is itself a change to it. Otherwise, byte-identical to the mirror is a
# duplicate and is replaced below; different means touch nothing and warn.
set -l protected
test -f "$proj_agents"; and not test -L "$proj_agents"; and _agents_init_path_is_protected "$root" "$proj_agents"; and set -a protected $disp_agents
test -f "$proj_claude"; and not test -L "$proj_claude"; and _agents_init_path_is_protected "$root" "$proj_claude"; and set -a protected $disp_claude
if set -q protected[1]
echo "_agents_init_sync_instructions: "(string join ', ' -- $protected)" tracked by git; leaving this directory's instruction files untouched" >&2
return 0
end
for f in $proj_agents $proj_claude
if test -f "$f"; and not test -L "$f"
if not command diff -q "$f" "$mirror_agents" >/dev/null 2>&1
echo "_agents_init_sync_instructions: $f and $mirror_agents differ; leaving both, resolve by hand" >&2
return 0
end
end
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
return 0
end
+105 -101
View File
@@ -5,10 +5,10 @@
# 12-ai-and-developer-tools
#
# DEPENDENCIES
# _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore
# _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore
#
# CLASSIFICATION
# self-limiting(rm,mkdir), bypasses-shadow(mv)
# self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling)
#
# SYNOPSIS
# agents-init [-a | --agents] [-p | --plugins] [-v | --verbose]
@@ -17,8 +17,18 @@
# DESCRIPTION
# Scaffolds an AGENTS/ sub-repository inside a project directory. Creates
# a self-contained git repo for agent specifications, moves any existing
# agent-related files into it, and replaces them with symlinks so the outer
# project never tracks agent files directly.
# agent-related files into it, and replaces them with symlinks so the
# outer project never tracks agent files directly. This applies at the
# project root and, automatically, to any subdirectory that carries its
# own scoped AGENTS.md or CLAUDE.md -- discovered by scanning the tree,
# not a hardcoded list. The scan prunes dot-directories (.git/, .claude/,
# ...), nested repos, AGENTS/ itself, node_modules/, and generated-output
# directories (build/, dist/, out/, target/).
#
# A real instruction file that the project deliberately tracks -- in
# git's index, in a project whose .gitignore is non-empty -- is left
# exactly where it is, with a warning, rather than moved into AGENTS/ and
# replaced by a symlink. See _agents_init_path_is_protected.
#
# Scaffolding runs only inside a git repository, or in a directory that
# already has an AGENTS.md, CLAUDE.md, or AGENTS/. Elsewhere it is a
@@ -26,11 +36,12 @@
# create a repository there.
#
# File layout after setup:
# AGENTS/AGENTS.md canonical agent spec (real file)
# AGENTS/CLAUDE.md real file (if CLAUDE.md existed separately)
# or symlink → AGENTS.md (single-source case)
# AGENTS/AGENTS.md canonical root agent spec (real file)
# AGENTS/<subdir>/AGENTS.md canonical spec for any subdir with its own
# scoped instructions (real file, discovered
# automatically -- see above)
# <root>/AGENTS.md → AGENTS/AGENTS.md
# <root>/CLAUDE.md → AGENTS/CLAUDE.md
# <root>/<subdir>/AGENTS.md → AGENTS/<subdir>/AGENTS.md
# AGENTS/plans superpowers plans (real dir, .gitkeep)
# AGENTS/specs superpowers specs (real dir, .gitkeep)
# AGENTS/devlogs agent development logs (real dir, .gitkeep)
@@ -42,6 +53,12 @@
# docs/specs → ../AGENTS/specs (only if docs/specs existed)
# docs/devlogs → ../AGENTS/devlogs (only if docs/devlogs existed)
#
# No CLAUDE.md survives anywhere in a managed tree: claude-code reads
# AGENTS.md natively when CLAUDE.md is absent, so CLAUDE.md exists here
# purely as a retirement target -- any found (root or subdirectory, real
# file or leftover symlink) is folded into the AGENTS.md-only shape
# above by _agents_init_sync_instructions.
#
# plans/ and specs/ are merged from every legacy location (docs/<tgt>,
# docs/superpowers/<tgt>, and the old AGENTS/plugins/ layout) into the
# canonical AGENTS/<tgt>; the AGENTS/plugins/ layer is removed.
@@ -75,7 +92,8 @@
# Called automatically by the claude and agy wrappers on every invocation.
#
# ARGUMENTS
# -a, --agents Set up AGENTS/ repo + AGENTS.md / CLAUDE.md symlinks only
# -a, --agents Set up AGENTS/ repo + AGENTS.md symlinks (root and every
# discovered subdirectory) only
# -p, --plugins Set up AGENTS/ repo + plans/specs/devlogs dirs + docs/ symlinks only
# -v, --verbose Print all per-step output (default)
# -q, --quiet Print one summary line only if changes were made
@@ -92,6 +110,15 @@
# agents-init --agents
# agents-init --plugins
# agents-init --quiet
#
# NOTES
# This header covers usage only. The full concept/behavior/purpose
# write-up -- the AGENTS.md convention, the AGENTS/ sub-repository, the
# discovery and safety model, and a complete scenario-by-scenario
# reference table -- lives in its own manual section:
# docs/manual/16-agent-tooling.md. Update that section in the same
# change whenever this function's behavior changes; see "Dedicated
# manual sections for complex subsystems" in CONTRIBUTING.md.
function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec files and plugin dirs'
__fish_palette
@@ -105,7 +132,7 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
echo
echo "$c_head""Options:$c_reset"
echo " $c_flag-h$c_reset, $c_flag--help$c_reset Show this help message"
echo " $c_flag-a$c_reset, $c_flag--agents$c_reset Set up AGENTS.md / CLAUDE.md symlinks only"
echo " $c_flag-a$c_reset, $c_flag--agents$c_reset Set up AGENTS.md symlinks only"
echo " $c_flag-p$c_reset, $c_flag--plugins$c_reset Set up plans/specs/devlogs dirs and docs/ symlinks only"
echo " $c_flag-v$c_reset, $c_flag--verbose$c_reset Print all per-step output (default)"
echo " $c_flag-q$c_reset, $c_flag--quiet$c_reset Print one summary line only if changes were made"
@@ -140,7 +167,9 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
# directory created an AGENTS/ repo, two root symlinks, and a docs/
# tree there.
set -l root (git rev-parse --show-toplevel 2>/dev/null)
set -l in_git 1
if test -z "$root"
set in_git 0
if test -e (pwd)/AGENTS.md -o -e (pwd)/CLAUDE.md -o -d (pwd)/AGENTS
set root (pwd)
else
@@ -201,109 +230,84 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
# ──────────────────────────── --agents mode ──────────────────────────────
if test $do_agents -eq 1
# Detect which root-level files are real (not symlinks)
set -l has_agents 0
set -l has_claude 0
if test -f "$root/AGENTS.md"; and not test -L "$root/AGENTS.md"
set has_agents 1
# Discover every directory carrying agent instructions -- root
# included, subdirectories found automatically rather than by a
# hardcoded list. A real file, an already-migrated symlink, or a
# leftover inverted-mirror survivor all match, so one pass covers
# fresh, migrated, and legacy state alike.
#
# Discovery stays inside this project: a non-git root (a lone
# agent file in, say, ~) syncs only itself -- walking it would
# reach into every unrelated tree below. In a git root, pruned:
# any AGENTS/ (a mirror, never a source), dot-directories (.git,
# .claude, .github: tool state, not scoped project dirs),
# node_modules, generated-output directories (build, dist, out,
# target: an instruction file there is a build artifact, never a
# source -- pruned outright, before tracked-file protection would
# even be consulted), and nested repos/submodules/worktrees (their
# own .git marks another project). -mindepth 1 keeps the root
# itself, which has a .git, from pruning the whole walk.
set -l found
if test $in_git -eq 1
set found (find "$root" -mindepth 1 \
-type d \( -name '.*' -o -name AGENTS -o -name node_modules \
-o -name build -o -name dist -o -name out -o -name target \
-o -exec test -e '{}/.git' \; \) -prune -o \
\( -name AGENTS.md -o -name CLAUDE.md \) -print)
end
if test -f "$root/CLAUDE.md"; and not test -L "$root/CLAUDE.md"
set has_claude 1
set -l rels "."
for f in $found
set -l d (path dirname "$f")
set -l rel (string replace "$root/" "" "$d")
test "$rel" = "$d"; and set rel "."
contains -- "$rel" $rels; or set -a rels "$rel"
end
# ── Move real files into AGENTS/ ──────────────────────────────────────
if test $has_agents -eq 1; and test $has_claude -eq 1
# Both exist: preserve each as its own file in AGENTS/
if not test -f "$agents_dir/AGENTS.md"
if not command mv "$root/AGENTS.md" "$agents_dir/AGENTS.md"
echo "$c_err""Error: could not move AGENTS.md → AGENTS/AGENTS.md$c_reset" >&2
return 1
end
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Moved AGENTS.md → AGENTS/AGENTS.md$c_reset"
end
if not test -f "$agents_dir/CLAUDE.md"; and not test -L "$agents_dir/CLAUDE.md"
if not command mv "$root/CLAUDE.md" "$agents_dir/CLAUDE.md"
echo "$c_err""Error: could not move CLAUDE.md → AGENTS/CLAUDE.md$c_reset" >&2
return 1
end
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Moved CLAUDE.md → AGENTS/CLAUDE.md$c_reset"
end
else if test $has_agents -eq 1
if not test -f "$agents_dir/AGENTS.md"
if not command mv "$root/AGENTS.md" "$agents_dir/AGENTS.md"
echo "$c_err""Error: could not move AGENTS.md → AGENTS/AGENTS.md$c_reset" >&2
return 1
end
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Moved AGENTS.md → AGENTS/AGENTS.md$c_reset"
end
else if test $has_claude -eq 1
# Only CLAUDE.md: treat it as the agent spec
if not test -f "$agents_dir/AGENTS.md"
if not command mv "$root/CLAUDE.md" "$agents_dir/AGENTS.md"
echo "$c_err""Error: could not move CLAUDE.md → AGENTS/AGENTS.md$c_reset" >&2
return 1
end
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Moved CLAUDE.md → AGENTS/AGENTS.md$c_reset"
end
else
# Neither exists: create AGENTS/AGENTS.md with the agent directive
if not test -f "$agents_dir/AGENTS.md"
printf '%s\n' \
'# AGENTS.md' \
'' \
'> ⚠️ **SYSTEM DIRECTIVE FOR AI AGENTS: FILE EDITING**' \
'> You may be reading this file via a symlink (`CLAUDE.md` or `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 `CLAUDE.md` or `AGENTS.md` in the' \
'> project root. If you need to update these instructions, you **MUST write' \
'> directly to `AGENTS/AGENTS.md`**.' >"$agents_dir/AGENTS.md"
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Created AGENTS/AGENTS.md with agent directive$c_reset"
end
end
# ── Ensure AGENTS/CLAUDE.md exists ────────────────────────────────────
# When both files existed, AGENTS/CLAUDE.md is already a real file.
# Otherwise, create it as a symlink → AGENTS.md (within AGENTS/).
if not test -f "$agents_dir/CLAUDE.md"; and not test -L "$agents_dir/CLAUDE.md"
if not ln -s AGENTS.md "$agents_dir/CLAUDE.md"
echo "$c_err""Error: could not create AGENTS/CLAUDE.md symlink$c_reset" >&2
for rel in $rels
set -l out (_agents_init_sync_instructions "$root" "$agents_dir" "$rel")
set -l rc $status
if test $rc -ne 0
echo "$c_err""Error: could not sync AGENTS.md for $rel$c_reset" >&2
return 1
end
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Linked AGENTS/CLAUDE.md → AGENTS/AGENTS.md$c_reset"
if test -n "$out"
set changed 1
if test $verbose -eq 1
for line in $out
echo "$c_ok$line$c_reset"
end
end
end
end
# Root symlinks point at files, not directories, so they cannot use
# _agents_repo_ensure_symlink (which is directory-only by design).
for pair in "AGENTS.md:AGENTS/AGENTS.md" "CLAUDE.md:AGENTS/CLAUDE.md"
set -l name (string split -f1 ':' -- $pair)
set -l want (string split -f2 ':' -- $pair)
set -l need 0
if not test -L "$root/$name"
set need 1
else if test (readlink "$root/$name") != "$want"
rm -f "$root/$name"
set need 1
end
if test $need -eq 1
if not ln -s "$want" "$root/$name"
echo "$c_err""Error: could not create $name symlink$c_reset" >&2
return 1
end
# ── Migrate stale anchored gitignore lines ──────────────────────────────
# A project scaffolded by the old agents-init already has anchored
# /AGENTS.md and/or /CLAUDE.md lines in .gitignore. git check-ignore
# sees those as covering the literal path "AGENTS.md", so the new
# unanchored pattern below would be judged already-covered and never
# added -- leaving any newly discovered subdirectory AGENTS.md with no
# gitignore coverage at all. Strip the stale exact lines first so the
# unanchored pattern always gets a chance to be added. No-op when
# neither stale line is present.
set -l gitignore "$root/.gitignore"
if test -f "$gitignore"
if grep -qxF "/AGENTS.md" "$gitignore"
sed -i '/^\/AGENTS\.md$/d' "$gitignore"
set changed 1
test $verbose -eq 1; and echo "$c_ok→ Linked $name$want$c_reset"
test $verbose -eq 1; and echo "$c_warn→ Removed stale /AGENTS.md line from .gitignore$c_reset"
end
if grep -qxF "/CLAUDE.md" "$gitignore"
sed -i '/^\/CLAUDE\.md$/d' "$gitignore"
set changed 1
test $verbose -eq 1; and echo "$c_warn→ Removed stale /CLAUDE.md line from .gitignore$c_reset"
end
end
# ── .gitignore ────────────────────────────────────────────────────────
set -l _gi (_agents_init_ensure_gitignore "$root" "agents-init --agents" "AGENTS/" "/AGENTS.md" "/CLAUDE.md")
# Unanchored: matches AGENTS.md at every depth, so a newly
# discovered subdirectory needs no additional gitignore entry.
# CLAUDE.md is dropped entirely -- nothing creates one anymore.
set -l _gi (_agents_init_ensure_gitignore "$root" "agents-init --agents" "AGENTS/" "AGENTS.md")
if test -n "$_gi"
set changed 1
test $verbose -eq 1; and echo $_gi
+8 -1
View File
@@ -10,7 +10,7 @@
# _agents_repo_install_tools, git, hostname
#
# CLASSIFICATION
# self-limiting(rm,mkdir)
# self-limiting(rm,mkdir), manual-section(16-agent-tooling)
#
# SYNOPSIS
# agents-vault [--link] [--push] [--restore] [--status]
@@ -188,6 +188,13 @@
# machine that has a real global memory directory would move it into a
# throwaway directory and leave a dangling symlink behind, which is
# strictly worse than having had no backup at all.
#
# This header covers usage only. The full concept/behavior/purpose
# write-up -- how this relates to the per-project AGENTS/ repository
# agents-init manages, and where each kind of agent state actually lives
# -- is in docs/manual/16-agent-tooling.md. Update that section in the
# same change whenever this function's behavior changes; see "Dedicated
# manual sections for complex subsystems" in CONTRIBUTING.md.
function agents-vault --description 'track curated agent memory in a host-scoped vault repo'
__fish_palette
+2 -2
View File
@@ -17,8 +17,8 @@
# Wrapper for the agy Antigravity AI CLI that ensures the AGENTS/
# sub-repository is initialized and any agent-made changes are committed
# before launch. Delegates all scaffold and commit logic to agents-init
# --quiet (full setup), which ensures AGENTS/ is scaffolded and CLAUDE.md
# is symlinked to AGENTS/AGENTS.md in the current project.
# --quiet (full setup), which ensures AGENTS.md (root and every scoped
# subdirectory) is symlinked into AGENTS/ in the current project.
#
# Also syncs the host-scoped agent memory vault (agents-vault). agy has
# no session-end hook, so its memory is captured on the next launch
+8
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 08-terminal-management
#
# DEPENDENCIES
# nohup
#
# SYNOPSIS
# bkg <command> [args...]
#
@@ -32,6 +35,11 @@ function bkg --description 'Execute bkg'
return 1
end
if not type -q nohup
echo (set_color red)"Error: nohup is not installed."(set_color normal) >&2
return 1
end
# Run the command using nohup to make it immune to hangups (like closing the terminal).
# Redirect both stdout and stderr to /dev/null to discard all output.
# The final ampersand (&) sends the entire process to the background.
+4 -3
View File
@@ -20,8 +20,9 @@
# Wrapper for the claude CLI that ensures the AGENTS/ sub-repository is
# initialized and any agent-made changes are committed before launch.
# Delegates all scaffold and commit logic to agents-init --quiet (full
# setup), which ensures AGENTS/ is scaffolded and CLAUDE.md is symlinked
# to AGENTS/AGENTS.md in the current project.
# setup), which ensures AGENTS.md (root and every scoped subdirectory)
# is symlinked into AGENTS/ in the current project. claude-code reads
# AGENTS.md natively, so no CLAUDE.md is created or maintained.
#
# Also syncs the host-scoped agent memory vault (agents-vault), which
# tracks curated memory living outside the project tree. The vault
@@ -44,7 +45,7 @@
# claude
# claude --resume
# claude "Explain the recent changes"
function claude --wraps=claude --description 'claude wrapper: auto-links AGENTS.md as CLAUDE.md'
function claude --wraps=claude --description 'claude wrapper: ensures AGENTS/ is scaffolded before launch'
if not __fish_config_op_enabled (status current-function)
command claude $argv
return $status
+11 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 02-navigation
#
# DEPENDENCIES
# clone-in-kitty
#
# SYNOPSIS
# clone [args...]
#
@@ -16,7 +19,7 @@
#
# EXIT STATUS
# 0 Repository cloned
# 1 Not running inside Kitty terminal
# 1 Not running inside Kitty terminal, or clone-in-kitty isn't available
#
# EXAMPLE
# clone https://github.com/user/repo.git
@@ -25,5 +28,12 @@ function clone --wraps='clone-in-kitty' --description 'alias clone=clone-in-kitt
echo "Error: The 'clone' command requires Kitty terminal." >&2
return 1
end
# $TERM only proves the terminal type -- clone-in-kitty is a function
# Kitty's own shell integration injects, which doesn't happen over an
# ssh session that merely inherits $TERM from the local Kitty.
if not type -q clone-in-kitty
echo "Error: 'clone' detected Kitty but clone-in-kitty isn't available (shell integration not loaded)." >&2
return 1
end
clone-in-kitty $argv
end
+11 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 02-navigation
#
# DEPENDENCIES
# clone-in-kitty
#
# SYNOPSIS
# clonet [args...]
#
@@ -16,7 +19,7 @@
#
# EXIT STATUS
# 0 Repository cloned
# 1 Not running inside Kitty terminal
# 1 Not running inside Kitty terminal, or clone-in-kitty isn't available
#
# EXAMPLE
# clonet https://github.com/user/repo.git
@@ -25,5 +28,12 @@ function clonet --wraps='clone-in-kitty --type=tab' --description 'alias clonet=
echo "Error: The 'clonet' command requires Kitty terminal." >&2
return 1
end
# $TERM only proves the terminal type -- clone-in-kitty is a function
# Kitty's own shell integration injects, which doesn't happen over an
# ssh session that merely inherits $TERM from the local Kitty.
if not type -q clone-in-kitty
echo "Error: 'clonet' detected Kitty but clone-in-kitty isn't available (shell integration not loaded)." >&2
return 1
end
clone-in-kitty --type=tab $argv
end
+8
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 08-terminal-management
#
# DEPENDENCIES
# nohup
#
# SYNOPSIS
# detach [-h] [--version] <command> [args...]
#
@@ -66,5 +69,10 @@ function detach --description 'Execute detach'
return 1
end
if not type -q nohup
echo (set_color red)"Error: nohup is not installed."(set_color normal) >&2
return 1
end
nohup $args >/dev/null 2>&1 &
end
+11
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 10-network
#
# DEPENDENCIES
# fast
#
# SYNOPSIS
# fast-cli [args...]
#
@@ -13,8 +16,16 @@
# ARGUMENTS
# args... Arguments forwarded to the fast command
#
# EXIT STATUS
# 1 fast is not installed
# * Exit status of fast otherwise
#
# EXAMPLE
# fast-cli
function fast-cli --description "Run a speed test using fast.com"
if not type -q -f fast
echo (set_color red)"Error: fast is not installed."(set_color normal) >&2
return 1
end
command fast $argv
end
-251
View File
@@ -1,251 +0,0 @@
function fisher --argument-names cmd --description "A plugin manager for Fish"
set --query fisher_path || set --local fisher_path $__fish_config_dir
set --local fisher_version 4.4.8
set --local fish_plugins $__fish_config_dir/fish_plugins
switch "$cmd"
case -v --version
echo "fisher, version $fisher_version"
case "" -h --help
echo "Usage: fisher install <plugins...> Install plugins"
echo " fisher remove <plugins...> Remove installed plugins"
echo " fisher uninstall <plugins...> Remove installed plugins (alias)"
echo " fisher update <plugins...> Update installed plugins"
echo " fisher update Update all installed plugins"
echo " fisher list [<regex>] List installed plugins matching regex"
echo "Options:"
echo " -v, --version Print version"
echo " -h, --help Print this help message"
echo "Variables:"
echo " \$fisher_path Plugin installation path. Default: $__fish_config_dir" | string replace --regex -- $HOME \~
case ls list
string match --entire --regex -- "$argv[2]" $_fisher_plugins
case install update remove uninstall
isatty || read --local --null --array stdin && set --append argv $stdin
test "$cmd" = uninstall && set cmd remove
set --local install_plugins
set --local update_plugins
set --local remove_plugins
set --local arg_plugins $argv[2..-1]
set --local old_plugins $_fisher_plugins
set --local new_plugins
test -e $fish_plugins && set --local file_plugins (string match --regex -- '^[^\s]+$' <$fish_plugins | string replace -- \~ ~)
if ! set --query argv[2]
if test "$cmd" != update
echo "fisher: Not enough arguments for command: \"$cmd\"" >&2 && return 1
else if ! set --query file_plugins
echo "fisher: \"$fish_plugins\" file not found: \"$cmd\"" >&2 && return 1
end
set arg_plugins $file_plugins
else if test "$cmd" = install && ! set --query old_plugins[1]
set --append arg_plugins $file_plugins
end
for plugin in $arg_plugins
set plugin (test -e "$plugin" && realpath $plugin || string lower -- $plugin)
contains -- "$plugin" $new_plugins || set --append new_plugins $plugin
end
if set --query argv[2]
for plugin in $new_plugins
if contains -- "$plugin" $old_plugins
test "$cmd" = remove &&
set --append remove_plugins $plugin ||
set --append update_plugins $plugin
else if test "$cmd" = install
set --append install_plugins $plugin
else
echo "fisher: Plugin not installed: \"$plugin\"" >&2 && return 1
end
end
else
for plugin in $new_plugins
contains -- "$plugin" $old_plugins &&
set --append update_plugins $plugin ||
set --append install_plugins $plugin
end
for plugin in $old_plugins
contains -- "$plugin" $new_plugins || set --append remove_plugins $plugin
end
end
set --local pid_list
set --local source_plugins
set --local fetch_plugins $update_plugins $install_plugins
set --local fish_path (status fish-path)
echo (set_color --bold)fisher $cmd version $fisher_version(set_color normal)
for plugin in $fetch_plugins
set --local source (command mktemp -d)
set --append source_plugins $source
command mkdir -p $source/{completions,conf.d,themes,functions}
$fish_path --command "
if test -e $plugin
command cp -Rf $plugin/* $source
else
set resp (command mktemp)
set temp (command mktemp -d)
set repo (string split -- \@ $plugin) || set repo[2] HEAD
if set path (string replace --regex -- '^(https://)?gitlab.com/' '' \$repo[1])
set name (string split -- / \$path)[-1]
set url https://gitlab.com/\$path/-/archive/\$repo[2]/\$name-\$repo[2].tar.gz
else
set url https://api.github.com/repos/\$repo[1]/tarball/\$repo[2]
end
echo Fetching (set_color --underline)\$url(set_color normal)
set http (command curl -q --silent -L -o \$resp -w %{http_code} \$url)
if test \"\$http\" = 200 && command tar -xzC \$temp -f \$resp 2>/dev/null
command cp -Rf \$temp/*/* $source
else if test \"\$http\" = 403
echo fisher: GitHub API rate limit exceeded \(HTTP 403\) >&2
command rm -rf $source
else
echo fisher: Invalid plugin name or host unavailable: \\\"$plugin\\\" >&2
command rm -rf $source
end
command rm -rf \$temp
end
set files $source/* && string match --quiet --regex -- .+\.fish\\\$ \$files
" &
set --append pid_list (jobs --last --pid)
end
wait $pid_list 2>/dev/null
for plugin in $fetch_plugins
if set --local source $source_plugins[(contains --index -- "$plugin" $fetch_plugins)] && test ! -e $source
if set --local index (contains --index -- "$plugin" $install_plugins)
set --erase install_plugins[$index]
else
set --erase update_plugins[(contains --index -- "$plugin" $update_plugins)]
end
end
end
for plugin in $update_plugins $remove_plugins
if set --local index (contains --index -- "$plugin" $_fisher_plugins)
set --local plugin_files_var _fisher_(string escape --style=var -- $plugin)_files
if contains -- "$plugin" $remove_plugins
for name in (string replace --filter --regex -- '.+/conf\.d/([^/]+)\.fish$' '$1' $$plugin_files_var)
emit {$name}_uninstall
end
printf "%s\n" Removing\ (set_color red --bold)$plugin(set_color normal) " "$$plugin_files_var | string replace -- \~ ~
set --erase _fisher_plugins[$index]
end
command rm -rf (string replace -- \~ ~ $$plugin_files_var)
functions --erase (string replace --filter --regex -- '.+/functions/([^/]+)\.fish$' '$1' $$plugin_files_var)
for name in (string replace --filter --regex -- '.+/completions/([^/]+)\.fish$' '$1' $$plugin_files_var)
complete --erase --command $name
end
set --erase $plugin_files_var
end
end
if set --query update_plugins[1] || set --query install_plugins[1]
command mkdir -p $fisher_path/{functions,themes,conf.d,completions}
end
for plugin in $update_plugins $install_plugins
set --local source $source_plugins[(contains --index -- "$plugin" $fetch_plugins)]
set --local files $source/{functions,themes,conf.d,completions}/*
if set --local index (contains --index -- $plugin $install_plugins)
set --local user_files $fisher_path/{functions,themes,conf.d,completions}/*
set --local conflict_files
for file in (string replace -- $source/ $fisher_path/ $files)
contains -- $file $user_files && set --append conflict_files $file
end
if set --query conflict_files[1] && set --erase install_plugins[$index]
echo -s "fisher: Cannot install \"$plugin\": please remove or move conflicting files first:" \n" "$conflict_files >&2
continue
end
end
for file in (string replace -- $source/ "" $files)
command cp -RLf $source/$file $fisher_path/$file
end
set --local plugin_files_var _fisher_(string escape --style=var -- $plugin)_files
set --query files[1] && set --universal $plugin_files_var (string replace -- $source $fisher_path $files | string replace -- ~ \~)
contains -- $plugin $_fisher_plugins || set --universal --append _fisher_plugins $plugin
contains -- $plugin $install_plugins && set --local event install || set --local event update
printf "%s\n" Installing\ (set_color --bold)$plugin(set_color normal) " "$$plugin_files_var | string replace -- \~ ~
for file in (string match --regex -- '.+/[^/]+\.fish$' $$plugin_files_var | string replace -- \~ ~)
source $file
if set --local name (string replace --regex -- '.+conf\.d/([^/]+)\.fish$' '$1' $file)
emit {$name}_$event
end
end
end
command rm -rf $source_plugins
if set --query _fisher_plugins[1]
set --local commit_plugins
for plugin in $file_plugins
contains -- (string lower -- $plugin) (string lower -- $_fisher_plugins) && set --append commit_plugins $plugin
end
for plugin in $_fisher_plugins
contains -- (string lower -- $plugin) (string lower -- $commit_plugins) || set --append commit_plugins $plugin
end
string replace --regex -- $HOME \~ $commit_plugins >$fish_plugins
else
set --erase _fisher_plugins
command rm -f $fish_plugins
end
set --local total (count $install_plugins) (count $update_plugins) (count $remove_plugins)
test "$total" != "0 0 0" && echo (string join ", " (
test $total[1] = 0 || echo "Installed $total[1]") (
test $total[2] = 0 || echo "Updated $total[2]") (
test $total[3] = 0 || echo "Removed $total[3]")
) plugin/s
case \*
echo "fisher: Unknown command: \"$cmd\"" >&2 && return 1
end
end
if ! set --query _fisher_upgraded_to_4_4
set --universal _fisher_upgraded_to_4_4
if functions --query _fisher_list
set --query XDG_DATA_HOME[1] || set --local XDG_DATA_HOME ~/.local/share
command rm -rf $XDG_DATA_HOME/fisher
functions --erase _fisher_{list,plugin_parse}
fisher update >/dev/null 2>/dev/null
else
for var in (set --names | string match --entire --regex '^_fisher_.+_files$')
set $var (string replace -- ~ \~ $$var)
end
functions --erase _fisher_fish_postexec
end
end
+139 -51
View File
@@ -5,43 +5,52 @@
# 04-git-and-version-control
#
# DEPENDENCIES
# curl, md5sum, md5
# curl, md5sum, md5, gitignore-scrub
#
# CLASSIFICATION
# self-limiting(grep,cat), network, blocking-prompt
#
# SYNOPSIS
# gi [-h] [-b] [-p] [-s] [-l] [targets...]
# gi [-h] [-b] [-p] [-o] [-s] [-f] [-c TEMPLATE] [-l] [targets...]
#
# DESCRIPTION
# Generates .gitignore content by querying the gitignore.io API. Appends
# results to the repository's .gitignore with MD5-based deduplication —
# patterns already present are not re-appended — or prints to stdout with
# -s. Supports generic boilerplate and interactive prompt modes.
# -o/--stdout. Boilerplate mode uses $GITIGNORE_BOILERPLATE if set, a
# -c/--custom template if given, or falls back to the bundled standard
# template (data/gi/boilerplate.gitignore) when neither is configured.
# Supports generic boilerplate and interactive prompt modes.
#
# ARGUMENTS
# -h, --help Show help message
# -d, --description Show the function description
# -l, --list List all supported targets from the API
# -b, --boilerplate Append boilerplate from $GITIGNORE_BOILERPLATE
# -b, --boilerplate Append boilerplate (implied by -c)
# -p, --prompt Prompt for patterns to append
# -s, --stdout Print API output to stdout instead of .gitignore
# -o, --stdout Print generated content to stdout instead of .gitignore
# -s, --silent Suppress progress output (errors and prompts still show)
# -f, --force Bypass prompts, proceeding with the default action
# -c, --custom PATH Use PATH as the boilerplate template instead of
# $GITIGNORE_BOILERPLATE
# targets Comma- or space-separated list of language/tool names
#
# EXIT STATUS
# 0 Patterns appended, or resolved with -s/--stdout or -l/--list
# 0 Patterns appended, or resolved with -o/--stdout or -l/--list
# 1 Not in a git repository or API fetch failed
#
# RETURNS
# With -s/--stdout, the fetched .gitignore pattern text, printed to stdout.
# With -o/--stdout, the fetched .gitignore pattern text, printed to stdout.
# With -l/--list, the supported target list, printed to stdout.
#
# EXAMPLE
# gi python,venv
# gi -b -p
# gi -s node > .gitignore
# gi -o node > .gitignore
# gi -f # skip prompt, proceed with no patterns
# gi -c ~/my-template.gitignore
function gi --description 'Generate .gitignore files using the gitignore.io API'
argparse h/help d/description l/list b/boilerplate p/prompt s/stdout -- $argv
argparse h/help d/description l/list b/boilerplate p/prompt o/stdout s/silent f/force c/custom= -- $argv
or return 1
if set -q _flag_help
@@ -56,9 +65,18 @@ function gi --description 'Generate .gitignore files using the gitignore.io API'
echo " $c_flag-h, --help $c_reset Show this help message"
echo " $c_flag-d, --description $c_reset Show the Fish function description"
echo " $c_flag-l, --list $c_reset List all supported targets from the API"
echo " $c_flag-b, --boilerplate $c_reset Append boilerplate from $c_arg""\$GITIGNORE_BOILERPLATE$c_reset to .gitignore"
echo " $c_flag-b, --boilerplate $c_reset Append boilerplate (implied by "$c_flag"-c$c_reset)"
echo " $c_flag-p, --prompt $c_reset Prompt for patterns and append them to .gitignore"
echo " $c_flag-s, --stdout $c_reset Print API output to stdout instead of appending to .gitignore"
echo " $c_flag-o, --stdout $c_reset Print generated content to stdout instead of .gitignore"
echo " $c_flag-s, --silent $c_reset Suppress progress output (errors and prompts still show)"
echo " $c_flag-f, --force $c_reset Bypass prompts, proceeding with the default action"
echo " $c_flag-c, --custom $c_reset $c_arg""PATH$c_reset Use PATH as the boilerplate template"
echo " $c_dim""instead of \$GITIGNORE_BOILERPLATE$c_reset"
echo ""
echo "$c_head""Boilerplate source (in priority order):$c_reset"
echo " 1. "$c_flag"-c/--custom$c_reset PATH, if given"
echo " 2. "$c_arg"\$GITIGNORE_BOILERPLATE$c_reset, if set"
echo " 3. "$c_dim"the bundled standard template$c_reset"
echo ""
echo "$c_head""Examples:$c_reset"
echo " $c_cmd""gi$c_reset $c_dim""# Append boilerplate and prompt for patterns (default)$c_reset"
@@ -66,7 +84,9 @@ function gi --description 'Generate .gitignore files using the gitignore.io API'
echo " $c_cmd""gi -p$c_reset $c_dim""# Prompt for patterns and append to .gitignore$c_reset"
echo " $c_cmd""gi$c_reset $c_arg""c++$c_reset $c_dim""# Append C++ patterns to .gitignore$c_reset"
echo " $c_cmd""gi$c_reset $c_arg""python,venv$c_reset $c_dim""# Append Python+venv patterns to .gitignore$c_reset"
echo " $c_cmd""gi -s$c_reset $c_arg""python,venv$c_reset $c_dim""# Print Python+venv patterns to stdout$c_reset"
echo " $c_cmd""gi -o$c_reset $c_arg""python,venv$c_reset $c_dim""# Print Python+venv patterns to stdout$c_reset"
echo " $c_cmd""gi -f$c_reset $c_dim""# Skip prompt, proceed with no patterns$c_reset"
echo " $c_cmd""gi -c$c_reset $c_arg""~/my.gitignore$c_reset $c_dim""# Append a custom boilerplate template$c_reset"
echo " $c_cmd""gi -l$c_reset | grep -i linux $c_dim""# Search for specific OS support$c_reset"
return 0
end
@@ -81,11 +101,14 @@ function gi --description 'Generate .gitignore files using the gitignore.io API'
return 0
end
set -l silent_flag 0
set -q _flag_silent; and set silent_flag 1
# Determine which modes to run
set -l do_boilerplate 0
set -l do_prompt 0
if set -q _flag_boilerplate
if set -q _flag_boilerplate; or set -q _flag_custom
set do_boilerplate 1
end
if set -q _flag_prompt
@@ -98,14 +121,17 @@ function gi --description 'Generate .gitignore files using the gitignore.io API'
set do_prompt 1
end
# Resolve git context for anything that writes to .gitignore
# Resolve git context for anything that writes to .gitignore.
# --stdout never touches .gitignore, so it never needs a git repo.
set -l gitignore_path ""
set -l readable_path ""
set -l needs_git 0
if test $do_boilerplate -eq 1; or test $do_prompt -eq 1
set needs_git 1
else if set -q argv[1]; and not set -q _flag_stdout
set needs_git 1
if not set -q _flag_stdout
if test $do_boilerplate -eq 1; or test $do_prompt -eq 1
set needs_git 1
else if set -q argv[1]
set needs_git 1
end
end
if test $needs_git -eq 1
@@ -119,39 +145,85 @@ function gi --description 'Generate .gitignore files using the gitignore.io API'
set readable_path (string replace -r "^$HOME" "~" $gitignore_path)
end
# Boilerplate mode
# Boilerplate mode: resolve the template source, in priority order:
# 1. -c/--custom PATH
# 2. $GITIGNORE_BOILERPLATE
# 3. the bundled standard template (data/gi/boilerplate.gitignore)
if test $do_boilerplate -eq 1
if not set -q GITIGNORE_BOILERPLATE
set_color red --bold
echo "Error:" (set_color normal)"\$GITIGNORE_BOILERPLATE environment variable is not defined" >&2
else if not test -f "$GITIGNORE_BOILERPLATE"
set_color red --bold
echo "Error:" (set_color normal)"Boilerplate file not found at '$GITIGNORE_BOILERPLATE'" >&2
else
set -l template_hash ""
if command -q md5sum
set template_hash (md5sum "$GITIGNORE_BOILERPLATE" | string split ' ')[1]
else if command -q md5
set template_hash (md5 -q "$GITIGNORE_BOILERPLATE")
end
set -l boilerplate_path ""
set -l boilerplate_ok 1
set -l sig "# id: gitig-boilerplate-$template_hash"
if test -f "$gitignore_path"; and grep -qF "$sig" "$gitignore_path"
set_color yellow --bold
echo "Notice:" (set_color normal)"Boilerplate already present in "(set_color cyan)"$readable_path"(set_color normal)"."
if set -q _flag_custom
if test -f "$_flag_custom"
set boilerplate_path "$_flag_custom"
else
printf "\n%s\n" "$sig" >>"$gitignore_path"
cat "$GITIGNORE_BOILERPLATE" >>"$gitignore_path"
echo (set_color green)"✔"(set_color normal)" Appended boilerplate to "(set_color cyan)"$readable_path"(set_color normal)
set_color red --bold
echo "Error:" (set_color normal)"Custom boilerplate file not found at '$_flag_custom'" >&2
set boilerplate_ok 0
end
else if set -q GITIGNORE_BOILERPLATE
if test -f "$GITIGNORE_BOILERPLATE"
set boilerplate_path "$GITIGNORE_BOILERPLATE"
else
set_color red --bold
echo "Error:" (set_color normal)"Boilerplate file not found at '$GITIGNORE_BOILERPLATE'" >&2
set boilerplate_ok 0
end
else
if set -q __fish_config_dir
set boilerplate_path "$__fish_config_dir/data/gi/boilerplate.gitignore"
else
set boilerplate_path "$HOME/.config/fish/data/gi/boilerplate.gitignore"
end
if not test -f "$boilerplate_path"
set_color red --bold
echo "Error:" (set_color normal)"Bundled default boilerplate missing at '$boilerplate_path'" >&2
set boilerplate_ok 0
else if not set -q _flag_silent
set_color yellow --bold
echo "Notice:" (set_color normal)"\$GITIGNORE_BOILERPLATE not set; using the bundled default template."
end
end
if test $boilerplate_ok -eq 1
if set -q _flag_stdout
cat "$boilerplate_path"
else
set -l template_hash ""
if command -q md5sum
set template_hash (md5sum "$boilerplate_path" | string split ' ')[1]
else if command -q md5
set template_hash (md5 -q "$boilerplate_path")
end
set -l sig "# id: gitig-boilerplate-$template_hash"
if test -f "$gitignore_path"; and grep -qF "$sig" "$gitignore_path"
if not set -q _flag_silent
set_color yellow --bold
echo "Notice:" (set_color normal)"Boilerplate already present in "(set_color cyan)"$readable_path"(set_color normal)"."
end
else
printf "\n%s\n" "$sig" >>"$gitignore_path"
cat "$boilerplate_path" >>"$gitignore_path"
if not set -q _flag_silent
echo (set_color green)"✔"(set_color normal)" Appended boilerplate to "(set_color cyan)"$readable_path"(set_color normal)
end
end
end
end
end
# Prompt mode: ask for patterns, fetch and dedup each one individually
# Prompt mode: ask for patterns, fetch and dedup (or print) each one individually
if test $do_prompt -eq 1
read -P "Enter gitignore patterns (comma-separated, e.g. python,vim): " patterns
or return 0
set -l patterns ""
if set -q _flag_force
# Bypass the prompt: proceed with the default action (no patterns)
set patterns ""
else
read -P "Enter gitignore patterns (comma-separated, e.g. python,vim): " patterns
or return 0
end
set patterns (string trim -- $patterns)
if test -n "$patterns"
for pattern in (string split "," -- $patterns)
@@ -162,11 +234,16 @@ function gi --description 'Generate .gitignore files using the gitignore.io API'
echo "Error: Failed to fetch gitignore for '$pattern'. Is the target spelled correctly?" >&2
continue
end
__gi_append_dedup "$content" "$pattern" "$gitignore_path" "$readable_path"
if set -q _flag_stdout
echo "$content"
else
__gi_append_dedup "$content" "$pattern" "$gitignore_path" "$readable_path" $silent_flag
end
end
else
else if not set -q _flag_silent
echo (set_color brblack)"No patterns selected. Skipping API fetch."(set_color normal)
end
test $needs_git -eq 1; and gitignore-scrub
return 0
end
@@ -192,14 +269,19 @@ function gi --description 'Generate .gitignore files using the gitignore.io API'
echo "Error: Failed to fetch gitignore for '$target'. Is the target spelled correctly?" >&2
continue
end
__gi_append_dedup "$content" "$target" "$gitignore_path" "$readable_path"
__gi_append_dedup "$content" "$target" "$gitignore_path" "$readable_path" $silent_flag
end
end
end
if test $needs_git -eq 1
gitignore-scrub
end
return 0
end
# SYNOPSIS
# __gi_append_dedup <content> <label> <gitignore_path> <readable_path>
# __gi_append_dedup <content> <label> <gitignore_path> <readable_path> [silent]
#
# DESCRIPTION
# Appends gitignore content to a .gitignore file using MD5-based deduplication.
@@ -210,14 +292,16 @@ end
# label Human-readable label for the pattern set
# gitignore_path Absolute path to the .gitignore file
# readable_path Home-abbreviated path shown in output messages
# silent 1 to suppress progress output, 0/omitted to show it
#
# EXAMPLE
# __gi_append_dedup "$content" "python" "$root/.gitignore" "~/.gitignore"
# __gi_append_dedup "$content" "python" "$root/.gitignore" "~/.gitignore" 0
function __gi_append_dedup
set -l content $argv[1]
set -l label $argv[2]
set -l gitignore_path $argv[3]
set -l readable_path $argv[4]
set -l silent $argv[5]
set -l content_hash ""
if command -q md5sum
@@ -229,10 +313,14 @@ function __gi_append_dedup
set -l sig "# id: gi-patterns-$content_hash"
if test -f "$gitignore_path"; and grep -qF "$sig" "$gitignore_path"
set_color yellow --bold
echo "Notice:" (set_color normal)"$label patterns already present in "(set_color cyan)"$readable_path"(set_color normal)"."
if test "$silent" != 1
set_color yellow --bold
echo "Notice:" (set_color normal)"$label patterns already present in "(set_color cyan)"$readable_path"(set_color normal)"."
end
else
printf "\n%s\n%s\n" "$sig" "$content" >>"$gitignore_path"
echo (set_color green)"✔"(set_color normal)" Appended $label patterns to "(set_color cyan)"$readable_path"(set_color normal)
if test "$silent" != 1
echo (set_color green)"✔"(set_color normal)" Appended $label patterns to "(set_color cyan)"$readable_path"(set_color normal)
end
end
end
+145
View File
@@ -0,0 +1,145 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# CATEGORY
# 04-git-and-version-control
#
# DEPENDENCIES
# git
#
# CLASSIFICATION
# blocking-prompt
#
# SYNOPSIS
# gitignore-scrub [-h] [-r] [-w | -f | -i]
#
# DESCRIPTION
# Finds files that are tracked by git but now match a .gitignore pattern
# (git ls-files -ci --exclude-standard) and offers to untrack them. In the
# default interactive mode, prompts once for all matches and runs
# git rm --cached on confirmation; a decline is remembered per-path in the
# repo's local git config (gitignore-scrub.skip) so the same file is not
# asked about again. -w/--warn is read-only: prints a Warning line per
# match and makes no changes, meant for non-interactive callers such as a
# git hook. -f/--force skips the prompt and untracks every match
# immediately. -i/--individual prompts once per file instead of once for
# the whole group. -w, -f, and -i are mutually exclusive. -r/--reset
# clears the repo's skip list first (combinable with any mode), so
# previously declined files are reconsidered. Silently does nothing on a
# repo with more tracked files than $GITIGNORE_SCRUB_LIMIT (default
# 5000), to avoid adding latency to huge repos.
#
# ARGUMENTS
# -h, --help Show help message
# -r, --reset Clear the remembered skip list before checking
# -w, --warn Read-only: print warnings instead of prompting, make no changes
# -f, --force Untrack every match immediately, no prompt
# -i, --individual Prompt once per file instead of once for the whole group
#
# EXIT STATUS
# 0 Clean, or a prompt/force run was handled
# 1 Not a git repository, or (-w only) unconfirmed matches were found
#
# EXAMPLE
# gitignore-scrub
# gitignore-scrub --warn
# gitignore-scrub --force
# gitignore-scrub --individual
# gitignore-scrub --reset
function gitignore-scrub --description 'Find and optionally untrack files newly matched by .gitignore'
argparse --exclusive w,f,i h/help r/reset w/warn f/force i/individual -- $argv
or return 1
if set -q _flag_help
__fish_palette
echo "$c_head""Usage:$c_reset $c_cmd""gitignore-scrub$c_reset $c_arg""[FLAGS]$c_reset"
echo ""
echo "$c_head""Flags:$c_reset"
echo " $c_flag-h, --help$c_reset Show this help message"
echo " $c_flag-r, --reset$c_reset Clear the remembered skip list before checking"
echo " $c_flag-w, --warn$c_reset Read-only: print warnings instead of prompting"
echo " $c_flag-f, --force$c_reset Untrack every match immediately, no prompt"
echo " $c_flag-i, --individual$c_reset Prompt once per file instead of once for the group"
echo ""
echo "$c_head""Examples:$c_reset"
echo " $c_cmd""gitignore-scrub$c_reset $c_dim""# Interactive: prompt to untrack matches$c_reset"
echo " $c_cmd""gitignore-scrub --warn$c_reset $c_dim""# Read-only: for use in a git hook$c_reset"
echo " $c_cmd""gitignore-scrub --force$c_reset $c_dim""# Untrack every match, no prompt$c_reset"
echo " $c_cmd""gitignore-scrub --individual$c_reset $c_dim""# Prompt per file instead of as a group$c_reset"
echo " $c_cmd""gitignore-scrub --reset$c_reset $c_dim""# Reconsider previously declined files$c_reset"
return 0
end
if not git rev-parse --is-inside-work-tree >/dev/null 2>&1
set_color red --bold
echo "Error:" (set_color normal)"Not a git repository (or any parent directories)" >&2
return 1
end
set -l limit 5000
set -q GITIGNORE_SCRUB_LIMIT; and set limit $GITIGNORE_SCRUB_LIMIT
set -l tracked_count (git ls-files | count)
if test $tracked_count -gt $limit
return 0
end
set -l offenders (git ls-files -ci --exclude-standard)
set -q offenders[1]; or return 0
if set -q _flag_reset
git config --local --remove-section gitignore-scrub 2>/dev/null
end
set -l skip_list (git config --local --get-all gitignore-scrub.skip 2>/dev/null)
set -l pending
for f in $offenders
contains -- "$f" $skip_list; or set -a pending $f
end
set -q pending[1]; or return 0
if set -q _flag_warn
for f in $pending
set_color yellow --bold
echo -n "Warning: "
set_color normal
echo "$f is tracked but ignored"
end
return 1
end
if set -q _flag_force
git rm --cached -- $pending >/dev/null
echo (set_color green)"✔"(set_color normal)" Untracked "(count $pending)" file(s)."
return 0
end
if set -q _flag_individual
for f in $pending
read -P "Remove '$f' from git tracking? [y/N] " confirm
if string match -qir '^y' -- "$confirm"
git rm --cached -- "$f" >/dev/null
echo (set_color green)"✔"(set_color normal)" Untracked $f"
else
git config --local --add gitignore-scrub.skip "$f"
end
end
return 0
end
set_color yellow
echo (count $pending)" tracked file(s) now match .gitignore:"(set_color normal)
for f in $pending
echo " $f"
end
read -P "Remove from git tracking? [y/N] " confirm
if string match -qir '^y' -- "$confirm"
git rm --cached -- $pending >/dev/null
echo (set_color green)"✔"(set_color normal)" Untracked "(count $pending)" file(s)."
else
for f in $pending
git config --local --add gitignore-scrub.skip "$f"
end
echo (set_color brblack)"Remembered — won't ask again for these files."(set_color normal)
end
end
+12 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 04-git-and-version-control
#
# DEPENDENCIES
# gitui
#
# SYNOPSIS
# gitui [args...]
#
@@ -14,9 +17,17 @@
# ARGUMENTS
# args... Arguments forwarded to the gitui command
#
# EXIT STATUS
# 1 gitui is not installed
# * Exit status of gitui otherwise
#
# EXAMPLE
# gitui
function gitui --wraps='gitui' --description 'alias gitui=gitui -t mocha.ron'
command gitui -t frappe.ron $argv
if not type -q -f gitui
echo (set_color red)"Error: gitui is not installed."(set_color normal) >&2
return 1
end
command gitui -t frappe.ron $argv
end
+10 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 07-system-and-monitoring
#
# DEPENDENCIES
# loginctl
#
# SYNOPSIS
# lock
#
@@ -11,12 +14,18 @@
# Locks the current desktop session using loginctl lock-session.
#
# EXIT STATUS
# Exit status of loginctl lock-session
# 1 loginctl is not installed
# * Exit status of loginctl lock-session otherwise
#
# EXAMPLE
# lock
function lock --wraps='loginctl' --description 'alias lock=loginctl'
__fish_help_header (status current-function) $argv; and return 0
if not type -q loginctl
echo (set_color red)"Error: loginctl is not installed."(set_color normal) >&2
return 1
end
loginctl lock-session
end
+1 -1
View File
@@ -5,7 +5,7 @@
# 13-media-and-utilities
#
# DEPENDENCIES
# _fzf_preview_media, _fzf_wrapper, fd, fdfind, file, xdg-mime
# _fzf_preview_media, _fzf_wrapper, fd, fdfind, file, xdg-mime, mpv, vlc
#
# SYNOPSIS
# play-media [-p|--player <cmd>]
+10 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 07-system-and-monitoring
#
# DEPENDENCIES
# lsof
#
# SYNOPSIS
# ports
#
@@ -12,12 +15,18 @@
# port numbers and addresses without hostname resolution.
#
# EXIT STATUS
# Exit status of lsof
# 1 lsof is not installed
# * Exit status of lsof otherwise
#
# EXAMPLE
# ports
function ports --wraps='sudo' --description 'Show active network listeners'
__fish_help_header (status current-function) $argv; and return 0
if not type -q lsof
echo (set_color red)"Error: lsof is not installed."(set_color normal) >&2
return 1
end
sudo lsof -iTCP -sTCP:LISTEN -P -n
end
+10 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 07-system-and-monitoring
#
# DEPENDENCIES
# busctl
#
# SYNOPSIS
# screensleep
#
@@ -12,13 +15,19 @@
# PowerDevil "Turn Off Screen" global shortcut via busctl.
#
# EXIT STATUS
# Exit status of busctl
# 1 busctl is not installed
# * Exit status of busctl otherwise
#
# EXAMPLE
# screensleep
function screensleep --description 'Turn off the display using KDE PowerDevil'
__fish_help_header (status current-function) $argv; and return 0
if not type -q busctl
echo (set_color red)"Error: busctl is not installed."(set_color normal) >&2
return 1
end
# Optional: 1-second delay to ensure no keystrokes wake it immediately
sleep 1
busctl --user call \
+12 -1
View File
@@ -8,7 +8,7 @@
# integrations/window-mgmt
#
# DEPENDENCIES
# kitty
# kitty, wezterm
#
# SYNOPSIS
# split [-h | -v] [command...]
@@ -55,6 +55,17 @@ function split --description 'Run a command in a new terminal split'
return 1
end
# $TERM/$TERM_PROGRAM only prove the terminal type, not that its CLI
# binary is on $PATH -- e.g. sshing out from Kitty/WezTerm inherits the
# env var on the remote host without the binary. Check explicitly.
if test $is_kitty -eq 1; and not type -q kitty
echo "Error: 'split' detected Kitty but the kitty binary is not installed." >&2
return 1
else if test $is_wezterm -eq 1; and not type -q wezterm
echo "Error: 'split' detected WezTerm but the wezterm binary is not installed." >&2
return 1
end
set -l kitty_loc hsplit
set -l wez_loc --bottom
+12 -2
View File
@@ -8,7 +8,7 @@
# integrations/window-mgmt
#
# DEPENDENCIES
# kitty
# kitty, wezterm
#
# SYNOPSIS
# spwin [args...]
@@ -36,13 +36,23 @@ function spwin --wraps='~/.config/kitty/spawn-window.sh' --description 'spawn wi
return 1
end
# $TERM/$TERM_PROGRAM only prove the terminal type, not that its CLI
# binary is on $PATH -- e.g. sshing out from Kitty/WezTerm inherits the
# env var on the remote host without the binary. Check explicitly.
if test "$TERM" = xterm-kitty
if test -x ~/.config/kitty/spawn-window.sh
~/.config/kitty/spawn-window.sh $argv
else
else if type -q kitty
kitty @ launch --type=window $argv
else
echo "Error: 'spwin' detected Kitty but neither spawn-window.sh nor the kitty binary is available." >&2
return 1
end
else if test "$TERM_PROGRAM" = WezTerm
if not type -q wezterm
echo "Error: 'spwin' detected WezTerm but the wezterm binary is not installed." >&2
return 1
end
wezterm cli spawn $argv
else
echo "Error: The 'spwin' command requires Kitty or WezTerm." >&2
+12 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 13-media-and-utilities
#
# DEPENDENCIES
# systemd-inhibit, steam
#
# SYNOPSIS
# steam-dl
#
@@ -12,13 +15,21 @@
# or sleeping during active downloads.
#
# EXIT STATUS
# Exit status of steam (via systemd-inhibit)
# 1 systemd-inhibit or steam is not installed
# * Exit status of steam (via systemd-inhibit) otherwise
#
# EXAMPLE
# steam-dl
function steam-dl --description 'Run Steam while inhibiting system sleep'
__fish_help_header (status current-function) $argv; and return 0
for cmd in systemd-inhibit steam
if not type -q $cmd
echo (set_color red)"Error: $cmd is not installed."(set_color normal) >&2
return 1
end
end
echo "Inhibiting sleep while Steam downloads..."
systemd-inhibit --why="Active Download" --who="User" --what=idle:sleep steam
end
+17 -1
View File
@@ -8,7 +8,7 @@
# integrations/window-mgmt
#
# DEPENDENCIES
# kitty
# kitty, wezterm, konsole
#
# SYNOPSIS
# tab [args...]
@@ -42,11 +42,27 @@ function tab --description 'Spawn a new tab in the current terminal'
set dir "$PWD"
end
# $TERM/$TERM_PROGRAM/$KONSOLE_VERSION only prove the terminal type,
# not that its CLI binary is on $PATH -- e.g. sshing out from one of
# these inherits the env var on the remote host without the binary.
# Check explicitly.
if test "$TERM" = xterm-kitty
if not type -q kitty
echo "Error: 'tab' detected Kitty but the kitty binary is not installed." >&2
return 1
end
kitty @ launch --type=tab --cwd="$dir" $argv
else if test "$TERM_PROGRAM" = WezTerm
if not type -q wezterm
echo "Error: 'tab' detected WezTerm but the wezterm binary is not installed." >&2
return 1
end
wezterm cli spawn --cwd "$dir" $argv
else if set -q KONSOLE_VERSION
if not type -q konsole
echo "Error: 'tab' detected Konsole but the konsole binary is not installed." >&2
return 1
end
konsole --new-tab --workdir "$dir" $argv
else
echo "Error: No supported terminal found. Try Kitty, WezTerm, or Konsole." >&2
+9 -1
View File
@@ -4,6 +4,9 @@
# CATEGORY
# 14-miscellaneous
#
# DEPENDENCIES
# systemd-inhibit
#
# SYNOPSIS
# wake-lock <command> [args...]
#
@@ -17,7 +20,7 @@
#
# EXIT STATUS
# 0 Command ran and completed
# 1 No command provided
# 1 No command provided, or systemd-inhibit is not installed
#
# EXAMPLE
# wake-lock rsync -avz src/ dest/
@@ -30,6 +33,11 @@ function wake-lock --description 'Run a command while inhibiting system sleep'
return 1
end
if not type -q systemd-inhibit
echo (set_color red)"Error: systemd-inhibit is not installed."(set_color normal) >&2
return 1
end
echo "Running '$argv' with sleep inhibition active..."
# --what=idle:sleep prevents the system from auto-sleeping or being suspended
+465
View File
@@ -0,0 +1,465 @@
#!/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)
echo ""
echo "== _agents_init_sync_instructions: subdir with only a real AGENTS.md =="
set -l r7 (new_repo)
mkdir -p $r7/AGENTS $r7/onlyagents
echo onlyagents >$r7/onlyagents/AGENTS.md
set -l out7 (_agents_init_sync_instructions $r7 $r7/AGENTS onlyagents)
set -l rc7 $status
check "subdir lone AGENTS.md: exits 0" 0 "$rc7"
check "subdir lone AGENTS.md: mirror AGENTS.md created" onlyagents (cat $r7/AGENTS/onlyagents/AGENTS.md)
check "subdir lone AGENTS.md: no mirror CLAUDE.md" false (test -e $r7/AGENTS/onlyagents/CLAUDE.md; and echo true; or echo false)
check "subdir lone AGENTS.md: project AGENTS.md links to mirror" ../AGENTS/onlyagents/AGENTS.md (readlink $r7/onlyagents/AGENTS.md)
check "subdir lone AGENTS.md: no project CLAUDE.md" false (test -e $r7/onlyagents/CLAUDE.md; and echo true; or echo false)
echo ""
echo "== agents-init: end-to-end CLAUDE.md retirement =="
set -l e1 (new_repo)
echo root-real >$e1/CLAUDE.md
mkdir -p $e1/functions
echo scoped-real >$e1/functions/CLAUDE.md
pushd $e1 >/dev/null
set -l ercA (agents-init --agents --silent 2>/dev/null; echo $status)
popd >/dev/null
check "e2e: exits 0" 0 "$ercA"
check "e2e: root CLAUDE.md gone" false (test -e $e1/CLAUDE.md; and echo true; or echo false)
check "e2e: root AGENTS.md links to mirror" AGENTS/AGENTS.md (readlink $e1/AGENTS.md)
check "e2e: root content preserved" root-real (cat $e1/AGENTS.md)
check "e2e: functions CLAUDE.md gone" false (test -e $e1/functions/CLAUDE.md; and echo true; or echo false)
check "e2e: functions AGENTS.md links to mirror" ../AGENTS/functions/AGENTS.md (readlink $e1/functions/AGENTS.md)
check "e2e: functions content preserved" scoped-real (cat $e1/functions/AGENTS.md)
check "e2e: no CLAUDE.md left anywhere under AGENTS/" "" (find $e1/AGENTS -name CLAUDE.md)
check "e2e: gitignore covers AGENTS.md unanchored" true (grep -qx 'AGENTS.md' $e1/.gitignore; and echo true; or echo false)
pushd $e1 >/dev/null
set -l ercB (agents-init --agents --quiet 2>/dev/null)
popd >/dev/null
check "e2e: idempotent second run prints nothing" "" "$ercB"
echo ""
echo "== agents-init: this-repo-shaped inversion is fixed live =="
set -l e2 (new_repo)
mkdir -p $e2/AGENTS/docs $e2/docs
echo docs-content >$e2/AGENTS/docs/CLAUDE.md
ln -s CLAUDE.md $e2/AGENTS/docs/AGENTS.md
ln -s CLAUDE.md $e2/docs/AGENTS.md
ln -s ../AGENTS/docs/CLAUDE.md $e2/docs/CLAUDE.md
pushd $e2 >/dev/null
set -l ercC (agents-init --agents --silent 2>/dev/null; echo $status)
popd >/dev/null
check "inversion fix: exits 0" 0 "$ercC"
check "inversion fix: mirror AGENTS.md real" docs-content (cat $e2/AGENTS/docs/AGENTS.md)
check "inversion fix: mirror CLAUDE.md gone" false (test -e $e2/AGENTS/docs/CLAUDE.md; and echo true; or echo false)
check "inversion fix: project docs/AGENTS.md relinked directly" ../AGENTS/docs/AGENTS.md (readlink $e2/docs/AGENTS.md)
check "inversion fix: project docs/CLAUDE.md gone" false (test -e $e2/docs/CLAUDE.md; and echo true; or echo false)
echo ""
echo "== agents-init: stale anchored gitignore lines migrated =="
set -l e3 (new_repo)
mkdir -p $e3/AGENTS
echo real-agents >$e3/AGENTS/AGENTS.md
ln -s AGENTS/AGENTS.md $e3/AGENTS.md
printf '%s\n' AGENTS/ "/AGENTS.md" "/CLAUDE.md" >$e3/.gitignore
pushd $e3 >/dev/null
set -l ercD (agents-init --agents --silent 2>/dev/null; echo $status)
popd >/dev/null
check "stale gitignore: exits 0" 0 "$ercD"
check "stale gitignore: anchored /AGENTS.md line removed" false (grep -qxF '/AGENTS.md' $e3/.gitignore; and echo true; or echo false)
check "stale gitignore: anchored /CLAUDE.md line removed" false (grep -qxF '/CLAUDE.md' $e3/.gitignore; and echo true; or echo false)
check "stale gitignore: unanchored AGENTS.md pattern present" true (grep -qxF 'AGENTS.md' $e3/.gitignore; and echo true; or echo false)
echo ""
echo "== agents-init: discovery stays out of nested repos and dot-dirs =="
set -l e4 (new_repo)
mkdir -p $e4/sub $e4/.claude $e4/vendor/other/AGENTS
git -C $e4/sub init -q
mkdir -p $e4/.gemini
echo nested-claude >$e4/sub/CLAUDE.md
echo tool-claude >$e4/.claude/CLAUDE.md
echo tool-agents >$e4/.gemini/AGENTS.md
echo foreign-mirror >$e4/vendor/other/AGENTS/CLAUDE.md
echo own-docs >$e4/vendor/CLAUDE.md
pushd $e4 >/dev/null
set -l ercE (agents-init --agents --silent 2>/dev/null; echo $status)
popd >/dev/null
check "containment: exits 0" 0 "$ercE"
check "containment: nested repo got no AGENTS.md" false (test -e $e4/sub/AGENTS.md -o -L $e4/sub/AGENTS.md; and echo true; or echo false)
check "containment: nested repo CLAUDE.md still real" nested-claude (test -L $e4/sub/CLAUDE.md; or cat $e4/sub/CLAUDE.md)
check "containment: no mirror for nested repo" false (test -e $e4/AGENTS/sub; and echo true; or echo false)
check "containment: .claude/CLAUDE.md untouched" tool-claude (test -L $e4/.claude/CLAUDE.md; or cat $e4/.claude/CLAUDE.md)
check "containment: .gemini/AGENTS.md untouched" tool-agents (test -L $e4/.gemini/AGENTS.md; or cat $e4/.gemini/AGENTS.md)
check "containment: no mirror for .claude" false (test -e $e4/AGENTS/.claude; and echo true; or echo false)
check "containment: foreign AGENTS/ dir untouched" foreign-mirror (test -L $e4/vendor/other/AGENTS/CLAUDE.md; or cat $e4/vendor/other/AGENTS/CLAUDE.md)
check "containment: ordinary subdir still discovered" own-docs (cat $e4/AGENTS/vendor/AGENTS.md)
check "containment: ordinary subdir linked" ../AGENTS/vendor/AGENTS.md (readlink $e4/vendor/AGENTS.md)
echo ""
echo "== agents-init: non-git root syncs only itself =="
set -l n1 (mktemp -d)
set -ga TMPDIRS $n1
mkdir -p $n1/sub
echo root-agents >$n1/AGENTS.md
echo sub-agents >$n1/sub/AGENTS.md
mkdir -p $n1/sub2
echo sub-claude >$n1/sub2/CLAUDE.md
pushd $n1 >/dev/null
set -l ercF (set -lx GIT_CEILING_DIRECTORIES (path dirname $n1); agents-init --agents --silent 2>/dev/null; echo $status)
popd >/dev/null
check "non-git: exits 0" 0 "$ercF"
check "non-git: root adopted into mirror" root-agents (cat $n1/AGENTS/AGENTS.md)
check "non-git: root linked" AGENTS/AGENTS.md (readlink $n1/AGENTS.md)
check "non-git: subdir AGENTS.md untouched" sub-agents (test -L $n1/sub/AGENTS.md; or cat $n1/sub/AGENTS.md)
check "non-git: subdir CLAUDE.md untouched" sub-claude (test -L $n1/sub2/CLAUDE.md; or cat $n1/sub2/CLAUDE.md)
check "non-git: no mirror for subdirs" false (test -e $n1/AGENTS/sub -o -e $n1/AGENTS/sub2; and echo true; or echo false)
echo ""
echo "== _agents_init_sync_instructions: real file written after mirror settled =="
set -l s1 (new_repo)
mkdir -p $s1/AGENTS
echo settled >$s1/AGENTS/AGENTS.md
echo settled >$s1/AGENTS.md
echo settled >$s1/CLAUDE.md
set -l outS1 (_agents_init_sync_instructions $s1 $s1/AGENTS . 2>/dev/null)
set -l rcS1 $status
check "settled identical: exits 0" 0 "$rcS1"
check "settled identical: AGENTS.md replaced by link" AGENTS/AGENTS.md (readlink $s1/AGENTS.md)
check "settled identical: duplicate CLAUDE.md dropped" false (test -e $s1/CLAUDE.md; and echo true; or echo false)
check "settled identical: mirror intact" settled (cat $s1/AGENTS/AGENTS.md)
set -l s2 (new_repo)
mkdir -p $s2/AGENTS
echo settled >$s2/AGENTS/AGENTS.md
echo rewritten >$s2/AGENTS.md
set -l errS2 (_agents_init_sync_instructions $s2 $s2/AGENTS . 2>&1 >/dev/null)
set -l rcS2 $status
check "settled differs (AGENTS.md): exits 0" 0 "$rcS2"
check "settled differs (AGENTS.md): real file kept" rewritten (test -L $s2/AGENTS.md; or cat $s2/AGENTS.md)
check "settled differs (AGENTS.md): warned on stderr" true (string match -q '*differ*' -- "$errS2"; and echo true; or echo false)
check "settled differs (AGENTS.md): mirror intact" settled (cat $s2/AGENTS/AGENTS.md)
set -l s3 (new_repo)
mkdir -p $s3/AGENTS
echo settled >$s3/AGENTS/AGENTS.md
ln -s AGENTS/AGENTS.md $s3/AGENTS.md
echo recreated >$s3/CLAUDE.md
set -l errS3 (_agents_init_sync_instructions $s3 $s3/AGENTS . 2>&1 >/dev/null)
set -l rcS3 $status
check "settled differs (CLAUDE.md): exits 0" 0 "$rcS3"
check "settled differs (CLAUDE.md): real file kept" recreated (cat $s3/CLAUDE.md)
check "settled differs (CLAUDE.md): warned on stderr" true (string match -q '*differ*' -- "$errS3"; and echo true; or echo false)
check "settled differs (CLAUDE.md): link intact" AGENTS/AGENTS.md (readlink $s3/AGENTS.md)
echo ""
echo "== _agents_init_sync_instructions: deliberately git-tracked files are protected =="
# Tracked (committed) + populated .gitignore: left alone.
set -l p1 (new_repo)
mkdir -p $p1/AGENTS
echo node_modules/ >$p1/.gitignore
echo team-rules >$p1/AGENTS.md
git -C $p1 add .gitignore AGENTS.md
git -C $p1 commit -qm init
set -l errP1 (_agents_init_sync_instructions $p1 $p1/AGENTS . 2>&1 >/dev/null)
set -l rcP1 $status
check "tracked+ignore: exits 0" 0 "$rcP1"
check "tracked+ignore: still a real file" team-rules (test -L $p1/AGENTS.md; or cat $p1/AGENTS.md)
check "tracked+ignore: mirror not populated" false (test -e $p1/AGENTS/AGENTS.md; and echo true; or echo false)
check "tracked+ignore: still tracked, unmodified" "" (git -C $p1 status --porcelain -- AGENTS.md)
check "tracked+ignore: warned on stderr, naming the file" true (string match -q '*AGENTS.md tracked by git*' -- "$errP1"; and echo true; or echo false)
# Untracked because gitignored + populated .gitignore: adopted normally.
set -l p2 (new_repo)
mkdir -p $p2/AGENTS
echo 'AGENTS.md' >$p2/.gitignore
git -C $p2 add .gitignore
git -C $p2 commit -qm init
echo ignored-local >$p2/AGENTS.md
set -l outP2 (_agents_init_sync_instructions $p2 $p2/AGENTS . 2>/dev/null)
check "gitignored untracked: adopted into mirror" ignored-local (cat $p2/AGENTS/AGENTS.md)
check "gitignored untracked: linked" AGENTS/AGENTS.md (readlink $p2/AGENTS.md)
# Tracked but no .gitignore at all (bootstrap): adopted anyway.
set -l p3 (new_repo)
mkdir -p $p3/AGENTS
echo bootstrap >$p3/AGENTS.md
git -C $p3 add AGENTS.md
git -C $p3 commit -qm init
set -l outP3 (_agents_init_sync_instructions $p3 $p3/AGENTS . 2>/dev/null)
check "tracked, no .gitignore: adopted into mirror" bootstrap (cat $p3/AGENTS/AGENTS.md)
check "tracked, no .gitignore: linked" AGENTS/AGENTS.md (readlink $p3/AGENTS.md)
# Tracked but .gitignore empty: same bootstrap rule.
set -l p3b (new_repo)
mkdir -p $p3b/AGENTS
touch $p3b/.gitignore
echo bootstrap-empty >$p3b/AGENTS.md
git -C $p3b add .gitignore AGENTS.md
git -C $p3b commit -qm init
set -l outP3b (_agents_init_sync_instructions $p3b $p3b/AGENTS . 2>/dev/null)
check "tracked, empty .gitignore: adopted into mirror" bootstrap-empty (cat $p3b/AGENTS/AGENTS.md)
check "tracked, empty .gitignore: linked" AGENTS/AGENTS.md (readlink $p3b/AGENTS.md)
# Staged, never committed + populated .gitignore: staged is enough.
set -l p4 (new_repo)
mkdir -p $p4/AGENTS
echo node_modules/ >$p4/.gitignore
echo staged-only >$p4/AGENTS.md
git -C $p4 add AGENTS.md
set -l errP4 (_agents_init_sync_instructions $p4 $p4/AGENTS . 2>&1 >/dev/null)
check "staged-only: still a real file" staged-only (test -L $p4/AGENTS.md; or cat $p4/AGENTS.md)
check "staged-only: mirror not populated" false (test -e $p4/AGENTS/AGENTS.md; and echo true; or echo false)
check "staged-only: warned on stderr" true (string match -q '*tracked by git*' -- "$errP4"; and echo true; or echo false)
# Never added, not matched by .gitignore, populated .gitignore: adopted.
set -l p5 (new_repo)
mkdir -p $p5/AGENTS
echo node_modules/ >$p5/.gitignore
git -C $p5 add .gitignore
git -C $p5 commit -qm init
echo brand-new >$p5/CLAUDE.md
set -l outP5 (_agents_init_sync_instructions $p5 $p5/AGENTS . 2>/dev/null)
check "never added: adopted into mirror" brand-new (cat $p5/AGENTS/AGENTS.md)
check "never added: linked" AGENTS/AGENTS.md (readlink $p5/AGENTS.md)
check "never added: CLAUDE.md gone" false (test -e $p5/CLAUDE.md; and echo true; or echo false)
# A pair where only CLAUDE.md is tracked: both left, only CLAUDE.md named.
set -l p5b (new_repo)
mkdir -p $p5b/AGENTS
echo node_modules/ >$p5b/.gitignore
echo pair >$p5b/CLAUDE.md
git -C $p5b add .gitignore CLAUDE.md
git -C $p5b commit -qm init
echo pair >$p5b/AGENTS.md
set -l errP5b (_agents_init_sync_instructions $p5b $p5b/AGENTS . 2>&1 >/dev/null)
check "pair, one tracked: AGENTS.md left real" pair (test -L $p5b/AGENTS.md; or cat $p5b/AGENTS.md)
check "pair, one tracked: CLAUDE.md left real" pair (test -L $p5b/CLAUDE.md; or cat $p5b/CLAUDE.md)
check "pair, one tracked: mirror not populated" false (test -e $p5b/AGENTS/AGENTS.md; and echo true; or echo false)
check "pair, one tracked: names only the tracked file" true (string match -q '*: CLAUDE.md tracked by git*' -- "$errP5b"; and echo true; or echo false)
# Settled mirror (step 4): a tracked real file arriving later is protected,
# whether it differs from the mirror or is byte-identical to it.
set -l p7 (new_repo)
mkdir -p $p7/AGENTS
echo settled >$p7/AGENTS/AGENTS.md
ln -s AGENTS/AGENTS.md $p7/AGENTS.md
echo node_modules/ >$p7/.gitignore
echo team-claude >$p7/CLAUDE.md
git -C $p7 add .gitignore CLAUDE.md
git -C $p7 commit -qm init
set -l errP7 (_agents_init_sync_instructions $p7 $p7/AGENTS . 2>&1 >/dev/null)
check "settled, tracked differs: exits 0" 0 "$status"
check "settled, tracked differs: CLAUDE.md kept" team-claude (test -L $p7/CLAUDE.md; or cat $p7/CLAUDE.md)
check "settled, tracked differs: protection wins over diff warning" true (string match -q '*CLAUDE.md tracked by git*' -- "$errP7"; and echo true; or echo false)
check "settled, tracked differs: mirror intact" settled (cat $p7/AGENTS/AGENTS.md)
set -l p7b (new_repo)
mkdir -p $p7b/AGENTS
echo settled >$p7b/AGENTS/AGENTS.md
echo node_modules/ >$p7b/.gitignore
echo settled >$p7b/AGENTS.md
git -C $p7b add .gitignore AGENTS.md
git -C $p7b commit -qm init
set -l errP7b (_agents_init_sync_instructions $p7b $p7b/AGENTS . 2>&1 >/dev/null)
check "settled, tracked identical: still a real file" true (test -f $p7b/AGENTS.md; and not test -L $p7b/AGENTS.md; and echo true; or echo false)
check "settled, tracked identical: warned on stderr" true (string match -q '*AGENTS.md tracked by git*' -- "$errP7b"; and echo true; or echo false)
echo ""
echo "== agents-init: tracked subdir file protected, generated dirs pruned =="
set -l e5 (new_repo)
echo node_modules/ >$e5/.gitignore
mkdir -p $e5/team $e5/build $e5/dist $e5/out $e5/target
echo team-shared >$e5/team/CLAUDE.md
for g in build dist out target
echo gen-$g >$e5/$g/AGENTS.md
end
git -C $e5 add .gitignore team/CLAUDE.md build/AGENTS.md
git -C $e5 commit -qm init
pushd $e5 >/dev/null
set -l ercG (agents-init --agents --silent 2>/dev/null; echo $status)
popd >/dev/null
check "subdir protection: exits 0" 0 "$ercG"
check "subdir protection: team/CLAUDE.md still real" team-shared (test -L $e5/team/CLAUDE.md; or cat $e5/team/CLAUDE.md)
check "subdir protection: no team/AGENTS.md created" false (test -e $e5/team/AGENTS.md -o -L $e5/team/AGENTS.md; and echo true; or echo false)
check "subdir protection: no mirror file for team" false (test -e $e5/AGENTS/team/AGENTS.md; and echo true; or echo false)
check "subdir protection: team/CLAUDE.md unmodified in git" "" (git -C $e5 status --porcelain -- team/CLAUDE.md)
check "subdir protection: root still scaffolded" AGENTS/AGENTS.md (readlink $e5/AGENTS.md)
for g in build dist out target
check "pruned $g/: AGENTS.md untouched" gen-$g (test -L $e5/$g/AGENTS.md; or cat $e5/$g/AGENTS.md)
check "pruned $g/: no mirror" false (test -e $e5/AGENTS/$g; and echo true; or echo false)
end
echo ""
echo "== _agents_init_path_is_protected: glob characters in filenames not false-matched =="
# Glob character filenames (e.g. a[1]) should be treated literally, not as glob patterns.
# A committed file a1/AGENTS.md should NOT falsely protect an untracked a[1]/AGENTS.md
# when checking if a[1]/AGENTS.md is protected.
set -l g1 (new_repo)
mkdir -p $g1/a1
echo committed-a1 >$g1/a1/AGENTS.md
git -C $g1 add a1/AGENTS.md
git -C $g1 commit -qm init
mkdir -p "$g1/a[1]"
echo untracked-bracket >"$g1/a[1]/AGENTS.md"
echo node_modules/ >$g1/.gitignore
git -C $g1 add .gitignore
git -C $g1 commit -qm add-ignore
# Before the fix, this would return 0 (protected) due to glob matching a1/AGENTS.md
# After the fix, it should return 1 (not protected) since a[1]/AGENTS.md is untracked
_agents_init_path_is_protected $g1 "$g1/a[1]/AGENTS.md"
set -l protected $status
check "glob false-match: untracked a[1]/AGENTS.md is not protected" 1 "$protected"
cleanup
report
+87 -4
View File
@@ -20,6 +20,16 @@
source (realpath (dirname (status filename)))/lib.fish
set -p fish_function_path $repo_root/functions
# Isolated runs sandbox XDG_CONFIG_HOME to an empty dir, and __fish_config_dir
# is read-only under --no-config, so gi's bundled boilerplate fallback
# (data/gi/boilerplate.gitignore) needs the real file reachable wherever
# __fish_config_dir actually points. Same pattern as
# test-string-and-expansion.fish uses for rand_string's data/words/.
if not test -d "$__fish_config_dir/data/gi"
mkdir -p "$__fish_config_dir/data"
ln -sf "$repo_root/data/gi" "$__fish_config_dir/data/gi"
end
set -gx TERM xterm-256color
set -g TMPDIRS
@@ -177,15 +187,88 @@ check "gi -l: returns 0 per contract" 0 $status
# stdout mode with valid content
reset_mocks
set -gx MOCK_CURL_BODY "# Python gitignore\n*.pyc\n__pycache__/"
set -l stdout_out (gi -s python)
check "gi -s: prints fetched patterns to stdout" "# Python gitignore\n*.pyc\n__pycache__/" "$stdout_out"
set -l stdout_out (gi -o python)
check "gi -o: prints fetched patterns to stdout" "# Python gitignore\n*.pyc\n__pycache__/" "$stdout_out"
# stdout mode with network failure / 404 (curl exit 22)
reset_mocks
set -gx MOCK_CURL_STATUS 22
set -gx MOCK_CURL_BODY ""
gi -s invalid_target >/dev/null 2>&1
check "gi -s: API failure returns 1" 1 $status
gi -o invalid_target >/dev/null 2>&1
check "gi -o: API failure returns 1" 1 $status
# Regression: default mode (no args/flags) + --stdout must not touch .gitignore
reset_mocks
set -l stdout_repo (new_repo)
set -l boilerplate_file (mktemp)
set -ga TMPDIRS $boilerplate_file
printf '%s\n' '*.log' >$boilerplate_file
begin
set -l prev_pwd $PWD
builtin cd $stdout_repo
set -gx GITIGNORE_BOILERPLATE $boilerplate_file
set -l default_stdout_out (echo "" | gi -o -s)
set -e GITIGNORE_BOILERPLATE
builtin cd $prev_pwd
check "gi -o (default mode): boilerplate goes to stdout" "*.log" "$default_stdout_out"
check "gi -o (default mode): does not create .gitignore" false (test -f "$stdout_repo/.gitignore"; and echo true; or echo false)
end
# Regression: stdout mode must return 0 on success, not leak needs_git's test status
reset_mocks
set -gx MOCK_CURL_BODY "# Python gitignore\n*.pyc"
gi -o python >/dev/null 2>&1
check "gi -o: success returns 0" 0 $status
# Boilerplate: unset $GITIGNORE_BOILERPLATE falls back to the bundled standard template
reset_mocks
set -l fallback_repo (new_repo)
begin
set -l prev_pwd $PWD
builtin cd $fallback_repo
set -e GITIGNORE_BOILERPLATE
gi -b -s >/dev/null 2>&1
set -l rc $status
builtin cd $prev_pwd
check "gi -b (no env var): falls back returns 0" 0 $rc
check "gi -b (no env var): appends bundled template content" true \
(grep -qF ".Trash-*" "$fallback_repo/.gitignore"; and echo true; or echo false)
end
# Boilerplate: -c/--custom overrides $GITIGNORE_BOILERPLATE
reset_mocks
set -l custom_repo (new_repo)
set -l custom_file (mktemp)
set -ga TMPDIRS $custom_file
set -l env_file (mktemp)
set -ga TMPDIRS $env_file
printf '%s\n' from-custom-flag/ >$custom_file
printf '%s\n' from-env-var/ >$env_file
begin
set -l prev_pwd $PWD
builtin cd $custom_repo
set -gx GITIGNORE_BOILERPLATE $env_file
gi -c $custom_file -s >/dev/null 2>&1
set -e GITIGNORE_BOILERPLATE
builtin cd $prev_pwd
check "gi -c: uses custom template over env var" true \
(grep -qF "from-custom-flag/" "$custom_repo/.gitignore"; and echo true; or echo false)
check "gi -c: does not use env var template" false \
(grep -qF "from-env-var/" "$custom_repo/.gitignore"; and echo true; or echo false)
end
# Boilerplate: -c alone (no -b) still runs boilerplate mode, and a missing
# custom template reports an error without touching .gitignore
reset_mocks
set -l custom_missing_repo (new_repo)
begin
set -l prev_pwd $PWD
builtin cd $custom_missing_repo
gi -c /nonexistent/template.gitignore -s >/dev/null 2>&1
builtin cd $prev_pwd
check "gi -c: missing template leaves no .gitignore" false \
(test -f "$custom_missing_repo/.gitignore"; and echo true; or echo false)
end
# Append mode outside git repository
reset_mocks