ixt-cli 0.8.1__tar.gz → 0.9.0__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 (152) hide show
  1. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/.gitignore +1 -0
  2. ixt_cli-0.9.0/LICENSE +21 -0
  3. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/PKG-INFO +32 -6
  4. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/README.md +30 -5
  5. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/pyproject.toml +1 -5
  6. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/backends/binary.py +71 -19
  7. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_apply.py +54 -7
  8. ixt_cli-0.9.0/src/ixt/cli/cmd_lock.py +89 -0
  9. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/commands.py +3 -0
  10. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/parser.py +28 -0
  11. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/heuristics.py +47 -1
  12. ixt_cli-0.9.0/src/ixt/config/lockfile.py +279 -0
  13. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/apply.py +148 -6
  14. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/discover.py +84 -1
  15. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/install.py +4 -0
  16. ixt_cli-0.9.0/src/ixt/core/lockfile.py +504 -0
  17. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/data/heuristics.toml +1 -1
  18. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/net/npm.py +18 -2
  19. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/net/pypi.py +12 -0
  20. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_apply.py +372 -2
  21. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_argparse_completion.py +1 -1
  22. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_binary_backend.py +193 -18
  23. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_cli.py +9 -0
  24. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_discover.py +80 -0
  25. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_heuristics.py +33 -0
  26. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_install.py +1 -1
  27. ixt_cli-0.9.0/tests/unit/test_lock_write.py +561 -0
  28. ixt_cli-0.9.0/tests/unit/test_lockfile.py +195 -0
  29. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/__init__.py +0 -0
  30. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/__main__.py +0 -0
  31. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/backends/__init__.py +0 -0
  32. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/backends/binary_resolver.py +0 -0
  33. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/backends/node.py +0 -0
  34. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/backends/python.py +0 -0
  35. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/__init__.py +0 -0
  36. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/argparse_completion.py +0 -0
  37. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_cache.py +0 -0
  38. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_config.py +0 -0
  39. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_info.py +0 -0
  40. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_install.py +0 -0
  41. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_misc.py +0 -0
  42. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_registry.py +0 -0
  43. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/cmd_upgrade.py +0 -0
  44. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/cli/render.py +0 -0
  45. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/__init__.py +0 -0
  46. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/asset_index.py +0 -0
  47. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/asset_pattern_cache.py +0 -0
  48. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/env_policy.py +0 -0
  49. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/flags.py +0 -0
  50. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/fs_policy.py +0 -0
  51. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/models.py +0 -0
  52. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/registry.py +0 -0
  53. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/settings.py +0 -0
  54. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/setup_toml.py +0 -0
  55. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/config/toml.py +0 -0
  56. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/__init__.py +0 -0
  57. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/apply_actions.py +0 -0
  58. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/backend.py +0 -0
  59. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/bootstrap.py +0 -0
  60. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/cache.py +0 -0
  61. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/doctor.py +0 -0
  62. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/export.py +0 -0
  63. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/expose.py +0 -0
  64. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/extract.py +0 -0
  65. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/hooks.py +0 -0
  66. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/identity.py +0 -0
  67. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/inject.py +0 -0
  68. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/install_local.py +0 -0
  69. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/locks.py +0 -0
  70. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/pathlink.py +0 -0
  71. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/resolution_stats.py +0 -0
  72. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/resolve.py +0 -0
  73. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/resolve_cache.py +0 -0
  74. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/runtimes.py +0 -0
  75. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/save.py +0 -0
  76. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/setup_completions.py +0 -0
  77. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/setup_path.py +0 -0
  78. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/core/upgrade.py +0 -0
  79. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/data/__init__.py +0 -0
  80. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/data/asset_index.json +0 -0
  81. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/data/registry.toml +0 -0
  82. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/__init__.py +0 -0
  83. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/constants.py +0 -0
  84. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/fmt.py +0 -0
  85. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/logger.py +0 -0
  86. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/output.py +0 -0
  87. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/req_spec.py +0 -0
  88. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/semver.py +0 -0
  89. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/shell.py +0 -0
  90. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/libs/style.py +0 -0
  91. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/net/__init__.py +0 -0
  92. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/net/github_api.py +0 -0
  93. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/net/gitlab_api.py +0 -0
  94. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/net/http.py +0 -0
  95. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/net/source.py +0 -0
  96. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/platform/__init__.py +0 -0
  97. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/src/ixt/platform/win.py +0 -0
  98. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/__init__.py +0 -0
  99. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/conftest.py +0 -0
  100. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/README.md +0 -0
  101. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/conftest.py +0 -0
  102. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/docker/Dockerfile.alpine +0 -0
  103. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/docker/Dockerfile.fedora +0 -0
  104. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/docker/Dockerfile.ubuntu +0 -0
  105. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/playground_incus.sh +0 -0
  106. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/run_e2e_docker.sh +0 -0
  107. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/run_e2e_incus.sh +0 -0
  108. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/runtime_matrix.sh +0 -0
  109. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/e2e/test_e2e.py +0 -0
  110. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/integration/__init__.py +0 -0
  111. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/integration/test_lifecycle.py +0 -0
  112. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/integration/test_policy_runtime.py +0 -0
  113. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/__init__.py +0 -0
  114. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_asset_index.py +0 -0
  115. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_asset_pattern_cache.py +0 -0
  116. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_backend.py +0 -0
  117. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_bootstrap.py +0 -0
  118. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_cache.py +0 -0
  119. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_doctor.py +0 -0
  120. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_env_policy.py +0 -0
  121. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_export.py +0 -0
  122. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_expose.py +0 -0
  123. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_extract.py +0 -0
  124. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_flags.py +0 -0
  125. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_fs_policy.py +0 -0
  126. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_github_api.py +0 -0
  127. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_gitlab_api.py +0 -0
  128. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_hooks.py +0 -0
  129. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_http.py +0 -0
  130. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_inject.py +0 -0
  131. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_install_local.py +0 -0
  132. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_models.py +0 -0
  133. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_namespacing.py +0 -0
  134. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_node_backend.py +0 -0
  135. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_output.py +0 -0
  136. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_pathlink.py +0 -0
  137. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_platform.py +0 -0
  138. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_python_backend.py +0 -0
  139. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_registry.py +0 -0
  140. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_req_spec.py +0 -0
  141. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_resolve.py +0 -0
  142. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_resolve_cache.py +0 -0
  143. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_runtimes.py +0 -0
  144. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_save.py +0 -0
  145. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_semver.py +0 -0
  146. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_settings.py +0 -0
  147. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_setup_path.py +0 -0
  148. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_setup_toml.py +0 -0
  149. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_source.py +0 -0
  150. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_style.py +0 -0
  151. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_toml.py +0 -0
  152. {ixt_cli-0.8.1 → ixt_cli-0.9.0}/tests/unit/test_upgrade.py +0 -0
