hud-python 0.4.45__py3-none-any.whl → 0.5.13__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 (282) hide show
  1. hud/__init__.py +27 -7
  2. hud/agents/__init__.py +70 -5
  3. hud/agents/base.py +238 -500
  4. hud/agents/claude.py +236 -247
  5. hud/agents/gateway.py +42 -0
  6. hud/agents/gemini.py +264 -0
  7. hud/agents/gemini_cua.py +324 -0
  8. hud/agents/grounded_openai.py +98 -100
  9. hud/agents/misc/integration_test_agent.py +51 -20
  10. hud/agents/misc/response_agent.py +48 -36
  11. hud/agents/openai.py +282 -296
  12. hud/agents/{openai_chat_generic.py → openai_chat.py} +63 -33
  13. hud/agents/operator.py +199 -0
  14. hud/agents/resolver.py +70 -0
  15. hud/agents/tests/conftest.py +133 -0
  16. hud/agents/tests/test_base.py +300 -622
  17. hud/agents/tests/test_base_runtime.py +233 -0
  18. hud/agents/tests/test_claude.py +381 -214
  19. hud/agents/tests/test_client.py +9 -10
  20. hud/agents/tests/test_gemini.py +369 -0
  21. hud/agents/tests/test_grounded_openai_agent.py +65 -50
  22. hud/agents/tests/test_openai.py +377 -140
  23. hud/agents/tests/test_operator.py +362 -0
  24. hud/agents/tests/test_resolver.py +192 -0
  25. hud/agents/tests/test_run_eval.py +179 -0
  26. hud/agents/types.py +148 -0
  27. hud/cli/__init__.py +493 -546
  28. hud/cli/analyze.py +43 -5
  29. hud/cli/build.py +699 -113
  30. hud/cli/debug.py +8 -5
  31. hud/cli/dev.py +889 -732
  32. hud/cli/eval.py +793 -667
  33. hud/cli/flows/dev.py +167 -0
  34. hud/cli/flows/init.py +191 -0
  35. hud/cli/flows/tasks.py +153 -56
  36. hud/cli/flows/templates.py +151 -0
  37. hud/cli/flows/tests/__init__.py +1 -0
  38. hud/cli/flows/tests/test_dev.py +126 -0
  39. hud/cli/init.py +60 -58
  40. hud/cli/pull.py +1 -1
  41. hud/cli/push.py +38 -13
  42. hud/cli/rft.py +311 -0
  43. hud/cli/rft_status.py +145 -0
  44. hud/cli/tests/test_analyze.py +5 -5
  45. hud/cli/tests/test_analyze_metadata.py +3 -2
  46. hud/cli/tests/test_analyze_module.py +120 -0
  47. hud/cli/tests/test_build.py +110 -8
  48. hud/cli/tests/test_build_failure.py +41 -0
  49. hud/cli/tests/test_build_module.py +50 -0
  50. hud/cli/tests/test_cli_init.py +6 -1
  51. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  52. hud/cli/tests/test_cli_root.py +140 -0
  53. hud/cli/tests/test_convert.py +361 -0
  54. hud/cli/tests/test_debug.py +12 -10
  55. hud/cli/tests/test_dev.py +197 -0
  56. hud/cli/tests/test_eval.py +251 -0
  57. hud/cli/tests/test_eval_bedrock.py +51 -0
  58. hud/cli/tests/test_init.py +124 -0
  59. hud/cli/tests/test_main_module.py +11 -5
  60. hud/cli/tests/test_mcp_server.py +12 -100
  61. hud/cli/tests/test_push.py +1 -1
  62. hud/cli/tests/test_push_happy.py +74 -0
  63. hud/cli/tests/test_push_wrapper.py +23 -0
  64. hud/cli/tests/test_registry.py +1 -1
  65. hud/cli/tests/test_utils.py +1 -1
  66. hud/cli/{rl → utils}/celebrate.py +14 -12
  67. hud/cli/utils/config.py +18 -1
  68. hud/cli/utils/docker.py +130 -4
  69. hud/cli/utils/env_check.py +9 -9
  70. hud/cli/utils/git.py +136 -0
  71. hud/cli/utils/interactive.py +39 -5
  72. hud/cli/utils/metadata.py +70 -1
  73. hud/cli/utils/runner.py +1 -1
  74. hud/cli/utils/server.py +2 -2
  75. hud/cli/utils/source_hash.py +3 -3
  76. hud/cli/utils/tasks.py +4 -1
  77. hud/cli/utils/tests/__init__.py +0 -0
  78. hud/cli/utils/tests/test_config.py +58 -0
  79. hud/cli/utils/tests/test_docker.py +93 -0
  80. hud/cli/utils/tests/test_docker_hints.py +71 -0
  81. hud/cli/utils/tests/test_env_check.py +74 -0
  82. hud/cli/utils/tests/test_environment.py +42 -0
  83. hud/cli/utils/tests/test_git.py +142 -0
  84. hud/cli/utils/tests/test_interactive_module.py +60 -0
  85. hud/cli/utils/tests/test_local_runner.py +50 -0
  86. hud/cli/utils/tests/test_logging_utils.py +23 -0
  87. hud/cli/utils/tests/test_metadata.py +49 -0
  88. hud/cli/utils/tests/test_package_runner.py +35 -0
  89. hud/cli/utils/tests/test_registry_utils.py +49 -0
  90. hud/cli/utils/tests/test_remote_runner.py +25 -0
  91. hud/cli/utils/tests/test_runner_modules.py +52 -0
  92. hud/cli/utils/tests/test_source_hash.py +36 -0
  93. hud/cli/utils/tests/test_tasks.py +80 -0
  94. hud/cli/utils/version_check.py +258 -0
  95. hud/cli/{rl → utils}/viewer.py +2 -2
  96. hud/clients/README.md +12 -11
  97. hud/clients/__init__.py +4 -3
  98. hud/clients/base.py +166 -26
  99. hud/clients/environment.py +51 -0
  100. hud/clients/fastmcp.py +13 -6
  101. hud/clients/mcp_use.py +45 -15
  102. hud/clients/tests/test_analyze_scenarios.py +206 -0
  103. hud/clients/tests/test_protocol.py +9 -3
  104. hud/datasets/__init__.py +23 -20
  105. hud/datasets/loader.py +326 -0
  106. hud/datasets/runner.py +198 -105
  107. hud/datasets/tests/__init__.py +0 -0
  108. hud/datasets/tests/test_loader.py +221 -0
  109. hud/datasets/tests/test_utils.py +315 -0
  110. hud/datasets/utils.py +270 -90
  111. hud/environment/__init__.py +52 -0
  112. hud/environment/connection.py +258 -0
  113. hud/environment/connectors/__init__.py +33 -0
  114. hud/environment/connectors/base.py +68 -0
  115. hud/environment/connectors/local.py +177 -0
  116. hud/environment/connectors/mcp_config.py +137 -0
  117. hud/environment/connectors/openai.py +101 -0
  118. hud/environment/connectors/remote.py +172 -0
  119. hud/environment/environment.py +835 -0
  120. hud/environment/integrations/__init__.py +45 -0
  121. hud/environment/integrations/adk.py +67 -0
  122. hud/environment/integrations/anthropic.py +196 -0
  123. hud/environment/integrations/gemini.py +92 -0
  124. hud/environment/integrations/langchain.py +82 -0
  125. hud/environment/integrations/llamaindex.py +68 -0
  126. hud/environment/integrations/openai.py +238 -0
  127. hud/environment/mock.py +306 -0
  128. hud/environment/router.py +263 -0
  129. hud/environment/scenarios.py +620 -0
  130. hud/environment/tests/__init__.py +1 -0
  131. hud/environment/tests/test_connection.py +317 -0
  132. hud/environment/tests/test_connectors.py +205 -0
  133. hud/environment/tests/test_environment.py +593 -0
  134. hud/environment/tests/test_integrations.py +257 -0
  135. hud/environment/tests/test_local_connectors.py +242 -0
  136. hud/environment/tests/test_scenarios.py +1086 -0
  137. hud/environment/tests/test_tools.py +208 -0
  138. hud/environment/types.py +23 -0
  139. hud/environment/utils/__init__.py +35 -0
  140. hud/environment/utils/formats.py +215 -0
  141. hud/environment/utils/schema.py +171 -0
  142. hud/environment/utils/tool_wrappers.py +113 -0
  143. hud/eval/__init__.py +67 -0
  144. hud/eval/context.py +727 -0
  145. hud/eval/display.py +299 -0
  146. hud/eval/instrument.py +187 -0
  147. hud/eval/manager.py +533 -0
  148. hud/eval/parallel.py +268 -0
  149. hud/eval/task.py +372 -0
  150. hud/eval/tests/__init__.py +1 -0
  151. hud/eval/tests/test_context.py +178 -0
  152. hud/eval/tests/test_eval.py +210 -0
  153. hud/eval/tests/test_manager.py +152 -0
  154. hud/eval/tests/test_parallel.py +168 -0
  155. hud/eval/tests/test_task.py +291 -0
  156. hud/eval/types.py +65 -0
  157. hud/eval/utils.py +194 -0
  158. hud/patches/__init__.py +19 -0
  159. hud/patches/mcp_patches.py +308 -0
  160. hud/patches/warnings.py +54 -0
  161. hud/samples/browser.py +4 -4
  162. hud/server/__init__.py +2 -1
  163. hud/server/low_level.py +2 -1
  164. hud/server/router.py +164 -0
  165. hud/server/server.py +567 -80
  166. hud/server/tests/test_mcp_server_integration.py +11 -11
  167. hud/server/tests/test_mcp_server_more.py +1 -1
  168. hud/server/tests/test_server_extra.py +2 -0
  169. hud/settings.py +45 -3
  170. hud/shared/exceptions.py +36 -10
  171. hud/shared/hints.py +26 -1
  172. hud/shared/requests.py +15 -3
  173. hud/shared/tests/test_exceptions.py +40 -31
  174. hud/shared/tests/test_hints.py +167 -0
  175. hud/telemetry/__init__.py +20 -19
  176. hud/telemetry/exporter.py +201 -0
  177. hud/telemetry/instrument.py +165 -253
  178. hud/telemetry/tests/test_eval_telemetry.py +356 -0
  179. hud/telemetry/tests/test_exporter.py +258 -0
  180. hud/telemetry/tests/test_instrument.py +401 -0
  181. hud/tools/__init__.py +18 -2
  182. hud/tools/agent.py +223 -0
  183. hud/tools/apply_patch.py +639 -0
  184. hud/tools/base.py +54 -4
  185. hud/tools/bash.py +2 -2
  186. hud/tools/computer/__init__.py +36 -3
  187. hud/tools/computer/anthropic.py +2 -2
  188. hud/tools/computer/gemini.py +385 -0
  189. hud/tools/computer/hud.py +23 -6
  190. hud/tools/computer/openai.py +20 -21
  191. hud/tools/computer/qwen.py +434 -0
  192. hud/tools/computer/settings.py +37 -0
  193. hud/tools/edit.py +3 -7
  194. hud/tools/executors/base.py +4 -2
  195. hud/tools/executors/pyautogui.py +1 -1
  196. hud/tools/grounding/grounded_tool.py +13 -18
  197. hud/tools/grounding/grounder.py +10 -31
  198. hud/tools/grounding/tests/test_grounded_tool.py +26 -44
  199. hud/tools/jupyter.py +330 -0
  200. hud/tools/playwright.py +18 -3
  201. hud/tools/shell.py +308 -0
  202. hud/tools/tests/test_agent_tool.py +355 -0
  203. hud/tools/tests/test_apply_patch.py +718 -0
  204. hud/tools/tests/test_computer.py +4 -9
  205. hud/tools/tests/test_computer_actions.py +24 -2
  206. hud/tools/tests/test_jupyter_tool.py +181 -0
  207. hud/tools/tests/test_shell.py +596 -0
  208. hud/tools/tests/test_submit.py +85 -0
  209. hud/tools/tests/test_types.py +193 -0
  210. hud/tools/types.py +21 -1
  211. hud/types.py +194 -56
  212. hud/utils/__init__.py +2 -0
  213. hud/utils/env.py +67 -0
  214. hud/utils/hud_console.py +89 -18
  215. hud/utils/mcp.py +15 -58
  216. hud/utils/strict_schema.py +162 -0
  217. hud/utils/tests/test_init.py +1 -2
  218. hud/utils/tests/test_mcp.py +1 -28
  219. hud/utils/tests/test_pretty_errors.py +186 -0
  220. hud/utils/tests/test_tool_shorthand.py +154 -0
  221. hud/utils/tests/test_version.py +1 -1
  222. hud/utils/types.py +20 -0
  223. hud/version.py +1 -1
  224. hud_python-0.5.13.dist-info/METADATA +264 -0
  225. hud_python-0.5.13.dist-info/RECORD +305 -0
  226. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/WHEEL +1 -1
  227. hud/agents/langchain.py +0 -261
  228. hud/agents/lite_llm.py +0 -72
  229. hud/cli/rl/__init__.py +0 -180
  230. hud/cli/rl/config.py +0 -101
  231. hud/cli/rl/display.py +0 -133
  232. hud/cli/rl/gpu.py +0 -63
  233. hud/cli/rl/gpu_utils.py +0 -321
  234. hud/cli/rl/local_runner.py +0 -595
  235. hud/cli/rl/presets.py +0 -96
  236. hud/cli/rl/remote_runner.py +0 -463
  237. hud/cli/rl/rl_api.py +0 -150
  238. hud/cli/rl/vllm.py +0 -177
  239. hud/cli/rl/wait_utils.py +0 -89
  240. hud/datasets/parallel.py +0 -687
  241. hud/misc/__init__.py +0 -1
  242. hud/misc/claude_plays_pokemon.py +0 -292
  243. hud/otel/__init__.py +0 -35
  244. hud/otel/collector.py +0 -142
  245. hud/otel/config.py +0 -181
  246. hud/otel/context.py +0 -570
  247. hud/otel/exporters.py +0 -369
  248. hud/otel/instrumentation.py +0 -135
  249. hud/otel/processors.py +0 -121
  250. hud/otel/tests/__init__.py +0 -1
  251. hud/otel/tests/test_processors.py +0 -197
  252. hud/rl/README.md +0 -30
  253. hud/rl/__init__.py +0 -1
  254. hud/rl/actor.py +0 -176
  255. hud/rl/buffer.py +0 -405
  256. hud/rl/chat_template.jinja +0 -101
  257. hud/rl/config.py +0 -192
  258. hud/rl/distributed.py +0 -132
  259. hud/rl/learner.py +0 -637
  260. hud/rl/tests/__init__.py +0 -1
  261. hud/rl/tests/test_learner.py +0 -186
  262. hud/rl/train.py +0 -382
  263. hud/rl/types.py +0 -101
  264. hud/rl/utils/start_vllm_server.sh +0 -30
  265. hud/rl/utils.py +0 -524
  266. hud/rl/vllm_adapter.py +0 -143
  267. hud/telemetry/job.py +0 -352
  268. hud/telemetry/replay.py +0 -74
  269. hud/telemetry/tests/test_replay.py +0 -40
  270. hud/telemetry/tests/test_trace.py +0 -63
  271. hud/telemetry/trace.py +0 -158
  272. hud/utils/agent_factories.py +0 -86
  273. hud/utils/async_utils.py +0 -65
  274. hud/utils/group_eval.py +0 -223
  275. hud/utils/progress.py +0 -149
  276. hud/utils/tasks.py +0 -127
  277. hud/utils/tests/test_async_utils.py +0 -173
  278. hud/utils/tests/test_progress.py +0 -261
  279. hud_python-0.4.45.dist-info/METADATA +0 -552
  280. hud_python-0.4.45.dist-info/RECORD +0 -228
  281. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/entry_points.txt +0 -0
  282. {hud_python-0.4.45.dist-info → hud_python-0.5.13.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,32 +555,86 @@ 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
+ secrets: list[str] | None = None,
559
+ remote_cache: str | None = None,
230
560
  ) -> bool:
