hud-python 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (192) hide show
  1. hud/__init__.py +22 -89
  2. hud/agents/__init__.py +17 -0
  3. hud/agents/art.py +101 -0
  4. hud/agents/base.py +599 -0
  5. hud/{mcp → agents}/claude.py +373 -321
  6. hud/{mcp → agents}/langchain.py +250 -250
  7. hud/agents/misc/__init__.py +7 -0
  8. hud/{agent → agents}/misc/response_agent.py +80 -80
  9. hud/{mcp → agents}/openai.py +352 -334
  10. hud/agents/openai_chat_generic.py +154 -0
  11. hud/{mcp → agents}/tests/__init__.py +1 -1
  12. hud/agents/tests/test_base.py +742 -0
  13. hud/agents/tests/test_claude.py +324 -0
  14. hud/{mcp → agents}/tests/test_client.py +363 -324
  15. hud/{mcp → agents}/tests/test_openai.py +237 -238
  16. hud/cli/__init__.py +617 -0
  17. hud/cli/__main__.py +8 -0
  18. hud/cli/analyze.py +371 -0
  19. hud/cli/analyze_metadata.py +230 -0
  20. hud/cli/build.py +427 -0
  21. hud/cli/clone.py +185 -0
  22. hud/cli/cursor.py +92 -0
  23. hud/cli/debug.py +392 -0
  24. hud/cli/docker_utils.py +83 -0
  25. hud/cli/init.py +281 -0
  26. hud/cli/interactive.py +353 -0
  27. hud/cli/mcp_server.py +756 -0
  28. hud/cli/pull.py +336 -0
  29. hud/cli/push.py +379 -0
  30. hud/cli/remote_runner.py +311 -0
  31. hud/cli/runner.py +160 -0
  32. hud/cli/tests/__init__.py +3 -0
  33. hud/cli/tests/test_analyze.py +284 -0
  34. hud/cli/tests/test_cli_init.py +265 -0
  35. hud/cli/tests/test_cli_main.py +27 -0
  36. hud/cli/tests/test_clone.py +142 -0
  37. hud/cli/tests/test_cursor.py +253 -0
  38. hud/cli/tests/test_debug.py +453 -0
  39. hud/cli/tests/test_mcp_server.py +139 -0
  40. hud/cli/tests/test_utils.py +388 -0
  41. hud/cli/utils.py +263 -0
  42. hud/clients/README.md +143 -0
  43. hud/clients/__init__.py +16 -0
  44. hud/clients/base.py +354 -0
  45. hud/clients/fastmcp.py +202 -0
  46. hud/clients/mcp_use.py +278 -0
  47. hud/clients/tests/__init__.py +1 -0
  48. hud/clients/tests/test_client_integration.py +111 -0
  49. hud/clients/tests/test_fastmcp.py +342 -0
  50. hud/clients/tests/test_protocol.py +188 -0
  51. hud/clients/utils/__init__.py +1 -0
  52. hud/clients/utils/retry_transport.py +160 -0
  53. hud/datasets.py +322 -192
  54. hud/misc/__init__.py +1 -0
  55. hud/{agent → misc}/claude_plays_pokemon.py +292 -283
  56. hud/otel/__init__.py +35 -0
  57. hud/otel/collector.py +142 -0
  58. hud/otel/config.py +164 -0
  59. hud/otel/context.py +536 -0
  60. hud/otel/exporters.py +366 -0
  61. hud/otel/instrumentation.py +97 -0
  62. hud/otel/processors.py +118 -0
  63. hud/otel/tests/__init__.py +1 -0
  64. hud/otel/tests/test_processors.py +197 -0
  65. hud/server/__init__.py +5 -5
  66. hud/server/context.py +114 -0
  67. hud/server/helper/__init__.py +5 -0
  68. hud/server/low_level.py +132 -0
  69. hud/server/server.py +166 -0
  70. hud/server/tests/__init__.py +3 -0
  71. hud/settings.py +73 -79
  72. hud/shared/__init__.py +5 -0
  73. hud/{exceptions.py → shared/exceptions.py} +180 -180
  74. hud/{server → shared}/requests.py +264 -264
  75. hud/shared/tests/test_exceptions.py +157 -0
  76. hud/{server → shared}/tests/test_requests.py +275 -275
  77. hud/telemetry/__init__.py +25 -30
  78. hud/telemetry/instrument.py +379 -0
  79. hud/telemetry/job.py +309 -141
  80. hud/telemetry/replay.py +74 -0
  81. hud/telemetry/trace.py +83 -0
  82. hud/tools/__init__.py +33 -34
  83. hud/tools/base.py +365 -65
  84. hud/tools/bash.py +161 -137
  85. hud/tools/computer/__init__.py +15 -13
  86. hud/tools/computer/anthropic.py +437 -414
  87. hud/tools/computer/hud.py +376 -328
  88. hud/tools/computer/openai.py +295 -286
  89. hud/tools/computer/settings.py +82 -0
  90. hud/tools/edit.py +314 -290
  91. hud/tools/executors/__init__.py +30 -30
  92. hud/tools/executors/base.py +539 -532
  93. hud/tools/executors/pyautogui.py +621 -619
  94. hud/tools/executors/tests/__init__.py +1 -1
  95. hud/tools/executors/tests/test_base_executor.py +338 -338
  96. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  97. hud/tools/executors/xdo.py +511 -503
  98. hud/tools/{playwright_tool.py → playwright.py} +412 -379
  99. hud/tools/tests/__init__.py +3 -3
  100. hud/tools/tests/test_base.py +282 -0
  101. hud/tools/tests/test_bash.py +158 -152
  102. hud/tools/tests/test_bash_extended.py +197 -0
  103. hud/tools/tests/test_computer.py +425 -52
  104. hud/tools/tests/test_computer_actions.py +34 -34
  105. hud/tools/tests/test_edit.py +259 -240
  106. hud/tools/tests/test_init.py +27 -27
  107. hud/tools/tests/test_playwright_tool.py +183 -183
  108. hud/tools/tests/test_tools.py +145 -157
  109. hud/tools/tests/test_utils.py +156 -156
  110. hud/tools/types.py +72 -0
  111. hud/tools/utils.py +50 -50
  112. hud/types.py +136 -89
  113. hud/utils/__init__.py +10 -16
  114. hud/utils/async_utils.py +65 -0
  115. hud/utils/design.py +168 -0
  116. hud/utils/mcp.py +55 -0
  117. hud/utils/progress.py +149 -149
  118. hud/utils/telemetry.py +66 -66
  119. hud/utils/tests/test_async_utils.py +173 -0
  120. hud/utils/tests/test_init.py +17 -21
  121. hud/utils/tests/test_progress.py +261 -225
  122. hud/utils/tests/test_telemetry.py +82 -37
  123. hud/utils/tests/test_version.py +8 -8
  124. hud/version.py +7 -7
  125. hud_python-0.4.0.dist-info/METADATA +474 -0
  126. hud_python-0.4.0.dist-info/RECORD +132 -0
  127. hud_python-0.4.0.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.4.dist-info → hud_python-0.4.0.dist-info}/licenses/LICENSE +21 -21
  129. hud/adapters/__init__.py +0 -8
  130. hud/adapters/claude/__init__.py +0 -5
  131. hud/adapters/claude/adapter.py +0 -180
  132. hud/adapters/claude/tests/__init__.py +0 -1
  133. hud/adapters/claude/tests/test_adapter.py +0 -519
  134. hud/adapters/common/__init__.py +0 -6
  135. hud/adapters/common/adapter.py +0 -178
  136. hud/adapters/common/tests/test_adapter.py +0 -289
  137. hud/adapters/common/types.py +0 -446
  138. hud/adapters/operator/__init__.py +0 -5
  139. hud/adapters/operator/adapter.py +0 -108
  140. hud/adapters/operator/tests/__init__.py +0 -1
  141. hud/adapters/operator/tests/test_adapter.py +0 -370
  142. hud/agent/__init__.py +0 -19
  143. hud/agent/base.py +0 -126
  144. hud/agent/claude.py +0 -271
  145. hud/agent/langchain.py +0 -215
  146. hud/agent/misc/__init__.py +0 -3
  147. hud/agent/operator.py +0 -268
  148. hud/agent/tests/__init__.py +0 -1
  149. hud/agent/tests/test_base.py +0 -202
  150. hud/env/__init__.py +0 -11
  151. hud/env/client.py +0 -35
  152. hud/env/docker_client.py +0 -349
  153. hud/env/environment.py +0 -446
  154. hud/env/local_docker_client.py +0 -358
  155. hud/env/remote_client.py +0 -212
  156. hud/env/remote_docker_client.py +0 -292
  157. hud/gym.py +0 -130
  158. hud/job.py +0 -773
  159. hud/mcp/__init__.py +0 -17
  160. hud/mcp/base.py +0 -631
  161. hud/mcp/client.py +0 -312
  162. hud/mcp/tests/test_base.py +0 -512
  163. hud/mcp/tests/test_claude.py +0 -294
  164. hud/task.py +0 -149
  165. hud/taskset.py +0 -237
  166. hud/telemetry/_trace.py +0 -347
  167. hud/telemetry/context.py +0 -230
  168. hud/telemetry/exporter.py +0 -575
  169. hud/telemetry/instrumentation/__init__.py +0 -3
  170. hud/telemetry/instrumentation/mcp.py +0 -259
  171. hud/telemetry/instrumentation/registry.py +0 -59
  172. hud/telemetry/mcp_models.py +0 -270
  173. hud/telemetry/tests/__init__.py +0 -1
  174. hud/telemetry/tests/test_context.py +0 -210
  175. hud/telemetry/tests/test_trace.py +0 -312
  176. hud/tools/helper/README.md +0 -56
  177. hud/tools/helper/__init__.py +0 -9
  178. hud/tools/helper/mcp_server.py +0 -78
  179. hud/tools/helper/server_initialization.py +0 -115
  180. hud/tools/helper/utils.py +0 -58
  181. hud/trajectory.py +0 -94
  182. hud/utils/agent.py +0 -37
  183. hud/utils/common.py +0 -256
  184. hud/utils/config.py +0 -120
  185. hud/utils/deprecation.py +0 -115
  186. hud/utils/misc.py +0 -53
  187. hud/utils/tests/test_common.py +0 -277
  188. hud/utils/tests/test_config.py +0 -129
  189. hud_python-0.3.4.dist-info/METADATA +0 -284
  190. hud_python-0.3.4.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {hud_python-0.3.4.dist-info → hud_python-0.4.0.dist-info}/WHEEL +0 -0
