dotagents-cli 0.3.0__tar.gz → 0.3.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.
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/CHANGELOG.md +24 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/PKG-INFO +9 -3
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/README.md +8 -2
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/pyproject.toml +1 -1
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/env.py +108 -1
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_env_format.py +134 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/.gitignore +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/LICENSE +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/build.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/agents.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/cli.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/context.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/env.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/hooks.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/merge.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/overlays.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/reference.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/resolve.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/scope.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/skills.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/sync.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/changelog.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/authoring.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/commands.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/install.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/overlays.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/private-sync.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/index.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/install.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/mkdocs.yml +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/AGENTS.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/__init__.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/__main__.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_agents.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_context.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_env.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_hooks.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_merge.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/AGENTS.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/CLAUDE.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/README.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/dotagents/DECISIONS.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/dotagents/cmds/README.md +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlays.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_resolve.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_scope.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_skills.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_sync.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_wrappers.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/__init__.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/_common.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/build_pyz.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/context.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/init.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/overlays.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_agents.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_audit_leak.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_cmds_discovery.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_context.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_env.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_hooks.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_overlays_command.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_scope.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_wire_hooks.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_wrappers.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tools/audit.py +0 -0
- {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tools/cloud-setup.sh +0 -0
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- fix: **`dotagents env --format export`/`dotenv`/`fish` now emit a POSIX PATH on
|
|
13
|
+
Windows**, instead of the OS-native `C:\...;C:\...` form. This is the exact
|
|
14
|
+
command the Claude `SessionStart` hook appends into `$CLAUDE_ENV_FILE`, which
|
|
15
|
+
Claude sources before *every* subsequent Bash tool call in the session — an
|
|
16
|
+
unconverted PATH broke command lookup (`git`, `grep`, `head`, `python`, ...) for
|
|
17
|
+
the rest of the session once poisoned, not just once. PATH-shaped values are
|
|
18
|
+
now converted per segment: backslash to forward-slash, `;` to `:`, and a drive
|
|
19
|
+
letter to its MSYS mount point (`C:/...` -> `/c/...` — required for PATH
|
|
20
|
+
*lookups* specifically; slash direction alone does not work in MSYS2/Cygwin
|
|
21
|
+
bash). `PATHEXT` is dropped (no POSIX meaning). A handful of real Windows env
|
|
22
|
+
vars with parentheses in their names (`ProgramFiles(x86)`) are also dropped for
|
|
23
|
+
these formats — `export FOO(X86)=...` is a bash syntax error, not a bad value,
|
|
24
|
+
and aborts sourcing the rest of the file. `powershell`/`cmd`/`json`/`ini`/`yaml`
|
|
25
|
+
are unaffected.
|
|
26
|
+
- fix: a UNC PATH segment (`\\server\share\...`) no longer collapses to a single
|
|
27
|
+
leading slash (`/server/share/...`) during the POSIX conversion above — MSYS
|
|
28
|
+
requires the double-slash UNC root (`//server/share/...`) to resolve it.
|
|
29
|
+
|
|
30
|
+
## [0.3.1] - 2026-07-24
|
|
31
|
+
|
|
32
|
+
Patch release: the PATH/POSIX-conversion fix above (the only change since 0.3.0).
|
|
33
|
+
|
|
10
34
|
## [0.3.0] - 2026-07-24
|
|
11
35
|
|
|
12
36
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotagents-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -62,6 +62,7 @@ Description-Content-Type: text/markdown
|
|
|
62
62
|
# dotagents
|
|
63
63
|
|
|
64
64
|
[](https://github.com/jose-pr/dotagents/actions/workflows/test.yml)
|
|
65
|
+
[](https://pypi.org/project/dotagents-cli/)
|
|
65
66
|
[](https://jose-pr.github.io/dotagents/)
|
|
66
67
|
[](https://github.com/jose-pr/dotagents/blob/main/LICENSE)
|
|
67
68
|
|
|
@@ -123,8 +124,13 @@ manifest read by the `dotagents overlays` subcommand, which manages overlays by
|
|
|
123
124
|
|
|
124
125
|
## Install
|
|
125
126
|
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
```bash
|
|
128
|
+
pip install dotagents-cli # gives you `import dotagents` and the `dotagents` command
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Or skip `pip` entirely with the self-contained downloadable `.pyz` (see below).
|
|
132
|
+
|
|
133
|
+
**`dotagents init`** lays down the neutral base config.
|
|
128
134
|
|
|
129
135
|
`init` writes the `.agents/` scaffolding — the `AGENTS.md` managed block, the per-agent
|
|
130
136
|
`<CLAUDE|ANTIGRAVITY|...>.md → @AGENTS.md` pattern, the design-log convention — and
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# dotagents
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jose-pr/dotagents/actions/workflows/test.yml)
|
|
4
|
+
[](https://pypi.org/project/dotagents-cli/)
|
|
4
5
|
[](https://jose-pr.github.io/dotagents/)
|
|
5
6
|
[](https://github.com/jose-pr/dotagents/blob/main/LICENSE)
|
|
6
7
|
|
|
@@ -62,8 +63,13 @@ manifest read by the `dotagents overlays` subcommand, which manages overlays by
|
|
|
62
63
|
|
|
63
64
|
## Install
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
```bash
|
|
67
|
+
pip install dotagents-cli # gives you `import dotagents` and the `dotagents` command
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or skip `pip` entirely with the self-contained downloadable `.pyz` (see below).
|
|
71
|
+
|
|
72
|
+
**`dotagents init`** lays down the neutral base config.
|
|
67
73
|
|
|
68
74
|
`init` writes the `.agents/` scaffolding — the `AGENTS.md` managed block, the per-agent
|
|
69
75
|
`<CLAUDE|ANTIGRAVITY|...>.md → @AGENTS.md` pattern, the design-log convention — and
|
|
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
|
|
|
9
9
|
# redirect/placeholder before someone else does, but the CLI ships under -cli.
|
|
10
10
|
[project]
|
|
11
11
|
name = "dotagents-cli"
|
|
12
|
-
version = "0.3.
|
|
12
|
+
version = "0.3.1"
|
|
13
13
|
authors = [{ name = "Jose A." }]
|
|
14
14
|
description = "The dotagents CLI: install and manage a portable ~/.agents config for AI coding agents"
|
|
15
15
|
readme = "README.md"
|
|
@@ -7,10 +7,17 @@ DOTAGENTS_*/AGENTS_* VALUES -- output goes to stdout for the caller to consume;
|
|
|
7
7
|
the logger only ever names vars (Leakage rule).
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path, PureWindowsPath
|
|
11
12
|
|
|
12
13
|
from duho import Cmd, LoggingArgs
|
|
13
14
|
|
|
15
|
+
# POSIX shell variable names: a leading letter/underscore, then letters/digits/
|
|
16
|
+
# underscores only (IEEE Std 1003.1 "Name"). Windows env vars like
|
|
17
|
+
# `ProgramFiles(x86)` don't qualify -- `(`/`)` make `export NAME=...` a syntax
|
|
18
|
+
# error in bash, not just an unset/misinterpreted var.
|
|
19
|
+
_POSIX_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
20
|
+
|
|
14
21
|
|
|
15
22
|
def _dotenv_value(v: str) -> str:
|
|
16
23
|
"""Dotenv quoting: bare unless the value needs quoting.
|
|
@@ -27,6 +34,75 @@ def _dotenv_value(v: str) -> str:
|
|
|
27
34
|
return v
|
|
28
35
|
|
|
29
36
|
|
|
37
|
+
def _looks_like_path_list(key: str, value: str) -> bool:
|
|
38
|
+
"""True if `key`/`value` is a PATH-shaped var that needs POSIX conversion
|
|
39
|
+
for a shell-sourceable format.
|
|
40
|
+
|
|
41
|
+
Matches `PATH` exactly and any `*_PATH`/`*PATH` suffix an overlay's env.py
|
|
42
|
+
might emit (e.g. `PYTHONPATH`, `AGENTS_LIB_PATH`) -- the same convention
|
|
43
|
+
`get_bin_paths`/contract B use for path-list vars. Requires the value to
|
|
44
|
+
actually look like an OS-native path list (contains a backslash, or the
|
|
45
|
+
Windows `;` separator) so an ordinary single POSIX path or an unrelated
|
|
46
|
+
string is never touched.
|
|
47
|
+
"""
|
|
48
|
+
if not key.endswith("PATH"):
|
|
49
|
+
return False
|
|
50
|
+
return "\\" in value or ";" in value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_DRIVE_LETTER_RE = re.compile(r"^([A-Za-z]):/")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _to_posix_path(segment: str) -> str:
|
|
57
|
+
"""One path segment, converted to a form MSYS2/Cygwin bash can actually
|
|
58
|
+
resolve a PATH lookup through -- not just cosmetically POSIX-shaped.
|
|
59
|
+
|
|
60
|
+
Uses ``PureWindowsPath`` explicitly, NOT the platform-dependent bare
|
|
61
|
+
``Path``. This distinction is load-bearing, not stylistic: `Path` resolves
|
|
62
|
+
to `PosixPath` on a POSIX host, and `PosixPath("C:\\Users\\x").as_posix()`
|
|
63
|
+
does NOT recognize `C:` as a drive or `\\` as a separator -- backslashes
|
|
64
|
+
pass through as literal filename characters. This code runs in CI on
|
|
65
|
+
Linux/macOS runners (the test suite exercises it there), so a bare `Path`
|
|
66
|
+
silently only worked when the CI happened to run on a Windows runner --
|
|
67
|
+
which for THIS job it does not. `PureWindowsPath` parses Windows syntax
|
|
68
|
+
unconditionally, everywhere, matching what the value actually is (a
|
|
69
|
+
Windows path string), independent of the host running the code.
|
|
70
|
+
|
|
71
|
+
`PureWindowsPath(...).as_posix()` alone still leaves a drive letter as
|
|
72
|
+
`C:/...`, which fails PATH lookup identically to the backslash form --
|
|
73
|
+
verified directly on Windows (`command -v grep` empty for `C:/Program
|
|
74
|
+
Files/Git/usr/bin`, populated for `/c/Program Files/Git/usr/bin`, same
|
|
75
|
+
directory). So a genuine drive-letter prefix is additionally rewritten to
|
|
76
|
+
its MSYS mount point (`C:/...` -> `/c/...`).
|
|
77
|
+
|
|
78
|
+
A UNC segment (`\\\\server\\share\\...`) is handled correctly by
|
|
79
|
+
`PureWindowsPath` natively -- it already preserves the double-slash UNC
|
|
80
|
+
root through `.as_posix()` (`//server/share/...`), unlike a naive
|
|
81
|
+
backslash-replace on a plain string, which collapses it to one slash.
|
|
82
|
+
|
|
83
|
+
An already-POSIX or relative segment (`.agents/bin`, `/etc/agents/bin`) is
|
|
84
|
+
parsed as a relative Windows path (backslashes are still separators there,
|
|
85
|
+
but there are none to convert) and passes through unchanged.
|
|
86
|
+
"""
|
|
87
|
+
posix = PureWindowsPath(segment).as_posix()
|
|
88
|
+
m = _DRIVE_LETTER_RE.match(posix)
|
|
89
|
+
if m:
|
|
90
|
+
return "/%s%s" % (m.group(1).lower(), posix[2:])
|
|
91
|
+
return posix
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _to_posix_path_list(value: str) -> str:
|
|
95
|
+
"""Convert an OS-native (Windows) `;`-joined path list to a POSIX
|
|
96
|
+
`:`-joined one, for a shell-sourceable target format.
|
|
97
|
+
|
|
98
|
+
Empty segments are dropped (a stray leading/trailing `;` must not become a
|
|
99
|
+
bare `:` -- POSIX treats an empty PATH segment as `.`, the cwd, which is
|
|
100
|
+
both wrong and a real security footgun).
|
|
101
|
+
"""
|
|
102
|
+
segments = [_to_posix_path(s) for s in value.split(";") if s]
|
|
103
|
+
return ":".join(segments)
|
|
104
|
+
|
|
105
|
+
|
|
30
106
|
def _format_env(env: "dict[str, str]", output_format: str) -> str:
|
|
31
107
|
"""Render the assembled env in the requested (canonical or aliased) format.
|
|
32
108
|
|
|
@@ -60,6 +136,37 @@ def _format_env(env: "dict[str, str]", output_format: str) -> str:
|
|
|
60
136
|
from dotagents._env import FORMAT_ALIASES
|
|
61
137
|
|
|
62
138
|
fmt = FORMAT_ALIASES.get(output_format, output_format)
|
|
139
|
+
|
|
140
|
+
# `get_environment` assembles PATH using the HOST OS's own convention
|
|
141
|
+
# (os.pathsep + native Path separators -- `;` and `\` on Windows), because
|
|
142
|
+
# that is what a Windows subprocess (cmd/PowerShell, or `dotagents` itself
|
|
143
|
+
# spawning a child) needs. Shell-sourceable POSIX formats need the opposite:
|
|
144
|
+
# bash/fish always use `:` and `/`, regardless of host OS. Left unconverted
|
|
145
|
+
# on Windows, `export PATH="C:\...;C:\..."` sourced into a POSIX shell (this
|
|
146
|
+
# is exactly what the SessionStart hook writes into $CLAUDE_ENV_FILE) hands
|
|
147
|
+
# bash a PATH it cannot parse -- every `;`-joined, backslash-laden segment
|
|
148
|
+
# becomes one broken entry, and EVERY bare-name command lookup breaks for
|
|
149
|
+
# the rest of that session. This is not cosmetic: it can take down `git`,
|
|
150
|
+
# `grep`, `python` -- anything resolved via PATH -- for the shell that
|
|
151
|
+
# sources it. Convert PATH-shaped values only, for POSIX target formats
|
|
152
|
+
# only; every other var (and every other format) is untouched.
|
|
153
|
+
if fmt in ("export", "dotenv", "fish"):
|
|
154
|
+
# PATHEXT is a Windows-only concept (extensionless exec resolution) with
|
|
155
|
+
# no POSIX meaning; dropped rather than emitted as noise.
|
|
156
|
+
#
|
|
157
|
+
# A handful of Windows-native var names (`ProgramFiles(x86)`,
|
|
158
|
+
# `CommonProgramFiles(Arm)`, inherited from os.environ into base_env)
|
|
159
|
+
# contain parentheses -- not a legal POSIX shell identifier. `export
|
|
160
|
+
# FOO(X86)=...` is a hard bash SYNTAX ERROR, not a bad value: sourcing
|
|
161
|
+
# it aborts the rest of the file, so every var after the first offender
|
|
162
|
+
# in iteration order never gets set either. Worse than the PATH bug,
|
|
163
|
+
# same root cause (unfiltered OS-native env reaching a POSIX target).
|
|
164
|
+
env = {
|
|
165
|
+
k: (_to_posix_path_list(v) if _looks_like_path_list(k, v) else v)
|
|
166
|
+
for k, v in env.items()
|
|
167
|
+
if k != "PATHEXT" and _POSIX_IDENTIFIER_RE.match(k)
|
|
168
|
+
}
|
|
169
|
+
|
|
63
170
|
keys = sorted(env)
|
|
64
171
|
|
|
65
172
|
if fmt == "json":
|
|
@@ -8,6 +8,7 @@ these tests only exercise pure rendering + a process-name walk.
|
|
|
8
8
|
Run from repo root: ``python -m pytest tests/``.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
import sys
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
@@ -228,3 +229,136 @@ def test_auto_resolves_to_concrete_format():
|
|
|
228
229
|
assert resolved in _env.FORMAT_ALIASES
|
|
229
230
|
# And the resolved format renders.
|
|
230
231
|
assert isinstance(_format_env(SAMPLE, resolved), str)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# --------------------------------------------------------------------------- #
|
|
235
|
+
# PATH conversion for POSIX target formats (export/dotenv/fish).
|
|
236
|
+
#
|
|
237
|
+
# `get_environment` assembles PATH using the HOST OS's own convention -- on
|
|
238
|
+
# Windows, `os.pathsep` (`;`) and backslash `Path` separators, because that is
|
|
239
|
+
# what a Windows subprocess needs. A POSIX shell-sourceable format needs `:`
|
|
240
|
+
# and `/`, plus MSYS2/Cygwin's drive-letter-to-mount-point form (`C:/...` ->
|
|
241
|
+
# `/c/...`) for PATH LOOKUPS specifically -- slash direction alone is not
|
|
242
|
+
# enough; verified live that `command -v grep` resolves through `/c/Program
|
|
243
|
+
# Files/Git/usr/bin` but not `C:/Program Files/Git/usr/bin`, the same
|
|
244
|
+
# directory. Left unconverted, this is exactly what the Claude SessionStart
|
|
245
|
+
# hook writes into $CLAUDE_ENV_FILE -- sourcing it breaks PATH-based command
|
|
246
|
+
# lookup (git, grep, python, ...) for the rest of that session.
|
|
247
|
+
# --------------------------------------------------------------------------- #
|
|
248
|
+
|
|
249
|
+
WINDOWS_PATH = {
|
|
250
|
+
"PATH": r"C:\Users\jose\.agents\bin;C:\Program Files\Git\usr\bin;.agents\bin",
|
|
251
|
+
"PATHEXT": ".COM;.EXE;.BAT;.CMD",
|
|
252
|
+
"AGENTS_HOME": r"C:\Users\jose\.agents", # single path, NOT a list -- untouched
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_export_converts_windows_path_to_posix():
|
|
257
|
+
out = _format_env(WINDOWS_PATH, "export")
|
|
258
|
+
assert (
|
|
259
|
+
'export PATH="/c/Users/jose/.agents/bin:/c/Program Files/Git/usr/bin:'
|
|
260
|
+
'.agents/bin"' in out
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_unc_path_keeps_double_slash_root():
|
|
265
|
+
"""`\\\\server\\share` -> `//server/share`, NOT `/server/share`.
|
|
266
|
+
|
|
267
|
+
Both `Path.as_posix()` and a bare backslash-to-slash replace collapse a
|
|
268
|
+
UNC path's leading `\\\\` to a single `/`, which MSYS does not accept as
|
|
269
|
+
the UNC root -- caught by independent verification of this fix, which
|
|
270
|
+
found the collapsed form was untested.
|
|
271
|
+
"""
|
|
272
|
+
from dotagents.cli.env import _to_posix_path
|
|
273
|
+
|
|
274
|
+
assert _to_posix_path("\\\\server\\share\\bin") == "//server/share/bin"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_export_drops_pathext():
|
|
278
|
+
"""PATHEXT has no POSIX meaning; dropped rather than emitted as garbage."""
|
|
279
|
+
out = _format_env(WINDOWS_PATH, "export")
|
|
280
|
+
assert "PATHEXT" not in out
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_export_leaves_single_path_values_untouched():
|
|
284
|
+
"""Only PATH-LIST vars (name ends in PATH, value looks OS-native) convert --
|
|
285
|
+
a plain single-path value must not be mangled."""
|
|
286
|
+
out = _format_env(WINDOWS_PATH, "export")
|
|
287
|
+
# export JSON-quotes values, so a literal backslash is doubled on the wire;
|
|
288
|
+
# this is exactly what json.dumps(WINDOWS_PATH["AGENTS_HOME"]) produces.
|
|
289
|
+
assert json.dumps(WINDOWS_PATH["AGENTS_HOME"]) in out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_dotenv_and_fish_also_convert_path():
|
|
293
|
+
for fmt in ("dotenv", "fish"):
|
|
294
|
+
out = _format_env(WINDOWS_PATH, fmt)
|
|
295
|
+
assert "/c/Program Files/Git/usr/bin" in out
|
|
296
|
+
path_line = next(l for l in out.split("\n") if l.startswith(("PATH=", "set -gx PATH")))
|
|
297
|
+
assert "\\" not in path_line
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_powershell_and_cmd_keep_native_path():
|
|
301
|
+
"""Non-POSIX formats must NOT be touched by the conversion -- Windows
|
|
302
|
+
subprocesses need the native form, PATHEXT included."""
|
|
303
|
+
for fmt in ("powershell", "cmd"):
|
|
304
|
+
out = _format_env(WINDOWS_PATH, fmt)
|
|
305
|
+
assert r"C:\Users\jose\.agents\bin" in out
|
|
306
|
+
assert "PATHEXT" in out
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_json_and_yaml_keep_native_path():
|
|
310
|
+
"""Data formats are for machine consumption of the RAW assembled env, not
|
|
311
|
+
for sourcing -- must not silently rewrite values."""
|
|
312
|
+
import json
|
|
313
|
+
|
|
314
|
+
out = json.loads(_format_env(WINDOWS_PATH, "json"))
|
|
315
|
+
assert out["PATH"] == WINDOWS_PATH["PATH"]
|
|
316
|
+
assert "PATHEXT" in out
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
ILLEGAL_NAME_ENV = {
|
|
320
|
+
"PATH": "/usr/bin",
|
|
321
|
+
"ProgramFiles(x86)": r"C:\Program Files (x86)",
|
|
322
|
+
"CommonProgramFiles(Arm)": r"C:\Program Files (Arm)\Common Files",
|
|
323
|
+
"NORMAL_VAR": "ok",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_export_drops_posix_illegal_var_names():
|
|
328
|
+
"""`export FOO(X86)=...` is a bash SYNTAX ERROR, not a bad value -- it
|
|
329
|
+
aborts the rest of the sourced file. A handful of real Windows env vars
|
|
330
|
+
(ProgramFiles(x86), inherited via os.environ into base_env) have this
|
|
331
|
+
shape; they must never reach a POSIX-targeted format."""
|
|
332
|
+
out = _format_env(ILLEGAL_NAME_ENV, "export")
|
|
333
|
+
assert "(" not in out
|
|
334
|
+
assert ")" not in out
|
|
335
|
+
assert "NORMAL_VAR" in out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_dotenv_and_fish_also_drop_illegal_names():
|
|
339
|
+
for fmt in ("dotenv", "fish"):
|
|
340
|
+
out = _format_env(ILLEGAL_NAME_ENV, fmt)
|
|
341
|
+
assert "ProgramFiles" not in out
|
|
342
|
+
assert "NORMAL_VAR" in out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_powershell_and_cmd_keep_illegal_named_vars():
|
|
346
|
+
"""Non-POSIX formats have no such restriction -- must not lose data."""
|
|
347
|
+
for fmt in ("powershell", "cmd"):
|
|
348
|
+
out = _format_env(ILLEGAL_NAME_ENV, fmt)
|
|
349
|
+
assert "ProgramFiles(x86)" in out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_export_output_actually_sources_in_real_bash(tmp_path):
|
|
353
|
+
"""The end-to-end property that matters: the rendered output must be
|
|
354
|
+
syntactically valid POSIX shell, sourceable with no error."""
|
|
355
|
+
import subprocess
|
|
356
|
+
|
|
357
|
+
script = tmp_path / "env.sh"
|
|
358
|
+
script.write_text(_format_env(ILLEGAL_NAME_ENV, "export") + "\n", encoding="utf-8")
|
|
359
|
+
proc = subprocess.run(
|
|
360
|
+
["sh", "-c", ". %s && echo OK" % json.dumps(str(script))],
|
|
361
|
+
capture_output=True, text=True,
|
|
362
|
+
)
|
|
363
|
+
assert proc.returncode == 0, proc.stderr
|
|
364
|
+
assert "OK" in proc.stdout
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|