231
561
  """Build a Docker image from a directory."""
232
562
  hud_console = HUDConsole()
233
563
  build_args = build_args or {}
564
+ secrets = secrets or []
234
565
 
235
- # Check if Dockerfile exists
236
- dockerfile = directory / "Dockerfile"
237
- if not dockerfile.exists():
566
+ # Check if Dockerfile exists (prefer Dockerfile.hud)
567
+ dockerfile = find_dockerfile(directory)
568
+ if dockerfile is None:
238
569
  hud_console.error(f"No Dockerfile found in {directory}")
570
+ hud_console.info("Expected: Dockerfile.hud or Dockerfile")
239
571
  return False
240
572
 
241
- # Default platform to match RL pipeline unless explicitly overridden
573
+ # Build command - use buildx when remote cache is enabled
242
574
  effective_platform = platform if platform is not None else "linux/amd64"
575
+ cmd = ["docker", "buildx", "build"] if remote_cache else ["docker", "build"]
576
+
577
+ # Specify dockerfile explicitly if not the default name
578
+ if dockerfile.name != "Dockerfile":
579
+ cmd.extend(["-f", str(dockerfile)])
243
580
 
244
- # Build command
245
- cmd = ["docker", "build"]
246
581
  if effective_platform:
247
582
  cmd.extend(["--platform", effective_platform])
