Merge pull request 'feat(agents-init): retire CLAUDE.md, discover and normalize AGENTS.md in every subdirectory' (#177) from docs/retire-claude-md-for-agents-md into main
This commit was merged in pull request #177.
This commit is contained in:
+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
|
||||
|
||||
|
||||
+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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1674,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user