hud/cli/push.py ADDED
@@ -0,0 +1,379 @@
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)
@@ -0,0 +1,311 @@
1
+ """Remote runner for HUD MCP servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+ from fastmcp import FastMCP
11
+
12
+ from hud.settings import settings
13
+
14
+
15
+ def parse_headers(header_args: list[str]) -> dict[str, str]:
16
+ """Parse header arguments into a dictionary.
17
+
18
+ Args:
19
+ header_args: List of header strings in format "Key:Value" or "Key=Value"
20
+
21
+ Returns:
22
+ Dictionary of headers
23
+ """
24
+ headers = {}
25
+ for header in header_args:
26
+ if ":" in header:
27
+ key, value = header.split(":", 1)
28
+ elif "=" in header:
29
+ key, value = header.split("=", 1)
30
+ else:
31
+ click.echo(f"⚠️ Invalid header format: {header} (use Key:Value or Key=Value)")
32
+ continue
33
+
34
+ headers[key.strip()] = value.strip()
35
+
36
+ return headers
37
+
38
+
39
+ def parse_env_vars(env_args: list[str]) -> dict[str, str]:
40
+ """Parse environment variable arguments into headers.
41
+
42
+ Args:
43
+ env_args: List of env var strings in format "KEY=VALUE"
44
+
45
+ Returns:
46
+ Dictionary of headers with Env- prefix
47
+ """
48
+ env_headers = {}
49
+ for env in env_args:
50
+ if "=" not in env:
51
+ click.echo(f"⚠️ Invalid env format: {env} (use KEY=VALUE)")
52
+ continue
53
+
54
+ key, value = env.split("=", 1)
55
+ # Convert KEY_NAME to Env-Key-Name header format
56
+ # e.g., API_KEY=xxx becomes Env-Api-Key: xxx
57
+ # e.g., OPENAI_API_KEY=xxx becomes Env-Openai-Api-Key: xxx
58
+ header_parts = key.split("_")
59
+ header_key = f"Env-{'-'.join(part.capitalize() for part in header_parts)}"
60
+ env_headers[header_key] = value
61
+
62
+ return env_headers
63
+
64
+
65
+ def build_remote_headers(
66
+ image: str,
67
+ env_args: list[str],
68
+ header_args: list[str],
69
+ api_key: str | None = None,
70
+ run_id: str | None = None,
71
+ ) -> dict[str, str]:
72
+ """Build headers for remote MCP server.
73
+
74
+ Args:
75
+ image: Docker image name
76
+ env_args: Environment variable arguments
77
+ header_args: Additional header arguments
78
+ api_key: API key (from env or arg)
79
+ run_id: Run ID (optional)
80
+
81
+ Returns:
82
+ Complete headers dictionary
83
+ """
84
+ headers = {}
85
+
86
+ # Required headers
87
+ headers["Mcp-Image"] = image
88
+
89
+ # API key
90
+ if api_key:
91
+ headers["Authorization"] = f"Bearer {api_key}"
92
+
93
+ # Run ID if provided
94
+ if run_id:
95
+ headers["Run-Id"] = run_id
96
+
97
+ # Environment variables as headers
98
+ env_headers = parse_env_vars(env_args)
99
+ headers.update(env_headers)
100
+
101
+ # Additional headers
102
+ extra_headers = parse_headers(header_args)
103
+ headers.update(extra_headers)
104
+
105
+ return headers
106
+
107
+
108
+ def run_remote_stdio(
109
+ url: str,
110
+ headers: dict[str, str],
111
+ verbose: bool = False,
112
+ ) -> None:
113
+ """Run remote MCP server with stdio transport."""
114
+ # CRITICAL: Configure ALL output to go to stderr to keep stdout clean for MCP protocol
115
+ import logging
116
+ import warnings
117
+
118
+ # Force all output to stderr
119
+ sys.stdout = sys.stderr
120
+
121
+ # Always disable FastMCP banner for stdio
122
+ os.environ["FASTMCP_DISABLE_BANNER"] = "1"
123
+
124
+ # Configure root logger to use stderr
125
+ root_logger = logging.getLogger()
126
+ root_logger.handlers.clear()
127
+
128
+ if not verbose:
129
+ # Suppress all logs and warnings for clean stdio
130
+ stderr_handler = logging.StreamHandler(sys.stderr)
131
+ stderr_handler.setLevel(logging.CRITICAL)
132
+ root_logger.addHandler(stderr_handler)
133
+ root_logger.setLevel(logging.CRITICAL)
134
+
135
+ # Set all known loggers to CRITICAL
136
+ for logger_name in ["fastmcp", "mcp", "httpx", "httpcore", "anyio", "asyncio", "uvicorn"]:
137
+ logging.getLogger(logger_name).setLevel(logging.CRITICAL)
138
+
139
+ # Suppress warnings
140
+ warnings.filterwarnings("ignore")
141
+ else:
142
+ # Only show important logs to stderr
143
+ stderr_handler = logging.StreamHandler(sys.stderr)
144
+ stderr_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
145
+ root_logger.addHandler(stderr_handler)
146
+ root_logger.setLevel(logging.INFO)
147
+
148
+ async def run() -> None:
149
+ # Save the real stdout before we redirected it
150
+ real_stdout = sys.__stdout__
151
+
152
+ if verbose:
153
+ click.echo(f"🔗 Connecting to: {url}", err=True)
154
+ click.echo(f"📦 Image: {headers.get('Mcp-Image', 'unknown')}", err=True)
155
+ click.echo(f"🔑 Headers: {list(headers.keys())}", err=True)
156
+
157
+ # Create proxy configuration
158
+ proxy_config = {
159
+ "mcpServers": {
160
+ "remote": {"transport": "streamable-http", "url": url, "headers": headers}
161
+ }
162
+ }
163
+
164
+ try:
165
+ # Restore stdout for the proxy to use
166
+ sys.stdout = real_stdout
167
+
168
+ # Create proxy that forwards remote HTTP to local stdio
169
+ proxy = FastMCP.as_proxy(proxy_config, name="HUD Remote Proxy")
170
+
171
+ # Run with stdio transport - this will handle stdin/stdout properly
172
+ await proxy.run_async(transport="stdio", show_banner=False)
173
+ except Exception as e:
174
+ # Ensure errors go to stderr
175
+ sys.stdout = sys.stderr
176
+ if verbose:
177
+ import traceback
178
+
179
+ click.echo(f"❌ Proxy error: {e}", err=True)
180
+ click.echo(traceback.format_exc(), err=True)
181
+ raise
182
+
183
+ try:
184
+ asyncio.run(run())
185
+ except KeyboardInterrupt:
186
+ if verbose:
187
+ click.echo("\n✅ Remote proxy stopped", err=True)
188
+ sys.exit(0)
189
+ except Exception as e:
190
+ if verbose:
191
+ click.echo(f"❌ Error: {e}", err=True)
192
+ sys.exit(1)
193
+
194
+
195
+ async def run_remote_http(
196
+ url: str,
197
+ headers: dict[str, str],
198
+ port: int,
199
+ verbose: bool = False,
200
+ ) -> None:
201
+ """Run remote MCP server with HTTP transport."""
202
+ from .utils import find_free_port
203
+
204
+ # Find available port
205
+ actual_port = find_free_port(port)
206
+ if actual_port is None:
207
+ click.echo(f"❌ No available ports found starting from {port}")
208
+ return
209
+
210
+ if actual_port != port:
211
+ click.echo(f"⚠️ Port {port} in use, using port {actual_port} instead")
212
+
213
+ # Suppress logs unless verbose
214
+ if not verbose:
215
+ import logging
216
+ import os
217
+
218
+ os.environ["FASTMCP_DISABLE_BANNER"] = "1"
219
+ logging.getLogger("fastmcp").setLevel(logging.ERROR)
220
+ logging.getLogger("mcp").setLevel(logging.ERROR)
221
+ logging.getLogger("uvicorn").setLevel(logging.ERROR)
222
+ logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
223
+ logging.getLogger("uvicorn.error").setLevel(logging.ERROR)
224
+ logging.getLogger("httpx").setLevel(logging.ERROR)
225
+ logging.getLogger("httpcore").setLevel(logging.ERROR)
226
+
227
+ # Create the MCP config for the proxy
228
+ config = {"remote": {"transport": "streamable-http", "url": url, "headers": headers}}
229
+
230
+ # Create proxy that forwards remote HTTP to local HTTP
231
+ proxy = FastMCP.as_proxy(config, name="HUD Remote Proxy")
232
+
233
+ click.echo(f"🌐 Starting HTTP proxy on port {actual_port}")
234
+ click.echo(f"🔗 Server URL: http://localhost:{actual_port}/mcp")
235
+ click.echo(f"☁️ Proxying to: {url}")
236
+ click.echo("⏹️ Press Ctrl+C to stop")
237
+
238
+ try:
239
+ # Run with HTTP transport
240
+ await proxy.run_async(
241
+ transport="http",
242
+ host="0.0.0.0", # noqa: S104
243
+ port=actual_port,
244
+ path="/mcp",
245
+ log_level="error" if not verbose else "info",
246
+ show_banner=False,
247
+ )
248
+ except KeyboardInterrupt:
249
+ click.echo("\n👋 Shutting down...")
250
+
251
+
252
+ def run_remote_server(
253
+ image: str,
254
+ docker_args: list[str],
255
+ transport: str,
256
+ port: int,
257
+ url: str,
258
+ api_key: str | None,
259
+ run_id: str | None,
260
+ verbose: bool,
261
+ ) -> None:
262
+ """Run remote MCP server via proxy.
263
+
264
+ Args:
265
+ image: Docker image name
266
+ docker_args: Docker-style arguments (-e, -h)
267
+ transport: Output transport (stdio or http)
268
+ port: Port for HTTP transport
269
+ url: Remote MCP server URL
270
+ api_key: API key for authentication
271
+ run_id: Optional run ID
272
+ verbose: Show detailed logs
273
+ """
274
+ # Parse docker args into env vars and headers
275
+ env_args = []
276
+ header_args = []
277
+
278
+ i = 0
279
+ while i < len(docker_args):
280
+ arg = docker_args[i]
281
+
282
+ if arg == "-e" and i + 1 < len(docker_args):
283
+ env_args.append(docker_args[i + 1])
284
+ i += 2
285
+ elif arg == "-h" and i + 1 < len(docker_args):
286
+ header_args.append(docker_args[i + 1])
287
+ i += 2
288
+ else:
289
+ click.echo(f"⚠️ Unknown argument: {arg}", err=True)
290
+ i += 1
291
+
292
+ # Get API key from env if not provided
293
+ if not api_key:
294
+ api_key = settings.api_key
295
+ if not api_key:
296
+ click.echo("❌ API key required. Set HUD_API_KEY env var or use --api-key", err=True)
297
+ sys.exit(1)
298
+
299
+ # Build headers
300
+ headers = build_remote_headers(image, env_args, header_args, api_key, run_id)
301
+
302
+ if verbose:
303
+ click.echo(f"🔧 Remote URL: {url}", err=True)
304
+ click.echo(f"📦 Image: {image}", err=True)
305
+ click.echo(f"🔑 Headers: {list(headers.keys())}", err=True)
306
+
307
+ # Run based on transport
308
+ if transport == "stdio":
309
+ run_remote_stdio(url, headers, verbose)
310
+ else:
311
+ asyncio.run(run_remote_http(url, headers, port, verbose))