dotagents-cli 0.6.2__tar.gz → 0.6.3__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 (109) hide show
  1. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/CHANGELOG.md +28 -1
  2. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/PKG-INFO +1 -1
  3. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/guide/commands.md +12 -2
  4. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/AGENTS.md +20 -3
  5. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/__init__.py +1 -1
  6. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_agents.py +11 -4
  7. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_env.py +22 -2
  8. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_resources.py +12 -0
  9. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/env.py +77 -3
  10. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_env.py +37 -0
  11. dotagents_cli-0.6.3/tests/test_env_into.py +89 -0
  12. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_wire_hooks.py +10 -7
  13. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tools/pyz_smoke.sh +7 -0
  14. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/.gitignore +0 -0
  15. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/LICENSE +0 -0
  16. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/README.md +0 -0
  17. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/agents.md +0 -0
  18. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/cli.md +0 -0
  19. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/commands.md +0 -0
  20. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/context.md +0 -0
  21. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/env.md +0 -0
  22. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/fs.md +0 -0
  23. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/hooks.md +0 -0
  24. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/merge.md +0 -0
  25. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/overlays.md +0 -0
  26. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/reference.md +0 -0
  27. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/resources.md +0 -0
  28. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/scope.md +0 -0
  29. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/skills.md +0 -0
  30. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/sources.md +0 -0
  31. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/api/wrappers.md +0 -0
  32. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/changelog.md +0 -0
  33. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/guide/authoring.md +0 -0
  34. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/guide/install.md +0 -0
  35. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/guide/overlays.md +0 -0
  36. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/guide/private-sync.md +0 -0
  37. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/docs/index.md +0 -0
  38. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/install.py +0 -0
  39. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/mkdocs.yml +0 -0
  40. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/pyproject.toml +0 -0
  41. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/__main__.py +0 -0
  42. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_context.py +0 -0
  43. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_fs.py +0 -0
  44. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_hooks.py +0 -0
  45. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_merge.py +0 -0
  46. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlay/dotagents/cmds/findings.py +0 -0
  47. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlay/dotagents/cmds/launch.py +0 -0
  48. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlay/dotagents/hooks/preinvocation_antigravity_context.py +0 -0
  49. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlay/dotagents/hooks/pretooluse_codex_env.py +0 -0
  50. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlay/dotagents/hooks/sessionstart_codex_context.py +0 -0
  51. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlay/dotagents/templates/AGENTS.md +0 -0
  52. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlay/dotagents/templates/PROJECT.md +0 -0
  53. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_overlays.py +0 -0
  54. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_scope.py +0 -0
  55. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_skills.py +0 -0
  56. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_sources.py +0 -0
  57. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/_wrappers.py +0 -0
  58. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/__init__.py +0 -0
  59. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/_common.py +0 -0
  60. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/about.py +0 -0
  61. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/build_pyz.py +0 -0
  62. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/context.py +0 -0
  63. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/init.py +0 -0
  64. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/overlays.py +0 -0
  65. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/cli/path.py +0 -0
  66. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/src/dotagents/py.typed +0 -0
  67. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/_helpers.py +0 -0
  68. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/_shell.py +0 -0
  69. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/conftest.py +0 -0
  70. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_about.py +0 -0
  71. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_agents.py +0 -0
  72. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_api_header.py +0 -0
  73. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_audit_leak.py +0 -0
  74. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_build_pyz.py +0 -0
  75. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_cli_main.py +0 -0
  76. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_cloud_setup.py +0 -0
  77. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_cmds_discovery.py +0 -0
  78. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_context.py +0 -0
  79. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_env_cli_scope.py +0 -0
  80. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_env_format.py +0 -0
  81. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_env_regressions.py +0 -0
  82. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_env_safety.py +0 -0
  83. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_findings_cmd.py +0 -0
  84. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_adapters.py +0 -0
  85. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_cli.py +0 -0
  86. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_env.py +0 -0
  87. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_hooks.py +0 -0
  88. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_init.py +0 -0
  89. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_overlays.py +0 -0
  90. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_packaging.py +0 -0
  91. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_scope_context.py +0 -0
  92. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_fix_skills_adapters.py +0 -0
  93. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_hooks.py +0 -0
  94. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_init_base.py +0 -0
  95. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_last_mile.py +0 -0
  96. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_launch_cmd.py +0 -0
  97. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_mcp.py +0 -0
  98. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_overlay_libs.py +0 -0
  99. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_overlays_cli.py +0 -0
  100. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_overlays_command.py +0 -0
  101. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_path_timeout_frontmatter.py +0 -0
  102. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_powershell_env_hook.py +0 -0
  103. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_review_security.py +0 -0
  104. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_scope.py +0 -0
  105. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_sources.py +0 -0
  106. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_sources_uri.py +0 -0
  107. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tests/test_wrappers.py +0 -0
  108. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tools/audit.py +0 -0
  109. {dotagents_cli-0.6.2 → dotagents_cli-0.6.3}/tools/cloud-setup.sh +0 -0
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.3] - 2026-09-30
11
+
12
+ ### Added
13
+
14
+ - `env --into FILE` writes the output into FILE as the one block `env` owns
15
+ there, replacing the block an earlier run wrote and keeping every other line.
16
+ - With `--diff --format export`, a `PATH` that is the caller's own with entries
17
+ put in front is written as `export PATH='<entries>'"${PATH:+:$PATH}"`, so it
18
+ stays short and keeps what the sourcing shell has.
19
+ - When `dotagents` runs from a `.pyz`, `env` exports `AGENTS_PYLIB`, the path
20
+ of that archive. The libraries it bundles (duho, pathlib_next) import from
21
+ it, so an overlay built on duho works even when the interpreter in
22
+ `AGENTS_PYTHON` has no duho installed. An installed `dotagents` sets nothing:
23
+ its libraries are already importable from `AGENTS_PYTHON`. The archive is
24
+ never put on `PYTHONPATH`, where it would shadow a project's own copy; a
25
+ consumer adds it to `sys.path` itself when its import fails.
26
+
27
+ ### Fixed
28
+
29
+ - The Claude Code SessionStart hook no longer appends the whole environment to
30
+ `$CLAUDE_ENV_FILE` every time it runs (startup, resume, compact, clear). Claude
31
+ Code inlines that file into every Bash command, and once it passed about 8 KB,
32
+ Git for Windows' bash truncated the command and every Bash call failed with an
33
+ unterminated quote. The hook now uses `env --into`, which keeps one block.
34
+ Re-run `dotagents init -g` to update an installed hook.
35
+
10
36
  ## [0.6.2] - 2026-09-28
