ixt-cli 0.8.0__py3-none-any.whl

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 (84) hide show
  1. ixt/__init__.py +8 -0
  2. ixt/__main__.py +8 -0
  3. ixt/backends/__init__.py +1 -0
  4. ixt/backends/binary.py +935 -0
  5. ixt/backends/binary_resolver.py +307 -0
  6. ixt/backends/node.py +490 -0
  7. ixt/backends/python.py +234 -0
  8. ixt/cli/__init__.py +31 -0
  9. ixt/cli/argparse_completion.py +557 -0
  10. ixt/cli/cmd_apply.py +404 -0
  11. ixt/cli/cmd_cache.py +86 -0
  12. ixt/cli/cmd_config.py +295 -0
  13. ixt/cli/cmd_info.py +116 -0
  14. ixt/cli/cmd_install.py +508 -0
  15. ixt/cli/cmd_misc.py +261 -0
  16. ixt/cli/cmd_registry.py +35 -0
  17. ixt/cli/cmd_upgrade.py +336 -0
  18. ixt/cli/commands.py +70 -0
  19. ixt/cli/parser.py +555 -0
  20. ixt/cli/render.py +85 -0
  21. ixt/config/__init__.py +5 -0
  22. ixt/config/asset_index.py +305 -0
  23. ixt/config/asset_pattern_cache.py +87 -0
  24. ixt/config/env_policy.py +340 -0
  25. ixt/config/flags.py +29 -0
  26. ixt/config/fs_policy.py +17 -0
  27. ixt/config/heuristics.py +465 -0
  28. ixt/config/models.py +176 -0
  29. ixt/config/registry.py +145 -0
  30. ixt/config/settings.py +173 -0
  31. ixt/config/setup_toml.py +179 -0
  32. ixt/config/toml.py +416 -0
  33. ixt/core/__init__.py +16 -0
  34. ixt/core/apply.py +564 -0
  35. ixt/core/apply_actions.py +106 -0
  36. ixt/core/backend.py +187 -0
  37. ixt/core/bootstrap.py +410 -0
  38. ixt/core/cache.py +332 -0
  39. ixt/core/discover.py +150 -0
  40. ixt/core/doctor.py +591 -0
  41. ixt/core/export.py +419 -0
  42. ixt/core/expose.py +350 -0
  43. ixt/core/extract.py +261 -0
  44. ixt/core/hooks.py +182 -0
  45. ixt/core/identity.py +148 -0
  46. ixt/core/inject.py +143 -0
  47. ixt/core/install.py +509 -0
  48. ixt/core/install_local.py +229 -0
  49. ixt/core/locks.py +54 -0
  50. ixt/core/pathlink.py +86 -0
  51. ixt/core/resolution_stats.py +191 -0
  52. ixt/core/resolve.py +150 -0
  53. ixt/core/resolve_cache.py +185 -0
  54. ixt/core/runtimes.py +192 -0
  55. ixt/core/save.py +237 -0
  56. ixt/core/setup_completions.py +11 -0
  57. ixt/core/setup_path.py +368 -0
  58. ixt/core/upgrade.py +596 -0
  59. ixt/data/__init__.py +10 -0
  60. ixt/data/asset_index.json +574 -0
  61. ixt/data/heuristics.toml +98 -0
  62. ixt/data/registry.toml +71 -0
  63. ixt/libs/__init__.py +3 -0
  64. ixt/libs/constants.py +4 -0
  65. ixt/libs/fmt.py +108 -0
  66. ixt/libs/logger.py +109 -0
  67. ixt/libs/output.py +25 -0
  68. ixt/libs/req_spec.py +115 -0
  69. ixt/libs/semver.py +149 -0
  70. ixt/libs/shell.py +126 -0
  71. ixt/libs/style.py +238 -0
  72. ixt/net/__init__.py +1 -0
  73. ixt/net/github_api.py +158 -0
  74. ixt/net/gitlab_api.py +149 -0
  75. ixt/net/http.py +194 -0
  76. ixt/net/npm.py +24 -0
  77. ixt/net/pypi.py +26 -0
  78. ixt/net/source.py +163 -0
  79. ixt/platform/__init__.py +131 -0
  80. ixt/platform/win.py +68 -0
  81. ixt_cli-0.8.0.dist-info/METADATA +294 -0
  82. ixt_cli-0.8.0.dist-info/RECORD +84 -0
  83. ixt_cli-0.8.0.dist-info/WHEEL +4 -0
  84. ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/net/http.py ADDED
