hud-python 0.4.35__py3-none-any.whl → 0.4.37__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 (63) hide show
  1. hud/agents/__init__.py +2 -0
  2. hud/agents/lite_llm.py +72 -0
  3. hud/agents/openai_chat_generic.py +21 -7
  4. hud/agents/tests/test_claude.py +32 -7
  5. hud/agents/tests/test_openai.py +29 -6
  6. hud/cli/__init__.py +228 -79
  7. hud/cli/build.py +26 -6
  8. hud/cli/dev.py +21 -40
  9. hud/cli/eval.py +96 -15
  10. hud/cli/flows/tasks.py +198 -65
  11. hud/cli/init.py +222 -629
  12. hud/cli/pull.py +6 -0
  13. hud/cli/push.py +11 -1
  14. hud/cli/rl/__init__.py +14 -4
  15. hud/cli/rl/celebrate.py +187 -0
  16. hud/cli/rl/config.py +15 -8
  17. hud/cli/rl/local_runner.py +44 -20
  18. hud/cli/rl/remote_runner.py +166 -87
  19. hud/cli/rl/viewer.py +141 -0
  20. hud/cli/rl/wait_utils.py +89 -0
  21. hud/cli/tests/test_build.py +3 -27
  22. hud/cli/tests/test_mcp_server.py +1 -12
  23. hud/cli/utils/config.py +85 -0
  24. hud/cli/utils/docker.py +21 -39
  25. hud/cli/utils/env_check.py +196 -0
  26. hud/cli/utils/environment.py +4 -3
  27. hud/cli/utils/interactive.py +2 -1
  28. hud/cli/utils/local_runner.py +204 -0
  29. hud/cli/utils/metadata.py +3 -1
  30. hud/cli/utils/package_runner.py +292 -0
  31. hud/cli/utils/remote_runner.py +4 -1
  32. hud/cli/utils/source_hash.py +108 -0
  33. hud/clients/base.py +1 -1
  34. hud/clients/fastmcp.py +1 -1
  35. hud/clients/mcp_use.py +30 -7
  36. hud/datasets/parallel.py +3 -1
  37. hud/datasets/runner.py +4 -1
  38. hud/otel/config.py +1 -1
  39. hud/otel/context.py +40 -6
  40. hud/rl/buffer.py +3 -0
  41. hud/rl/tests/test_learner.py +1 -1
  42. hud/rl/vllm_adapter.py +1 -1
  43. hud/server/server.py +234 -7
  44. hud/server/tests/test_add_tool.py +60 -0
  45. hud/server/tests/test_context.py +128 -0
  46. hud/server/tests/test_mcp_server_handlers.py +44 -0
  47. hud/server/tests/test_mcp_server_integration.py +405 -0
  48. hud/server/tests/test_mcp_server_more.py +247 -0
  49. hud/server/tests/test_run_wrapper.py +53 -0
  50. hud/server/tests/test_server_extra.py +166 -0
  51. hud/server/tests/test_sigterm_runner.py +78 -0
  52. hud/settings.py +38 -0
  53. hud/shared/hints.py +2 -2
  54. hud/telemetry/job.py +2 -2
  55. hud/types.py +9 -2
  56. hud/utils/tasks.py +32 -24
  57. hud/utils/tests/test_version.py +1 -1
  58. hud/version.py +1 -1
  59. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/METADATA +43 -23
  60. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/RECORD +63 -46
  61. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
  62. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
  63. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
hud/cli/__init__.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextlib
6
7
  import json
7
8
  import sys
8
9
  from pathlib import Path
@@ -28,6 +29,7 @@ from .init import create_environment
28
29
  from .pull import pull_command
29
30
  from .push import push_command
30
31
  from .remove import remove_command
32
+ from .utils.config import set_env_values
31
33
  from .utils.cursor import get_cursor_config_path, list_cursor_servers, parse_cursor_config
32
34
  from .utils.logging import CaptureLogger
33
35
 
