Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4fcc47f
dev changes
lorenss-m Oct 28, 2025
b96a93b
improved lock file
shfunc Oct 29, 2025
ff76c80
feat(lock): enrich lock file (baseImage, platform, runtime, internalT…
shfunc Oct 29, 2025
6492ae5
Merge branch 'platform-redesign' into improved-lock-file-v2
lorenss-m Oct 29, 2025
16bbd9f
Merge pull request #188 from shfunc/improved-lock-file-v2
lorenss-m Oct 29, 2025
4e2ee5f
refactor(cli): migrate hud init to clone from separate repos
farrelmahaztra Oct 29, 2025
253052b
lint: ruff
farrelmahaztra Oct 29, 2025
7870ec5
refactor(cli): remove text-2048 and remote-browser from presets
farrelmahaztra Oct 30, 2025
7b7182f
Merge pull request #187 from hud-evals/hud-clone-templates
lorenss-m Oct 30, 2025
a3f5f6e
git url support for hud dev
lorenss-m Nov 17, 2025
6ff24eb
Merge branch 'main' of https://github.com/hud-evals/hud-python into p…
lorenss-m Nov 24, 2025
e6110c5
remove unused functions
lorenss-m Nov 24, 2025
469bada
Merge branch 'g/opus4.5' of https://github.com/hud-evals/hud-python i…
lorenss-m Nov 24, 2025
d3ffba9
Merge branch 'g/opus4.5' of https://github.com/hud-evals/hud-python i…
lorenss-m Nov 24, 2025
58f9a77
Merge branch 'main' of https://github.com/hud-evals/hud-python into p…
lorenss-m Nov 24, 2025
45f2df6
fix hub tools
lorenss-m Nov 24, 2025
b62eb3c
test coverage and tracing updates
lorenss-m Nov 30, 2025
937458c
Merge branch 'main' of https://github.com/hud-evals/hud-python into p…
lorenss-m Nov 30, 2025
ab74664
version bump
lorenss-m Nov 30, 2025
78e22a8
Merge branch 'main' of https://github.com/hud-evals/hud-python into p…
lorenss-m Dec 7, 2025
31ac1f5
version bump (again)
lorenss-m Dec 7, 2025
146c394
add new feature flag back
lorenss-m Dec 7, 2025
8e84dab
hub tools from analyze
lorenss-m Dec 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"navigation": {
"versions": [
{
"version": "0.4.70",
"version": "0.4.71",
"groups": [
{
"group": "Get Started",
Expand Down
2 changes: 1 addition & 1 deletion docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ icon: "book"
---

<Note>
**Version 0.4.70** - Latest stable release
**Version 0.4.71** - Latest stable release
</Note>

<CardGroup cols={3}>
Expand Down
12 changes: 10 additions & 2 deletions docs/reference/cli/build.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,32 @@ They’re recorded in the lock file as placeholders under `environment.variables
Minimal structure you’ll see:

```yaml
version: "1.0"
image: "my-env:dev@sha256:..."
version: "1.2"
image: "my-env:0.1.0@sha256:..."
build:
generatedAt: "2025-01-01T12:00:00Z"
hudVersion: "0.x.y"
directory: "my-env"
version: "0.1.0"
sourceHash: "..."
baseImage: "python:3.11-slim"
platform: "linux/amd64"
environment:
initializeMs: 450
toolCount: 3
runtime:
python: "3.11.6"
cuda: null
cudnn: null
pytorch: null
variables:
provided: { API_KEY: "${API_KEY}" }
required: ["OTHER_KEY"]
tools:
- name: setup
description: Initialize environment
inputSchema: { type: object }
internalTools: ["board", "seed"]
```

## Next Steps
Expand Down
5 changes: 3 additions & 2 deletions hud/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ def dev(
new: bool = typer.Option(
False,
"--new",
help="Show Cursor installation link for new server setup",
help="Create a new dev trace on hud.ai (opens in browser)",
),
) -> None:
"""🔥 Development mode - run MCP server with hot-reload.
Expand All @@ -416,6 +416,7 @@ def dev(

Examples:
hud dev # Auto-detect in current directory
hud dev --new # Create live dev trace on hud.ai
hud dev controller # Run specific module
hud dev --inspector # Launch MCP Inspector
hud dev --interactive # Launch interactive testing mode
Expand All @@ -439,7 +440,7 @@ def dev(
watch,
docker=docker,
docker_args=docker_args,
new=new,
new_trace=new,
)


Expand Down
201 changes: 191 additions & 10 deletions hud/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,121 @@ def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str],
return required, optional


def parse_base_image(dockerfile_path: Path) -> str | None:
"""Extract the base image from the first FROM directive in Dockerfile.

For multi-stage builds, returns the image from the first FROM. Strips any
trailing AS <stage> segment.
"""
try:
if not dockerfile_path.exists():
return None
for raw_line in dockerfile_path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.upper().startswith("FROM "):
rest = line[5:].strip()
# Remove stage alias if present
lower = rest.lower()
if " as " in lower:
# Split using the original case string at the index of lower-case match
idx = lower.index(" as ")
rest = rest[:idx]
return rest.strip()
except Exception:
return None
return None
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: parse_base_image doesn't handle FROM --platform flag

The parse_base_image function correctly strips the AS <stage> suffix from FROM directives but doesn't handle the --platform flag. When a Dockerfile uses FROM --platform=linux/amd64 python:3.11, the function returns --platform=linux/amd64 python:3.11 instead of just python:3.11. This results in incorrect baseImage metadata being written to the lock file. The --platform flag is commonly used in multi-architecture Docker builds.

Fix in Cursor Fix in Web



def collect_runtime_metadata(image: str, *, verbose: bool = False) -> dict[str, str | None]:
"""Probe container to capture Python/CUDA/cuDNN/PyTorch versions.

Runs a tiny Python snippet inside the built image using docker run.
"""
hud_console = HUDConsole()

runtime_script = (
"import json, platform\n"
"info = {'python': platform.python_version()}\n"
"try:\n"
" import torch\n"
" info['pytorch'] = getattr(torch, '__version__', None)\n"
" cuda_version = None\n"
" try:\n"
" cuda_version = getattr(getattr(torch, 'version', None), 'cuda', None)\n"
" except Exception:\n"
" cuda_version = None\n"
" if cuda_version:\n"
" info['cuda'] = cuda_version\n"
" try:\n"
" cudnn_version = torch.backends.cudnn.version()\n"
" except Exception:\n"
" cudnn_version = None\n"
" if cudnn_version:\n"
" info['cudnn'] = str(cudnn_version)\n"
"except Exception:\n"
" pass\n"
"info.setdefault('pytorch', None)\n"
"info.setdefault('cuda', None)\n"
"info.setdefault('cudnn', None)\n"
"print(json.dumps(info))\n"
)

for binary in ("python", "python3"):
cmd = [
"docker",
"run",
"--rm",
image,
binary,
"-c",
runtime_script,
]
try:
result = subprocess.run( # noqa: S603
cmd, capture_output=True, text=True, check=False
)
except FileNotFoundError:
return {}

if result.returncode != 0:
if verbose:
hud_console.debug(
f"Runtime probe failed with {binary}: {result.stderr.strip() or 'no stderr'}"
)
continue

output = (result.stdout or "").strip()
if not output:
return {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Early return prevents fallback to python3 binary

The collect_runtime_metadata function returns an empty dict when python succeeds but produces no output, preventing fallback to python3. Lines 381, 390, and 397 should use continue instead of return {} to match the pattern at line 377, allowing the loop to try the next binary when one fails.

Fix in Cursor Fix in Web


try:
data = json.loads(output.splitlines()[-1])
except json.JSONDecodeError:
if verbose:
hud_console.debug(
"Runtime probe returned non-JSON output; skipping metadata capture"
)
return {}

if not isinstance(data, dict):
if verbose:
hud_console.debug(
"Runtime probe returned JSON that is not an object; skipping metadata capture"
)
return {}

return {
"python": data.get("python"),
"cuda": data.get("cuda"),
"cudnn": data.get("cudnn"),
"pytorch": data.get("pytorch"),
}

return {}


async def analyze_mcp_environment(
image: str, verbose: bool = False, env_vars: dict[str, str] | None = None
) -> dict[str, Any]:
Expand Down Expand Up @@ -325,17 +440,60 @@ async def analyze_mcp_environment(
initialized = True
initialize_ms = int((time.time() - start_time) * 1000)

# Delegate to standard analysis helper for consistency
# Delegate to standard analysis helper
full_analysis = await client.analyze_environment()

# Normalize to build's expected fields
# Normalize and enrich with internalTools if a hub map is present
tools_list = full_analysis.get("tools", [])
return {
hub_map = full_analysis.get("hub_tools", {}) or full_analysis.get("hubTools", {})

normalized_tools: list[dict[str, Any]] = []
internal_total = 0
for t in tools_list:
# Extract core fields (support object or dict forms)
if hasattr(t, "name"):
name = getattr(t, "name", None)
description = getattr(t, "description", None)
input_schema = getattr(t, "inputSchema", None)
existing_internal = getattr(t, "internalTools", None)
else:
name = t.get("name")
description = t.get("description")
# accept either inputSchema or input_schema
input_schema = t.get("inputSchema") or t.get("input_schema")
# accept either internalTools or internal_tools
existing_internal = t.get("internalTools") or t.get("internal_tools")

tool_entry: dict[str, Any] = {"name": name}
if description:
tool_entry["description"] = description
if input_schema:
tool_entry["inputSchema"] = input_schema

# Merge internal tools: preserve any existing declaration and add hub_map[name]
merged_internal: list[str] = []
if isinstance(existing_internal, list):
merged_internal.extend([str(x) for x in existing_internal])
if isinstance(hub_map, dict) and name in hub_map and isinstance(hub_map[name], list):
merged_internal.extend([str(x) for x in hub_map[name]])
if merged_internal:
# Deduplicate while preserving order
merged_internal = list(dict.fromkeys(merged_internal))
tool_entry["internalTools"] = merged_internal
internal_total += len(merged_internal)

normalized_tools.append(tool_entry)

result = {
"initializeMs": initialize_ms,
"toolCount": len(tools_list),
"tools": tools_list,
"internalToolCount": internal_total,
"tools": normalized_tools,
"success": True,
}
if hub_map:
result["hub_tools"] = hub_map
return result
except TimeoutError:
from hud.shared.exceptions import HudException

Expand Down Expand Up @@ -562,7 +720,9 @@ def build_environment(
finally:
loop.close()

hud_console.success(f"Analyzed environment: {analysis['toolCount']} tools found")
# Show analysis results including hub tools
tool_msg = f"Analyzed environment: {analysis['toolCount']} tools found"
hud_console.success(tool_msg)

# Extract environment variables from Dockerfile
dockerfile_path = env_dir / "Dockerfile"
Expand Down Expand Up @@ -604,9 +764,14 @@ def build_environment(
if image_tag:
base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag

# Collect runtime metadata and compute base image/platform
runtime_info = collect_runtime_metadata(temp_tag, verbose=verbose)
base_image = parse_base_image(dockerfile_path)
effective_platform = platform if platform is not None else "linux/amd64"

# Create lock file content with images subsection at top
lock_content = {
"version": "1.1", # Lock file format version
"version": "1.2", # Lock file format version
"images": {
"local": f"{base_name}:{new_version}", # Local tag with version
"full": None, # Will be set with digest after build
Expand All @@ -619,13 +784,20 @@ def build_environment(
"version": new_version,
# Fast source fingerprint for change detection
"sourceHash": compute_source_hash(env_dir),
"baseImage": base_image,
"platform": effective_platform,
},
"environment": {
"initializeMs": analysis["initializeMs"],
"toolCount": analysis["toolCount"],
},
}

if runtime_info:
lock_content["environment"]["runtime"] = runtime_info
internal_count = int(analysis.get("internalToolCount", 0) or 0)
lock_content["environment"]["internalToolCount"] = internal_count

# Add environment variables section if any exist
# Include env vars from .env file as well
env_vars_from_file = set(env_from_file.keys()) if env_from_file else set()
Expand Down Expand Up @@ -662,14 +834,23 @@ def build_environment(

# Add tools with full schemas for RL config generation
if analysis["tools"]:
lock_content["tools"] = [
{
tools_serialized: list[dict[str, Any]] = []
for tool in analysis["tools"]:
entry: dict[str, Any] = {
"name": tool["name"],
# Preserve legacy shape: always include description/inputSchema
"description": tool.get("description", ""),
"inputSchema": tool.get("inputSchema", {}),
}
for tool in analysis["tools"]
]
if tool.get("internalTools"):
entry["internalTools"] = tool.get("internalTools")
tools_serialized.append(entry)
lock_content["tools"] = tools_serialized

# Add hub tools if present (analyze_environment returns hub_tools with snake_case)
hub_tools = analysis.get("hub_tools") or analysis.get("hubTools")
if hub_tools:
lock_content["hubTools"] = hub_tools

# Write lock file
lock_path = env_dir / "hud.lock.yaml"
Expand Down
Loading
Loading