dotagents-cli 0.8.0__tar.gz → 0.8.2__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 (120) hide show
  1. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/CHANGELOG.md +43 -1
  2. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/PKG-INFO +2 -1
  3. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/guide/authoring.md +35 -6
  4. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/guide/overlays.md +5 -2
  5. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/guide/private-sync.md +17 -10
  6. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/pyproject.toml +2 -0
  7. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/AGENTS.md +64 -9
  8. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/__init__.py +1 -1
  9. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_env.py +25 -1
  10. dotagents_cli-0.8.2/src/dotagents/_launchers.py +241 -0
  11. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlay/dotagents/cmds/findings.py +21 -0
  12. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlays.py +99 -7
  13. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_pyvenv.py +32 -2
  14. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_scope.py +25 -2
  15. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_wrappers.py +34 -23
  16. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/build_pyz.py +5 -0
  17. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_build_pyz.py +2 -1
  18. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_findings_cmd.py +14 -0
  19. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_scope_context.py +27 -0
  20. dotagents_cli-0.8.2/tests/test_launchers.py +322 -0
  21. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_pyvenv_core.py +10 -2
  22. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_wrappers.py +29 -3
  23. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/.gitignore +0 -0
  24. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/LICENSE +0 -0
  25. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/README.md +0 -0
  26. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/agents.md +0 -0
  27. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/cli.md +0 -0
  28. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/commands.md +0 -0
  29. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/context.md +0 -0
  30. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/env.md +0 -0
  31. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/fs.md +0 -0
  32. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/hooks.md +0 -0
  33. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/merge.md +0 -0
  34. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/overlays.md +0 -0
  35. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/reference.md +0 -0
  36. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/resources.md +0 -0
  37. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/scope.md +0 -0
  38. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/skills.md +0 -0
  39. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/sources.md +0 -0
  40. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/api/wrappers.md +0 -0
  41. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/changelog.md +0 -0
  42. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/guide/commands.md +0 -0
  43. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/guide/install.md +0 -0
  44. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/docs/index.md +0 -0
  45. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/install.py +0 -0
  46. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/mkdocs.yml +0 -0
  47. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/__main__.py +0 -0
  48. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_agents.py +0 -0
  49. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_context.py +0 -0
  50. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_fs.py +0 -0
  51. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_hooks.py +0 -0
  52. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_ignore.py +0 -0
  53. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_merge.py +0 -0
  54. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlay/dotagents/cmds/launch.py +0 -0
  55. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlay/dotagents/hooks/preinvocation_antigravity_context.py +0 -0
  56. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlay/dotagents/hooks/pretooluse_codex_env.py +0 -0
  57. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlay/dotagents/hooks/sessionstart_codex_context.py +0 -0
  58. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlay/dotagents/templates/AGENTS.md +0 -0
  59. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_overlay/dotagents/templates/PROJECT.md +0 -0
  60. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_pipwheel.py +0 -0
  61. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_resources.py +0 -0
  62. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_skills.py +0 -0
  63. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/_sources.py +0 -0
  64. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/__init__.py +0 -0
  65. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/_common.py +0 -0
  66. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/about.py +0 -0
  67. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/context.py +0 -0
  68. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/env.py +0 -0
  69. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/init.py +0 -0
  70. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/overlays.py +0 -0
  71. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/cli/path.py +0 -0
  72. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/src/dotagents/py.typed +0 -0
  73. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/_helpers.py +0 -0
  74. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/_shell.py +0 -0
  75. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/conftest.py +0 -0
  76. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_about.py +0 -0
  77. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_agents.py +0 -0
  78. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_api_header.py +0 -0
  79. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_audit_leak.py +0 -0
  80. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_cli_main.py +0 -0
  81. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_cloud_setup.py +0 -0
  82. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_cmds_discovery.py +0 -0
  83. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_context.py +0 -0
  84. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_env.py +0 -0
  85. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_env_cli_scope.py +0 -0
  86. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_env_format.py +0 -0
  87. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_env_into.py +0 -0
  88. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_env_regressions.py +0 -0
  89. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_env_safety.py +0 -0
  90. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_adapters.py +0 -0
  91. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_cli.py +0 -0
  92. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_env.py +0 -0
  93. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_hooks.py +0 -0
  94. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_init.py +0 -0
  95. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_overlays.py +0 -0
  96. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_packaging.py +0 -0
  97. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_fix_skills_adapters.py +0 -0
  98. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_git_ssl_revoke.py +0 -0
  99. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_hooks.py +0 -0
  100. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_ignore.py +0 -0
  101. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_init_base.py +0 -0
  102. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_last_mile.py +0 -0
  103. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_launch_cmd.py +0 -0
  104. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_local_additions.py +0 -0
  105. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_mcp.py +0 -0
  106. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_no_internal_labels.py +0 -0
  107. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_overlay_libs.py +0 -0
  108. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_overlays_cli.py +0 -0
  109. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_overlays_command.py +0 -0
  110. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_path_timeout_frontmatter.py +0 -0
  111. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_pipwheel.py +0 -0
  112. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_powershell_env_hook.py +0 -0
  113. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_quoting_injection_safety.py +0 -0
  114. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_scope.py +0 -0
  115. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_sources.py +0 -0
  116. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_sources_uri.py +0 -0
  117. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tests/test_wire_hooks.py +0 -0
  118. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tools/audit.py +0 -0
  119. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tools/cloud-setup.sh +0 -0
  120. {dotagents_cli-0.8.0 → dotagents_cli-0.8.2}/tools/pyz_smoke.sh +0 -0
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.2] - 2026-10-25
11
+
12
+ ### Fixed
13
+
14
+ - `findings` (and `init` / `overlays`) run from inside a project's `.agents`, such as
15
+ `<project>/.agents/findings`, target that project. They used to write a second
16
+ `.agents` inside the store. Deeper than a top-level directory of the user store,
17
+ where no project can be named, they refuse instead of writing there.
18
+ - `findings` keeps a hand-written `INDEX.md`: before the first regeneration it is
19
+ copied to `INDEX.md.handwritten`, with a warning.
20
+ - The routing note above overlay paths no longer suggests `dotagents env` for
21
+ reading an overlay root (it prints the whole environment, secrets included); it
22
+ says to read just the variable.
23
+
24
+ - The `sh` wrapper `init` writes into `<store>/bin/dotagents` works from another
25
+ system that shares the store, such as WSL reading a Windows store. It ran the
26
+ interpreter recorded by `init`, a Windows path there; where that interpreter
27
+ does not run it now takes the store's agents' venv for that OS and machine
28
+ (`.pyvenv/<version>-<os>-<arch>`), else `python3`, and converts a recorded
29
+ `C:/...` pyz path with `wslpath`.
30
+
31
+ ## [0.8.1] - 2026-10-21
32
+
33
+ ### Added
34
+
35
+ - Overlay commands get a launcher on `PATH`: each top-level `bin/<name>.py`, and
36
+ each `[scripts]` entry in `overlay.toml` (`name = "path"` adds or replaces one,
37
+ `name = false` opts a `bin/<name>.py` out). The launchers live in the agents'
38
+ venv, in `<venv>/overlays/`: `<name>.exe` on Windows, made with distlib's
39
+ `ScriptMaker` so no command interpreter parses the arguments, and a `/bin/sh`
40
+ script elsewhere. They run the script through `$<NAME>_OVERLAY_ROOT` when they
41
+ run, so only a new interpreter needs them made again. `init` and `overlays add` /
42
+ `sync` / `remove` keep them current and delete those no overlay has; two
43
+ overlays with one script name is an error naming both.
44
+ - `dotagents env` puts the launcher directory of the venv `AGENTS_PYTHON` names
45
+ first on `PATH`, ahead of the levels' `bin` dirs, and takes any other venv's
46
+ off it.
47
+ - A project gets its own venv when one of its overlays has scripts.
48
+ - New dependency: `distlib>=0.4.0,<0.5`, vendored in the `.pyz`.
49
+
10
50
  ## [0.8.0] - 2026-10-16