@@ -150,3 +150,4 @@ ixt.egg-info
150
150
 
151
151
  .memsearch/
152
152
  .pyscn/
153
+ .pi/
ixt_cli-0.9.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gaëtan Montury
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ixt-cli
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: Universal CLI tool isolator - manage Python, Node, and binary tools with per-tool isolation
5
5
  Project-URL: Homepage, https://gitlab.com/pytgaen-group/ixt
6
6
  Project-URL: Repository, https://gitlab.com/pytgaen-group/ixt.git
@@ -8,6 +8,7 @@ Project-URL: Issues, https://gitlab.com/pytgaen-group/ixt/-/issues
8
8
  Project-URL: Documentation, https://ixt-39f604.gitlab.io
9
9
  Author-email: Gaëtan Montury <10528250-pytgaen@users.noreply.gitlab.com>
10
10
  License: MIT
11
+ License-File: LICENSE
11
12
  Keywords: cli,github,isolation,npm,package-manager,pypi,tool
12
13
  Classifier: Development Status :: 4 - Beta
13
14
  Classifier: Environment :: Console
@@ -33,8 +34,10 @@ Description-Content-Type: text/markdown
33
34
  <h1 align="center">ixt — Universal CLI Tool Isolator</h1>
34
35
 
35
36
  <p align="center">
36
- <a href="https://gitlab.com/pytgaen-group/ixt/-/releases"><img src="https://img.shields.io/gitlab/v/release/pytgaen-group/ixt?label=release" alt="Release"></a>
37
- <a href="https://gitlab.com/pytgaen-group/ixt/-/pipelines"><img src="https://img.shields.io/gitlab/pipeline-status/pytgaen-group/ixt?branch=main" alt="Pipeline"></a>
37
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/tags"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F81169049%2Frepository%2Ftags%3Fper_page%3D1&amp;query=%24%5B0%5D.name&amp;label=release" alt="Release"></a>
38
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/blob/main/.gitlab-ci.yml"><img src="https://img.shields.io/badge/build-GitLab%20CI-blue?logo=gitlab" alt="Build: GitLab CI"></a>
39
+ <a href="https://pypi.org/project/ixt-cli/"><img src="https://img.shields.io/pypi/v/ixt-cli?label=pypi" alt="PyPI"></a>
40
+ <a href="https://pepy.tech/projects/ixt-cli"><img src="https://static.pepy.tech/badge/ixt-cli/month" alt="PyPI Downloads"></a>
38
41
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
39
42
  <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
