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.
- hud/__init__.py +27 -7
- hud/agents/__init__.py +70 -5
- hud/agents/base.py +238 -500
- hud/agents/claude.py +236 -247
- hud/agents/gateway.py +42 -0
- hud/agents/gemini.py +264 -0
- hud/agents/gemini_cua.py +324 -0
- hud/agents/grounded_openai.py +98 -100
- hud/agents/misc/integration_test_agent.py +51 -20
- hud/agents/misc/response_agent.py +48 -36
- hud/agents/openai.py +282 -296
- hud/agents/{openai_chat_generic.py → openai_chat.py} +63 -33
- hud/agents/operator.py +199 -0
- hud/agents/resolver.py +70 -0
- hud/agents/tests/conftest.py +133 -0
- hud/agents/tests/test_base.py +300 -622
- hud/agents/tests/test_base_runtime.py +233 -0
- hud/agents/tests/test_claude.py +381 -214
- hud/agents/tests/test_client.py +9 -10
- hud/agents/tests/test_gemini.py +369 -0
- hud/agents/tests/test_grounded_openai_agent.py +65 -50
- hud/agents/tests/test_openai.py +377 -140
- hud/agents/tests/test_operator.py +362 -0
- hud/agents/tests/test_resolver.py +192 -0
- hud/agents/tests/test_run_eval.py +179 -0
- hud/agents/types.py +148 -0
- hud/cli/__init__.py +493 -546
- hud/cli/analyze.py +43 -5
- hud/cli/build.py +699 -113
- hud/cli/debug.py +8 -5
- hud/cli/dev.py +889 -732
- hud/cli/eval.py +793 -667
- hud/cli/flows/dev.py +167 -0
- hud/cli/flows/init.py +191 -0
- hud/cli/flows/tasks.py +153 -56
- hud/cli/flows/templates.py +151 -0
- hud/cli/flows/tests/__init__.py +1 -0
- hud/cli/flows/tests/test_dev.py +126 -0
- hud/cli/init.py +60 -58
- hud/cli/pull.py +1 -1
- hud/cli/push.py +38 -13
- hud/cli/rft.py +311 -0
- hud/cli/rft_status.py +145 -0
- hud/cli/tests/test_analyze.py +5 -5
- hud/cli/tests/test_analyze_metadata.py +3 -2
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +110 -8
- hud/cli/tests/test_build_failure.py +41 -0
- hud/cli/tests/test_build_module.py +50 -0
- hud/cli/tests/test_cli_init.py +6 -1
- hud/cli/tests/test_cli_more_wrappers.py +30 -0
- hud/cli/tests/test_cli_root.py +140 -0
- hud/cli/tests/test_convert.py +361 -0
- hud/cli/tests/test_debug.py +12 -10
- hud/cli/tests/test_dev.py +197 -0
- hud/cli/tests/test_eval.py +251 -0
- hud/cli/tests/test_eval_bedrock.py +51 -0
- hud/cli/tests/test_init.py +124 -0
- hud/cli/tests/test_main_module.py +11 -5
- hud/cli/tests/test_mcp_server.py +12 -100
- hud/cli/tests/test_push.py +1 -1
- hud/cli/tests/test_push_happy.py +74 -0
- hud/cli/tests/test_push_wrapper.py +23 -0
- hud/cli/tests/test_registry.py +1 -1
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/{rl → utils}/celebrate.py +14 -12
- hud/cli/utils/config.py +18 -1
- hud/cli/utils/docker.py +130 -4
- hud/cli/utils/env_check.py +9 -9
- hud/cli/utils/git.py +136 -0
- hud/cli/utils/interactive.py +39 -5
- hud/cli/utils/metadata.py +70 -1
- hud/cli/utils/runner.py +1 -1
- hud/cli/utils/server.py +2 -2
- hud/cli/utils/source_hash.py +3 -3
- hud/cli/utils/tasks.py +4 -1
- hud/cli/utils/tests/__init__.py +0 -0
- hud/cli/utils/tests/test_config.py +58 -0
- hud/cli/utils/tests/test_docker.py +93 -0
- hud/cli/utils/tests/test_docker_hints.py +71 -0
- hud/cli/utils/tests/test_env_check.py +74 -0
- hud/cli/utils/tests/test_environment.py +42 -0
- hud/cli/utils/tests/test_git.py +142 -0
- hud/cli/utils/tests/test_interactive_module.py +60 -0
- hud/cli/utils/tests/test_local_runner.py +50 -0
- hud/cli/utils/tests/test_logging_utils.py +23 -0
- hud/cli/utils/tests/test_metadata.py +49 -0
- hud/cli/utils/tests/test_package_runner.py +35 -0
- hud/cli/utils/tests/test_registry_utils.py +49 -0
- hud/cli/utils/tests/test_remote_runner.py +25 -0
- hud/cli/utils/tests/test_runner_modules.py +52 -0
- hud/cli/utils/tests/test_source_hash.py +36 -0
- hud/cli/utils/tests/test_tasks.py +80 -0
- hud/cli/utils/version_check.py +258 -0
- hud/cli/{rl → utils}/viewer.py +2 -2
- hud/clients/README.md +12 -11
- hud/clients/__init__.py +4 -3
- hud/clients/base.py +166 -26
- hud/clients/environment.py +51 -0
- hud/clients/fastmcp.py +13 -6
- hud/clients/mcp_use.py +45 -15
- hud/clients/tests/test_analyze_scenarios.py +206 -0
- hud/clients/tests/test_protocol.py +9 -3
- hud/datasets/__init__.py +23 -20
- hud/datasets/loader.py +326 -0
- hud/datasets/runner.py +198 -105
- hud/datasets/tests/__init__.py +0 -0
- hud/datasets/tests/test_loader.py +221 -0
- hud/datasets/tests/test_utils.py +315 -0
- hud/datasets/utils.py +270 -90
- hud/environment/__init__.py +52 -0
- hud/environment/connection.py +258 -0
- hud/environment/connectors/__init__.py +33 -0
- hud/environment/connectors/base.py +68 -0
- hud/environment/connectors/local.py +177 -0
- hud/environment/connectors/mcp_config.py +137 -0
- hud/environment/connectors/openai.py +101 -0
- hud/environment/connectors/remote.py +172 -0
- hud/environment/environment.py +835 -0
- hud/environment/integrations/__init__.py +45 -0
- hud/environment/integrations/adk.py +67 -0
- hud/environment/integrations/anthropic.py +196 -0
- hud/environment/integrations/gemini.py +92 -0
- hud/environment/integrations/langchain.py +82 -0
- hud/environment/integrations/llamaindex.py +68 -0
- hud/environment/integrations/openai.py +238 -0
- hud/environment/mock.py +306 -0
- hud/environment/router.py +263 -0
- hud/environment/scenarios.py +620 -0
- hud/environment/tests/__init__.py +1 -0
- hud/environment/tests/test_connection.py +317 -0
- hud/environment/tests/test_connectors.py +205 -0
- hud/environment/tests/test_environment.py +593 -0
- hud/environment/tests/test_integrations.py +257 -0
- hud/environment/tests/test_local_connectors.py +242 -0
- hud/environment/tests/test_scenarios.py +1086 -0
- hud/environment/tests/test_tools.py +208 -0
- hud/environment/types.py +23 -0
- hud/environment/utils/__init__.py +35 -0
- hud/environment/utils/formats.py +215 -0
- hud/environment/utils/schema.py +171 -0
- hud/environment/utils/tool_wrappers.py +113 -0
- hud/eval/__init__.py +67 -0
- hud/eval/context.py +727 -0
- hud/eval/display.py +299 -0
- hud/eval/instrument.py +187 -0
- hud/eval/manager.py +533 -0
- hud/eval/parallel.py +268 -0
- hud/eval/task.py +372 -0
- hud/eval/tests/__init__.py +1 -0
- hud/eval/tests/test_context.py +178 -0
- hud/eval/tests/test_eval.py +210 -0
- hud/eval/tests/test_manager.py +152 -0
- hud/eval/tests/test_parallel.py +168 -0
- hud/eval/tests/test_task.py +291 -0
- hud/eval/types.py +65 -0
- hud/eval/utils.py +194 -0
- hud/patches/__init__.py +19 -0
- hud/patches/mcp_patches.py +308 -0
- hud/patches/warnings.py +54 -0
- hud/samples/browser.py +4 -4
- hud/server/__init__.py +2 -1
- hud/server/low_level.py +2 -1
- hud/server/router.py +164 -0
- hud/server/server.py +567 -80
- hud/server/tests/test_mcp_server_integration.py +11 -11
- hud/server/tests/test_mcp_server_more.py +1 -1
- hud/server/tests/test_server_extra.py +2 -0
- hud/settings.py +45 -3
- hud/shared/exceptions.py +36 -10
- hud/shared/hints.py +26 -1
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +40 -31
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/__init__.py +20 -19
- hud/telemetry/exporter.py +201 -0
- hud/telemetry/instrument.py +165 -253
- hud/telemetry/tests/test_eval_telemetry.py +356 -0
- hud/telemetry/tests/test_exporter.py +258 -0
- hud/telemetry/tests/test_instrument.py +401 -0
- hud/tools/__init__.py +18 -2
- hud/tools/agent.py +223 -0
- hud/tools/apply_patch.py +639 -0
- hud/tools/base.py +54 -4
- hud/tools/bash.py +2 -2
- hud/tools/computer/__init__.py +36 -3
- hud/tools/computer/anthropic.py +2 -2
- hud/tools/computer/gemini.py +385 -0
- hud/tools/computer/hud.py +23 -6
- hud/tools/computer/openai.py +20 -21
- hud/tools/computer/qwen.py +434 -0
- hud/tools/computer/settings.py +37 -0
- hud/tools/edit.py +3 -7
- hud/tools/executors/base.py +4 -2
- hud/tools/executors/pyautogui.py +1 -1
- hud/tools/grounding/grounded_tool.py +13 -18
- hud/tools/grounding/grounder.py +10 -31
- hud/tools/grounding/tests/test_grounded_tool.py +26 -44
- hud/tools/jupyter.py +330 -0
- hud/tools/playwright.py +18 -3
- hud/tools/shell.py +308 -0
- hud/tools/tests/test_agent_tool.py +355 -0
- hud/tools/tests/test_apply_patch.py +718 -0
- hud/tools/tests/test_computer.py +4 -9
- hud/tools/tests/test_computer_actions.py +24 -2
- hud/tools/tests/test_jupyter_tool.py +181 -0
- hud/tools/tests/test_shell.py +596 -0
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/tools/types.py +21 -1
- hud/types.py +194 -56
- hud/utils/__init__.py +2 -0
- hud/utils/env.py +67 -0
- hud/utils/hud_console.py +89 -18
- hud/utils/mcp.py +15 -58
- hud/utils/strict_schema.py +162 -0
- hud/utils/tests/test_init.py +1 -2
- hud/utils/tests/test_mcp.py +1 -28
- hud/utils/tests/test_pretty_errors.py +186 -0
- hud/utils/tests/test_tool_shorthand.py +154 -0
- hud/utils/tests/test_version.py +1 -1
- hud/utils/types.py +20 -0
- hud/version.py +1 -1
- hud_python-0.5.13.dist-info/METADATA +264 -0
- hud_python-0.5.13.dist-info/RECORD +305 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/WHEEL +1 -1
- hud/agents/langchain.py +0 -261
- hud/agents/lite_llm.py +0 -72
- hud/cli/rl/__init__.py +0 -180
- hud/cli/rl/config.py +0 -101
- hud/cli/rl/display.py +0 -133
- hud/cli/rl/gpu.py +0 -63
- hud/cli/rl/gpu_utils.py +0 -321
- hud/cli/rl/local_runner.py +0 -595
- hud/cli/rl/presets.py +0 -96
- hud/cli/rl/remote_runner.py +0 -463
- hud/cli/rl/rl_api.py +0 -150
- hud/cli/rl/vllm.py +0 -177
- hud/cli/rl/wait_utils.py +0 -89
- hud/datasets/parallel.py +0 -687
- hud/misc/__init__.py +0 -1
- hud/misc/claude_plays_pokemon.py +0 -292
- hud/otel/__init__.py +0 -35
- hud/otel/collector.py +0 -142
- hud/otel/config.py +0 -181
- hud/otel/context.py +0 -570
- hud/otel/exporters.py +0 -369
- hud/otel/instrumentation.py +0 -135
- hud/otel/processors.py +0 -121
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_processors.py +0 -197
- hud/rl/README.md +0 -30
- hud/rl/__init__.py +0 -1
- hud/rl/actor.py +0 -176
- hud/rl/buffer.py +0 -405
- hud/rl/chat_template.jinja +0 -101
- hud/rl/config.py +0 -192
- hud/rl/distributed.py +0 -132
- hud/rl/learner.py +0 -637
- hud/rl/tests/__init__.py +0 -1
- hud/rl/tests/test_learner.py +0 -186
- hud/rl/train.py +0 -382
- hud/rl/types.py +0 -101
- hud/rl/utils/start_vllm_server.sh +0 -30
- hud/rl/utils.py +0 -524
- hud/rl/vllm_adapter.py +0 -143
- hud/telemetry/job.py +0 -352
- hud/telemetry/replay.py +0 -74
- hud/telemetry/tests/test_replay.py +0 -40
- hud/telemetry/tests/test_trace.py +0 -63
- hud/telemetry/trace.py +0 -158
- hud/utils/agent_factories.py +0 -86
- hud/utils/async_utils.py +0 -65
- hud/utils/group_eval.py +0 -223
- hud/utils/progress.py +0 -149
- hud/utils/tasks.py +0 -127
- hud/utils/tests/test_async_utils.py +0 -173
- hud/utils/tests/test_progress.py +0 -261
- hud_python-0.4.45.dist-info/METADATA +0 -552
- hud_python-0.4.45.dist-info/RECORD +0 -228
- {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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 =
|
|
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(
|
|
462
|
+
hud_console.info("Initializing MCP client...")
|
|
186
463
|
|
|
187
|
-
|
|
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
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
205
|
-
"
|
|
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
|
|
237
|
-
if
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
try:
|
|
297
|
-
import toml
|
|
680
|
+
require_docker_running()
|
|
298
681
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
#
|
|
360
|
-
|
|
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"
|
|
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
|
-
#
|
|
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.
|
|
384
|
-
"
|
|
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.
|
|
842
|
+
"generatedAt": datetime.now(UTC).isoformat() + "Z",
|
|
387
843
|
"hudVersion": hud_version,
|
|
388
844
|
"directory": str(env_dir.name),
|
|
389
|
-
"version": new_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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1008
|
+
version_tag, # Always tag with new version
|
|
470
1009
|
"-t",
|
|
471
|
-
|
|
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
|
-
#
|
|
1036
|
+
# Capture output for error reporting, but don't show unless it fails
|
|
483
1037
|
result = subprocess.run( # noqa: S603
|
|
484
|
-
label_cmd,
|
|
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(
|
|
1055
|
+
image_id = get_docker_image_id(version_tag)
|
|
495
1056
|
if image_id:
|
|
496
|
-
#
|
|
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["
|
|
1059
|
+
lock_content["images"]["full"] = f"{version_tag}@{image_id}"
|
|
500
1060
|
else:
|
|
501
|
-
lock_content["
|
|
1061
|
+
lock_content["images"]["full"] = f"{version_tag}@sha256:{image_id}"
|
|
502
1062
|
|
|
503
|
-
# Update the lock file with the
|
|
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
|
|
1067
|
+
hud_console.success("Updated lock file with image digest")
|
|
508
1068
|
else:
|
|
509
|
-
hud_console.warning("Could not retrieve image
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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 {
|
|
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(
|
|
1141
|
+
build_environment(
|
|
1142
|
+
directory, tag, no_cache, verbose, env_vars, platform, secrets, remote_cache, build_args
|
|
1143
|
+
)
|