hud-python 0.4.47__py3-none-any.whl → 0.4.49__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (45) hide show
  1. hud/agents/base.py +55 -142
  2. hud/agents/claude.py +5 -6
  3. hud/agents/grounded_openai.py +1 -1
  4. hud/agents/misc/integration_test_agent.py +2 -0
  5. hud/agents/tests/test_base.py +2 -5
  6. hud/cli/__init__.py +80 -215
  7. hud/cli/build.py +105 -45
  8. hud/cli/dev.py +614 -743
  9. hud/cli/eval.py +14 -9
  10. hud/cli/flows/tasks.py +100 -21
  11. hud/cli/init.py +18 -14
  12. hud/cli/push.py +27 -9
  13. hud/cli/rl/local_runner.py +28 -16
  14. hud/cli/rl/vllm.py +2 -0
  15. hud/cli/tests/test_analyze_metadata.py +3 -2
  16. hud/cli/tests/test_eval.py +574 -0
  17. hud/cli/tests/test_mcp_server.py +6 -95
  18. hud/cli/tests/test_utils.py +1 -1
  19. hud/cli/utils/env_check.py +9 -9
  20. hud/cli/utils/source_hash.py +1 -1
  21. hud/datasets/parallel.py +0 -12
  22. hud/datasets/runner.py +1 -4
  23. hud/rl/actor.py +4 -2
  24. hud/rl/distributed.py +1 -1
  25. hud/rl/learner.py +2 -1
  26. hud/rl/train.py +1 -1
  27. hud/server/__init__.py +2 -1
  28. hud/server/router.py +160 -0
  29. hud/server/server.py +246 -79
  30. hud/telemetry/trace.py +1 -1
  31. hud/tools/base.py +20 -10
  32. hud/tools/computer/__init__.py +2 -0
  33. hud/tools/computer/qwen.py +431 -0
  34. hud/tools/computer/settings.py +16 -0
  35. hud/tools/executors/pyautogui.py +1 -1
  36. hud/tools/playwright.py +1 -1
  37. hud/types.py +2 -3
  38. hud/utils/hud_console.py +43 -0
  39. hud/utils/tests/test_version.py +1 -1
  40. hud/version.py +1 -1
  41. {hud_python-0.4.47.dist-info → hud_python-0.4.49.dist-info}/METADATA +1 -1
  42. {hud_python-0.4.47.dist-info → hud_python-0.4.49.dist-info}/RECORD +45 -42
  43. {hud_python-0.4.47.dist-info → hud_python-0.4.49.dist-info}/WHEEL +0 -0
  44. {hud_python-0.4.47.dist-info → hud_python-0.4.49.dist-info}/entry_points.txt +0 -0
  45. {hud_python-0.4.47.dist-info → hud_python-0.4.49.dist-info}/licenses/LICENSE +0 -0
hud/cli/__init__.py CHANGED
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import contextlib
7
6
  import json
8
7
  import sys
9
8
  from pathlib import Path
@@ -39,6 +38,7 @@ app = typer.Typer(
39
38
  help="🚀 HUD CLI for MCP environment analysis and debugging",
40
39
  add_completion=False,
41
40
  rich_markup_mode="rich",
41
+ pretty_exceptions_enable=False, # Disable Rich's verbose tracebacks
42
42
  )
43
43
 
44
44
  console = Console()