40
43
  </p>
@@ -183,6 +186,24 @@ ixt tool install sharkdp/fd@10 # latest 10.x.x
183
186
  ixt tool install ruff>=0.5.0 # PEP 508 (Python)
184
187
  ```
185
188
 
189
+ ## 🔒 Declarative locks
190
+
191
+ `ixt.toml` is the human-edited intent: which tools you want, with optional
192
+ version bounds, exposure rules, and injections. `ixt.lock` is generated from
193
+ that intent and records the exact resolved versions and binary release assets.
194
+
195
+ For teams and CI, commit both files:
196
+
197
+ ```bash
198
+ ixt lock write
199
+ ixt lock check
200
+ ixt tool apply --locked --strict
201
+ ```
202
+
203
+ `--locked` replays the exact lock instead of resolving newer versions.
204
+ `--strict` fails before changing the machine if `ixt.toml` and `ixt.lock`
205
+ diverge.
206
+
186
207
  ## 🧰 Commands
187
208
 
188
209
  | Command | Description |
@@ -196,7 +217,11 @@ ixt tool install ruff>=0.5.0 # PEP 508 (Python)
196
217
  | `ixt tool upgrade <pkg>` | Upgrade a tool |
197
218
  | `ixt tool upgrade --all` | Upgrade all tools |
198
219
  | `ixt tool apply` | Sync tools from declarative config |
220
+ | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
199
221
  | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
222
+ | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
223
+ | `ixt lock check` | Fail if ixt.lock is missing or stale |
224
+ | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
200
225
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
201
226
  | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
202
227
  | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
@@ -219,7 +244,8 @@ ixt tool install ruff>=0.5.0 # PEP 508 (Python)
219
244
  ```
220
245
  $IXT_HOME/ # default ~/.local/share/ixt (respects $XDG_DATA_HOME)
221
246
  ├── config/
222
- │ ├── ixt.toml
247
+ │ ├── ixt.toml # global declarative manifest fallback
248
+ │ ├── ixt.lock # global lock when using the global ixt.toml
223
249
  │ ├── registry.toml
224
250
  │ └── heuristics.toml
225
251
  └── installed/
@@ -266,10 +292,10 @@ uv run ruff format src/ tests/ # format
266
292
 
267
293
  ## 🤝 Contributing
268
294
 
269
- Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. The GitHub repository (if present) is a **read-only mirror** — please open issues and merge requests on GitLab.
295
+ Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. Any GitHub repository for this project is a **read-only mirror** of GitLab; it is not the review or issue tracker. Please open issues and merge requests on GitLab.
270
296
 
271
297
  Bug reports and reproduction recipes are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details, or open an issue to start a discussion.
272
298
 
273
299
  ## 📄 License
274
300
 
275
- MIT
301
+ MIT. See [`LICENSE`](LICENSE).
@@ -5,8 +5,10 @@
5
5
  <h1 align="center">ixt — Universal CLI Tool Isolator</h1>
6
6
 
7
7
  <p align="center">
8
- <a href="https://gitlab.com/pytgaen-group/ixt/-/releases"><img src="https://img.shields.io/gitlab/v/release/pytgaen-group/ixt?label=release" alt="Release"></a>
9
- <a href="https://gitlab.com/pytgaen-group/ixt/-/pipelines"><img src="https://img.shields.io/gitlab/pipeline-status/pytgaen-group/ixt?branch=main" alt="Pipeline"></a>
8
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/tags"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2F81169049%2Frepository%2Ftags%3Fper_page%3D1&amp;query=%24%5B0%5D.name&amp;label=release" alt="Release"></a>
9
+ <a href="https://gitlab.com/pytgaen-group/ixt/-/blob/main/.gitlab-ci.yml"><img src="https://img.shields.io/badge/build-GitLab%20CI-blue?logo=gitlab" alt="Build: GitLab CI"></a>
10
+ <a href="https://pypi.org/project/ixt-cli/"><img src="https://img.shields.io/pypi/v/ixt-cli?label=pypi" alt="PyPI"></a>
11
+ <a href="https://pepy.tech/projects/ixt-cli"><img src="https://static.pepy.tech/badge/ixt-cli/month" alt="PyPI Downloads"></a>
10
12
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
11
13
  <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
