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
@@ -0,0 +1,465 @@
1
+ """Heuristic asset selection engine for binary backend.
2
+
3
+ Loads heuristics.toml (embedded defaults + optional user overrides)
4
+ and implements the 3-level asset selection algorithm:
5
+ Level 1 — Exact pattern (from ixt.setup.toml)
6
+ Level 2 — Generic patterns (from heuristics.toml)
7
+ Level 3 — Scoring fallback
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import functools
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ixt.config.toml import _load_toml
19
+ from ixt.data import get_data_path
20
+ from ixt.net.source import Asset
21
+ from ixt.platform import OS, Arch, get_arch, get_os
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Config models
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class ScoringConfig:
30
+ prefer: list[str] = field(default_factory=list)
31
+ avoid: list[str] = field(default_factory=list)
32
+ skip_ext: list[str] = field(default_factory=list)
33
+ archive_ext: list[str] = field(default_factory=list)
34
+ archive_priority: list[str] = field(default_factory=list)
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class ExposeConfig:
39
+ skip_names: list[str] = field(default_factory=list)
40
+ skip_patterns: list[str] = field(default_factory=list)
41
+ skip_keywords: list[str] = field(default_factory=list)
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class NamesConfig:
46
+ strip_suffix_tokens: list[str] = field(default_factory=list)
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class HeuristicsConfig:
51
+ os_aliases: dict[str, list[str]] = field(default_factory=dict)
52
+ arch_aliases: dict[str, list[str]] = field(default_factory=dict)
53
+ patterns: list[str] = field(default_factory=list)
54
+ scoring: ScoringConfig = field(default_factory=ScoringConfig)
55
+ expose: ExposeConfig = field(default_factory=ExposeConfig)
56
+ names: NamesConfig = field(default_factory=NamesConfig)
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Loading
61
+ # ---------------------------------------------------------------------------
62
+
63
+ _OS_MAP: dict[OS, str] = {
64
+ OS.LINUX: "linux",
65
+ OS.MACOS: "macos",
66
+ OS.WINDOWS: "windows",
67
+ }
68
+
69
+ _ARCH_MAP: dict[Arch, str] = {
70
+ Arch.X86_64: "x86_64",
71
+ Arch.ARM64: "aarch64",
72
+ Arch.ARMV7: "armv7",
73
+ }
74
+
75
+
76
+ def _parse_heuristics(data: dict[str, Any]) -> HeuristicsConfig:
77
+ """Build a HeuristicsConfig from parsed TOML dict."""
78
+ scoring_data = data.get("scoring", {})
79
+ expose_data = data.get("expose", {})
80
+ names_data = data.get("names", {})
81
+
82
+ return HeuristicsConfig(
83
+ os_aliases=data.get("os", {}),
84
+ arch_aliases=data.get("arch", {}),
85
+ patterns=data.get("patterns", {}).get("templates", []),
86
+ scoring=ScoringConfig(
87
+ prefer=scoring_data.get("prefer", []),
88
+ avoid=scoring_data.get("avoid", []),
89
+ skip_ext=scoring_data.get("skip_ext", []),
90
+ archive_ext=scoring_data.get("archive_ext", []),
91
+ archive_priority=scoring_data.get("archive_priority", []),
92
+ ),
93
+ expose=ExposeConfig(
94
+ skip_names=expose_data.get("skip_names", []),
95
+ skip_patterns=expose_data.get("skip_patterns", []),
96
+ skip_keywords=expose_data.get("skip_keywords", []),
97
+ ),
98
+ names=NamesConfig(
99
+ strip_suffix_tokens=names_data.get("strip_suffix_tokens", []),
100
+ ),
101
+ )
102
+
103
+
104
+ def _merge_heuristics(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
105
+ """Shallow merge: override replaces entire top-level sections."""
106
+ merged = dict(base)
107
+ for key, val in override.items():
108
+ if isinstance(val, dict) and isinstance(merged.get(key), dict):
109
+ merged[key] = {**merged[key], **val}
110
+ else:
111
+ merged[key] = val
112
+ return merged
113
+
114
+
115
+ @functools.lru_cache(maxsize=1)
116
+ def load_heuristics(user_path: Path | None = None) -> HeuristicsConfig:
117
+ """Load heuristics config (embedded defaults + optional user overrides).
118
+
119
+ Args:
120
+ user_path: Explicit path to user override file.
121
+ If None, checks $IXT_HOME/config/heuristics.toml.
122
+ """
123
+ # Embedded defaults
124
+ data = _load_toml(get_data_path("heuristics.toml"))
125
+
126
+ # User overrides
127
+ if user_path is None:
128
+ from ixt.config.settings import get_ixt_config_dir
129
+
130
+ user_path = get_ixt_config_dir() / "heuristics.toml"
131
+
132
+ if user_path.is_file():
133
+ user_data = _load_toml(user_path)
134
+ data = _merge_heuristics(data, user_data)
135
+
136
+ return _parse_heuristics(data)
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Verbose trace
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ class _Trace:
145
+ """Collects selection trace lines. No-op when level=0.
146
+
147
+ level 1 (-v): key decisions — pattern matches, winner, scoring
148
+ level 2 (-vv): adds filtering — skipped assets, all patterns tried
149
+ """
150
+
151
+ def __init__(self, level: int = 0):
152
+ self.level = level
153
+ self.lines: list[str] = []
154
+
155
+ def __call__(self, msg: str, *, min_level: int = 1) -> None:
156
+ if self.level >= min_level:
157
+ self.lines.append(msg)
158
+
159
+ def flush(self) -> None:
160
+ if not self.level or not self.lines:
161
+ return
162
+ from ixt.libs.logger import Level, get_logger
163
+
164
+ log = get_logger("heuristics", Level.DEBUG)
165
+ for line in self.lines:
166
+ log.debug(line)
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Pattern helpers
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def _resolve_pattern(pattern: str, name: str, os_str: str, arch_str: str, version: str) -> str:
175
+ """Replace {name}, {os}, {arch}, {version} in a pattern template."""
176
+ result = pattern.replace("{name}", name)
177
+ result = result.replace("{os}", os_str)
178
+ result = result.replace("{arch}", arch_str)
179
+ result = result.replace("{version}", version)
180
+ return result
181
+
182
+
183
+ def _get_os_aliases(cfg: HeuristicsConfig, os_key: str) -> list[str]:
184
+ """Return all aliases for the given OS key (includes the key itself)."""
185
+ aliases = cfg.os_aliases.get(os_key, [])
186
+ if os_key not in aliases:
187
+ return [os_key, *aliases]
188
+ return aliases
189
+
190
+
191
+ def _get_arch_aliases(cfg: HeuristicsConfig, arch_key: str) -> list[str]:
192
+ """Return all aliases for the given arch key (includes the key itself)."""
193
+ aliases = cfg.arch_aliases.get(arch_key, [])
194
+ if arch_key not in aliases:
195
+ return [arch_key, *aliases]
196
+ return aliases
197
+
198
+
199
+ def get_platform_aliases(
200
+ cfg: HeuristicsConfig | None = None,
201
+ *,
202
+ os_override: OS | None = None,
203
+ arch_override: Arch | None = None,
204
+ ) -> tuple[list[str], list[str]]:
205
+ """Return OS and architecture aliases for the current platform."""
206
+ cfg = cfg or load_heuristics()
207
+ current_os = _OS_MAP[os_override or get_os()]
208
+ current_arch = _ARCH_MAP[arch_override or get_arch()]
209
+ return _get_os_aliases(cfg, current_os), _get_arch_aliases(cfg, current_arch)
210
+
211
+
212
+ def get_name_suffix_tokens(cfg: HeuristicsConfig | None = None) -> list[str]:
213
+ """Return extra suffix tokens stripped from generated executable names."""
214
+ cfg = cfg or load_heuristics()
215
+ return list(cfg.names.strip_suffix_tokens)
216
+
217
+
218
+ def _has_skip_ext(name_lower: str, skip_ext: list[str]) -> bool:
219
+ """Check if asset name ends with a skippable extension."""
220
+ return any(name_lower.endswith(ext) for ext in skip_ext)
221
+
222
+
223
+ def _has_avoid_keyword(name_lower: str, avoid: list[str]) -> bool:
224
+ """Check if asset name contains an avoid keyword."""
225
+ return any(kw in name_lower for kw in avoid)
226
+
227
+
228
+ def _get_archive_priority_score(name_lower: str, priority: list[str]) -> int:
229
+ """Return archive priority score (higher index = lower priority)."""
230
+ for i, ext in enumerate(priority):
231
+ if name_lower.endswith(ext):
232
+ return len(priority) - i # first in list = highest score
233
+ return 0
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Asset selection — 3-level algorithm
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ def select_asset(
242
+ assets: list[Asset],
243
+ name: str,
244
+ version: str = "",
245
+ *,
246
+ heuristics: HeuristicsConfig | None = None,
247
+ asset_pattern: str | None = None,
248
+ os_override: OS | None = None,
249
+ arch_override: Arch | None = None,
250
+ verbose: bool | int = False,
251
+ ) -> Asset | None:
252
+ """Select the best matching asset from a release.
253
+
254
+ Args:
255
+ assets: List of release assets.
256
+ name: Tool name (e.g. "ripgrep", "fd").
257
+ version: Release version tag (e.g. "14.1.0"), stripped of "v" prefix.
258
+ heuristics: Config to use. Loads defaults if None.
259
+ asset_pattern: Exact pattern from ixt.setup.toml (Level 1).
260
+ os_override: Override detected OS (for testing).
261
+ arch_override: Override detected arch (for testing).
262
+ verbose: Verbosity level (0=off, 1=decisions, 2=filtering details).
263
+
264
+ Returns:
265
+ Best matching Asset, or None if no match found.
266
+ """
267
+ if not assets:
268
+ return None
269
+
270
+ cfg = heuristics or load_heuristics()
271
+ current_os = _OS_MAP[os_override or get_os()]
272
+ current_arch = _ARCH_MAP[arch_override or get_arch()]
273
+ t = _Trace(int(verbose))
274
+
275
+ # Strip v prefix from version for pattern matching
276
+ ver = version.lstrip("v") if version else ""
277
+
278
+ os_aliases = _get_os_aliases(cfg, current_os)
279
+ arch_aliases = _get_arch_aliases(cfg, current_arch)
280
+
281
+ t(f"select: name={name!r} version={ver!r} os={current_os} arch={current_arch}")
282
+ t(f" os_aliases: {os_aliases}")
283
+ t(f" arch_aliases: {arch_aliases}")
284
+ t(f" assets ({len(assets)}): {[a.name for a in assets]}")
285
+
286
+ # Level 1 — Exact pattern
287
+ if asset_pattern:
288
+ t(f" level 1: asset_pattern={asset_pattern!r}")
289
+ matches = _match_pattern(assets, asset_pattern, name, current_os, current_arch, ver, t)
290
+ if len(matches) == 1:
291
+ t(f" level 1 -> exact match: {matches[0].name}")
292
+ t.flush()
293
+ return matches[0]
294
+ if matches:
295
+ result = _score_best(matches, os_aliases, arch_aliases, cfg.scoring, t)
296
+ t(f" level 1 -> scored winner: {result.name if result else 'none'}")
297
+ t.flush()
298
+ return result
299
+ t(" level 1 -> no match")
300
+
301
+ # Level 2 — Generic patterns
302
+ t(" level 2: trying generic patterns")
303
+ for pattern in cfg.patterns:
304
+ if "{version}" in pattern and not ver:
305
+ t(f" pattern {pattern!r} -> skip (no version)", min_level=2)
306
+ continue
307
+ matches = _match_pattern(assets, pattern, name, current_os, current_arch, ver, t)
308
+ if matches:
309
+ t(f" pattern {pattern!r} -> {len(matches)} match(es): {[a.name for a in matches]}")
310
+ if len(matches) == 1:
311
+ t(f" level 2 -> single match: {matches[0].name}")
312
+ t.flush()
313
+ return matches[0]
314
+ result = _score_best(matches, os_aliases, arch_aliases, cfg.scoring, t)
315
+ t(f" level 2 -> scored winner: {result.name if result else 'none'}")
316
+ t.flush()
317
+ return result
318
+ t(f" pattern {pattern!r} -> no match", min_level=2)
319
+
320
+ # Level 3 — Scoring fallback
321
+ t(" level 3: scoring all assets")
322
+ result = _score_assets(assets, os_aliases, arch_aliases, cfg.scoring, t)
323
+ t(f" level 3 -> winner: {result.name if result else 'none'}")
324
+ t.flush()
325
+ return result
326
+
327
+
328
+ def _match_pattern(
329
+ assets: list[Asset],
330
+ pattern: str,
331
+ name: str,
332
+ os_str: str,
333
+ arch_str: str,
334
+ version: str,
335
+ trace: _Trace | None = None,
336
+ ) -> list[Asset]:
337
+ """Try to match assets against a pattern template.
338
+
339
+ For each OS alias x arch alias combination, resolve the pattern
340
+ and fnmatch against all assets. Assets with skip_ext are excluded.
341
+ Return list of matches (may be empty).
342
+ """
343
+ t = trace or _Trace(0)
344
+ cfg = load_heuristics()
345
+ os_aliases = _get_os_aliases(cfg, os_str)
346
+ arch_aliases = _get_arch_aliases(cfg, arch_str)
347
+
348
+ matches: list[Asset] = []
349
+ seen: set[str] = set()
350
+ skipped: list[str] = []
351
+
352
+ for os_alias in os_aliases:
353
+ for arch_alias in arch_aliases:
354
+ resolved = _resolve_pattern(pattern, name, os_alias, arch_alias, version)
355
+ for asset in assets:
356
+ if asset.name in seen:
357
+ continue
358
+ lower = asset.name.lower()
359
+ if _has_skip_ext(lower, cfg.scoring.skip_ext):
360
+ seen.add(asset.name)
361
+ skipped.append(asset.name)
362
+ continue
363
+ if fnmatch.fnmatch(lower, resolved.lower()):
364
+ matches.append(asset)
365
+ seen.add(asset.name)
366
+
367
+ if skipped:
368
+ t(f" skip_ext: {skipped}", min_level=2)
369
+
370
+ return matches
371
+
372
+
373
+ def _score_best(
374
+ assets: list[Asset],
375
+ os_aliases: list[str],
376
+ arch_aliases: list[str],
377
+ scoring: ScoringConfig,
378
+ trace: _Trace | None = None,
379
+ ) -> Asset | None:
380
+ """Score a pre-filtered list and return the best."""
381
+ scored = _compute_scores(assets, os_aliases, arch_aliases, scoring, trace)
382
+ if not scored:
383
+ return None
384
+ scored.sort(key=lambda x: (-x[1], len(x[0].name)))
385
+ return scored[0][0]
386
+
387
+
388
+ def _score_assets(
389
+ assets: list[Asset],
390
+ os_aliases: list[str],
391
+ arch_aliases: list[str],
392
+ scoring: ScoringConfig,
393
+ trace: _Trace | None = None,
394
+ ) -> Asset | None:
395
+ """Level 3: score all assets and return the best match."""
396
+ t = trace or _Trace(0)
397
+
398
+ # Pre-filter
399
+ candidates = []
400
+ for asset in assets:
401
+ lower = asset.name.lower()
402
+ if _has_skip_ext(lower, scoring.skip_ext):
403
+ t(f" skip (ext): {asset.name}", min_level=2)
404
+ continue
405
+ if _has_avoid_keyword(lower, scoring.avoid):
406
+ t(f" skip (avoid): {asset.name}", min_level=2)
407
+ continue
408
+ candidates.append(asset)
409
+
410
+ scored = _compute_scores(candidates, os_aliases, arch_aliases, scoring, trace)
411
+ if not scored:
412
+ return None
413
+
414
+ # Must have at least OS or arch match
415
+ scored = [(a, s) for a, s in scored if s > 0]
416
+ if not scored:
417
+ return None
418
+
419
+ scored.sort(key=lambda x: (-x[1], len(x[0].name)))
420
+ return scored[0][0]
421
+
422
+
423
+ def _compute_scores(
424
+ assets: list[Asset],
425
+ os_aliases: list[str],
426
+ arch_aliases: list[str],
427
+ scoring: ScoringConfig,
428
+ trace: _Trace | None = None,
429
+ ) -> list[tuple[Asset, int]]:
430
+ """Compute score for each asset."""
431
+ t = trace or _Trace(False)
432
+ results: list[tuple[Asset, int]] = []
433
+
434
+ for asset in assets:
435
+ lower = asset.name.lower()
436
+ score = 0
437
+ reasons: list[str] = []
438
+
439
+ # OS match (+10)
440
+ if any(alias.lower() in lower for alias in os_aliases):
441
+ score += 10
442
+ reasons.append("os+10")
443
+
444
+ # Arch match (+10)
445
+ if any(alias.lower() in lower for alias in arch_aliases):
446
+ score += 10
447
+ reasons.append("arch+10")
448
+
449
+ # Prefer keywords (earlier in list = higher bonus)
450
+ for i, kw in enumerate(scoring.prefer):
451
+ if kw.lower() in lower:
452
+ bonus = len(scoring.prefer) - i + 1
453
+ score += bonus
454
+ reasons.append(f"{kw}+{bonus}")
455
+
456
+ # Archive priority (+1..N)
457
+ ap = _get_archive_priority_score(lower, scoring.archive_priority)
458
+ if ap:
459
+ score += ap
460
+ reasons.append(f"archive+{ap}")
461
+
462
+ t(f" score {score:3d} ({', '.join(reasons) or '-':30s}) {asset.name}")
463
+ results.append((asset, score))
464
+
465
+ return results
ixt/config/models.py ADDED
@@ -0,0 +1,176 @@
1
+ """Backend-agnostic metadata persisted per installed tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ixt.libs.constants import EXPOSE_MAIN
13
+
14
+ _METADATA_FILENAME = "ixt.json"
15
+ _MAX_METADATA_BYTES = 1024 * 1024
16
+
17
+
18
+ def _metadata_path(file_path: str | Path) -> Path:
19
+ """Return a ToolRecord metadata path after cheap path-safety checks."""
20
+ path = Path(file_path).expanduser()
21
+ if path.name != _METADATA_FILENAME:
22
+ raise ValueError(f"ToolRecord metadata path must end with {_METADATA_FILENAME!r}: {path}")
23
+ if ".." in path.parts:
24
+ raise ValueError(f"Path traversal detected in ToolRecord metadata path: {path}")
25
+ return path
26
+
27
+
28
+ def _absolute_path(path: str | Path) -> Path:
29
+ return Path(os.path.abspath(Path(path).expanduser()))
30
+
31
+
32
+ def _encode_under_base(value: str, base: Path) -> str:
33
+ """Persist *value* relative to *base* when it is inside that tree."""
34
+ path = Path(value).expanduser()
35
+ if not path.is_absolute():
36
+ return value
37
+ try:
38
+ rel = _absolute_path(path).relative_to(_absolute_path(base))
39
+ except ValueError:
40
+ return value
41
+ return "." if rel == Path(".") else str(rel)
42
+
43
+
44
+ def _metadata_env_dir(data: dict[str, Any], path: Path) -> tuple[Path, Path | None]:
45
+ """Return current env dir and legacy absolute env dir, if one was stored."""
46
+ raw = data.get("env_dir", ".")
47
+ raw_path = Path(raw)
48
+ if not raw_path.is_absolute():
49
+ return _absolute_path(path.parent / raw_path), None
50
+
51
+ current = _absolute_path(raw_path)
52
+ metadata_parent = _absolute_path(path.parent)
53
+ # Legacy records stored absolute env_dir values. If the metadata file is
54
+ # now inside a same-named env dir elsewhere, trust the file location so a
55
+ # copied installed layer can rebase itself on load.
56
+ if current != metadata_parent and current.name == metadata_parent.name:
57
+ return metadata_parent, current
58
+ return current, current
59
+
60
+
61
+ def _decode_exposed_bin(value: str, current_env_dir: Path, legacy_env_dir: Path | None) -> str:
62
+ path = Path(value)
63
+ if path.is_absolute():
64
+ if legacy_env_dir is not None:
65
+ try:
66
+ return str(current_env_dir / path.relative_to(legacy_env_dir))
67
+ except ValueError:
68
+ pass
69
+ return value
70
+ return str(current_env_dir / path)
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class ToolRecord:
75
+ """Persisted state for one installed tool (``ixt.json``).
76
+
77
+ This model is intentionally flat and backend-agnostic.
78
+ Backend-specific details (venv layout, node_modules structure)
79
+ stay in the backend implementations.
80
+ """
81
+
82
+ name: str # installed tool id (equals env_dir basename; may include a --slot prefix)
83
+ backend: str # "python" | "node" | "binary"
84
+ spec: str # original install spec as given by user
85
+ env_dir: str # in-memory absolute path; persisted relative when possible
86
+ version: str | None = None
87
+ # Underlying package name for resolve/expose. None on legacy records →
88
+ # pkg() falls back to ``name`` for records without explicit package metadata.
89
+ pkg_name: str | None = None
90
+ injected: list[str] = field(default_factory=list)
91
+ expose_rules: list[str] = field(default_factory=lambda: [EXPOSE_MAIN])
92
+ exposed_bins: dict[str, str] = field(default_factory=dict)
93
+ node_shim: bool | None = None
94
+ asset_pattern: str | None = None
95
+ asset_pattern_forced: bool = False
96
+ env_base: str = "all"
97
+ env_allow: list[str] = field(default_factory=list)
98
+ env_deny: dict[str, dict[str, list[str]]] = field(default_factory=dict)
99
+ fs_base: str = "all"
100
+ fs_ro: list[str] = field(default_factory=list)
101
+ fs_rw: list[str] = field(default_factory=list)
102
+ fs_scratch: list[str] = field(default_factory=list)
103
+ source: str = "registry" # "registry" | "local" (extensible: "git", "url", ...)
104
+ config_version: str = "1"
105
+
106
+ def pkg(self) -> str:
107
+ """Underlying package name (for PyPI/npm resolve, backend bin lookup)."""
108
+ return self.pkg_name or self.name
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict[str, Any]) -> ToolRecord:
112
+ return cls(
113
+ name=data["name"],
114
+ backend=data["backend"],
115
+ spec=data["spec"],
116
+ env_dir=data.get("env_dir", "."),
117
+ version=data.get("version"),
118
+ pkg_name=data.get("pkg_name"),
119
+ injected=list(data.get("injected", [])),
120
+ expose_rules=list(data.get("expose_rules", [EXPOSE_MAIN])),
121
+ exposed_bins=dict(data.get("exposed_bins", {})),
122
+ node_shim=data.get("node_shim"),
123
+ asset_pattern=data.get("asset_pattern"),
124
+ asset_pattern_forced=bool(data.get("asset_pattern_forced", False)),
125
+ env_base=data.get("env_base", "all"),
126
+ env_allow=list(data.get("env_allow", [])),
127
+ env_deny=dict(data.get("env_deny", {})),
128
+ fs_base=data.get("fs_base", "all"),
129
+ fs_ro=list(data.get("fs_ro", [])),
130
+ fs_rw=list(data.get("fs_rw", [])),
131
+ fs_scratch=list(data.get("fs_scratch", [])),
132
+ source=data.get("source", "registry"),
133
+ config_version=data.get("config_version", "1"),
134
+ )
135
+
136
+ @classmethod
137
+ def load_json(cls, file_path: str | Path) -> ToolRecord:
138
+ path = _metadata_path(file_path)
139
+ if not path.exists():
140
+ raise FileNotFoundError(path)
141
+ if path.is_symlink() or not path.is_file():
142
+ raise ValueError(f"ToolRecord metadata must be a regular file: {path}")
143
+ if path.stat().st_size > _MAX_METADATA_BYTES:
144
+ raise ValueError(f"ToolRecord metadata is too large: {path}")
145
+ data = json.loads(path.read_text(encoding="utf-8"))
146
+ current_env_dir, legacy_env_dir = _metadata_env_dir(data, path)
147
+ record = cls.from_dict(data)
148
+ record.env_dir = str(current_env_dir)
149
+ record.exposed_bins = {
150
+ name: _decode_exposed_bin(source, current_env_dir, legacy_env_dir)
151
+ for name, source in record.exposed_bins.items()
152
+ }
153
+ return record
154
+
155
+ def save_json(self, file_path: str | Path) -> Path:
156
+ path = _metadata_path(file_path)
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ if path.exists() and (path.is_symlink() or not path.is_file()):
159
+ raise ValueError(f"ToolRecord metadata destination must be a regular file: {path}")
160
+ env_dir = _absolute_path(self.env_dir)
161
+ data = asdict(self)
162
+ data["env_dir"] = _encode_under_base(str(env_dir), path.parent)
163
+ data["exposed_bins"] = {
164
+ name: _encode_under_base(source, env_dir)
165
+ for name, source in self.exposed_bins.items()
166
+ }
167
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
168
+ try:
169
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
170
+ json.dump(data, f, indent=2)
171
+ f.write("\n")
172
+ os.replace(tmp, path)
173
+ except Exception:
174
+ Path(tmp).unlink(missing_ok=True)
175
+ raise
176
+ return path