11
37
 
12
38
  ### Added
@@ -1308,7 +1334,8 @@ Patch release: the PATH/POSIX-conversion fix above (the only change since 0.3.0)
1308
1334
  affected module sources (`dotagents.cli`, `duho.presets`) to extracted temp files
1309
1335
  before dispatch; a no-op for a plain install.
1310
1336
 
1311
- [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.6.2...HEAD
1337
+ [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.6.3...HEAD
1338
+ [0.6.3]: https://github.com/jose-pr/dotagents/compare/v0.6.2...v0.6.3
1312
1339
  [0.6.2]: https://github.com/jose-pr/dotagents/compare/v0.6.1...v0.6.2
1313
1340
  [0.6.1]: https://github.com/jose-pr/dotagents/compare/v0.6.0...v0.6.1
1314
1341
  [0.6.0]: https://github.com/jose-pr/dotagents/compare/v0.5.1...v0.6.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dotagents-cli
3
- Version: 0.6.2
3
+ Version: 0.6.3
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
@@ -87,7 +87,7 @@ the store's, else `dotagents` on `PATH`.
87
87
 
88
88
  | Hook | Command | Why |
89
89
  | --- | --- | --- |
90
- | `SessionStart` | appends `dotagents env --diff --format export` to `$CLAUDE_ENV_FILE`, 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. |
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
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`. |
92
92
 
93
93
  On Windows each event gets a second, PowerShell-native handler (`shell:
@@ -272,6 +272,11 @@ dotagents env --diff --format json # only vars that differ from the caller's e
272
272
  environment, the working directory, the stores, the overlays, every env and `lib`
273
273
  file), for at most five minutes; the per-command env loaders use it. The cache is
274
274
  owner-only, under `<user store>/.cache/env/`.
275
+ - `--into <file>` — write into `<file>` instead of stdout, as the one block this
276
+ command owns there (between `: dotagents-env-begin` / `: dotagents-env-end`):
277
+ an earlier block is replaced, anything else in the file is kept. The SessionStart
278
+ hook writes `$CLAUDE_ENV_FILE` this way, so the file stays one block however often
279
+ the hook runs. `export` format only.
275
280
  - `-g` / `--global` — skip the project-level env files (the store is unaffected).
276
281
  - `--agents-dir <dir>` — user store override for this run.
277
282
 
@@ -307,7 +312,12 @@ form and drop names that are not shell identifiers.
307
312
  overlay, where `NAME` is the overlay's name upper-cased with `-` turned into `_`
308
313
  (`my-ov` → `MY_OV_OVERLAY_ROOT`). That is the same name `context` expands as a
309
314
  `<NAME_OVERLAY_ROOT>` placeholder, so an env file and a context file refer to an
310
- overlay's install dir by one name.
315
+ overlay's install dir by one name. When `dotagents` runs from a `.pyz`,
316
+ `AGENTS_PYLIB` names that archive: the libraries it bundles (duho,
317
+ pathlib_next) import from it, so an overlay built on them appends it to
318
+ `sys.path` when its own import fails. An installed `dotagents` sets nothing,
319
+ since those libraries are already in `AGENTS_PYTHON`'s site-packages. It is
320
+ never put on `PYTHONPATH`, where it would shadow a project's own copy.
311
321
  3. **`PATH`** — every level's `bin/` (each store's overlays, then the store) goes
312
322
  to the front in that order, including ones that do not exist yet, so an `env.py`
313
323
  and every subprocess can call an overlay's helpers by name. The order holds even
@@ -38,7 +38,7 @@ and an error saying a command is required, and exits 2.
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. |
40
40
  | `path [-g] [--lib] [--format list\|native\|posix\|json]` | Print the bin dirs `env` puts on `PATH` (`--lib`: the existing lib dirs it puts on `PYTHONPATH`), highest precedence first; runs no env file. `posix` is `:`-joined, MSYS2 form on Windows. |