248
583
  cmd.extend(["-t", tag])
249
584
  if no_cache:
250
585
  cmd.append("--no-cache")
251
586
 
587
+ # Add remote cache support for ECR
588
+ if remote_cache:
589
+ try:
590
+ # Validate ECR repo name
591
+ if not re.match(r"^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$", remote_cache):
592
+ hud_console.error(f"Invalid ECR repo name: {remote_cache}")
593
+ hud_console.info(
594
+ "ECR repo names must contain only lowercase letters, numbers, hyphens, underscores, and forward slashes" # noqa: E501
595
+ )
596
+ return False
597
+
598
+ # Get required environment variables
599
+ aws_account_id = os.getenv("AWS_ACCOUNT_ID")
600
+ aws_region = os.getenv("AWS_DEFAULT_REGION", "us-east-1")
601
+
602
+ if not aws_account_id:
603
+ hud_console.error("AWS_ACCOUNT_ID environment variable not set")
604
+ return False
605
+
606
+ # ECR cache image reference
607
+ cache_image = (
608
+ f"{aws_account_id}.dkr.ecr.{aws_region}.amazonaws.com/{remote_cache}:cache"
609
+ )
610
+
611
+ # Add cache arguments with proper ECR format
612
+ cmd.extend(
613
+ [
614
+ "--cache-from",
615
+ f"type=registry,ref={cache_image}",
616
+ "--cache-to",
617
+ f"mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref={cache_image}",
618
+ "--load", # Load image to local Docker after build
619
+ ]
620
+ )
621
+
622
+ hud_console.success(f"Remote cache configured: {cache_image}")
623
+
624
+ except typer.Exit:
625
+ raise
626
+ except Exception as e:
627
+ hud_console.error(f"Remote cache setup error: {e}")
628
+ return False
629
+
252
630
  # Add build args
