hud-python 0.4.45__py3-none-any.whl → 0.5.1__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.
Files changed (274) hide show
  1. hud/__init__.py +27 -7
  2. hud/agents/__init__.py +11 -5
  3. hud/agents/base.py +220 -500
  4. hud/agents/claude.py +200 -240
  5. hud/agents/gemini.py +275 -0
  6. hud/agents/gemini_cua.py +335 -0
  7. hud/agents/grounded_openai.py +98 -100
  8. hud/agents/misc/integration_test_agent.py +51 -20
  9. hud/agents/misc/response_agent.py +41 -36
  10. hud/agents/openai.py +291 -292
  11. hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
  12. hud/agents/operator.py +211 -0
  13. hud/agents/tests/conftest.py +133 -0
  14. hud/agents/tests/test_base.py +300 -622
  15. hud/agents/tests/test_base_runtime.py +233 -0
  16. hud/agents/tests/test_claude.py +379 -210
  17. hud/agents/tests/test_client.py +9 -10
  18. hud/agents/tests/test_gemini.py +369 -0
  19. hud/agents/tests/test_grounded_openai_agent.py +65 -50
  20. hud/agents/tests/test_openai.py +376 -140
  21. hud/agents/tests/test_operator.py +362 -0
  22. hud/agents/tests/test_run_eval.py +179 -0
  23. hud/cli/__init__.py +461 -545
  24. hud/cli/analyze.py +43 -5
  25. hud/cli/build.py +664 -110
  26. hud/cli/debug.py +8 -5
  27. hud/cli/dev.py +882 -734
  28. hud/cli/eval.py +782 -668
  29. hud/cli/flows/dev.py +167 -0
  30. hud/cli/flows/init.py +191 -0
  31. hud/cli/flows/tasks.py +153 -56
  32. hud/cli/flows/templates.py +151 -0
  33. hud/cli/flows/tests/__init__.py +1 -0
  34. hud/cli/flows/tests/test_dev.py +126 -0
  35. hud/cli/init.py +60 -58
  36. hud/cli/push.py +29 -11
  37. hud/cli/rft.py +311 -0
  38. hud/cli/rft_status.py +145 -0
  39. hud/cli/tests/test_analyze.py +5 -5
  40. hud/cli/tests/test_analyze_metadata.py +3 -2
  41. hud/cli/tests/test_analyze_module.py +120 -0
  42. hud/cli/tests/test_build.py +108 -6
  43. hud/cli/tests/test_build_failure.py +41 -0
  44. hud/cli/tests/test_build_module.py +50 -0
  45. hud/cli/tests/test_cli_init.py +6 -1
  46. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  47. hud/cli/tests/test_cli_root.py +140 -0
  48. hud/cli/tests/test_convert.py +361 -0
  49. hud/cli/tests/test_debug.py +12 -10
  50. hud/cli/tests/test_dev.py +197 -0
  51. hud/cli/tests/test_eval.py +251 -0
  52. hud/cli/tests/test_eval_bedrock.py +51 -0
  53. hud/cli/tests/test_init.py +124 -0
  54. hud/cli/tests/test_main_module.py +11 -5
  55. hud/cli/tests/test_mcp_server.py +12 -100
  56. hud/cli/tests/test_push_happy.py +74 -0
  57. hud/cli/tests/test_push_wrapper.py +23 -0
  58. hud/cli/tests/test_registry.py +1 -1
  59. hud/cli/tests/test_utils.py +1 -1
  60. hud/cli/{rl → utils}/celebrate.py +14 -12
  61. hud/cli/utils/config.py +18 -1
  62. hud/cli/utils/docker.py +130 -4
  63. hud/cli/utils/env_check.py +9 -9
  64. hud/cli/utils/git.py +136 -0
  65. hud/cli/utils/interactive.py +39 -5
  66. hud/cli/utils/metadata.py +69 -0
  67. hud/cli/utils/runner.py +1 -1
  68. hud/cli/utils/server.py +2 -2
  69. hud/cli/utils/source_hash.py +3 -3
  70. hud/cli/utils/tasks.py +4 -1
  71. hud/cli/utils/tests/__init__.py +0 -0
  72. hud/cli/utils/tests/test_config.py +58 -0
  73. hud/cli/utils/tests/test_docker.py +93 -0
  74. hud/cli/utils/tests/test_docker_hints.py +71 -0
  75. hud/cli/utils/tests/test_env_check.py +74 -0
  76. hud/cli/utils/tests/test_environment.py +42 -0
  77. hud/cli/utils/tests/test_git.py +142 -0
  78. hud/cli/utils/tests/test_interactive_module.py +60 -0
  79. hud/cli/utils/tests/test_local_runner.py +50 -0
  80. hud/cli/utils/tests/test_logging_utils.py +23 -0
  81. hud/cli/utils/tests/test_metadata.py +49 -0
  82. hud/cli/utils/tests/test_package_runner.py +35 -0
  83. hud/cli/utils/tests/test_registry_utils.py +49 -0
  84. hud/cli/utils/tests/test_remote_runner.py +25 -0
  85. hud/cli/utils/tests/test_runner_modules.py +52 -0
  86. hud/cli/utils/tests/test_source_hash.py +36 -0
  87. hud/cli/utils/tests/test_tasks.py +80 -0
  88. hud/cli/utils/version_check.py +258 -0
  89. hud/cli/{rl → utils}/viewer.py +2 -2
  90. hud/clients/README.md +12 -11
  91. hud/clients/__init__.py +4 -3
  92. hud/clients/base.py +166 -26
  93. hud/clients/environment.py +51 -0
  94. hud/clients/fastmcp.py +13 -6
  95. hud/clients/mcp_use.py +40 -15
  96. hud/clients/tests/test_analyze_scenarios.py +206 -0
  97. hud/clients/tests/test_protocol.py +9 -3
  98. hud/datasets/__init__.py +23 -20
  99. hud/datasets/loader.py +327 -0
  100. hud/datasets/runner.py +192 -105
  101. hud/datasets/tests/__init__.py +0 -0
  102. hud/datasets/tests/test_loader.py +221 -0
  103. hud/datasets/tests/test_utils.py +315 -0
  104. hud/datasets/utils.py +270 -90
  105. hud/environment/__init__.py +50 -0
  106. hud/environment/connection.py +206 -0
  107. hud/environment/connectors/__init__.py +33 -0
  108. hud/environment/connectors/base.py +68 -0
  109. hud/environment/connectors/local.py +177 -0
  110. hud/environment/connectors/mcp_config.py +109 -0
  111. hud/environment/connectors/openai.py +101 -0
  112. hud/environment/connectors/remote.py +172 -0
  113. hud/environment/environment.py +694 -0
  114. hud/environment/integrations/__init__.py +45 -0
  115. hud/environment/integrations/adk.py +67 -0
  116. hud/environment/integrations/anthropic.py +196 -0
  117. hud/environment/integrations/gemini.py +92 -0
  118. hud/environment/integrations/langchain.py +82 -0
  119. hud/environment/integrations/llamaindex.py +68 -0
  120. hud/environment/integrations/openai.py +238 -0
  121. hud/environment/mock.py +306 -0
  122. hud/environment/router.py +112 -0
  123. hud/environment/scenarios.py +493 -0
  124. hud/environment/tests/__init__.py +1 -0
  125. hud/environment/tests/test_connection.py +317 -0
  126. hud/environment/tests/test_connectors.py +218 -0
  127. hud/environment/tests/test_environment.py +161 -0
  128. hud/environment/tests/test_integrations.py +257 -0
  129. hud/environment/tests/test_local_connectors.py +201 -0
  130. hud/environment/tests/test_scenarios.py +280 -0
  131. hud/environment/tests/test_tools.py +208 -0
  132. hud/environment/types.py +23 -0
  133. hud/environment/utils/__init__.py +35 -0
  134. hud/environment/utils/formats.py +215 -0
  135. hud/environment/utils/schema.py +171 -0
  136. hud/environment/utils/tool_wrappers.py +113 -0
  137. hud/eval/__init__.py +67 -0
  138. hud/eval/context.py +674 -0
  139. hud/eval/display.py +299 -0
  140. hud/eval/instrument.py +185 -0
  141. hud/eval/manager.py +466 -0
  142. hud/eval/parallel.py +268 -0
  143. hud/eval/task.py +340 -0
  144. hud/eval/tests/__init__.py +1 -0
  145. hud/eval/tests/test_context.py +178 -0
  146. hud/eval/tests/test_eval.py +210 -0
  147. hud/eval/tests/test_manager.py +152 -0
  148. hud/eval/tests/test_parallel.py +168 -0
  149. hud/eval/tests/test_task.py +145 -0
  150. hud/eval/types.py +63 -0
  151. hud/eval/utils.py +183 -0
  152. hud/patches/__init__.py +19 -0
  153. hud/patches/mcp_patches.py +151 -0
  154. hud/patches/warnings.py +54 -0
  155. hud/samples/browser.py +4 -4
  156. hud/server/__init__.py +2 -1
  157. hud/server/low_level.py +2 -1
  158. hud/server/router.py +164 -0
  159. hud/server/server.py +567 -80
  160. hud/server/tests/test_mcp_server_integration.py +11 -11
  161. hud/server/tests/test_mcp_server_more.py +1 -1
  162. hud/server/tests/test_server_extra.py +2 -0
  163. hud/settings.py +45 -3
  164. hud/shared/exceptions.py +36 -10
  165. hud/shared/hints.py +26 -1
  166. hud/shared/requests.py +15 -3
  167. hud/shared/tests/test_exceptions.py +40 -31
  168. hud/shared/tests/test_hints.py +167 -0
  169. hud/telemetry/__init__.py +20 -19
  170. hud/telemetry/exporter.py +201 -0
  171. hud/telemetry/instrument.py +158 -253
  172. hud/telemetry/tests/test_eval_telemetry.py +356 -0
  173. hud/telemetry/tests/test_exporter.py +258 -0
  174. hud/telemetry/tests/test_instrument.py +401 -0
  175. hud/tools/__init__.py +16 -2
  176. hud/tools/apply_patch.py +639 -0
  177. hud/tools/base.py +54 -4
  178. hud/tools/bash.py +2 -2
  179. hud/tools/computer/__init__.py +4 -0
  180. hud/tools/computer/anthropic.py +2 -2
  181. hud/tools/computer/gemini.py +385 -0
  182. hud/tools/computer/hud.py +23 -6
  183. hud/tools/computer/openai.py +20 -21
  184. hud/tools/computer/qwen.py +434 -0
  185. hud/tools/computer/settings.py +37 -0
  186. hud/tools/edit.py +3 -7
  187. hud/tools/executors/base.py +4 -2
  188. hud/tools/executors/pyautogui.py +1 -1
  189. hud/tools/grounding/grounded_tool.py +13 -18
  190. hud/tools/grounding/grounder.py +10 -31
  191. hud/tools/grounding/tests/test_grounded_tool.py +26 -44
  192. hud/tools/jupyter.py +330 -0
  193. hud/tools/playwright.py +18 -3
  194. hud/tools/shell.py +308 -0
  195. hud/tools/tests/test_apply_patch.py +718 -0
  196. hud/tools/tests/test_computer.py +4 -9
  197. hud/tools/tests/test_computer_actions.py +24 -2
  198. hud/tools/tests/test_jupyter_tool.py +181 -0
  199. hud/tools/tests/test_shell.py +596 -0
  200. hud/tools/tests/test_submit.py +85 -0
  201. hud/tools/tests/test_types.py +193 -0
  202. hud/tools/types.py +21 -1
  203. hud/types.py +167 -57
  204. hud/utils/__init__.py +2 -0
  205. hud/utils/env.py +67 -0
  206. hud/utils/hud_console.py +61 -3
  207. hud/utils/mcp.py +15 -58
  208. hud/utils/strict_schema.py +162 -0
  209. hud/utils/tests/test_init.py +1 -2
  210. hud/utils/tests/test_mcp.py +1 -28
  211. hud/utils/tests/test_pretty_errors.py +186 -0
  212. hud/utils/tests/test_tool_shorthand.py +154 -0
  213. hud/utils/tests/test_version.py +1 -1
  214. hud/utils/types.py +20 -0
  215. hud/version.py +1 -1
  216. hud_python-0.5.1.dist-info/METADATA +264 -0
  217. hud_python-0.5.1.dist-info/RECORD +299 -0
  218. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
  219. hud/agents/langchain.py +0 -261
  220. hud/agents/lite_llm.py +0 -72
  221. hud/cli/rl/__init__.py +0 -180
  222. hud/cli/rl/config.py +0 -101
  223. hud/cli/rl/display.py +0 -133
  224. hud/cli/rl/gpu.py +0 -63
  225. hud/cli/rl/gpu_utils.py +0 -321
  226. hud/cli/rl/local_runner.py +0 -595
  227. hud/cli/rl/presets.py +0 -96
  228. hud/cli/rl/remote_runner.py +0 -463
  229. hud/cli/rl/rl_api.py +0 -150
  230. hud/cli/rl/vllm.py +0 -177
  231. hud/cli/rl/wait_utils.py +0 -89
  232. hud/datasets/parallel.py +0 -687
  233. hud/misc/__init__.py +0 -1
  234. hud/misc/claude_plays_pokemon.py +0 -292
  235. hud/otel/__init__.py +0 -35
  236. hud/otel/collector.py +0 -142
  237. hud/otel/config.py +0 -181
  238. hud/otel/context.py +0 -570
  239. hud/otel/exporters.py +0 -369
  240. hud/otel/instrumentation.py +0 -135
  241. hud/otel/processors.py +0 -121
  242. hud/otel/tests/__init__.py +0 -1
  243. hud/otel/tests/test_processors.py +0 -197
  244. hud/rl/README.md +0 -30
  245. hud/rl/__init__.py +0 -1
  246. hud/rl/actor.py +0 -176
  247. hud/rl/buffer.py +0 -405
  248. hud/rl/chat_template.jinja +0 -101
  249. hud/rl/config.py +0 -192
  250. hud/rl/distributed.py +0 -132
  251. hud/rl/learner.py +0 -637
  252. hud/rl/tests/__init__.py +0 -1
  253. hud/rl/tests/test_learner.py +0 -186
  254. hud/rl/train.py +0 -382
  255. hud/rl/types.py +0 -101
  256. hud/rl/utils/start_vllm_server.sh +0 -30
  257. hud/rl/utils.py +0 -524
  258. hud/rl/vllm_adapter.py +0 -143
  259. hud/telemetry/job.py +0 -352
  260. hud/telemetry/replay.py +0 -74
  261. hud/telemetry/tests/test_replay.py +0 -40
  262. hud/telemetry/tests/test_trace.py +0 -63
  263. hud/telemetry/trace.py +0 -158
  264. hud/utils/agent_factories.py +0 -86
  265. hud/utils/async_utils.py +0 -65
  266. hud/utils/group_eval.py +0 -223
  267. hud/utils/progress.py +0 -149
  268. hud/utils/tasks.py +0 -127
  269. hud/utils/tests/test_async_utils.py +0 -173
  270. hud/utils/tests/test_progress.py +0 -261
  271. hud_python-0.4.45.dist-info/METADATA +0 -552
  272. hud_python-0.4.45.dist-info/RECORD +0 -228
  273. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
  274. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
