dotagents-cli 0.6.3__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/CHANGELOG.md +41 -1
  2. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/PKG-INFO +8 -8
  3. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/README.md +7 -7
  4. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/guide/commands.md +28 -15
  5. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/guide/install.md +1 -1
  6. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/guide/overlays.md +17 -4
  7. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/index.md +2 -1
  8. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/AGENTS.md +47 -14
  9. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/__init__.py +1 -1
  10. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_agents.py +14 -2
  11. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_context.py +47 -3
  12. dotagents_cli-0.7.1/src/dotagents/_ignore.py +146 -0
  13. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlay/dotagents/templates/AGENTS.md +6 -0
  14. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlays.py +90 -59
  15. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/__init__.py +23 -2
  16. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/overlays.py +32 -33
  17. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_cli_main.py +2 -2
  18. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_cmds_discovery.py +38 -0
  19. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_overlays.py +59 -20
  20. dotagents_cli-0.7.1/tests/test_ignore.py +58 -0
  21. dotagents_cli-0.7.1/tests/test_local_additions.py +86 -0
  22. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_overlays_cli.py +5 -10
  23. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_overlays_command.py +7 -5
  24. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_wire_hooks.py +25 -1
  25. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/.gitignore +0 -0
  26. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/LICENSE +0 -0
  27. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/agents.md +0 -0
  28. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/cli.md +0 -0
  29. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/commands.md +0 -0
  30. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/context.md +0 -0
  31. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/env.md +0 -0
  32. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/fs.md +0 -0
  33. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/hooks.md +0 -0
  34. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/merge.md +0 -0
  35. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/overlays.md +0 -0
  36. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/reference.md +0 -0
  37. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/resources.md +0 -0
  38. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/scope.md +0 -0
  39. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/skills.md +0 -0
  40. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/sources.md +0 -0
  41. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/api/wrappers.md +0 -0
  42. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/changelog.md +0 -0
  43. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/guide/authoring.md +0 -0
  44. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/docs/guide/private-sync.md +0 -0
  45. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/install.py +0 -0
  46. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/mkdocs.yml +0 -0
  47. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/pyproject.toml +0 -0
  48. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/__main__.py +0 -0
  49. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_env.py +0 -0
  50. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_fs.py +0 -0
  51. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_hooks.py +0 -0
  52. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_merge.py +0 -0
  53. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlay/dotagents/cmds/findings.py +0 -0
  54. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlay/dotagents/cmds/launch.py +0 -0
  55. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlay/dotagents/hooks/preinvocation_antigravity_context.py +0 -0
  56. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlay/dotagents/hooks/pretooluse_codex_env.py +0 -0
  57. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlay/dotagents/hooks/sessionstart_codex_context.py +0 -0
  58. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_overlay/dotagents/templates/PROJECT.md +0 -0
  59. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_resources.py +0 -0
  60. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_scope.py +0 -0
  61. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_skills.py +0 -0
  62. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_sources.py +0 -0
  63. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/_wrappers.py +0 -0
  64. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/_common.py +0 -0
  65. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/about.py +0 -0
  66. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/build_pyz.py +0 -0
  67. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/context.py +0 -0
  68. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/env.py +0 -0
  69. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/init.py +0 -0
  70. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/cli/path.py +0 -0
  71. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/src/dotagents/py.typed +0 -0
  72. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/_helpers.py +0 -0
  73. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/_shell.py +0 -0
  74. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/conftest.py +0 -0
  75. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_about.py +0 -0
  76. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_agents.py +0 -0
  77. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_api_header.py +0 -0
  78. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_audit_leak.py +0 -0
  79. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_build_pyz.py +0 -0
  80. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_cloud_setup.py +0 -0
  81. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_context.py +0 -0
  82. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_env.py +0 -0
  83. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_env_cli_scope.py +0 -0
  84. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_env_format.py +0 -0
  85. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_env_into.py +0 -0
  86. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_env_regressions.py +0 -0
  87. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_env_safety.py +0 -0
  88. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_findings_cmd.py +0 -0
  89. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_adapters.py +0 -0
  90. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_cli.py +0 -0
  91. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_env.py +0 -0
  92. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_hooks.py +0 -0
  93. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_init.py +0 -0
  94. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_packaging.py +0 -0
  95. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_scope_context.py +0 -0
  96. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_fix_skills_adapters.py +0 -0
  97. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_hooks.py +0 -0
  98. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_init_base.py +0 -0
  99. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_last_mile.py +0 -0
  100. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_launch_cmd.py +0 -0
  101. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_mcp.py +0 -0
  102. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_overlay_libs.py +0 -0
  103. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_path_timeout_frontmatter.py +0 -0
  104. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_powershell_env_hook.py +0 -0
  105. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_review_security.py +0 -0
  106. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_scope.py +0 -0
  107. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_sources.py +0 -0
  108. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_sources_uri.py +0 -0
  109. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tests/test_wrappers.py +0 -0
  110. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tools/audit.py +0 -0
  111. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tools/cloud-setup.sh +0 -0
  112. {dotagents_cli-0.6.3 → dotagents_cli-0.7.1}/tools/pyz_smoke.sh +0 -0
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.1] - 2026-10-01
11
+
12
+ ### Added
13
+
14
+ - Your own additions to an installed overlay's file live at the same path under the
15
+ store root (`~/.agents/kb/RUST.md` for the rust overlay's `kb/RUST.md`, or
16
+ `<project>/.agents/kb/RUST.md`), where `overlays sync` never touches them. The
17
+ base `AGENTS.md` tells agents to read them alongside the overlay's file, and
18
+ `dotagents context` lists the ones it finds (JSON: `local_additions`).
19
+
20
+ ## [0.7.0] - 2026-10-01
21
+
22
+ ### Changed
23
+
24
+ - `overlays sync`, and `overlays add` of an overlay already installed, make the
25
+ install exactly what the source ships: changed files are replaced and files the
26
+ source does not ship are removed, including ones edited or added in the install
27
+ (each backed up under `<store>/install_backup/` first). What the `.gitignore` /
28
+ `.ignore` at the overlay's root match (read as git reads a `.gitignore`; one in a
29
+ subdirectory is an ordinary file), and tool caches, are the install's own and left
30
+ alone. Previously an edited file was kept unless `--overwrite`.
31
+ - `--prune` (on `sync`, and now on `add`) clears what `.gitignore` / `.ignore`
32
+ protect and the tool caches too, leaving exactly the upstream files.
33
+
34
+ ### Fixed
35
+
36
+ - A command module whose class declares no `_parsername_` is named by its
37
+ class in kebab case again (`LeakCheck` -> `dotagents leak-check`). Since 0.6.2
38
+ (duho 0.6.0) such a command was only reachable as `dotagents LeakCheck`.
39
+ - The Claude Code CwdChanged hook keeps one `AGENTS_PROJECT_ROOT` pin in
40
+ `$CLAUDE_ENV_FILE`, replacing the previous one, instead of appending a line on
41
+ every `cd` (the file is inlined into every Bash command). Other lines in the file
42
+ are kept. Re-run `dotagents init -g` to update an installed hook.
43
+
44
+ ### Removed
45
+
46
+ - `overlays sync --overwrite`: replacing is what `sync` always does now.
47
+
10
48
  ## [0.6.3] - 2026-09-30
