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/push.py CHANGED
@@ -1,370 +1,404 @@
1
- """Push HUD environments to registry."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import subprocess
7
- from pathlib import Path
8
-
9
- import click
10
- import requests
11
- import typer
12
- import yaml
13
- from rich.console import Console
14
-
15
- from hud.settings import settings
16
- from hud.utils.design import HUDDesign
17
-
18
- console = Console()
19
-
20
-
21
- def get_docker_username() -> str | None:
22
- """Get the current Docker username if logged in."""
23
- try:
24
- # Docker config locations
25
- config_paths = [
26
- Path.home() / ".docker" / "config.json",
27
- Path.home() / ".docker" / "plaintext-credentials.json", # Alternative location
28
- ]
29
-
30
- for config_path in config_paths:
31
- if config_path.exists():
32
- try:
33
- with open(config_path) as f:
34
- config = json.load(f)
35
-
36
- # Look for auth entries
37
- auths = config.get("auths", {})
38
- for registry_url, auth_info in auths.items():
39
- if (
40
- any(
41
- hub in registry_url
42
- for hub in ["docker.io", "index.docker.io", "registry-1.docker.io"]
43
- )
44
- and "auth" in auth_info
45
- ):
46
- import base64
47
-
48
- try:
49
- decoded = base64.b64decode(auth_info["auth"]).decode()
50
- username = decoded.split(":", 1)[0]
51
- if username and username != "token": # Skip token-based auth
52
- return username
53
- except Exception:
54
- click.echo("Failed to decode auth info", err=True)
55
- except Exception:
56
- click.echo("Failed to get Docker username", err=True)
57
-
58
- # Alternative: Check credsStore/credHelpers
59
- for config_path in config_paths:
60
- if config_path.exists():
61
- try:
62
- with open(config_path) as f:
63
- config = json.load(f)
64
-
65
- # Check if using credential helpers
66
- if "credsStore" in config:
67
- # Try to get credentials from helper
68
- helper = config["credsStore"]
69
- try:
70
- result = subprocess.run( # noqa: S603
71
- [f"docker-credential-{helper}", "list"],
72
- capture_output=True,
73
- text=True,
74
- )
75
- if result.returncode == 0:
76
- creds = json.loads(result.stdout)
77
- for url in creds:
78
- if "docker.io" in url:
79
- # Try to get the username
80
- get_result = subprocess.run( # noqa: S603
81
- [f"docker-credential-{helper}", "get"],
82
- input=url,
83
- capture_output=True,
84
- text=True,
85
- )
86
- if get_result.returncode == 0:
87
- cred_data = json.loads(get_result.stdout)
88
- username = cred_data.get("Username", "")
89
- if username and username != "token":
90
- return username
91
- except Exception:
92
- click.echo("Failed to get Docker username", err=True)
93
- except Exception:
94
- click.echo("Failed to get Docker username", err=True)
95
- except Exception:
96
- click.echo("Failed to get Docker username", err=True)
97
- return None
98
-
99
-
100
- def get_docker_image_labels(image: str) -> dict:
101
- """Get labels from a Docker image."""
102
- try:
103
- result = subprocess.run( # noqa: S603
104
- ["docker", "inspect", "--format", "{{json .Config.Labels}}", image], # noqa: S607
105
- capture_output=True,
106
- text=True,
107
- check=True,
108
- )
109
- return json.loads(result.stdout.strip()) or {}
110
- except Exception:
111
- click.echo("Failed to get Docker image labels", err=True)
112
- return {}
113
-
114
-
115
- def push_environment(
116
- directory: str = ".",
117
- image: str | None = None,
118
- tag: str | None = None,
119
- sign: bool = False,
120
- yes: bool = False,
121
- verbose: bool = False,
122
- ) -> None:
123
- """Push HUD environment to registry."""
124
- design = HUDDesign()
125
- design.header("HUD Environment Push")
126
-
127
- # Find hud.lock.yaml in specified directory
128
- env_dir = Path(directory)
129
- lock_path = env_dir / "hud.lock.yaml"
130
-
131
- if not lock_path.exists():
132
- design.error(f"No hud.lock.yaml found in {directory}")
133
- design.info("Run 'hud build' first to generate a lock file")
134
- raise typer.Exit(1)
135
-
136
- # Check for API key first
137
- if not settings.api_key:
138
- design.error("No HUD API key found")
139
- console.print("\n[yellow]A HUD API key is required to push environments.[/yellow]")
140
- console.print("\nTo get started:")
141
- console.print(" 1. Get your API key at: [link]https://hud.so/settings[/link]")
142
- console.print(" 2. Set it: [cyan]export HUD_API_KEY=your-key-here[/cyan]")
143
- console.print(" 3. Try again: [cyan]hud push[/cyan]\n")
144
- raise typer.Exit(1)
145
-
146
- # Load lock file
147
- with open(lock_path) as f:
148
- lock_data = yaml.safe_load(f)
149
-
150
- # Handle both old and new lock file formats
151
- local_image = lock_data.get("image", "")
152
- if not local_image and "build" in lock_data:
153
- # New format might have image elsewhere
154
- local_image = lock_data.get("image", "")
155
-
156
- # If no image specified, try to be smart
157
- if not image:
158
- # Check if user is logged in
159
- username = get_docker_username()
160
- if username:
161
- # Extract image name from lock file (handle @sha256:... format)
162
- base_image = local_image.split("@")[0] if "@" in local_image else local_image
163
-
164
- if ":" in base_image:
165
- base_name = base_image.split(":")[0]
166
- current_tag = base_image.split(":")[1]
167
- else:
168
- base_name = base_image
169
- current_tag = "latest"
170
-
171
- # Remove any existing registry prefix
172
- if "/" in base_name:
173
- base_name = base_name.split("/")[-1]
174
-
175
- # Use provided tag or default
176
- final_tag = tag if tag else current_tag
177
-
178
- # Suggest a registry image
179
- image = f"{username}/{base_name}:{final_tag}"
180
- design.info(f"Auto-detected Docker username: {username}")
181
- if tag:
182
- design.info(f"Using specified tag: {tag}")
183
- design.info(f"Will push to: {image}")
184
-
185
- if not yes and not typer.confirm(f"\nPush to {image}?"):
186
- design.info("Aborted.")
187
- raise typer.Exit(0)
188
- else:
189
- design.error(
190
- "Not logged in to Docker Hub. Please specify --image or run 'docker login'"
191
- )
192
- raise typer.Exit(1)
193
- elif tag:
194
- # Handle tag when image is provided
195
- if ":" in image:
196
- # Image already has a tag
197
- existing_tag = image.split(":")[-1]
198
- if existing_tag != tag:
199
- design.warning(f"Image already has tag '{existing_tag}', overriding with '{tag}'")
200
- image = image.rsplit(":", 1)[0] + f":{tag}"
201
- # else: tags match, no action needed
202
- else:
203
- # Image has no tag, append the specified one
204
- image = f"{image}:{tag}"
205
- design.info(f"Using specified tag: {tag}")
206
- design.info(f"Will push to: {image}")
207
-
208
- # Verify local image exists
209
- # Extract the tag part (before @sha256:...) for Docker operations
210
- local_tag = local_image.split("@")[0] if "@" in local_image else local_image
211
-
212
- # Verify the image exists locally
213
- try:
214
- subprocess.run(["docker", "inspect", local_tag], capture_output=True, check=True) # noqa: S603, S607
215
- except subprocess.CalledProcessError:
216
- design.error(f"Local image not found: {local_tag}")
217
- design.info("Run 'hud build' first to create the image")
218
- raise typer.Exit(1) # noqa: B904
219
-
220
- # Check if local image has the expected label
221
- labels = get_docker_image_labels(local_tag)
222
- expected_label = labels.get("org.hud.manifest.head", "")
223
-
224
- # Skip hash verification - the lock file may have been updated with digest after build
225
- if verbose and expected_label:
226
- design.info(f"Image label: {expected_label[:12]}...")
227
-
228
- # Tag the image for push
229
- design.progress_message(f"Tagging {local_tag} as {image}")
230
- subprocess.run(["docker", "tag", local_tag, image], check=True) # noqa: S603, S607
231
-
232
- # Push the image
233
- design.progress_message(f"Pushing {image} to registry...")
234
-
235
- # Show push output
236
- process = subprocess.Popen( # noqa: S603
237
- ["docker", "push", image], # noqa: S607
238
- stdout=subprocess.PIPE,
239
- stderr=subprocess.STDOUT,
240
- text=True,
241
- encoding="utf-8",
242
- errors="replace",
243
- )
244
-
245
- for line in process.stdout or []:
246
- click.echo(line.rstrip(), err=True)
247
-
248
- process.wait()
249
-
250
- if process.returncode != 0:
251
- design.error("Push failed")
252
- raise typer.Exit(1)
253
-
254
- # Get the digest of the pushed image
255
- result = subprocess.run( # noqa: S603
256
- ["docker", "inspect", "--format", "{{index .RepoDigests 0}}", image], # noqa: S607
257
- capture_output=True,
258
- text=True,
259
- )
260
-
261
- if result.returncode == 0 and result.stdout.strip():
262
- pushed_digest = result.stdout.strip()
263
- else:
264
- pushed_digest = image
265
-
266
- # Success!
267
- design.success("Push complete!")
268
-
269
- # Show the final image reference
270
- console.print("\n[bold green]✓ Pushed image:[/bold green]")
271
- console.print(f" [bold cyan]{pushed_digest}[/bold cyan]\n")
272
-
273
- # Update the lock file with registry information
274
- lock_data["image"] = pushed_digest
275
-
276
- # Add push information
277
- from datetime import datetime
278
-
279
- lock_data["push"] = {
280
- "source": local_image,
281
- "pushedAt": datetime.utcnow().isoformat() + "Z",
282
- "registry": pushed_digest.split("/")[0] if "/" in pushed_digest else "docker.io",
283
- }
284
-
285
- # Save updated lock file
286
- with open(lock_path, "w") as f:
287
- yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
288
-
289
- console.print("[green]✓[/green] Updated lock file with registry image")
290
-
291
- # Upload lock file to HUD registry
292
- try:
293
- # Extract org/name:tag from the pushed image
294
- # e.g., "docker.io/hudpython/test_init:latest@sha256:..." -> "hudpython/test_init:latest"
295
- # e.g., "hudpython/test_init:v1.0" -> "hudpython/test_init:v1.0"
296
- registry_parts = pushed_digest.split("/")
297
- if len(registry_parts) >= 2:
298
- # Handle docker.io/org/name or just org/name
299
- if registry_parts[0] in ["docker.io", "registry-1.docker.io", "index.docker.io"]:
300
- # Remove registry prefix and get org/name:tag
301
- name_with_tag = "/".join(registry_parts[1:]).split("@")[0]
302
- else:
303
- # Just org/name:tag
304
- name_with_tag = "/".join(registry_parts[:2]).split("@")[0]
305
-
306
- # If no tag specified, use "latest"
307
- if ":" not in name_with_tag:
308
- name_with_tag = f"{name_with_tag}:latest"
309
-
310
- # Upload to HUD registry
311
- design.progress_message("Uploading metadata to HUD registry...")
312
-
313
- registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{name_with_tag}"
314
-
315
- # Prepare the payload
316
- payload = {
317
- "lock": yaml.dump(lock_data, default_flow_style=False, sort_keys=False),
318
- "digest": pushed_digest.split("@")[-1] if "@" in pushed_digest else "latest",
319
- }
320
-
321
- headers = {"Authorization": f"Bearer {settings.api_key}"}
322
-
323
- response = requests.post(registry_url, json=payload, headers=headers, timeout=10)
324
-
325
- if response.status_code in [200, 201]:
326
- design.success("Metadata uploaded to HUD registry")
327
- console.print(
328
- f" Others can now pull with: [cyan]hud pull {name_with_tag}[/cyan]\n"
329
- )
330
- else:
331
- design.warning(f"Could not upload to registry: {response.status_code}")
332
- if verbose:
333
- design.info(f"Response: {response.text}")
334
- console.print(" Share [cyan]hud.lock.yaml[/cyan] manually\n")
335
- else:
336
- if verbose:
337
- design.info("Could not parse registry path for upload")
338
- console.print(
339
- " Share [cyan]hud.lock.yaml[/cyan] to let others reproduce your exact environment\n" # noqa: E501
340
- )
341
- except Exception as e:
342
- design.warning(f"Registry upload failed: {e}")
343
- console.print(" Share [cyan]hud.lock.yaml[/cyan] manually\n")
344
-
345
- # Show usage examples
346
- design.section_title("What's Next?")
347
-
348
- console.print("Test locally:")
349
- console.print(f" [cyan]hud run {image}[/cyan]\n")
350
-
351
- console.print("Share environment:")
352
- console.print(
353
- " Share the updated [cyan]hud.lock.yaml[/cyan] for others to reproduce your exact environment" # noqa: E501
354
- )
355
-
356
- # TODO: Upload lock file to HUD registry
357
- if sign:
358
- design.warning("Signing not yet implemented")
359
-
360
-
361
- def push_command(
362
- directory: str = ".",
363
- image: str | None = None,
364
- tag: str | None = None,
365
- sign: bool = False,
366
- yes: bool = False,
367
- verbose: bool = False,
368
- ) -> None:
369
- """Push HUD environment to registry."""
370
- push_environment(directory, image, tag, sign, yes, verbose)
1
+ """Push HUD environments to registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import requests
10
+ import typer
11
+ import yaml
12
+
13
+ from hud.settings import settings
14
+ from hud.utils.design import HUDDesign
15
+
16
+
17
+ def get_docker_username() -> str | None:
18
+ """Get the current Docker username if logged in."""
19
+ try:
20
+ # Docker config locations
21
+ config_paths = [
22
+ Path.home() / ".docker" / "config.json",
23
+ Path.home() / ".docker" / "plaintext-credentials.json", # Alternative location
24
+ ]
25
+
26
+ for config_path in config_paths:
27
+ if config_path.exists():
28
+ try:
29
+ with open(config_path) as f:
30
+ config = json.load(f)
31
+
32
+ # Look for auth entries
33
+ auths = config.get("auths", {})
34
+ for registry_url, auth_info in auths.items():
35
+ if (
36
+ any(
37
+ hub in registry_url
38
+ for hub in ["docker.io", "index.docker.io", "registry-1.docker.io"]
39
+ )
40
+ and "auth" in auth_info
41
+ ):
42
+ import base64
43
+
44
+ try:
45
+ decoded = base64.b64decode(auth_info["auth"]).decode()
46
+ username = decoded.split(":", 1)[0]
47
+ if username and username != "token": # Skip token-based auth
48
+ return username
49
+ except Exception:
50
+ pass # Silent failure, try other methods
51
+ except Exception:
52
+ pass # Silent failure, try other methods
53
+
54
+ # Alternative: Check credsStore/credHelpers
55
+ for config_path in config_paths:
56
+ if config_path.exists():
57
+ try:
58
+ with open(config_path) as f:
59
+ config = json.load(f)
60
+
61
+ # Check if using credential helpers
62
+ if "credsStore" in config:
63
+ # Try to get credentials from helper
64
+ helper = config["credsStore"]
65
+ try:
66
+ result = subprocess.run( # noqa: S603
67
+ [f"docker-credential-{helper}", "list"],
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+ if result.returncode == 0:
72
+ creds = json.loads(result.stdout)
73
+ for url in creds:
74
+ if "docker.io" in url:
75
+ # Try to get the username
76
+ get_result = subprocess.run( # noqa: S603
77
+ [f"docker-credential-{helper}", "get"],
78
+ input=url,
79
+ capture_output=True,
80
+ text=True,
81
+ )
82
+ if get_result.returncode == 0:
83
+ cred_data = json.loads(get_result.stdout)
84
+ username = cred_data.get("Username", "")
85
+ if username and username != "token":
86
+ return username
87
+ except Exception:
88
+ pass # Silent failure, try other methods
89
+ except Exception:
90
+ pass # Silent failure, try other methods
91
+ except Exception:
92
+ pass # Silent failure, try other methods
93
+ return None
94
+
95
+
96
+ def get_docker_image_labels(image: str) -> dict:
97
+ """Get labels from a Docker image."""
98
+ try:
99
+ result = subprocess.run( # noqa: S603
100
+ ["docker", "inspect", "--format", "{{json .Config.Labels}}", image], # noqa: S607
101
+ capture_output=True,
102
+ text=True,
103
+ check=True,
104
+ )
105
+ return json.loads(result.stdout.strip()) or {}
106
+ except Exception:
107
+ return {}
108
+
109
+
110
+ def push_environment(
111
+ directory: str = ".",
112
+ image: str | None = None,
113
+ tag: str | None = None,
114
+ sign: bool = False,
115
+ yes: bool = False,
116
+ verbose: bool = False,
117
+ ) -> None:
118
+ """Push HUD environment to registry."""
119
+ design = HUDDesign()
120
+ design.header("HUD Environment Push")
121
+
122
+ # Find hud.lock.yaml in specified directory
123
+ env_dir = Path(directory)
124
+ lock_path = env_dir / "hud.lock.yaml"
125
+
126
+ if not lock_path.exists():
127
+ design.error(f"No hud.lock.yaml found in {directory}")
128
+ design.info("Run 'hud build' first to generate a lock file")
129
+ raise typer.Exit(1)
130
+
131
+ # Check for API key first
132
+ if not settings.api_key:
133
+ design.error("No HUD API key found")
134
+ design.warning("A HUD API key is required to push environments.")
135
+ design.info("\nTo get started:")
136
+ design.info("1. Get your API key at: https://hud.so/settings")
137
+ design.command_example("export HUD_API_KEY=your-key-here", "Set your API key")
138
+ design.command_example("hud push", "Try again")
139
+ design.info("")
140
+ raise typer.Exit(1)
141
+
142
+ # Load lock file
143
+ with open(lock_path) as f:
144
+ lock_data = yaml.safe_load(f)
145
+
146
+ # Handle both old and new lock file formats
147
+ local_image = lock_data.get("image", "")
148
+ if not local_image and "build" in lock_data:
149
+ # New format might have image elsewhere
150
+ local_image = lock_data.get("image", "")
151
+
152
+ # Get internal version from lock file
153
+ internal_version = lock_data.get("build", {}).get("version", None)
154
+
155
+ # If no image specified, try to be smart
156
+ if not image:
157
+ # Check if user is logged in
158
+ username = get_docker_username()
159
+ if username:
160
+ # Extract image name from lock file (handle @sha256:... format)
161
+ base_image = local_image.split("@")[0] if "@" in local_image else local_image
162
+
163
+ if ":" in base_image:
164
+ base_name = base_image.split(":")[0]
165
+ current_tag = base_image.split(":")[1]
166
+ else:
167
+ base_name = base_image
168
+ current_tag = "latest"
169
+
170
+ # Remove any existing registry prefix
171
+ if "/" in base_name:
172
+ base_name = base_name.split("/")[-1]
173
+
174
+ # Use provided tag, or internal version, or current tag as fallback
175
+ if tag:
176
+ final_tag = tag
177
+ design.info(f"Using specified tag: {tag}")
178
+ elif internal_version:
179
+ final_tag = internal_version
180
+ design.info(f"Using internal version from lock file: {internal_version}")
181
+ else:
182
+ final_tag = current_tag
183
+ design.info(f"Using current tag: {current_tag}")
184
+
185
+ # Suggest a registry image
186
+ image = f"{username}/{base_name}:{final_tag}"
187
+ design.info(f"Auto-detected Docker username: {username}")
188
+ design.info(f"Will push to: {image}")
189
+
190
+ if not yes and not typer.confirm(f"\nPush to {image}?"):
191
+ design.info("Aborted.")
192
+ raise typer.Exit(0)
193
+ else:
194
+ design.error(
195
+ "Not logged in to Docker Hub. Please specify --image or run 'docker login'"
196
+ )
197
+ raise typer.Exit(1)
198
+ elif tag or internal_version:
199
+ # Handle tag when image is provided
200
+ # Prefer explicit tag over internal version
201
+ final_tag = tag if tag else internal_version
202
+
203
+ if ":" in image:
204
+ # Image already has a tag
205
+ existing_tag = image.split(":")[-1]
206
+ if existing_tag != final_tag:
207
+ if tag:
208
+ design.warning(f"Image already has tag '{existing_tag}', overriding with '{final_tag}'")
209
+ else:
210
+ design.info(f"Image has tag '{existing_tag}', but using internal version '{final_tag}'")
211
+ image = image.rsplit(":", 1)[0] + f":{final_tag}"
212
+ # else: tags match, no action needed
213
+ else:
214
+ # Image has no tag, append the appropriate one
215
+ image = f"{image}:{final_tag}"
216
+
217
+ if tag:
218
+ design.info(f"Using specified tag: {tag}")
219
+ else:
220
+ design.info(f"Using internal version from lock file: {internal_version}")
221
+ design.info(f"Will push to: {image}")
222
+
223
+ # Verify local image exists
224
+ # Extract the tag part (before @sha256:...) for Docker operations
225
+ local_tag = local_image.split("@")[0] if "@" in local_image else local_image
226
+
227
+ # Also check for version-tagged image if we have internal version
228
+ version_tag = None
229
+ if internal_version and ":" in local_tag:
230
+ base_name = local_tag.split(":")[0]
231
+ version_tag = f"{base_name}:{internal_version}"
232
+
233
+ # Try to find the image - prefer version tag if it exists
234
+ image_to_push = None
235
+ if version_tag:
236
+ try:
237
+ subprocess.run(["docker", "inspect", version_tag], capture_output=True, check=True) # noqa: S603, S607
238
+ image_to_push = version_tag
239
+ design.info(f"Found version-tagged image: {version_tag}")
240
+ except subprocess.CalledProcessError:
241
+ pass
242
+
243
+ if not image_to_push:
244
+ try:
245
+ subprocess.run(["docker", "inspect", local_tag], capture_output=True, check=True) # noqa: S603, S607
246
+ image_to_push = local_tag
247
+ except subprocess.CalledProcessError:
248
+ design.error(f"Local image not found: {local_tag}")
249
+ if version_tag:
250
+ design.error(f"Also tried: {version_tag}")
251
+ design.info("Run 'hud build' first to create the image")
252
+ raise typer.Exit(1) # noqa: B904
253
+
254
+ # Check if local image has the expected label
255
+ labels = get_docker_image_labels(image_to_push)
256
+ expected_label = labels.get("org.hud.manifest.head", "")
257
+ version_label = labels.get("org.hud.version", "")
258
+
259
+ # Skip hash verification - the lock file may have been updated with digest after build
260
+ if verbose:
261
+ if expected_label:
262
+ design.info(f"Image label: {expected_label[:12]}...")
263
+ if version_label:
264
+ design.info(f"Version label: {version_label}")
265
+
266
+ # Tag the image for push
267
+ design.progress_message(f"Tagging {image_to_push} as {image}")
268
+ subprocess.run(["docker", "tag", image_to_push, image], check=True) # noqa: S603, S607
269
+
270
+ # Push the image
271
+ design.progress_message(f"Pushing {image} to registry...")
272
+
273
+ # Show push output
274
+ process = subprocess.Popen( # noqa: S603
275
+ ["docker", "push", image], # noqa: S607
276
+ stdout=subprocess.PIPE,
277
+ stderr=subprocess.STDOUT,
278
+ text=True,
279
+ encoding="utf-8",
280
+ errors="replace",
281
+ )
282
+
283
+ for line in process.stdout or []:
284
+ design.info(line.rstrip())
285
+
286
+ process.wait()
287
+
288
+ if process.returncode != 0:
289
+ design.error("Push failed")
290
+ raise typer.Exit(1)
291
+
292
+ # Get the digest of the pushed image
293
+ result = subprocess.run( # noqa: S603
294
+ ["docker", "inspect", "--format", "{{index .RepoDigests 0}}", image], # noqa: S607
295
+ capture_output=True,
296
+ text=True,
297
+ )
298
+
299
+ if result.returncode == 0 and result.stdout.strip():
300
+ pushed_digest = result.stdout.strip()
301
+ else:
302
+ pushed_digest = image
303
+
304
+ # Success!
305
+ design.success("Push complete!")
306
+
307
+ # Show the final image reference
308
+ design.section_title("Pushed Image")
309
+ design.status_item("Registry", pushed_digest, primary=True)
310
+
311
+ # Update the lock file with registry information
312
+ lock_data["image"] = pushed_digest
313
+
314
+ # Add push information
315
+ from datetime import datetime
316
+
317
+ lock_data["push"] = {
318
+ "source": local_image,
319
+ "pushedAt": datetime.utcnow().isoformat() + "Z",
320
+ "registry": pushed_digest.split("/")[0] if "/" in pushed_digest else "docker.io",
321
+ }
322
+
323
+ # Save updated lock file
324
+ with open(lock_path, "w") as f:
325
+ yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
326
+
327
+ design.success("Updated lock file with registry image")
328
+
329
+ # Upload lock file to HUD registry
330
+ try:
331
+ # Extract org/name:tag from the pushed image
332
+ # e.g., "docker.io/hudpython/test_init:latest@sha256:..." -> "hudpython/test_init:latest"
333
+ # e.g., "hudpython/test_init:v1.0" -> "hudpython/test_init:v1.0"
334
+ registry_parts = pushed_digest.split("/")
335
+ if len(registry_parts) >= 2:
336
+ # Handle docker.io/org/name or just org/name
337
+ if registry_parts[0] in ["docker.io", "registry-1.docker.io", "index.docker.io"]:
338
+ # Remove registry prefix and get org/name:tag
339
+ name_with_tag = "/".join(registry_parts[1:]).split("@")[0]
340
+ else:
341
+ # Just org/name:tag
342
+ name_with_tag = "/".join(registry_parts[:2]).split("@")[0]
343
+
344
+ # If no tag specified, use "latest"
345
+ if ":" not in name_with_tag:
346
+ name_with_tag = f"{name_with_tag}:latest"
347
+
348
+ # Upload to HUD registry
349
+ design.progress_message("Uploading metadata to HUD registry...")
350
+
351
+ registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{name_with_tag}"
352
+
353
+ # Prepare the payload
354
+ payload = {
355
+ "lock": yaml.dump(lock_data, default_flow_style=False, sort_keys=False),
356
+ "digest": pushed_digest.split("@")[-1] if "@" in pushed_digest else "latest",
357
+ }
358
+
359
+ headers = {"Authorization": f"Bearer {settings.api_key}"}
360
+
361
+ response = requests.post(registry_url, json=payload, headers=headers, timeout=10)
362
+
363
+ if response.status_code in [200, 201]:
364
+ design.success("Metadata uploaded to HUD registry")
365
+ design.info("Others can now pull with:")
366
+ design.command_example(f"hud pull {name_with_tag}")
367
+ design.info("")
368
+ else:
369
+ design.warning(f"Could not upload to registry: {response.status_code}")
370
+ if verbose:
371
+ design.info(f"Response: {response.text}")
372
+ design.info("Share hud.lock.yaml manually\n")
373
+ else:
374
+ if verbose:
375
+ design.info("Could not parse registry path for upload")
376
+ design.info("Share hud.lock.yaml to let others reproduce your exact environment\n")
377
+ except Exception as e:
378
+ design.warning(f"Registry upload failed: {e}")
379
+ design.info("Share hud.lock.yaml manually\n")
380
+
381
+ # Show usage examples
382
+ design.section_title("What's Next?")
383
+
384
+ design.info("Test locally:")
385
+ design.command_example(f"hud run {image}")
386
+ design.info("")
387
+ design.info("Share environment:")
388
+ design.info(" Share the updated hud.lock.yaml for others to reproduce your exact environment")
389
+
390
+ # TODO: Upload lock file to HUD registry
391
+ if sign:
392
+ design.warning("Signing not yet implemented")
393
+
394
+
395
+ def push_command(
396
+ directory: str = ".",
397
+ image: str | None = None,
398
+ tag: str | None = None,
399
+ sign: bool = False,
400
+ yes: bool = False,
401
+ verbose: bool = False,
402
+ ) -> None:
403
+ """Push HUD environment to registry."""
404
+ push_environment(directory, image, tag, sign, yes, verbose)