41
- | `env [-g] [--format F] [--diff] [--cache]` | Print the assembled environment (`--diff`: only what differs from the caller's; `--cache`: replay the previous output while `env_cache_key` holds, up to `ENV_CACHE_TTL`) in format `F`: `auto` (default: the calling shell), `export`/`posix`/`sh`/`bash`, `dotenv`/`env`, `powershell`/`pwsh`/`ps`, `cmd`/`bat`/`batch`, `fish`, `json`, `ini`, `yaml`. |
41
+ | `env [-g] [--format F] [--diff] [--cache] [--into FILE]` | Print the assembled environment (`--diff`: only what differs from the caller's; `--cache`: replay the previous output while `env_cache_key` holds, up to `ENV_CACHE_TTL`; `--into FILE`: write it into FILE as the one block this command owns there) in format `F`: `auto` (default: the calling shell), `export`/`posix`/`sh`/`bash`, `dotenv`/`env`, `powershell`/`pwsh`/`ps`, `cmd`/`bat`/`batch`, `fish`, `json`, `ini`, `yaml`. |
42
42
  | `launch [AGENT] [-g] [--command PROG] [--no-context] [--inline] [--write-agent] [--dry-run] [-- ARGS...]` | Run the agent's CLI with the env applied and the context handed over; the exit code is the harness's (`128+N` on signal N). |
43
43
  | `findings add\|list\|show\|done\|reopen\|remove\|index\|path [-g] [--dir D]` | The findings queue at `<store>/findings/` (`--dir` overrides): one markdown file per finding, `done` moves it to `processed/` with a required resolution, `INDEX.md` regenerated on every change. |
44
44
  | `about [--json]` | `dotagents-cli <version>`, then `<distribution> <version>` per bundled (`.pyz`) or installed package. |
@@ -108,6 +108,10 @@ The package's own data; library code, importing nothing from `dotagents.cli`.
108
108
  store at `dest`: the project template for a project store when the base has one,
109
109
  else the user template, else `<src>/AGENTS.md`; `{{AGENTS_MD}}` rendered as the
110
110
  absolute POSIX path of `<dest>/AGENTS.md`.
111
+ - `pyz_archive() -> str | None` — the `.pyz` this dotagents is imported from (the
112
+ ancestor of the module's `__file__` that is a file), or `None` for a plain
113
+ install. The archive also holds the vendored duho and pathlib_next; `env`
114
+ exports it as `AGENTS_PYLIB`.
111
115
 
112
116
  ## Commands — `dotagents.cli.init`, `dotagents.cli.overlays`, `dotagents.cli.context`, `dotagents.cli.env`, `dotagents.cli.path`, `dotagents.cli.about`, `dotagents.cli.build_pyz`
113
117
 
@@ -119,6 +123,13 @@ returns the exit code.
119
123
  ("markdown", "system-reminder", "json")`); `env.Env`; `path.PathCmd`
120
124
  (`PATH_FORMATS = ("list", "native", "posix", "json")`); `build_pyz.BuildPyz`;
121
125
  `about.About`.
126
+ - `env`: `write_into(path, text, output_format)` — what `--into` does: the file
127
+ keeps ONE block between `INTO_BEGIN = ": dotagents-env-begin"` and
128
+ `INTO_END = ": dotagents-env-end"` (no-op commands, not comments, so a `; cmd`
129
+ appended to the last line still runs; earlier blocks removed, other lines kept,
130
+ the new block last), written atomically; `INTO_FORMATS = ("export",)`. With
131
+ `--diff --format export`, a `PATH` that is the caller's with entries in front is
132
+ written as `export PATH='<prefix>'"${PATH:+:$PATH}"`.
122
133
  - `about`: `DISTRIBUTION = "dotagents-cli"`, `BUNDLE_FILE = "_bundle.json"` (written
123
134
  by `build-pyz` into the package), `RUNTIME_PACKAGES = ("duho", "pathlib_next",
124
135
  "uritools", "netimps", "requests")` (the only packages a plain install reports);
@@ -322,7 +333,11 @@ proxy model.
322
333
  user-scope walk `AGENTS_PROJECT_ROOT` is set only when unset. Only when unset:
323
334
  `AGENTS_PYTHON` (`sys.executable`) and one `<NAME>_OVERLAY_ROOT` per
324
335
  `scope.overlays` (a value pinned to the user store's copy is re-pointed when a
325
- project copy shadows it).
336
+ project copy shadows it). `AGENTS_PYLIB` (`_env.PYLIB_VAR`): the `.pyz` when
337
+ dotagents runs from one (`_resources.pyz_archive()`), else not set — where its
338
+ own libraries (duho, pathlib_next) import from by zipimport for an overlay
339
+ built on them. Never added to `PYTHONPATH`: a consumer appends it to
340
+ `sys.path` when its import fails.
326
341
  - `PATH`: every level's `bin` (contract A, not project-root, missing dirs included)
327
342
  at the front in that order, whatever the caller's `PATH` held; empty and
328
343
  cwd-relative inherited entries are dropped.
@@ -631,6 +646,8 @@ Read:
631
646
  - `AGENTS_HARNESS` — the running harness (registry name or harness id), for
632
647
  identity. `AGENTS_PYTHON` — the interpreter for `env.py`. `AGENTS_PYTHONPATH` —
633
648
  the overlay/store `lib` dirs `env` put on `PYTHONPATH`, highest precedence first.
649
+ `AGENTS_PYLIB` — the `.pyz` dotagents runs from (duho and pathlib_next import
650
+ from it), unset for an installed dotagents.
634
651
  - Harness config dirs: `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `PI_CODING_AGENT_DIR`.
635
652
  Harness markers: `CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `GEMINI_CLI`,
636
653
  `CODEX_SANDBOX*`, `CURSOR_AGENT`, `PI_CODING_AGENT`. Model vars: `ANTHROPIC_MODEL`,
@@ -644,7 +661,7 @@ Read:
644
661
 
645
662
  Emitted by `env` (and applied by `launch`): `AGENTS_HARNESS`, `AGENTS_VENDOR`,
646
663
  `AGENT`, `AGENTS_MODEL`, `AGENTS_HOME`, `AGENTS_PROJECT_ROOT`, `AGENTS_PYTHON`,
647
- `<NAME>_OVERLAY_ROOT`, `PATH`, `AGENTS_PYTHONPATH`, `AGENTS_PROXY` and the mirrored
664
+ `AGENTS_PYLIB` (from a `.pyz` only), `<NAME>_OVERLAY_ROOT`, `PATH`, `AGENTS_PYTHONPATH`, `AGENTS_PROXY` and the mirrored
648
665
  proxy vars, plus whatever the env files set. `AGENTS_AGENT` is never emitted.
649
666
  `launch` also exports `AGENTS_CONTEXT_FILE`.
650
667
 
@@ -1,3 +1,3 @@
1
1
  """dotagents: installable CLI for the dotagents agent-config payload."""
2
2
 
3
- __version__ = "0.6.2"
3
+ __version__ = "0.6.3"
@@ -500,14 +500,21 @@ class ClaudeAgent(Agent):
500
500
  # SessionStart does two things, in one `type: command` hook (SessionStart
501
501
  # supports only command/mcp_tool hooks, and env must run before context):
502
502
  #
503
- # 1. `dotagents env --diff` output is appended to $CLAUDE_ENV_FILE
503
+ # 1. `dotagents env --diff` output goes into $CLAUDE_ENV_FILE
504
504
  # (code.claude.com/docs/en/env-vars + hooks.md "Persist environment
505
505
  # variables"): Claude sources that file before each Bash command. It is
506
506
  # set only inside SessionStart/Setup/CwdChanged/FileChanged hook
507
507
  # processes, never in the session's own environment.
508
- # * APPEND (`>>`), never truncate -- other hooks write to the same file.
508
+ # * `--into`, not `>>`: the block a previous run wrote is REPLACED
509
+ # and anything else in the file (another hook's lines) is kept.
510
+ # SessionStart re-runs on resume, compact and clear; appending
511
+ # grew the file by the whole env each time, and Claude Code inlines
512
+ # it into every Bash command's `bash -c` argument, which Git for
513
+ # Windows' bash truncates at about 8 KB -- every Bash call then
514
+ # failed with an unterminated quote. PATH is written relative to
515
+ # the shell's own ($PATH), which keeps the block small.
509
516
  # * Guard on non-empty (`[ -n "$CLAUDE_ENV_FILE" ]`) -- when unset,
510
- # `>> ""` would create a file literally named "".
517
+ # `--into ""` has nowhere to write.
511
518
  #
512
519
  # 2. `dotagents context`: Claude injects a SessionStart hook's stdout into
513
520
  # the session context.
@@ -534,7 +541,7 @@ class ClaudeAgent(Agent):
534
541
  SESSION_START_COMMAND = (
535
542
  _HOOK_DOTAGENTS
536
543
  + 'if [ -n "$CLAUDE_ENV_FILE" ]; then '
537
- '"$d" env --diff --format export >> "$CLAUDE_ENV_FILE"; '
544
+ '"$d" env --diff --format export --into "$CLAUDE_ENV_FILE"; '
538
545
  "fi; "
539
546
  '"$d" context'
540
547
  )
@@ -59,7 +59,10 @@ The identity/proxy model is wired into the output around the file chain:
59
59
  the same set :func:`get_overlay_roots` lists, named by
60
60
  :attr:`_overlays.Overlay.root_var` --
61
61
  the same name ``dotagents context`` expands as a placeholder), so an env
62
- file can reference an overlay's install dir by name.
62
+ file can reference an overlay's install dir by name. ``AGENTS_PYLIB``
63
+ (:data:`PYLIB_VAR`) names the ``.pyz`` when dotagents runs from one --
64
+ where its own libraries (duho, pathlib_next) import from for an overlay
65
+ built on them; never put on ``PYTHONPATH``.
63
66
  * **Proxy** is normalized AFTER the file chain: ``AGENTS_PROXY`` is seeded if
64
67
  unset (from ``AGENTS_WEBFETCH_PROXY_URL`` else the global
65
68
  HTTPS/HTTP/ALL_PROXY, either case); any proxy var that *already exists* is
@@ -94,6 +97,11 @@ from typing import Iterable, Optional
94
97
 
95
98
  from dotagents._scope import Scope, project_root_default
96
99
 
100
+ #: Names the ``.pyz`` dotagents runs from (unset for an installed dotagents,
101
+ #: whose libraries are in ``AGENTS_PYTHON``'s site-packages): an overlay built
102
+ #: on duho appends it to ``sys.path`` when ``import duho`` fails.
103
+ PYLIB_VAR = "AGENTS_PYLIB"
104
+
97
105
 
98
106
  class EnvChanges(dict):
99
107
  """A change set: the vars set (``dict[str, str]``, as before) plus
@@ -1047,6 +1055,17 @@ def get_environment(
1047
1055
  # `sys.executable` is '' or None under an embedding host: then nothing is
1048
1056
  # exported rather than an empty command name.
1049
1057
  _seed("AGENTS_PYTHON", sys.executable)
1058
+ # --- AGENTS_PYLIB --- where dotagents' own libraries (duho, pathlib_next)
1059
+ # import from for an overlay that builds on them. Set only when dotagents
1060
+ # runs from a .pyz: the archive, importable by zipimport. An installed
1061
+ # dotagents has them in AGENTS_PYTHON's site-packages already. Never put on
1062
+ # PYTHONPATH, which would shadow a project's own copy: a consumer appends
1063
+ # it to sys.path when its import fails.
1064
+ from dotagents._resources import pyz_archive
1065
+
1066
+ pylib = pyz_archive()
1067
+ if pylib and osenv.get(PYLIB_VAR) != pylib:
1068
+ _apply({PYLIB_VAR: pylib})
1050
1069
 
1051
1070
  # --- Overlay roots --- one `<NAME>_OVERLAY_ROOT` per installed overlay, the
1052
1071
  # same name `dotagents context` expands as a `<NAME_OVERLAY_ROOT>` placeholder
@@ -1208,10 +1227,11 @@ def env_cache_key(scope: Scope, base_env: "dict[str, str]", *parts: str) -> str:
1208
1227
  import hashlib
1209
1228
 
1210
1229
  from dotagents import __version__
1230
+ from dotagents._resources import pyz_archive
1211
1231
 
1212
1232
  scope = _absolute_scope(scope)
1213
1233
  items = [
1214
- __version__, sys.executable or "", os.getcwd(), scope.level,
1234
+ __version__, sys.executable or "", pyz_archive() or "", os.getcwd(), scope.level,
1215
1235
  str(scope.user_root), str(scope.agents_root), str(scope.project_root),
1216
1236
  str(scope.system_root), *parts,
1217
1237
  ]
@@ -37,6 +37,18 @@ def _scratch_dir() -> Path:
37
37
  return _scratch
38
38
 
39
39
 
40
+ def pyz_archive() -> "str | None":
41
+ """The ``.pyz`` this dotagents is imported from, or None for a plain
42
+ install. Inside a zipapp a module's ``__file__`` is a path INTO the
43
+ archive (``.../dotagents.pyz/dotagents/_resources.py``), so the archive
44
+ is the ancestor that is a file. It also holds the vendored runtime
45
+ dependencies (duho, pathlib_next), importable from it by zipimport."""
46
+ for parent in Path(__file__).parents:
47
+ if parent.is_file():
48
+ return str(parent)
49
+ return None
50
+
51
+
40
52
  def _package_data_dir(name: str) -> "Path | None":
41
53
  """Resolve a directory under the installed `dotagents` package (e.g.
42
54
  `_overlay`) to a real filesystem Path, working whether the package is a
@@ -232,10 +232,63 @@ def _looks_like_posix_path_list(key: str, value: str) -> bool:
232
232
  return any(_looks_like_posix_chunk(chunk) for chunk in value.split(";") if chunk)
233
233
 
234
234
 
235
+ #: The lines around the block `env --into` owns in a file. No-op COMMANDS,
236
+ #: not `#` comments: a harness that inlines the file and appends `; cmd` or
237
+ #: `&& cmd` to its last line would otherwise comment the command out.
238
+ INTO_BEGIN = ": dotagents-env-begin"
239
+ INTO_END = ": dotagents-env-end"
240
+ #: The formats `--into` writes: a POSIX shell's, where `:` is the no-op.
241
+ INTO_FORMATS = ("export",)
242
+
243
+
244
+ def write_into(path: Path, text: str, output_format: str) -> None:
245
+ """Put ``text`` into ``path`` as the ONE block ``env --into`` owns there:
246
+ every earlier block (between :data:`INTO_BEGIN` / :data:`INTO_END`) is
247
+ removed, anything else in the file -- another hook's lines -- is kept in
248
+ place, and the new block goes at the end, where a sourced file's later
249
+ lines win (what an append did). Atomic, LF-only. A hook that re-runs
250
+ (resume, compact, clear) used to append its whole output each time, and
251
+ the file Claude Code inlines into every Bash command grew past what Git
252
+ for Windows' bash accepts as a ``-c`` argument (about 8 KB)."""
253
+ from dotagents._env import FORMAT_ALIASES
254
+ from dotagents._fs import write_text_lf
255
+
256
+ fmt = FORMAT_ALIASES.get(output_format, output_format)
257
+ if fmt not in INTO_FORMATS:
258
+ raise SystemExit("error: --into writes the %s format only, not %r" % ("/".join(INTO_FORMATS), fmt))
259
+ kept: "list[str]" = []
260
+ inside = False
261
+ try:
262
+ current = path.read_text(encoding="utf-8")
263
+ except FileNotFoundError:
264
+ current = ""
265
+ for line in current.splitlines():
266
+ if line.strip() == INTO_BEGIN:
267
+ inside = True
268
+ elif line.strip() == INTO_END and inside:
269
+ inside = False
270
+ elif not inside:
271
+ kept.append(line)
272
+ block = [INTO_BEGIN] + [ln for ln in text.splitlines() if ln] + [INTO_END]
273
+ write_text_lf(path, "\n".join(kept + block) + "\n", atomic=True)
274
+
275
+
276
+ def _prefixed_path_line(new: str, old: str, sep: str) -> "Optional[str]":
277
+ """``export PATH='<prefix>'"${PATH:+:$PATH}"`` when ``new`` is ``old``
278
+ with entries put in front, else ``None``. The sourcing shell's own PATH
279
+ completes it, so the line stays short however long PATH is (the env file
280
+ is inlined into every Bash command) and keeps what the shell adds."""
281
+ if not old or not new.endswith(sep + old) or len(new) == len(old) + 1:
282
+ return None
283
+ prefix = new[: -(len(old) + 1)]
284
+ return 'export PATH=%s"${PATH:+%s$PATH}"' % (_sh_quote(prefix), sep)
285
+
286
+
235
287
  def _format_env(
236
288
  env: "dict[str, str]",
237
289
  output_format: str,
238
290
  removed: "Optional[Iterable[str]]" = None,
291
+ base: "Optional[dict[str, str]]" = None,
239
292
  ) -> str:
240
293
  """Render the assembled env in the requested (canonical or aliased) format.
241
294
 
@@ -369,9 +422,17 @@ def _format_env(
369
422
  for k in keys]
370
423
  + ["set -e %s" % k for k in gone if _POSIX_IDENTIFIER_RE.match(k)]
371
424
  )
372
- # default / "export"
425
+ # default / "export". With the caller's env (`--diff`), a PATH that is the
426
+ # caller's own with entries in front is written relative to the sourcing
427
+ # shell's $PATH rather than in full (see `_prefixed_path_line`).
428
+ path_line = None
429
+ if base is not None and "PATH" in env:
430
+ old = base.get("PATH") or ""
431
+ if windows_host and _looks_like_path_list("PATH", old):
432
+ old = _to_posix_path_list(old)
433
+ path_line = _prefixed_path_line(env["PATH"], old, ":")
373
434
  return "\n".join(
374
- ["export %s=%s" % (k, _sh_quote(env[k])) for k in keys]
435
+ [path_line if k == "PATH" and path_line else "export %s=%s" % (k, _sh_quote(env[k])) for k in keys]
375
436
  + ["unset %s" % k for k in gone if _POSIX_IDENTIFIER_RE.match(k)]
376
437
  )
377
438
 
@@ -568,6 +629,14 @@ class Env(DotAgentsArgs):
568
629
  )
569
630
  ("--cache",)
570
631
 
632
+ into: "Optional[Path]" = None
633
+ (
634
+ "Write into this file instead of stdout (a hook's $CLAUDE_ENV_FILE), "
635
+ "replacing the block an earlier run wrote there and keeping the rest: "
636
+ "the file holds one current block however often the hook runs."
637
+ )
638
+ ("--into",)
639
+
571
640
  # Both flags come from `DotAgentsArgs`; only their HELP is restated here
572
641
  # (same flags, defaults and types), because this command's `-g` is narrower
573
642
  # than the base's and its store is always the user store.
@@ -631,8 +700,13 @@ class Env(DotAgentsArgs):
631
700
 
632
701
  # UTF-8 straight to the buffer: a bare print() encodes with the console
633
702
  # codepage and dies on the first non-Latin-1 character in any value.
634
- text = _format_env(env, output_format, removed) + "\n"
703
+ # With --diff, PATH may be written relative to the caller's own PATH
704
+ # (see `_format_env`): the base is what the diff was taken against.
705
+ text = _format_env(env, output_format, removed, base=base if self.diff else None) + "\n"
635
706
  if key is not None:
636
707
  _env.write_env_cache(scope, key, text)
708
+ if self.into is not None:
709
+ write_into(Path(self.into), text, output_format)
710
+ return 0
637
711
  _write_stdout(text)
638
712
  return 0
@@ -489,6 +489,43 @@ def test_agents_python_is_not_exported_when_the_interpreter_is_unknown(tree, mon
489
489
  assert "AGENTS_PYTHON" not in env
490
490
 
491
491
 
492
+ def test_agents_pylib_names_the_pyz_only_when_running_from_one(tree, tmp_path, monkeypatch):
493
+ """An installed dotagents has its libraries in AGENTS_PYTHON's
494
+ site-packages: nothing to export. From a .pyz the archive is where they
495
+ import from; the walk's value replaces another, and it never reaches
496
+ PYTHONPATH (a project's own duho must not be shadowed)."""
497
+ from dotagents import _resources
498
+
499
+ agents_dir, project_root = tree
500
+ assert _resources.pyz_archive() is None, "the suite runs from the source tree"
501
+ env = _run(agents_dir, project_root, {"PATH": "/usr/bin"})
502
+ assert _env.PYLIB_VAR not in env
503
+ pyz = tmp_path / "dotagents.pyz"
504
+ pyz.write_bytes(b"PK")
505
+ monkeypatch.setattr(_resources, "pyz_archive", lambda: str(pyz))
506
+ env = _run(agents_dir, project_root, {"PATH": "/usr/bin"})
507
+ assert env[_env.PYLIB_VAR] == str(pyz)
508
+ assert str(pyz) not in env.get("PYTHONPATH", "")
509
+ env = _run(agents_dir, project_root, {"PATH": "/usr/bin", _env.PYLIB_VAR: str(pyz)})
510
+ assert _env.PYLIB_VAR not in env, "already right: no change"
511
+ env = _run(agents_dir, project_root, {"PATH": "/usr/bin", _env.PYLIB_VAR: "/old/dotagents.pyz"})
512
+ assert env[_env.PYLIB_VAR] == str(pyz), "another archive's value is replaced"
513
+
514
+
515
+ def test_pyz_archive_finds_the_archive_a_module_is_imported_from(tmp_path):
516
+ """Run from a real zipapp: the archive is the ancestor of __file__ that is a file."""
517
+ import subprocess
518
+ import zipfile
519
+
520
+ src = Path(_env.__file__).resolve().parents[1]
521
+ pyz = tmp_path / "t.pyz"
522
+ with zipfile.ZipFile(str(pyz), "w") as zf:
523
+ zf.write(str(src / "dotagents" / "_resources.py"), "probe_resources.py")
524
+ code = "import sys; sys.path.insert(0, sys.argv[1]); import probe_resources as r; print(r.pyz_archive())"
525
+ out = subprocess.run([sys.executable, "-c", code, str(pyz)], capture_output=True, text=True, check=True)
526
+ assert out.stdout.strip() == str(pyz)
527
+
528
+
492
529
  def test_env_py_runs_under_a_pinned_agents_python(tree, tmp_path, monkeypatch):
493
530
  """The pin `env` exports for shims is honoured for dotagents' own
494
531
  subprocesses too (only when it names an existing file)."""
@@ -0,0 +1,89 @@
1
+ """`env --into FILE` keeps ONE dotagents block in a hook's env file however
2
+ often the hook runs, leaves other hooks' lines alone, and `--diff --format
3
+ export` writes PATH relative to the sourcing shell's own -- so the file
4
+ Claude Code inlines into every Bash command stays small. (A SessionStart hook
5
+ that appended on every resume/compact grew it past Git for Windows' ~8 KB
6
+ `bash -c` limit, and every Bash call failed.)"""
7
+ import os
8
+ import subprocess
9
+
10
+ import pytest
11
+
12
+ from dotagents.cli.env import INTO_BEGIN, INTO_END, _format_env, _prefixed_path_line, write_into
13
+ from _shell import real_bash
14
+
15
+
16
+ def test_write_into_replaces_only_its_own_block(tmp_path):
17
+ env_file = tmp_path / "hook.sh"
18
+ env_file.write_text("export OTHER_HOOK='kept'\n", encoding="utf-8")
19
+ for value in ("one", "two", "three"):
20
+ write_into(env_file, "export A='%s'\nexport B='x'\n" % value, "export")
21
+ text = env_file.read_text(encoding="utf-8")
22
+ assert text.count(INTO_BEGIN) == 1 and text.count(INTO_END) == 1, "one block, however often"
23
+ assert "export OTHER_HOOK='kept'" in text, "another hook's line stays"
24
+ assert "export A='three'" in text and "one" not in text and "two" not in text
25
+ assert text.index("OTHER_HOOK") < text.index(INTO_BEGIN), "ours last: it wins, as an append did"
26
+ assert b"\r" not in env_file.read_bytes()
27
+
28
+
29
+ def test_write_into_creates_the_file_and_refuses_formats_without_comments(tmp_path):
30
+ env_file = tmp_path / "new.sh"
31
+ write_into(env_file, "export A='1'\n", "export")
32
+ assert env_file.read_text(encoding="utf-8") == "%s\nexport A='1'\n%s\n" % (INTO_BEGIN, INTO_END)
33
+ with pytest.raises(SystemExit):
34
+ write_into(tmp_path / "x.cmd", 'set "A=1"\n', "cmd")
35
+
36
+
37
+ def test_a_command_appended_to_the_block_still_runs(tmp_path):
38
+ """The markers are no-op COMMANDS: a harness that inlines the file and
39
+ joins the command onto its last line (`; cmd`, `&& cmd`) must not have it
40
+ commented out."""
41
+ bash = real_bash()
42
+ if not bash:
43
+ pytest.skip("no bash")
44
+ env_file = tmp_path / "hook.sh"
45
+ write_into(env_file, "export A='1'\n", "export")
46
+ body = env_file.read_text(encoding="utf-8").rstrip("\n")
47
+ for joiner in ("; ", " && ", "\n"):
48
+ out = subprocess.run([bash, "-c", body + joiner + 'printf "%s" "$A"'], capture_output=True, text=True)
49
+ assert out.stdout == "1", (joiner, out.stderr)
50
+ with pytest.raises(SystemExit):
51
+ write_into(tmp_path / "x.env", "A=1\n", "dotenv")
52
+
53
+
54
+ def test_prefixed_path_line():
55
+ assert _prefixed_path_line("/a:/b:/usr/bin", "/usr/bin", ":") == 'export PATH=\'/a:/b\'"${PATH:+:$PATH}"'
56
+ assert _prefixed_path_line("/usr/bin", "/usr/bin", ":") is None, "nothing in front"
57
+ assert _prefixed_path_line("/a:/usr/local/bin", "/usr/bin", ":") is None, "not a prefix: full value"
58
+ assert _prefixed_path_line("/a", "", ":") is None
59
+
60
+
61
+ @pytest.mark.skipif(os.name == "nt", reason="a POSIX PATH; the Windows conversion is covered below")
62
+ def test_diff_export_writes_path_as_a_prefix_on_posix():
63
+ out = _format_env({"PATH": "/opt/x/bin:/usr/bin:/bin", "A": "1"}, "export", base={"PATH": "/usr/bin:/bin"})
64
+ assert "export PATH='/opt/x/bin'\"${PATH:+:$PATH}\"" in out
65
+ assert _format_env({"PATH": "/opt/x/bin:/usr/bin"}, "export") == "export PATH='/opt/x/bin:/usr/bin'", "no base: full"
66
+
67
+
68
+ def test_diff_export_prefix_survives_the_windows_conversion(monkeypatch):
69
+ from dotagents.cli import env as env_cli
70
+
71
+ monkeypatch.setattr(env_cli, "_host_is_windows", lambda: True)
72
+ base = {"PATH": r"C:\Windows\System32;C:\Windows"}
73
+ new = {"PATH": r"C:\Tools\dotagents\bin;C:\Windows\System32;C:\Windows"}
74
+ out = _format_env(new, "export", base=base)
75
+ assert out == "export PATH='/c/Tools/dotagents/bin'\"${PATH:+:$PATH}\""
76
+
77
+
78
+ def test_a_sourced_block_really_prepends(tmp_path):
79
+ """Source the rendered line in a real POSIX shell: the prefix lands in
80
+ front of that shell's own PATH."""
81
+ bash = real_bash()
82
+ if not bash:
83
+ pytest.skip("no bash")
84
+ line = _prefixed_path_line("/opt/dotagents/bin:/usr/bin", "/usr/bin", ":")
85
+ script = tmp_path / "env.sh"
86
+ script.write_text(line + "\n", encoding="utf-8")
87
+ out = subprocess.run([bash, "-c", "PATH=/shell/own; . ./env.sh; printf %s \"$PATH\""],
88
+ cwd=str(tmp_path), capture_output=True, text=True)
89
+ assert out.stdout == "/opt/dotagents/bin:/shell/own", out.stderr
@@ -110,15 +110,18 @@ def test_hook_finds_dotagents_by_path_not_by_splicing_path():
110
110
  assert "PATH=" not in cmd
111
111
 
112
112
 
113
- def test_env_hook_appends_and_is_guarded():
114
- """Two documented requirements, both load-bearing.
115
-
116
- `>` would discard variables other hooks wrote to the same file; an unguarded
117
- redirect with CLAUDE_ENV_FILE unset would create a file named "".
113
+ def test_env_hook_replaces_its_own_block_and_is_guarded():
114
+ """Three requirements, all load-bearing.
115
+
116
+ `>` would discard variables other hooks wrote to the same file; `>>`
117
+ grew it by the whole env on every SessionStart (resume, compact, clear)
118
+ until the file Claude Code inlines into each Bash command passed Git for
119
+ Windows' ~8 KB `bash -c` limit -- so `env --into` replaces only its own
120
+ block. An unguarded write with CLAUDE_ENV_FILE unset has nowhere to go.
118
121
  """
119
122
  cmd = ClaudeAgent.SESSION_START_COMMAND
120
- assert '>> "$CLAUDE_ENV_FILE"' in cmd, "must append, never truncate"
121
- assert ">>" in cmd and not _has_truncating_redirect(cmd)
123
+ assert 'env --diff --format export --into "$CLAUDE_ENV_FILE"' in cmd
124
+ assert ">>" not in cmd and not _has_truncating_redirect(cmd), "no shell redirect into the env file"
122
125
  assert '[ -n "$CLAUDE_ENV_FILE" ]' in cmd, "must guard against an unset var"
123
126
  assert cmd.index('"$d" env') < cmd.index('"$d" context'), (
124
127
  "env must be written before context runs"
@@ -181,6 +181,13 @@ has "$(pinned_env --format powershell)" "\${env:FROM_PINNED_STORE} = 'yes'"
181
181
  has "$(pinned_env --format cmd)" 'set "FROM_PINNED_STORE=yes"'
182
182
  has_re "$(pinned_env --format dotenv)" "^FROM_PINNED_STORE=yes$"
183
183
  has "$(pinned_env)" "FROM_PINNED_STORE"
184
+ # Run from a .pyz, env names it in AGENTS_PYLIB, and the vendored duho imports
185
+ # from it by zipimport: what an overlay built on duho (the net overlay's curl
186
+ # fallback) relies on when AGENTS_PYTHON has no duho of its own.
187
+ has_re "$(pyz env --diff --format dotenv -g)" "^AGENTS_PYLIB=.*\.pyz"
188
+ # -S -E: no site-packages, no PYTHONPATH -- a runner that has duho installed
189
+ # must still prove the archive alone provides it.
190
+ "$PYTHON" -S -E -c "import sys; sys.path.insert(0, sys.argv[1]); import duho; assert '.pyz' in duho.__file__, duho.__file__" "$(native "$PYZ")"
184
191
 
185
192
  # overlays add: positional and aliased flags.
186
193
  h=$(pyz overlays add --help)
File without changes
File without changes
File without changes
File without changes
File without changes