@@ -116,7 +118,9 @@ def analyze(
116
118
  image, *docker_args = params
117
119
  if live or docker_args: # If docker args provided, assume live mode
118
120
  # Build Docker command from image and args
119
- docker_cmd = ["docker", "run", "--rm", "-i", *docker_args, image]
121
+ from .utils.docker import build_run_command
122
+
123
+ docker_cmd = build_run_command(image, docker_args)
120
124
  asyncio.run(analyze_environment(docker_cmd, output_format, verbose))
121
125
  else:
122
126
  # Fast mode - analyze from metadata
@@ -239,11 +243,15 @@ def debug(
239
243
  raise typer.Exit(1)
240
244
 
241
245
  # Build Docker command
242
- command = ["docker", "run", "--rm", "-i", *docker_args, image_name]
246
+ from .utils.docker import build_run_command
247
+
248
+ command = build_run_command(image_name, docker_args)
243
249
  else:
244
250
  # Assume it's an image name
245
251
  image = first_param
246
- command = ["docker", "run", "--rm", "-i", *docker_args, image]
252
+ from .utils.docker import build_run_command
253
+
254
+ command = build_run_command(image, docker_args)
247
255
  else:
248
256
  console.print(
249
257
  "[red]Error: Must specify a directory, Docker image, --config, or --cursor[/red]"
@@ -370,12 +378,10 @@ def dev(
370
378
  False, "--interactive", help="Launch interactive testing mode (HTTP mode only)"
371
379
  ),
372
380
  ) -> None:
373
- """🔥 Development mode with hot-reload.
381
+ """🔥 Development mode - interactive MCP environment.
374
382
 
375
- Runs your MCP environment in Docker with automatic restart on file changes.
376
-
377
- The container's last command (typically the MCP server) will be wrapped
378
- with watchfiles for hot-reload functionality.
383
+ Runs your MCP environment in Docker with mounted source for development.
384
+ The container's CMD determines reload behavior.
379
385
 
380
386
  Examples:
381
387
  hud dev # Auto-detect in current directory
@@ -388,13 +394,12 @@ def dev(
388
394
  hud dev . --inspector # Launch MCP Inspector (HTTP mode only)
389
395
  hud dev . --interactive # Launch interactive testing mode (HTTP mode only)
390
396
  hud dev . --no-logs # Disable Docker log streaming
391
- hud dev . --full-reload # Restart entire container on file changes (instead of just server)
392
397
 
393
398
  # With Docker arguments (after all options):
394
399
  hud dev . -e BROWSER_PROVIDER=anchorbrowser -e ANCHOR_API_KEY=xxx
395
400
  hud dev . -e API_KEY=secret -v /tmp/data:/data --network host
396
401
  hud dev . --build -e DEBUG=true --memory 2g
397
- """ # noqa: E501
402
+ """
398
403
  # Parse directory and Docker arguments
399
404
  if params:
400
405
  directory = params[0]
@@ -424,7 +429,7 @@ def dev(
424
429
  def run(
425
430
  params: list[str] = typer.Argument( # type: ignore[arg-type] # noqa: B008
426
431
  None,
427
- help="Docker image followed by optional arguments (e.g., 'hud-image:latest -e KEY=value')",
432
+ help="Python file/module/package or Docker image followed by optional arguments",
428
433
  ),
429
434
  local: bool = typer.Option(
430
435
  False,
@@ -474,32 +479,152 @@ def run(
474
479
  "--interactive",
475
480
  help="Launch interactive testing mode (HTTP transport only)",
476
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
+ ),
477
497
  ) -> None:
478
- """🚀 Run MCP server locally or remotely.
479
-
480
- By default, runs remotely via mcp.hud.so. Use --local for Docker.
498
+ """🚀 Run MCP server.
481
499
 
482
- Remote Examples:
483
- hud run hud-text-2048:latest
484
- hud run my-server:v1 -e API_KEY=xxx -h Run-Id:abc123
485
- hud run my-server:v1 --transport http --port 9000
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.
486
504
 
487
- Local Examples:
488
- hud run --local hud-text-2048:latest
489
- hud run --local my-server:v1 -e API_KEY=xxx
490
- hud run --local my-server:v1 --transport http
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.
491
507
 
492
- Interactive Testing (local only):
493
- hud run --local --interactive --transport http hud-text-2048:latest
494
- hud run --local --interactive --transport http --port 9000 my-server:v1
495
- """
496
- if not params:
497
- typer.echo("❌ Docker image is required")
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")
498
512
  raise typer.Exit(1)
499
513
 
500
- # Parse image and args
501
- image = params[0]
502
- docker_args = params[1:] if len(params) > 1 else []
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
624
+
625
+ # Docker image mode
626
+ image = first_param
627
+ docker_args = extra_args
503
628
 
504
629
  # Handle conflicting flags
505
630
  if local and remote:
@@ -741,6 +866,12 @@ def remove(
741
866
  @app.command()
742
867
  def init(
743
868
  name: str = typer.Argument(None, help="Environment name (default: current directory name)"),
869
+ preset: str | None = typer.Option(
870
+ None,
871
+ "--preset",
872
+ "-p",
873
+ help="Preset to use: blank, deep-research, browser. If omitted, you'll choose interactively.", # noqa: E501
874
+ ),
744
875
  directory: str = typer.Option(".", "--dir", "-d", help="Target directory"),
745
876
  force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
746
877
  ) -> None:
@@ -757,7 +888,7 @@ def init(
757
888
  hud init my-env # Create in ./my-env/
758
889
  hud init my-env --dir /tmp # Create in /tmp/my-env/
759
890
  """
760
- create_environment(name, directory, force)
891
+ create_environment(name, directory, force, preset)
761
892
 
762
893
 
763
894
  @app.command()
@@ -774,14 +905,14 @@ def eval(
774
905
  source: str | None = typer.Argument(
775
906
  None,
776
907
  help=(
777
- "HuggingFace dataset identifier (e.g. 'hud-evals/SheetBench-50') or task JSON file. "
908
+ "HuggingFace dataset (e.g. 'hud-evals/SheetBench-50') or task JSON file. "
778
909
  "If not provided, looks for task.json in current directory."
779
910
  ),
780
911
  ),
781
912
  agent: str | None = typer.Argument(
782
913
  None,
783
914
  help=(
784
- "Agent backend to use (claude, openai, or vllm). If not provided, will prompt interactively." # noqa: E501
915
+ "Agent backend to use (claude, openai, vllm, or litellm). If not provided, will prompt interactively." # noqa: E501
785
916
  ),
786
917
  ),
787
918
  full: bool = typer.Option(
@@ -829,6 +960,12 @@ def eval(
829
960
  "--verbose",
830
961
  help="Enable verbose output from the agent",
831
962
  ),
963
+ very_verbose: bool = typer.Option(
964
+ False,
965
+ "--very-verbose",
966
+ "-vv",
967
+ help="Enable debug-level logs for maximum visibility",
968
+ ),
832
969
  vllm_base_url: str | None = typer.Option(
833
970
  None,
834
971
  "--vllm-base-url",
@@ -846,54 +983,21 @@ def eval(
846
983
 
847
984
  hud_console = HUDConsole()
848
985
 
849
- # If no source provided, look for task/eval JSON files in current directory
986
+ # If no source provided, reuse RL helper to find a tasks file interactively
850
987
  if source is None:
851
- # Search for JSON files with "task" or "eval" in the name (case-insensitive)
852
- json_files = []
853
- patterns = [
854
- "*task*.json",
855
- "*eval*.json",
856
- "*Task*.json",
857
- "*Eval*.json",
858
- "*TASK*.json",
859
- "*EVAL*.json",
860
- ]
861
-
862
- # First check current directory
863
- for pattern in patterns:
864
- json_files.extend(Path(".").glob(pattern))
865
-
866
- # If no files found, search recursively (but limit depth to avoid deep searches)
867
- if not json_files:
868
- for pattern in patterns:
869
- # Search up to 2 levels deep
870
- json_files.extend(Path(".").glob(f"*/{pattern}"))
871
- json_files.extend(Path(".").glob(f"*/*/{pattern}"))
872
-
873
- # Remove duplicates and sort
874
- json_files = sorted(set(json_files))
875
-
876
- if not json_files:
988
+ try:
989
+ from hud.cli.utils.tasks import find_tasks_file
990
+
991
+ source = find_tasks_file(None, msg="Select a tasks file to run")
992
+ hud_console.success(f"Selected: {source}")
993
+ except Exception as e:
877
994
  hud_console.error(
878
995
  "No source provided and no task/eval JSON files found in current directory"
879
996
  )
880
997
  hud_console.info(
881
- "Usage: hud eval <source> or create a task JSON file "
882
- "(e.g., task.json, eval_config.json)"
883
- )
884
- raise typer.Exit(1)
885
- elif len(json_files) == 1:
886
- source = str(json_files[0])
887
- hud_console.info(f"Found task file: {source}")
888
- else:
889
- # Multiple files found, let user choose
890
- hud_console.info("Multiple task files found:")
891
- file_choice = hud_console.select(
892
- "Select a task file to run:",
893
- choices=[str(f) for f in json_files],
998
+ "Usage: hud eval <source> or create a task JSON file (e.g., task.json, tasks.jsonl)"
894
999
  )
895
- source = file_choice
896
- hud_console.success(f"Selected: {source}")
1000
+ raise typer.Exit(1) from e
897
1001
 
898
1002
  # Import eval_command lazily to avoid importing agent dependencies
899
1003
  try:
@@ -927,13 +1031,14 @@ def eval(
927
1031
  {"name": "Claude 4 Sonnet", "value": "claude"},
928
1032
  {"name": "OpenAI Computer Use", "value": "openai"},
929
1033
  {"name": "vLLM (Local Server)", "value": "vllm"},
1034
+ {"name": "LiteLLM (Multi-provider)", "value": "litellm"},
930
1035
  ]
931
1036
  )
932
1037
 
933
1038
  agent = hud_console.select("Select an agent to use:", choices=choices, default=0)
934
1039
 
935
1040
  # Handle HUD model selection
936
- if agent and agent not in ["claude", "openai", "vllm"]:
1041
+ if agent and agent not in ["claude", "openai", "vllm", "litellm"]:
937
1042
  # Find remote model name
938
1043
  model = agent
939
1044
  if not vllm_base_url:
@@ -954,7 +1059,7 @@ def eval(
954
1059
  hud_console.info(f"Using HUD model: {model} (trained on {base_model})")
955
1060
 
956
1061
  # Validate agent choice
957
- valid_agents = ["claude", "openai", "vllm"]
1062
+ valid_agents = ["claude", "openai", "vllm", "litellm"]
958
1063
  if agent not in valid_agents:
959
1064
  hud_console.error(f"Invalid agent: {agent}. Must be one of: {', '.join(valid_agents)}")
960
1065
  raise typer.Exit(1)
@@ -972,6 +1077,7 @@ def eval(
972
1077
  max_workers=max_workers,
973
1078
  max_concurrent_per_worker=max_concurrent_per_worker,
974
1079
  verbose=verbose,
1080
+ very_verbose=very_verbose,
975
1081
  vllm_base_url=vllm_base_url,
976
1082
  group_size=group_size,
977
1083
  )
@@ -1021,7 +1127,7 @@ def rl(
1021
1127
  ),
1022
1128
  model: str | None = typer.Argument(
1023
1129
  None,
1024
- help="Model to train (default: interactive selection)",
1130
+ help="Model to train from https://hud.so/models (default: interactive selection)",
1025
1131
  ),
1026
1132
  config_file: Path | None = typer.Option( # noqa: B008
1027
1133
  None,
@@ -1061,6 +1167,12 @@ def rl(
1061
1167
  "--ddp-gpus",
1062
1168
  help="Specific GPUs for DDP (e.g., '0,1,2,3')",
1063
1169
  ),
1170
+ yes: bool = typer.Option(
1171
+ False,
1172
+ "--yes",
1173
+ "-y",
1174
+ help="Auto-accept all prompts and use defaults (lazy mode)",
1175
+ ),
1064
1176
  vllm_gpu: int | None = typer.Option(
1065
1177
  None,
1066
1178
  "--vllm-gpu",
@@ -1082,9 +1194,46 @@ def rl(
1082
1194
  no_ddp=no_ddp,
1083
1195
  ddp_gpus=ddp_gpus,
1084
1196
  vllm_gpu=vllm_gpu,
1197
+ yes=yes,
1085
1198
  )
1086
1199
 
1087
1200
 
1201
+ @app.command()
1202
+ def set(
1203
+ assignments: list[str] = typer.Argument( # type: ignore[arg-type] # noqa: B008
1204
+ ..., help="One or more KEY=VALUE pairs to persist in ~/.hud/.env"
1205
+ ),
1206
+ ) -> None:
1207
+ """Persist API keys or other variables for HUD to use by default.
1208
+
1209
+ Examples:
1210
+ hud set ANTHROPIC_API_KEY=sk-... OPENAI_API_KEY=sk-...
1211
+
1212
+ Values are stored in ~/.hud/.env and are loaded by hud.settings with
1213
+ the lowest precedence (overridden by process env and project .env).
1214
+ """
1215
+ from hud.utils.hud_console import HUDConsole
1216
+
1217
+ hud_console = HUDConsole()
1218
+
1219
+ updates: dict[str, str] = {}
1220
+ for item in assignments:
1221
+ if "=" not in item:
1222
+ hud_console.error(f"Invalid assignment (expected KEY=VALUE): {item}")
1223
+ raise typer.Exit(1)
1224
+ key, value = item.split("=", 1)
1225
+ key = key.strip()
1226
+ value = value.strip()
1227
+ if not key:
1228
+ hud_console.error(f"Invalid key in assignment: {item}")
1229
+ raise typer.Exit(1)
1230
+ updates[key] = value
1231
+
1232
+ path = set_env_values(updates)
1233
+ hud_console.success("Saved credentials to user config")
1234
+ hud_console.info(f"Location: {path}")
1235
+
1236
+
1088
1237
  def main() -> None:
1089
1238
  """Main entry point for the CLI."""
1090
1239
  # Handle --version flag before Typer parses args
hud/cli/build.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextlib
6
7
  import hashlib
7
8
  import subprocess
8
9
  import time
@@ -13,6 +14,7 @@ from typing import Any
13
14
  import typer
14
15
  import yaml
15
16
 
17
+ from hud.cli.utils.source_hash import compute_source_hash, list_source_files
16
18
  from hud.clients import MCPClient
17
19
  from hud.utils.hud_console import HUDConsole
18
20
  from hud.version import __version__ as hud_version
@@ -236,10 +238,13 @@ def build_docker_image(
236
238
  hud_console.error(f"No Dockerfile found in {directory}")
237
239
  return False
238
240
 
241
+ # Default platform to match RL pipeline unless explicitly overridden
242
+ effective_platform = platform if platform is not None else "linux/amd64"
243
+
239
244
  # Build command
240
245
  cmd = ["docker", "build"]
241
- if platform:
242
- cmd.extend(["--platform", platform])
246
+ if effective_platform:
247
+ cmd.extend(["--platform", effective_platform])
243
248
  cmd.extend(["-t", tag])
244
249
  if no_cache:
245
250
  cmd.append("--no-cache")
@@ -338,10 +343,11 @@ def build_environment(
338
343
  required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
339
344
 
340
345
  # Merge user-provided env vars with detected ones
341
- provided_env_vars = {}
346
+ provided_env_vars: dict[str, str] = {}
342
347
  missing_required = []
343
348
  if env_vars:
344
- provided_env_vars = env_vars.copy()
349
+ # Use placeholders in lock file for any provided values to avoid storing secrets
350
+ provided_env_vars = {k: f"${{{k}}}" for k in env_vars}
345
351
  # Track which required vars are still missing
346
352
  missing_required = [e for e in required_env if e not in env_vars]
347
353
 
@@ -381,6 +387,8 @@ def build_environment(
381
387
  "hudVersion": hud_version,
382
388
  "directory": str(env_dir.name),
383
389
  "version": new_version, # Internal environment version
390
+ # Fast source fingerprint for change detection
391
+ "sourceHash": compute_source_hash(env_dir),
384
392
  },
385
393
  "environment": {
386
394
  "initializeMs": analysis["initializeMs"],
@@ -421,6 +429,16 @@ def build_environment(
421
429
  with open(lock_path, "w") as f:
422
430
  yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
423
431
 
432
+ # Also write the file list we hashed for transparency (non-essential)
433
+ with contextlib.suppress(Exception):
434
+ files = [
435
+ str(p.resolve().relative_to(env_dir)).replace("\\", "/")
436
+ for p in list_source_files(env_dir)
437
+ ]
438
+ lock_content["build"]["sourceFiles"] = files
439
+ with open(lock_path, "w") as f:
440
+ yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
441
+
424
442
  hud_console.success("Created lock file: hud.lock.yaml")
425
443
 
426
444
  # Calculate lock file hash
@@ -437,8 +455,10 @@ def build_environment(
437
455
  version_tag = f"{base_name}:{new_version}"
438
456
 
439
457
  label_cmd = ["docker", "build"]
440
- if platform is not None:
441
- label_cmd.extend(["--platform", platform])
458
+ # Use same defaulting for the second build step
459
+ label_platform = platform if platform is not None else "linux/amd64"
460
+ if label_platform:
461
+ label_cmd.extend(["--platform", label_platform])
442
462
  label_cmd.extend(
443
463
  [
444
464
  "--label",
hud/cli/dev.py CHANGED
@@ -14,7 +14,7 @@ from fastmcp import FastMCP
14
14
 
15
15
  from hud.utils.hud_console import HUDConsole
16
16
 
17
- from .utils.docker import get_docker_cmd, inject_supervisor
17
+ from .utils.docker import get_docker_cmd
18
18
  from .utils.environment import (
19
19
  build_environment,
20
20
  get_image_name,
@@ -42,7 +42,7 @@ def create_proxy_server(
42
42
  interactive: bool = False,
43
43
  ) -> FastMCP:
44
44
  """Create an HTTP proxy server that forwards to Docker container with hot-reload."""
45
- src_path = Path(directory) / "src"
45
+ project_path = Path(directory)
46
46
 
47
47
  # Get the original CMD from the image
48
48
  original_cmd = get_docker_cmd(image_name)
@@ -66,15 +66,22 @@ def create_proxy_server(
66
66
  "--name",
67
67
  container_name,
68
68
  "-v",
69
- f"{src_path.absolute()}:/app/src:rw",
69
+ f"{project_path.absolute()}:/app:rw",
70
70
  "-e",
71
- "PYTHONPATH=/app/src",
71
+ "PYTHONPATH=/app",
72
+ "-e",
73
+ "PYTHONUNBUFFERED=1", # Ensure Python output is not buffered
72
74
  ]
73
75
 
74
76
  # Add user-provided Docker arguments
75
77
  if docker_args:
76
78
  docker_cmd.extend(docker_args)
77
79
 
80
+ # Append the image name and CMD
81
+ docker_cmd.append(image_name)
82
+ if original_cmd:
83
+ docker_cmd.extend(original_cmd)
84
+
78
85
  # Disable hot-reload if interactive mode is enabled
79
86
  if interactive:
80
87
  no_reload = True
@@ -84,17 +91,6 @@ def create_proxy_server(
84
91
  hud_console.warning("Cannot use --full-reload with --no-reload, ignoring --full-reload")
85
92
  full_reload = False
86
93
 
87
- if not no_reload and not full_reload:
88
- # Standard hot-reload: inject supervisor for server restart within container
89
- modified_cmd = inject_supervisor(original_cmd)
90
- docker_cmd.extend(["--entrypoint", modified_cmd[0]])
91
- docker_cmd.append(image_name)
92
- docker_cmd.extend(modified_cmd[1:])
93
- else:
94
- # No reload or full reload: use original CMD without supervisor
95
- # Note: Full reload logic (container restart) would be implemented here in the future
96
- docker_cmd.append(image_name)
97
-
98
94
  # Create configuration following MCPConfig schema
99
95
  config = {
100
96
  "mcpServers": {
@@ -108,17 +104,12 @@ def create_proxy_server(
108
104
 
109
105
  # Debug output - only if verbose
110
106
  if verbose:
111
- if not no_reload and not full_reload:
112
- hud_console.info("Mode: Hot-reload (server restart within container)")
113
- hud_console.info("Watching: /app/src for changes")
114
- elif full_reload:
107
+ if full_reload:
115
108
  hud_console.info("Mode: Full reload (container restart on file changes)")
116
- hud_console.info(
117
- "Note: Full container restart not yet implemented, using no-reload mode"
118
- )
109
+ hud_console.info("Note: Full container restart not yet implemented")
119
110
  else:
120
- hud_console.info("Mode: No reload")
121
- hud_console.info("Container will run without hot-reload")
111
+ hud_console.info("Mode: Container manages its own reload")
112
+ hud_console.info("The container's CMD determines reload behavior")
122
113
  hud_console.command_example(f"docker logs -f {container_name}", "View container logs")
123
114
 
124
115
  # Show the full Docker command if there are environment variables
@@ -227,10 +218,10 @@ async def start_mcp_proxy(
227
218
  stderr_handler = logging.StreamHandler(sys.stderr)
228
219
  root_logger.addHandler(stderr_handler)
229
220
 
230
- # Now check for src directory
231
- src_path = Path(directory) / "src"
232
- if not src_path.exists():
233
- hud_console.error(f"Source directory not found: {src_path}")
221
+ # Validate project directory exists
222
+ project_path = Path(directory)
223
+ if not project_path.exists():
224
+ hud_console.error(f"Project directory not found: {project_path}")
234
225
  raise click.Abort
235
226
 
236
227
  # Extract container name from the proxy configuration (must match create_proxy_server naming)
@@ -539,7 +530,7 @@ async def start_mcp_proxy(
539
530
  stderr=asyncio.subprocess.DEVNULL,
540
531
  )
541
532
  await stop_result.communicate()
542
- hud_console.success("Container stopped successfully")
533
+ hud_console.success("Container stopped successfully")
543
534
  container_stopped = True
544
535
  except Exception as e:
545
536
  hud_console.warning(f"Failed to stop container: {e}")
@@ -774,16 +765,6 @@ def run_mcp_dev_server(
774
765
  elif not interactive:
775
766
  hud_console.progress_message("🧪 Run with --interactive for interactive testing mode")
776
767
 
777
- # Disable logs and hot-reload if interactive mode is enabled
778
- if interactive and not no_logs:
779
- hud_console.warning("Docker logs disabled in interactive mode for better UI experience")
780
- no_logs = True
781
- # if not no_reload:
782
- # hud_console.warning(
783
- # "Hot-reload disabled in interactive mode to prevent output interference"
784
- # )
785
- # no_reload = True
786
-
787
768
  # Show configuration as JSON (just the server config, not wrapped)
788
769
  full_config = {}
789
770
  full_config[server_name] = server_config
@@ -796,9 +777,9 @@ def run_mcp_dev_server(
796
777
  "Connect to Cursor (be careful with multiple windows as that may interfere with the proxy)"
797
778
  )
798
779
  hud_console.link(deeplink)
780
+
799
781
  hud_console.info("") # Empty line
800
782
 
801
- # Start the proxy (pass original port, start_mcp_proxy will find actual port again)
802
783
  try:
803
784
  asyncio.run(
804
785
  start_mcp_proxy(