@@ -0,0 +1,194 @@
1
+ """Minimal HTTP client built on urllib (stdlib only).
2
+
3
+ Handles JSON GET, file downloads, auth headers, proxy, timeout, and retry.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shutil
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ class HttpError(Exception):
18
+ """HTTP request failed."""
19
+
20
+ def __init__(self, url: str, status: int, reason: str, body: str = ""):
21
+ self.url = url
22
+ self.status = status
23
+ self.reason = reason
24
+ self.body = body
25
+ super().__init__(f"HTTP {status} {reason}: {url}")
26
+
27
+
28
+ class RateLimitError(HttpError):
29
+ """GitHub/GitLab API rate limit exceeded."""
30
+
31
+ def __init__(self, url: str, status: int, reason: str, body: str = ""):
32
+ super().__init__(url, status, reason, body)
33
+
34
+
35
+ class NotFoundError(HttpError):
36
+ """Resource not found (404)."""
37
+
38
+ def __init__(self, url: str, body: str = ""):
39
+ super().__init__(url, 404, "Not Found", body)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Internal helpers
44
+ # ---------------------------------------------------------------------------
45
+
46
+ _DEFAULT_TIMEOUT = 30
47
+ _DOWNLOAD_TIMEOUT = 300
48
+ _MAX_RETRIES = 2
49
+ _RETRY_DELAY = 1.0
50
+ _RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
51
+
52
+
53
+ def _build_opener() -> urllib.request.OpenerDirector:
54
+ """Build a urllib opener with proxy support."""
55
+ proxy = urllib.request.ProxyHandler()
56
+ return urllib.request.build_opener(proxy)
57
+
58
+
59
+ def _classify_error(url: str, exc: urllib.error.HTTPError) -> HttpError:
60
+ """Turn an HTTPError into a typed exception."""
61
+ body = ""
62
+ try:
63
+ body = exc.read().decode("utf-8", errors="replace")
64
+ except Exception:
65
+ pass
66
+ status = exc.code
67
+ reason = exc.reason if isinstance(exc.reason, str) else str(exc.reason)
68
+ if status == 404:
69
+ return NotFoundError(url, body)
70
+ if status in (403, 429):
71
+ return RateLimitError(url, status, reason, body)
72
+ return HttpError(url, status, reason, body)
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Public API
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def get_json(
81
+ url: str,
82
+ *,
83
+ headers: dict[str, str] | None = None,
84
+ timeout: int = _DEFAULT_TIMEOUT,
85
+ ) -> Any:
86
+ """GET a URL and parse the response as JSON."""
87
+ data = get_bytes(url, headers=headers, timeout=timeout)
88
+ return json.loads(data)
89
+
90
+
91
+ def get_text(
92
+ url: str,
93
+ *,
94
+ headers: dict[str, str] | None = None,
95
+ timeout: int = _DEFAULT_TIMEOUT,
96
+ ) -> str:
97
+ """GET a URL and return the response as text."""
98
+ data = get_bytes(url, headers=headers, timeout=timeout)
99
+ return data.decode("utf-8")
100
+
101
+
102
+ def get_bytes(
103
+ url: str,
104
+ *,
105
+ headers: dict[str, str] | None = None,
106
+ timeout: int = _DEFAULT_TIMEOUT,
107
+ ) -> bytes:
108
+ """GET a URL and return raw bytes, with retry on transient errors."""
109
+ req = urllib.request.Request(url, method="GET")
110
+ req.add_header("User-Agent", "ixt/0.1")
111
+ req.add_header("Accept", "application/json")
112
+ if headers:
113
+ for k, v in headers.items():
114
+ req.add_header(k, v)
115
+
116
+ opener = _build_opener()
117
+ last_exc: Exception | None = None
118
+
119
+ for attempt in range(_MAX_RETRIES + 1):
120
+ try:
121
+ with opener.open(req, timeout=timeout) as resp:
122
+ return resp.read()
123
+ except urllib.error.HTTPError as exc:
124
+ typed = _classify_error(url, exc)
125
+ if exc.code not in _RETRYABLE_STATUSES or attempt == _MAX_RETRIES:
126
+ raise typed from exc
127
+ last_exc = typed
128
+ except urllib.error.URLError as exc:
129
+ if attempt == _MAX_RETRIES:
130
+ raise HttpError(url, 0, str(exc.reason)) from exc
131
+ last_exc = exc # type: ignore[assignment]
132
+
133
+ time.sleep(_RETRY_DELAY * (attempt + 1))
134
+
135
+ raise last_exc # type: ignore[misc] # unreachable but keeps mypy happy
136
+
137
+
138
+ def get_final_url(
139
+ url: str,
140
+ *,
141
+ headers: dict[str, str] | None = None,
142
+ timeout: int = _DEFAULT_TIMEOUT,
143
+ ) -> str:
144
+ """Issue a HEAD request following redirects and return the final URL.
145
+
146
+ Used by the GitHub ``/releases/latest`` → ``/releases/tag/{tag}`` trick to
147
+ resolve the current latest tag without consuming an API request.
148
+ """
149
+ req = urllib.request.Request(url, method="HEAD")
150
+ req.add_header("User-Agent", "ixt/0.1")
151
+ if headers:
152
+ for k, v in headers.items():
153
+ req.add_header(k, v)
154
+
155
+ opener = _build_opener()
156
+ try:
157
+ with opener.open(req, timeout=timeout) as resp:
158
+ return resp.geturl()
159
+ except urllib.error.HTTPError as exc:
160
+ raise _classify_error(url, exc) from exc
161
+ except urllib.error.URLError as exc:
162
+ raise HttpError(url, 0, str(exc.reason)) from exc
163
+
164
+
165
+ def download_file(
166
+ url: str,
167
+ dest: Path,
168
+ *,
169
+ headers: dict[str, str] | None = None,
170
+ timeout: int = _DOWNLOAD_TIMEOUT,
171
+ ) -> Path:
172
+ """Download a URL to a local file path with streaming."""
173
+ dest.parent.mkdir(parents=True, exist_ok=True)
174
+ tmp = dest.with_suffix(dest.suffix + ".part")
175
+
176
+ req = urllib.request.Request(url, method="GET")
177
+ req.add_header("User-Agent", "ixt/0.1")
178
+ if headers:
179
+ for k, v in headers.items():
180
+ req.add_header(k, v)
181
+
182
+ opener = _build_opener()
183
+ try:
184
+ with opener.open(req, timeout=timeout) as resp, tmp.open("wb") as f:
185
+ shutil.copyfileobj(resp, f)
186
+ tmp.rename(dest)
187
+ except urllib.error.HTTPError as exc:
188
+ tmp.unlink(missing_ok=True)
189
+ raise _classify_error(url, exc) from exc
190
+ except Exception:
191
+ tmp.unlink(missing_ok=True)
192
+ raise
193
+
194
+ return dest
ixt/net/npm.py ADDED
@@ -0,0 +1,24 @@
1
+ """npm registry client — used to resolve the latest version of a package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ from ixt.net.http import get_json
8
+
9
+
10
+ def latest_version_url(name: str) -> str:
11
+ # @scope/pkg must be URL-encoded (/ becomes %2F).
12
+ return f"https://registry.npmjs.org/{quote(name, safe='@')}/latest"
13
+
14
+
15
+ def get_latest_version(name: str) -> str:
16
+ """Return the latest published version of *name* on the npm registry."""
17
+ from ixt.core.resolution_stats import record_npm_registry
18
+
19
+ record_npm_registry(name)
20
+ data = get_json(latest_version_url(name))
21
+ version = data.get("version")
22
+ if not isinstance(version, str) or not version:
23
+ raise ValueError(f"npm response for '{name}' missing version")
24
+ return version
ixt/net/pypi.py ADDED
@@ -0,0 +1,26 @@
1
+ """PyPI registry client — used to resolve the latest version of a package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ixt.net.http import get_json
6
+
7
+
8
+ def latest_version_url(name: str) -> str:
9
+ return f"https://pypi.org/pypi/{name}/json"
10
+
11
+
12
+ def get_latest_version(name: str) -> str:
13
+ """Return the latest published version of *name* on PyPI.
14
+
15
+ Raises any http.HttpError subclass on failure (NotFoundError for unknown
16
+ packages, HttpError for network/timeouts).
17
+ """
18
+ from ixt.core.resolution_stats import record_pypi_registry
19
+
20
+ record_pypi_registry(name)
21
+ data = get_json(latest_version_url(name))
22
+ info = data.get("info", {})
23
+ version = info.get("version")
24
+ if not isinstance(version, str) or not version:
25
+ raise ValueError(f"PyPI response for '{name}' missing info.version")
26
+ return version
ixt/net/source.py ADDED
@@ -0,0 +1,163 @@
1
+ """ReleaseSource protocol and spec parsing for GitHub/GitLab detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Data models
10
+ # ---------------------------------------------------------------------------
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class Asset:
15
+ """A single downloadable asset from a release."""
16
+
17
+ name: str
18
+ url: str
19
+ size: int = 0
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class Release:
24
+ """A release with its tag and assets."""
25
+
26
+ tag: str
27
+ assets: list[Asset] = field(default_factory=list)
28
+ prerelease: bool = False
29
+ draft: bool = False
30
+ resolution_source: str | None = None
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # ReleaseSource protocol
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ @runtime_checkable
39
+ class ReleaseSource(Protocol):
40
+ """Interface for fetching releases from a forge (GitHub, GitLab)."""
41
+
42
+ def get_latest_release(self, owner: str, repo: str) -> Release: ...
43
+
44
+ def get_release_by_tag(self, owner: str, repo: str, tag: str) -> Release: ...
45
+
46
+ def list_releases(self, owner: str, repo: str) -> list[Release]: ...
47
+
48
+ def download_asset(self, url: str, dest: Path) -> None: ...
49
+
50
+ def get_file_content(
51
+ self, owner: str, repo: str, path: str, *, ref: str | None = None
52
+ ) -> str | None: ...
53
+
54
+
55
+ # Avoid circular import — Path is only needed for type checking
56
+ from pathlib import Path # noqa: E402
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Spec parsing
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class RepoSpec:
65
+ """Parsed result of a package specification.
66
+
67
+ Attributes:
68
+ host: The forge hostname (e.g. "github.com", "gitlab.com", "gitlab.company.com").
69
+ owner: Repository owner/org.
70
+ repo: Repository name.
71
+ version: Optional version constraint (tag or semver fragment).
72
+ platform: "github" or "gitlab".
73
+ """
74
+
75
+ host: str
76
+ owner: str
77
+ repo: str
78
+ version: str | None = None
79
+ platform: str = "github" # "github" | "gitlab"
80
+
81
+
82
+ def strip_version_suffix(spec: str) -> str:
83
+ """Drop a trailing ``@version`` from *spec*, preserving a leading ``@scope``.
84
+
85
+ ``owner/repo@v1.2.3`` -> ``owner/repo`` but ``@scope/pkg`` is left intact
86
+ (npm scopes are not version pins).
87
+ """
88
+ if "@" in spec and not spec.startswith("@"):
89
+ return spec.rsplit("@", 1)[0]
90
+ return spec
91
+
92
+
93
+ def parse_spec(spec: str) -> RepoSpec | None:
94
+ """Parse a binary backend spec into a RepoSpec, or None if not a binary spec.
95
+
96
+ Recognized formats:
97
+ owner/repo -> GitHub (default)
98
+ owner/repo@version -> GitHub with version
99
+ github.com/owner/repo -> GitHub (explicit)
100
+ gitlab.com/owner/repo -> GitLab
101
+ gitlab.company.com/owner/repo -> GitLab self-hosted
102
+ *.gitlab.*/owner/repo -> GitLab self-hosted
103
+
104
+ Returns None for:
105
+ simple-name -> not a binary spec (PyPI or registry lookup)
106
+ @scope/pkg -> not a binary spec (npm)
107
+ """
108
+ raw = spec.strip()
109
+
110
+ # Strip version suffix
111
+ version: str | None = None
112
+ if "@" in raw:
113
+ # Handle @scope/pkg (npm) — starts with @
114
+ if raw.startswith("@"):
115
+ return None
116
+ # owner/repo@version or domain/owner/repo@version
117
+ raw, version = raw.rsplit("@", 1)
118
+ if not version:
119
+ version = None
120
+
121
+ # No slash → not a binary spec (simple name, needs registry lookup)
122
+ if "/" not in raw:
123
+ return None
124
+
125
+ parts = raw.split("/")
126
+
127
+ # Convention: ``.git`` clone-URL suffix on the repo segment is stripped.
128
+ # ``gitlab-org/cli.git`` → ``gitlab-org/cli`` so the API call hits the
129
+ # actual project, not a literal ``cli.git`` that doesn't exist.
130
+ def _strip_git(name: str) -> str:
131
+ return name[:-4] if name.endswith(".git") and len(name) > 4 else name
132
+
133
+ # domain/owner/repo (3+ parts → has a host)
134
+ if len(parts) >= 3:
135
+ host = parts[0]
136
+ owner = parts[1]
137
+ repo = _strip_git("/".join(parts[2:])) # handle nested groups (GitLab)
138
+ platform = _detect_platform(host)
139
+ return RepoSpec(host=host, owner=owner, repo=repo, version=version, platform=platform)
140
+
141
+ # owner/repo (2 parts → default GitHub)
142
+ if len(parts) == 2:
143
+ owner, repo = parts
144
+ return RepoSpec(
145
+ host="github.com",
146
+ owner=owner,
147
+ repo=_strip_git(repo),
148
+ version=version,
149
+ platform="github",
150
+ )
151
+
152
+ return None
153
+
154
+
155
+ def _detect_platform(host: str) -> str:
156
+ """Detect whether a host is GitHub or GitLab."""
157
+ h = host.lower()
158
+ if h == "github.com":
159
+ return "github"
160
+ if "gitlab" in h:
161
+ return "gitlab"
162
+ # Unknown host — assume GitLab (GitHub is the default for bare owner/repo)
163
+ return "gitlab"
@@ -0,0 +1,131 @@
1
+ """Platform detection and utilities"""
2
+
3
+ import functools
4
+ import platform
5
+ import sys
6
+ from enum import Enum
7
+
8
+
9
+ class OS(Enum):
10
+ """Supported operating systems"""
11
+
12
+ LINUX = "linux"
13
+ MACOS = "macos"
14
+ WINDOWS = "windows"
15
+
16
+
17
+ class Arch(Enum):
18
+ """Supported architectures"""
19
+
20
+ X86_64 = "x86_64"
21
+ ARM64 = "arm64"
22
+ ARMV7 = "armv7"
23
+
24
+
25
+ @functools.lru_cache(maxsize=1)
26
+ def get_os() -> OS:
27
+ """Get the current operating system"""
28
+ system = platform.system().lower()
29
+ if system == "linux":
30
+ return OS.LINUX
31
+ elif system == "darwin":
32
+ return OS.MACOS
33
+ elif system == "windows":
34
+ return OS.WINDOWS
35
+ else:
36
+ raise RuntimeError(f"Unsupported OS: {system}")
37
+
38
+
39
+ @functools.lru_cache(maxsize=1)
40
+ def get_arch() -> Arch:
41
+ """Get the current architecture"""
42
+ machine = platform.machine().lower()
43
+
44
+ if machine in ("x86_64", "amd64", "x64"):
45
+ return Arch.X86_64
46
+ elif machine in ("arm64", "aarch64"):
47
+ return Arch.ARM64
48
+ elif machine in ("armv7", "armhf"):
49
+ return Arch.ARMV7
50
+ else:
51
+ raise RuntimeError(f"Unsupported architecture: {machine}")
52
+
53
+
54
+ def is_linux() -> bool:
55
+ """Check if running on Linux"""
56
+ return get_os() == OS.LINUX
57
+
58
+
59
+ def is_macos() -> bool:
60
+ """Check if running on macOS"""
61
+ return get_os() == OS.MACOS
62
+
63
+
64
+ def is_windows() -> bool:
65
+ """Check if running on Windows"""
66
+ return get_os() == OS.WINDOWS
67
+
68
+
69
+ def is_x86_64() -> bool:
70
+ """Check if running on x86_64 architecture"""
71
+ return get_arch() == Arch.X86_64
72
+
73
+
74
+ def is_arm64() -> bool:
75
+ """Check if running on ARM64 architecture"""
76
+ return get_arch() == Arch.ARM64
77
+
78
+
79
+ class PlatformInfo:
80
+ """Information about the current platform"""
81
+
82
+ def __init__(self):
83
+ self.os = get_os()
84
+ self.arch = get_arch()
85
+ self.python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
86
+
87
+ def __repr__(self) -> str:
88
+ return (
89
+ f"PlatformInfo(os={self.os.value}, arch={self.arch.value},"
90
+ f" python={self.python_version})"
91
+ )
92
+
93
+
94
+ def get_platform_info() -> PlatformInfo:
95
+ """Get current platform information"""
96
+ return PlatformInfo()
97
+
98
+
99
+ def get_binary_extension() -> str:
100
+ """Get the binary file extension for the current OS"""
101
+ return ".exe" if is_windows() else ""
102
+
103
+
104
+ def get_shell_profile_paths() -> list[str]:
105
+ """Get shell profile paths for the current OS
106
+
107
+ Returns:
108
+ List of shell profile file names (~/.bashrc, ~/.zshrc, etc.)
109
+ """
110
+ if is_windows():
111
+ return ["$PROFILE"]
112
+ else:
113
+ # Unix-like systems
114
+ return ["~/.profile", "~/.bashrc", "~/.zshrc", "~/.kshrc", "~/.tcshrc"]
115
+
116
+
117
+ __all__ = [
118
+ "OS",
119
+ "Arch",
120
+ "PlatformInfo",
121
+ "get_arch",
122
+ "get_binary_extension",
123
+ "get_os",
124
+ "get_platform_info",
125
+ "get_shell_profile_paths",
126
+ "is_arm64",
127
+ "is_linux",
128
+ "is_macos",
129
+ "is_windows",
130
+ "is_x86_64",
131
+ ]
ixt/platform/win.py ADDED
@@ -0,0 +1,68 @@
1
+ """Windows-specific utilities (registry, PATH management, etc.)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from typing import Any, cast
8
+
9
+ from ixt.platform import is_windows
10
+
11
+
12
+ def _modify_user_path(mutate: Callable[[list[str]], list[str]]) -> bool:
13
+ """Open the user PATH registry value, apply *mutate*, and write back.
14
+
15
+ Uses REG_EXPAND_SZ so paths containing %USERPROFILE% etc. are expanded
16
+ correctly by the shell. No-op on non-Windows platforms.
17
+ """
18
+ if not is_windows():
19
+ return True
20
+ try:
21
+ import winreg as _winreg
22
+
23
+ winreg = cast(Any, _winreg)
24
+
25
+ with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as reg:
26
+ with winreg.OpenKey(reg, r"Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
27
+ try:
28
+ current_path, _ = winreg.QueryValueEx(key, "Path")
29
+ except FileNotFoundError:
30
+ current_path = ""
31
+ old = [p for p in current_path.split(os.pathsep) if p]
32
+ new = mutate(old)
33
+ if new != old:
34
+ winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, os.pathsep.join(new))
35
+ return True
36
+ except Exception as e:
37
+ from ixt.libs.logger import get_logger
38
+
39
+ get_logger("platform.win").warn(f"Could not modify Windows PATH: {e}")
40
+ return False
41
+
42
+
43
+ def add_to_user_path(path_str: str) -> bool:
44
+ """Add a directory to the user PATH on Windows (no-op elsewhere)."""
45
+ return _modify_user_path(lambda ps: ps if path_str in ps else [*ps, path_str])
46
+
47
+
48
+ def get_user_path() -> str | None:
49
+ """Return the raw HKCU Path value, or None if unset / not Windows."""
50
+ if not is_windows():
51
+ return None
52
+ try:
53
+ import winreg as _winreg
54
+
55
+ winreg = cast(Any, _winreg)
56
+
57
+ with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as reg:
58
+ with winreg.OpenKey(reg, r"Environment", 0, winreg.KEY_READ) as key:
59
+ try:
60
+ value, _ = winreg.QueryValueEx(key, "Path")
61
+ return value
62
+ except FileNotFoundError:
63
+ return None
64
+ except Exception as e:
65
+ from ixt.libs.logger import get_logger
66
+
67
+ get_logger("platform.win").warn(f"Could not read Windows user PATH: {e}")
68
+ return None