12
14
  </p>
@@ -155,6 +157,24 @@ ixt tool install sharkdp/fd@10 # latest 10.x.x
155
157
  ixt tool install ruff>=0.5.0 # PEP 508 (Python)
156
158
  ```
157
159
 
160
+ ## 🔒 Declarative locks
161
+
162
+ `ixt.toml` is the human-edited intent: which tools you want, with optional
163
+ version bounds, exposure rules, and injections. `ixt.lock` is generated from
164
+ that intent and records the exact resolved versions and binary release assets.
165
+
166
+ For teams and CI, commit both files:
167
+
168
+ ```bash
169
+ ixt lock write
170
+ ixt lock check
171
+ ixt tool apply --locked --strict
172
+ ```
173
+
174
+ `--locked` replays the exact lock instead of resolving newer versions.
175
+ `--strict` fails before changing the machine if `ixt.toml` and `ixt.lock`
176
+ diverge.
177
+
158
178
  ## 🧰 Commands
159
179
 
160
180
  | Command | Description |
@@ -168,7 +188,11 @@ ixt tool install ruff>=0.5.0 # PEP 508 (Python)
168
188
  | `ixt tool upgrade <pkg>` | Upgrade a tool |
169
189
  | `ixt tool upgrade --all` | Upgrade all tools |
170
190
  | `ixt tool apply` | Sync tools from declarative config |
191
+ | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
171
192
  | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
193
+ | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
194
+ | `ixt lock check` | Fail if ixt.lock is missing or stale |
195
+ | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
172
196
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
173
197
  | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
174
198
  | `ixt tool config <pkg> inject <dep>` | Inject a package into the tool's env |
@@ -191,7 +215,8 @@ ixt tool install ruff>=0.5.0 # PEP 508 (Python)
191
215
  ```
192
216
  $IXT_HOME/ # default ~/.local/share/ixt (respects $XDG_DATA_HOME)
193
217
  ├── config/
194
- │ ├── ixt.toml
218
+ │ ├── ixt.toml # global declarative manifest fallback
219
+ │ ├── ixt.lock # global lock when using the global ixt.toml
195
220
  │ ├── registry.toml
196
221
  │ └── heuristics.toml
197
222
  └── installed/
@@ -238,10 +263,10 @@ uv run ruff format src/ tests/ # format
238
263
 
239
264
  ## 🤝 Contributing
240
265
 
241
- Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. The GitHub repository (if present) is a **read-only mirror** — please open issues and merge requests on GitLab.
266
+ Development happens on **GitLab**: <https://gitlab.com/pytgaen-group/ixt>. Any GitHub repository for this project is a **read-only mirror** of GitLab; it is not the review or issue tracker. Please open issues and merge requests on GitLab.
242
267
 
243
268
  Bug reports and reproduction recipes are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details, or open an issue to start a discussion.
244
269
 
245
270
  ## 📄 License
246
271
 
247
- MIT
272
+ MIT. See [`LICENSE`](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ixt-cli"
7
- version = "0.8.1" # to bump
7
+ version = "0.9.0" # to bump
8
8
  description = "Universal CLI tool isolator - manage Python, Node, and binary tools with per-tool isolation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -37,10 +37,6 @@ dev = [
37
37
  "pytest-cov>=4.1",
38
38
  "pyright>=1.1",
39
39
  ]
40
- # Standalone binary build (Nuitka). Not a runtime dependency — install with
41
- # `uv sync --group build`. [onefile] pulls zstandard for payload compression.
42
- # See notes/standalone-build.md.
43
- build = ["nuitka[onefile]>=2.5"]
44
40
 
45
41
  [project.scripts]
46
42
  ixt = "ixt.cli:main"
@@ -23,6 +23,7 @@ from ixt.config.heuristics import (
23
23
  )
24
24
 
25
25
  if TYPE_CHECKING:
26
+ from ixt.config.lockfile import BinaryLockMetadata
26
27
  from ixt.config.setup_toml import SetupConfig
27
28
  from ixt.config.settings import Settings, get_settings
28
29
  from ixt.core.backend import BackendType
@@ -30,10 +31,12 @@ from ixt.core.discover import find_executables
30
31
  from ixt.core.extract import extract, is_archive
