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/build.py CHANGED
@@ -1,427 +1,427 @@
1
- """Build HUD environments and generate lock files."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import hashlib
7
- import subprocess
8
- import time
9
- from datetime import datetime
10
- from pathlib import Path
11
- from typing import Any
12
-
13
- import typer
14
- import yaml
15
- from rich.console import Console
16
-
17
- from hud.clients import MCPClient
18
- from hud.utils.design import HUDDesign
19
- from hud.version import __version__ as hud_version
20
-
21
- console = Console()
22
-
23
-
24
- def get_docker_image_digest(image: str) -> str | None:
25
- """Get the digest of a Docker image."""
26
- try:
27
- result = subprocess.run( # noqa: S603
28
- ["docker", "inspect", "--format", "{{.RepoDigests}}", image], # noqa: S607
29
- capture_output=True,
30
- text=True,
31
- check=True,
32
- )
33
- # Parse the output - it's in format [repo@sha256:digest]
34
- digests = result.stdout.strip()
35
- if digests and digests != "[]":
36
- # Extract the first digest
37
- digest_list = eval(digests) # noqa: S307 # Safe since it's from docker
38
- if digest_list:
39
- # Return full image reference with digest
40
- return digest_list[0]
41
- except Exception:
42
- console.print("Failed to get Docker image digest")
43
- return None
44
-
45
-
46
- def get_docker_image_id(image: str) -> str | None:
47
- """Get the ID of a Docker image."""
48
- try:
49
- result = subprocess.run( # noqa: S603
50
- ["docker", "inspect", "--format", "{{.Id}}", image], # noqa: S607
51
- capture_output=True,
52
- text=True,
53
- check=True,
54
- )
55
- image_id = result.stdout.strip()
56
- if image_id:
57
- return image_id
58
- return None
59
- except Exception:
60
- # Don't log here to avoid import issues
61
- return None
62
-
63
-
64
- def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str], list[str]]:
65
- """Extract required and optional environment variables from Dockerfile."""
66
- required = []
67
- optional = []
68
-
69
- if not dockerfile_path.exists():
70
- return required, optional
71
-
72
- # Simple parsing - look for ENV directives
73
- # This is a basic implementation - could be enhanced
74
- content = dockerfile_path.read_text()
75
- for line in content.splitlines():
76
- line = line.strip()
77
- if line.startswith("ENV "):
78
- # Basic parsing - this could be more sophisticated
79
- parts = line[4:].strip().split("=", 1)
80
- if len(parts) == 2 and not parts[1].strip():
81
- # No default value = required
82
- required.append(parts[0])
83
- elif len(parts) == 1:
84
- # No equals sign = required
85
- required.append(parts[0])
86
-
87
- return required, optional
88
-
89
-
90
- async def analyze_mcp_environment(image: str, verbose: bool = False) -> dict[str, Any]:
91
- """Analyze an MCP environment to extract metadata."""
92
- design = HUDDesign()
93
-
94
- # Build Docker command to run the image
95
- docker_cmd = ["docker", "run", "--rm", "-i", image]
96
-
97
- # Create MCP config
98
- config = {
99
- "server": {"command": docker_cmd[0], "args": docker_cmd[1:] if len(docker_cmd) > 1 else []}
100
- }
101
-
102
- # Initialize client and measure timing
103
- start_time = time.time()
104
- client = MCPClient(mcp_config=config, verbose=verbose, auto_trace=False)
105
- initialized = False
106
-
107
- try:
108
- if verbose:
109
- design.info(f"Initializing MCP client with command: {' '.join(docker_cmd)}")
110
-
111
- await client.initialize()
112
- initialized = True
113
- initialize_ms = int((time.time() - start_time) * 1000)
114
-
115
- # Get tools
116
- tools = await client.list_tools()
117
-
118
- # Extract tool information
119
- tool_info = []
120
- for tool in tools:
121
- tool_dict = {"name": tool.name, "description": tool.description}
122
- if hasattr(tool, "inputSchema") and tool.inputSchema:
123
- tool_dict["inputSchema"] = tool.inputSchema
124
- tool_info.append(tool_dict)
125
-
126
- return {
127
- "initializeMs": initialize_ms,
128
- "toolCount": len(tools),
129
- "tools": tool_info,
130
- "success": True,
131
- }
132
- except Exception as e:
133
- import traceback
134
-
135
- error_msg = str(e)
136
- if verbose:
137
- design.error(f"Failed to analyze environment: {error_msg}")
138
- design.error(f"Traceback:\n{traceback.format_exc()}")
139
-
140
- # Common issues
141
- if "Connection reset" in error_msg or "EOF" in error_msg:
142
- design.warning(
143
- "The MCP server may have crashed on startup. Check your server.py for errors."
144
- )
145
- elif "timeout" in error_msg:
146
- design.warning(
147
- "The MCP server took too long to initialize. It might need more startup time."
148
- )
149
-
150
- return {
151
- "initializeMs": 0,
152
- "toolCount": 0,
153
- "tools": [],
154
- "success": False,
155
- "error": error_msg,
156
- }
157
- finally:
158
- # Only shutdown if we successfully initialized
159
- if initialized:
160
- try:
161
- await client.shutdown()
162
- except Exception:
163
- # Ignore shutdown errors
164
- design.warning("Failed to shutdown MCP client")
165
-
166
-
167
- def build_docker_image(
168
- directory: Path, tag: str, no_cache: bool = False, verbose: bool = False
169
- ) -> bool:
170
- """Build a Docker image from a directory."""
171
- design = HUDDesign()
172
-
173
- # Check if Dockerfile exists
174
- dockerfile = directory / "Dockerfile"
175
- if not dockerfile.exists():
176
- design.error(f"No Dockerfile found in {directory}")
177
- return False
178
-
179
- # Build command
180
- cmd = ["docker", "build", "-t", tag]
181
- if no_cache:
182
- cmd.append("--no-cache")
183
- cmd.append(str(directory))
184
-
185
- # Always show build output
186
- design.info(f"Running: {' '.join(cmd)}")
187
-
188
- try:
189
- # Run with real-time output, handling encoding issues on Windows
190
- process = subprocess.Popen( # noqa: S603
191
- cmd,
192
- stdout=subprocess.PIPE,
193
- stderr=subprocess.STDOUT,
194
- text=True,
195
- encoding="utf-8",
196
- errors="replace", # Replace invalid chars instead of failing
197
- )
198
-
199
- # Stream output
200
- for line in process.stdout or []:
201
- design.info(line.rstrip())
202
-
203
- process.wait()
204
-
205
- return process.returncode == 0
206
- except Exception as e:
207
- design.error(f"Build error: {e}")
208
- return False
209
-
210
-
211
- def build_environment(
212
- directory: str = ".", tag: str | None = None, no_cache: bool = False, verbose: bool = False
213
- ) -> None:
214
- """Build a HUD environment and generate lock file."""
215
- design = HUDDesign()
216
- design.header("HUD Environment Build")
217
-
218
- # Resolve directory
219
- env_dir = Path(directory).resolve()
220
- if not env_dir.exists():
221
- design.error(f"Directory not found: {directory}")
222
- raise typer.Exit(1)
223
-
224
- # Check for pyproject.toml
225
- pyproject_path = env_dir / "pyproject.toml"
226
- if not pyproject_path.exists():
227
- design.error(f"No pyproject.toml found in {directory}")
228
- raise typer.Exit(1)
229
-
230
- # Read pyproject.toml to get image name
231
- try:
232
- import toml
233
-
234
- pyproject = toml.load(pyproject_path)
235
- default_image = pyproject.get("tool", {}).get("hud", {}).get("image", None)
236
- if not default_image:
237
- # Generate default from directory name
238
- default_image = f"{env_dir.name}:dev"
239
- except Exception:
240
- default_image = f"{env_dir.name}:dev"
241
-
242
- # Use provided tag or default
243
- if not tag:
244
- tag = default_image
245
-
246
- # Build temporary image first
247
- temp_tag = f"hud-build-temp:{int(time.time())}"
248
-
249
- design.progress_message(f"Building Docker image: {temp_tag}")
250
-
251
- # Build the image
252
- if not build_docker_image(env_dir, temp_tag, no_cache, verbose):
253
- design.error("Docker build failed")
254
- raise typer.Exit(1)
255
-
256
- design.success(f"Built temporary image: {temp_tag}")
257
-
258
- # Analyze the environment
259
- design.progress_message("Analyzing MCP environment...")
260
-
261
- loop = asyncio.new_event_loop()
262
- asyncio.set_event_loop(loop)
263
- try:
264
- analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose))
265
- finally:
266
- loop.close()
267
-
268
- if not analysis["success"]:
269
- design.error("Failed to analyze MCP environment")
270
- if "error" in analysis:
271
- design.error(f"Error: {analysis['error']}")
272
-
273
- # Provide helpful debugging tips
274
- design.section_title("Debugging Tips")
275
- console.print("1. Test your server directly:")
276
- console.print(f" [cyan]docker run --rm -it {temp_tag}[/cyan]")
277
- console.print(" (Should see MCP initialization output)")
278
- console.print("")
279
- console.print("2. Check for common issues:")
280
- console.print(" - Server crashes on startup")
281
- console.print(" - Missing dependencies")
282
- console.print(" - Syntax errors in server.py")
283
- console.print("")
284
- console.print("3. Run with verbose mode:")
285
- console.print(" [cyan]hud build . --verbose[/cyan]")
286
-
287
- raise typer.Exit(1)
288
-
289
- design.success(f"Analyzed environment: {analysis['toolCount']} tools found")
290
-
291
- # Extract environment variables from Dockerfile
292
- dockerfile_path = env_dir / "Dockerfile"
293
- required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
294
-
295
- # Create lock file content - minimal and useful
296
- lock_content = {
297
- "version": "1.0", # Lock file format version
298
- "image": tag, # Will be updated with ID/digest later
299
- "build": {
300
- "generatedAt": datetime.utcnow().isoformat() + "Z",
301
- "hudVersion": hud_version,
302
- "directory": str(env_dir.name),
303
- },
304
- "environment": {
305
- "initializeMs": analysis["initializeMs"],
306
- "toolCount": analysis["toolCount"],
307
- },
308
- }
309
-
310
- # Only add environment variables if they exist
311
- if required_env or optional_env:
312
- lock_content["environment"]["variables"] = {}
313
- if required_env:
314
- lock_content["environment"]["variables"]["required"] = required_env
315
- if optional_env:
316
- lock_content["environment"]["variables"]["optional"] = optional_env
317
-
318
- # Add tool summary (not full schemas to keep it concise)
319
- if analysis["tools"]:
320
- lock_content["tools"] = [
321
- {"name": tool["name"], "description": tool.get("description", "")}
322
- for tool in analysis["tools"]
323
- ]
324
-
325
- # Write lock file
326
- lock_path = env_dir / "hud.lock.yaml"
327
- with open(lock_path, "w") as f:
328
- yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
329
-
330
- design.success("Created lock file: hud.lock.yaml")
331
-
332
- # Calculate lock file hash
333
- lock_content_str = yaml.dump(lock_content, default_flow_style=False, sort_keys=True)
334
- lock_hash = hashlib.sha256(lock_content_str.encode()).hexdigest()
335
- lock_size = len(lock_content_str)
336
-
337
- # Rebuild with label containing lock file hash
338
- design.progress_message("Rebuilding with lock file metadata...")
339
-
340
- # Build final image with label (uses cache from first build)
341
- label_cmd = [
342
- "docker",
343
- "build",
344
- "--label",
345
- f"org.hud.manifest.head={lock_hash}:{lock_size}",
346
- "-t",
347
- tag,
348
- str(env_dir),
349
- ]
350
-
351
- # Run rebuild with proper encoding
352
- process = subprocess.Popen( # noqa: S603
353
- label_cmd,
354
- stdout=subprocess.PIPE,
355
- stderr=subprocess.STDOUT,
356
- text=True,
357
- encoding="utf-8",
358
- errors="replace",
359
- )
360
-
361
- # Stream output if verbose
362
- if verbose:
363
- for line in process.stdout or []:
364
- design.info(line.rstrip())
365
- else:
366
- # Just consume output to avoid blocking
367
- process.stdout.read() # type: ignore
368
-
369
- process.wait()
370
-
371
- if process.returncode != 0:
372
- design.error("Failed to rebuild with label")
373
- raise typer.Exit(1)
374
-
375
- design.success("Built final image with lock file metadata")
376
-
377
- # NOW get the image ID after the final build
378
- image_id = get_docker_image_id(tag) # type: ignore
379
- if image_id:
380
- # For local builds, store the image ID
381
- # Docker IDs come as sha256:hash, we want tag@sha256:hash
382
- if image_id.startswith("sha256:"):
383
- lock_content["image"] = f"{tag}@{image_id}"
384
- else:
385
- lock_content["image"] = f"{tag}@sha256:{image_id}"
386
-
387
- # Update the lock file with the new image reference
388
- with open(lock_path, "w") as f:
389
- yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
390
-
391
- design.success("Updated lock file with image ID")
392
- else:
393
- design.warning("Could not retrieve image ID for lock file")
394
-
395
- # Remove temp image after we're done
396
- subprocess.run(["docker", "rmi", temp_tag], capture_output=True) # noqa: S603, S607
397
-
398
- # Print summary
399
- design.section_title("Build Complete")
400
-
401
- # Show the actual image reference from the lock file
402
- final_image_ref = lock_content.get("image", tag)
403
- console.print(f"[green]✓[/green] Local image : {final_image_ref}")
404
- console.print("[green]✓[/green] Lock file : hud.lock.yaml")
405
- console.print(f"[green]✓[/green] Tools found : {analysis['toolCount']}")
406
-
407
- design.section_title("Next Steps")
408
- console.print("Test locally:")
409
- console.print(" [cyan]hud dev[/cyan] # Hot-reload development")
410
- console.print(f" [cyan]hud run {tag}[/cyan] # Run the built image")
411
- console.print("")
412
- console.print("Publish to registry:")
413
- console.print(f" [cyan]hud push --image <registry>/{tag}[/cyan]")
414
- console.print("")
415
- console.print("The lock file can be used to reproduce this exact environment.")
416
-
417
-
418
- def build_command(
419
- directory: str = typer.Argument(".", help="Environment directory to build"),
420
- tag: str | None = typer.Option(
421
- None, "--tag", "-t", help="Docker image tag (default: from pyproject.toml)"
422
- ),
423
- no_cache: bool = typer.Option(False, "--no-cache", help="Build without Docker cache"),
424
- verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
425
- ) -> None:
426
- """Build a HUD environment and generate lock file."""
427
- build_environment(directory, tag, no_cache, verbose)
1
+ """Build HUD environments and generate lock files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import subprocess
8
+ import time
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import typer
14
+ import yaml
15
+ from rich.console import Console
16
+
17
+ from hud.clients import MCPClient
18
+ from hud.utils.design import HUDDesign
19
+ from hud.version import __version__ as hud_version
20
+
21
+ console = Console()
22
+
23
+
24
+ def get_docker_image_digest(image: str) -> str | None:
25
+ """Get the digest of a Docker image."""
26
+ try:
27
+ result = subprocess.run( # noqa: S603
28
+ ["docker", "inspect", "--format", "{{.RepoDigests}}", image], # noqa: S607
29
+ capture_output=True,
30
+ text=True,
31
+ check=True,
32
+ )
33
+ # Parse the output - it's in format [repo@sha256:digest]
34
+ digests = result.stdout.strip()
35
+ if digests and digests != "[]":
36
+ # Extract the first digest
37
+ digest_list = eval(digests) # noqa: S307 # Safe since it's from docker
38
+ if digest_list:
39
+ # Return full image reference with digest
40
+ return digest_list[0]
41
+ except Exception:
42
+ console.print("Failed to get Docker image digest")
43
+ return None
44
+
45
+
46
+ def get_docker_image_id(image: str) -> str | None:
47
+ """Get the ID of a Docker image."""
48
+ try:
49
+ result = subprocess.run( # noqa: S603
50
+ ["docker", "inspect", "--format", "{{.Id}}", image], # noqa: S607
51
+ capture_output=True,
52
+ text=True,
53
+ check=True,
54
+ )
55
+ image_id = result.stdout.strip()
56
+ if image_id:
57
+ return image_id
58
+ return None
59
+ except Exception:
60
+ # Don't log here to avoid import issues
61
+ return None
62
+
63
+
64
+ def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str], list[str]]:
65
+ """Extract required and optional environment variables from Dockerfile."""
66
+ required = []
67
+ optional = []
68
+
69
+ if not dockerfile_path.exists():
70
+ return required, optional
71
+
72
+ # Simple parsing - look for ENV directives
73
+ # This is a basic implementation - could be enhanced
74
+ content = dockerfile_path.read_text()
75
+ for line in content.splitlines():
76
+ line = line.strip()
77
+ if line.startswith("ENV "):
78
+ # Basic parsing - this could be more sophisticated
79
+ parts = line[4:].strip().split("=", 1)
80
+ if len(parts) == 2 and not parts[1].strip():
81
+ # No default value = required
82
+ required.append(parts[0])
83
+ elif len(parts) == 1:
84
+ # No equals sign = required
85
+ required.append(parts[0])
86
+
87
+ return required, optional
88
+
89
+
90
+ async def analyze_mcp_environment(image: str, verbose: bool = False) -> dict[str, Any]:
91
+ """Analyze an MCP environment to extract metadata."""
92
+ design = HUDDesign()
93
+
94
+ # Build Docker command to run the image
95
+ docker_cmd = ["docker", "run", "--rm", "-i", image]
96
+
97
+ # Create MCP config
98
+ config = {
99
+ "server": {"command": docker_cmd[0], "args": docker_cmd[1:] if len(docker_cmd) > 1 else []}
100
+ }
101
+
102
+ # Initialize client and measure timing
103
+ start_time = time.time()
104
+ client = MCPClient(mcp_config=config, verbose=verbose, auto_trace=False)
105
+ initialized = False
106
+
107
+ try:
108
+ if verbose:
109
+ design.info(f"Initializing MCP client with command: {' '.join(docker_cmd)}")
110
+
111
+ await client.initialize()
112
+ initialized = True
113
+ initialize_ms = int((time.time() - start_time) * 1000)
114
+
115
+ # Get tools
116
+ tools = await client.list_tools()
117
+
118
+ # Extract tool information
119
+ tool_info = []
120
+ for tool in tools:
121
+ tool_dict = {"name": tool.name, "description": tool.description}
122
+ if hasattr(tool, "inputSchema") and tool.inputSchema:
123
+ tool_dict["inputSchema"] = tool.inputSchema
124
+ tool_info.append(tool_dict)
125
+
126
+ return {
127
+ "initializeMs": initialize_ms,
128
+ "toolCount": len(tools),
129
+ "tools": tool_info,
130
+ "success": True,
131
+ }
132
+ except Exception as e:
133
+ import traceback
134
+
135
+ error_msg = str(e)
136
+ if verbose:
137
+ design.error(f"Failed to analyze environment: {error_msg}")
138
+ design.error(f"Traceback:\n{traceback.format_exc()}")
139
+
140
+ # Common issues
141
+ if "Connection reset" in error_msg or "EOF" in error_msg:
142
+ design.warning(
143
+ "The MCP server may have crashed on startup. Check your server.py for errors."
144
+ )
145
+ elif "timeout" in error_msg:
146
+ design.warning(
147
+ "The MCP server took too long to initialize. It might need more startup time."
148
+ )
149
+
150
+ return {
151
+ "initializeMs": 0,
152
+ "toolCount": 0,
153
+ "tools": [],
154
+ "success": False,
155
+ "error": error_msg,
156
+ }
157
+ finally:
158
+ # Only shutdown if we successfully initialized
159
+ if initialized:
160
+ try:
161
+ await client.shutdown()
162
+ except Exception:
163
+ # Ignore shutdown errors
164
+ design.warning("Failed to shutdown MCP client")
165
+
166
+
167
+ def build_docker_image(
168
+ directory: Path, tag: str, no_cache: bool = False, verbose: bool = False
169
+ ) -> bool:
170
+ """Build a Docker image from a directory."""
171
+ design = HUDDesign()
172
+
173
+ # Check if Dockerfile exists
174
+ dockerfile = directory / "Dockerfile"
175
+ if not dockerfile.exists():
176
+ design.error(f"No Dockerfile found in {directory}")
177
+ return False
178
+
179
+ # Build command
180
+ cmd = ["docker", "build", "-t", tag]
181
+ if no_cache:
182
+ cmd.append("--no-cache")
183
+ cmd.append(str(directory))
184
+
185
+ # Always show build output
186
+ design.info(f"Running: {' '.join(cmd)}")
187
+
188
+ try:
189
+ # Run with real-time output, handling encoding issues on Windows
190
+ process = subprocess.Popen( # noqa: S603
191
+ cmd,
192
+ stdout=subprocess.PIPE,
193
+ stderr=subprocess.STDOUT,
194
+ text=True,
195
+ encoding="utf-8",
196
+ errors="replace", # Replace invalid chars instead of failing
197
+ )
198
+
199
+ # Stream output
200
+ for line in process.stdout or []:
201
+ design.info(line.rstrip())
202
+
203
+ process.wait()
204
+
205
+ return process.returncode == 0
206
+ except Exception as e:
207
+ design.error(f"Build error: {e}")
208
+ return False
209
+
210
+
211
+ def build_environment(
212
+ directory: str = ".", tag: str | None = None, no_cache: bool = False, verbose: bool = False
213
+ ) -> None:
214
+ """Build a HUD environment and generate lock file."""
215
+ design = HUDDesign()
216
+ design.header("HUD Environment Build")
217
+
218
+ # Resolve directory
219
+ env_dir = Path(directory).resolve()
220
+ if not env_dir.exists():
221
+ design.error(f"Directory not found: {directory}")
222
+ raise typer.Exit(1)
223
+
224
+ # Check for pyproject.toml
225
+ pyproject_path = env_dir / "pyproject.toml"
226
+ if not pyproject_path.exists():
227
+ design.error(f"No pyproject.toml found in {directory}")
228
+ raise typer.Exit(1)
229
+
230
+ # Read pyproject.toml to get image name
231
+ try:
232
+ import toml
233
+
234
+ pyproject = toml.load(pyproject_path)
235
+ default_image = pyproject.get("tool", {}).get("hud", {}).get("image", None)
236
+ if not default_image:
237
+ # Generate default from directory name
238
+ default_image = f"{env_dir.name}:dev"
239
+ except Exception:
240
+ default_image = f"{env_dir.name}:dev"
241
+
242
+ # Use provided tag or default
243
+ if not tag:
244
+ tag = default_image
245
+
246
+ # Build temporary image first
247
+ temp_tag = f"hud-build-temp:{int(time.time())}"
248
+
249
+ design.progress_message(f"Building Docker image: {temp_tag}")
250
+
251
+ # Build the image
252
+ if not build_docker_image(env_dir, temp_tag, no_cache, verbose):
253
+ design.error("Docker build failed")
254
+ raise typer.Exit(1)
255
+
256
+ design.success(f"Built temporary image: {temp_tag}")
257
+
258
+ # Analyze the environment
259
+ design.progress_message("Analyzing MCP environment...")
260
+
261
+ loop = asyncio.new_event_loop()
262
+ asyncio.set_event_loop(loop)
263
+ try:
264
+ analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose))
265
+ finally:
266
+ loop.close()
267
+
268
+ if not analysis["success"]:
269
+ design.error("Failed to analyze MCP environment")
270
+ if "error" in analysis:
271
+ design.error(f"Error: {analysis['error']}")
272
+
273
+ # Provide helpful debugging tips
274
+ design.section_title("Debugging Tips")
275
+ console.print("1. Test your server directly:")
276
+ console.print(f" [cyan]docker run --rm -it {temp_tag}[/cyan]")
277
+ console.print(" (Should see MCP initialization output)")
278
+ console.print("")
279
+ console.print("2. Check for common issues:")
280
+ console.print(" - Server crashes on startup")
281
+ console.print(" - Missing dependencies")
282
+ console.print(" - Syntax errors in server.py")
283
+ console.print("")
284
+ console.print("3. Run with verbose mode:")
285
+ console.print(" [cyan]hud build . --verbose[/cyan]")
286
+
287
+ raise typer.Exit(1)
288
+
289
+ design.success(f"Analyzed environment: {analysis['toolCount']} tools found")
290
+
291
+ # Extract environment variables from Dockerfile
292
+ dockerfile_path = env_dir / "Dockerfile"
293
+ required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
294
+
295
+ # Create lock file content - minimal and useful
296
+ lock_content = {
297
+ "version": "1.0", # Lock file format version
298
+ "image": tag, # Will be updated with ID/digest later
299
+ "build": {
300
+ "generatedAt": datetime.utcnow().isoformat() + "Z",
301
+ "hudVersion": hud_version,
302
+ "directory": str(env_dir.name),
303
+ },
304
+ "environment": {
305
+ "initializeMs": analysis["initializeMs"],
306
+ "toolCount": analysis["toolCount"],
307
+ },
308
+ }
309
+
310
+ # Only add environment variables if they exist
311
+ if required_env or optional_env:
312
+ lock_content["environment"]["variables"] = {}
313
+ if required_env:
314
+ lock_content["environment"]["variables"]["required"] = required_env
315
+ if optional_env:
316
+ lock_content["environment"]["variables"]["optional"] = optional_env
317
+
318
+ # Add tool summary (not full schemas to keep it concise)
319
+ if analysis["tools"]:
320
+ lock_content["tools"] = [
321
+ {"name": tool["name"], "description": tool.get("description", "")}
322
+ for tool in analysis["tools"]
323
+ ]
324
+
325
+ # Write lock file
326
+ lock_path = env_dir / "hud.lock.yaml"
327
+ with open(lock_path, "w") as f:
328
+ yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
329
+
330
+ design.success("Created lock file: hud.lock.yaml")
331
+
332
+ # Calculate lock file hash
333
+ lock_content_str = yaml.dump(lock_content, default_flow_style=False, sort_keys=True)
334
+ lock_hash = hashlib.sha256(lock_content_str.encode()).hexdigest()
335
+ lock_size = len(lock_content_str)
336
+
337
+ # Rebuild with label containing lock file hash
338
+ design.progress_message("Rebuilding with lock file metadata...")
339
+
340
+ # Build final image with label (uses cache from first build)
341
+ label_cmd = [
342
+ "docker",
343
+ "build",
344
+ "--label",
345
+ f"org.hud.manifest.head={lock_hash}:{lock_size}",
346
+ "-t",
347
+ tag,
348
+ str(env_dir),
349
+ ]
350
+
351
+ # Run rebuild with proper encoding
352
+ process = subprocess.Popen( # noqa: S603
353
+ label_cmd,
354
+ stdout=subprocess.PIPE,
355
+ stderr=subprocess.STDOUT,
356
+ text=True,
357
+ encoding="utf-8",
358
+ errors="replace",
359
+ )
360
+
361
+ # Stream output if verbose
362
+ if verbose:
363
+ for line in process.stdout or []:
364
+ design.info(line.rstrip())
365
+ else:
366
+ # Just consume output to avoid blocking
367
+ process.stdout.read() # type: ignore
368
+
369
+ process.wait()
370
+
371
+ if process.returncode != 0:
372
+ design.error("Failed to rebuild with label")
373
+ raise typer.Exit(1)
374
+
375
+ design.success("Built final image with lock file metadata")
376
+
377
+ # NOW get the image ID after the final build
378
+ image_id = get_docker_image_id(tag) # type: ignore
379
+ if image_id:
380
+ # For local builds, store the image ID
381
+ # Docker IDs come as sha256:hash, we want tag@sha256:hash
382
+ if image_id.startswith("sha256:"):
383
+ lock_content["image"] = f"{tag}@{image_id}"
384
+ else:
385
+ lock_content["image"] = f"{tag}@sha256:{image_id}"
386
+
387
+ # Update the lock file with the new image reference
388
+ with open(lock_path, "w") as f:
389
+ yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
390
+
391
+ design.success("Updated lock file with image ID")
392
+ else:
393
+ design.warning("Could not retrieve image ID for lock file")
394
+
395
+ # Remove temp image after we're done
396
+ subprocess.run(["docker", "rmi", temp_tag], capture_output=True) # noqa: S603, S607
397
+
398
+ # Print summary
399
+ design.section_title("Build Complete")
400
+
401
+ # Show the actual image reference from the lock file
402
+ final_image_ref = lock_content.get("image", tag)
403
+ console.print(f"[green]✓[/green] Local image : {final_image_ref}")
404
+ console.print("[green]✓[/green] Lock file : hud.lock.yaml")
405
+ console.print(f"[green]✓[/green] Tools found : {analysis['toolCount']}")
406
+
407
+ design.section_title("Next Steps")
408
+ console.print("Test locally:")
409
+ console.print(" [cyan]hud dev[/cyan] # Hot-reload development")
410
+ console.print(f" [cyan]hud run {tag}[/cyan] # Run the built image")
411
+ console.print("")
412
+ console.print("Publish to registry:")
413
+ console.print(f" [cyan]hud push --image <registry>/{tag}[/cyan]")
414
+ console.print("")
415
+ console.print("The lock file can be used to reproduce this exact environment.")
416
+
417
+
418
+ def build_command(
419
+ directory: str = typer.Argument(".", help="Environment directory to build"),
420
+ tag: str | None = typer.Option(
421
+ None, "--tag", "-t", help="Docker image tag (default: from pyproject.toml)"
422
+ ),
423
+ no_cache: bool = typer.Option(False, "--no-cache", help="Build without Docker cache"),
424
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
425
+ ) -> None:
426
+ """Build a HUD environment and generate lock file."""
427
+ build_environment(directory, tag, no_cache, verbose)