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/cli/cmd_install.py ADDED
@@ -0,0 +1,508 @@
1
+ """``ixt tool add/install/uninstall`` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from ixt.cli.render import format_exposed_bins, id_suffix
9
+ from ixt.config.models import ToolRecord
10
+ from ixt.config.settings import get_settings
11
+ from ixt.core.backend import BackendType
12
+ from ixt.core.install import display_tool_name, resolve_tool_arg
13
+ from ixt.libs.constants import EXPOSE_MAIN
14
+ from ixt.libs.logger import get_logger
15
+ from ixt.libs.style import bold, cyan, dim, green, red, yellow
16
+
17
+
18
+ def _log_not_found_hint(logger, pkg: str) -> None:
19
+ """Context-aware hint when install fails with 404 / package not found.
20
+
21
+ A simple name (no ``/``, no ``@``) is auto-detected as PyPI. When that
22
+ lookup 404s, the user most likely meant a different backend — show the
23
+ three concrete alternatives instead of a generic "check the name" note.
24
+ """
25
+ is_simple = "/" not in pkg and "@" not in pkg
26
+ if is_simple:
27
+ header = f"Hint: '{pkg}' not found on PyPI. Try:"
28
+ logger.error(f" {dim(header)}")
29
+ logger.error(f" {dim('ixt tool install @npm:' + pkg)} {dim('(npm package)')}")
30
+ logger.error(f" {dim('ixt tool install owner/' + pkg)} {dim('(GitHub repo)')}")
31
+ logger.error(f" {dim('ixt tool install @gl:owner/' + pkg)} {dim('(GitLab repo)')}")
32
+ else:
33
+ logger.error(
34
+ f" {dim('Hint: check the package name.')}"
35
+ f" {dim('Use owner/repo for GitHub, @scope/pkg for npm')}"
36
+ )
37
+
38
+
39
+ def cmd_install(args: argparse.Namespace) -> int:
40
+ logger = get_logger("install")
41
+ dry_run = getattr(args, "dry_run", False)
42
+ from_path = getattr(args, "from_path", None)
43
+
44
+ if args.package and from_path:
45
+ logger.error("--from is mutually exclusive with the positional package spec.")
46
+ return 1
47
+ if not args.package and not from_path:
48
+ logger.error("either a package spec or --from PATH is required.")
49
+ return 1
50
+
51
+ if not from_path:
52
+ gate = _check_setup_file_gate(args, logger)
53
+ if gate is not None:
54
+ return gate
55
+
56
+ if dry_run:
57
+ if from_path:
58
+ return _cmd_install_from_local_dry_run(args, from_path, logger)
59
+ return _cmd_install_dry_run(args, logger)
60
+
61
+ if from_path:
62
+ return _cmd_install_from_local(args, from_path, logger)
63
+
64
+ from ixt.core.backend import BackendType, detect_backend
65
+ from ixt.core.install import ToolAlreadyInstalledError, install_tool
66
+
67
+ setup_path = Path(args.setup) if getattr(args, "setup", None) else None
68
+ asset_pattern = getattr(args, "asset_pattern", None)
69
+ if asset_pattern:
70
+ try:
71
+ bt = detect_backend(args.package)
72
+ except Exception:
73
+ bt = None
74
+ if bt is not None and bt != BackendType.BINARY:
75
+ logger.warn(
76
+ f"--asset-pattern ignored: only applies to the binary backend (detected {bt.value})"
77
+ )
78
+ asset_pattern = None
79
+
80
+ runtime_arg, node_shim = _resolve_runtime(getattr(args, "runtime", "bun"))
81
+
82
+ try:
83
+ record = install_tool(
84
+ args.package,
85
+ slot=args.slot,
86
+ force=args.reinstall,
87
+ backend_type=None,
88
+ runtime=runtime_arg,
89
+ setup_path=setup_path,
90
+ node_shim=node_shim,
91
+ asset_pattern=asset_pattern,
92
+ bare=args.bare,
93
+ )
94
+ except ToolAlreadyInstalledError as e:
95
+ logger.error(f"{e}")
96
+ logger.error(
97
+ f" {dim('Hint: use --reinstall to replace it, or ixt tool upgrade ' + args.package)}"
98
+ )
99
+ return 1
100
+ except ValueError as e:
101
+ logger.error(str(e))
102
+ return 1
103
+ except Exception as e:
104
+ return _handle_install_error(e, args, logger)
105
+
106
+ settings = get_settings()
107
+ version = f" {dim(record.version)}" if record.version else ""
108
+ logger.info(
109
+ f" {green('+')} {bold(display_tool_name(record, settings))}{version} "
110
+ f"{id_suffix(record.name)}"
111
+ )
112
+ logger.info(f" exposed: {format_exposed_bins(record.exposed_bins)}")
113
+
114
+ if args.save:
115
+ _save_install_to_toml(args, record, logger)
116
+
117
+ return 0
118
+
119
+
120
+ def cmd_add(args: argparse.Namespace) -> int:
121
+ """Persist a tool in ixt.toml without installing it."""
122
+ from ixt.core.install import plan_install
123
+ from ixt.core.save import (
124
+ extract_version_from_spec,
125
+ save_tool,
126
+ tool_entry_key,
127
+ tool_install_spec,
128
+ )
129
+
130
+ logger = get_logger("add")
131
+ try:
132
+ plan = plan_install(args.package, slot=args.slot, bare=args.bare)
133
+ except ValueError as e:
134
+ logger.error(str(e))
135
+ return 1
136
+
137
+ asset_pattern = getattr(args, "asset_pattern", None)
138
+ if asset_pattern and plan.backend != BackendType.BINARY:
139
+ logger.warn(
140
+ f"--asset-pattern ignored: only applies to the binary backend "
141
+ f"(detected {plan.backend.value})"
142
+ )
143
+ asset_pattern = None
144
+
145
+ runtime_arg, node_shim = _resolve_runtime(getattr(args, "runtime", None))
146
+ runtime_out = None
147
+ if plan.backend == BackendType.NODE:
148
+ runtime_out = "node" if runtime_arg == "node" else None
149
+ else:
150
+ node_shim = None
151
+
152
+ entry_key = tool_entry_key(plan.tool_name, plan.resolved_spec, plan.backend)
153
+ entry_install = None
154
+ if args.slot is not None:
155
+ entry_key = args.slot
156
+ entry_install = tool_install_spec(plan.resolved_spec, plan.backend)
157
+
158
+ path = save_tool(
159
+ entry_key,
160
+ install=entry_install,
161
+ version=extract_version_from_spec(plan.resolved_spec, plan.backend),
162
+ expose=[] if args.bare else None,
163
+ node_shim=node_shim,
164
+ runtime=runtime_out,
165
+ asset_pattern=asset_pattern,
166
+ )
167
+ logger.info(f" {green('+')} {bold(entry_key)} {dim('saved to ' + str(path))}")
168
+ return 0
169
+
170
+
171
+ def _cmd_install_from_local(args: argparse.Namespace, from_path: str, logger) -> int:
172
+ """Handle ``ixt tool install --from <path>`` (bypass spec/registry resolution)."""
173
+ from ixt.core.install import ToolAlreadyInstalledError
174
+ from ixt.core.install_local import install_from_local
175
+
176
+ try:
177
+ record = install_from_local(
178
+ Path(from_path),
179
+ slot=args.slot,
180
+ force=args.reinstall,
181
+ )
182
+ except ToolAlreadyInstalledError as e:
183
+ logger.error(f"{e}")
184
+ logger.error(f" {dim('Hint: use --reinstall to replace it.')}")
185
+ return 1
186
+ except ValueError as e:
187
+ logger.error(str(e))
188
+ return 1
189
+
190
+ version = f" {dim(record.version)}" if record.version else ""
191
+ logger.info(
192
+ f" {green('+')} {bold(display_tool_name(record))}{version} "
193
+ f"{id_suffix(record.name)} {dim('[local]')}"
194
+ )
195
+ logger.info(f" exposed: {format_exposed_bins(record.exposed_bins)}")
196
+ return 0
197
+
198
+
199
+ def _cmd_install_from_local_dry_run(args: argparse.Namespace, from_path: str, logger) -> int:
200
+ """Resolve a local install plan, print it, and exit without mutating."""
201
+ from ixt.core.install_local import plan_install_from_local
202
+
203
+ try:
204
+ plan = plan_install_from_local(Path(from_path), slot=args.slot)
205
+ except ValueError as e:
206
+ logger.error(str(e))
207
+ return 1
208
+
209
+ logger.info(f"{dim('[dry-run]')} would install from local path:")
210
+ logger.info(
211
+ f" {bold(str(plan.source_path))} {dim('[' + plan.backend.value + ']')} "
212
+ f"{id_suffix(plan.tool_name)}"
213
+ )
214
+ logger.info(f" source: {plan.source_path}")
215
+ logger.info(f" package: {plan.pkg_name}")
216
+ logger.info(f" env: {dim(str(plan.env_dir))}")
217
+ expose_display = ", ".join(plan.expose_rules) if plan.expose_rules else dim("none")
218
+ logger.info(f" would expose: {expose_display}")
219
+ if plan.already_installed:
220
+ if args.reinstall:
221
+ logger.info(f" {yellow('note:')} env exists, would be reinstalled")
222
+ else:
223
+ logger.error(f" {red('note:')} env exists, would fail without --reinstall")
224
+ return 1
225
+ return 0
226
+
227
+
228
+ _RUNTIME_MAP: dict[str, tuple[str | None, bool | None]] = {
229
+ "bun": ("bun", True), # default — rewrite #!node shebangs to bun
230
+ "bun-strict": ("bun", False), # bun runtime, shebangs untouched (host needs node)
231
+ "node": ("node", None), # node runtime — shim flag irrelevant
232
+ }
233
+
234
+
235
+ def _resolve_runtime(choice: str | None) -> tuple[str | None, bool | None]:
236
+ """Return ``(runtime, node_shim)`` for the --runtime CLI choice."""
237
+ return _RUNTIME_MAP.get(choice or "", (choice, None))
238
+
239
+
240
+ def _check_setup_file_gate(args: argparse.Namespace, logger) -> int | None:
241
+ """Guard: --setup-file is gated behind IXT_ENABLE_SETUP_TOML (A/B test v0.1.0)."""
242
+ if not getattr(args, "setup", None):
243
+ return None
244
+ from ixt.config.flags import SETUP_TOML_ENV, is_setup_toml_enabled
245
+
246
+ if is_setup_toml_enabled():
247
+ return None
248
+ logger.error("ixt.setup.toml support is disabled in v0.1.0 (A/B test).")
249
+ logger.error(f" {dim(f'Set {SETUP_TOML_ENV}=1 to enable --setup-file and remote fetch.')}")
250
+ return 1
251
+
252
+
253
+ def _cmd_install_dry_run(args: argparse.Namespace, logger) -> int:
254
+ """Resolve the install plan, print it, and exit without mutating."""
255
+ from ixt.core.install import plan_install
256
+
257
+ setup_path = Path(args.setup) if getattr(args, "setup", None) else None
258
+ try:
259
+ plan = plan_install(
260
+ args.package,
261
+ slot=args.slot,
262
+ setup_path=setup_path,
263
+ bare=args.bare,
264
+ )
265
+ except ValueError as e:
266
+ logger.error(str(e))
267
+ return 1
268
+
269
+ logger.info(f"{dim('[dry-run]')} would install:")
270
+ logger.info(
271
+ f" {bold(plan.resolved_spec)} {dim('[' + plan.backend.value + ']')} "
272
+ f"{id_suffix(plan.tool_name)}"
273
+ )
274
+ logger.info(f" spec: {plan.resolved_spec}")
275
+ logger.info(f" env: {dim(str(plan.env_dir))}")
276
+ expose_display = ", ".join(plan.expose_rules) if plan.expose_rules else dim("none")
277
+ logger.info(f" would expose: {expose_display}")
278
+ if plan.inject:
279
+ logger.info(f" inject: {', '.join(cyan(p) for p in plan.inject)}")
280
+ if plan.already_installed:
281
+ if args.reinstall:
282
+ logger.info(f" {yellow('note:')} env exists, would be reinstalled")
283
+ else:
284
+ logger.error(f" {red('note:')} env exists, would fail without --reinstall")
285
+ return 1
286
+ if plan.backend != BackendType.PYTHON:
287
+ logger.info(f" {dim('note: version resolved at install time (network)')}")
288
+ return 0
289
+
290
+
291
+ def _handle_install_error(exc: Exception, args: argparse.Namespace, logger) -> int:
292
+ """Map an install_tool exception to a user-facing error with actionable hint."""
293
+ import os
294
+
295
+ from ixt.net.http import NotFoundError, RateLimitError
296
+
297
+ err_lower = str(exc).lower()
298
+ body = getattr(exc, "body", "") or ""
299
+ is_rate_limit = (
300
+ isinstance(exc, RateLimitError) or "rate limit" in err_lower or "rate limit" in body.lower()
301
+ )
302
+ is_not_found = isinstance(exc, NotFoundError) or "404" in str(exc) or "not found" in err_lower
303
+ is_no_asset = "No compatible asset" in str(exc) or "No release matching" in str(exc)
304
+ is_unauthorized = "401" in str(exc) or "unauthorized" in err_lower
305
+
306
+ if is_rate_limit:
307
+ logger.error(f"Failed to install '{args.package}': GitHub/GitLab API rate limit reached")
308
+ logger.error(f" {dim('Hint: export GITHUB_TOKEN=$(gh auth token) (or GITLAB_TOKEN)')}")
309
+ elif is_unauthorized:
310
+ # 401 on a public release endpoint almost always means a stale token
311
+ # is being sent. Name the offender so the user knows where to look.
312
+ which = []
313
+ if "gitlab" in str(exc).lower() and os.environ.get("GITLAB_TOKEN"):
314
+ which.append("GITLAB_TOKEN")
315
+ if "github" in str(exc).lower() and os.environ.get("GITHUB_TOKEN"):
316
+ which.append("GITHUB_TOKEN")
317
+ if not which:
318
+ for var in ("GITLAB_TOKEN", "GITHUB_TOKEN"):
319
+ if os.environ.get(var):
320
+ which.append(var)
321
+ logger.error(f"Failed to install '{args.package}': 401 Unauthorized")
322
+ if which:
323
+ tokens = " / ".join(which)
324
+ msg = f"Hint: {tokens} is set but rejected — likely expired or revoked."
325
+ logger.error(f" {dim(msg)}")
326
+ logger.error(f" {dim(f' Try: unset {which[0]} (or rotate the token)')}")
327
+ else:
328
+ logger.error(f" {dim('Hint: API rejected — check the project URL is public.')}")
329
+ elif is_not_found:
330
+ logger.error(f"Failed to install '{args.package}': not found")
331
+ _log_not_found_hint(logger, args.package)
332
+ elif is_no_asset:
333
+ logger.error(f"Failed to install '{args.package}': {exc}")
334
+ logger.error(f" {dim('Hint: prefix the spec to force a backend (e.g. @gh:owner/repo)')}")
335
+ else:
336
+ logger.error(f"Failed to install '{args.package}': {exc}")
337
+
338
+ logger.debug(f"raw error: {type(exc).__name__}: {exc}")
339
+ if body:
340
+ logger.debug(f"response body: {body}")
341
+ return 1
342
+
343
+
344
+ def _save_install_to_toml(args: argparse.Namespace, record: ToolRecord, logger) -> None:
345
+ """Persist the just-installed tool into ixt.toml."""
346
+ from ixt.core.save import (
347
+ extract_version_from_spec,
348
+ save_tool,
349
+ tool_entry_key,
350
+ tool_install_spec,
351
+ )
352
+
353
+ bt = BackendType(record.backend)
354
+ entry_key = tool_entry_key(record.name, record.spec, bt)
355
+ entry_install = None
356
+ entry_slot = _saved_entry_slot(entry_key, record)
357
+ if entry_slot is not None:
358
+ entry_key = entry_slot
359
+ entry_install = tool_install_spec(record.spec, bt)
360
+ # Only persist ``expose`` when it differs from the implicit default
361
+ # (``[__main__]``) — a bare install writes ``expose = []`` so apply can
362
+ # reproduce it faithfully.
363
+ expose_out: list[str] | None
364
+ if record.expose_rules == [EXPOSE_MAIN]:
365
+ expose_out = None
366
+ else:
367
+ expose_out = list(record.expose_rules)
368
+ save_tool(
369
+ entry_key,
370
+ install=entry_install,
371
+ version=extract_version_from_spec(args.package, bt),
372
+ expose=expose_out,
373
+ node_shim=record.node_shim,
374
+ )
375
+ logger.info(f" {dim('saved to ixt.toml')}")
376
+
377
+
378
+ def _saved_entry_slot(entry_key: str, record: ToolRecord) -> str | None:
379
+ from ixt.core.identity import slot_from_id
380
+ from ixt.core.install import plan_install
381
+
382
+ base_id = plan_install(entry_key, settings=get_settings()).tool_name
383
+ return slot_from_id(record.name, base_id)
384
+
385
+
386
+ def cmd_uninstall(args: argparse.Namespace) -> int:
387
+ logger = get_logger("uninstall")
388
+ dry_run = getattr(args, "dry_run", False)
389
+ yes = getattr(args, "yes", False)
390
+ settings = get_settings()
391
+
392
+ if args.uninstall_all:
393
+ return _cmd_uninstall_all(logger, settings, dry_run=dry_run, yes=yes)
394
+ return _cmd_uninstall_one(args, logger, settings, dry_run=dry_run)
395
+
396
+
397
+ def _cmd_uninstall_all(logger, settings, *, dry_run: bool, yes: bool) -> int:
398
+ meta_files = settings.iter_installed_metadata()
399
+ if not meta_files:
400
+ logger.info(f"{dim('[dry-run]')} no tools installed" if dry_run else "No tools installed")
401
+ return 0
402
+ records = [ToolRecord.load_json(mf) for mf in meta_files]
403
+ records_by_id = {record.name: record for record in records}
404
+
405
+ if dry_run:
406
+ logger.info(f"{dim('[dry-run]')} would remove {len(meta_files)} tool(s):")
407
+ for record in records:
408
+ bins = ", ".join(sorted(record.exposed_bins)) or dim("no bins")
409
+ display = bold(display_tool_name(record, settings))
410
+ logger.info(f" {red('-')} {display} {dim('bins:')} {bins}")
411
+ return 0
412
+
413
+ if not yes:
414
+ names = [display_tool_name(record, settings) for record in records]
415
+ confirmed = _confirm_uninstall_all(logger, names)
416
+ if confirmed is None:
417
+ return 1
418
+ if not confirmed:
419
+ logger.info("aborted by user")
420
+ return 0
421
+
422
+ from ixt.core.install import uninstall_all
423
+
424
+ results = uninstall_all(settings=settings)
425
+
426
+ errors = 0
427
+ for name, result in results.items():
428
+ display = (
429
+ display_tool_name(records_by_id[name], settings) if name in records_by_id else name
430
+ )
431
+ if isinstance(result, Exception):
432
+ logger.error(f" {red('x')} {display}: {result}")
433
+ errors += 1
434
+ else:
435
+ logger.info(f" {red('-')} {bold(display)}")
436
+ return 1 if errors else 0
437
+
438
+
439
+ def _confirm_uninstall_all(logger, names: list[str]) -> bool | None:
440
+ """Prompt before deleting every installed tool."""
441
+ import sys
442
+
443
+ summary = f"About to remove {len(names)} tool(s): {', '.join(sorted(names))}"
444
+ if not sys.stdin.isatty():
445
+ logger.error(summary)
446
+ logger.error(f" {dim('non-interactive session; pass --yes to confirm')}")
447
+ return None
448
+ print(summary, file=sys.stderr)
449
+ try:
450
+ answer = input("Proceed? [y/N] ").strip().lower()
451
+ except (EOFError, KeyboardInterrupt):
452
+ print("", file=sys.stderr)
453
+ return False
454
+ return answer in ("y", "yes")
455
+
456
+
457
+ def _cmd_uninstall_one(args: argparse.Namespace, logger, settings, *, dry_run: bool) -> int:
458
+ if not args.target:
459
+ logger.error("Specify a tool name or use --all")
460
+ return 1
461
+
462
+ from ixt.core.install import uninstall_tool
463
+
464
+ try:
465
+ tool_name = resolve_tool_arg(args.target, settings=settings)
466
+ except ValueError as e:
467
+ logger.error(str(e))
468
+ return 1
469
+
470
+ meta_file = settings.get_tool_metadata_file(tool_name)
471
+ env_dir = settings.get_tool_env_dir(tool_name)
472
+ record = ToolRecord.load_json(meta_file) if meta_file.exists() else None
473
+ display = display_tool_name(record, settings) if record else tool_name
474
+
475
+ if dry_run:
476
+ if not env_dir.exists():
477
+ logger.error(f"Tool '{args.target}' is not installed")
478
+ logger.error(f" {dim('Hint: run ixt tool list to see installed tools')}")
479
+ return 1
480
+ logger.info(f"{dim('[dry-run]')} would remove:")
481
+ logger.info(f" {red('-')} {bold(display)}")
482
+ logger.info(f" env: {dim(str(env_dir))}")
483
+ if record:
484
+ if record.exposed_bins:
485
+ logger.info(" unexpose:")
486
+ for name, source in sorted(record.exposed_bins.items()):
487
+ logger.info(f" {cyan(name)} {dim('->')} {dim(source)}")
488
+ else:
489
+ logger.info(f" unexpose: {dim('none')}")
490
+ if args.save:
491
+ logger.info(f" {dim('would remove from ixt.toml')}")
492
+ return 0
493
+
494
+ try:
495
+ uninstall_tool(tool_name)
496
+ except FileNotFoundError:
497
+ logger.error(f"Tool '{args.target}' is not installed")
498
+ logger.error(f" {dim('Hint: run ixt tool list to see installed tools')}")
499
+ return 1
500
+
501
+ logger.info(f" {red('-')} {bold(display)}")
502
+
503
+ if args.save:
504
+ from ixt.core.save import remove_tool
505
+
506
+ remove_tool(tool_name)
507
+ logger.info(f" {dim('removed from ixt.toml')}")
508
+ return 0