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/core/save.py ADDED
@@ -0,0 +1,237 @@
1
+ """Save/remove tool entries in the nearest ixt.toml (--save flag support)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ixt.config.toml import (
8
+ IxtConfig,
9
+ ToolSpec,
10
+ find_config_file,
11
+ load_config,
12
+ parse_config,
13
+ serialize_config,
14
+ )
15
+ from ixt.core.backend import BackendType
16
+ from ixt.libs.constants import EXPOSE_MAIN
17
+
18
+
19
+ def extract_version_from_spec(spec: str, backend_type: BackendType) -> str | None:
20
+ """Return the version constraint expressed in a user-provided install spec.
21
+
22
+ Preserves the user's intent: an explicit pin stays pinned, a range stays
23
+ a range, and a bare name returns ``None`` (no version). Normalizes bare
24
+ versions (e.g. ``1.2.3``) to ``==1.2.3`` so all backends share the
25
+ ``==<ver>`` convention used elsewhere in ixt.toml.
26
+ """
27
+ from ixt.core.backend import strip_protocol
28
+
29
+ _, clean = strip_protocol(spec)
30
+
31
+ if backend_type == BackendType.BINARY:
32
+ if "@" in clean and not clean.startswith("@"):
33
+ tag = clean.rsplit("@", 1)[1]
34
+ return f"=={tag}" if tag else None
35
+ return None
36
+
37
+ if backend_type == BackendType.NODE:
38
+ from ixt.backends.node import parse_npm_spec
39
+
40
+ _, version = parse_npm_spec(clean)
41
+ if not version:
42
+ return None
43
+ if version[0] in "^~<>=":
44
+ return version
45
+ return f"=={version}"
46
+
47
+ # Python / PEP 508
48
+ from ixt.libs.req_spec import parse_requirement
49
+
50
+ return parse_requirement(clean).version_constraint
51
+
52
+
53
+ def tool_entry_key(_record_name: str, record_spec: str, backend_type: BackendType) -> str:
54
+ """Return the install-spec key to write in ``ixt.toml``."""
55
+ return _canonical_install_spec(record_spec, backend_type)
56
+
57
+
58
+ def tool_install_spec(record_spec: str, backend_type: BackendType) -> str:
59
+ """Return the explicit install spec for a slotted ``ixt.toml`` entry."""
60
+ return _canonical_install_spec(record_spec, backend_type)
61
+
62
+
63
+ def _canonical_install_spec(record_spec: str, backend_type: BackendType) -> str:
64
+ from ixt.core.backend import strip_protocol
65
+
66
+ _forced, clean_spec = strip_protocol(record_spec)
67
+ if backend_type == BackendType.BINARY:
68
+ from ixt.net.source import parse_spec, strip_version_suffix
69
+
70
+ repo_spec = parse_spec(strip_version_suffix(clean_spec))
71
+ if repo_spec is None:
72
+ return clean_spec
73
+ if repo_spec.platform == "github" and repo_spec.host == "github.com":
74
+ return f"@gh:{repo_spec.owner}/{repo_spec.repo}"
75
+ if repo_spec.host == "gitlab.com":
76
+ return f"@gl:{repo_spec.owner}/{repo_spec.repo}"
77
+ return f"@gl:{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}"
78
+
79
+ if backend_type == BackendType.NODE:
80
+ from ixt.backends.node import parse_npm_spec
81
+
82
+ pkg, _version = parse_npm_spec(clean_spec)
83
+ return f"@npm:{pkg}"
84
+
85
+ from ixt.libs.req_spec import parse_requirement
86
+
87
+ pkg = parse_requirement(clean_spec).name
88
+ return f"@pypi:{pkg}"
89
+
90
+
91
+ def save_tool(
92
+ name: str,
93
+ *,
94
+ install: str | None = None,
95
+ version: str | None = None,
96
+ expose: list[str] | None = None,
97
+ inject: list[str] | None = None,
98
+ node_shim: bool | None = None,
99
+ runtime: str | None = None,
100
+ asset_pattern: str | None = None,
101
+ config_path: Path | None = None,
102
+ ) -> Path:
103
+ """Add or update a tool entry in ixt.toml. Returns the path written.
104
+
105
+ Preserves existing fields on update — only non-``None`` arguments override
106
+ the current entry, so callers can write partial updates without wiping
107
+ previously-stored inject/expose lists.
108
+ """
109
+ path, config = _load_or_create(config_path)
110
+
111
+ existing = config.tools.get(name)
112
+ resolved_expose = expose if expose is not None else (existing.expose if existing else None)
113
+ resolved_inject = inject if inject is not None else (existing.inject if existing else None)
114
+ resolved_version = version if version is not None else (existing.version if existing else None)
115
+ resolved_install = install if install is not None else (existing.install if existing else None)
116
+ resolved_node_shim = (
117
+ node_shim if node_shim is not None else (existing.node_shim if existing else None)
118
+ )
119
+ resolved_runtime = runtime if runtime is not None else (existing.runtime if existing else None)
120
+ resolved_asset_pattern = (
121
+ asset_pattern
122
+ if asset_pattern is not None
123
+ else (existing.asset_pattern if existing else None)
124
+ )
125
+
126
+ spec = ToolSpec(
127
+ name=name,
128
+ install=resolved_install,
129
+ version=resolved_version,
130
+ expose=resolved_expose if resolved_expose is not None else [EXPOSE_MAIN],
131
+ inject=resolved_inject if resolved_inject is not None else [],
132
+ node_shim=resolved_node_shim,
133
+ runtime=resolved_runtime,
134
+ asset_pattern=resolved_asset_pattern,
135
+ )
136
+ config.tools[name] = spec
137
+
138
+ path.write_text(serialize_config(config), encoding="utf-8")
139
+ return path
140
+
141
+
142
+ def save_inject(
143
+ tool_name: str,
144
+ package: str,
145
+ *,
146
+ config_path: Path | None = None,
147
+ ) -> Path | None:
148
+ """Append *package* to ``tools.<tool_name>.inject`` in ixt.toml.
149
+
150
+ Returns the path written, or None if no ixt.toml exists. Does nothing if
151
+ the package is already listed.
152
+ """
153
+ path = config_path or find_config_file()
154
+ if path is None or not path.is_file():
155
+ return None
156
+
157
+ config = load_config(path)
158
+ spec = config.tools.get(tool_name)
159
+ if spec is None:
160
+ return None
161
+ if package in spec.inject:
162
+ return path
163
+
164
+ spec.inject = [*spec.inject, package]
165
+ path.write_text(serialize_config(config), encoding="utf-8")
166
+ return path
167
+
168
+
169
+ def remove_inject(
170
+ tool_name: str,
171
+ package: str,
172
+ *,
173
+ config_path: Path | None = None,
174
+ ) -> Path | None:
175
+ """Remove *package* from ``tools.<tool_name>.inject`` in ixt.toml."""
176
+ path = config_path or find_config_file()
177
+ if path is None or not path.is_file():
178
+ return None
179
+
180
+ config = load_config(path)
181
+ spec = config.tools.get(tool_name)
182
+ if spec is None or package not in spec.inject:
183
+ return None
184
+
185
+ spec.inject = [p for p in spec.inject if p != package]
186
+ path.write_text(serialize_config(config), encoding="utf-8")
187
+ return path
188
+
189
+
190
+ def remove_tool(
191
+ name: str,
192
+ *,
193
+ config_path: Path | None = None,
194
+ ) -> Path | None:
195
+ """Remove a tool entry from ixt.toml. Returns the path written, or None if not found."""
196
+ path = config_path or find_config_file()
197
+ if path is None:
198
+ return None
199
+
200
+ config = load_config(path)
201
+ key = _find_tool_key(config, name)
202
+ if key is None:
203
+ return None
204
+
205
+ del config.tools[key]
206
+ path.write_text(serialize_config(config), encoding="utf-8")
207
+ return path
208
+
209
+
210
+ def _load_or_create(config_path: Path | None) -> tuple[Path, IxtConfig]:
211
+ """Load existing config or create a new empty one at cwd/ixt.toml."""
212
+ path = config_path or find_config_file()
213
+ if path is not None and path.is_file():
214
+ return path, load_config(path)
215
+
216
+ # Use explicit path or default to cwd/ixt.toml
217
+ path = path or (Path.cwd() / "ixt.toml")
218
+ return path, parse_config({}, source_path=path)
219
+
220
+
221
+ def _find_tool_key(config: IxtConfig, selector: str) -> str | None:
222
+ if selector in config.tools:
223
+ return selector
224
+ try:
225
+ from ixt.config.settings import get_settings
226
+ from ixt.core.apply import _normalize_tool_entry
227
+ except Exception:
228
+ return None
229
+ settings = get_settings()
230
+ for key, spec in config.tools.items():
231
+ try:
232
+ tool_id, _normalized = _normalize_tool_entry(key, spec, settings)
233
+ except Exception:
234
+ continue
235
+ if tool_id == selector:
236
+ return key
237
+ return None
@@ -0,0 +1,11 @@
1
+ """Generate shell completion setup snippets for the ixt CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ixt.cli.argparse_completion import render_completion
6
+ from ixt.cli.parser import build_parser
7
+
8
+
9
+ def generate_completion(shell: str) -> str:
10
+ """Return the shell script that enables ixt CLI completions."""
11
+ return render_completion(build_parser(), shell)
ixt/core/setup_path.py ADDED
@@ -0,0 +1,368 @@
1
+ """Configure PATH for the ixt bin directory across shells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from ixt.config.settings import Settings, get_settings
12
+ from ixt.platform import is_windows
13
+
14
+ _BEGIN = "# Added by ixt"
15
+ _END = "# /Added by ixt"
16
+
17
+ KNOWN_SHELLS = ("bash", "zsh", "fish", "pwsh")
18
+
19
+
20
+ SetupPathStatus = Literal[
21
+ "ok", # bin_dir already in current PATH env var
22
+ "ok_restart", # already configured in shell file, restart needed
23
+ "added", # new block written
24
+ "migrated", # legacy block removed, new block written
25
+ "not_configured", # check_only, would need to write
26
+ "blocked", # symlink target or unknown shell, snippet returned
27
+ ]
28
+
29
+
30
+ @dataclass
31
+ class SetupPathResult:
32
+ """Outcome of a setup_path operation."""
33
+
34
+ status: SetupPathStatus
35
+ config_file: Path | None = None
36
+ message: str = ""
37
+ warning: str | None = None
38
+ snippet: str | None = None
39
+
40
+
41
+ def _detect_shell() -> str:
42
+ """Return the user's login shell from ``$SHELL``.
43
+
44
+ One of: bash, zsh, fish, pwsh, unknown.
45
+ """
46
+ shell_path = os.environ.get("SHELL", "")
47
+ name = Path(shell_path).name if shell_path else ""
48
+ if name in KNOWN_SHELLS:
49
+ return name
50
+ if name in ("powershell", "powershell.exe"):
51
+ return "pwsh"
52
+ return "unknown"
53
+
54
+
55
+ def _block(shell: str, bin_dir: Path) -> str:
56
+ """Build the multi-line PATH block for *shell* targeting *bin_dir*."""
57
+ if shell in ("bash", "zsh"):
58
+ return f'{_BEGIN}\nif [ -d "{bin_dir}" ]; then\n PATH="{bin_dir}:$PATH"\nfi\n{_END}\n'
59
+ if shell == "fish":
60
+ return (
61
+ f'{_BEGIN}\nif test -d "{bin_dir}"\n set -gx PATH "{bin_dir}" $PATH\nend\n{_END}\n'
62
+ )
63
+ if shell == "pwsh":
64
+ sep = ";" if is_windows() else ":"
65
+ return (
66
+ f"{_BEGIN}\n"
67
+ f'if (Test-Path -Path "{bin_dir}" -PathType Container) {{\n'
68
+ f' $env:PATH = "{bin_dir}{sep}" + $env:PATH\n'
69
+ f"}}\n"
70
+ f"{_END}\n"
71
+ )
72
+ raise ValueError(f"unsupported shell: {shell}")
73
+
74
+
75
+ def _target(shell: str) -> Path:
76
+ """Where the new block is written for *shell*."""
77
+ home = Path.home()
78
+ if shell == "zsh":
79
+ return home / ".zshenv"
80
+ if shell == "bash":
81
+ return home / ".bashrc"
82
+ if shell == "fish":
83
+ return home / ".config" / "fish" / "conf.d" / "ixt.fish"
84
+ if shell == "pwsh":
85
+ if is_windows():
86
+ return home / "Documents" / "PowerShell" / "profile.ps1"
87
+ return home / ".config" / "powershell" / "profile.ps1"
88
+ raise ValueError(f"unsupported shell: {shell}")
89
+
90
+
91
+ def _legacy(shell: str) -> Path | None:
92
+ """Where ixt may have written before the new format. None = no migration."""
93
+ home = Path.home()
94
+ if shell == "zsh":
95
+ return home / ".zshrc"
96
+ if shell == "fish":
97
+ return home / ".config" / "fish" / "config.fish"
98
+ if shell == "bash":
99
+ # Legacy and target are the same file — migration is in-place.
100
+ return home / ".bashrc"
101
+ return None
102
+
103
+
104
+ @dataclass
105
+ class _BlockSpan:
106
+ kind: str # "new" or "legacy"
107
+ line_start: int
108
+ line_end: int # exclusive
109
+ lines: list[str] = field(default_factory=list)
110
+
111
+
112
+ def _find_blocks(content: str) -> list[_BlockSpan]:
113
+ """Return ixt blocks (new + legacy) found in *content*."""
114
+ lines = content.splitlines(keepends=True)
115
+ blocks: list[_BlockSpan] = []
116
+ i = 0
117
+ while i < len(lines):
118
+ if lines[i].rstrip("\n") != _BEGIN:
119
+ i += 1
120
+ continue
121
+ end_idx = -1
122
+ for j in range(i + 1, min(i + 21, len(lines))):
123
+ if lines[j].rstrip("\n") == _END:
124
+ end_idx = j
125
+ break
126
+ if end_idx >= 0:
127
+ blocks.append(_BlockSpan("new", i, end_idx + 1, lines[i : end_idx + 1]))
128
+ i = end_idx + 1
129
+ else:
130
+ tail = i + 2 if i + 1 < len(lines) else i + 1
131
+ blocks.append(_BlockSpan("legacy", i, tail, lines[i:tail]))
132
+ i = tail
133
+ return blocks
134
+
135
+
136
+ def _legacy_export_re(shell: str, bin_dir: Path) -> re.Pattern[str]:
137
+ """Match the legacy single-line export ixt previously wrote."""
138
+ bin_re = re.escape(str(bin_dir))
139
+ if shell == "fish":
140
+ return re.compile(r'^\s*set -gx PATH \$PATH "' + bin_re + r'"\s*$')
141
+ return re.compile(r'^\s*export PATH="\$PATH:' + bin_re + r'"\s*$')
142
+
143
+
144
+ def _strip_comments(content: str) -> str:
145
+ return "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#"))
146
+
147
+
148
+ def _heuristic_has_bin(content: str, bin_dir: Path) -> bool:
149
+ """Detect a non-comment line referencing *bin_dir* as a path component."""
150
+ bin_re = re.compile(r"(?<![\w./-])" + re.escape(str(bin_dir)) + r"(?![\w./-])")
151
+ return bool(bin_re.search(_strip_comments(content)))
152
+
153
+
154
+ def _remove_block(path: Path, block: _BlockSpan) -> None:
155
+ """Strip *block* from *path* in-place."""
156
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
157
+ new = lines[: block.line_start] + lines[block.line_end :]
158
+ path.write_text("".join(new), encoding="utf-8")
159
+
160
+
161
+ def _append_block(path: Path, body: str) -> None:
162
+ """Append *body* to *path*, separating from prior content with a blank line."""
163
+ path.parent.mkdir(parents=True, exist_ok=True)
164
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
165
+ with path.open("a", encoding="utf-8") as f:
166
+ if existing and not existing.endswith("\n"):
167
+ f.write("\n")
168
+ if existing:
169
+ f.write("\n")
170
+ f.write(body)
171
+
172
+
173
+ def setup_path(
174
+ *,
175
+ check_only: bool = False,
176
+ settings: Settings | None = None,
177
+ shell: str | None = None,
178
+ ) -> SetupPathResult:
179
+ """Ensure the ixt bin directory is on PATH.
180
+
181
+ Detects the user's shell from ``$SHELL`` (overridable via *shell*) and
182
+ writes a multi-line block with start/end markers, a directory guard, and
183
+ a prepend so ixt shims shadow same-named binaries elsewhere on PATH.
184
+
185
+ Migrates legacy single-line ixt blocks when found:
186
+ strict match → auto-remove + write new block (status "migrated"),
187
+ divergent or heuristic → leave + warn (status "added", warning set).
188
+ """
189
+ settings = settings or get_settings()
190
+ bin_dir = settings.bin_dir
191
+ bin_str = str(bin_dir)
192
+
193
+ sep = ";" if is_windows() else ":"
194
+ path_dirs = [p for p in os.environ.get("PATH", "").split(sep) if p]
195
+ path_already_active = False
196
+ if is_windows():
197
+ bin_low = bin_str.lower()
198
+ path_already_active = any(p.lower() == bin_low for p in path_dirs)
199
+ elif bin_str in path_dirs:
200
+ path_already_active = True
201
+
202
+ detected = shell or _detect_shell()
203
+
204
+ if detected == "unknown":
205
+ if is_windows():
206
+ return _setup_path_windows(bin_dir, check_only=check_only)
207
+ if path_already_active:
208
+ return SetupPathResult("ok", message="Already in PATH")
209
+ return _blocked_unknown_shell(bin_dir)
210
+
211
+ if detected not in KNOWN_SHELLS:
212
+ if path_already_active:
213
+ return SetupPathResult("ok", message="Already in PATH")
214
+ return _blocked_unknown_shell(bin_dir, shell=detected)
215
+
216
+ result = _setup_path_file(detected, bin_dir, check_only=check_only)
217
+ if path_already_active and result.status == "ok_restart":
218
+ result.status = "ok"
219
+ result.message = "Already in PATH"
220
+ return result
221
+
222
+
223
+ def _blocked_unknown_shell(bin_dir: Path, shell: str | None = None) -> SetupPathResult:
224
+ """Return a blocked result with a POSIX snippet to apply manually."""
225
+ msg = (
226
+ f"Unknown shell '{shell}'; PATH not configured automatically"
227
+ if shell
228
+ else "Could not detect shell; PATH not configured automatically"
229
+ )
230
+ return SetupPathResult("blocked", message=msg, snippet=_block("bash", bin_dir))
231
+
232
+
233
+ def _setup_path_file(
234
+ shell: str,
235
+ bin_dir: Path,
236
+ *,
237
+ check_only: bool = False,
238
+ ) -> SetupPathResult:
239
+ """Write or update the PATH block in the per-shell config file."""
240
+ target = _target(shell)
241
+
242
+ if target.is_symlink():
243
+ return SetupPathResult(
244
+ "blocked",
245
+ config_file=target,
246
+ message=(
247
+ f"{target} is a symlink — likely managed externally "
248
+ f"(Nix, home-manager, chezmoi, ...). Add this snippet manually:"
249
+ ),
250
+ snippet=_block(shell, bin_dir),
251
+ )
252
+
253
+ target_content = target.read_text(encoding="utf-8") if target.exists() else ""
254
+ target_blocks = _find_blocks(target_content)
255
+
256
+ for blk in target_blocks:
257
+ if blk.kind == "new" and str(bin_dir) in "".join(blk.lines):
258
+ return SetupPathResult(
259
+ "ok_restart",
260
+ config_file=target,
261
+ message=f"Already in {target}, restart shell to activate",
262
+ )
263
+
264
+ target_lines = target_content.splitlines(keepends=True)
265
+ own_ranges = [(b.line_start, b.line_end) for b in target_blocks]
266
+ non_own = "".join(
267
+ line
268
+ for idx, line in enumerate(target_lines)
269
+ if not any(s <= idx < e for s, e in own_ranges)
270
+ )
271
+ if _heuristic_has_bin(non_own, bin_dir):
272
+ return SetupPathResult(
273
+ "ok_restart",
274
+ config_file=target,
275
+ message=f"{bin_dir} already exported from {target}",
276
+ )
277
+
278
+ legacy_path = _legacy(shell)
279
+
280
+ legacy_action: tuple[str, _BlockSpan] | None = None
281
+ legacy_warning: str | None = None
282
+
283
+ if legacy_path and legacy_path.exists():
284
+ same_file = legacy_path == target
285
+ legacy_blocks = (
286
+ target_blocks if same_file else _find_blocks(legacy_path.read_text(encoding="utf-8"))
287
+ )
288
+
289
+ export_re = _legacy_export_re(shell, bin_dir)
290
+ for blk in legacy_blocks:
291
+ if blk.kind != "legacy":
292
+ continue
293
+ export_line = blk.lines[1] if len(blk.lines) >= 2 else ""
294
+ if export_re.match(export_line.rstrip("\n")):
295
+ legacy_action = ("strict", blk)
296
+ else:
297
+ legacy_action = ("divergent", blk)
298
+ legacy_warning = (
299
+ f"Found a previous '{_BEGIN}' block in {legacy_path} "
300
+ f"(line {blk.line_start + 1}) that does not match what "
301
+ f"ixt wrote. Leaving as-is — review and remove manually."
302
+ )
303
+ break
304
+
305
+ if legacy_action is None and not same_file:
306
+ legacy_content = legacy_path.read_text(encoding="utf-8")
307
+ if _heuristic_has_bin(legacy_content, bin_dir):
308
+ legacy_warning = (
309
+ f"{legacy_path} already references {bin_dir} but ixt did "
310
+ f"not write that block. Leaving as-is. ixt's block goes "
311
+ f"to {target}."
312
+ )
313
+
314
+ if check_only:
315
+ return SetupPathResult(
316
+ "not_configured",
317
+ config_file=target,
318
+ message=f"Not configured; would add to {target}",
319
+ warning=legacy_warning,
320
+ )
321
+
322
+ migrated = False
323
+ if legacy_action and legacy_action[0] == "strict":
324
+ _, blk = legacy_action
325
+ if legacy_path is None:
326
+ raise RuntimeError("legacy block selected without a legacy path")
327
+ _remove_block(legacy_path, blk)
328
+ migrated = True
329
+
330
+ _append_block(target, _block(shell, bin_dir))
331
+
332
+ if migrated and legacy_path is not None and legacy_path != target:
333
+ message = f"Migrated from {legacy_path} to {target}, restart shell to activate"
334
+ elif migrated:
335
+ message = f"Migrated legacy block in {target} to new format, restart shell"
336
+ else:
337
+ message = f"Added to {target}, restart shell to activate"
338
+
339
+ return SetupPathResult(
340
+ "migrated" if migrated else "added",
341
+ config_file=target,
342
+ message=message,
343
+ warning=legacy_warning,
344
+ )
345
+
346
+
347
+ def _setup_path_windows(bin_dir: Path, *, check_only: bool = False) -> SetupPathResult:
348
+ """Windows registry fallback when no POSIX shell is detected."""
349
+ from ixt.platform.win import add_to_user_path, get_user_path
350
+
351
+ bin_str = str(bin_dir)
352
+ bin_low = bin_str.lower()
353
+
354
+ user_path = get_user_path() or ""
355
+ if any(p.lower() == bin_low for p in user_path.split(";")):
356
+ return SetupPathResult(
357
+ "ok_restart",
358
+ message="Already in user registry PATH, restart shell to activate",
359
+ )
360
+
361
+ if check_only:
362
+ return SetupPathResult("not_configured", message="Not in PATH")
363
+
364
+ add_to_user_path(bin_str)
365
+ return SetupPathResult(
366
+ "added",
367
+ message="Added to user PATH in registry, restart shell to activate",
368
+ )