253
631
  for key, value in build_args.items():
254
632
  cmd.extend(["--build-arg", f"{key}={value}"])
255
633
 
634
+ # Add secrets
635
+ for secret in secrets:
636
+ cmd.extend(["--secret", secret])
637
+
256
638
  cmd.append(str(directory))
257
639
 
258
640
  # Always show build output
@@ -260,7 +642,10 @@ def build_docker_image(
260
642
 
261
643
  try:
262
644
  # Use Docker's native output formatting - no capture, let Docker handle display
263
- result = subprocess.run(cmd, check=False) # noqa: S603
645
+ env = os.environ.copy()
646
+ if secrets:
647
+ env["DOCKER_BUILDKIT"] = "1"
648
+ result = subprocess.run(cmd, check=False, env=env) # noqa: S603
264
649
  return result.returncode == 0
265
650
  except Exception as e:
266
651
  hud_console.error(f"Build error: {e}")
@@ -274,10 +659,14 @@ def build_environment(
274
659
  verbose: bool = False,
275
660
  env_vars: dict[str, str] | None = None,
276
661
  platform: str | None = None,
662
+ secrets: list[str] | None = None,
663
+ remote_cache: str | None = None,
664
+ build_args: dict[str, str] | None = None,
277
665
  ) -> None:
278
666
  """Build a HUD environment and generate lock file."""
279
667
  hud_console = HUDConsole()
280
668
  env_vars = env_vars or {}
669
+ build_args = build_args or {}
281
670
  hud_console.header("HUD Environment Build")
282
671
 
283
672
  # Resolve directory
@@ -286,26 +675,52 @@ def build_environment(
286
675
  hud_console.error(f"Directory not found: {directory}")
287
676
  raise typer.Exit(1)
288
677
 
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)
678
+ from hud.cli.utils.docker import require_docker_running
294
679
 
295
- # Read pyproject.toml to get image name
296
- try:
297
- import toml
680
+ require_docker_running()
298
681
 
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"
306
-
307
- # Determine final image tag to use
308
- image_tag: str = tag if tag else default_image
682
+ # Step 1: Check for hud.lock.yaml (previous build)
683
+ lock_path = env_dir / "hud.lock.yaml"
684
+ base_name = None
685
+
686
+ if lock_path.exists():
687
+ try:
688
+ with open(lock_path) as f:
689
+ lock_data = yaml.safe_load(f)
690
+ # Get base name from lock file (strip version/digest)
691
+ lock_image = lock_data.get("images", {}).get("local") or lock_data.get("image", "")
692
+ if lock_image:
693
+ # Remove @sha256:... digest if present
694
+ if "@" in lock_image:
695
+ lock_image = lock_image.split("@")[0]
696
+ # Extract base name (remove :version tag)
697
+ base_name = lock_image.split(":")[0] if ":" in lock_image else lock_image
698
+ hud_console.info(f"Using base name from lock file: {base_name}")
699
+ except Exception as e:
700
+ hud_console.warning(f"Could not read lock file: {e}")
701
+
702
+ # Step 2: If no lock, check for Dockerfile
703
+ if not base_name:
704
+ dockerfile_path = find_dockerfile(env_dir)
705
+ if dockerfile_path is None:
706
+ hud_console.error(f"Not a valid environment directory: {directory}")
707
+ hud_console.info("Expected: Dockerfile.hud, Dockerfile, or hud.lock.yaml")
708
+ raise typer.Exit(1)
709
+
710
+ # First build - use directory name
711
+ base_name = env_dir.name
712
+ hud_console.info(f"First build - using base name: {base_name}")
713
+ if dockerfile_path.name == "Dockerfile.hud":
714
+ hud_console.info("Using Dockerfile.hud")
715
+
716
+ # If user provides --tag, respect it; otherwise use base name only (version added later)
717
+ if tag:
718
+ # User explicitly provided a tag
719
+ image_tag = tag
720
+ base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
721
+ else:
722
+ # No tag provided - we'll add version later
723
+ image_tag = None
309
724
 
310
725
  # Build temporary image first
311
726
  temp_tag = f"hud-build-temp:{int(time.time())}"
@@ -318,52 +733,80 @@ def build_environment(
318
733
  temp_tag,
319
734
  no_cache,
320
735
  verbose,
321
- build_args=None,
736
+ build_args=build_args or None,
322
737
  platform=platform,
738
+ secrets=secrets,
739
+ remote_cache=remote_cache,
323
740
  ):
324
741
  hud_console.error("Docker build failed")
325
742
  raise typer.Exit(1)
326
743
 
327
744
  hud_console.success(f"Built temporary image: {temp_tag}")
328
745
 
329
- # Analyze the environment
746
+ # Analyze the environment (merge folder .env if present)
330
747
  hud_console.progress_message("Analyzing MCP environment...")
331
748
 
332
749
  loop = asyncio.new_event_loop()
333
750
  asyncio.set_event_loop(loop)
334
751
  try:
335
- analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose, env_vars))
752
+ # Merge .env from env_dir for analysis only
753
+ try:
754
+ from hud.cli.utils.docker import load_env_vars_for_dir
755
+
756
+ env_from_file = load_env_vars_for_dir(env_dir)
757
+ except Exception:
758
+ env_from_file = {}
759
+ merged_env_for_analysis = {**env_from_file, **(env_vars or {})}
760
+
761
+ analysis = loop.run_until_complete(
762
+ analyze_mcp_environment(temp_tag, verbose, merged_env_for_analysis)
763
+ )
764
+ except Exception as e:
765
+ hud_console.error(f"Failed to analyze MCP environment: {e}")
766
+ hud_console.info("")
767
+ hud_console.info("To debug this issue, run:")
768
+ hud_console.command_example(f"hud debug {temp_tag}")
769
+ hud_console.info("")
770
+ raise typer.Exit(1) from e
336
771
  finally:
337
772
  loop.close()
338
773
 
339
- hud_console.success(f"Analyzed environment: {analysis['toolCount']} tools found")
774
+ # Show analysis results including hub tools, prompts, resources
775
+ tool_count = analysis["toolCount"]
776
+ prompt_count = len(analysis.get("prompts") or [])
777
+ resource_count = len(analysis.get("resources") or [])
778
+
779
+ parts = [f"{tool_count} tools"]
780
+ if prompt_count:
781
+ parts.append(f"{prompt_count} prompts")
782
+ if resource_count:
783
+ parts.append(f"{resource_count} resources")
784
+
785
+ tool_msg = f"Analyzed environment: {', '.join(parts)} found"
786
+ hud_console.success(tool_msg)
340
787
 
341
788
  # Extract environment variables from Dockerfile
342
- dockerfile_path = env_dir / "Dockerfile"
789
+ dockerfile_path = find_dockerfile(env_dir) or env_dir / "Dockerfile"
343
790
  required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
344
791
 
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[:]
792
+ # Show env vars detected from .env file
793
+ if env_from_file:
794
+ hud_console.info(
795
+ f"Detected environment variables from .env file: {', '.join(sorted(env_from_file.keys()))}" # noqa: E501
796
+ )
358
797
 
359
- # Warn about missing required variables
360
- if missing_required:
798
+ # Create a complete set of all required variables for warning
799
+ all_required_for_warning = set(required_env)
800
+ all_required_for_warning.update(env_from_file.keys())
801
+
802
+ # Find which ones are missing (not provided via -e flags)
803
+ all_missing = all_required_for_warning - set(env_vars.keys() if env_vars else [])
804
+
805
+ if all_missing:
361
806
  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"