31
32
  from ixt.libs.logger import get_logger, get_verbosity
32
33
  from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
34
+ from ixt.platform import get_arch, get_os
33
35
 
34
36
  __all__ = [
35
37
  "AssetSelectionError",
36
38
  "BinaryBackend",
39
+ "BinaryPlatformError",
37
40
  "BinarySpecError",
38
41
  "HookEntry",
39
42
  "SetupMetadata",
@@ -89,6 +92,31 @@ class AssetSelectionError(RuntimeError):
89
92
  )
90
93
 
91
94
 
95
+ class BinaryPlatformError(RuntimeError):
96
+ """Raised when extracted executables are incompatible with the current platform."""
97
+
98
+ def __init__(
99
+ self,
100
+ spec: str,
101
+ release_tag: str,
102
+ asset_name: str,
103
+ rejected: list[tuple[Path, str]],
104
+ ):
105
+ self.spec = spec
106
+ self.release_tag = release_tag
107
+ self.asset_name = asset_name
108
+ self.rejected = rejected
109
+ platform = f"{get_os().value}/{get_arch().value}"
110
+ details = ", ".join(
111
+ f"{path.name} ({format_label})"
112
+ for path, format_label in sorted(rejected, key=lambda item: str(item[0]))
113
+ )
114
+ super().__init__(
115
+ f"No executable compatible with {platform} found for '{spec}' "
116
+ f"(release {release_tag}, asset {asset_name}). Rejected: {details}"
117
+ )
118
+
119
+
92
120
  class BinarySpecError(ValueError):
93
121
  """Raised when a spec cannot be parsed as a binary backend spec."""
94
122
 
@@ -396,6 +424,7 @@ class BinaryBackend:
396
424
  setup_path: Path | None = None,
397
425
  asset_pattern: str | None = None,
398
426
  known_tag: str | None = None,
427
+ locked_binary: BinaryLockMetadata | None = None,
399
428
  use_cache: bool = True,
400
429
  ) -> dict | None:
401
430
  """Compatibility adapter for the shared backend protocol."""
@@ -410,6 +439,7 @@ class BinaryBackend:
410
439
  setup_path=setup_path,
411
440
  asset_pattern=asset_pattern,
412
441
  known_tag=known_tag,
442
+ locked_binary=locked_binary,
413
443
  use_cache=use_cache,
414
444
  )
415
445
 
@@ -422,6 +452,7 @@ class BinaryBackend:
422
452
  setup_path: Path | None = None,
423
453
  asset_pattern: str | None = None,
424
454
  known_tag: str | None = None,
455
+ locked_binary: BinaryLockMetadata | None = None,
425
456
  use_cache: bool = True,
426
457
  ) -> dict | None:
