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.
Files changed (67) hide show
  1. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/CHANGELOG.md +24 -0
  2. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/PKG-INFO +9 -3
  3. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/README.md +8 -2
  4. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/pyproject.toml +1 -1
  5. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/env.py +108 -1
  6. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_env_format.py +134 -0
  7. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/.gitignore +0 -0
  8. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/LICENSE +0 -0
  9. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/build.py +0 -0
  10. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/agents.md +0 -0
  11. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/cli.md +0 -0
  12. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/context.md +0 -0
  13. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/env.md +0 -0
  14. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/hooks.md +0 -0
  15. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/merge.md +0 -0
  16. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/overlays.md +0 -0
  17. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/reference.md +0 -0
  18. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/resolve.md +0 -0
  19. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/scope.md +0 -0
  20. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/skills.md +0 -0
  21. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/api/sync.md +0 -0
  22. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/changelog.md +0 -0
  23. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/authoring.md +0 -0
  24. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/commands.md +0 -0
  25. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/install.md +0 -0
  26. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/overlays.md +0 -0
  27. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/guide/private-sync.md +0 -0
  28. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/docs/index.md +0 -0
  29. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/install.py +0 -0
  30. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/mkdocs.yml +0 -0
  31. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/AGENTS.md +0 -0
  32. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/__init__.py +0 -0
  33. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/__main__.py +0 -0
  34. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_agents.py +0 -0
  35. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_context.py +0 -0
  36. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_env.py +0 -0
  37. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_hooks.py +0 -0
  38. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_merge.py +0 -0
  39. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/AGENTS.md +0 -0
  40. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/CLAUDE.md +0 -0
  41. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/README.md +0 -0
  42. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/dotagents/DECISIONS.md +0 -0
  43. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlay/dotagents/cmds/README.md +0 -0
  44. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_overlays.py +0 -0
  45. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_resolve.py +0 -0
  46. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_scope.py +0 -0
  47. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_skills.py +0 -0
  48. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_sync.py +0 -0
  49. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/_wrappers.py +0 -0
  50. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/__init__.py +0 -0
  51. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/_common.py +0 -0
  52. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/build_pyz.py +0 -0
  53. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/context.py +0 -0
  54. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/init.py +0 -0
  55. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/src/dotagents/cli/overlays.py +0 -0
  56. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_agents.py +0 -0
  57. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_audit_leak.py +0 -0
  58. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_cmds_discovery.py +0 -0
  59. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_context.py +0 -0
  60. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_env.py +0 -0
  61. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_hooks.py +0 -0
  62. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_overlays_command.py +0 -0
  63. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_scope.py +0 -0
  64. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_wire_hooks.py +0 -0
  65. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tests/test_wrappers.py +0 -0
  66. {dotagents_cli-0.3.0 → dotagents_cli-0.3.1}/tools/audit.py +0 -0
  67. {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.0
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
  [![Test](https://img.shields.io/github/actions/workflow/status/jose-pr/dotagents/test.yml?branch=main&label=tests)](https://github.com/jose-pr/dotagents/actions/workflows/test.yml)
65
+ [![PyPI](https://img.shields.io/pypi/v/dotagents-cli.svg)](https://pypi.org/project/dotagents-cli/)
65
66
  [![Documentation](https://img.shields.io/badge/docs-online-blue.svg)](https://jose-pr.github.io/dotagents/)
66
67
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- **`dotagents init`** lays down the neutral base config; a self-contained downloadable
127
- `.pyz` needs no `pip install` at all.
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
  [![Test](https://img.shields.io/github/actions/workflow/status/jose-pr/dotagents/test.yml?branch=main&label=tests)](https://github.com/jose-pr/dotagents/actions/workflows/test.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/dotagents-cli.svg)](https://pypi.org/project/dotagents-cli/)
4
5
  [![Documentation](https://img.shields.io/badge/docs-online-blue.svg)](https://jose-pr.github.io/dotagents/)
5
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- **`dotagents init`** lays down the neutral base config; a self-contained downloadable
66
- `.pyz` needs no `pip install` at all.
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.0"
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
- from pathlib import Path
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