807
+ f"Environment variables not provided via -e flags: {', '.join(sorted(all_missing))}"
366
808
  )
809
+ hud_console.info("These will be added to the required list in the lock file")
367
810
 
368
811
  # Check for existing version and increment
369
812
  lock_path = env_dir / "hud.lock.yaml"
@@ -378,17 +821,32 @@ def build_environment(
378
821
  new_version = "0.1.0"
379
822
  hud_console.info(f"Setting initial version: {new_version}")
380
823
 
381
- # Create lock file content - minimal and useful
824
+ # Determine base name for image references
825
+ if image_tag:
826
+ base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
827
+
828
+ # Collect runtime metadata and compute base image/platform
829
+ runtime_info = collect_runtime_metadata(temp_tag, verbose=verbose)
830
+ base_image = parse_base_image(dockerfile_path)
831
+ effective_platform = platform if platform is not None else "linux/amd64"
832
+
833
+ # Create lock file content with images subsection at top
382
834
  lock_content = {
383
- "version": "1.0", # Lock file format version
384
- "image": tag, # Will be updated with ID/digest later
835
+ "version": "1.3", # Lock file format version
836
+ "images": {
837
+ "local": f"{base_name}:{new_version}", # Local tag with version
838
+ "full": None, # Will be set with digest after build
839
+ "pushed": None, # Will be set by hud push
840
+ },
385
841
  "build": {
386
- "generatedAt": datetime.utcnow().isoformat() + "Z",
842
+ "generatedAt": datetime.now(UTC).isoformat() + "Z",
387
843
  "hudVersion": hud_version,
388
844
  "directory": str(env_dir.name),
389
- "version": new_version, # Internal environment version
845
+ "version": new_version,
390
846
  # Fast source fingerprint for change detection
391
847
  "sourceHash": compute_source_hash(env_dir),
848
+ "baseImage": base_image,
849
+ "platform": effective_platform,
392
850
  },
393
851
  "environment": {
394
852
  "initializeMs": analysis["initializeMs"],
@@ -396,8 +854,19 @@ def build_environment(
396
854
  },
397
855
  }
398
856
 
857
+ if runtime_info:
858
+ lock_content["environment"]["runtime"] = runtime_info
859
+ internal_count = int(analysis.get("internalToolCount", 0) or 0)
860
+ lock_content["environment"]["internalToolCount"] = internal_count
861
+
399
862
  # Add environment variables section if any exist
400
- if missing_required or optional_env or provided_env_vars:
863
+ # Include env vars from .env file as well
864
+ env_vars_from_file = set(env_from_file.keys()) if env_from_file else set()
865
+
866
+ # Check if we have any env vars to document
867
+ has_env_vars = bool(required_env or optional_env or env_vars or env_vars_from_file)
868
+
869
+ if has_env_vars:
401
870
  lock_content["environment"]["variables"] = {}
402
871
 
403
872
  # Add note about editing environment variables
@@ -406,23 +875,53 @@ def build_environment(
406
875
  "Provided variables will be used when running the environment."
407
876
  )
408
877
 
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
878
+ # Combine all required variables: from Dockerfile, .env file, and provided vars
879
+ all_required = set(required_env)
880
+
881
+ # Add all env vars from .env file to required
882
+ all_required.update(env_vars_from_file)
883
+
884
+ # Add all provided env vars to required
885
+ if env_vars:
886
+ all_required.update(env_vars.keys())
887
+
888
+ # Remove any that are optional - they stay in optional
889
+ all_required = all_required - set(optional_env)
890
+
891
+ if all_required:
892
+ lock_content["environment"]["variables"]["required"] = sorted(list(all_required))
413
893
  if optional_env:
414
894
  lock_content["environment"]["variables"]["optional"] = optional_env
415
895
 
416
896
  # Add tools with full schemas for RL config generation
417
897
  if analysis["tools"]:
418
- lock_content["tools"] = [
419
- {
898
+ tools_serialized: list[dict[str, Any]] = []
899
+ for tool in analysis["tools"]:
900
+ entry: dict[str, Any] = {
420
901
  "name": tool["name"],
902
+ # Preserve legacy shape: always include description/inputSchema
421
903
  "description": tool.get("description", ""),
422
904
  "inputSchema": tool.get("inputSchema", {}),
423
905
  }
424
- for tool in analysis["tools"]
425
- ]
906
+ if tool.get("internalTools"):
907
+ entry["internalTools"] = tool.get("internalTools")
908
+ tools_serialized.append(entry)
909
+ lock_content["tools"] = tools_serialized
910
+
911
+ # Add hub tools if present (analyze_environment returns hub_tools with snake_case)
912
+ hub_tools = analysis.get("hub_tools") or analysis.get("hubTools")
913
+ if hub_tools:
914
+ lock_content["hubTools"] = hub_tools
915
+
916
+ # Add prompts if present
917
+ prompts = analysis.get("prompts")
918
+ if prompts:
919
+ lock_content["prompts"] = prompts
920
+
921
+ # Add resources if present
922
+ resources = analysis.get("resources")
923
+ if resources:
924
+ lock_content["resources"] = resources
426
925
 
427
926
  # Write lock file
428
927
  lock_path = env_dir / "hud.lock.yaml"
@@ -450,15 +949,55 @@ def build_environment(
450
949
  hud_console.progress_message("Rebuilding with lock file metadata...")
451
950
 
452
951
  # 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
952
+ # Create tags: versioned and latest (and custom tag if provided)
455
953
  version_tag = f"{base_name}:{new_version}"
954
+ latest_tag = f"{base_name}:latest"
955
+
956
+ # Build command - use buildx when remote cache is enabled
957
+ label_cmd = ["docker", "buildx", "build"] if remote_cache else ["docker", "build"]
958
+
959
+ # Specify dockerfile explicitly if not the default name
960
+ if dockerfile_path and dockerfile_path.name != "Dockerfile":
961
+ label_cmd.extend(["-f", str(dockerfile_path)])
456
962
 
457
- label_cmd = ["docker", "build"]
458
963
  # Use same defaulting for the second build step
459
964
  label_platform = platform if platform is not None else "linux/amd64"
460
965
  if label_platform:
461
966
  label_cmd.extend(["--platform", label_platform])
967
+
968
+ # Add remote cache support for final build
969
+ if remote_cache:
970
+ try:
971
+ if not re.match(r"^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$", remote_cache):
972
+ hud_console.error(f"Invalid ECR repo name: {remote_cache}")
973
+ raise typer.Exit(1)
974
+
975
+ aws_account_id = os.getenv("AWS_ACCOUNT_ID")
976
+ aws_region = os.getenv("AWS_DEFAULT_REGION", "us-east-1")
977
+
978
+ if not aws_account_id:
979
+ hud_console.error("AWS_ACCOUNT_ID environment variable not set")
980
+ raise typer.Exit(1)
981
+
982
+ cache_image = (
983
+ f"{aws_account_id}.dkr.ecr.{aws_region}.amazonaws.com/{remote_cache}:cache"
984
+ )
985
+
986
+ label_cmd.extend(
987
+ [
988
+ "--cache-from",
989
+ f"type=registry,ref={cache_image}",
990
+ "--cache-to",
991
+ f"mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref={cache_image}",
992
+ "--load", # Load image to local Docker after build
993
+ ]
994
+ )
995
+ except typer.Exit:
996
+ raise
997
+ except Exception as e:
998
+ hud_console.error(f"Remote cache setup error: {e}")
999
+ raise typer.Exit(1) from e
1000
+
462
1001
  label_cmd.extend(
463
1002
  [
464
1003
  "--label",
@@ -466,47 +1005,68 @@ def build_environment(
466
1005
  "--label",
467
1006
  f"org.hud.version={new_version}",
468
1007
  "-t",
469
- image_tag,
1008
+ version_tag, # Always tag with new version
470
1009
  "-t",
471
- version_tag,
1010
+ latest_tag, # Always tag with latest
472
1011
  ]
473
1012
  )
474
1013
 
1014
+ # Add custom tag if user provided one
1015
+ if image_tag and image_tag not in [version_tag, latest_tag]:
1016
+ label_cmd.extend(["-t", image_tag])
1017
+
1018
+ # Add build args to final image build (same as initial build)
1019
+ for key, value in build_args.items():
1020
+ label_cmd.extend(["--build-arg", f"{key}={value}"])
1021
+
1022
+ # Add secrets to final image build (same as initial build)
1023
+ for secret in secrets or []:
1024
+ label_cmd.extend(["--secret", secret])
1025
+
475
1026
  label_cmd.append(str(env_dir))
476
1027
 
477
1028
  # Run rebuild using Docker's native output formatting
1029
+ env = os.environ.copy()
1030
+ if secrets:
1031
+ env["DOCKER_BUILDKIT"] = "1"
478
1032
  if verbose:
479
1033
  # Show Docker's native output when verbose
480
- result = subprocess.run(label_cmd, check=False) # noqa: S603
1034
+ result = subprocess.run(label_cmd, check=False, env=env) # noqa: S603
481
1035
  else:
482
- # Hide output when not verbose
1036
+ # Capture output for error reporting, but don't show unless it fails
483
1037
  result = subprocess.run( # noqa: S603
484
- label_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False
1038
+ label_cmd, capture_output=True, text=True, check=False, env=env
485
1039
  )
486
1040
 
487
1041
  if result.returncode != 0:
488
1042
  hud_console.error("Failed to rebuild with label")
1043
+ if not verbose and result.stderr:
1044
+ hud_console.info("Error output:")
1045
+ hud_console.info(str(result.stderr))
1046
+ if not verbose:
1047
+ hud_console.info("")
1048
+ hud_console.info("Run with --verbose to see full build output:")
1049
+ hud_console.command_example("hud build --verbose")
489
1050
  raise typer.Exit(1)
490
1051
 
491
1052
  hud_console.success("Built final image with lock file metadata")
492
1053
 
493
1054
  # NOW get the image ID after the final build
494
- image_id = get_docker_image_id(image_tag)
1055
+ image_id = get_docker_image_id(version_tag)
495
1056
  if image_id:
496
- # For local builds, store the image ID
497
- # Docker IDs come as sha256:hash, we want tag@sha256:hash
1057
+ # Store full reference with digest
498
1058
  if image_id.startswith("sha256:"):
499
- lock_content["image"] = f"{image_tag}@{image_id}"
1059
+ lock_content["images"]["full"] = f"{version_tag}@{image_id}"
500
1060
  else:
501
- lock_content["image"] = f"{image_tag}@sha256:{image_id}"
1061
+ lock_content["images"]["full"] = f"{version_tag}@sha256:{image_id}"
502
1062
 
503
- # Update the lock file with the new image reference
1063
+ # Update the lock file with the full image reference
504
1064
  with open(lock_path, "w") as f:
505
1065
  yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
506
1066
 
507
- hud_console.success("Updated lock file with image ID")
1067
+ hud_console.success("Updated lock file with image digest")
508
1068
  else:
509
- hud_console.warning("Could not retrieve image ID for lock file")
1069
+ hud_console.warning("Could not retrieve image digest")
510
1070
 
511
1071
  # Remove temp image after we're done
512
1072
  subprocess.run(["docker", "rmi", "-f", temp_tag], capture_output=True) # noqa: S603, S607
@@ -514,15 +1074,32 @@ def build_environment(
514
1074
  # Add to local registry
515
1075
  if image_id:
516
1076
  # Save to local registry using the helper
517
- save_to_registry(lock_content, lock_content.get("image", tag), verbose)
1077
+ local_ref = lock_content.get("images", {}).get("local", version_tag)
1078
+ save_to_registry(lock_content, local_ref, verbose)
1079
+
1080
+ # Update tasks.json files with new version
1081
+ hud_console.progress_message("Updating task files with new version...")
1082
+ updated_task_files = update_tasks_json_versions(
1083
+ env_dir, base_name, existing_version, new_version
1084
+ )
1085
+
1086
+ if updated_task_files:
1087
+ hud_console.success(f"Updated {len(updated_task_files)} task file(s)")
1088
+ else:
1089
+ hud_console.dim_info("No task files found or updated", value="")
518
1090
 
519
1091
  # Print summary
520
1092
  hud_console.section_title("Build Complete")
521
1093
 
522
1094
  # Show the version tag as primary since that's what will be pushed
523
1095
  hud_console.status_item("Built image", version_tag, primary=True)
524
- if image_tag:
525
- hud_console.status_item("Also tagged", image_tag)
1096
+
1097
+ # Show additional tags
1098
+ additional_tags = [latest_tag]
1099
+ if image_tag and image_tag not in [version_tag, latest_tag]:
1100
+ additional_tags.append(image_tag)
1101
+ hud_console.status_item("Also tagged", ", ".join(additional_tags))
1102
+
526
1103
  hud_console.status_item("Version", new_version)
527
1104
  hud_console.status_item("Lock file", "hud.lock.yaml")
528
1105
  hud_console.status_item("Tools found", str(analysis["toolCount"]))
@@ -534,7 +1111,7 @@ def build_environment(
534
1111
  hud_console.section_title("Next Steps")
535
1112
  hud_console.info("Test locally:")
536
1113
  hud_console.command_example("hud dev", "Hot-reload development")
537
- hud_console.command_example(f"hud run {image_tag}", "Run the built image")
1114
+ hud_console.command_example(f"hud run {version_tag}", "Run the built image")
538
1115
  hud_console.info("")
539
1116
  hud_console.info("Publish to registry:")
540
1117
  hud_console.command_example("hud push", f"Push as {version_tag}")
@@ -552,6 +1129,15 @@ def build_command(
552
1129
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
553
1130
  env_vars: dict[str, str] | None = None,
554
1131
  platform: str | None = None,
1132
+ secrets: list[str] | None = typer.Option( # noqa: B008
1133
+ None,
1134
+ "--secret",
1135
+ help=("Docker build secret (repeatable), e.g. --secret id=GITHUB_TOKEN,env=GITHUB_TOKEN"),
1136
+ ),
1137
+ remote_cache: str | None = None,
1138
+ build_args: dict[str, str] | None = None,
555
1139
  ) -> None:
556
1140
  """Build a HUD environment and generate lock file."""
557
- build_environment(directory, tag, no_cache, verbose, env_vars, platform)
1141
+ build_environment(
1142
+ directory, tag, no_cache, verbose, env_vars, platform, secrets, remote_cache, build_args
1143
+ )