427
458
  """Resolve, download, extract, and discover one atomic binary tool.
@@ -460,24 +491,34 @@ class BinaryBackend:
460
491
  )
461
492
  release_pattern_name = setup.name if setup and setup.name else None
462
493
 
463
- # Reuse the release already fetched during version resolution (e.g. an
464
- # upgrade or a dry-run within the TTL window). This skips a second API
465
- # call — the only option for GitLab, whose asset URLs can't be rebuilt
466
- # from a pattern like GitHub's.
467
- release = self._cached_release(spec) if use_cache and not repo_spec.version else None
468
- if release is not None:
469
- log.debug(f"release (resolve cache): {release.tag} ({len(release.assets)} assets)")
470
- else:
471
- release = self._fetch_release(
472
- source,
473
- repo_spec,
474
- env_dir=env_dir,
475
- upgrade=upgrade,
476
- known_tag=known_tag,
477
- asset_pattern=release_pattern,
478
- pattern_name=release_pattern_name,
494
+ if locked_binary is not None:
495
+ if not repo_spec.version:
496
+ raise BinarySpecError("locked binary install requires a pinned release tag")
497
+ release = Release(
498
+ tag=repo_spec.version,
499
+ assets=[Asset(name=locked_binary.asset, url=locked_binary.url)],
500
+ resolution_source="lockfile",
479
501
  )
480
- log.debug(f"release: {release.tag} ({len(release.assets)} assets)")
502
+ log.debug(f"release (lockfile): {release.tag} ({locked_binary.asset})")
503
+ else:
504
+ # Reuse the release already fetched during version resolution (e.g. an
505
+ # upgrade or a dry-run within the TTL window). This skips a second API
506
+ # call — the only option for GitLab, whose asset URLs can't be rebuilt
507
+ # from a pattern like GitHub's.
508
+ release = self._cached_release(spec) if use_cache and not repo_spec.version else None
509
+ if release is not None:
510
+ log.debug(f"release (resolve cache): {release.tag} ({len(release.assets)} assets)")
511
+ else:
512
+ release = self._fetch_release(
513
+ source,
514
+ repo_spec,
515
+ env_dir=env_dir,
516
+ upgrade=upgrade,
517
+ known_tag=known_tag,
518
+ asset_pattern=release_pattern,
519
+ pattern_name=release_pattern_name,
520
+ )
521
+ log.debug(f"release: {release.tag} ({len(release.assets)} assets)")
481
522
 
482
523
  if setup is None and not setup_resolved:
483
524
  setup = self._resolve_setup(
@@ -491,7 +532,10 @@ class BinaryBackend:
491
532
  if asset_pattern:
492
533
  log.debug(f"user-forced asset_pattern: {asset_pattern!r}")
493
534
  selection_pattern = _selection_pattern(effective_pattern, release.tag)
494
- asset = self._select_asset(release, selection_pattern, tool_name, spec)
535
+ if locked_binary is None:
536
+ asset = self._select_asset(release, selection_pattern, tool_name, spec)
537
+ else:
538
+ asset = release.assets[0]
495
539
  log.debug(f"selected asset: {asset.name}")
496
540
 
497
541
  archive_path = self._download_asset(
@@ -512,8 +556,16 @@ class BinaryBackend:
512
556
  log.debug(f"extracted to: {result.root}")
513
557
 
514
558
  heuristics = load_heuristics()
515
- executables = find_executables(result.root, expose=heuristics.expose, log=log)
559
+ rejected_executables: list[tuple[Path, str]] = []
560
+ executables = find_executables(
561
+ result.root,
562
+ expose=heuristics.expose,
563
+ log=log,
564
+ rejected=rejected_executables,
565
+ )
516
566
  log.debug(f"executables: {[e.name for e in executables]}")
567
+ if not executables and rejected_executables:
568
+ raise BinaryPlatformError(spec, release.tag, asset.name, rejected_executables)
517
569
 
518
570
  binaries = {exe.name: str(exe) for exe in executables}
519
571
 
@@ -33,16 +33,21 @@ if TYPE_CHECKING:
33
33
 
34
34
 
35
35
  def cmd_apply(args: argparse.Namespace) -> int:
36
- from ixt.core.apply import RemovalsRefused, apply_config
36
+ from ixt.core.apply import LockedApplyError, RemovalsRefused, apply_config
37
37
 
38
38
  logger = get_logger("apply")
39
39
  dry_run = getattr(args, "dry_run", False)
40
40
  yes = getattr(args, "yes", False)
41
+ locked = getattr(args, "locked", False)
42
+ strict = getattr(args, "strict", False)
41
43
 
42
44
  config_path = Path(args.config_file) if args.config_file else None
45
+ if strict and not locked:
46
+ logger.error("--strict requires --locked")
47
+ return 1
43
48
 
44
49
  if dry_run:
45
- return _cmd_apply_dry_run(config_path, args.remove, logger)
50
+ return _cmd_apply_dry_run(config_path, args.remove, logger, locked=locked, strict=strict)
46
51
 
47
52
  confirm: Callable[[list[str]], bool] | None = None
48
53
  if args.remove and not yes:
@@ -53,6 +58,8 @@ def cmd_apply(args: argparse.Namespace) -> int:
53
58
  result = apply_config(
54
59
  config_path=config_path,
55
60
  remove_unlisted=args.remove,
61
+ locked=locked,
62
+ strict=strict,
56
63
  confirm_removals=confirm,
57
64
  progress=_make_apply_progress(logger),
58
65
  )
@@ -63,6 +70,11 @@ def cmd_apply(args: argparse.Namespace) -> int:
63
70
  except RemovalsRefused:
64
71
  logger.info("aborted by user")
65
72
  return 0
73
+ except LockedApplyError as exc:
74
+ logger.error(str(exc))
75
+ if str(exc) == "No ixt.lock found":
76
+ logger.error(f" {dim('Hint: create one with ixt lock write')}")
77
+ return 1
66
78
 
67
79
  total = len(result.installed) + len(result.updated) + len(result.removed)
68
80
  if not total and not result.errors:
@@ -226,11 +238,21 @@ def _make_remove_confirmer(logger) -> Callable[[list[str]], bool]:
226
238
 
227
239
 
228
240
  def _load_apply_plan(
229
- config_path: Path | None, remove_unlisted: bool, logger
241
+ config_path: Path | None,
242
+ remove_unlisted: bool,
243
+ logger,
244
+ *,
245
+ locked: bool = False,
246
+ strict: bool = False,
230
247
  ) -> tuple[ApplyPlan, Path] | int:
231
248
  """Load the ixt.toml and build an ApplyPlan, or return an exit code on error."""
232
249
  from ixt.config.toml import find_config_file, load_config
233
- from ixt.core.apply import _get_installed_tools, _normalize_config_for_apply, plan_apply
250
+ from ixt.core.apply import (
251
+ LockedApplyError,
252
+ _get_installed_tools,
253
+ _prepare_config_for_apply,
254
+ plan_apply,
255
+ )
234
256
 
235
257
  path = config_path or find_config_file()
236
258
  if path is None:
@@ -246,7 +268,19 @@ def _load_apply_plan(
246
268
 
247
269
  settings = get_settings()
248
270
  installed = _get_installed_tools(settings)
249
- config = _normalize_config_for_apply(config, settings)
271
+ try:
272
+ config, _locked_tools = _prepare_config_for_apply(
273
+ config,
274
+ path,
275
+ settings,
276
+ locked=locked,
277
+ strict=strict,
278
+ )
279
+ except LockedApplyError as exc:
280
+ logger.error(str(exc))
281
+ if str(exc) == "No ixt.lock found":
282
+ logger.error(f" {dim('Hint: create one with ixt lock write')}")
283
+ return 1
250
284
  plan = plan_apply(config, installed, remove_unlisted=remove_unlisted)
251
285
  return plan, path
252
286
 
@@ -346,8 +380,21 @@ def _print_apply_summary(plan, logger) -> None:
346
380
  logger.info(f" {dim('total: ' + ', '.join(parts))}")
347
381
 
348
382
 
349
- def _cmd_apply_dry_run(config_path: Path | None, remove_unlisted: bool, logger) -> int:
350
- result = _load_apply_plan(config_path, remove_unlisted, logger)
383
+ def _cmd_apply_dry_run(
384
+ config_path: Path | None,
385
+ remove_unlisted: bool,
386
+ logger,
387
+ *,
388
+ locked: bool = False,
389
+ strict: bool = False,
390
+ ) -> int:
391
+ result = _load_apply_plan(
392
+ config_path,
393
+ remove_unlisted,
394
+ logger,
395
+ locked=locked,
396
+ strict=strict,
397
+ )
351
398
  if isinstance(result, int):
352
399
  return result
353
400
  plan, path = result
@@ -0,0 +1,89 @@
1
+ """``ixt lock`` command handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ixt.libs.logger import get_logger
8
+ from ixt.libs.output import data
9
+ from ixt.libs.style import dim
10
+
11
+
12
+ def cmd_lock(args: argparse.Namespace) -> int:
13
+ """Dispatch ``ixt lock`` subcommands."""
14
+
15
+ action = getattr(args, "lock_action", None)
16
+ if action == "write":
17
+ return _cmd_lock_write()
18
+ if action == "check":
19
+ return _cmd_lock_check()
20
+ if action == "diff":
21
+ return _cmd_lock_diff()
22
+ get_logger("lock").error("Specify a lock action: write, check, diff")
23
+ return 1
24
+
25
+
26
+ def _cmd_lock_write() -> int:
27
+ from ixt.core.lockfile import write_lockfile
28
+
29
+ logger = get_logger("lock")
30
+ try:
31
+ result = write_lockfile()
32
+ except FileNotFoundError:
33
+ logger.error("No ixt.toml found")
34
+ logger.error(f" {dim('Hint: create one with ixt tool export > ixt.toml')}")
35
+ return 1
36
+ except Exception as exc:
37
+ logger.error(f"Failed to write ixt.lock: {exc}")
38
+ return 1
39
+
40
+ logger.info(f"Wrote {result.path} ({len(result.lock.tools)} tool(s))")
41
+ return 0
42
+
43
+
44
+ def _cmd_lock_check() -> int:
45
+ from ixt.core.lockfile import check_lockfile
46
+
47
+ logger = get_logger("lock")
48
+ try:
49
+ result = check_lockfile()
50
+ except Exception as exc:
51
+ return _log_lock_check_error(exc)
52
+
53
+ if result.ok:
54
+ logger.info(f"{result.path} is up to date")
55
+ return 0
56
+
57
+ logger.error(f"{result.path} is out of date")
58
+ for difference in result.differences:
59
+ logger.error(f" - {difference.message}")
60
+ return 1
61
+
62
+
63
+ def _cmd_lock_diff() -> int:
64
+ from ixt.core.lockfile import check_lockfile
65
+
66
+ try:
67
+ result = check_lockfile()
68
+ except Exception as exc:
69
+ return _log_lock_check_error(exc)
70
+
71
+ if result.ok:
72
+ data("ixt.lock is up to date")
73
+ return 0
74
+
75
+ data("ixt.lock differs from ixt.toml:")
76
+ for difference in result.differences:
77
+ data(f"- {difference.message}")
78
+ return 1
79
+
80
+
81
+ def _log_lock_check_error(exc: Exception) -> int:
82
+ logger = get_logger("lock")
83
+ message = str(exc)
84
+ logger.error(message)
85
+ if message == "No ixt.toml found":
86
+ logger.error(f" {dim('Hint: create one with ixt tool export > ixt.toml')}")
87
+ elif message == "No ixt.lock found":
88
+ logger.error(f" {dim('Hint: create one with ixt lock write')}")
89
+ return 1
@@ -15,6 +15,7 @@ from ixt.cli.cmd_cache import cmd_cache
15
15
  from ixt.cli.cmd_config import cmd_config
