hud-python 0.4.0__py3-none-any.whl → 0.4.2__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 -17
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +250 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +80 -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 +427 -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 +281 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +789 -756
  27. hud/cli/pull.py +336 -336
  28. hud/cli/push.py +370 -379
  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 +379 -354
  44. hud/clients/fastmcp.py +202 -202
  45. hud/clients/mcp_use.py +278 -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 +322 -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 +168 -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.0.dist-info → hud_python-0.4.2.dist-info}/METADATA +23 -19
  125. hud_python-0.4.2.dist-info/RECORD +131 -0
  126. {hud_python-0.4.0.dist-info → hud_python-0.4.2.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.0.dist-info/RECORD +0 -132
  129. {hud_python-0.4.0.dist-info → hud_python-0.4.2.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.0.dist-info → hud_python-0.4.2.dist-info}/entry_points.txt +0 -0
hud/cli/push.py CHANGED
@@ -1,379 +1,370 @@
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("Use in MCP configs:")
352
- console.print(" Claude Desktop:")
353
- console.print(
354
- f' [cyan]{{"docker": {{"image": "{pushed_digest}", "command": "auto"}}}}[/cyan]\n'
355
- )
356
-
357
- console.print(" Via HUD (recommended):")
358
- console.print(f' [cyan]{{"hud": {{"registry": "{pushed_digest}"}}}}[/cyan]\n')
359
-
360
- console.print("Share environment:")
361
- console.print(
362
- " Share the updated [cyan]hud.lock.yaml[/cyan] for others to reproduce your exact environment" # noqa: E501
363
- )
364
-
365
- # TODO: Upload lock file to HUD registry
366
- if sign:
367
- design.warning("Signing not yet implemented")
368
-
369
-
370
- def push_command(
371
- directory: str = ".",
372
- image: str | None = None,
373
- tag: str | None = None,
374
- sign: bool = False,
375
- yes: bool = False,
376
- verbose: bool = False,
377
- ) -> None:
378
- """Push HUD environment to registry."""
379
- 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 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)