hud-python 0.4.1__py3-none-any.whl → 0.4.3__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/cli/build.py CHANGED
@@ -1,427 +1,498 @@
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
+
16
+ from hud.clients import MCPClient
17
+ from hud.utils.design import HUDDesign
18
+ from hud.version import __version__ as hud_version
19
+
20
+
21
+ def parse_version(version_str: str) -> tuple[int, int, int]:
22
+ """Parse version string like '1.0.0' or '1.0' into tuple of integers."""
23
+ # Remove 'v' prefix if present
24
+ version_str = version_str.lstrip('v')
25
+
26
+ # Split by dots and pad with zeros if needed
27
+ parts = version_str.split('.')
28
+ parts.extend(['0'] * (3 - len(parts))) # Ensure we have at least 3 parts
29
+
30
+ try:
31
+ return (int(parts[0]), int(parts[1]), int(parts[2]))
32
+ except (ValueError, IndexError):
33
+ # Default to 0.0.0 if parsing fails
34
+ return (0, 0, 0)
35
+
36
+
37
+ def increment_version(version_str: str, increment_type: str = "patch") -> str:
38
+ """Increment version string. increment_type can be 'major', 'minor', or 'patch'."""
39
+ major, minor, patch = parse_version(version_str)
40
+
41
+ if increment_type == "major":
42
+ return f"{major + 1}.0.0"
43
+ elif increment_type == "minor":
44
+ return f"{major}.{minor + 1}.0"
45
+ else: # patch
46
+ return f"{major}.{minor}.{patch + 1}"
47
+
48
+
49
+ def get_existing_version(lock_path: Path) -> str | None:
50
+ """Get the internal version from existing lock file if it exists."""
51
+ if not lock_path.exists():
52
+ return None
53
+
54
+ try:
55
+ with open(lock_path) as f:
56
+ lock_data = yaml.safe_load(f)
57
+
58
+ # Look for internal version in build metadata
59
+ build_data = lock_data.get("build", {})
60
+ return build_data.get("version", None)
61
+ except Exception:
62
+ return None
63
+
64
+
65
+ def get_docker_image_digest(image: str) -> str | None:
66
+ """Get the digest of a Docker image."""
67
+ try:
68
+ result = subprocess.run( # noqa: S603
69
+ ["docker", "inspect", "--format", "{{.RepoDigests}}", image], # noqa: S607
70
+ capture_output=True,
71
+ text=True,
72
+ check=True,
73
+ )
74
+ # Parse the output - it's in format [repo@sha256:digest]
75
+ digests = result.stdout.strip()
76
+ if digests and digests != "[]":
77
+ # Extract the first digest
78
+ digest_list = eval(digests) # noqa: S307 # Safe since it's from docker
79
+ if digest_list:
80
+ # Return full image reference with digest
81
+ return digest_list[0]
82
+ except Exception:
83
+ # Don't print error here, let calling code handle it
84
+ pass
85
+ return None
86
+
87
+
88
+ def get_docker_image_id(image: str) -> str | None:
89
+ """Get the ID of a Docker image."""
90
+ try:
91
+ result = subprocess.run( # noqa: S603
92
+ ["docker", "inspect", "--format", "{{.Id}}", image], # noqa: S607
93
+ capture_output=True,
94
+ text=True,
95
+ check=True,
96
+ )
97
+ image_id = result.stdout.strip()
98
+ if image_id:
99
+ return image_id
100
+ return None
101
+ except Exception:
102
+ # Don't log here to avoid import issues
103
+ return None
104
+
105
+
106
+ def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str], list[str]]:
107
+ """Extract required and optional environment variables from Dockerfile."""
108
+ required = []
109
+ optional = []
110
+
111
+ if not dockerfile_path.exists():
112
+ return required, optional
113
+
114
+ # Simple parsing - look for ENV directives
115
+ # This is a basic implementation - could be enhanced
116
+ content = dockerfile_path.read_text()
117
+ for line in content.splitlines():
118
+ line = line.strip()
119
+ if line.startswith("ENV "):
120
+ # Basic parsing - this could be more sophisticated
121
+ parts = line[4:].strip().split("=", 1)
122
+ if len(parts) == 2 and not parts[1].strip():
123
+ # No default value = required
124
+ required.append(parts[0])
125
+ elif len(parts) == 1:
126
+ # No equals sign = required
127
+ required.append(parts[0])
128
+
129
+ return required, optional
130
+
131
+
132
+ async def analyze_mcp_environment(image: str, verbose: bool = False) -> dict[str, Any]:
133
+ """Analyze an MCP environment to extract metadata."""
134
+ design = HUDDesign()
135
+
136
+ # Build Docker command to run the image
137
+ docker_cmd = ["docker", "run", "--rm", "-i", image]
138
+
139
+ # Create MCP config
140
+ config = {
141
+ "server": {"command": docker_cmd[0], "args": docker_cmd[1:] if len(docker_cmd) > 1 else []}
142
+ }
143
+
144
+ # Initialize client and measure timing
145
+ start_time = time.time()
146
+ client = MCPClient(mcp_config=config, verbose=verbose, auto_trace=False)
147
+ initialized = False
148
+
149
+ try:
150
+ if verbose:
151
+ design.info(f"Initializing MCP client with command: {' '.join(docker_cmd)}")
152
+
153
+ await client.initialize()
154
+ initialized = True
155
+ initialize_ms = int((time.time() - start_time) * 1000)
156
+
157
+ # Get tools
158
+ tools = await client.list_tools()
159
+
160
+ # Extract tool information
161
+ tool_info = []
162
+ for tool in tools:
163
+ tool_dict = {"name": tool.name, "description": tool.description}
164
+ if hasattr(tool, "inputSchema") and tool.inputSchema:
165
+ tool_dict["inputSchema"] = tool.inputSchema
166
+ tool_info.append(tool_dict)
167
+
168
+ return {
169
+ "initializeMs": initialize_ms,
170
+ "toolCount": len(tools),
171
+ "tools": tool_info,
172
+ "success": True,
173
+ }
174
+ except Exception as e:
175
+ import traceback
176
+
177
+ error_msg = str(e)
178
+ if verbose:
179
+ design.error(f"Failed to analyze environment: {error_msg}")
180
+ design.error(f"Traceback:\n{traceback.format_exc()}")
181
+
182
+ # Common issues
183
+ if "Connection reset" in error_msg or "EOF" in error_msg:
184
+ design.warning(
185
+ "The MCP server may have crashed on startup. Check your server.py for errors."
186
+ )
187
+ elif "timeout" in error_msg:
188
+ design.warning(
189
+ "The MCP server took too long to initialize. It might need more startup time."
190
+ )
191
+
192
+ return {
193
+ "initializeMs": 0,
194
+ "toolCount": 0,
195
+ "tools": [],
196
+ "success": False,
197
+ "error": error_msg,
198
+ }
199
+ finally:
200
+ # Only shutdown if we successfully initialized
201
+ if initialized:
202
+ try:
203
+ await client.shutdown()
204
+ except Exception:
205
+ # Ignore shutdown errors
206
+ design.warning("Failed to shutdown MCP client")
207
+
208
+
209
+ def build_docker_image(
210
+ directory: Path, tag: str, no_cache: bool = False, verbose: bool = False
211
+ ) -> bool:
212
+ """Build a Docker image from a directory."""
213
+ design = HUDDesign()
214
+
215
+ # Check if Dockerfile exists
216
+ dockerfile = directory / "Dockerfile"
217
+ if not dockerfile.exists():
218
+ design.error(f"No Dockerfile found in {directory}")
219
+ return False
220
+
221
+ # Build command
222
+ cmd = ["docker", "build", "-t", tag]
223
+ if no_cache:
224
+ cmd.append("--no-cache")
225
+ cmd.append(str(directory))
226
+
227
+ # Always show build output
228
+ design.info(f"Running: {' '.join(cmd)}")
229
+
230
+ try:
231
+ # Run with real-time output, handling encoding issues on Windows
232
+ process = subprocess.Popen( # noqa: S603
233
+ cmd,
234
+ stdout=subprocess.PIPE,
235
+ stderr=subprocess.STDOUT,
236
+ text=True,
237
+ encoding="utf-8",
238
+ errors="replace", # Replace invalid chars instead of failing
239
+ )
240
+
241
+ # Stream output
242
+ for line in process.stdout or []:
243
+ design.info(line.rstrip())
244
+
245
+ process.wait()
246
+
247
+ return process.returncode == 0
248
+ except Exception as e:
249
+ design.error(f"Build error: {e}")
250
+ return False
251
+
252
+
253
+ def build_environment(
254
+ directory: str = ".", tag: str | None = None, no_cache: bool = False, verbose: bool = False
255
+ ) -> None:
256
+ """Build a HUD environment and generate lock file."""
257
+ design = HUDDesign()
258
+ design.header("HUD Environment Build")
259
+
260
+ # Resolve directory
261
+ env_dir = Path(directory).resolve()
262
+ if not env_dir.exists():
263
+ design.error(f"Directory not found: {directory}")
264
+ raise typer.Exit(1)
265
+
266
+ # Check for pyproject.toml
267
+ pyproject_path = env_dir / "pyproject.toml"
268
+ if not pyproject_path.exists():
269
+ design.error(f"No pyproject.toml found in {directory}")
270
+ raise typer.Exit(1)
271
+
272
+ # Read pyproject.toml to get image name
273
+ try:
274
+ import toml
275
+
276
+ pyproject = toml.load(pyproject_path)
277
+ default_image = pyproject.get("tool", {}).get("hud", {}).get("image", None)
278
+ if not default_image:
279
+ # Generate default from directory name
280
+ default_image = f"{env_dir.name}:dev"
281
+ except Exception:
282
+ default_image = f"{env_dir.name}:dev"
283
+
284
+ # Use provided tag or default
285
+ if not tag:
286
+ tag = default_image
287
+
288
+ # Build temporary image first
289
+ temp_tag = f"hud-build-temp:{int(time.time())}"
290
+
291
+ design.progress_message(f"Building Docker image: {temp_tag}")
292
+
293
+ # Build the image
294
+ if not build_docker_image(env_dir, temp_tag, no_cache, verbose):
295
+ design.error("Docker build failed")
296
+ raise typer.Exit(1)
297
+
298
+ design.success(f"Built temporary image: {temp_tag}")
299
+
300
+ # Analyze the environment
301
+ design.progress_message("Analyzing MCP environment...")
302
+
303
+ loop = asyncio.new_event_loop()
304
+ asyncio.set_event_loop(loop)
305
+ try:
306
+ analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose))
307
+ finally:
308
+ loop.close()
309
+
310
+ if not analysis["success"]:
311
+ design.error("Failed to analyze MCP environment")
312
+ if "error" in analysis:
313
+ design.error(f"Error: {analysis['error']}")
314
+
315
+ # Provide helpful debugging tips
316
+ design.section_title("Debugging Tips")
317
+ design.info("1. Test your server directly:")
318
+ design.command_example(f"docker run --rm -it {temp_tag}")
319
+ design.dim_info(" Expected output", "MCP initialization messages")
320
+ design.info("")
321
+ design.info("2. Check for common issues:")
322
+ design.info(" - Server crashes on startup")
323
+ design.info(" - Missing dependencies")
324
+ design.info(" - Syntax errors in server.py")
325
+ design.info("")
326
+ design.info("3. Run with verbose mode:")
327
+ design.command_example("hud build . --verbose")
328
+
329
+ raise typer.Exit(1)
330
+
331
+ design.success(f"Analyzed environment: {analysis['toolCount']} tools found")
332
+
333
+ # Extract environment variables from Dockerfile
334
+ dockerfile_path = env_dir / "Dockerfile"
335
+ required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
336
+
337
+ # Check for existing version and increment
338
+ lock_path = env_dir / "hud.lock.yaml"
339
+ existing_version = get_existing_version(lock_path)
340
+
341
+ if existing_version:
342
+ # Increment existing version
343
+ new_version = increment_version(existing_version)
344
+ design.info(f"Incrementing version: {existing_version} → {new_version}")
345
+ else:
346
+ # Start with 0.1.0 for new environments
347
+ new_version = "0.1.0"
348
+ design.info(f"Setting initial version: {new_version}")
349
+
350
+ # Create lock file content - minimal and useful
351
+ lock_content = {
352
+ "version": "1.0", # Lock file format version
353
+ "image": tag, # Will be updated with ID/digest later
354
+ "build": {
355
+ "generatedAt": datetime.utcnow().isoformat() + "Z",
356
+ "hudVersion": hud_version,
357
+ "directory": str(env_dir.name),
358
+ "version": new_version, # Internal environment version
359
+ },
360
+ "environment": {
361
+ "initializeMs": analysis["initializeMs"],
362
+ "toolCount": analysis["toolCount"],
363
+ },
364
+ }
365
+
366
+ # Only add environment variables if they exist
367
+ if required_env or optional_env:
368
+ lock_content["environment"]["variables"] = {}
369
+ if required_env:
370
+ lock_content["environment"]["variables"]["required"] = required_env
371
+ if optional_env:
372
+ lock_content["environment"]["variables"]["optional"] = optional_env
373
+
374
+ # Add tool summary (not full schemas to keep it concise)
375
+ if analysis["tools"]:
376
+ lock_content["tools"] = [
377
+ {"name": tool["name"], "description": tool.get("description", "")}
378
+ for tool in analysis["tools"]
379
+ ]
380
+
381
+ # Write lock file
382
+ lock_path = env_dir / "hud.lock.yaml"
383
+ with open(lock_path, "w") as f:
384
+ yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
385
+
386
+ design.success("Created lock file: hud.lock.yaml")
387
+
388
+ # Calculate lock file hash
389
+ lock_content_str = yaml.dump(lock_content, default_flow_style=False, sort_keys=True)
390
+ lock_hash = hashlib.sha256(lock_content_str.encode()).hexdigest()
391
+ lock_size = len(lock_content_str)
392
+
393
+ # Rebuild with label containing lock file hash
394
+ design.progress_message("Rebuilding with lock file metadata...")
395
+
396
+ # Build final image with label (uses cache from first build)
397
+ # Also tag with version
398
+ base_name = tag.split(":")[0] if tag and ":" in tag else tag
399
+ version_tag = f"{base_name}:{new_version}"
400
+
401
+ label_cmd = [
402
+ "docker",
403
+ "build",
404
+ "--label",
405
+ f"org.hud.manifest.head={lock_hash}:{lock_size}",
406
+ "--label",
407
+ f"org.hud.version={new_version}",
408
+ "-t",
409
+ tag,
410
+ "-t",
411
+ version_tag,
412
+ str(env_dir),
413
+ ]
414
+
415
+ # Run rebuild with proper encoding
416
+ process = subprocess.Popen( # noqa: S603
417
+ label_cmd,
418
+ stdout=subprocess.PIPE,
419
+ stderr=subprocess.STDOUT,
420
+ text=True,
421
+ encoding="utf-8",
422
+ errors="replace",
423
+ )
424
+
425
+ # Stream output if verbose
426
+ if verbose:
427
+ for line in process.stdout or []:
428
+ design.info(line.rstrip())
429
+ else:
430
+ # Just consume output to avoid blocking
431
+ process.stdout.read() # type: ignore
432
+
433
+ process.wait()
434
+
435
+ if process.returncode != 0:
436
+ design.error("Failed to rebuild with label")
437
+ raise typer.Exit(1)
438
+
439
+ design.success("Built final image with lock file metadata")
440
+
441
+ # NOW get the image ID after the final build
442
+ image_id = get_docker_image_id(tag) # type: ignore
443
+ if image_id:
444
+ # For local builds, store the image ID
445
+ # Docker IDs come as sha256:hash, we want tag@sha256:hash
446
+ if image_id.startswith("sha256:"):
447
+ lock_content["image"] = f"{tag}@{image_id}"
448
+ else:
449
+ lock_content["image"] = f"{tag}@sha256:{image_id}"
450
+
451
+ # Update the lock file with the new image reference
452
+ with open(lock_path, "w") as f:
453
+ yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
454
+
455
+ design.success("Updated lock file with image ID")
456
+ else:
457
+ design.warning("Could not retrieve image ID for lock file")
458
+
459
+ # Remove temp image after we're done
460
+ subprocess.run(["docker", "rmi", temp_tag], capture_output=True) # noqa: S603, S607
461
+
462
+ # Print summary
463
+ design.section_title("Build Complete")
464
+
465
+ # Show the version tag as primary since that's what will be pushed
466
+ design.status_item("Built image", version_tag, primary=True)
467
+ if tag:
468
+ design.status_item("Also tagged", tag)
469
+ design.status_item("Version", new_version)
470
+ design.status_item("Lock file", "hud.lock.yaml")
471
+ design.status_item("Tools found", str(analysis['toolCount']))
472
+
473
+ # Show the digest info separately if we have it
474
+ if image_id:
475
+ design.dim_info("\nImage digest", image_id)
476
+
477
+ design.section_title("Next Steps")
478
+ design.info("Test locally:")
479
+ design.command_example("hud dev", "Hot-reload development")
480
+ design.command_example(f"hud run {tag}", "Run the built image")
481
+ design.info("")
482
+ design.info("Publish to registry:")
483
+ design.command_example("hud push", f"Push as {version_tag}")
484
+ design.command_example("hud push --tag latest", "Push with custom tag")
485
+ design.info("")
486
+ design.info("The lock file can be used to reproduce this exact environment.")
487
+
488
+
489
+ def build_command(
490
+ directory: str = typer.Argument(".", help="Environment directory to build"),
491
+ tag: str | None = typer.Option(
492
+ None, "--tag", "-t", help="Docker image tag (default: from pyproject.toml)"
493
+ ),
494
+ no_cache: bool = typer.Option(False, "--no-cache", help="Build without Docker cache"),
495
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
496
+ ) -> None:
497
+ """Build a HUD environment and generate lock file."""
498
+ build_environment(directory, tag, no_cache, verbose)