16
16
  from ixt.cli.cmd_info import cmd_info, cmd_list
17
17
  from ixt.cli.cmd_install import _log_not_found_hint, cmd_add, cmd_install, cmd_uninstall
18
+ from ixt.cli.cmd_lock import cmd_lock
18
19
  from ixt.cli.cmd_misc import (
19
20
  cmd_doctor,
20
21
  cmd_environment,
@@ -39,6 +40,7 @@ __all__ = [
39
40
  "cmd_info",
40
41
  "cmd_install",
41
42
  "cmd_list",
43
+ "cmd_lock",
42
44
  "cmd_registry",
43
45
  "cmd_runtimes",
44
46
  "cmd_setup",
@@ -61,6 +63,7 @@ COMMANDS: dict[str, Callable] = {
61
63
  "where": cmd_where,
62
64
  "apply": cmd_apply,
63
65
  "export": cmd_export,
66
+ "lock": cmd_lock,
64
67
  "cache": cmd_cache,
65
68
  "registry": cmd_registry,
66
69
  "runtime": cmd_runtimes,
@@ -392,6 +392,16 @@ def build_parser() -> argparse.ArgumentParser:
392
392
  dest="dry_run",
393
393
  help="Show what would change without installing, reinstalling, or removing",
394
394
  )
395
+ p_apply.add_argument(
396
+ "--locked",
397
+ action="store_true",
398
+ help="Install exact versions and assets from ixt.lock",
399
+ )
400
+ p_apply.add_argument(
401
+ "--strict",
402
+ action="store_true",
403
+ help="With --locked, fail if ixt.toml and ixt.lock diverge",
404
+ )
395
405
 
396
406
  # -- tool export -----------------------------------------------------------
397
407
  p_export_tools = tool_sub.add_parser(
@@ -433,6 +443,24 @@ def build_parser() -> argparse.ArgumentParser:
433
443
  help="Generate the initial linux/macos x86_64/aarch64 platform matrix",
434
444
  )
435
445
 
446
+ # -- lock ------------------------------------------------------------------
447
+ p_lock = sub.add_parser("lock", help="Manage ixt.lock")
448
+ p_lock.set_defaults(command="lock")
449
+ lock_sub = p_lock.add_subparsers(dest="lock_action", metavar="ACTION", required=True)
450
+
451
+ lock_sub.add_parser(
452
+ "write",
453
+ help="Resolve the effective ixt.toml and write ixt.lock",
454
+ )
455
+ lock_sub.add_parser(
456
+ "check",
457
+ help="Check whether ixt.lock matches the effective ixt.toml",
458
+ )
459
+ lock_sub.add_parser(
460
+ "diff",
461
+ help="Show differences between ixt.lock and the effective ixt.toml",
462
+ )
463
+
436
464
  # -- cache -----------------------------------------------------------------
437
465
  p_cache = sub.add_parser("cache", help="Inspect and prune ixt caches")
438
466
  p_cache.set_defaults(command="cache")