11
49
 
12
50
  ### Added
@@ -1334,7 +1372,9 @@ Patch release: the PATH/POSIX-conversion fix above (the only change since 0.3.0)
1334
1372
  affected module sources (`dotagents.cli`, `duho.presets`) to extracted temp files
1335
1373
  before dispatch; a no-op for a plain install.
1336
1374
 
1337
- [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.6.3...HEAD
1375
+ [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.7.1...HEAD
1376
+ [0.7.1]: https://github.com/jose-pr/dotagents/compare/v0.7.0...v0.7.1
1377
+ [0.7.0]: https://github.com/jose-pr/dotagents/compare/v0.6.3...v0.7.0
1338
1378
  [0.6.3]: https://github.com/jose-pr/dotagents/compare/v0.6.2...v0.6.3
1339
1379
  [0.6.2]: https://github.com/jose-pr/dotagents/compare/v0.6.1...v0.6.2
1340
1380
  [0.6.1]: https://github.com/jose-pr/dotagents/compare/v0.6.0...v0.6.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dotagents-cli
3
- Version: 0.6.3
3
+ Version: 0.7.1
4
4
  Summary: The dotagents CLI: install and manage a portable ~/.agents config for AI coding agents
5
5
  Project-URL: Homepage, https://github.com/jose-pr/dotagents/
6
6
  Project-URL: Repository, https://github.com/jose-pr/dotagents
@@ -203,14 +203,14 @@ installed first, its `routing`/`rules` merge into `AGENTS.md`'s managed block, a
203
203
  `skills/<name>/` are symlinked (or `--copy`'d, for Windows / no-symlink) into the
204
204
  store's shared `skills/` so every agent sees the same skills.
205
205
 
206
- - `add` and `sync` never clobber a file you hand-edited inside an installed overlay:
207
- a file that differs from the source is kept and reported. A fresh `add` whose setup
208
- script fails is rolled back.
206
+ - `add` and `sync` keep an installed overlay exactly as its source ships it: changed
207
+ files are replaced and files the source does not ship are removed, anything edited
208
+ there backed up under `<store>/install_backup/` first. What the `.gitignore` /
209
+ `.ignore` at the overlay's root match (setup output, local state) and tool caches are left
210
+ alone; `--prune` clears them too. A fresh `add` whose setup script fails is rolled
211
+ back.
209
212
  - `sync` refreshes each overlay from the repo it was installed from (`--repo` replaces
210
- that source), removes files the source dropped that you never edited, installs a
211
- `requires` added upstream, and re-merges the managed block. `--overwrite` also
212
- replaces edited files and `--prune` removes edited files the source dropped; both
213
- back each file up under `<store>/install_backup/` first.
213
+ that source), installs a `requires` added upstream, and re-merges the managed block.
214
214
  - `remove` deletes the overlay's directory, unpublishes only the skills **it**
215
215
  published, and recomposes `AGENTS.md`'s managed block over the overlays that remain,
216
216
  so its rules and routing leave with it. It refuses an overlay another installed
@@ -153,14 +153,14 @@ installed first, its `routing`/`rules` merge into `AGENTS.md`'s managed block, a
153
153
  `skills/<name>/` are symlinked (or `--copy`'d, for Windows / no-symlink) into the
154
154
  store's shared `skills/` so every agent sees the same skills.
155
155
 
156
- - `add` and `sync` never clobber a file you hand-edited inside an installed overlay:
157
- a file that differs from the source is kept and reported. A fresh `add` whose setup
158
- script fails is rolled back.
156
+ - `add` and `sync` keep an installed overlay exactly as its source ships it: changed
157
+ files are replaced and files the source does not ship are removed, anything edited
158
+ there backed up under `<store>/install_backup/` first. What the `.gitignore` /
159
+ `.ignore` at the overlay's root match (setup output, local state) and tool caches are left
160
+ alone; `--prune` clears them too. A fresh `add` whose setup script fails is rolled
161
+ back.
159
162
  - `sync` refreshes each overlay from the repo it was installed from (`--repo` replaces
160
- that source), removes files the source dropped that you never edited, installs a
161
- `requires` added upstream, and re-merges the managed block. `--overwrite` also
162
- replaces edited files and `--prune` removes edited files the source dropped; both
163
- back each file up under `<store>/install_backup/` first.
163
+ that source), installs a `requires` added upstream, and re-merges the managed block.
164
164
  - `remove` deletes the overlay's directory, unpublishes only the skills **it**
165
165
  published, and recomposes `AGENTS.md`'s managed block over the overlays that remain,
166
166
  so its rules and routing leave with it. It refuses an overlay another installed
@@ -88,7 +88,7 @@ the store's, else `dotagents` on `PATH`.
88
88
  | Hook | Command | Why |
89
89
  | --- | --- | --- |
90
90
  | `SessionStart` | writes `dotagents env --diff --format export` into `$CLAUDE_ENV_FILE` (`--into`: one block, replaced on every run; `PATH` as a prefix on the shell's own), then runs `dotagents context` | Claude sources `$CLAUDE_ENV_FILE` before each Bash command, so the env layers reach every command in the session; and it injects the hook's **stdout into the session context**, which is how the assembled context reaches the model. |
91
- | `CwdChanged` | when the new directory has a `.agents/`, appends `export AGENTS_PROJECT_ROOT=<it>` to `$CLAUDE_ENV_FILE`; then prints the directory's `AGENTS.md` if there is one | Re-pins the project root after a `cd` into another project, and surfaces that directory's `AGENTS.md`. |
91
+ | `CwdChanged` | when the new directory has a `.agents/`, writes `export AGENTS_PROJECT_ROOT=<it>` into `$CLAUDE_ENV_FILE`, replacing the pin an earlier `cd` wrote; then prints the directory's `AGENTS.md` if there is one | Re-pins the project root after a `cd` into another project, and surfaces that directory's `AGENTS.md`. |
92
92
 
93
93
  On Windows each event gets a second, PowerShell-native handler (`shell:
94
94
  "powershell"`) that runs only when Claude Code would find no Git Bash, so a machine
@@ -105,11 +105,16 @@ without a prompt on each one requires `permissionDecision: "allow"`, which skips
105
105
  approval prompt (your deny and ask rules still apply). It is off by default, and a
106
106
  plain `init` removes one an earlier release wired.
107
107
 
108
- The env half **appends** (`>>`) and is guarded by `[ -n "$CLAUDE_ENV_FILE" ]`, both
109
- per the [hooks docs](https://code.claude.com/docs/en/hooks#persist-environment-variables):
110
- other hooks write to the same file, so `>` would discard their variables, and an
111
- unguarded redirect would create a file literally named `""` where the variable is
112
- unset. `$CLAUDE_ENV_FILE` exists only inside SessionStart/Setup/CwdChanged/FileChanged
108
+ The env half never truncates the file and is guarded by `[ -n "$CLAUDE_ENV_FILE" ]`
109
+ (see the [hooks docs](https://code.claude.com/docs/en/hooks#persist-environment-variables)):
110
+ another hook may write to the same file, so each hook replaces only its own part --
111
+ SessionStart its `env --into` block, CwdChanged its one `AGENTS_PROJECT_ROOT` line --
112
+ and keeps every other line. Appending instead grew the file on every re-run (resume,
113
+ compact, clear) and every `cd`, and Claude Code inlines it into each Bash command, which
114
+ Git for Windows' bash cuts off at about 8 KB. Each run starts from a fresh environment
115
+ (the hooks docs: previously sourced env files are not loaded in the hook's process), so
116
+ SessionStart recomputes the whole diff: a value changed since the last run is updated,
117
+ and one no env layer sets any more drops out. `$CLAUDE_ENV_FILE` exists only inside SessionStart/Setup/CwdChanged/FileChanged
113
118
  hook processes — it is absent from the session's own shell, so checking for it with
114
119
  `env | grep` proves nothing.
115
120
 
@@ -163,7 +168,7 @@ Manages opt-in overlays **by name**. See [Overlays](overlays.md) for the full mo
163
168
  dotagents overlays add python engineering # install into the scope, publish skills, merge rules/routing
164
169
  dotagents overlays list # installed (discovered) + available from the repos
165
170
  dotagents overlays sync 'py*' # refresh installed overlays matching a glob
166
- dotagents overlays sync --overwrite # also replace installed files you edited (backed up)
171
+ dotagents overlays sync --prune # also clear what .gitignore/.ignore protect: exactly upstream
167
172
  dotagents overlays remove python # delete the overlay dir, unpublish its skills, un-merge its rules
168
173
  dotagents overlays show python # describe one: manifest, requires, setup, skills, source (--json)
169
174
  ```
@@ -184,13 +189,17 @@ dotagents overlays show python # describe one: manifest, requires, s
184
189
  it as done.
185
190
  - **`sync`** refreshes each installed overlay from the repo it was installed from
186
191
  (recorded in `<overlay>/.dotagents-install.json`); `--repo` replaces that recorded
187
- source for the run. New files land and `overlay.toml` is refreshed; a file that
188
- differs from the source is kept and reported, and `--overwrite` replaces it. A file
189
- the source no longer ships is removed when you never edited it, and kept (reported)
190
- when you did, unless `--prune`. `--overwrite` and `--prune` copy each file to
191
- `<store>/install_backup/<timestamp>/overlays/<name>/` first. A `requires` added
192
- upstream is installed, and a published skill copy you edited is kept unless
193
- `--overwrite`.
192
+ source for the run. The install is made to match the source exactly: new and changed
193
+ files land, `overlay.toml` is refreshed, and files the source does not ship are
194
+ removed. A file you edited (or added) there is copied to
195
+ `<store>/install_backup/<timestamp>/overlays/<name>/` before it is replaced or
196
+ removed. What the `.gitignore` / `.ignore` at the overlay's root match — setup
197
+ output, local state — and tool caches (`__pycache__`, `.venv`, …) are the install's own and
198
+ left alone; `--prune` clears them too, leaving exactly the upstream files. A
199
+ `requires` added upstream is installed, and published skill copies are refreshed.
200
+ `add` of an overlay already installed does the same (with `--prune` too). Keep your
201
+ own additions outside `<store>/overlays/` — a `kb/` file of your own, an
202
+ `AGENTS.local.md` — where a sync never touches them.
194
203
  - **`remove`** deletes the overlay's directory, unpublishes the skills it published
195
204
  (and their links in Claude's skills dir), and recomposes `AGENTS.md`'s managed block over the overlays that remain, so its
196
205
  rules and routing leave with it. It refuses an overlay another installed overlay
@@ -207,7 +216,11 @@ Assembles the effective context an agent should load — the overlay `CONTEXT.md
207
216
  and the project root's own `AGENTS.md` / `AGENTS.local.md` — minus whatever the harness already loads by itself (for Claude Code, whatever its
208
217
  `CLAUDE.md` files really `@`-include) — and prints it to **stdout** by default
209
218
  (POSIX convention); pass a path to write a file, or `--write-agent` to merge it into
210
- each agent's own instruction file.
219
+ each agent's own instruction file. It ends with the skills on offer and, when there are
220
+ any, your **local additions to overlay files** — a store file at the same path as an
221
+ overlay's (`~/.agents/kb/RUST.md` for the rust overlay's `kb/RUST.md`; see
222
+ [Overlays](overlays.md)); `--format json` carries both as their own keys (`skills`,
223
+ `local_additions`).
211
224
 
212
225
  ```bash
213
226
  dotagents context # print the active agent's context to stdout
@@ -96,7 +96,7 @@ python -m dotagents build-pyz --out dist/dotagents.pyz # or build it (needs a
96
96
  holds the git checkouts and remote sources the repos materialize (the directory carries a
97
97
  `.gitignore` of `*`, so a store kept in git never records them).
98
98
  - `install_backup/<timestamp>/` — what `init --force` replaced, and what
99
- `overlays sync --overwrite` / `--prune` replaced or removed.
99
+ `overlays add` / `sync` replaced or removed that had been edited in an installed overlay.
100
100
  - `dotagents/cmds/` — only if you create it: your own command modules (see
101
101
  [Authoring → Custom commands](authoring.md#custom-commands)).
102
102
  - `dotagents/config.toml` — only after `init --from`: records that base, so a later
@@ -73,10 +73,23 @@ store). Each overlay:
73
73
  reads that dir sees the same skills.
74
74
 
75
75
  `sync` refreshes each overlay from the repo it was installed from (`--repo` replaces
76
- that source for the run), removes files the source dropped that you never edited,
77
- installs a `requires` added upstream, and reports files that differ from the source
78
- instead of replacing them — `--overwrite` replaces them and `--prune` also removes
79
- edited files the source dropped, each backed up under `<store>/install_backup/` first.
76
+ that source for the run) and makes the install exactly the source: changed files are
77
+ replaced and files the source does not ship are removed, anything edited there backed
78
+ up under `<store>/install_backup/` first. What the `.gitignore` / `.ignore` at the
79
+ overlay's root match (an ignore file in a subdirectory is just a file it ships), and
80
+ tool caches, belong to the install (a setup script's output, local
81
+ state) and are left alone; `--prune` clears them too. A `requires` added upstream is
82
+ installed. An installed overlay is upstream's copy, not a place for your edits.
83
+
84
+ ### Your additions to an overlay's files
85
+
86
+ Put your own text for an overlay file at the **same path under the store root**: an
87
+ addition to the rust overlay's `kb/RUST.md` goes in `~/.agents/kb/RUST.md` (or
88
+ `<project>/.agents/kb/RUST.md` for one project). `sync` never touches it, and agents
89
+ read it alongside the overlay's file, winning on conflict: the base `AGENTS.md` tells
90
+ them to, and `dotagents context` lists every addition it finds (for each `.md` an
91
+ overlay ships in a subdirectory other than `skills/`). Private names, local tool paths
92
+ and links into your own notes belong there, never in the overlay.
80
93
 
81
94
  Removing an overlay deletes only its directory and unpublishes only the skills **it**
82
95
  published (a copy the user edited is left alone). Its lines in `AGENTS.md`'s managed
@@ -23,7 +23,8 @@ carry the opinions.
23
23
  composable **overlays** you layer in explicitly with `dotagents overlays add
24
24
  <name>`. The overlays in this repo are examples — payloads riding on dotagents,
25
25
  swappable for your own; see [Overlays](guide/overlays.md) for what each ships.
26
- Overlays are additive: they never overwrite a file you have already customized.
26
+ An installed overlay is kept exactly as upstream ships it (what the `.gitignore` /
27
+ `.ignore` at its root protect stays yours), so your own changes live in your own files.
27
28
  - **Two scopes.** Config installs into a **user** store (`~/.agents`, configurable)
28
29
  or a **project** store (`<project>/.agents`). Overlays, skills, commands, and env
29
30
  files all resolve across the same scope precedence.
@@ -31,9 +31,9 @@ and an error saying a command is required, and exits 2.
31
31
  | Command | Contract |
32
32
  | --- | --- |
33
33
  | `init [-g] [--dest D] [--from SRC] [--bin-dir D] [--agents a,b] [--no-hooks] [--powershell-env-hook] [--force] [--dry-run]` | Merge the base block into `<store>/AGENTS.md`, write `<store>/bin/dotagents[.cmd]`, and per active agent (`--agents`, else detected + `claude`) write its include and hooks. `--force` replaces `AGENTS.md`'s content, backed up under `<store>/install_backup/<timestamp>/`. `--from` records its base in `<store>/dotagents/config.toml`. |
34
- | `overlays add NAME... [-g] [--repo SPEC]... [--copy] [--no-setup] [--setup-timeout S] [--no-requires] [--dry-run]` | Install each overlay and its `requires` into `<store>/overlays/<name>/`, publish its skills, run its `setup.py`, recompose the managed block. Every name is resolved before anything is written; a source inside `<store>/overlays/` is refused; a fresh install whose setup fails is rolled back. No name: exit 2. |
34
+ | `overlays add NAME... [-g] [--repo SPEC]... [--copy] [--no-setup] [--setup-timeout S] [--no-requires] [--prune] [--dry-run]` | Install each overlay and its `requires` into `<store>/overlays/<name>/` (an installed one is made to match its source again, as `sync` does; `--prune` clears its ignored files too), publish its skills, run its `setup.py`, recompose the managed block. Every name is resolved before anything is written; a source inside `<store>/overlays/` is refused; a fresh install whose setup fails is rolled back. No name: exit 2. |
35
35
  | `overlays remove NAME... [-g] [--force] [--dry-run]` | Delete the overlay dirs, unpublish the skills they published, recompose the block. Refuses an overlay another installed overlay requires unless `--force`. No name: exit 2. |
36
- | `overlays sync [GLOB] [-g] [--repo SPEC]... [--copy] [--overwrite] [--prune] [--no-setup] [--setup-timeout S] [--dry-run]` | Refresh installed overlays from the repo recorded at install (`--repo` replaces it). Files that differ from the source are kept unless `--overwrite`; files the source dropped are removed if unedited, else kept unless `--prune`; replaced/pruned files are backed up under `<store>/install_backup/<timestamp>/overlays/<name>/`. Installs new `requires`. Exits 1 when an overlay's repo cannot be loaded, its source lies inside `<store>/overlays/`, or its setup fails. |
36
+ | `overlays sync [GLOB] [-g] [--repo SPEC]... [--copy] [--prune] [--no-setup] [--setup-timeout S] [--dry-run]` | Refresh installed overlays from the repo recorded at install (`--repo` replaces it): the install is made to match the source exactly: new and changed files land, `overlay.toml` is refreshed, files the source does not ship are removed; a file edited here is backed up first under `<store>/install_backup/<timestamp>/overlays/<name>/`. What the `.gitignore` / `.ignore` at the overlay's root match (setup output, local state) and tool caches are left alone; `--prune` clears them too. Installs new `requires`. Exits 1 when an overlay's repo cannot be loaded, its source lies inside `<store>/overlays/`, or its setup fails. |
37
37
  | `overlays list [-g] [--repo SPEC]... [--json]` | Installed overlays per store (shadowed copies marked, unmet `requires` flagged) and what the repos offer. |
38
38
  | `overlays show NAME [-g] [--repo SPEC]... [--json]` | One overlay: the copy a session would use, else the source's; manifest, setup, skills, file count, recorded source. No name: exit 2. |
39
39
  | `context [OUT] [-g] [--agents a,b] [--format markdown\|system-reminder\|json] [--write-agent] [--inline]` | Print (or write to `OUT`) the assembled context. `--write-agent` merges it into each agent's `context_target` under the project root (not with `-g`). Exits 2 when `--agents` names no known agent. |
@@ -68,6 +68,10 @@ are skipped.
68
68
  - `class Dotagents(LoggingArgs, Cli)` — the umbrella; field `cmdspath: list[str]`.
69
69
  - `CMDS_PATH_ENV = "AGENTS_CMDS_PATH"`.
70
70
  - Re-exported for command modules: `DotAgentsArgs`, `_write_stdout` (see below).
71
+ - `kebab_name(class_name) -> str` — a discovered command's default name when its
72
+ class declares no `_parsername_`: `LeakCheck` -> `leak-check`, `HTTPServer` ->
73
+ `http-server`, `my_cmd` -> `my-cmd`. `_parsername_` is optional in a command
74
+ module; an explicit one always wins.
71
75
 
72
76
  ## `dotagents.cli._common`
73
77
 
@@ -75,7 +79,8 @@ are skipped.
75
79
  `global_scope: bool = False` (`--global`, `-g`) and
76
80
  `agents_dir: Optional[Path] = None` (`--agents-dir`); `resolve_scope(*,
77
81
  project_root=None) -> Scope` (via `_scope.resolve_scope`). Subclass it alone
78
- (`class X(DotAgentsArgs)`), define `_parsername_` and `__call__(self) -> int`. A
82
+ (`class X(DotAgentsArgs)`), define `__call__(self) -> int` and optionally
83
+ `_parsername_` (default: `kebab_name` of the class, for a discovered module). A
79
84
  subclass may redeclare a field with the same type and default to change its help.
80
85
  - `_write_stdout(text) -> None` — write to stdout as UTF-8 whatever the console's
81
86
  encoding (a bare `print` fails on a non-Latin-1 character under cp1252).
@@ -95,6 +100,23 @@ are skipped.
95
100
  `OVERLAY_ROOT_NOTE` (`dotagents._overlays`), `resolve_user_store`
96
101
  (`dotagents._scope`).
97
102
 
103
+ ## `dotagents._ignore`
104
+
105
+ The `.gitignore` / `.ignore` at the ROOT of a directory, read as git reads a
106
+ `.gitignore` (pure stdlib) -- what an installed overlay keeps as its own when `add`
107
+ / `sync` mirror its source. An ignore file in a subdirectory is an ordinary file;
108
+ nothing above the root is read.
109
+
110
+ - `IGNORE_FILES = (".gitignore", ".ignore")` — read at the root in that order, so
111
+ `.ignore` wins (ripgrep's precedence).
112
+ - `parse(text, base="") -> list` — one file's rules (`base`: its directory):
113
+ comments, `\#` / `\!` escapes, `!` negation, leading/middle `/` anchoring,
114
+ trailing `/` directory-only, `*`, `?`, `[...]`, `**`.
115
+ - `class IgnoreRules(rules=())` — `for_root(root)` reads `root`'s two files;
116
+ `ignored(rel, is_dir=False) -> bool` decides a `/`-joined path (a pattern without
117
+ a slash matches at any depth), a path under an ignored directory staying ignored
118
+ whatever a later `!` says, as in git.
119
+
98
120
  ## `dotagents._resources`
99
121
 
100
122
  The package's own data; library code, importing nothing from `dotagents.cli`.
@@ -198,9 +220,9 @@ returns the exit code.
198
220
  string arrays including multi-line strings, an integer); `None` plus one warning
199
221
  when it is not valid TOML.
200
222
  - `class InstallResult` — what `install_to` did: `written`, `unchanged`,
201
- `kept` (differ from source), `removed` (dropped by the source), `orphans_kept`
202
- (dropped but edited), `backed_up`, `lines`; property `skipped` (= unchanged +
203
- kept). Unpacks as `(written, skipped, lines)`.
223
+ `replaced` (edited here, replaced by the source's file), `removed` (not in the
224
+ source, or cleared by `prune`), `backed_up`, `lines`; property `skipped`
225
+ (= unchanged). Unpacks as `(written, skipped, lines)`.
204
226
  - `class Overlay(path, store=None)` — one overlay directory; `os.PathLike`; equal
205
227
  and hashed by `path`; nothing read at construction.
206
228
  - Class data: `NAME_RE`, `RESERVED_NAMES` (Windows device names),
@@ -246,12 +268,16 @@ returns the exit code.
246
268
  - `rule_blocks(rel_paths) -> tuple[list[str], list[str]]` — `(blocks, warnings)`:
247
269
  each file's leading `- **` bullets up to its next `## ` heading; a path outside
248
270
  the overlay, missing or unreadable is a warning.
249
- - `install_to(dest_overlay_dir, dry_run, overwrite=False, *, prune=False,
250
- backup_root=None, source=None) -> InstallResult` — copy `files()` in; a file
251
- that differs is kept unless `overwrite`; `overlay.toml` always refreshed; a
252
- recorded file the source dropped is removed when unedited, kept unless `prune`
253
- when edited; replaced/pruned files copied under `backup_root` first; writes the
254
- install record (`source`, or the previous one when `None`).
271
+ - `ignore_rules() -> IgnoreRules` — the rules of the `.gitignore` / `.ignore`
272
+ at the overlay's root (`dotagents._ignore`).
273
+ - `install_to(dest_overlay_dir, dry_run, *, prune=False, backup_root=None,
274
+ source=None) -> InstallResult` — make the install exactly the overlay:
275
+ `files()` installed or replaced, `overlay.toml` refreshed, every other file
276
+ removed; what `ignore_rules()` matches, the install record and `SKIP_PARTS`
277
+ caches are never copied, replaced or removed unless `prune` (which clears
278
+ them, without backup); a replaced or removed file that is not what dotagents
279
+ installed is copied under `backup_root` first; writes the install record
280
+ (`source`, or the previous one when `None`).
255
281
  - `OVERLAY_ROOT_NOTE: str` — the line emitted once above overlay routing lines that
256
282
  use `$<NAME>_OVERLAY_ROOT`.
257
283
  - `recompose_overlay_block(agents_md, base_block, overlays, dry_run, logger) -> bool`
@@ -407,7 +433,8 @@ API:
407
433
  `Overlay.sort_key`, then contract-A `AGENTS.md` (every store and the project root)
408
434
  and `AGENTS.local.md` (project store and project root), each prefixed
409
435
  `<!-- Source: <path> -->`. Then a skills listing (`- **name** (`<SKILL.md>`):
410
- description`, from each skill's leading frontmatter; a project skill wins).
436
+ description`, from each skill's leading frontmatter; a project skill wins), then
437
+ a "Local additions to overlay files" section when `local_additions` finds any.
411
438
  `<PROJECT_ROOT>` and `<NAME_OVERLAY_ROOT>` placeholders expand; with
412
439
  `expand_vars` so do `$NAME_OVERLAY_ROOT` / `${NAME_OVERLAY_ROOT}`. `inline`
413
440
  appends the on-demand `.md` files the sources reference, each resolved against
@@ -415,7 +442,13 @@ API:
415
442
  system source never into the project).
416
443
  - `assemble_context_data(agent, scope, *, inline=False, expand_vars=True) -> dict`
417
444
  — `{"agent", "harness", "sources", "context", "skills": [{"name",
418
- "description", "path"}]}`; `context` without the skills listing.
445
+ "description", "path"}], "local_additions": [{"overlay_file", "local": [...]}]}`;
446
+ `context` without the skills and local-additions listings.
447
+ - `local_additions(scope) -> list[tuple[Path, list[Path]]]` — the user's own
448
+ additions to installed overlay files: for each `.md` an overlay in `scope.overlays`
449
+ ships in a subdirectory other than `skills/`, every store's file at the same
450
+ relative path (`<store>/kb/RUST.md` for the overlay's `kb/RUST.md`), in
451
+ `scope.stores` order. A store's root files never match.
419
452
 
420
453
  ## `dotagents._agents`
421
454
 
@@ -1,3 +1,3 @@
1
1
  """dotagents: installable CLI for the dotagents agent-config payload."""
2
2
 
3
- __version__ = "0.6.3"
3
+ __version__ = "0.7.1"
@@ -553,10 +553,22 @@ class ClaudeAgent(Agent):
553
553
  # `%q` quotes the path for the bash that sources the file: a `'` in a
554
554
  # directory name used to leave an unterminated quote that broke every
555
555
  # later source of $CLAUDE_ENV_FILE (and a crafted name ran commands).
556
+ # The file keeps ONE pin, the latest: an earlier `export
557
+ # AGENTS_PROJECT_ROOT=` line is dropped and every other line (another
558
+ # hook's) kept. Appending grew the file by a line per `cd`, and Claude Code
559
+ # inlines it into every Bash command (see SESSION_START_COMMAND). Pure bash,
560
+ # 3.2-compatible (macOS): no mapfile, no external command, so a `cd` stays
561
+ # instant.
556
562
  CWD_CHANGED_COMMAND = (
557
563
  'if [ -n "$CLAUDE_ENV_FILE" ] && [ -d .agents ]; then '
558
- "printf 'export AGENTS_PROJECT_ROOT=%q\\n' \"$(pwd -W 2>/dev/null || pwd)\" "
559
- '>> "$CLAUDE_ENV_FILE"; fi; '
564
+ "kept=(); "
565
+ 'if [ -f "$CLAUDE_ENV_FILE" ]; then '
566
+ 'while IFS= read -r line || [ -n "$line" ]; do '
567
+ 'case "$line" in "export AGENTS_PROJECT_ROOT="*) ;; *) kept+=("$line") ;; esac; '
568
+ 'done < "$CLAUDE_ENV_FILE"; fi; '
569
+ '{ for line in ${kept[@]+"${kept[@]}"}; do printf \'%s\\n\' "$line"; done; '
570
+ "printf 'export AGENTS_PROJECT_ROOT=%q\\n' \"$(pwd -W 2>/dev/null || pwd)\"; "
571
+ '} > "$CLAUDE_ENV_FILE"; fi; '
560
572
  "[ -f AGENTS.md ] && cat AGENTS.md || true"
561
573
  )
562
574
 
@@ -309,6 +309,44 @@ def _get_skills_listing(scope: _scope.Scope) -> str:
309
309
  return "\n\n## Available Skills (Opt-in)\n" + "\n".join(lines) + "\n"
310
310
 
311
311
 
312
+ def local_additions(scope: _scope.Scope) -> "list[tuple[Path, list[Path]]]":
313
+ """The user's own additions to installed overlay files: for each ``.md``
314
+ an overlay ships in a subdirectory (``kb/``, ``flows/``, ``rules/`` ... --
315
+ not ``skills/``, whose published copies sit at the same path in a store),
316
+ every store's file at the same relative path (``~/.agents/kb/RUST.md`` for
317
+ an overlay's ``kb/RUST.md``), in store order. An overlay file is upstream's
318
+ copy, replaced by ``overlays sync``; the addition is where the user's own
319
+ text lives. A store's root files (``AGENTS.md``, ``env.py``) never match:
320
+ only a file in a subdirectory counts."""
321
+ stores = list(scope.stores)
322
+ out: "list[tuple[Path, list[Path]]]" = []
323
+ for overlay in scope.overlays:
324
+ for path in overlay.files():
325
+ rel = path.relative_to(overlay.path)
326
+ if path.suffix.lower() != ".md" or len(rel.parts) < 2 or rel.parts[0] == "skills":
327
+ continue
328
+ found = [store / rel for store in stores if (store / rel).is_file()]
329
+ if found:
330
+ out.append((path, found))
331
+ return out
332
+
333
+
334
+ def _get_local_additions_listing(scope: _scope.Scope) -> str:
335
+ """The local additions (:func:`local_additions`) as a markdown section, or ''."""
336
+ additions = local_additions(scope)
337
+ if not additions:
338
+ return ""
339
+ lines = [
340
+ "- `%s` -- also read %s" % (path, ", ".join("`%s`" % p for p in found))
341
+ for path, found in additions
342
+ ]
343
+ return (
344
+ "\n\n## Local additions to overlay files\n"
345
+ "The user's own additions: read each with the overlay file it extends; it wins on conflict.\n"
346
+ + "\n".join(lines) + "\n"
347
+ )
348
+
349
+
312
350
  def _resolve_and_filter_sources(
313
351
  agent: _agents.Agent, scope: _scope.Scope
314
352
  ) -> "tuple[list[tuple[str, Path, Path | None]], list[Path]]":
@@ -424,6 +462,7 @@ def assemble_context(
424
462
  if not source_paths:
425
463
  return ""
426
464
  text += _get_skills_listing(scope)
465
+ text += _get_local_additions_listing(scope)
427
466
  return text
428
467
 
429
468
 
@@ -440,11 +479,12 @@ def assemble_context_data(
440
479
  "sources": [<absolute source path>, ...], # after harness subtraction
441
480
  "context": <assembled markdown text, minus the skills listing>,
442
481
  "skills": [{"name": ..., "description": ..., "path": <SKILL.md>}, ...],
482
+ "local_additions": [{"overlay_file": ..., "local": [<path>, ...]}, ...],
443
483
  }
444
484
  ``context`` is the same assembled(+inlined, with ``inline=True``) text the
445
- markdown format emits, but WITHOUT the skills listing appended -- skills are
446
- their own structured field so a consumer can render them separately and
447
- keep the opt-in distinction."""
485
+ markdown format emits, but WITHOUT the skills and local-additions listings
486
+ appended -- both are their own structured fields so a consumer can render
487
+ them separately."""
448
488
  text, source_paths = _assemble(agent, scope, inline, expand_vars)
449
489
 
450
490
  skills = [
@@ -457,4 +497,8 @@ def assemble_context_data(
457
497
  "sources": source_paths,
458
498
  "context": text,
459
499
  "skills": skills,
500
+ "local_additions": [
501
+ {"overlay_file": str(path), "local": [str(p) for p in found]}
502
+ for path, found in local_additions(scope)
503
+ ],
460
504
  }
@@ -0,0 +1,146 @@
1
+ """The ``.gitignore`` / ``.ignore`` at the ROOT of a directory, read as git
2
+ reads a ``.gitignore`` -- what an installed overlay keeps as its own (setup
3
+ output, caches, local state) when ``overlays add`` / ``sync`` make the rest
4
+ of it match upstream.
5
+
6
+ Only the root's two files count: an ignore file in a subdirectory is an
7
+ ordinary file (the engineering overlay ships a ``references/.gitignore``
8
+ template), and nothing above the root is read. ``.ignore`` is the ripgrep /
9
+ fd convention, same syntax, read after ``.gitignore`` so its rules win.
10
+
11
+ Pure stdlib. Supported: blank lines and ``#`` comments, ``\\#`` / ``\\!``
12
+ escapes, ``!`` negation, a leading or middle ``/`` anchoring the pattern to
13
+ the root, a trailing ``/`` matching directories only, ``*``, ``?``,
14
+ ``[...]`` and ``**``; a pattern without a slash matches at any depth. As in
15
+ git, a file under an ignored directory stays ignored whatever a later ``!``
16
+ says.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Iterable, List, Optional
24
+
25
+ #: The ignore files read at the root, in order: a later one's rules win.
26
+ IGNORE_FILES = (".gitignore", ".ignore")
27
+
28
+
29
+ def _translate(pattern: str) -> str:
30
+ """A gitignore glob (without anchoring) as a regex for a ``/``-joined path."""
31
+ out, i, n = [], 0, len(pattern)
32
+ while i < n:
33
+ c = pattern[i]
34
+ if c == "*":
35
+ if pattern[i:i + 2] == "**":
36
+ i += 2
37
+ if i < n and pattern[i] == "/": # "**/": zero or more directories
38
+ out.append("(?:.*/)?")
39
+ i += 1
40
+ else: # a trailing "**": everything below
41
+ out.append(".*")
42
+ continue
43
+ out.append("[^/]*")
44
+ elif c == "?":
45
+ out.append("[^/]")
46
+ elif c == "[":
47
+ j = pattern.find("]", i + 1)
48
+ if j < 0:
49
+ out.append(re.escape(c))
50
+ else:
51
+ body = pattern[i + 1:j]
52
+ if body.startswith("!"):
53
+ body = "^" + body[1:]
54
+ out.append("[%s]" % body.replace("\\", "\\\\"))
55
+ i = j
56
+ elif c == "\\" and i + 1 < n:
57
+ i += 1
58
+ out.append(re.escape(pattern[i]))
59
+ else:
60
+ out.append(re.escape(c))
61
+ i += 1
62
+ return "".join(out)
63
+
64
+
65
+ class _Rule:
66
+ __slots__ = ("base", "regex", "negate", "dir_only")
67
+
68
+ def __init__(self, base: str, regex: "re.Pattern[str]", negate: bool, dir_only: bool) -> None:
69
+ self.base = base # the .gitignore's directory, "" or "a/b"
70
+ self.regex = regex
71
+ self.negate = negate
72
+ self.dir_only = dir_only
73
+
74
+ def matches(self, rel: str, is_dir: bool) -> bool:
75
+ if self.dir_only and not is_dir:
76
+ return False
77
+ if self.base:
78
+ if not rel.startswith(self.base + "/"):
79
+ return False
80
+ rel = rel[len(self.base) + 1:]
81
+ return bool(self.regex.fullmatch(rel))
82
+
83
+
84
+ def parse(text: str, base: str = "") -> "List[_Rule]":
85
+ """The rules of one ``.gitignore`` whose directory is ``base``."""
86
+ rules: "List[_Rule]" = []
87
+ for raw in text.splitlines():
88
+ line = raw.rstrip("\r")
89
+ # Trailing spaces are ignored unless escaped.
90
+ stripped = line.rstrip(" ")
91
+ if stripped.endswith("\\") and len(line) > len(stripped):
92
+ stripped += " "
93
+ line = stripped
94
+ if not line or line.startswith("#"):
95
+ continue
96
+ negate = line.startswith("!")
97
+ if negate:
98
+ line = line[1:]
99
+ if line.startswith(("\\#", "\\!")):
100
+ line = line[1:]
101
+ dir_only = line.endswith("/")
102
+ line = line.rstrip("/")
103
+ if not line:
104
+ continue
105
+ anchored = "/" in line
106
+ line = line.lstrip("/")
107
+ body = _translate(line)
108
+ regex = re.compile(body if anchored else "(?:.*/)?" + body)
109
+ rules.append(_Rule(base, regex, negate, dir_only))
110
+ return rules
111
+
112
+
113
+ class IgnoreRules:
114
+ """The root ``.gitignore`` / ``.ignore`` rules of a directory, applied as
115
+ git applies a ``.gitignore``."""
116
+
117
+ def __init__(self, rules: "Iterable[_Rule]" = ()) -> None:
118
+ self.rules = list(rules)
119
+
120
+ @classmethod
121
+ def for_root(cls, root: Path) -> "IgnoreRules":
122
+ """The rules of ``root/.gitignore`` then ``root/.ignore`` (absent
123
+ files contribute nothing); subdirectories' ignore files are not read."""
124
+ rules: "List[_Rule]" = []
125
+ for name in IGNORE_FILES:
126
+ try:
127
+ rules.extend(parse((Path(root) / name).read_text(encoding="utf-8", errors="replace")))
128
+ except OSError:
129
+ continue
130
+ return cls(rules)
131
+
132
+ def _decide(self, rel: str, is_dir: bool) -> Optional[bool]:
133
+ verdict = None
134
+ for rule in self.rules:
135
+ if rule.matches(rel, is_dir):
136
+ verdict = not rule.negate
137
+ return verdict
138
+
139
+ def ignored(self, rel: str, is_dir: bool = False) -> bool:
140
+ """Is ``rel`` (``/``-separated, relative to the root) ignored? A path
141
+ under an ignored directory is, whatever a later ``!`` says."""
142
+ parts = rel.strip("/").split("/")
143
+ for depth in range(1, len(parts)):
144
+ if self._decide("/".join(parts[:depth]), True):
145
+ return True
146
+ return bool(self._decide("/".join(parts), is_dir))