hud/cli/build.py CHANGED
@@ -5,9 +5,12 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import contextlib
7
7
  import hashlib
8
+ import json
9
+ import os
10
+ import re
8
11
  import subprocess
9
12
  import time
10
- from datetime import datetime
13
+ from datetime import UTC, datetime
11
14
  from pathlib import Path
12
15
  from typing import Any
13
16
 
@@ -15,13 +18,36 @@ import typer
15
18
  import yaml
16
19
 
17
20
  from hud.cli.utils.source_hash import compute_source_hash, list_source_files
18
- from hud.clients import MCPClient
19
21
  from hud.utils.hud_console import HUDConsole
20
22
  from hud.version import __version__ as hud_version
21
23
 
22
24
  from .utils.registry import save_to_registry
23
25
 
24
26
 
27
+ def find_dockerfile(directory: Path) -> Path | None:
28
+ """Find the Dockerfile in a directory, preferring Dockerfile.hud.
29
+
30
+ Checks for Dockerfile.hud first (HUD-specific), then falls back to Dockerfile.
31
+
32
+ Args:
33
+ directory: Directory to search in
34
+
35
+ Returns:
36
+ Path to the Dockerfile if found, None otherwise
37
+ """
38
+ # Prefer Dockerfile.hud for HUD environments
39
+ hud_dockerfile = directory / "Dockerfile.hud"
40
+ if hud_dockerfile.exists():
41
+ return hud_dockerfile
42
+
43
+ # Fall back to standard Dockerfile
44
+ standard_dockerfile = directory / "Dockerfile"
45
+ if standard_dockerfile.exists():
46
+ return standard_dockerfile
47
+
48
+ return None
49
+
50
+
25
51
  def parse_version(version_str: str) -> tuple[int, int, int]:
26
52
  """Parse version string like '1.0.0' or '1.0' into tuple of integers."""
27
53
  # Remove 'v' prefix if present
@@ -50,6 +76,140 @@ def increment_version(version_str: str, increment_type: str = "patch") -> str:
50
76
  return f"{major}.{minor}.{patch + 1}"
51
77
 
52
78
 
79
+ def find_task_files_in_env(env_dir: Path) -> list[Path]:
80
+ """Find all task files in an environment directory.
81
+
82
+ This looks for .json and .jsonl files that contain task definitions,
83
+ excluding config files and lock files.
84
+
85
+ Args:
86
+ env_dir: Environment directory to search
87
+
88
+ Returns:
89
+ List of task file paths
90
+ """
91
+ task_files: list[Path] = []
92
+
93
+ # Find all .json and .jsonl files
94
+ json_files = list(env_dir.glob("*.json")) + list(env_dir.glob("*.jsonl"))
95
+
96
+ # Filter out config files and lock files
97
+ for file in json_files:
98
+ # Skip hidden files, config files, and lock files
99
+ if (
100
+ file.name.startswith(".")
101
+ or file.name == "package.json"
102
+ or file.name == "tsconfig.json"
103
+ or file.name == "gcp.json"
104
+ or file.name.endswith(".lock.json")
105
+ ):
106
+ continue
107
+
108
+ # Check if it's a task file by looking for mcp_config
109
+ try:
110
+ with open(file, encoding="utf-8") as f:
111
+ content = json.load(f)
112
+
113
+ # It's a task file if it's a list with mcp_config entries
114
+ if (
115
+ isinstance(content, list)
116
+ and len(content) > 0
117
+ and any(isinstance(item, dict) and "mcp_config" in item for item in content)
118
+ ):
119
+ task_files.append(file)
120
+ except (json.JSONDecodeError, Exception): # noqa: S112
121
+ continue
122
+
123
+ return task_files
124
+
125
+
126
+ def update_tasks_json_versions(
127
+ env_dir: Path, base_name: str, old_version: str | None, new_version: str
128
+ ) -> list[Path]:
129
+ """Update image references in tasks.json files to use the new version.
130
+
131
+ Args:
132
+ env_dir: Environment directory
133
+ base_name: Base image name (without version)
134
+ old_version: Previous version (if any)
135
+ new_version: New version to use
136
+
137
+ Returns:
138
+ List of updated task files
139
+ """
140
+ hud_console = HUDConsole()
141
+ updated_files: list[Path] = []
142
+
143
+ for task_file in find_task_files_in_env(env_dir):
144
+ try:
145
+ with open(task_file, encoding="utf-8") as f:
146
+ tasks = json.load(f)
147
+ if not isinstance(tasks, list):
148
+ continue
149
+
150
+ modified = False
151
+
152
+ # Process each task
153
+ for task in tasks:
154
+ if not isinstance(task, dict) or "mcp_config" not in task:
155
+ continue
156
+
157
+ mcp_config = task["mcp_config"]
158
+
159
+ # Handle local Docker format
160
+ if "local" in mcp_config and isinstance(mcp_config["local"], dict):
161
+ local_config = mcp_config["local"]
162
+
163
+ # Check for docker run args
164
+ if "args" in local_config and isinstance(local_config["args"], list):
165
+ for i, arg in enumerate(local_config["args"]):
166
+ # Match image references
167
+ if isinstance(arg, str) and (
168
+ arg == f"{base_name}:latest"
169
+ or (old_version and arg == f"{base_name}:{old_version}")
170
+ or re.match(rf"^{re.escape(base_name)}:\d+\.\d+\.\d+$", arg)
171
+ ):
172
+ # Update to new version
173
+ local_config["args"][i] = f"{base_name}:{new_version}"
174
+ modified = True
175
+
176
+ # Handle HUD API format (remote MCP)
177
+ elif "hud" in mcp_config and isinstance(mcp_config["hud"], dict):
178
+ hud_config = mcp_config["hud"]
179
+
180
+ # Check headers for Mcp-Image
181
+ if "headers" in hud_config and isinstance(hud_config["headers"], dict):
182
+ headers = hud_config["headers"]
183
+
184
+ if "Mcp-Image" in headers:
185
+ image_ref = headers["Mcp-Image"]
186
+
187
+ # Match various image formats
188
+ if isinstance(image_ref, str) and ":" in image_ref:
189
+ # Split into image name and tag
190
+ image_name, _ = image_ref.rsplit(":", 1)
191
+
192
+ if (
193
+ image_name == base_name # Exact match
194
+ or image_name.endswith(f"/{base_name}") # With prefix
195
+ ):
196
+ # Update to new version, preserving the full image path
197
+ headers["Mcp-Image"] = f"{image_name}:{new_version}"
198
+ modified = True
199
+
200
+ # Save the file if modified
201
+ if modified:
202
+ with open(task_file, "w") as f:
203
+ json.dump(tasks, f, indent=2)
204
+ updated_files.append(task_file)
205
+ hud_console.success(f"Updated {task_file.name} with version {new_version}")
206
+
207
+ except Exception as e:
208
+ hud_console.warning(f"Could not update {task_file.name}: {e}")
209
+
210
+ return updated_files
211
+
212
+
53
213
  def get_existing_version(lock_path: Path) -> str | None:
54
214
  """Get the internal version from existing lock file if it exists."""
55
215
  if not lock_path.exists():
@@ -154,6 +314,121 @@ def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str],
154
314
  return required, optional
155
315
 
156
316
 
317
+ def parse_base_image(dockerfile_path: Path) -> str | None:
318
+ """Extract the base image from the first FROM directive in Dockerfile.
319
+
320
+ For multi-stage builds, returns the image from the first FROM. Strips any
321
+ trailing AS <stage> segment.
322
+ """
323
+ try:
324
+ if not dockerfile_path.exists():
325
+ return None
326
+ for raw_line in dockerfile_path.read_text().splitlines():
327
+ line = raw_line.strip()
328
+ if not line or line.startswith("#"):
329
+ continue
330
+ if line.upper().startswith("FROM "):
331
+ rest = line[5:].strip()
332
+ # Remove stage alias if present
333
+ lower = rest.lower()
334
+ if " as " in lower:
335
+ # Split using the original case string at the index of lower-case match
336
+ idx = lower.index(" as ")
337
+ rest = rest[:idx]
338
+ return rest.strip()
339
+ except Exception:
340
+ return None
341
+ return None
342
+
343
+
344
+ def collect_runtime_metadata(image: str, *, verbose: bool = False) -> dict[str, str | None]:
345
+ """Probe container to capture Python/CUDA/cuDNN/PyTorch versions.
346
+
347
+ Runs a tiny Python snippet inside the built image using docker run.
348
+ """
349
+ hud_console = HUDConsole()
350
+
351
+ runtime_script = (
352
+ "import json, platform\n"
353
+ "info = {'python': platform.python_version()}\n"
354
+ "try:\n"
355
+ " import torch\n"
356
+ " info['pytorch'] = getattr(torch, '__version__', None)\n"
357
+ " cuda_version = None\n"
358
+ " try:\n"
359
+ " cuda_version = getattr(getattr(torch, 'version', None), 'cuda', None)\n"
360
+ " except Exception:\n"
361
+ " cuda_version = None\n"
362
+ " if cuda_version:\n"
363
+ " info['cuda'] = cuda_version\n"
364
+ " try:\n"
365
+ " cudnn_version = torch.backends.cudnn.version()\n"
366
+ " except Exception:\n"
367
+ " cudnn_version = None\n"
368
+ " if cudnn_version:\n"
369
+ " info['cudnn'] = str(cudnn_version)\n"
370
+ "except Exception:\n"
371
+ " pass\n"
372
+ "info.setdefault('pytorch', None)\n"
373
+ "info.setdefault('cuda', None)\n"
374
+ "info.setdefault('cudnn', None)\n"
375
+ "print(json.dumps(info))\n"
376
+ )
377
+
378
+ for binary in ("python", "python3"):
379
+ cmd = [
380
+ "docker",
381
+ "run",
382
+ "--rm",
383
+ image,
384
+ binary,
385
+ "-c",
386
+ runtime_script,
387
+ ]
388
+ try:
389
+ result = subprocess.run( # noqa: S603
390
+ cmd, capture_output=True, text=True, check=False
391
+ )
392
+ except FileNotFoundError:
393
+ return {}
394
+
395
+ if result.returncode != 0:
396
+ if verbose:
397
+ hud_console.debug(
398
+ f"Runtime probe failed with {binary}: {result.stderr.strip() or 'no stderr'}"
399
+ )
400
+ continue
401
+
402
+ output = (result.stdout or "").strip()
403
+ if not output:
404
+ return {}
405
+
406
+ try:
407
+ data = json.loads(output.splitlines()[-1])
408
+ except json.JSONDecodeError:
409
+ if verbose:
410
+ hud_console.debug(
411
+ "Runtime probe returned non-JSON output; skipping metadata capture"
412
+ )
413
+ return {}
414
+
415
+ if not isinstance(data, dict):
416
+ if verbose:
417
+ hud_console.debug(
418
+ "Runtime probe returned JSON that is not an object; skipping metadata capture"
419
+ )
420
+ return {}
421
+
422
+ return {
423
+ "python": data.get("python"),
424
+ "cuda": data.get("cuda"),
425
+ "cudnn": data.get("cudnn"),
426
+ "pytorch": data.get("pytorch"),
427
+ }
428
+
429
+ return {}
430
+
431
+
157
432
  async def analyze_mcp_environment(
158
433
  image: str, verbose: bool = False, env_vars: dict[str, str] | None = None
159
434
  ) -> dict[str, Any]:
@@ -161,50 +436,103 @@ async def analyze_mcp_environment(
161
436
  hud_console = HUDConsole()
162
437
  env_vars = env_vars or {}
163
438
 
164
- # Build Docker command to run the image
165
- docker_cmd = ["docker", "run", "--rm", "-i"]
439
+ # Build Docker command to run the image, injecting any provided env vars
440
+ from hud.cli.utils.docker import build_env_flags
166
441
 
167
- # Add environment variables
168
- for key, value in env_vars.items():
169
- docker_cmd.extend(["-e", f"{key}={value}"])
442
+ docker_cmd = ["docker", "run", "--rm", "-i", *build_env_flags(env_vars), image]
170
443
 
171
- docker_cmd.append(image)
444
+ # Show full docker command being used for analysis
445
+ hud_console.dim_info("Command:", " ".join(docker_cmd))
172
446
 
173
- # Create MCP config
174
- config = {
175
- "server": {"command": docker_cmd[0], "args": docker_cmd[1:] if len(docker_cmd) > 1 else []}
176
- }
447
+ # Create MCP config consistently with analyze helpers
448
+ from hud.cli.analyze import parse_docker_command
449
+
450
+ mcp_config = parse_docker_command(docker_cmd)
177
451
 
178
452
  # Initialize client and measure timing
453
+ # Use FastMCP client directly - no mcp_use deprecation warnings
454
+ from hud.clients.fastmcp import FastMCPHUDClient
455
+
179
456
  start_time = time.time()
180
- client = MCPClient(mcp_config=config, verbose=verbose, auto_trace=False)
457
+ client = FastMCPHUDClient(mcp_config=mcp_config, verbose=verbose)
181
458
  initialized = False
182
459
 
183
460
  try:
184
461
  if verbose:
185
- hud_console.info(f"Initializing MCP client with command: {' '.join(docker_cmd)}")
462
+ hud_console.info("Initializing MCP client...")
186
463
 
187
- await client.initialize()
464
+ # Add timeout to fail fast instead of hanging (60 seconds)
465
+ await asyncio.wait_for(client.initialize(), timeout=60.0)
188
466
  initialized = True
189
467
  initialize_ms = int((time.time() - start_time) * 1000)
190
468
 
191
- # Get tools
192
- tools = await client.list_tools()
193
-
194
- # Extract tool information
195
- tool_info = []
196
- for tool in tools:
197
- tool_dict = {"name": tool.name, "description": tool.description}
198
- if hasattr(tool, "inputSchema") and tool.inputSchema:
199
- tool_dict["inputSchema"] = tool.inputSchema
200
- tool_info.append(tool_dict)
201
-
202
- return {
469
+ # Delegate to standard analysis helper
470
+ full_analysis = await client.analyze_environment()
471
+
472
+ # Normalize and enrich with internalTools if a hub map is present
473
+ tools_list = full_analysis.get("tools", [])
474
+ hub_map = full_analysis.get("hub_tools", {}) or full_analysis.get("hubTools", {})
475
+
476
+ normalized_tools: list[dict[str, Any]] = []
477
+ internal_total = 0
478
+ for t in tools_list:
479
+ # Extract core fields (support object or dict forms)
480
+ if hasattr(t, "name"):
481
+ name = getattr(t, "name", None)
482
+ description = getattr(t, "description", None)
483
+ input_schema = getattr(t, "inputSchema", None)
484
+ existing_internal = getattr(t, "internalTools", None)
485
+ else:
486
+ name = t.get("name")
487
+ description = t.get("description")
488
+ # accept either inputSchema or input_schema
489
+ input_schema = t.get("inputSchema") or t.get("input_schema")
490
+ # accept either internalTools or internal_tools
491
+ existing_internal = t.get("internalTools") or t.get("internal_tools")
492
+
493
+ tool_entry: dict[str, Any] = {"name": name}
494
+ if description:
495
+ tool_entry["description"] = description
496
+ if input_schema:
497
+ tool_entry["inputSchema"] = input_schema
498
+
499
+ # Merge internal tools: preserve any existing declaration and add hub_map[name]
500
+ merged_internal: list[str] = []
501
+ if isinstance(existing_internal, list):
502
+ merged_internal.extend([str(x) for x in existing_internal])
503
+ if isinstance(hub_map, dict) and name in hub_map and isinstance(hub_map[name], list):
504
+ merged_internal.extend([str(x) for x in hub_map[name]])
505
+ if merged_internal:
506
+ # Deduplicate while preserving order
507
+ merged_internal = list(dict.fromkeys(merged_internal))
508
+ tool_entry["internalTools"] = merged_internal
509
+ internal_total += len(merged_internal)
510
+
511
+ normalized_tools.append(tool_entry)
512
+
513
+ result = {
203
514
  "initializeMs": initialize_ms,
204
- "toolCount": len(tools),
205
- "tools": tool_info,
515
+ "toolCount": len(tools_list),
516
+ "internalToolCount": internal_total,
517
+ "tools": normalized_tools,
206
518
  "success": True,
207
519
  }
520
+ if hub_map:
521
+ result["hub_tools"] = hub_map
522
+ # Include prompts and resources from analysis
523
+ if full_analysis.get("prompts"):
524
+ result["prompts"] = full_analysis["prompts"]
525
+ if full_analysis.get("resources"):
526
+ result["resources"] = full_analysis["resources"]
527
+ return result
528
+ except TimeoutError:
529
+ from hud.shared.exceptions import HudException
530
+
531
+ hud_console.error("MCP server initialization timed out after 60 seconds")
532
+ hud_console.info(
533
+ "The server likely crashed during startup - check stderr logs with 'hud debug'"
534
+ )
535
+ raise HudException("MCP server initialization timeout") from None
208
536
  except Exception as e:
209
537
  from hud.shared.exceptions import HudException
210
538
 
@@ -227,28 +555,76 @@ def build_docker_image(
227
555
  verbose: bool = False,
228
556
  build_args: dict[str, str] | None = None,
229
557
  platform: str | None = None,
558
+ remote_cache: str | None = None,
230
559
  ) -> bool:
231
560
  """Build a Docker image from a directory."""
232
561
  hud_console = HUDConsole()
233
562
  build_args = build_args or {}
234
563
 
235
- # Check if Dockerfile exists
236
- dockerfile = directory / "Dockerfile"
237
- if not dockerfile.exists():
564
+ # Check if Dockerfile exists (prefer Dockerfile.hud)
565
+ dockerfile = find_dockerfile(directory)
566
+ if dockerfile is None:
238
567
  hud_console.error(f"No Dockerfile found in {directory}")
568
+ hud_console.info("Expected: Dockerfile.hud or Dockerfile")
239
569
  return False
240
570
 
241
- # Default platform to match RL pipeline unless explicitly overridden
571
+ # Build command - use buildx when remote cache is enabled
242
572
  effective_platform = platform if platform is not None else "linux/amd64"
573
+ cmd = ["docker", "buildx", "build"] if remote_cache else ["docker", "build"]
574
+
575
+ # Specify dockerfile explicitly if not the default name
576
+ if dockerfile.name != "Dockerfile":
577
+ cmd.extend(["-f", str(dockerfile)])
243
578
 
244
- # Build command
245
- cmd = ["docker", "build"]
246
579
  if effective_platform:
247
580
  cmd.extend(["--platform", effective_platform])
248
581
  cmd.extend(["-t", tag])
249
582
  if no_cache:
250
583
  cmd.append("--no-cache")
251
584
 
585
+ # Add remote cache support for ECR
586
+ if remote_cache:
587
+ try:
588
+ # Validate ECR repo name
589
+ if not re.match(r"^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$", remote_cache):
590
+ hud_console.error(f"Invalid ECR repo name: {remote_cache}")
591
+ hud_console.info(
592
+ "ECR repo names must contain only lowercase letters, numbers, hyphens, underscores, and forward slashes" # noqa: E501
593
+ )
594
+ return False
595
+
596
+ # Get required environment variables
597
+ aws_account_id = os.getenv("AWS_ACCOUNT_ID")
598
+ aws_region = os.getenv("AWS_DEFAULT_REGION", "us-east-1")
599
+
600
+ if not aws_account_id:
601
+ hud_console.error("AWS_ACCOUNT_ID environment variable not set")
602
+ return False
603
+
604
+ # ECR cache image reference
605
+ cache_image = (
606
+ f"{aws_account_id}.dkr.ecr.{aws_region}.amazonaws.com/{remote_cache}:cache"
607
+ )
608
+
609
+ # Add cache arguments with proper ECR format
610
+ cmd.extend(
611
+ [
612
+ "--cache-from",
613
+ f"type=registry,ref={cache_image}",
614
+ "--cache-to",
615
+ f"mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref={cache_image}",
616
+ "--load", # Load image to local Docker after build
617
+ ]
618
+ )
619
+
620
+ hud_console.success(f"Remote cache configured: {cache_image}")
621
+
622
+ except typer.Exit:
623
+ raise
624
+ except Exception as e:
625
+ hud_console.error(f"Remote cache setup error: {e}")
626
+ return False
627
+
252
628
  # Add build args
253
629
  for key, value in build_args.items():
254
630
  cmd.extend(["--build-arg", f"{key}={value}"])
@@ -274,6 +650,7 @@ def build_environment(
274
650
  verbose: bool = False,
275
651
  env_vars: dict[str, str] | None = None,
276
652
  platform: str | None = None,
653
+ remote_cache: str | None = None,
277
654
  ) -> None:
278
655
  """Build a HUD environment and generate lock file."""
279
656
  hud_console = HUDConsole()
@@ -286,26 +663,52 @@ def build_environment(
286
663
  hud_console.error(f"Directory not found: {directory}")
287
664
  raise typer.Exit(1)
288
665
 
289
- # Check for pyproject.toml
290
- pyproject_path = env_dir / "pyproject.toml"
291
- if not pyproject_path.exists():
292
- hud_console.error(f"No pyproject.toml found in {directory}")
293
- raise typer.Exit(1)
294
-
295
- # Read pyproject.toml to get image name
296
- try:
297
- import toml
666
+ from hud.cli.utils.docker import require_docker_running
298
667
 
299
- pyproject = toml.load(pyproject_path)
300
- default_image = pyproject.get("tool", {}).get("hud", {}).get("image", None)
301
- if not default_image:
302
- # Generate default from directory name
303
- default_image = f"{env_dir.name}:dev"
304
- except Exception:
305
- default_image = f"{env_dir.name}:dev"
668
+ require_docker_running()
306
669
 
307
- # Determine final image tag to use
308
- image_tag: str = tag if tag else default_image
670
+ # Step 1: Check for hud.lock.yaml (previous build)
671
+ lock_path = env_dir / "hud.lock.yaml"
672
+ base_name = None
673
+
674
+ if lock_path.exists():
675
+ try:
676
+ with open(lock_path) as f:
677
+ lock_data = yaml.safe_load(f)
678
+ # Get base name from lock file (strip version/digest)
679
+ lock_image = lock_data.get("images", {}).get("local") or lock_data.get("image", "")
680
+ if lock_image:
681
+ # Remove @sha256:... digest if present
682
+ if "@" in lock_image:
683
+ lock_image = lock_image.split("@")[0]
684
+ # Extract base name (remove :version tag)
685
+ base_name = lock_image.split(":")[0] if ":" in lock_image else lock_image
686
+ hud_console.info(f"Using base name from lock file: {base_name}")
687
+ except Exception as e:
688
+ hud_console.warning(f"Could not read lock file: {e}")
689
+
690
+ # Step 2: If no lock, check for Dockerfile
691
+ if not base_name:
692
+ dockerfile_path = find_dockerfile(env_dir)
693
+ if dockerfile_path is None:
694
+ hud_console.error(f"Not a valid environment directory: {directory}")
695
+ hud_console.info("Expected: Dockerfile.hud, Dockerfile, or hud.lock.yaml")
696
+ raise typer.Exit(1)
697
+
698
+ # First build - use directory name
699
+ base_name = env_dir.name
700
+ hud_console.info(f"First build - using base name: {base_name}")
701
+ if dockerfile_path.name == "Dockerfile.hud":
702
+ hud_console.info("Using Dockerfile.hud")
703
+
704
+ # If user provides --tag, respect it; otherwise use base name only (version added later)
705
+ if tag:
706
+ # User explicitly provided a tag
707
+ image_tag = tag
708
+ base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
709
+ else:
710
+ # No tag provided - we'll add version later
711
+ image_tag = None
309
712
 
310
713
  # Build temporary image first
311
714
  temp_tag = f"hud-build-temp:{int(time.time())}"
@@ -320,50 +723,77 @@ def build_environment(
320
723
  verbose,
321
724
  build_args=None,
322
725
  platform=platform,
726
+ remote_cache=remote_cache,
323
727
  ):
324
728
  hud_console.error("Docker build failed")
325
729
  raise typer.Exit(1)
326
730
 
327
731
  hud_console.success(f"Built temporary image: {temp_tag}")
328
732
 
329
- # Analyze the environment
733
+ # Analyze the environment (merge folder .env if present)
330
734
  hud_console.progress_message("Analyzing MCP environment...")
331
735
 
332
736
  loop = asyncio.new_event_loop()
333
737
  asyncio.set_event_loop(loop)
334
738
  try:
335
- analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose, env_vars))
739
+ # Merge .env from env_dir for analysis only
740
+ try:
741
+ from hud.cli.utils.docker import load_env_vars_for_dir
742
+
743
+ env_from_file = load_env_vars_for_dir(env_dir)
744
+ except Exception:
745
+ env_from_file = {}
746
+ merged_env_for_analysis = {**env_from_file, **(env_vars or {})}
747
+
748
+ analysis = loop.run_until_complete(
749
+ analyze_mcp_environment(temp_tag, verbose, merged_env_for_analysis)
750
+ )
751
+ except Exception as e:
752
+ hud_console.error(f"Failed to analyze MCP environment: {e}")
753
+ hud_console.info("")
754
+ hud_console.info("To debug this issue, run:")
755
+ hud_console.command_example(f"hud debug {temp_tag}")
756
+ hud_console.info("")
757
+ raise typer.Exit(1) from e
336
758
  finally:
337
759
  loop.close()
338
760
 
339
- hud_console.success(f"Analyzed environment: {analysis['toolCount']} tools found")
761
+ # Show analysis results including hub tools, prompts, resources
762
+ tool_count = analysis["toolCount"]
763
+ prompt_count = len(analysis.get("prompts") or [])
764
+ resource_count = len(analysis.get("resources") or [])
765
+
766
+ parts = [f"{tool_count} tools"]
767
+ if prompt_count:
768
+ parts.append(f"{prompt_count} prompts")
769
+ if resource_count:
770
+ parts.append(f"{resource_count} resources")
771
+
772
+ tool_msg = f"Analyzed environment: {', '.join(parts)} found"
773
+ hud_console.success(tool_msg)
340
774
 
341
775
  # Extract environment variables from Dockerfile
342
- dockerfile_path = env_dir / "Dockerfile"
776
+ dockerfile_path = find_dockerfile(env_dir) or env_dir / "Dockerfile"
343
777
  required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
344
778
 
345
- # Merge user-provided env vars with detected ones
346
- provided_env_vars: dict[str, str] = {}
347
- missing_required = []
348
- if env_vars:
349
- # Use placeholders in lock file for any provided values to avoid storing secrets
350
- provided_env_vars = {k: f"${{{k}}}" for k in env_vars}
351
- # Track which required vars are still missing
352
- missing_required = [e for e in required_env if e not in env_vars]
353
-
354
- # Show what env vars were provided
355
- hud_console.success(f"Using provided environment variables: {', '.join(env_vars.keys())}")
356
- else:
357
- missing_required = required_env[:]
779
+ # Show env vars detected from .env file
780
+ if env_from_file:
781
+ hud_console.info(
782
+ f"Detected environment variables from .env file: {', '.join(sorted(env_from_file.keys()))}" # noqa: E501
783
+ )
358
784
 
359
- # Warn about missing required variables
360
- if missing_required:
785
+ # Create a complete set of all required variables for warning
786
+ all_required_for_warning = set(required_env)
787
+ all_required_for_warning.update(env_from_file.keys())
788
+
789
+ # Find which ones are missing (not provided via -e flags)
790
+ all_missing = all_required_for_warning - set(env_vars.keys() if env_vars else [])
791
+
792
+ if all_missing:
361
793
  hud_console.warning(
362
- f"Missing required environment variables: {', '.join(missing_required)}"
363
- )
364
- hud_console.info(
365
- "These can be added to the lock file after build or provided with -e flags"
794
+ f"Environment variables not provided via -e flags: {', '.join(sorted(all_missing))}"
366
795
  )
796
+ hud_console.info("These will be added to the required list in the lock file")
367
797
 
368
798
  # Check for existing version and increment
369
799
  lock_path = env_dir / "hud.lock.yaml"
@@ -378,17 +808,32 @@ def build_environment(
378
808
  new_version = "0.1.0"
379
809
  hud_console.info(f"Setting initial version: {new_version}")
380
810
 
381
- # Create lock file content - minimal and useful
811
+ # Determine base name for image references
812
+ if image_tag:
813
+ base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
814
+
815
+ # Collect runtime metadata and compute base image/platform
816
+ runtime_info = collect_runtime_metadata(temp_tag, verbose=verbose)
817
+ base_image = parse_base_image(dockerfile_path)
818
+ effective_platform = platform if platform is not None else "linux/amd64"
819
+
820
+ # Create lock file content with images subsection at top
382
821
  lock_content = {
383
- "version": "1.0", # Lock file format version
384
- "image": tag, # Will be updated with ID/digest later
822
+ "version": "1.3", # Lock file format version
823
+ "images": {
824
+ "local": f"{base_name}:{new_version}", # Local tag with version
825
+ "full": None, # Will be set with digest after build
826
+ "pushed": None, # Will be set by hud push
827
+ },
385
828
  "build": {
386
- "generatedAt": datetime.utcnow().isoformat() + "Z",
829
+ "generatedAt": datetime.now(UTC).isoformat() + "Z",
387
830
  "hudVersion": hud_version,
388
831
  "directory": str(env_dir.name),
389
- "version": new_version, # Internal environment version
832
+ "version": new_version,
390
833
  # Fast source fingerprint for change detection
391
834
  "sourceHash": compute_source_hash(env_dir),
835
+ "baseImage": base_image,
836
+ "platform": effective_platform,
392
837
  },
393
838
  "environment": {
394
839
  "initializeMs": analysis["initializeMs"],
@@ -396,8 +841,19 @@ def build_environment(
396
841
  },
397
842
  }
398
843
 
844
+ if runtime_info:
845
+ lock_content["environment"]["runtime"] = runtime_info
846
+ internal_count = int(analysis.get("internalToolCount", 0) or 0)
847
+ lock_content["environment"]["internalToolCount"] = internal_count
848
+
399
849
  # Add environment variables section if any exist
400
- if missing_required or optional_env or provided_env_vars:
850
+ # Include env vars from .env file as well
851
+ env_vars_from_file = set(env_from_file.keys()) if env_from_file else set()
852
+
853
+ # Check if we have any env vars to document
854
+ has_env_vars = bool(required_env or optional_env or env_vars or env_vars_from_file)
855
+
856
+ if has_env_vars:
401
857
  lock_content["environment"]["variables"] = {}
402
858
 
403
859
  # Add note about editing environment variables
@@ -406,23 +862,53 @@ def build_environment(
406
862
  "Provided variables will be used when running the environment."
407
863
  )
408
864
 
409
- if provided_env_vars:
410
- lock_content["environment"]["variables"]["provided"] = provided_env_vars
411
- if missing_required:
412
- lock_content["environment"]["variables"]["required"] = missing_required
865
+ # Combine all required variables: from Dockerfile, .env file, and provided vars
866
+ all_required = set(required_env)
867
+
868
+ # Add all env vars from .env file to required
869
+ all_required.update(env_vars_from_file)
870
+
871
+ # Add all provided env vars to required
872
+ if env_vars:
873
+ all_required.update(env_vars.keys())
874
+
875
+ # Remove any that are optional - they stay in optional
876
+ all_required = all_required - set(optional_env)
877
+
878
+ if all_required:
879
+ lock_content["environment"]["variables"]["required"] = sorted(list(all_required))
413
880
  if optional_env:
414
881
  lock_content["environment"]["variables"]["optional"] = optional_env
415
882
 
416
883
  # Add tools with full schemas for RL config generation
417
884
  if analysis["tools"]:
418
- lock_content["tools"] = [
419
- {
885
+ tools_serialized: list[dict[str, Any]] = []
886
+ for tool in analysis["tools"]:
887
+ entry: dict[str, Any] = {
420
888
  "name": tool["name"],
889
+ # Preserve legacy shape: always include description/inputSchema
421
890
  "description": tool.get("description", ""),
422
891
  "inputSchema": tool.get("inputSchema", {}),
423
892
  }
424
- for tool in analysis["tools"]
425
- ]
893
+ if tool.get("internalTools"):
894
+ entry["internalTools"] = tool.get("internalTools")
895
+ tools_serialized.append(entry)
896
+ lock_content["tools"] = tools_serialized
897
+
898
+ # Add hub tools if present (analyze_environment returns hub_tools with snake_case)
899
+ hub_tools = analysis.get("hub_tools") or analysis.get("hubTools")
900
+ if hub_tools:
901
+ lock_content["hubTools"] = hub_tools
902
+
903
+ # Add prompts if present
904
+ prompts = analysis.get("prompts")
905
+ if prompts:
906
+ lock_content["prompts"] = prompts
907
+
908
+ # Add resources if present
909
+ resources = analysis.get("resources")
910
+ if resources:
911
+ lock_content["resources"] = resources
426
912
 
427
913
  # Write lock file
428
914
  lock_path = env_dir / "hud.lock.yaml"
@@ -450,15 +936,55 @@ def build_environment(
450
936
  hud_console.progress_message("Rebuilding with lock file metadata...")
451
937
 
452
938
  # Build final image with label (uses cache from first build)
453
- # Also tag with version
454
- base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
939
+ # Create tags: versioned and latest (and custom tag if provided)
455
940
  version_tag = f"{base_name}:{new_version}"
941
+ latest_tag = f"{base_name}:latest"
942
+
943
+ # Build command - use buildx when remote cache is enabled
944
+ label_cmd = ["docker", "buildx", "build"] if remote_cache else ["docker", "build"]
945
+
946
+ # Specify dockerfile explicitly if not the default name
947
+ if dockerfile_path and dockerfile_path.name != "Dockerfile":
948
+ label_cmd.extend(["-f", str(dockerfile_path)])
456
949
 
457
- label_cmd = ["docker", "build"]
458
950
  # Use same defaulting for the second build step
459
951
  label_platform = platform if platform is not None else "linux/amd64"
460
952
  if label_platform:
461
953
  label_cmd.extend(["--platform", label_platform])
954
+
955
+ # Add remote cache support for final build
956
+ if remote_cache:
957
+ try:
958
+ if not re.match(r"^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$", remote_cache):
959
+ hud_console.error(f"Invalid ECR repo name: {remote_cache}")
960
+ raise typer.Exit(1)
961
+
962
+ aws_account_id = os.getenv("AWS_ACCOUNT_ID")
963
+ aws_region = os.getenv("AWS_DEFAULT_REGION", "us-east-1")
964
+
965
+ if not aws_account_id:
966
+ hud_console.error("AWS_ACCOUNT_ID environment variable not set")
967
+ raise typer.Exit(1)
968
+
969
+ cache_image = (
970
+ f"{aws_account_id}.dkr.ecr.{aws_region}.amazonaws.com/{remote_cache}:cache"
971
+ )
972
+
973
+ label_cmd.extend(
974
+ [
975
+ "--cache-from",
976
+ f"type=registry,ref={cache_image}",
977
+ "--cache-to",
978
+ f"mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref={cache_image}",
979
+ "--load", # Load image to local Docker after build
980
+ ]
981
+ )
982
+ except typer.Exit:
983
+ raise
984
+ except Exception as e:
985
+ hud_console.error(f"Remote cache setup error: {e}")
986
+ raise typer.Exit(1) from e
987
+
462
988
  label_cmd.extend(
463
989
  [
464
990
  "--label",
@@ -466,12 +992,16 @@ def build_environment(
466
992
  "--label",
467
993
  f"org.hud.version={new_version}",
468
994
  "-t",
469
- image_tag,
995
+ version_tag, # Always tag with new version
470
996
  "-t",
471
- version_tag,
997
+ latest_tag, # Always tag with latest
472
998
  ]
473
999
  )
474
1000
 
1001
+ # Add custom tag if user provided one
1002
+ if image_tag and image_tag not in [version_tag, latest_tag]:
1003
+ label_cmd.extend(["-t", image_tag])
1004
+
475
1005
  label_cmd.append(str(env_dir))
476
1006
 
477
1007
  # Run rebuild using Docker's native output formatting
@@ -479,34 +1009,40 @@ def build_environment(
479
1009
  # Show Docker's native output when verbose
480
1010
  result = subprocess.run(label_cmd, check=False) # noqa: S603
481
1011
  else:
482
- # Hide output when not verbose
1012
+ # Capture output for error reporting, but don't show unless it fails
483
1013
  result = subprocess.run( # noqa: S603
484
- label_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False
1014
+ label_cmd, capture_output=True, text=True, check=False
485
1015
  )
486
1016
 
487
1017
  if result.returncode != 0:
488
1018
  hud_console.error("Failed to rebuild with label")
1019
+ if not verbose and result.stderr:
1020
+ hud_console.info("Error output:")
1021
+ hud_console.info(str(result.stderr))
1022
+ if not verbose:
1023
+ hud_console.info("")
1024
+ hud_console.info("Run with --verbose to see full build output:")
1025
+ hud_console.command_example("hud build --verbose")
489
1026
  raise typer.Exit(1)
490
1027
 
491
1028
  hud_console.success("Built final image with lock file metadata")
492
1029
 
493
1030
  # NOW get the image ID after the final build
494
- image_id = get_docker_image_id(image_tag)
1031
+ image_id = get_docker_image_id(version_tag)
495
1032
  if image_id:
496
- # For local builds, store the image ID
497
- # Docker IDs come as sha256:hash, we want tag@sha256:hash
1033
+ # Store full reference with digest
498
1034
  if image_id.startswith("sha256:"):
499
- lock_content["image"] = f"{image_tag}@{image_id}"
1035
+ lock_content["images"]["full"] = f"{version_tag}@{image_id}"
500
1036
  else:
501
- lock_content["image"] = f"{image_tag}@sha256:{image_id}"
1037
+ lock_content["images"]["full"] = f"{version_tag}@sha256:{image_id}"
502
1038
 
503
- # Update the lock file with the new image reference
1039
+ # Update the lock file with the full image reference
504
1040
  with open(lock_path, "w") as f:
505
1041
  yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
506
1042
 
507
- hud_console.success("Updated lock file with image ID")
1043
+ hud_console.success("Updated lock file with image digest")
508
1044
  else:
509
- hud_console.warning("Could not retrieve image ID for lock file")
1045
+ hud_console.warning("Could not retrieve image digest")
510
1046
 
511
1047
  # Remove temp image after we're done
512
1048
  subprocess.run(["docker", "rmi", "-f", temp_tag], capture_output=True) # noqa: S603, S607
@@ -514,15 +1050,32 @@ def build_environment(
514
1050
  # Add to local registry
515
1051
  if image_id:
516
1052
  # Save to local registry using the helper
517
- save_to_registry(lock_content, lock_content.get("image", tag), verbose)
1053
+ local_ref = lock_content.get("images", {}).get("local", version_tag)
1054
+ save_to_registry(lock_content, local_ref, verbose)
1055
+
1056
+ # Update tasks.json files with new version
1057
+ hud_console.progress_message("Updating task files with new version...")
1058
+ updated_task_files = update_tasks_json_versions(
1059
+ env_dir, base_name, existing_version, new_version
1060
+ )
1061
+
1062
+ if updated_task_files:
1063
+ hud_console.success(f"Updated {len(updated_task_files)} task file(s)")
1064
+ else:
1065
+ hud_console.dim_info("No task files found or updated", value="")
518
1066
 
519
1067
  # Print summary
520
1068
  hud_console.section_title("Build Complete")
521
1069
 
522
1070
  # Show the version tag as primary since that's what will be pushed
523
1071
  hud_console.status_item("Built image", version_tag, primary=True)
524
- if image_tag:
525
- hud_console.status_item("Also tagged", image_tag)
1072
+
1073
+ # Show additional tags
1074
+ additional_tags = [latest_tag]
1075
+ if image_tag and image_tag not in [version_tag, latest_tag]:
1076
+ additional_tags.append(image_tag)
1077
+ hud_console.status_item("Also tagged", ", ".join(additional_tags))
1078
+
526
1079
  hud_console.status_item("Version", new_version)
527
1080
  hud_console.status_item("Lock file", "hud.lock.yaml")
528
1081
  hud_console.status_item("Tools found", str(analysis["toolCount"]))
@@ -534,7 +1087,7 @@ def build_environment(
534
1087
  hud_console.section_title("Next Steps")
535
1088
  hud_console.info("Test locally:")
536
1089
  hud_console.command_example("hud dev", "Hot-reload development")
537
- hud_console.command_example(f"hud run {image_tag}", "Run the built image")
1090
+ hud_console.command_example(f"hud run {version_tag}", "Run the built image")
538
1091
  hud_console.info("")
539
1092
  hud_console.info("Publish to registry:")
540
1093
  hud_console.command_example("hud push", f"Push as {version_tag}")
@@ -552,6 +1105,7 @@ def build_command(
552
1105
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
553
1106
  env_vars: dict[str, str] | None = None,
554
1107
  platform: str | None = None,
1108
+ remote_cache: str | None = None,
555
1109
  ) -> None:
556
1110
  """Build a HUD environment and generate lock file."""
557
- build_environment(directory, tag, no_cache, verbose, env_vars, platform)
1111
+ build_environment(directory, tag, no_cache, verbose, env_vars, platform, remote_cache)