hud-python 0.4.1__py3-none-any.whl → 0.4.3__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.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/cli/pull.py CHANGED
@@ -1,336 +1,330 @@
1
- """Pull HUD environments from registry."""
2
-
3
- from __future__ import annotations
4
-
5
- import subprocess
6
- from pathlib import Path
7
-
8
- import click
9
- import requests
10
- import typer
11
- import yaml
12
- from rich.console import Console
13
- from rich.table import Table
14
-
15
- from hud.settings import settings
16
- from hud.utils.design import HUDDesign
17
-
18
- console = Console()
19
-
20
-
21
- def get_docker_manifest(image: str) -> dict | None:
22
- """Get manifest from Docker registry without pulling the image."""
23
- try:
24
- # Try docker manifest inspect (requires experimental features)
25
- result = subprocess.run( # noqa: S603
26
- ["docker", "manifest", "inspect", image], # noqa: S607
27
- capture_output=True,
28
- text=True,
29
- )
30
- if result.returncode == 0:
31
- import json
32
-
33
- return json.loads(result.stdout)
34
- except Exception:
35
- click.echo("Failed to get Docker manifest", err=True)
36
- return None
37
-
38
-
39
- def get_image_size_from_manifest(manifest: dict) -> int | None:
40
- """Extract total image size from Docker manifest."""
41
- try:
42
- total_size = 0
43
-
44
- # Handle different manifest formats
45
- if "layers" in manifest:
46
- # v2 manifest
47
- for layer in manifest["layers"]:
48
- total_size += layer.get("size", 0)
49
- elif "manifests" in manifest:
50
- first_manifest = manifest["manifests"][0]
51
- total_size = first_manifest.get("size", 0)
52
-
53
- return total_size if total_size > 0 else None
54
- except Exception:
55
- click.echo("Failed to get image size from manifest", err=True)
56
- return None
57
-
58
-
59
- def fetch_lock_from_registry(reference: str) -> dict | None:
60
- """Fetch lock file from HUD registry."""
61
- try:
62
- # Reference should be org/name:tag format
63
- # If no tag specified, append :latest
64
- if "/" in reference and ":" not in reference:
65
- reference = f"{reference}:latest"
66
-
67
- registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{reference}"
68
-
69
- headers = {}
70
- if settings.api_key:
71
- headers["Authorization"] = f"Bearer {settings.api_key}"
72
-
73
- response = requests.get(registry_url, headers=headers, timeout=10)
74
-
75
- if response.status_code == 200:
76
- data = response.json()
77
- # Parse the lock YAML from the response
78
- if "lock" in data:
79
- return yaml.safe_load(data["lock"])
80
- elif "lock_data" in data:
81
- return data["lock_data"]
82
- else:
83
- # Try to treat the whole response as lock data
84
- return data
85
-
86
- return None
87
- except Exception:
88
- return None
89
-
90
-
91
- def format_size(size_bytes: int) -> str:
92
- """Format bytes to human readable size."""
93
- for unit in ["B", "KB", "MB", "GB"]:
94
- if size_bytes < 1024:
95
- return f"{size_bytes:.1f} {unit}"
96
- size_bytes //= 1024
97
- return f"{size_bytes:.1f} TB"
98
-
99
-
100
- def pull_environment(
101
- target: str,
102
- lock_file: str | None = None,
103
- yes: bool = False,
104
- verify_only: bool = False,
105
- verbose: bool = False,
106
- ) -> None:
107
- """Pull HUD environment from registry."""
108
- design = HUDDesign()
109
- design.header("HUD Environment Pull")
110
-
111
- # Two modes:
112
- # 1. Pull from lock file (recommended)
113
- # 2. Pull from image reference directly
114
-
115
- lock_data = None
116
- image_ref = target
117
-
118
- # Mode 1: Lock file provided
119
- if lock_file or target.endswith((".yaml", ".yml")):
120
- # If target looks like a lock file, use it
121
- if target.endswith((".yaml", ".yml")):
122
- lock_file = target
123
-
124
- lock_path = Path(lock_file) if lock_file else None
125
- if lock_path and not lock_path.exists():
126
- design.error(f"Lock file not found: {lock_file}")
127
- raise typer.Exit(1)
128
-
129
- design.info(f"Reading lock file: {lock_file}")
130
- if lock_path:
131
- with open(lock_path) as f:
132
- lock_data = yaml.safe_load(f)
133
-
134
- image_ref = lock_data.get("image", "") if lock_data else ""
135
-
136
- # Mode 2: Direct image reference
137
- else:
138
- # First, try to parse as org/env reference for HUD registry
139
- # Check if it's a simple org/name or org/name:tag format (no @sha256)
140
- if "/" in target and "@" not in target:
141
- # Looks like org/env reference, possibly with tag
142
- design.info(f"Checking HUD registry for: {target}")
143
-
144
- # Check for API key (not required for pulling, but good to inform)
145
- if not settings.api_key:
146
- design.info("No HUD API key set (pulling from public registry)")
147
-
148
- lock_data = fetch_lock_from_registry(target)
149
-
150
- if lock_data:
151
- design.success("Found in HUD registry")
152
- image_ref = lock_data.get("image", "")
153
- else:
154
- # Fall back to treating as Docker image
155
- if not settings.api_key:
156
- design.info(
157
- "Not found in HUD registry (try setting HUD_API_KEY for private environments)" # noqa: E501
158
- )
159
- else:
160
- design.info("Not found in HUD registry, treating as Docker image")
161
-
162
- # Try to get metadata from Docker registry
163
- if not lock_data:
164
- design.info(f"Fetching Docker metadata for: {image_ref}")
165
- manifest = get_docker_manifest(image_ref)
166
-
167
- if manifest:
168
- # Create minimal lock data from manifest
169
- lock_data = {"image": image_ref, "source": "docker-manifest"}
170
-
171
- # Try to get size
172
- size = get_image_size_from_manifest(manifest)
173
- if size:
174
- lock_data["size"] = format_size(size)
175
-
176
- if verbose:
177
- design.info(
178
- f"Retrieved manifest (type: {manifest.get('mediaType', 'unknown')})"
179
- )
180
-
181
- # Display environment summary
182
- design.section_title("Environment Details")
183
-
184
- # Create summary table
185
- table = Table(show_header=False, box=None)
186
- table.add_column("Property", style="cyan")
187
- table.add_column("Value")
188
-
189
- # Image info - show simple name in table
190
- display_ref = image_ref.split("@")[0] if ":" in image_ref and "@" in image_ref else image_ref
191
- table.add_row("Image", display_ref)
192
-
193
- if lock_data:
194
- # Show size if available
195
- if "size" in lock_data:
196
- table.add_row("Size", lock_data["size"])
197
-
198
- # Check if this is full lock data or minimal manifest data
199
- if lock_data.get("source") == "docker-manifest":
200
- # Minimal data from Docker manifest
201
- table.add_row("Source", "Docker Registry")
202
- if not yes:
203
- console.print(
204
- "\n[yellow]Note:[/yellow] Limited metadata available from Docker registry."
205
- )
206
- console.print("For full environment details, use a lock file.\n")
207
- else:
208
- # Full lock file data
209
- if "build" in lock_data:
210
- table.add_row("Built", lock_data["build"].get("generatedAt", "Unknown"))
211
- table.add_row("HUD Version", lock_data["build"].get("hudVersion", "Unknown"))
212
-
213
- if "environment" in lock_data:
214
- env_data = lock_data["environment"]
215
- table.add_row("Tools", str(env_data.get("toolCount", "Unknown")))
216
- table.add_row("Init Time", f"{env_data.get('initializeMs', 'Unknown')} ms")
217
-
218
- if "push" in lock_data:
219
- push_data = lock_data["push"]
220
- table.add_row("Registry", push_data.get("registry", "Unknown"))
221
- table.add_row("Pushed", push_data.get("pushedAt", "Unknown"))
222
-
223
- # Environment variables
224
- env_section = lock_data.get("environment", {})
225
- if "variables" in env_section:
226
- vars_data = env_section["variables"]
227
- if vars_data.get("required"):
228
- table.add_row("Required Env", ", ".join(vars_data["required"]))
229
- if vars_data.get("optional"):
230
- table.add_row("Optional Env", ", ".join(vars_data["optional"]))
231
-
232
- else:
233
- # No metadata available
234
- table.add_row("Source", "Unknown")
235
-
236
- console.print(table)
237
-
238
- # Tool summary (show after table)
239
- if lock_data and "tools" in lock_data:
240
- console.print("\n[bold]Available Tools:[/bold]")
241
- for tool in lock_data["tools"]:
242
- console.print(f" • {tool['name']}: {tool['description']}")
243
-
244
- # Show warnings if no metadata
245
- if not lock_data and not yes:
246
- console.print("\n[yellow]Warning:[/yellow] No metadata available for this image.")
247
- console.print("The image will be pulled without verification.")
248
-
249
- # If verify only, stop here
250
- if verify_only:
251
- design.success("Verification complete")
252
- return
253
-
254
- # Ask for confirmation unless --yes
255
- if not yes:
256
- console.print()
257
- # Show simple name for confirmation, not the full digest
258
- if ":" in image_ref and "@" in image_ref:
259
- simple_name = image_ref.split("@")[0]
260
- else:
261
- simple_name = image_ref
262
- if not typer.confirm(f"Pull {simple_name}?"):
263
- design.info("Aborted")
264
- raise typer.Exit(0)
265
-
266
- # Pull the image
267
- design.progress_message(f"Pulling {image_ref}...")
268
-
269
- # Run docker pull with progress
270
- process = subprocess.Popen( # noqa: S603
271
- ["docker", "pull", image_ref], # noqa: S607
272
- stdout=subprocess.PIPE,
273
- stderr=subprocess.STDOUT,
274
- text=True,
275
- encoding="utf-8",
276
- errors="replace",
277
- )
278
-
279
- for line in process.stdout or []:
280
- if verbose or "Downloading" in line or "Extracting" in line or "Pull complete" in line:
281
- click.echo(line.rstrip(), err=True)
282
-
283
- process.wait()
284
-
285
- if process.returncode != 0:
286
- design.error("Pull failed")
287
- raise typer.Exit(1)
288
-
289
- # Store lock file locally if we have full lock data (not minimal manifest data)
290
- if lock_data and lock_data.get("source") != "docker-manifest":
291
- # Extract digest from image ref
292
- digest = image_ref.split("@sha256:")[-1][:12] if "@sha256:" in image_ref else "latest"
293
-
294
- # Store under ~/.hud/envs/<digest>/
295
- local_env_dir = Path.home() / ".hud" / "envs" / digest
296
- local_env_dir.mkdir(parents=True, exist_ok=True)
297
-
298
- local_lock_path = local_env_dir / "hud.lock.yaml"
299
- with open(local_lock_path, "w") as f:
300
- yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
301
-
302
- if verbose:
303
- design.info(f"Stored lock file: {local_lock_path}")
304
-
305
- # Success!
306
- design.success("Pull complete!")
307
-
308
- # Show usage
309
- design.section_title("Next Steps")
310
-
311
- # Extract simple name for examples
312
- simple_ref = image_ref.split("@")[0] if ":" in image_ref and "@" in image_ref else image_ref
313
-
314
- console.print("1. Quick analysis (from metadata):")
315
- console.print(f" [cyan]hud analyze {simple_ref}[/cyan]\n")
316
-
317
- console.print("2. Live analysis (runs container):")
318
- console.print(f" [cyan]hud analyze {simple_ref} --live[/cyan]\n")
319
-
320
- console.print("3. Run the environment:")
321
- console.print(f" [cyan]hud run {simple_ref}[/cyan]")
322
-
323
-
324
- def pull_command(
325
- target: str = typer.Argument(..., help="Image reference or lock file to pull"),
326
- lock_file: str | None = typer.Option(
327
- None, "--lock", "-l", help="Path to lock file (if target is image ref)"
328
- ),
329
- yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
330
- verify_only: bool = typer.Option(
331
- False, "--verify-only", help="Only verify metadata without pulling"
332
- ),
333
- verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
334
- ) -> None:
335
- """Pull HUD environment from registry with metadata preview."""
336
- pull_environment(target, lock_file, yes, verify_only, verbose)
1
+ """Pull HUD environments from registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import requests
10
+ import typer
11
+ import yaml
12
+ from rich.table import Table
13
+
14
+ from hud.settings import settings
15
+ from hud.utils.design import HUDDesign
16
+
17
+
18
+ def get_docker_manifest(image: str) -> dict | None:
19
+ """Get manifest from Docker registry without pulling the image."""
20
+ try:
21
+ # Try docker manifest inspect (requires experimental features)
22
+ result = subprocess.run( # noqa: S603
23
+ ["docker", "manifest", "inspect", image], # noqa: S607
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+ if result.returncode == 0:
28
+ import json
29
+
30
+ return json.loads(result.stdout)
31
+ except Exception:
32
+ return None
33
+
34
+
35
+ def get_image_size_from_manifest(manifest: dict) -> int | None:
36
+ """Extract total image size from Docker manifest."""
37
+ try:
38
+ total_size = 0
39
+
40
+ # Handle different manifest formats
41
+ if "layers" in manifest:
42
+ # v2 manifest
43
+ for layer in manifest["layers"]:
44
+ total_size += layer.get("size", 0)
45
+ elif "manifests" in manifest:
46
+ first_manifest = manifest["manifests"][0]
47
+ total_size = first_manifest.get("size", 0)
48
+
49
+ return total_size if total_size > 0 else None
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def fetch_lock_from_registry(reference: str) -> dict | None:
55
+ """Fetch lock file from HUD registry."""
56
+ try:
57
+ # Reference should be org/name:tag format
58
+ # If no tag specified, append :latest
59
+ if "/" in reference and ":" not in reference:
60
+ reference = f"{reference}:latest"
61
+
62
+ registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{reference}"
63
+
64
+ headers = {}
65
+ if settings.api_key:
66
+ headers["Authorization"] = f"Bearer {settings.api_key}"
67
+
68
+ response = requests.get(registry_url, headers=headers, timeout=10)
69
+
70
+ if response.status_code == 200:
71
+ data = response.json()
72
+ # Parse the lock YAML from the response
73
+ if "lock" in data:
74
+ return yaml.safe_load(data["lock"])
75
+ elif "lock_data" in data:
76
+ return data["lock_data"]
77
+ else:
78
+ # Try to treat the whole response as lock data
79
+ return data
80
+
81
+ return None
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def format_size(size_bytes: int) -> str:
87
+ """Format bytes to human readable size."""
88
+ for unit in ["B", "KB", "MB", "GB"]:
89
+ if size_bytes < 1024:
90
+ return f"{size_bytes:.1f} {unit}"
91
+ size_bytes //= 1024
92
+ return f"{size_bytes:.1f} TB"
93
+
94
+
95
+ def pull_environment(
96
+ target: str,
97
+ lock_file: str | None = None,
98
+ yes: bool = False,
99
+ verify_only: bool = False,
100
+ verbose: bool = False,
101
+ ) -> None:
102
+ """Pull HUD environment from registry."""
103
+ design = HUDDesign()
104
+ design.header("HUD Environment Pull")
105
+
106
+ # Two modes:
107
+ # 1. Pull from lock file (recommended)
108
+ # 2. Pull from image reference directly
109
+
110
+ lock_data = None
111
+ image_ref = target
112
+
113
+ # Mode 1: Lock file provided
114
+ if lock_file or target.endswith((".yaml", ".yml")):
115
+ # If target looks like a lock file, use it
116
+ if target.endswith((".yaml", ".yml")):
117
+ lock_file = target
118
+
119
+ lock_path = Path(lock_file) if lock_file else None
120
+ if lock_path and not lock_path.exists():
121
+ design.error(f"Lock file not found: {lock_file}")
122
+ raise typer.Exit(1)
123
+
124
+ design.info(f"Reading lock file: {lock_file}")
125
+ if lock_path:
126
+ with open(lock_path) as f:
127
+ lock_data = yaml.safe_load(f)
128
+
129
+ image_ref = lock_data.get("image", "") if lock_data else ""
130
+
131
+ # Mode 2: Direct image reference
132
+ else:
133
+ # First, try to parse as org/env reference for HUD registry
134
+ # Check if it's a simple org/name or org/name:tag format (no @sha256)
135
+ if "/" in target and "@" not in target:
136
+ # Looks like org/env reference, possibly with tag
137
+ design.info(f"Checking HUD registry for: {target}")
138
+
139
+ # Check for API key (not required for pulling, but good to inform)
140
+ if not settings.api_key:
141
+ design.info("No HUD API key set (pulling from public registry)")
142
+
143
+ lock_data = fetch_lock_from_registry(target)
144
+
145
+ if lock_data:
146
+ design.success("Found in HUD registry")
147
+ image_ref = lock_data.get("image", "")
148
+ else:
149
+ # Fall back to treating as Docker image
150
+ if not settings.api_key:
151
+ design.info(
152
+ "Not found in HUD registry (try setting HUD_API_KEY for private environments)" # noqa: E501
153
+ )
154
+ else:
155
+ design.info("Not found in HUD registry, treating as Docker image")
156
+
157
+ # Try to get metadata from Docker registry
158
+ if not lock_data:
159
+ design.info(f"Fetching Docker metadata for: {image_ref}")
160
+ manifest = get_docker_manifest(image_ref)
161
+
162
+ if manifest:
163
+ # Create minimal lock data from manifest
164
+ lock_data = {"image": image_ref, "source": "docker-manifest"}
165
+
166
+ # Try to get size
167
+ size = get_image_size_from_manifest(manifest)
168
+ if size:
169
+ lock_data["size"] = format_size(size)
170
+
171
+ if verbose:
172
+ design.info(
173
+ f"Retrieved manifest (type: {manifest.get('mediaType', 'unknown')})"
174
+ )
175
+
176
+ # Display environment summary
177
+ design.section_title("Environment Details")
178
+
179
+ # Create summary table
180
+ table = Table(show_header=False, box=None)
181
+ table.add_column("Property", style="cyan")
182
+ table.add_column("Value")
183
+
184
+ # Image info - show simple name in table
185
+ display_ref = image_ref.split("@")[0] if ":" in image_ref and "@" in image_ref else image_ref
186
+ table.add_row("Image", display_ref)
187
+
188
+ if lock_data:
189
+ # Show size if available
190
+ if "size" in lock_data:
191
+ table.add_row("Size", lock_data["size"])
192
+
193
+ # Check if this is full lock data or minimal manifest data
194
+ if lock_data.get("source") == "docker-manifest":
195
+ # Minimal data from Docker manifest
196
+ table.add_row("Source", "Docker Registry")
197
+ if not yes:
198
+ design.warning("Note: Limited metadata available from Docker registry.")
199
+ design.info("For full environment details, use a lock file.\n")
200
+ else:
201
+ # Full lock file data
202
+ if "build" in lock_data:
203
+ table.add_row("Built", lock_data["build"].get("generatedAt", "Unknown"))
204
+ table.add_row("HUD Version", lock_data["build"].get("hudVersion", "Unknown"))
205
+
206
+ if "environment" in lock_data:
207
+ env_data = lock_data["environment"]
208
+ table.add_row("Tools", str(env_data.get("toolCount", "Unknown")))
209
+ table.add_row("Init Time", f"{env_data.get('initializeMs', 'Unknown')} ms")
210
+
211
+ if "push" in lock_data:
212
+ push_data = lock_data["push"]
213
+ table.add_row("Registry", push_data.get("registry", "Unknown"))
214
+ table.add_row("Pushed", push_data.get("pushedAt", "Unknown"))
215
+
216
+ # Environment variables
217
+ env_section = lock_data.get("environment", {})
218
+ if "variables" in env_section:
219
+ vars_data = env_section["variables"]
220
+ if vars_data.get("required"):
221
+ table.add_row("Required Env", ", ".join(vars_data["required"]))
222
+ if vars_data.get("optional"):
223
+ table.add_row("Optional Env", ", ".join(vars_data["optional"]))
224
+
225
+ else:
226
+ # No metadata available
227
+ table.add_row("Source", "Unknown")
228
+
229
+ # Use design's console to maintain consistent output
230
+ design.console.print(table)
231
+
232
+ # Tool summary (show after table)
233
+ if lock_data and "tools" in lock_data:
234
+ design.section_title("Available Tools")
235
+ for tool in lock_data["tools"]:
236
+ design.info(f"• {tool['name']}: {tool['description']}")
237
+
238
+ # Show warnings if no metadata
239
+ if not lock_data and not yes:
240
+ design.warning("No metadata available for this image.")
241
+ design.info("The image will be pulled without verification.")
242
+
243
+ # If verify only, stop here
244
+ if verify_only:
245
+ design.success("Verification complete")
246
+ return
247
+
248
+ # Ask for confirmation unless --yes
249
+ if not yes:
250
+ design.info("")
251
+ # Show simple name for confirmation, not the full digest
252
+ if ":" in image_ref and "@" in image_ref:
253
+ simple_name = image_ref.split("@")[0]
254
+ else:
255
+ simple_name = image_ref
256
+ if not typer.confirm(f"Pull {simple_name}?"):
257
+ design.info("Aborted")
258
+ raise typer.Exit(0)
259
+
260
+ # Pull the image
261
+ design.progress_message(f"Pulling {image_ref}...")
262
+
263
+ # Run docker pull with progress
264
+ process = subprocess.Popen( # noqa: S603
265
+ ["docker", "pull", image_ref], # noqa: S607
266
+ stdout=subprocess.PIPE,
267
+ stderr=subprocess.STDOUT,
268
+ text=True,
269
+ encoding="utf-8",
270
+ errors="replace",
271
+ )
272
+
273
+ for line in process.stdout or []:
274
+ if verbose or "Downloading" in line or "Extracting" in line or "Pull complete" in line:
275
+ design.info(line.rstrip())
276
+
277
+ process.wait()
278
+
279
+ if process.returncode != 0:
280
+ design.error("Pull failed")
281
+ raise typer.Exit(1)
282
+
283
+ # Store lock file locally if we have full lock data (not minimal manifest data)
284
+ if lock_data and lock_data.get("source") != "docker-manifest":
285
+ # Extract digest from image ref
286
+ digest = image_ref.split("@sha256:")[-1][:12] if "@sha256:" in image_ref else "latest"
287
+
288
+ # Store under ~/.hud/envs/<digest>/
289
+ local_env_dir = Path.home() / ".hud" / "envs" / digest
290
+ local_env_dir.mkdir(parents=True, exist_ok=True)
291
+
292
+ local_lock_path = local_env_dir / "hud.lock.yaml"
293
+ with open(local_lock_path, "w") as f:
294
+ yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
295
+
296
+ if verbose:
297
+ design.info(f"Stored lock file: {local_lock_path}")
298
+
299
+ # Success!
300
+ design.success("Pull complete!")
301
+
302
+ # Show usage
303
+ design.section_title("Next Steps")
304
+
305
+ # Extract simple name for examples
306
+ simple_ref = image_ref.split("@")[0] if ":" in image_ref and "@" in image_ref else image_ref
307
+
308
+ design.info("1. Quick analysis (from metadata):")
309
+ design.command_example(f"hud analyze {simple_ref}")
310
+ design.info("")
311
+ design.info("2. Live analysis (runs container):")
312
+ design.command_example(f"hud analyze {simple_ref} --live")
313
+ design.info("")
314
+ design.info("3. Run the environment:")
315
+ design.command_example(f"hud run {simple_ref}")
316
+
317
+
318
+ def pull_command(
319
+ target: str = typer.Argument(..., help="Image reference or lock file to pull"),
320
+ lock_file: str | None = typer.Option(
321
+ None, "--lock", "-l", help="Path to lock file (if target is image ref)"
322
+ ),
323
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
324
+ verify_only: bool = typer.Option(
325
+ False, "--verify-only", help="Only verify metadata without pulling"
326
+ ),
327
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
328
+ ) -> None:
329
+ """Pull HUD environment from registry with metadata preview."""
330
+ pull_environment(target, lock_file, yes, verify_only, verbose)