@@ -352,76 +352,71 @@ def version() -> None:
352
352
  def dev(
353
353
  params: list[str] = typer.Argument( # type: ignore[arg-type] # noqa: B008
354
354
  None,
355
- help="Environment directory followed by optional Docker arguments (e.g., '. -e KEY=value')",
355
+ help="Module path or extra Docker args (when using --docker)",
356
356
  ),
357
- image: str | None = typer.Option(
358
- None, "--image", "-i", help="Docker image name (overrides auto-detection)"
359
- ),
360
- build: bool = typer.Option(False, "--build", "-b", help="Build image before starting"),
361
- no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild without cache"),
362
- transport: str = typer.Option(
363
- "http", "--transport", "-t", help="Transport protocol: http (default) or stdio"
357
+ docker: bool = typer.Option(
358
+ False,
359
+ "--docker",
360
+ help="Run in Docker with volume mounts for hot-reload (for complex environments)",
364
361
  ),
365
- port: int = typer.Option(8765, "--port", "-p", help="HTTP server port (ignored for stdio)"),
366
- no_reload: bool = typer.Option(False, "--no-reload", help="Disable hot-reload"),
367
- full_reload: bool = typer.Option(
362
+ stdio: bool = typer.Option(
368
363
  False,
369
- "--full-reload",
370
- help="Restart entire container on file changes (instead of just server process)",
364
+ "--stdio",
365
+ help="Use stdio transport (default: HTTP)",
371
366
  ),
372
- verbose: bool = typer.Option(False, "--verbose", "-v", help="Show server logs"),
367
+ port: int = typer.Option(8765, "--port", "-p", help="HTTP server port (ignored for stdio)"),
368
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed logs"),
373
369
  inspector: bool = typer.Option(
374
370
  False, "--inspector", help="Launch MCP Inspector (HTTP mode only)"
375
371
  ),
376
- no_logs: bool = typer.Option(False, "--no-logs", help="Disable streaming Docker logs"),
377
372
  interactive: bool = typer.Option(
378
373
  False, "--interactive", help="Launch interactive testing mode (HTTP mode only)"
379
374
  ),
375
+ watch: list[str] = typer.Option( # noqa: B008
376
+ None,
377
+ "--watch",
378
+ help="Additional directories to watch for changes (default: current directory)",
379
+ ),
380
380
  ) -> None:
381
- """🔥 Development mode - interactive MCP environment.
381
+ """🔥 Development mode - run MCP server with hot-reload.
382
+
383
+ TWO MODES:
384
+
385
+ 1. Python Module:
386
+ hud dev # Auto-detects module
387
+ hud dev server.main # Explicit module
388
+
389
+ 2. Docker with Volume Mounts (Complex environments like 'browser'):
390
+ hud dev --docker # Auto-detects image from hud.lock.yaml
391
+ hud dev --docker -p 8080:8080 # With extra Docker args
382
392
 
383
- Runs your MCP environment in Docker with mounted source for development.
384
- The container's CMD determines reload behavior.
393
+ The server must define 'mcp' in its __init__.py or main.py.
385
394
 
386
395
  Examples:
387
396
  hud dev # Auto-detect in current directory
388
- hud dev environments/browser # Specific directory
389
- hud dev . --build # Build image first
390
- hud dev . --image custom:tag # Use specific image
391
- hud dev . --no-cache # Force clean rebuild
392
- hud dev . --verbose # Show detailed logs
393
- hud dev . --transport stdio # Use stdio proxy for multiple connections
394
- hud dev . --inspector # Launch MCP Inspector (HTTP mode only)
395
- hud dev . --interactive # Launch interactive testing mode (HTTP mode only)
396
- hud dev . --no-logs # Disable Docker log streaming
397
-
398
- # With Docker arguments (after all options):
399
- hud dev . -e BROWSER_PROVIDER=anchorbrowser -e ANCHOR_API_KEY=xxx
400
- hud dev . -e API_KEY=secret -v /tmp/data:/data --network host
401
- hud dev . --build -e DEBUG=true --memory 2g
397
+ hud dev controller # Run specific module
398
+ hud dev --inspector # Launch MCP Inspector
399
+ hud dev --interactive # Launch interactive testing mode
400
+ hud dev --stdio # Use stdio transport
401
+ hud dev --watch ../shared # Watch additional directories
402
+
403
+ For environment backend servers, use uvicorn directly:
404
+ uvicorn server:app --reload
402
405
  """
403
- # Parse directory and Docker arguments
404
- if params:
405
- directory = params[0]
406
- docker_args = params[1:] if len(params) > 1 else []
407
- else:
408
- directory = "."
409
- docker_args = []
406
+ # Extract module from params if provided (first param when not --docker)
407
+ module = params[0] if params and not docker else None
408
+ docker_args = params if docker else []
410
409
 
411
410
  run_mcp_dev_server(
412
- directory,
413
- image,
414
- build,
415
- no_cache,
416
- transport,
411
+ module,
412
+ stdio,
417
413
  port,
418
- no_reload,
419
- full_reload,
420
414
  verbose,
421
415
  inspector,
422
- no_logs,
423
416
  interactive,
424
- docker_args,
417
+ watch,
418
+ docker=docker,
419
+ docker_args=docker_args,
425
420
  )
426
421
 
427
422
 
@@ -429,18 +424,14 @@ def dev(
429
424
  def run(
430
425
  params: list[str] = typer.Argument( # type: ignore[arg-type] # noqa: B008
431
426
  None,
432
- help="Python file/module/package or Docker image followed by optional arguments",
427
+ help="Docker image followed by optional Docker run arguments "
428
+ "(e.g., 'my-image:latest -e KEY=value')",
433
429
  ),
434
430
  local: bool = typer.Option(
435
431
  False,
436
432
  "--local",
437
433
  help="Run locally with Docker (default: remote via mcp.hud.so)",
438
434
  ),
439
- remote: bool = typer.Option(
440
- False,
441
- "--remote",
442
- help="Run remotely via mcp.hud.so (default)",
443
- ),
444
435
  transport: str = typer.Option(
445
436
  "stdio",
446
437
  "--transport",
@@ -474,180 +465,54 @@ def run(
474
465
  "-v",
475
466
  help="Show detailed output",
476
467
  ),
477
- interactive: bool = typer.Option(
478
- False,
479
- "--interactive",
480
- help="Launch interactive testing mode (HTTP transport only)",
481
- ),
482
- reload: bool = typer.Option(
483
- False,
484
- "--reload",
485
- help="Enable auto-reload on file changes (local Python files only)",
486
- ),
487
- watch: list[str] = typer.Option( # noqa: B008
488
- None,
489
- "--watch",
490
- help="Directories to watch for changes (can be used multiple times). Defaults to current directory.", # noqa: E501
491
- ),
492
- cmd: str | None = typer.Option(
493
- None,
494
- "--cmd",
495
- help="Command to run as MCP server (e.g., 'python -m controller')",
496
- ),
497
468
  ) -> None:
498
- """🚀 Run MCP server.
469
+ """🚀 Run Docker image as MCP server.
499
470
 
500
- Modes:
501
- - Python (decorator-based): pass a dotted module path. Example: hud run controller
502
- The module is imported, decorators register implicitly, and the server runs.
503
- Use --reload to watch the module/package directory.
471
+ A simple wrapper around 'docker run' that can launch images locally or remotely.
472
+ By default, runs remotely via mcp.hud.so. Use --local to run with local Docker.
504
473
 
505
- - Command: use --cmd to run any command as an MCP server. Example: hud run --cmd "python -m controller"
506
- Works with Docker, binaries, or any executable. Supports --reload.
474
+ For local Python development with hot-reload, use 'hud dev' instead.
507
475
 
508
- - Docker image: pass a Docker image name (optionally with --local to run locally).
509
- """ # noqa: E501
510
- if not params and not cmd:
511
- typer.echo("❌ Dotted module path, Docker image, or --cmd is required")
476
+ Examples:
477
+ hud run my-image:latest # Run remotely (default)
478
+ hud run my-image:latest --local # Run with local Docker
479
+ hud run my-image:latest -e KEY=value # Remote with env vars
480
+ hud run my-image:latest --local -e KEY=val # Local with env vars
481
+ hud run my-image:latest --transport http # Use HTTP transport
482
+ """
483
+ if not params:
484
+ console.print("[red]❌ Docker image is required[/red]")
485
+ console.print("\nExamples:")
486
+ console.print(" hud run my-image:latest # Run remotely (default)")
487
+ console.print(" hud run my-image:latest --local # Run with local Docker")
488
+ console.print("\n[yellow]For local Python development:[/yellow]")
489
+ console.print(" hud dev # Run with hot-reload")
512
490
  raise typer.Exit(1)
513
491
 
514
- # Handle --cmd mode
515
- if cmd:
516
- import asyncio
517
-
518
- from .utils.package_runner import run_package_as_mcp
519
-
520
- asyncio.run(
521
- run_package_as_mcp(
522
- cmd, # Pass command string
523
- transport=transport,
524
- port=port,
525
- verbose=verbose,
526
- reload=reload,
527
- watch_paths=watch if watch else None,
528
- )
529
- )
530
- return
531
-
532
- first_param = params[0]
533
- extra_args = params[1:] if len(params) > 1 else []
534
-
535
- # Guard: strip accidental nested 'run' token from positional args,
536
- # which can happen with nested invocations or reload wrappers.
537
- if first_param == "run" and extra_args:
538
- first_param, extra_args = extra_args[0], extra_args[1:]
539
-
540
- # Try to interpret first_param as module[:attr] or file[:attr]
541
- target = first_param
542
- server_attr = "mcp"
543
- if ":" in target:
544
- target, server_attr = target.split(":", 1)
545
-
546
- # Only allow dotted import paths or python files for Python mode
547
- import importlib.util as _importlib_util
548
-
549
- # Ensure current working directory is importable for local packages like 'controller'
550
- try:
551
- import sys as _sys
552
- from pathlib import Path as _Path
553
-
554
- cwd_str = str(_Path.cwd())
555
- if cwd_str not in _sys.path:
556
- _sys.path.insert(0, cwd_str)
557
- except Exception: # noqa: S110
558
- pass
559
- try:
560
- # If given a file path, detect and import via file spec
561
- from pathlib import Path as _Path
562
-
563
- if target.endswith(".py") and _Path(target).exists():
564
- spec = _importlib_util.spec_from_file_location("_hud_module", target)
565
- else:
566
- spec = _importlib_util.find_spec(target)
567
- except Exception:
568
- spec = None
569
-
570
- # Fallback: treat a local package directory (e.g. 'controller') as a module target
571
- from pathlib import Path as _Path
572
-
573
- pkg_dir = _Path(target)
574
- is_pkg_dir = pkg_dir.is_dir() and (pkg_dir / "__init__.py").exists()
575
-
576
- is_python_target = (spec is not None) or is_pkg_dir
577
-
578
- if is_python_target and not (local or remote):
579
- # Python file/package mode - use implicit MCP server
580
- import asyncio
581
-
582
- from .utils.package_runner import run_package_as_mcp, run_with_reload
583
-
584
- if reload:
585
- # Run with watchfiles reload
586
- # Use user-provided watch paths or compute from module
587
- if watch:
588
- watch_paths = watch
589
- else:
590
- # Compute a watch path that works for dotted modules as well
591
- watch_paths = [target]
592
- if spec is not None:
593
- origin = getattr(spec, "origin", None)
594
- sublocs = getattr(spec, "submodule_search_locations", None)
595
- if origin:
596
- p = _Path(origin)
597
- # If package __init__.py, watch the package directory
598
- watch_paths = [str(p.parent if p.name == "__init__.py" else p)]
599
- elif sublocs:
600
- with contextlib.suppress(Exception):
601
- watch_paths = [next(iter(sublocs))]
602
-
603
- # Always run as subprocess when using reload to enable proper file watching
604
- # This ensures the parent process can watch files while the child runs the server
605
- run_with_reload(
606
- None, # This forces subprocess mode for both stdio and http
607
- watch_paths,
608
- verbose=verbose,
609
- )
610
- else:
611
- # Run normally (but still pass reload=False for consistency)
612
- asyncio.run(
613
- run_package_as_mcp(
614
- target,
615
- transport=transport,
616
- port=port,
617
- verbose=verbose,
618
- server_attr=server_attr,
619
- reload=False, # Explicitly pass reload state
620
- watch_paths=None,
621
- )
622
- )
623
- return
492
+ image = params[0]
493
+ docker_args = params[1:] if len(params) > 1 else []
624
494
 
625
- # Docker image mode
626
- image = first_param
627
- docker_args = extra_args
495
+ # Check if user accidentally passed a module path
496
+ from pathlib import Path
628
497
 
629
- # Handle conflicting flags
630
- if local and remote:
631
- typer.echo("❌ Cannot use both --local and --remote")
498
+ if not any(c in image for c in [":", "/"]) and (
499
+ Path(image).is_dir() or Path(image).is_file() or "." in image
500
+ ):
501
+ console.print(f"[yellow]⚠️ '{image}' looks like a module path, not a Docker image[/yellow]")
502
+ console.print("\n[green]For local Python development, use:[/green]")
503
+ console.print(f" hud dev {image}")
504
+ console.print("\n[green]For Docker images:[/green]")
505
+ console.print(" hud run my-image:latest")
632
506
  raise typer.Exit(1)
633
507
 
634
508
  # Default to remote if not explicitly local
635
- is_local = local and not remote
636
-
637
- # Check for interactive mode restrictions
638
- if interactive:
639
- if transport != "http":
640
- typer.echo("❌ Interactive mode requires HTTP transport (use --transport http)")
641
- raise typer.Exit(1)
642
- if not is_local:
643
- typer.echo("❌ Interactive mode is only available for local execution (use --local)")
644
- raise typer.Exit(1)
509
+ is_local = local
645
510
 
646
511
  if is_local:
647
512
  # Local Docker execution
648
513
  from .utils.runner import run_mcp_server
649
514
 
650
- run_mcp_server(image, docker_args, transport, port, verbose, interactive)
515
+ run_mcp_server(image, docker_args, transport, port, verbose, interactive=False)
651
516
  else:
652
517
  # Remote execution via proxy
653
518
  from .utils.remote_runner import run_remote_server
@@ -935,8 +800,8 @@ def eval(
935
800
  "--max-concurrent",
936
801
  help="Max concurrent tasks (prevents rate limits in both asyncio and parallel modes)",
937
802
  ),
938
- max_steps: int = typer.Option(
939
- 30,
803
+ max_steps: int | None = typer.Option(
804
+ None,
940
805
  "--max-steps",
941
806
  help="Maximum steps per task (default: 10 for single, 50 for full)",
942
807
  ),
hud/cli/build.py CHANGED
@@ -7,7 +7,7 @@ import contextlib
7
7
  import hashlib
8
8
  import subprocess
9
9
  import time
10
- from datetime import datetime
10
+ from datetime import UTC, datetime
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
@@ -184,7 +184,8 @@ async def analyze_mcp_environment(
184
184
  if verbose:
185
185
  hud_console.info(f"Initializing MCP client with command: {' '.join(docker_cmd)}")
186
186
 
187
- await client.initialize()
187
+ # Add timeout to fail fast instead of hanging (30 seconds)
188
+ await asyncio.wait_for(client.initialize(), timeout=60.0)
188
189
  initialized = True
189
190
  initialize_ms = int((time.time() - start_time) * 1000)
190
191
 
@@ -205,6 +206,14 @@ async def analyze_mcp_environment(
205
206
  "tools": tool_info,
206
207
  "success": True,
207
208
  }
209
+ except TimeoutError:
210
+ from hud.shared.exceptions import HudException
211
+
212
+ hud_console.error("MCP server initialization timed out after 60 seconds")
213
+ hud_console.info(
214
+ "The server likely crashed during startup - check stderr logs with 'hud debug'"
215
+ )
216
+ raise HudException("MCP server initialization timeout") from None
208
217
  except Exception as e:
209
218
  from hud.shared.exceptions import HudException
210
219
 
@@ -286,26 +295,46 @@ def build_environment(
286
295
  hud_console.error(f"Directory not found: {directory}")
287
296
  raise typer.Exit(1)
288
297
 
289
- # Check for pyproject.toml
290
- pyproject_path = env_dir / "pyproject.toml"
291
- if not pyproject_path.exists():
292
- hud_console.error(f"No pyproject.toml found in {directory}")
293
- raise typer.Exit(1)
294
-
295
- # Read pyproject.toml to get image name
296
- try:
297
- import toml
298
-
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
298
+ # Step 1: Check for hud.lock.yaml (previous build)
299
+ lock_path = env_dir / "hud.lock.yaml"
300
+ base_name = None
301
+
302
+ if lock_path.exists():
303
+ try:
304
+ with open(lock_path) as f:
305
+ lock_data = yaml.safe_load(f)
306
+ # Get base name from lock file (strip version/digest)
307
+ lock_image = lock_data.get("images", {}).get("local") or lock_data.get("image", "")
308
+ if lock_image:
309
+ # Remove @sha256:... digest if present
310
+ if "@" in lock_image:
311
+ lock_image = lock_image.split("@")[0]
312
+ # Extract base name (remove :version tag)
313
+ base_name = lock_image.split(":")[0] if ":" in lock_image else lock_image
314
+ hud_console.info(f"Using base name from lock file: {base_name}")
315
+ except Exception as e:
316
+ hud_console.warning(f"Could not read lock file: {e}")
317
+
318
+ # Step 2: If no lock, check for Dockerfile
319
+ if not base_name:
320
+ dockerfile_path = env_dir / "Dockerfile"
321
+ if not dockerfile_path.exists():
322
+ hud_console.error(f"Not a valid environment directory: {directory}")
323
+ hud_console.info("Expected: Dockerfile or hud.lock.yaml")
324
+ raise typer.Exit(1)
325
+
326
+ # First build - use directory name
327
+ base_name = env_dir.name
328
+ hud_console.info(f"First build - using base name: {base_name}")
329
+
330
+ # If user provides --tag, respect it; otherwise use base name only (version added later)
331
+ if tag:
332
+ # User explicitly provided a tag
333
+ image_tag = tag
334
+ base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
335
+ else:
336
+ # No tag provided - we'll add version later
337
+ image_tag = None
309
338
 
310
339
  # Build temporary image first
311
340
  temp_tag = f"hud-build-temp:{int(time.time())}"
@@ -333,6 +362,13 @@ def build_environment(
333
362
  asyncio.set_event_loop(loop)
334
363
  try:
335
364
  analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose, env_vars))
365
+ except Exception as e:
366
+ hud_console.error(f"Failed to analyze MCP environment: {e}")
367
+ hud_console.info("")
368
+ hud_console.info("To debug this issue, run:")
369
+ hud_console.command_example(f"hud debug {temp_tag}")
370
+ hud_console.info("")
371
+ raise typer.Exit(1) from e
336
372
  finally:
337
373
  loop.close()
338
374
 
@@ -378,15 +414,23 @@ def build_environment(
378
414
  new_version = "0.1.0"
379
415
  hud_console.info(f"Setting initial version: {new_version}")
380
416
 
381
- # Create lock file content - minimal and useful
417
+ # Determine base name for image references
418
+ if image_tag:
419
+ base_name = image_tag.split(":")[0] if ":" in image_tag else image_tag
420
+
421
+ # Create lock file content with images subsection at top
382
422
  lock_content = {
383
- "version": "1.0", # Lock file format version
384
- "image": tag, # Will be updated with ID/digest later
423
+ "version": "1.1", # Lock file format version
424
+ "images": {
425
+ "local": f"{base_name}:{new_version}", # Local tag with version
426
+ "full": None, # Will be set with digest after build
427
+ "pushed": None, # Will be set by hud push
428
+ },
385
429
  "build": {
386
- "generatedAt": datetime.utcnow().isoformat() + "Z",
430
+ "generatedAt": datetime.now(UTC).isoformat() + "Z",
387
431
  "hudVersion": hud_version,
388
432
  "directory": str(env_dir.name),
389
- "version": new_version, # Internal environment version
433
+ "version": new_version,
390
434
  # Fast source fingerprint for change detection
391
435
  "sourceHash": compute_source_hash(env_dir),
392
436
  },
@@ -450,9 +494,9 @@ def build_environment(
450
494
  hud_console.progress_message("Rebuilding with lock file metadata...")
451
495
 
452
496
  # 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
497
+ # Create tags: versioned and latest (and custom tag if provided)
455
498
  version_tag = f"{base_name}:{new_version}"
499
+ latest_tag = f"{base_name}:latest"
456
500
 
457
501
  label_cmd = ["docker", "build"]
458
502
  # Use same defaulting for the second build step
@@ -466,12 +510,16 @@ def build_environment(
466
510
  "--label",
467
511
  f"org.hud.version={new_version}",
468
512
  "-t",
469
- image_tag,
513
+ version_tag, # Always tag with new version
470
514
  "-t",
471
- version_tag,
515
+ latest_tag, # Always tag with latest
472
516
  ]
473
517
  )
474
518
 
519
+ # Add custom tag if user provided one
520
+ if image_tag and image_tag not in [version_tag, latest_tag]:
521
+ label_cmd.extend(["-t", image_tag])
522
+
475
523
  label_cmd.append(str(env_dir))
476
524
 
477
525
  # Run rebuild using Docker's native output formatting
@@ -479,34 +527,40 @@ def build_environment(
479
527
  # Show Docker's native output when verbose
480
528
  result = subprocess.run(label_cmd, check=False) # noqa: S603
481
529
  else:
482
- # Hide output when not verbose
530
+ # Capture output for error reporting, but don't show unless it fails
483
531
  result = subprocess.run( # noqa: S603
484
- label_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False
532
+ label_cmd, capture_output=True, text=True, check=False
485
533
  )
486
534
 
487
535
  if result.returncode != 0:
488
536
  hud_console.error("Failed to rebuild with label")
537
+ if not verbose and result.stderr:
538
+ hud_console.info("Error output:")
539
+ hud_console.info(str(result.stderr))
540
+ if not verbose:
541
+ hud_console.info("")
542
+ hud_console.info("Run with --verbose to see full build output:")
543
+ hud_console.command_example("hud build --verbose")
489
544
  raise typer.Exit(1)
490
545
 
491
546
  hud_console.success("Built final image with lock file metadata")
492
547
 
493
548
  # NOW get the image ID after the final build
494
- image_id = get_docker_image_id(image_tag)
549
+ image_id = get_docker_image_id(version_tag)
495
550
  if image_id:
496
- # For local builds, store the image ID
497
- # Docker IDs come as sha256:hash, we want tag@sha256:hash
551
+ # Store full reference with digest
498
552
  if image_id.startswith("sha256:"):
499
- lock_content["image"] = f"{image_tag}@{image_id}"
553
+ lock_content["images"]["full"] = f"{version_tag}@{image_id}"
500
554
  else:
501
- lock_content["image"] = f"{image_tag}@sha256:{image_id}"
555
+ lock_content["images"]["full"] = f"{version_tag}@sha256:{image_id}"
502
556
 
503
- # Update the lock file with the new image reference
557
+ # Update the lock file with the full image reference
504
558
  with open(lock_path, "w") as f:
505
559
  yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
506
560
 
507
- hud_console.success("Updated lock file with image ID")
561
+ hud_console.success("Updated lock file with image digest")
508
562
  else:
509
- hud_console.warning("Could not retrieve image ID for lock file")
563
+ hud_console.warning("Could not retrieve image digest")
510
564
 
511
565
  # Remove temp image after we're done
512
566
  subprocess.run(["docker", "rmi", "-f", temp_tag], capture_output=True) # noqa: S603, S607
@@ -514,15 +568,21 @@ def build_environment(
514
568
  # Add to local registry
515
569
  if image_id:
516
570
  # Save to local registry using the helper
517
- save_to_registry(lock_content, lock_content.get("image", tag), verbose)
571
+ local_ref = lock_content.get("images", {}).get("local", version_tag)
572
+ save_to_registry(lock_content, local_ref, verbose)
518
573
 
519
574
  # Print summary
520
575
  hud_console.section_title("Build Complete")
521
576
 
522
577
  # Show the version tag as primary since that's what will be pushed
523
578
  hud_console.status_item("Built image", version_tag, primary=True)
524
- if image_tag:
525
- hud_console.status_item("Also tagged", image_tag)
579
+
580
+ # Show additional tags
581
+ additional_tags = [latest_tag]
582
+ if image_tag and image_tag not in [version_tag, latest_tag]:
583
+ additional_tags.append(image_tag)
584
+ hud_console.status_item("Also tagged", ", ".join(additional_tags))
585
+
526
586
  hud_console.status_item("Version", new_version)
527
587
  hud_console.status_item("Lock file", "hud.lock.yaml")
528
588
  hud_console.status_item("Tools found", str(analysis["toolCount"]))
@@ -534,7 +594,7 @@ def build_environment(
534
594
  hud_console.section_title("Next Steps")
535
595
  hud_console.info("Test locally:")
536
596
  hud_console.command_example("hud dev", "Hot-reload development")
537
- hud_console.command_example(f"hud run {image_tag}", "Run the built image")
597
+ hud_console.command_example(f"hud run {latest_tag}", "Run the built image")
538
598
  hud_console.info("")
539
599
  hud_console.info("Publish to registry:")
540
600
  hud_console.command_example("hud push", f"Push as {version_tag}")