11
51
 
12
52
  ### Added
@@ -1527,7 +1567,9 @@ Patch release: the PATH/POSIX-conversion fix above (the only change since 0.3.0)
1527
1567
  affected module sources (`dotagents.cli`, `duho.presets`) to extracted temp files
1528
1568
  before dispatch; a no-op for a plain install.
1529
1569
 
1530
- [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.8.0...HEAD
1570
+ [Unreleased]: https://github.com/jose-pr/dotagents/compare/v0.8.2...HEAD
1571
+ [0.8.2]: https://github.com/jose-pr/dotagents/compare/v0.8.1...v0.8.2
1572
+ [0.8.1]: https://github.com/jose-pr/dotagents/compare/v0.8.0...v0.8.1
1531
1573
  [0.8.0]: https://github.com/jose-pr/dotagents/compare/v0.7.6...v0.8.0
1532
1574
  [0.7.6]: https://github.com/jose-pr/dotagents/compare/v0.7.5...v0.7.6
1533
1575
  [0.7.5]: https://github.com/jose-pr/dotagents/compare/v0.7.4...v0.7.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dotagents-cli
3
- Version: 0.8.0
3
+ Version: 0.8.2
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
@@ -23,6 +23,7 @@ Classifier: Programming Language :: Python :: 3.14
23
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
24
  Classifier: Typing :: Typed
25
25
  Requires-Python: >=3.9
26
+ Requires-Dist: distlib<0.5,>=0.4.0
26
27
  Requires-Dist: duho<0.8,>=0.7.5
27
28
  Requires-Dist: pathapi<0.2,>=0.1.0
28
29
  Requires-Dist: tomli>=1.1.0; python_version < '3.11'
@@ -72,12 +72,41 @@ dotagents starts with the libs in place whatever shell it runs from. A `lib/` mo
72
72
  comes before site-packages and the standard library: do not reuse a package name you
73
73
  do not mean to replace.
74
74
 
75
- A `bin/` launcher can also be run outside a `dotagents env` session (from cmd.exe or
76
- a PowerShell prompt, say), so it sets `PYTHONPATH` itself before starting Python:
77
- its own `lib/`, then `$AGENTS_PYTHONPATH`, then the caller's, leaving empty parts
78
- out (an empty entry means the current directory). The net overlay's `bin/curl` and
79
- `bin/curl.cmd` are the pattern: POSIX sh with no external commands, and a `.cmd`
80
- that uses `setlocal` so the caller's cmd session is not changed.
75
+ ## Commands on PATH: `bin/*.py` and `[scripts]`
76
+
77
+ A Python command needs no launcher of your own. Every top-level `bin/<name>.py`
78
+ gets one named `<name>`: `<name>.exe` on Windows (distlib's `ScriptMaker`, the
79
+ launcher pip gives console scripts) and a `/bin/sh` script elsewhere, made in the
80
+ agents' venv, in `<venv>/overlays/`, whenever `overlays add` / `sync` / `remove` or
81
+ `init` brings the venv up to date. `dotagents env` puts that directory first on
82
+ `PATH` for the venv `$AGENTS_PYTHON` names, so `bin/curl.py` answers to `curl`
83
+ ahead of the system's, and drops any other venv's.
84
+
85
+ The launcher runs your script under the venv's interpreter, as `python
86
+ bin/<name>.py` would, found when it runs through `$<NAME>_OVERLAY_ROOT`: editing
87
+ the script or moving the store needs nothing regenerated, only a new interpreter
88
+ does. Unset, or with the script missing, it exits 2 and says so. A file whose name
89
+ starts with `_` is not a command. Name other scripts, rename one, or opt one out
90
+ in `overlay.toml`:
91
+
92
+ ```toml
93
+ [scripts]
94
+ tool = "lib/tool/main.py" # a launcher `tool` for another path
95
+ helper = false # none for bin/helper.py
96
+ ```
97
+
98
+ Two overlays with one script name is an error naming both. A launcher no overlay
99
+ has any more is deleted at the next sync.
100
+
101
+ **Never ship a `.cmd` or `.bat` that takes arguments.** cmd.exe parses its command
102
+ line before the first line runs, so an argument's `&`, `|`, `>` or `%VAR%` acts:
103
+ from PowerShell, `curl "http://h/?a=1|hostname"` would run `hostname`. The `.exe`
104
+ above hands the arguments over as passed.
105
+
106
+ Your script may run outside a `dotagents env` session too, so set `PYTHONPATH`
107
+ for what it starts, if anything needs the libs: its own `lib/`, then
108
+ `$AGENTS_PYTHONPATH`, then the caller's, leaving empty parts out (an empty entry
109
+ means the current directory). The net overlay's `bin/curl.py` does this.
81
110
 
82
111
  ## Setup scripts
83
112
 
@@ -48,7 +48,7 @@ or `--repo <checkout>/overlays` for a local checkout of the branch.
48
48
  | `python`, `node`, `rust` | Per-language `kb/` conventions + manifest templates + CI workflow templates. |
49
49
  | `release` | A host-agnostic release helper: an agent-driven commit-plan loop plus tag/CI monitoring across GitHub (`gh`) and GitLab. |
50
50
  | `private-sync` | The one-private-repo, per-project `.agents` model (`kb/PRIVATE_SYNC.md` + cloud hooks) — see [Private sync](private-sync.md). |
51
- | `net` | Dependency-free HTTP tooling (a drop-in `curl` shim, an OS-trust-store `certifi` shim, an `httplib` session toolkit reading `AGENTS_PROXY`). |
51
+ | `net` | HTTP tooling: a drop-in `curl` launcher (curlike), an OS-trust-store `certifi` shim, and syshttp (the system proxy and the OS verifier by default), both packages installed from the overlay's `requirements.txt`; settings are `AGENTS_*` (`AGENTS_PROXY` and kin). |
52
52
  | `recovery` | A config-recovery playbook for reconstructing a lost `~/.agents`. |
53
53
 
54
54
  ## Managing overlays
@@ -182,7 +182,9 @@ dotagents overlays sync -g # r
182
182
 
183
183
  Nothing in an overlay has to set up its own paths. For every installed overlay,
184
184
  `dotagents env` prepends `bin/` to `PATH` and an existing `lib/` to `PYTHONPATH`
185
- (listing the libs in `AGENTS_PYTHONPATH` too), and exports **`$<NAME>_OVERLAY_ROOT`** — the overlay's
185
+ (listing the libs in `AGENTS_PYTHONPATH` too), puts the launchers of its scripts
186
+ (`bin/*.py` and `[scripts]`, in `<venv>/overlays/` of the agents' venv) first on
187
+ `PATH`, and exports **`$<NAME>_OVERLAY_ROOT`** — the overlay's
186
188
  installed directory (`NAME` is the overlay's name upper-cased, `-` → `_`:
187
189
  `private-sync` → `PRIVATE_SYNC_OVERLAY_ROOT`).
188
190
  That variable is how overlay content refers to itself and to other overlays: a
@@ -214,6 +216,7 @@ small built-in reader that covers these keys. Keys:
214
216
  | `rules` | Overlay-relative markdown paths whose rule bullets append to "Always-on rules". |
215
217
  | `priority` | Merge order (lower sorts earlier; unprioritized default is 500). |
216
218
  | `setup_timeout` | Seconds the setup script may run before it is stopped (default 300; `0` = no limit). |
219
+ | `[scripts]` | Launchers on `PATH`: `name = "path/in/overlay.py"` adds or replaces one, `name = false` drops the one for a `bin/<name>.py`. See [Authoring → Commands on PATH](authoring.md#commands-on-path-binpy-and-scripts). |
217
220
 
218
221
  `overlay.toml` is the only manifest. A leading `---` frontmatter block in a
219
222
  `CONTEXT.md` (the `priority:` / `scope:` / `dependencies:` an older overlay format
@@ -11,13 +11,19 @@ overlay is what makes them exist; a plain dotagents has no private-sync surface
11
11
 
12
12
  ## The idea
13
13
 
14
- Your global config store **is** a private git repo. Its root is the per-user config;
15
- a `projects/<name>/` tree inside it holds each project's private payload. For a
16
- checked-out project, its `.agents` directory is a **symlink** into the store (the
17
- project's `.gitignore` already excludes `.agents` per the Leakage rule, so the link
14
+ Your global config store **is** a private git repo, and its `main` branch is the
15
+ per-user config. Each project's private payload is a **branch** of it, named after
16
+ the project, and the project's `.agents` directory is a git worktree on that branch
17
+ (the project's `.gitignore` already excludes `.agents` per the Leakage rule, so it
18
18
  never lands in the public repo). `<name>` defaults to the project's basename, so a
19
- local checkout and a cloud checkout of the same project resolve to the same store
20
- entry.
19
+ local checkout and a cloud checkout of the same project resolve to the same branch.
20
+
21
+ That branch can live elsewhere: `link-project --store-repo self` keeps it on the
22
+ project's own repository (branch `agents`), and `--store-repo <url>` on any other,
23
+ such as a team account's (cloned into `.agents`); `--store-branch` names the branch.
24
+ The choice is recorded in the config repo, so every machine links the project the
25
+ same way. A store kept as a `projects/<name>/` directory, symlinked into the
26
+ project, still works and is kept by a project that already has one.
21
27
 
22
28
  The store's installed overlays can travel two ways:
23
29
 
@@ -39,11 +45,12 @@ dotagents overlays add private-sync -g \
39
45
  --repo "https://github.com/jose-pr/dotagents.git@repo#overlays" # commands + kb + hooks
40
46
 
41
47
  # link-project / sync-project come FROM that overlay -- run `overlays add` first.
42
- dotagents link-project . # symlink this project's .agents into its store
48
+ dotagents link-project . # check this project's store branch out at .agents
43
49
  # (an existing real .agents/ is adopted on the
44
- # first link; --copy mirrors it as a real dir
45
- # for no-symlink systems)
46
- dotagents sync-project -m "msg" # git pull --rebase / commit / push the store
50
+ # first link)
51
+ dotagents link-project . --store-repo self # ...on the project's own repo
52
+ dotagents link-project . --store-repo <team-repo-url> # ...on another repo
53
+ dotagents sync-project --project . -m "msg" # commit / push the project's branch, then the config
47
54
  dotagents sync-project --remote <url> -m init # one-command bootstrap
48
55
  ```
49
56
 
@@ -37,6 +37,8 @@ classifiers = [
37
37
  dependencies = [
38
38
  "duho>=0.7.5,<0.8",
39
39
  "pathapi>=0.1.0,<0.2",
40
+ # The launchers of overlays' [scripts]: ScriptMaker writes the Windows .exe.
41
+ "distlib>=0.4.0,<0.5",
40
42
  # overlay.toml manifests and TOML registries; tomllib is stdlib from 3.11.
41
43
  'tomli>=1.1.0; python_version < "3.11"',
42
44
  ]
@@ -239,8 +239,10 @@ returns the exit code.
239
239
  otherwise; `0` from either means no limit.
240
240
  - `parse_manifest_text(text, origin="overlay.toml") -> Optional[dict]` — the TOML
241
241
  document via `tomllib` / `tomli`, else a built-in reader (top-level strings,
242
- string arrays including multi-line strings, an integer); `None` plus one warning
243
- when it is not valid TOML.
242
+ string arrays including multi-line strings, an integer, and the `[scripts]` table
243
+ of strings and booleans); `None` plus one warning when it is not valid TOML.
244
+ - `SCRIPT_NAME_RE` — a script (launcher) name: a letter or digit, then letters,
245
+ digits, `.`, `_`, `-`; never ending in `.exe` nor a Windows device name.
244
246
  - `class InstallResult` — what `install_to` did: `written`, `unchanged`,
245
247
  `replaced` (edited here, replaced by the source's file), `removed` (not in the
246
248
  source, or cleared by `prune`), `backed_up`, `lines`; property `skipped`
@@ -270,8 +272,16 @@ returns the exit code.
270
272
  (the later copy shadows it).
271
273
  - `Overlay.sort_by_priority(overlays) -> list[Overlay]` — sorted by `sort_key`.
272
274
  - `read_manifest() -> dict` — keys `name`, `description`, `routing`, `rules`,
273
- `requires` (valid names only, normalized, de-duplicated), `priority`; a missing
274
- or invalid manifest gives empty contributions.
275
+ `requires` (valid names only, normalized, de-duplicated), `priority`,
276
+ `setup_timeout`, `scripts` (the `[scripts]` table as written: name -> path
277
+ relative to the overlay, `/`-separated, plain file-name characters, no `..`,
278
+ or `false`; any other entry is dropped with a warning); a missing or invalid
279
+ manifest gives empty contributions.
280
+ - `scripts() -> dict[str, str]` — the scripts that get a launcher
281
+ (`dotagents._launchers`): each top-level `bin/<name>.py` whose stem is a
282
+ `SCRIPT_NAME_RE` name (so not `_private.py`, not `__init__.py`), then
283
+ `[scripts]`, which adds or replaces a path by name, or drops a detected one
284
+ with `false`.
275
285
  - `find_setup_script() -> Optional[Path]`.
276
286
  - `setup_timeout(override=None) -> int` — `override`, else the manifest's
277
287
  `setup_timeout` (a non-negative int; anything else is ignored with a warning),
@@ -307,6 +317,35 @@ returns the exit code.
307
317
  `sort_key` order; creates the file when absent; content outside the markers kept;
308
318
  returns whether it changed (`False` and a warning when the file has no block).
309
319
 
320
+ ## `dotagents._launchers`
321
+
322
+ Launchers for overlays' scripts (`Overlay.scripts()`), in `<venv>/overlays/` of a
323
+ dotagents venv; `_pyvenv.sync_venvs` brings them up to date on every sync, and
324
+ `_env.get_environment` puts that dir first on `PATH` for the venv `AGENTS_PYTHON`
325
+ names. A launcher runs `$<NAME>_OVERLAY_ROOT/<script>` under the venv's
326
+ interpreter, resolved when it runs (argv[0] the script, its dir first on
327
+ `sys.path`, run as `__main__`); with the variable unset or the script missing it
328
+ exits 2 naming both. Windows: `<name>.exe`, distlib's `ScriptMaker` launcher (no
329
+ command interpreter parses its arguments). POSIX: `<name>`, mode 0755, a `/bin/sh`
330
+ script that `exec`s the interpreter.
331
+
332
+ - `DIR = "overlays"`, `MANIFEST = ".dotagents-launchers.json"` (in that dir:
333
+ `{name: overlay name}` of the launchers dotagents wrote).
334
+ - `class LauncherError(SystemExit)` — two overlays with one script name; the
335
+ message names both.
336
+ - `launcher_dir(venv) -> Path`. `file_name(name) -> str` (`.exe` added on Windows).
337
+ `is_launcher_dir(path) -> bool` — the shape `<store>/.pyvenv/<triplet>/overlays`,
338
+ no filesystem access.
339
+ - `declared(overlays) -> dict[str, tuple[Overlay, str]]` — every script by name;
340
+ raises `LauncherError` on a name two overlays have.
341
+ - `render(name, var, script, python) -> bytes` — one launcher's bytes; equal
342
+ inputs give equal bytes (the `.exe`'s archive carries a fixed time).
343
+ - `sync(venv, python, overlays, *, dry_run=False, logger=None) -> list[str]` —
344
+ writes each launcher whose bytes differ (atomic replace), deletes those
345
+ `MANIFEST` lists that no overlay has any more (other files there are left
346
+ alone), rewrites `MANIFEST`; returns the names written or deleted. Raises
347
+ `LauncherError` before any write.
348
+
310
349
  ## `dotagents._sources`
311
350
 
312
351
  Spec grammar: `[git+]<location>[@<ref>][#<path>]`. A location ending `.git`, starting
@@ -383,7 +422,12 @@ a directory of overlays or a registry file (default: the root); for a **source**
383
422
 
384
423
  Assembly order (`get_environment`): identity (`stamp_identity`), then the roots,
385
424
  then `PATH`, then `PYTHONPATH` / `AGENTS_PYTHONPATH`, then the env files, then the
386
- proxy model.
425
+ proxy model. `PATH`: every level's `bin` prepended, then, first of all, the active
426
+ venv's launcher dir (`<venv>/overlays`, `_launchers`) when `AGENTS_PYTHON` is that
427
+ venv's interpreter and the dir exists; every other dotagents venv's launcher dir is
428
+ taken out. First even before the `bin` dirs: cmd.exe and PowerShell try every
429
+ `PATHEXT` extension in a directory before the next one, so a `bin/<name>.py` (with
430
+ `.PY` in `PATHEXT`) or a `.cmd` there would otherwise win.
387
431
 
388
432
  - Roots: `AGENTS_HOME` and `AGENTS_PROJECT_ROOT` name the stores walked, absolute —
389
433
  an inherited value naming the same directory holds, another is replaced; in a
@@ -678,8 +722,11 @@ objects `{"matcher"?: str, "hooks": [{"type": "command", "command": str, ...}]}`
678
722
 
679
723
  ## `dotagents._wrappers`
680
724
 
681
- - `POSIX_TEMPLATE`, `POSIX_TEMPLATE_REL` (pyz path relative to the wrapper),
682
- `POSIX_TEMPLATE_MODULE` (`"<python>" -m dotagents`) — the `sh` wrapper bodies.
725
+ - `POSIX_TEMPLATE` — the `sh` wrapper body, for a `.pyz` (absolute, or relative
726
+ to the wrapper) or `-m dotagents`. It runs the recorded interpreter; where that
727
+ does not run (a Windows store read from WSL) it takes the store's agents' venv
728
+ for this OS and machine (`.pyvenv/*-<os>-<arch>/bin/python`), else `python3`,
729
+ and turns a recorded `C:/...` pyz path into a local one with `wslpath`.
683
730
  - `write_wrappers(bin_dir, pyz_path, python=None, *, relative=False) -> list[Path]` —
684
731
  `dotagents` (sh) and `dotagents.cmd`, both always, running the `.pyz`.
685
732
  - `write_module_wrappers(bin_dir, python=None) -> list[Path]` — both forms running
@@ -706,10 +753,16 @@ code. `AGENTS_NO_PYVENV=1` (or `true`/`yes`/`on`) turns it all off.
706
753
  when the requirements' digest changed, in one `pip install -r ... -r ...` per
707
754
  venv, with `--find-links` from `AGENTS_PIP_SOURCE`. The `.pth` is rewritten when
708
755
  it differs: the store's own libs, then each lower store's venv site-packages and
709
- libs, then `runtime_entry()`. Raises `VenvError` (a `SystemExit`) on failure.
756
+ libs, then `runtime_entry()`. Each venv's launchers (`_launchers.sync`) serve its
757
+ store's overlays and every lower store's; a project venv a sync made that is no
758
+ longer wanted keeps its launchers current too. With `AGENTS_NO_PYVENV` it logs
759
+ which scripts get no launcher. Raises `VenvError` or `LauncherError` (both
760
+ `SystemExit`) on failure.
710
761
  - `wants_venv(scope, level, store, libs) -> bool` — always for `user`; for `project`
711
- when it has overlay requirements or libs; never for `system` (a venv an
762
+ when it has overlay requirements, libs or scripts; never for `system` (a venv an
712
763
  administrator made there is still layered).
764
+ - `store_overlays(scope, stores) -> list[Overlay]` — the overlays in play
765
+ installed in any of `stores`.
713
766
  - `ensure_venv(target, python=None, *, with_pip=None, dry_run=False, logger=None)
714
767
  -> Optional[Path]` — the venv's interpreter, created when missing; `with_pip=None`
715
768
  tries ensurepip and falls back to `--without-pip`, `False` never uses it.
@@ -802,6 +855,8 @@ proxy vars, plus whatever the env files set. `AGENTS_AGENT` is never emitted.
802
855
  Set for an overlay's `setup.py`: `AGENTS_HOME` (user store), `AGENTS_SCOPE_ROOT`,
803
856
  `AGENTS_SCOPE`, `AGENTS_OVERLAY_DIR`.
804
857
 
858
+ Read by a script launcher (`_launchers`): its overlay's `<NAME>_OVERLAY_ROOT`.
859
+
805
860
  ## Gotchas
806
861
 
807
862
  - **Python 3.9 floor.** Runtime-evaluated annotations need `from __future__ import
@@ -1,3 +1,3 @@
1
1
  """dotagents: installable CLI for the dotagents agent-config payload."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.8.2"
@@ -4,7 +4,8 @@ Contract B, the exact sequence :func:`get_environment` performs:
4
4
 
5
5
  1. **Bins onto PATH FIRST**, before any env eval. Each level's ``bin`` dir
6
6
  (contract-A precedence order, *except* project-root) is prepended to
7
- ``PATH`` so env scripts can call overlay helpers by name. **Libs go on
7
+ ``PATH`` so env scripts can call overlay helpers by name, and ahead of
8
+ them the active venv's launcher dir (:func:`_launcher_path`). **Libs go on
8
9
  ``PYTHONPATH`` the same way**: each level's ``lib`` dir that EXISTS
9
10
  (:func:`get_lib_paths`) is prepended to ``PYTHONPATH`` and listed,
10
11
  ``os.pathsep``-joined, in ``AGENTS_PYTHONPATH`` -- so an ``env.py``, a
@@ -852,6 +853,25 @@ def _anchored(entry: str) -> bool:
852
853
  return bool(entry) and (os.path.isabs(entry) or entry[0] in "/\\")
853
854
 
854
855
 
856
+ def _launcher_path(path: str, venv: "Optional[Path]") -> "Optional[str]":
857
+ """``PATH`` with ``venv``'s launcher dir (``<venv>/overlays``, the
858
+ overlays' ``[scripts]``: :mod:`dotagents._launchers`) first when it
859
+ exists, and every other dotagents venv's taken out: a launcher names its
860
+ own venv's interpreter. ``None`` if nothing changes.
861
+
862
+ First, ahead of the levels' ``bin`` dirs too: cmd.exe and PowerShell try
863
+ every ``PATHEXT`` extension in one directory before the next, so a
864
+ ``bin`` holding the script itself (``curl.py``, with ``.PY`` in
865
+ ``PATHEXT``) or a ``.cmd`` of the same name would otherwise win."""
866
+ from dotagents import _launchers
867
+
868
+ entries = [e for e in path.split(os.pathsep) if not (e and _launchers.is_launcher_dir(e))] if path else []
869
+ if venv is not None and _launchers.launcher_dir(venv).is_dir():
870
+ entries.insert(0, str(_launchers.launcher_dir(venv)))
871
+ updated = os.pathsep.join(entries)
872
+ return updated if updated != path else None
873
+
874
+
855
875
  def _prepended_path_var(osenv: "dict[str, str]", var: str, entries: "list[str]") -> "Optional[str]":
856
876
  """The new value of an ``os.pathsep``-joined path var with ``entries``
857
877
  prepended (:func:`_prepend_missing` order), or ``None`` if nothing changes.
@@ -1106,6 +1126,10 @@ def get_environment(
1106
1126
  updated = _prepended_path_var(osenv, "PATH", bin_paths)
1107
1127
  if updated is not None:
1108
1128
  _apply({"PATH": updated})
1129
+ updated = _launcher_path(osenv.get("PATH", ""), venv if venv is not None
1130
+ and osenv.get("AGENTS_PYTHON") == str(_pyvenv.venv_python(venv)) else None)
1131
+ if updated is not None:
1132
+ _apply({"PATH": updated})
1109
1133
 
1110
1134
  # --- Libs: on PYTHONPATH, and published as AGENTS_PYTHONPATH, same moment.
1111
1135
  # Everything an overlay starts -- a skill script the agent runs, a `bin`
@@ -0,0 +1,241 @@
1
+ """Launchers for overlays' scripts: each top-level ``bin/<name>.py``, plus
2
+ what ``overlay.toml`` declares (``Overlay.scripts``)::
3
+
4
+ [scripts]
5
+ tool = "lib/tool/main.py" # another path, or another name
6
+ helper = false # no launcher for bin/helper.py
7
+
8
+ Each becomes an executable named after the key in ``<venv>/overlays/`` of a
9
+ dotagents venv (:mod:`dotagents._pyvenv`), which ``env`` puts first on
10
+ ``PATH`` for the venv ``AGENTS_PYTHON`` names. A launcher runs the script
11
+ under that venv's interpreter, found at RUN time through the overlay's
12
+ ``<NAME>_OVERLAY_ROOT``, so moving a store or syncing an overlay never
13
+ strands it; only a new interpreter needs a new launcher. With the variable
14
+ unset or the script missing it exits 2, saying so.
15
+
16
+ * Windows: ``<name>.exe``, made by distlib's ``ScriptMaker`` (pip's console
17
+ script launcher) around a short Python bootstrap. A native program: no
18
+ command interpreter parses its arguments, as cmd.exe would a ``.cmd``'s.
19
+ * POSIX: ``<name>``, a ``/bin/sh`` script that ``exec``s the interpreter.
20
+
21
+ :data:`MANIFEST` in the directory records which overlay made which launcher,
22
+ so one no overlay declares any more is deleted; files it does not list are
23
+ left alone. Writes are atomic and happen only when the bytes differ.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import shutil
31
+ import sys
32
+ import tempfile
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ #: ``<venv>/overlays``: the launchers, and nothing of the venv's own.
37
+ DIR = "overlays"
38
+ MANIFEST = ".dotagents-launchers.json"
39
+ #: The time stamped on the bootstrap in an ``.exe``'s archive (1980-01-01, the
40
+ #: earliest a ZIP can hold): fixed, so equal inputs give equal bytes.
41
+ _EPOCH = "315532800"
42
+
43
+
44
+ class LauncherError(SystemExit):
45
+ """Two overlays declare the same script name."""
46
+
47
+
48
+ def launcher_dir(venv: "str | os.PathLike[str]") -> Path:
49
+ return Path(venv) / DIR
50
+
51
+
52
+ def is_launcher_dir(path: str) -> bool:
53
+ """Whether ``path`` has the shape of any dotagents venv's launcher dir
54
+ (``<store>/.pyvenv/<triplet>/overlays``); the filesystem is not consulted."""
55
+ from dotagents._pyvenv import PYVENV_DIR
56
+
57
+ parts = Path(os.path.normpath(path)).parts
58
+ fold = (lambda s: s.lower()) if os.name == "nt" else (lambda s: s)
59
+ return len(parts) >= 3 and fold(parts[-1]) == fold(DIR) and fold(parts[-3]) == fold(PYVENV_DIR)
60
+
61
+
62
+ def file_name(name: str) -> str:
63
+ return name + ".exe" if os.name == "nt" else name
64
+
65
+
66
+ def declared(overlays) -> "dict[str, tuple]":
67
+ """``{name: (overlay, script)}`` over ``overlays``; a name two overlays
68
+ declare is a :class:`LauncherError` naming both."""
69
+ out: "dict[str, tuple]" = {}
70
+ seen: "dict[str, str]" = {}
71
+ for overlay in overlays:
72
+ for name, script in overlay.scripts().items():
73
+ key = name.lower() if os.name == "nt" else name
74
+ if key in seen and out[seen[key]][0].path != overlay.path:
75
+ raise LauncherError(
76
+ "error: overlays %s and %s both declare the script %r"
77
+ % (out[seen[key]][0].name, overlay.name, name)
78
+ )
79
+ seen[key] = name
80
+ out[name] = (overlay, script)
81
+ return out
82
+
83
+
84
+ def _bootstrap(name: str, var: str, script: str) -> str:
85
+ """The Python an ``.exe`` runs: the script as ``__main__``, as
86
+ ``python <script>`` would."""
87
+ return "\n".join([
88
+ "#!python",
89
+ "import os",
90
+ "import runpy",
91
+ "import sys",
92
+ "",
93
+ "root = os.environ.get(%r, '')" % var,
94
+ "script = os.path.join(root, *%r)" % (script.split("/"),),
95
+ "if not root or not os.path.isfile(script):",
96
+ " sys.stderr.write(%r %% root)" % (
97
+ "%s: %s=%%s has no %s: run it in a `dotagents env` session\n" % (name, var, script)),
98
+ " sys.exit(2)",
99
+ "sys.argv[0] = script",
100
+ "sys.path[0] = os.path.dirname(script)",
101
+ "runpy.run_path(script, run_name='__main__')",
102
+ "",
103
+ ])
104
+
105
+
106
+ def _sh_quote(text: str) -> str:
107
+ return "'%s'" % text.replace("'", "'\\''")
108
+
109
+
110
+ def _shell(name: str, var: str, script: str, python: str) -> bytes:
111
+ message = "%s: %s=${%s:-} has no %s: run it in a dotagents env session" % (name, var, var, script)
112
+ path = '"$%s"%s' % (var, _sh_quote("/" + script))
113
+ return "\n".join([
114
+ "#!/bin/sh",
115
+ "# Made by dotagents from overlay.toml [scripts]; regenerated on overlays sync.",
116
+ 'if [ -z "${%s:-}" ] || [ ! -f %s ]; then' % (var, path),
117
+ ' echo "%s" >&2' % message,
118
+ " exit 2",
119
+ "fi",
120
+ "exec %s %s \"$@\"" % (_sh_quote(python), path),
121
+ "",
122
+ ]).encode("utf-8")
123
+
124
+
125
+ def _exe(name: str, var: str, script: str, python: str, work: str) -> bytes:
126
+ from distlib.scripts import ScriptMaker
127
+
128
+ source = os.path.join(work, "source")
129
+ out = os.path.join(work, "out")
130
+ os.mkdir(source)
131
+ os.mkdir(out)
132
+ with open(os.path.join(source, name + ".py"), "w", encoding="utf-8", newline="\n") as handle:
133
+ handle.write(_bootstrap(name, var, script))
134
+ maker = ScriptMaker(source, out, add_launchers=True)
135
+ maker.executable = python
136
+ maker.variants = {""}
137
+ saved = os.environ.get("SOURCE_DATE_EPOCH")
138
+ os.environ["SOURCE_DATE_EPOCH"] = _EPOCH
139
+ try:
140
+ maker.make(name + ".py")
141
+ finally:
142
+ if saved is None:
143
+ del os.environ["SOURCE_DATE_EPOCH"]
144
+ else:
145
+ os.environ["SOURCE_DATE_EPOCH"] = saved
146
+ with open(os.path.join(out, name + ".exe"), "rb") as handle:
147
+ return handle.read()
148
+
149
+
150
+ def render(name: str, var: str, script: str, python: str) -> bytes:
151
+ """The bytes of launcher ``name`` for this platform."""
152
+ if os.name != "nt":
153
+ return _shell(name, var, script, python)
154
+ work = tempfile.mkdtemp(prefix="dotagents-launcher-")
155
+ try:
156
+ return _exe(name, var, script, python, work)
157
+ finally:
158
+ shutil.rmtree(work, ignore_errors=True)
159
+
160
+
161
+ def _read(path: Path) -> "Optional[bytes]":
162
+ try:
163
+ with open(str(path), "rb") as handle:
164
+ return handle.read()
165
+ except OSError:
166
+ return None
167
+
168
+
169
+ def _write(path: Path, data: bytes, mode: "Optional[int]") -> None:
170
+ partial = path.with_name(".%s.new" % path.name)
171
+ with open(str(partial), "wb") as handle:
172
+ handle.write(data)
173
+ if mode is not None:
174
+ os.chmod(str(partial), mode)
175
+ try:
176
+ os.replace(str(partial), str(path))
177
+ except OSError:
178
+ os.unlink(str(partial))
179
+ raise
180
+
181
+
182
+ def sync(venv: Path, python: Path, overlays, *, dry_run: bool = False, logger=None) -> "list[str]":
183
+ """Make ``<venv>/overlays`` hold exactly the launchers ``overlays``
184
+ declare (interpreter ``python``), deleting those :data:`MANIFEST` lists
185
+ that no overlay declares any more. Returns the launcher names written or
186
+ deleted. A name declared twice is a :class:`LauncherError`, before any
187
+ write."""
188
+ wanted = declared(overlays)
189
+ target = launcher_dir(venv)
190
+ manifest_path = target / MANIFEST
191
+ try:
192
+ recorded = json.loads((_read(manifest_path) or b"{}").decode("utf-8"))
193
+ except ValueError:
194
+ recorded = {}
195
+ if not isinstance(recorded, dict):
196
+ recorded = {}
197
+ changed: "list[str]" = []
198
+ for name in sorted(set(recorded) - set(wanted)):
199
+ path = target / file_name(name)
200
+ if path.exists():
201
+ _log(logger, "info", "launcher %s: removed (no overlay declares it)%s", path, _dry(dry_run))
202
+ if not dry_run:
203
+ path.unlink()
204
+ changed.append(name)
205
+ for name, (overlay, script) in sorted(wanted.items()):
206
+ if not (overlay.path / script).is_file():
207
+ _log(logger, "warning", "overlay %s: script %s (%s) not found; its launcher exits 2 until it is",
208
+ overlay.name, name, script)
209
+ data = render(name, overlay.root_var, script, str(python))
210
+ path = target / file_name(name)
211
+ if _read(path) == data:
212
+ continue
213
+ _log(logger, "info", "launcher %s: %s (%s)%s", path, "updated" if path.exists() else "created",
214
+ overlay.name, _dry(dry_run))
215
+ changed.append(name)
216
+ if not dry_run:
217
+ target.mkdir(parents=True, exist_ok=True)
218
+ _write(path, data, None if os.name == "nt" else 0o755)
219
+ record = {name: overlay.name for name, (overlay, _script) in wanted.items()}
220
+ if not dry_run and record != recorded:
221
+ if record:
222
+ target.mkdir(parents=True, exist_ok=True)
223
+ _write(manifest_path, (json.dumps(record, indent=1, sort_keys=True) + "\n").encode("utf-8"), None)
224
+ elif manifest_path.exists():
225
+ manifest_path.unlink()
226
+ try:
227
+ target.rmdir()
228
+ except OSError:
229
+ pass # something else lives there: left alone
230
+ return changed
231
+
232
+
233
+ def _dry(dry_run: bool) -> str:
234
+ return " [dry-run]" if dry_run else ""
235
+
236
+
237
+ def _log(logger, level: str, message: str, *args) -> None:
238
+ if logger is not None:
239
+ getattr(logger, level)(message, *args)
240
+ elif level == "warning":
241
+ sys.stderr.write("warning: " + (message % args) + "\n")