Compare commits
36
Commits
fceddfc43f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec700cece1
|
||
|
|
86aea9d5ef
|
||
|
|
1f3c8f41e7
|
||
|
|
911c6e95ed
|
||
|
|
8b08b4d5e5
|
||
|
|
1d714a84d0
|
||
|
|
e01e83bd36
|
||
|
|
82bea2f539
|
||
|
|
de92277226
|
||
|
|
2baa91f275
|
||
|
|
6a410a0fce
|
||
|
|
c3f134b5b4
|
||
|
|
4e5f0078ce
|
||
|
|
4240a04754
|
||
|
|
ecfb93a818
|
||
|
|
a776d4d12d
|
||
|
|
731661b581
|
||
|
|
c48af2fa23
|
||
|
|
028478940f
|
||
|
|
27b6f5d263
|
||
|
|
59ace6fe6b
|
||
|
|
344ee60acd
|
||
|
|
1b9f1558c4
|
||
|
|
ea1575f902
|
||
|
|
4797af85f3
|
||
|
|
6074687a80
|
||
|
|
7e3d48ddac
|
||
|
|
37fea155b3
|
||
|
|
355688c134
|
||
|
|
9e8d29cc30
|
||
|
|
af65092102
|
||
|
|
35a48ac868
|
||
|
|
8dcbc62359
|
||
|
|
17a95abebe
|
||
|
|
b03ba7490e
|
||
|
|
3bbda31eff
|
+136
-12
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
---
|
||||
title: Fish Shell Configuration
|
||||
title: Rootiest Fish Configuration
|
||||
description: Reference manual for the rootiest fish configuration.
|
||||
manTitle: DESCRIPTION
|
||||
sidebar:
|
||||
|
||||
+116
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user