hud-python 0.4.45__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. hud/__init__.py +27 -7
  2. hud/agents/__init__.py +11 -5
  3. hud/agents/base.py +220 -500
  4. hud/agents/claude.py +200 -240
  5. hud/agents/gemini.py +275 -0
  6. hud/agents/gemini_cua.py +335 -0
  7. hud/agents/grounded_openai.py +98 -100
  8. hud/agents/misc/integration_test_agent.py +51 -20
  9. hud/agents/misc/response_agent.py +41 -36
  10. hud/agents/openai.py +291 -292
  11. hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
  12. hud/agents/operator.py +211 -0
  13. hud/agents/tests/conftest.py +133 -0
  14. hud/agents/tests/test_base.py +300 -622
  15. hud/agents/tests/test_base_runtime.py +233 -0
  16. hud/agents/tests/test_claude.py +379 -210
  17. hud/agents/tests/test_client.py +9 -10
  18. hud/agents/tests/test_gemini.py +369 -0
  19. hud/agents/tests/test_grounded_openai_agent.py +65 -50
  20. hud/agents/tests/test_openai.py +376 -140
  21. hud/agents/tests/test_operator.py +362 -0
  22. hud/agents/tests/test_run_eval.py +179 -0
  23. hud/cli/__init__.py +461 -545
  24. hud/cli/analyze.py +43 -5
  25. hud/cli/build.py +664 -110
  26. hud/cli/debug.py +8 -5
  27. hud/cli/dev.py +882 -734
  28. hud/cli/eval.py +782 -668
  29. hud/cli/flows/dev.py +167 -0
  30. hud/cli/flows/init.py +191 -0
  31. hud/cli/flows/tasks.py +153 -56
  32. hud/cli/flows/templates.py +151 -0
  33. hud/cli/flows/tests/__init__.py +1 -0
  34. hud/cli/flows/tests/test_dev.py +126 -0
  35. hud/cli/init.py +60 -58
  36. hud/cli/push.py +29 -11
  37. hud/cli/rft.py +311 -0
  38. hud/cli/rft_status.py +145 -0
  39. hud/cli/tests/test_analyze.py +5 -5
  40. hud/cli/tests/test_analyze_metadata.py +3 -2
  41. hud/cli/tests/test_analyze_module.py +120 -0
  42. hud/cli/tests/test_build.py +108 -6
  43. hud/cli/tests/test_build_failure.py +41 -0
  44. hud/cli/tests/test_build_module.py +50 -0
  45. hud/cli/tests/test_cli_init.py +6 -1
  46. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  47. hud/cli/tests/test_cli_root.py +140 -0
  48. hud/cli/tests/test_convert.py +361 -0
  49. hud/cli/tests/test_debug.py +12 -10
  50. hud/cli/tests/test_dev.py +197 -0
  51. hud/cli/tests/test_eval.py +251 -0
  52. hud/cli/tests/test_eval_bedrock.py +51 -0
  53. hud/cli/tests/test_init.py +124 -0
  54. hud/cli/tests/test_main_module.py +11 -5
  55. hud/cli/tests/test_mcp_server.py +12 -100
  56. hud/cli/tests/test_push_happy.py +74 -0
  57. hud/cli/tests/test_push_wrapper.py +23 -0
  58. hud/cli/tests/test_registry.py +1 -1
  59. hud/cli/tests/test_utils.py +1 -1
  60. hud/cli/{rl → utils}/celebrate.py +14 -12
  61. hud/cli/utils/config.py +18 -1
  62. hud/cli/utils/docker.py +130 -4
  63. hud/cli/utils/env_check.py +9 -9
  64. hud/cli/utils/git.py +136 -0
  65. hud/cli/utils/interactive.py +39 -5
  66. hud/cli/utils/metadata.py +69 -0
  67. hud/cli/utils/runner.py +1 -1
  68. hud/cli/utils/server.py +2 -2
  69. hud/cli/utils/source_hash.py +3 -3
  70. hud/cli/utils/tasks.py +4 -1
  71. hud/cli/utils/tests/__init__.py +0 -0
  72. hud/cli/utils/tests/test_config.py +58 -0
  73. hud/cli/utils/tests/test_docker.py +93 -0
  74. hud/cli/utils/tests/test_docker_hints.py +71 -0
  75. hud/cli/utils/tests/test_env_check.py +74 -0
  76. hud/cli/utils/tests/test_environment.py +42 -0
  77. hud/cli/utils/tests/test_git.py +142 -0
  78. hud/cli/utils/tests/test_interactive_module.py +60 -0
  79. hud/cli/utils/tests/test_local_runner.py +50 -0
  80. hud/cli/utils/tests/test_logging_utils.py +23 -0
  81. hud/cli/utils/tests/test_metadata.py +49 -0
  82. hud/cli/utils/tests/test_package_runner.py +35 -0
  83. hud/cli/utils/tests/test_registry_utils.py +49 -0
  84. hud/cli/utils/tests/test_remote_runner.py +25 -0
  85. hud/cli/utils/tests/test_runner_modules.py +52 -0
  86. hud/cli/utils/tests/test_source_hash.py +36 -0
  87. hud/cli/utils/tests/test_tasks.py +80 -0
  88. hud/cli/utils/version_check.py +258 -0
  89. hud/cli/{rl → utils}/viewer.py +2 -2
  90. hud/clients/README.md +12 -11
  91. hud/clients/__init__.py +4 -3
  92. hud/clients/base.py +166 -26
  93. hud/clients/environment.py +51 -0
  94. hud/clients/fastmcp.py +13 -6
  95. hud/clients/mcp_use.py +40 -15
  96. hud/clients/tests/test_analyze_scenarios.py +206 -0
  97. hud/clients/tests/test_protocol.py +9 -3
  98. hud/datasets/__init__.py +23 -20
  99. hud/datasets/loader.py +327 -0
  100. hud/datasets/runner.py +192 -105
  101. hud/datasets/tests/__init__.py +0 -0
  102. hud/datasets/tests/test_loader.py +221 -0
  103. hud/datasets/tests/test_utils.py +315 -0
  104. hud/datasets/utils.py +270 -90
  105. hud/environment/__init__.py +50 -0
  106. hud/environment/connection.py +206 -0
  107. hud/environment/connectors/__init__.py +33 -0
  108. hud/environment/connectors/base.py +68 -0
  109. hud/environment/connectors/local.py +177 -0
  110. hud/environment/connectors/mcp_config.py +109 -0
  111. hud/environment/connectors/openai.py +101 -0
  112. hud/environment/connectors/remote.py +172 -0
  113. hud/environment/environment.py +694 -0
  114. hud/environment/integrations/__init__.py +45 -0
  115. hud/environment/integrations/adk.py +67 -0
  116. hud/environment/integrations/anthropic.py +196 -0
  117. hud/environment/integrations/gemini.py +92 -0
  118. hud/environment/integrations/langchain.py +82 -0
  119. hud/environment/integrations/llamaindex.py +68 -0
  120. hud/environment/integrations/openai.py +238 -0
  121. hud/environment/mock.py +306 -0
  122. hud/environment/router.py +112 -0
  123. hud/environment/scenarios.py +493 -0
  124. hud/environment/tests/__init__.py +1 -0
  125. hud/environment/tests/test_connection.py +317 -0
  126. hud/environment/tests/test_connectors.py +218 -0
  127. hud/environment/tests/test_environment.py +161 -0
  128. hud/environment/tests/test_integrations.py +257 -0
  129. hud/environment/tests/test_local_connectors.py +201 -0
  130. hud/environment/tests/test_scenarios.py +280 -0
  131. hud/environment/tests/test_tools.py +208 -0
  132. hud/environment/types.py +23 -0
  133. hud/environment/utils/__init__.py +35 -0
  134. hud/environment/utils/formats.py +215 -0
  135. hud/environment/utils/schema.py +171 -0
  136. hud/environment/utils/tool_wrappers.py +113 -0
  137. hud/eval/__init__.py +67 -0
  138. hud/eval/context.py +674 -0
  139. hud/eval/display.py +299 -0
  140. hud/eval/instrument.py +185 -0
  141. hud/eval/manager.py +466 -0
  142. hud/eval/parallel.py +268 -0
  143. hud/eval/task.py +340 -0
  144. hud/eval/tests/__init__.py +1 -0
  145. hud/eval/tests/test_context.py +178 -0
  146. hud/eval/tests/test_eval.py +210 -0
  147. hud/eval/tests/test_manager.py +152 -0
  148. hud/eval/tests/test_parallel.py +168 -0
  149. hud/eval/tests/test_task.py +145 -0
  150. hud/eval/types.py +63 -0
  151. hud/eval/utils.py +183 -0
  152. hud/patches/__init__.py +19 -0
  153. hud/patches/mcp_patches.py +151 -0
  154. hud/patches/warnings.py +54 -0
  155. hud/samples/browser.py +4 -4
  156. hud/server/__init__.py +2 -1
  157. hud/server/low_level.py +2 -1
  158. hud/server/router.py +164 -0
  159. hud/server/server.py +567 -80
  160. hud/server/tests/test_mcp_server_integration.py +11 -11
  161. hud/server/tests/test_mcp_server_more.py +1 -1
  162. hud/server/tests/test_server_extra.py +2 -0
  163. hud/settings.py +45 -3
  164. hud/shared/exceptions.py +36 -10
  165. hud/shared/hints.py +26 -1
  166. hud/shared/requests.py +15 -3
  167. hud/shared/tests/test_exceptions.py +40 -31
  168. hud/shared/tests/test_hints.py +167 -0
  169. hud/telemetry/__init__.py +20 -19
  170. hud/telemetry/exporter.py +201 -0
  171. hud/telemetry/instrument.py +158 -253
  172. hud/telemetry/tests/test_eval_telemetry.py +356 -0
  173. hud/telemetry/tests/test_exporter.py +258 -0
  174. hud/telemetry/tests/test_instrument.py +401 -0
  175. hud/tools/__init__.py +16 -2
  176. hud/tools/apply_patch.py +639 -0
  177. hud/tools/base.py +54 -4
  178. hud/tools/bash.py +2 -2
  179. hud/tools/computer/__init__.py +4 -0
  180. hud/tools/computer/anthropic.py +2 -2
  181. hud/tools/computer/gemini.py +385 -0
  182. hud/tools/computer/hud.py +23 -6
  183. hud/tools/computer/openai.py +20 -21
  184. hud/tools/computer/qwen.py +434 -0
  185. hud/tools/computer/settings.py +37 -0
  186. hud/tools/edit.py +3 -7
  187. hud/tools/executors/base.py +4 -2
  188. hud/tools/executors/pyautogui.py +1 -1
  189. hud/tools/grounding/grounded_tool.py +13 -18
  190. hud/tools/grounding/grounder.py +10 -31
  191. hud/tools/grounding/tests/test_grounded_tool.py +26 -44
  192. hud/tools/jupyter.py +330 -0
  193. hud/tools/playwright.py +18 -3
  194. hud/tools/shell.py +308 -0
  195. hud/tools/tests/test_apply_patch.py +718 -0
  196. hud/tools/tests/test_computer.py +4 -9
  197. hud/tools/tests/test_computer_actions.py +24 -2
  198. hud/tools/tests/test_jupyter_tool.py +181 -0
  199. hud/tools/tests/test_shell.py +596 -0
  200. hud/tools/tests/test_submit.py +85 -0
  201. hud/tools/tests/test_types.py +193 -0
  202. hud/tools/types.py +21 -1
  203. hud/types.py +167 -57
  204. hud/utils/__init__.py +2 -0
  205. hud/utils/env.py +67 -0
  206. hud/utils/hud_console.py +61 -3
  207. hud/utils/mcp.py +15 -58
  208. hud/utils/strict_schema.py +162 -0
  209. hud/utils/tests/test_init.py +1 -2
  210. hud/utils/tests/test_mcp.py +1 -28
  211. hud/utils/tests/test_pretty_errors.py +186 -0
  212. hud/utils/tests/test_tool_shorthand.py +154 -0
  213. hud/utils/tests/test_version.py +1 -1
  214. hud/utils/types.py +20 -0
  215. hud/version.py +1 -1
  216. hud_python-0.5.1.dist-info/METADATA +264 -0
  217. hud_python-0.5.1.dist-info/RECORD +299 -0
  218. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
  219. hud/agents/langchain.py +0 -261
  220. hud/agents/lite_llm.py +0 -72
  221. hud/cli/rl/__init__.py +0 -180
  222. hud/cli/rl/config.py +0 -101
  223. hud/cli/rl/display.py +0 -133
  224. hud/cli/rl/gpu.py +0 -63
  225. hud/cli/rl/gpu_utils.py +0 -321
  226. hud/cli/rl/local_runner.py +0 -595
  227. hud/cli/rl/presets.py +0 -96
  228. hud/cli/rl/remote_runner.py +0 -463
  229. hud/cli/rl/rl_api.py +0 -150
  230. hud/cli/rl/vllm.py +0 -177
  231. hud/cli/rl/wait_utils.py +0 -89
  232. hud/datasets/parallel.py +0 -687
  233. hud/misc/__init__.py +0 -1
  234. hud/misc/claude_plays_pokemon.py +0 -292
  235. hud/otel/__init__.py +0 -35
  236. hud/otel/collector.py +0 -142
  237. hud/otel/config.py +0 -181
  238. hud/otel/context.py +0 -570
  239. hud/otel/exporters.py +0 -369
  240. hud/otel/instrumentation.py +0 -135
  241. hud/otel/processors.py +0 -121
  242. hud/otel/tests/__init__.py +0 -1
  243. hud/otel/tests/test_processors.py +0 -197
  244. hud/rl/README.md +0 -30
  245. hud/rl/__init__.py +0 -1
  246. hud/rl/actor.py +0 -176
  247. hud/rl/buffer.py +0 -405
  248. hud/rl/chat_template.jinja +0 -101
  249. hud/rl/config.py +0 -192
  250. hud/rl/distributed.py +0 -132
  251. hud/rl/learner.py +0 -637
  252. hud/rl/tests/__init__.py +0 -1
  253. hud/rl/tests/test_learner.py +0 -186
  254. hud/rl/train.py +0 -382
  255. hud/rl/types.py +0 -101
  256. hud/rl/utils/start_vllm_server.sh +0 -30
  257. hud/rl/utils.py +0 -524
  258. hud/rl/vllm_adapter.py +0 -143
  259. hud/telemetry/job.py +0 -352
  260. hud/telemetry/replay.py +0 -74
  261. hud/telemetry/tests/test_replay.py +0 -40
  262. hud/telemetry/tests/test_trace.py +0 -63
  263. hud/telemetry/trace.py +0 -158
  264. hud/utils/agent_factories.py +0 -86
  265. hud/utils/async_utils.py +0 -65
  266. hud/utils/group_eval.py +0 -223
  267. hud/utils/progress.py +0 -149
  268. hud/utils/tasks.py +0 -127
  269. hud/utils/tests/test_async_utils.py +0 -173
  270. hud/utils/tests/test_progress.py +0 -261
  271. hud_python-0.4.45.dist-info/METADATA +0 -552
  272. hud_python-0.4.45.dist-info/RECORD +0 -228
  273. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
  274. {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
hud/cli/dev.py CHANGED
@@ -1,828 +1,976 @@
1
- """MCP Development Proxy - Hot-reload environments with MCP over HTTP."""
1
+ """MCP Development Server - Hot-reload Python modules."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import base64
7
- import json
6
+ import contextlib
7
+ import importlib
8
+ import importlib.util
9
+ import logging
10
+ import os
8
11
  import subprocess
12
+ import sys
13
+ import threading
9
14
  from pathlib import Path
10
15
  from typing import Any
11
16
 
12
- import click
13
- from fastmcp import FastMCP
17
+ import typer
14
18
 
15
19
  from hud.utils.hud_console import HUDConsole
16
20
 
17
- from .utils.docker import get_docker_cmd
18
- from .utils.environment import (
19
- build_environment,
20
- get_image_name,
21
- image_exists,
22
- update_pyproject_toml,
23
- )
24
-
25
- # Global hud_console instance
26
21
  hud_console = HUDConsole()
27
22
 
28
23
 
29
- def build_and_update(directory: str | Path, image_name: str, no_cache: bool = False) -> None:
30
- """Build Docker image and update pyproject.toml."""
31
- if not build_environment(directory, image_name, no_cache):
32
- raise click.Abort
24
+ def show_dev_server_info(
25
+ server_name: str,
26
+ port: int,
27
+ transport: str,
28
+ inspector: bool,
29
+ interactive: bool,
30
+ env_dir: Path | None = None,
31
+ docker_mode: bool = False,
32
+ telemetry: dict[str, Any] | None = None,
33
+ ) -> str:
34
+ """Show consistent server info for both Python and Docker modes.
35
+
36
+ Returns the Cursor deeplink URL.
37
+ """
38
+ import base64
39
+ import json
40
+
41
+ # Generate Cursor deeplink
42
+ server_config = {"url": f"http://localhost:{port}/mcp"}
43
+ config_json = json.dumps(server_config, indent=2)
44
+ config_base64 = base64.b64encode(config_json.encode()).decode()
45
+ cursor_deeplink = (
46
+ f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
47
+ )
33
48
 
49
+ # Server section
50
+ hud_console.section_title("Server")
51
+ hud_console.info(f"{hud_console.sym.ITEM} {server_name}")
52
+ if transport == "http":
53
+ hud_console.info(f"{hud_console.sym.ITEM} http://localhost:{port}/mcp")
54
+ else:
55
+ hud_console.info(f"{hud_console.sym.ITEM} (stdio)")
34
56
 
35
- def create_proxy_server(
36
- directory: str | Path,
37
- image_name: str,
38
- no_reload: bool = False,
39
- full_reload: bool = False,
40
- verbose: bool = False,
41
- docker_args: list[str] | None = None,
42
- interactive: bool = False,
43
- ) -> FastMCP:
44
- """Create an HTTP proxy server that forwards to Docker container with hot-reload."""
45
- project_path = Path(directory)
57
+ # Quick Links (only for HTTP mode)
58
+ if transport == "http":
59
+ hud_console.section_title("Quick Links")
60
+ hud_console.info(f"{hud_console.sym.ITEM} Docs: http://localhost:{port}/docs")
61
+ hud_console.info(f"{hud_console.sym.ITEM} Cursor:")
62
+ # Display the Cursor link on its own line to prevent wrapping
63
+ hud_console.link(cursor_deeplink)
64
+
65
+ # Show eval endpoint if in Docker mode
66
+ if docker_mode:
67
+ hud_console.info(
68
+ f"{hud_console.sym.ITEM} Eval API: http://localhost:{port}/eval (POST)"
69
+ )
46
70
 
47
- # Get the original CMD from the image
48
- original_cmd = get_docker_cmd(image_name)
49
- if not original_cmd:
50
- hud_console.warning(f"Could not extract CMD from {image_name}, using default")
51
- original_cmd = ["python", "-m", "hud_controller.server"]
71
+ # Show debugging URLs from telemetry
72
+ if telemetry:
73
+ if "live_url" in telemetry:
74
+ hud_console.info(f"{hud_console.sym.ITEM} Live URL: {telemetry['live_url']}")
75
+ if "vnc_url" in telemetry:
76
+ hud_console.info(f"{hud_console.sym.ITEM} VNC URL: {telemetry['vnc_url']}")
77
+ if "cdp_url" in telemetry:
78
+ hud_console.info(f"{hud_console.sym.ITEM} CDP URL: {telemetry['cdp_url']}")
79
+
80
+ # Check for VNC (browser environment)
81
+ if env_dir and (env_dir / "environment" / "server.py").exists():
82
+ try:
83
+ content = (env_dir / "environment" / "server.py").read_text()
84
+ if "x11vnc" in content.lower() or "vnc" in content.lower():
85
+ hud_console.info(f"{hud_console.sym.ITEM} VNC: http://localhost:8080/vnc.html")
86
+ except Exception: # noqa: S110
87
+ pass
88
+
89
+ # Inspector/Interactive status
90
+ if inspector or interactive:
91
+ hud_console.info("")
92
+ if inspector:
93
+ hud_console.info(f"{hud_console.sym.SUCCESS} Inspector launching...")
94
+ if interactive:
95
+ hud_console.info(f"{hud_console.sym.SUCCESS} Interactive mode enabled")
52
96
 
53
- # Generate unique container name from image to avoid conflicts between multiple instances
54
- import os
97
+ hud_console.info("")
98
+ hud_console.info(f"{hud_console.sym.SUCCESS} Hot-reload enabled")
99
+ hud_console.info("")
55
100
 
56
- pid = str(os.getpid())[-6:] # Last 6 digits of process ID for uniqueness
57
- base_name = image_name.replace(":", "-").replace("/", "-")
58
- container_name = f"{base_name}-{pid}"
59
-
60
- # Build the docker run command
61
- docker_cmd = [
62
- "docker",
63
- "run",
64
- "--rm",
65
- "-i",
66
- "--name",
67
- container_name,
68
- "-v",
69
- f"{project_path.absolute()}:/app:rw",
70
- "-e",
71
- "PYTHONPATH=/app",
72
- "-e",
73
- "PYTHONUNBUFFERED=1", # Ensure Python output is not buffered
74
- ]
101
+ return cursor_deeplink
102
+
103
+
104
+ def _has_mcp_or_env(content: str) -> bool:
105
+ """Check if file content defines an mcp or env variable."""
106
+ # Check for mcp = MCPServer(...) or mcp = FastMCP(...)
107
+ if "mcp" in content and ("= MCPServer" in content or "= FastMCP" in content):
108
+ return True
109
+ # Check for env = Environment(...)
110
+ return "env" in content and "= Environment" in content
111
+
112
+
113
+ def auto_detect_module() -> tuple[str, Path | None] | tuple[None, None]:
114
+ """Auto-detect MCP module in current directory.
75
115
 
76
- # Check for .env file in the project directory and add env vars
77
- env_file = project_path / ".env"
78
- loaded_env_vars = {}
79
- if env_file.exists():
116
+ Looks for 'mcp' or 'env' defined in either __init__.py or main.py.
117
+ - 'mcp' with MCPServer or FastMCP
118
+ - 'env' with Environment
119
+
120
+ Returns:
121
+ Tuple of (module_name, parent_dir_to_add_to_path) or (None, None)
122
+ """
123
+ cwd = Path.cwd()
124
+
125
+ # First check __init__.py
126
+ init_file = cwd / "__init__.py"
127
+ if init_file.exists():
80
128
  try:
81
- from hud.cli.utils.config import parse_env_file
82
-
83
- env_contents = env_file.read_text(encoding="utf-8")
84
- loaded_env_vars = parse_env_file(env_contents)
85
- for key, value in loaded_env_vars.items():
86
- docker_cmd.extend(["-e", f"{key}={value}"])
87
- if verbose and loaded_env_vars:
88
- hud_console.info(
89
- f"Loaded {len(loaded_env_vars)} environment variable(s) from .env file"
90
- )
91
- except Exception as e:
92
- hud_console.warning(f"Failed to load .env file: {e}")
93
-
94
- # Add user-provided Docker arguments
95
- if docker_args:
96
- docker_cmd.extend(docker_args)
97
-
98
- # Append the image name and CMD
99
- docker_cmd.append(image_name)
100
- if original_cmd:
101
- docker_cmd.extend(original_cmd)
102
-
103
- # Disable hot-reload if interactive mode is enabled
104
- if interactive:
105
- no_reload = True
106
-
107
- # Validate reload options
108
- if no_reload and full_reload:
109
- hud_console.warning("Cannot use --full-reload with --no-reload, ignoring --full-reload")
110
- full_reload = False
111
-
112
- # Create configuration following MCPConfig schema
113
- config = {
114
- "mcpServers": {
115
- "default": {
116
- "command": docker_cmd[0],
117
- "args": docker_cmd[1:] if len(docker_cmd) > 1 else [],
118
- # transport defaults to stdio
119
- }
120
- }
121
- }
129
+ content = init_file.read_text(encoding="utf-8")
130
+ if _has_mcp_or_env(content):
131
+ return (cwd.name, None)
132
+ except Exception: # noqa: S110
133
+ pass
134
+
135
+ # Then check main.py in current directory
136
+ main_file = cwd / "main.py"
137
+ if main_file.exists() and init_file.exists():
138
+ try:
139
+ content = main_file.read_text(encoding="utf-8")
140
+ if _has_mcp_or_env(content):
141
+ # Need to import as package.main, add parent to sys.path
142
+ return (f"{cwd.name}.main", cwd.parent)
143
+ except Exception: # noqa: S110
144
+ pass
145
+
146
+ return (None, None)
147
+
148
+
149
+ def should_use_docker_mode(cwd: Path) -> bool:
150
+ """Check if environment requires Docker mode (has Dockerfile in current dir).
151
+
152
+ Checks for Dockerfile.hud first (HUD-specific), then falls back to Dockerfile.
153
+ """
154
+ return (cwd / "Dockerfile.hud").exists() or (cwd / "Dockerfile").exists()
155
+
156
+
157
+ async def run_mcp_module(
158
+ module_spec: str,
159
+ transport: str,
160
+ port: int,
161
+ verbose: bool,
162
+ inspector: bool,
163
+ interactive: bool,
164
+ new_trace: bool = False,
165
+ ) -> None:
166
+ """Run an MCP module directly.
167
+
168
+ Args:
169
+ module_spec: Module specification in format "module" or "module:attribute"
170
+ e.g., "server" (looks for mcp), "env:env" (looks for env)
171
+ """
172
+ # Parse module:attribute format (like uvicorn/gunicorn)
173
+ if ":" in module_spec:
174
+ module_name, attr_name = module_spec.rsplit(":", 1)
175
+ else:
176
+ module_name = module_spec
177
+ attr_name = "mcp" # Default attribute
178
+
179
+ # Check if this is a reload (not first run)
180
+ is_reload = os.environ.get("_HUD_DEV_RELOAD") == "1"
181
+
182
+ # Configure logging
183
+ if verbose:
184
+ logging.basicConfig(
185
+ stream=sys.stderr, level=logging.DEBUG, format="[%(levelname)s] %(message)s"
186
+ )
187
+ else:
188
+ # Suppress tracebacks in logs unless verbose
189
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(message)s")
190
+
191
+ # Suppress FastMCP's verbose logging
192
+ logging.getLogger("fastmcp.tools.tool_manager").setLevel(logging.WARNING)
193
+ logging.getLogger("fastmcp.server.server").setLevel(logging.WARNING)
194
+ logging.getLogger("fastmcp.server.openapi").setLevel(logging.WARNING)
195
+
196
+ # On reload, suppress most startup logs
197
+ if is_reload:
198
+ logging.getLogger("hud.server.server").setLevel(logging.ERROR)
199
+ logging.getLogger("mcp.server").setLevel(logging.ERROR)
200
+ logging.getLogger("mcp.server.streamable_http_manager").setLevel(logging.ERROR)
201
+
202
+ # Suppress deprecation warnings on reload
203
+ from hud.patches.warnings import apply_default_warning_filters
204
+
205
+ apply_default_warning_filters(verbose=False)
206
+
207
+ # Ensure proper directory is in sys.path based on module name
208
+ cwd = Path.cwd()
209
+ if "." in module_name:
210
+ # For package.module imports (like server.server), add parent to sys.path
211
+ parent = str(cwd.parent)
212
+ if parent not in sys.path:
213
+ sys.path.insert(0, parent)
214
+ else:
215
+ # For simple module imports, add current directory
216
+ cwd_str = str(cwd)
217
+ if cwd_str not in sys.path:
218
+ sys.path.insert(0, cwd_str)
219
+
220
+ # Import the module
221
+ try:
222
+ module = importlib.import_module(module_name)
223
+ except Exception as e:
224
+ hud_console.error(f"Failed to import module '{module_name}'")
225
+ hud_console.info(f"Error: {e}")
226
+ hud_console.info("")
227
+ hud_console.info("[bold cyan]Troubleshooting:[/bold cyan]")
228
+ hud_console.info(" • Verify module exists and is importable")
229
+ hud_console.info(" • Check for __init__.py in module directory")
230
+ hud_console.info(" • Check for import errors in the module")
231
+ if verbose:
232
+ import traceback
233
+
234
+ hud_console.info("")
235
+ hud_console.info("[bold cyan]Full traceback:[/bold cyan]")
236
+ hud_console.info(traceback.format_exc())
237
+ sys.exit(1)
122
238
 
123
- # Debug output - only if verbose
239
+ # Look for the specified attribute
124
240
  if verbose:
125
- if full_reload:
126
- hud_console.info("Mode: Full reload (container restart on file changes)")
127
- hud_console.info("Note: Full container restart not yet implemented")
241
+ hud_console.info(f"Module attributes: {dir(module)}")
242
+ module_dict = module.__dict__ if hasattr(module, "__dict__") else {}
243
+ hud_console.info(f"Module __dict__ keys: {list(module_dict.keys())}")
244
+
245
+ mcp_server = None
246
+
247
+ # Try different ways to access the attribute
248
+ if hasattr(module, attr_name):
249
+ mcp_server = getattr(module, attr_name)
250
+ elif hasattr(module, "__dict__") and attr_name in module.__dict__:
251
+ mcp_server = module.__dict__[attr_name]
252
+
253
+ if mcp_server is None:
254
+ hud_console.error(f"Module '{module_name}' does not have '{attr_name}' defined")
255
+ hud_console.info("")
256
+ available = [k for k in dir(module) if not k.startswith("_")]
257
+ hud_console.info(f"Available in module: {available}")
258
+ hud_console.info("")
259
+ hud_console.info("[bold cyan]Expected structure:[/bold cyan]")
260
+ hud_console.info(" from hud.environment import Environment")
261
+ hud_console.info(f" {attr_name} = Environment('my-env')")
262
+ raise AttributeError(f"Module '{module_name}' must define '{attr_name}'")
263
+
264
+ # Only show full header on first run, brief message on reload
265
+ if is_reload:
266
+ hud_console.info(f"{hud_console.sym.SUCCESS} Reloaded")
267
+ # Run server without showing full UI
268
+ else:
269
+ # Show full header on first run
270
+ hud_console.info("")
271
+ hud_console.header("HUD Development Server")
272
+
273
+ # Show server info only on first run
274
+ if not is_reload:
275
+ # Try dynamic trace first for HTTP mode (only if --new flag is set)
276
+ live_trace_url: str | None = None
277
+ if transport == "http" and new_trace:
278
+ try:
279
+ local_mcp_config: dict[str, dict[str, Any]] = {
280
+ "hud": {
281
+ "url": f"http://localhost:{port}/mcp",
282
+ "headers": {},
283
+ }
284
+ }
285
+
286
+ from hud.cli.flows.dev import create_dynamic_trace
287
+
288
+ _, live_trace_url = await create_dynamic_trace(
289
+ mcp_config=local_mcp_config,
290
+ build_status=False,
291
+ environment_name=mcp_server.name or "mcp-server",
292
+ )
293
+ except SystemExit:
294
+ raise # Let API key requirement exits through
295
+ except Exception: # noqa: S110
296
+ pass
297
+
298
+ # Show UI using shared flow logic
299
+ if transport == "http" and live_trace_url:
300
+ # Minimal UI with live trace
301
+ from hud.cli.flows.dev import generate_cursor_deeplink, show_dev_ui
302
+
303
+ server_name = mcp_server.name or "mcp-server"
304
+ cursor_deeplink = generate_cursor_deeplink(server_name, port)
305
+
306
+ show_dev_ui(
307
+ live_trace_url=live_trace_url,
308
+ server_name=server_name,
309
+ port=port,
310
+ cursor_deeplink=cursor_deeplink,
311
+ is_docker=False,
312
+ )
128
313
  else:
129
- hud_console.info("Mode: Container manages its own reload")
130
- hud_console.info("The container's CMD determines reload behavior")
131
- hud_console.command_example(f"docker logs -f {container_name}", "View container logs")
314
+ # Full UI for HTTP without trace, or stdio mode
315
+ show_dev_server_info(
316
+ server_name=mcp_server.name or "mcp-server",
317
+ port=port,
318
+ transport=transport,
319
+ inspector=inspector,
320
+ interactive=interactive,
321
+ env_dir=Path.cwd().parent if (Path.cwd().parent / "environment").exists() else None,
322
+ )
132
323
 
133
- # Show the full Docker command if there are environment variables (from .env or args)
134
- has_env_from_args = docker_args and any(
135
- arg == "-e" or arg.startswith("--env") for arg in docker_args
136
- )
137
- has_env_from_file = bool(loaded_env_vars)
138
- if has_env_from_args or has_env_from_file:
324
+ # Check if there's an environment backend and remind user to start it (first run only)
325
+ if not is_reload:
326
+ cwd = Path.cwd()
327
+ env_dir = cwd.parent / "environment"
328
+ if env_dir.exists() and (env_dir / "server.py").exists():
139
329
  hud_console.info("")
140
- hud_console.info("Docker command with environment variables:")
141
- hud_console.info(" ".join(docker_cmd))
330
+ hud_console.info(
331
+ f"{hud_console.sym.FLOW} Don't forget to start the environment "
332
+ "backend in another terminal:"
333
+ )
334
+ hud_console.info(" cd environment && uv run python uvicorn server:app --reload")
335
+
336
+ # Launch inspector if requested (first run only)
337
+ if inspector and transport == "http":
338
+ await launch_inspector(port)
339
+
340
+ # Launch interactive mode if requested (first run only)
341
+ if interactive and transport == "http":
342
+ launch_interactive_thread(port, verbose)
343
+
344
+ hud_console.info("")
345
+
346
+ # Configure server options
347
+ run_kwargs = {
348
+ "transport": transport,
349
+ "show_banner": False,
350
+ }
351
+
352
+ if transport == "http":
353
+ run_kwargs["port"] = port
354
+ run_kwargs["path"] = "/mcp"
355
+ run_kwargs["host"] = "0.0.0.0" # noqa: S104
356
+ run_kwargs["log_level"] = "INFO" if verbose else "ERROR"
357
+
358
+ # Run the server
359
+ await mcp_server.run_async(**run_kwargs)
360
+
361
+
362
+ async def launch_inspector(port: int) -> None:
363
+ """Launch MCP Inspector in background."""
364
+ await asyncio.sleep(2)
142
365
 
143
- # Create the HTTP proxy server using config
144
366
  try:
145
- proxy = FastMCP.as_proxy(config, name=f"HUD Dev Proxy - {image_name}")
367
+ import platform
368
+ import urllib.parse
369
+
370
+ server_url = f"http://localhost:{port}/mcp"
371
+ encoded_url = urllib.parse.quote(server_url)
372
+ inspector_url = f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
373
+
374
+ hud_console.section_title("MCP Inspector")
375
+ hud_console.link(inspector_url)
376
+
377
+ env = os.environ.copy()
378
+ env["DANGEROUSLY_OMIT_AUTH"] = "true"
379
+ env["MCP_AUTO_OPEN_ENABLED"] = "true"
380
+
381
+ cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
382
+
383
+ if platform.system() == "Windows":
384
+ subprocess.Popen( # noqa: S602, ASYNC220
385
+ cmd,
386
+ env=env,
387
+ shell=True,
388
+ stdout=subprocess.DEVNULL,
389
+ stderr=subprocess.DEVNULL,
390
+ )
391
+ else:
392
+ subprocess.Popen( # noqa: S603, ASYNC220
393
+ cmd,
394
+ env=env,
395
+ stdout=subprocess.DEVNULL,
396
+ stderr=subprocess.DEVNULL,
397
+ )
398
+
146
399
  except Exception as e:
147
- hud_console.error(f"Failed to create proxy server: {e}")
148
- hud_console.info("")
149
- hud_console.info("💡 Tip: Run the following command to debug the container:")
150
- hud_console.info(f" hud debug {image_name}")
151
- raise
400
+ hud_console.error(f"Failed to launch inspector: {e}")
152
401
 
153
- return proxy
154
402
 
403
+ def launch_interactive_thread(port: int, verbose: bool) -> None:
404
+ """Launch interactive testing mode in separate thread."""
405
+ import time
155
406
 
156
- async def start_mcp_proxy(
157
- directory: str | Path,
158
- image_name: str,
407
+ def run_interactive() -> None:
408
+ time.sleep(2)
409
+
410
+ try:
411
+ hud_console.section_title("Interactive Mode")
412
+ hud_console.info("Starting interactive testing mode...")
413
+
414
+ from .utils.interactive import run_interactive_mode
415
+
416
+ server_url = f"http://localhost:{port}/mcp"
417
+
418
+ loop = asyncio.new_event_loop()
419
+ asyncio.set_event_loop(loop)
420
+ try:
421
+ loop.run_until_complete(run_interactive_mode(server_url, verbose))
422
+ finally:
423
+ loop.close()
424
+
425
+ except Exception as e:
426
+ if verbose:
427
+ hud_console.error(f"Interactive mode error: {e}")
428
+
429
+ interactive_thread = threading.Thread(target=run_interactive, daemon=True)
430
+ interactive_thread.start()
431
+
432
+
433
+ def run_with_reload(
434
+ module_name: str,
435
+ watch_paths: list[str],
159
436
  transport: str,
160
437
  port: int,
161
- no_reload: bool = False,
162
- full_reload: bool = False,
163
- verbose: bool = False,
164
- inspector: bool = False,
165
- no_logs: bool = False,
166
- interactive: bool = False,
167
- docker_args: list[str] | None = None,
438
+ verbose: bool,
439
+ inspector: bool,
440
+ interactive: bool,
441
+ new_trace: bool = False,
168
442
  ) -> None:
169
- """Start the MCP development proxy server."""
170
- # Suppress FastMCP's verbose output FIRST
171
- import asyncio
172
- import logging
173
- import os
443
+ """Run module with file watching and auto-reload."""
444
+ try:
445
+ import watchfiles
446
+ except ImportError:
447
+ hud_console.error("watchfiles required. Install: pip install watchfiles")
448
+ sys.exit(1)
449
+
450
+ # Resolve watch paths
451
+ resolved_paths = []
452
+ for path_str in watch_paths:
453
+ path = Path(path_str).resolve()
454
+ if path.is_file():
455
+ resolved_paths.append(str(path.parent))
456
+ else:
457
+ resolved_paths.append(str(path))
458
+
459
+ if verbose:
460
+ hud_console.info(f"Watching: {', '.join(resolved_paths)}")
461
+
174
462
  import signal
175
- import sys
176
463
 
177
- from .utils.logging import find_free_port
464
+ process = None
465
+ stop_event = threading.Event()
466
+ is_first_run = True
178
467
 
179
- # Always disable the banner - we have our own output
180
- os.environ["FASTMCP_DISABLE_BANNER"] = "1"
468
+ def handle_signal(signum: int, frame: Any) -> None:
469
+ if process:
470
+ process.terminate()
471
+ sys.exit(0)
181
472
 
182
- # Configure logging BEFORE creating proxy
183
- if not verbose:
184
- # Create a filter to block the specific "Starting MCP server" message
185
- class _BlockStartingMCPFilter(logging.Filter):
186
- def filter(self, record: logging.LogRecord) -> bool:
187
- return "Starting MCP server" not in record.getMessage()
188
-
189
- # Set environment variable for FastMCP logging
190
- os.environ["FASTMCP_LOG_LEVEL"] = "ERROR"
191
- os.environ["LOG_LEVEL"] = "ERROR"
192
- os.environ["UVICORN_LOG_LEVEL"] = "ERROR"
193
- # Suppress uvicorn's annoying shutdown messages
194
- os.environ["UVICORN_ACCESS_LOG"] = "0"
195
-
196
- # Configure logging to suppress INFO
197
- logging.basicConfig(level=logging.ERROR, force=True)
198
-
199
- # Set root logger to ERROR to suppress all INFO messages
200
- root_logger = logging.getLogger()
201
- root_logger.setLevel(logging.ERROR)
202
-
203
- # Add filter to all handlers
204
- block_filter = _BlockStartingMCPFilter()
205
- for handler in root_logger.handlers:
206
- handler.addFilter(block_filter)
207
-
208
- # Also specifically suppress these loggers
209
- for logger_name in [
210
- "fastmcp",
211
- "fastmcp.server",
212
- "fastmcp.server.server",
213
- "FastMCP",
214
- "FastMCP.fastmcp.server.server",
215
- "mcp",
216
- "mcp.server",
217
- "mcp.server.lowlevel",
218
- "mcp.server.lowlevel.server",
219
- "uvicorn",
220
- "uvicorn.access",
221
- "uvicorn.error",
222
- "hud.server",
223
- "hud.server.server",
224
- ]:
225
- logger = logging.getLogger(logger_name)
226
- logger.setLevel(logging.ERROR)
227
- # Add filter to this logger too
228
- logger.addFilter(block_filter)
229
-
230
- # Suppress deprecation warnings
231
- import warnings
232
-
233
- warnings.filterwarnings("ignore", category=DeprecationWarning)
234
-
235
- # CRITICAL: For stdio transport, ALL output must go to stderr
236
- if transport == "stdio":
237
- # Configure root logger to use stderr
238
- root_logger = logging.getLogger()
239
- root_logger.handlers.clear()
240
- stderr_handler = logging.StreamHandler(sys.stderr)
241
- root_logger.addHandler(stderr_handler)
242
-
243
- # Validate project directory exists
244
- project_path = Path(directory)
245
- if not project_path.exists():
246
- hud_console.error(f"Project directory not found: {project_path}")
247
- raise click.Abort
248
-
249
- # Extract container name from the proxy configuration (must match create_proxy_server naming)
250
- import os
251
-
252
- pid = str(os.getpid())[-6:] # Last 6 digits of process ID for uniqueness
253
- base_name = image_name.replace(":", "-").replace("/", "-")
254
- container_name = f"{base_name}-{pid}"
255
-
256
- # Remove any existing container with the same name (silently)
257
- # Note: The proxy creates containers on-demand when clients connect
258
- try: # noqa: SIM105
259
- subprocess.run( # noqa: S603, ASYNC221
260
- ["docker", "rm", "-f", container_name], # noqa: S607
261
- stdout=subprocess.DEVNULL,
262
- stderr=subprocess.DEVNULL,
263
- check=False, # Don't raise error if container doesn't exist
264
- )
265
- except Exception: # noqa: S110
266
- pass
473
+ signal.signal(signal.SIGTERM, handle_signal)
474
+ signal.signal(signal.SIGINT, handle_signal)
475
+
476
+ while True:
477
+ cmd = [sys.executable, "-m", "hud", "dev", module_name, f"--port={port}"]
478
+
479
+ if transport == "stdio":
480
+ cmd.append("--stdio")
267
481
 
268
- if transport == "stdio":
269
482
  if verbose:
270
- hud_console.info("Starting stdio proxy (each connection gets its own container)")
271
- else:
272
- # Find available port for HTTP
273
- actual_port = find_free_port(port)
274
- if actual_port is None:
275
- hud_console.error(f"No available ports found starting from {port}")
276
- raise click.Abort
483
+ cmd.append("--verbose")
277
484
 
278
- if actual_port != port and verbose:
279
- hud_console.warning(f"Port {port} in use, using port {actual_port} instead")
485
+ if new_trace and is_first_run:
486
+ cmd.append("--new")
280
487
 
281
- # Launch MCP Inspector if requested
282
- if inspector:
283
- server_url = f"http://localhost:{actual_port}/mcp"
488
+ if verbose:
489
+ hud_console.info(f"Starting: {' '.join(cmd)}")
284
490
 
285
- # Function to launch inspector in background
286
- async def launch_inspector() -> None:
287
- """Launch MCP Inspector and capture its output to extract the URL."""
288
- # Wait for server to be ready
289
- await asyncio.sleep(3)
491
+ # Mark as reload after first run to suppress logs
492
+ env = {**os.environ, "_HUD_DEV_CHILD": "1"}
493
+ if not is_first_run:
494
+ env["_HUD_DEV_RELOAD"] = "1"
290
495
 
291
- try:
292
- import platform
293
- import urllib.parse
294
-
295
- # Build the direct URL with query params to auto-connect
296
- encoded_url = urllib.parse.quote(server_url)
297
- inspector_url = (
298
- f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
299
- )
300
-
301
- # Print inspector info cleanly
302
- hud_console.section_title("MCP Inspector")
303
- hud_console.link(inspector_url)
304
-
305
- # Set environment to disable auth (for development only)
306
- env = os.environ.copy()
307
- env["DANGEROUSLY_OMIT_AUTH"] = "true"
308
- env["MCP_AUTO_OPEN_ENABLED"] = "true"
309
-
310
- # Launch inspector
311
- cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
312
-
313
- # Run in background, suppressing output to avoid log interference
314
- if platform.system() == "Windows":
315
- subprocess.Popen( # noqa: S602, ASYNC220
316
- cmd,
317
- env=env,
318
- shell=True,
319
- stdout=subprocess.DEVNULL,
320
- stderr=subprocess.DEVNULL,
321
- )
322
- else:
323
- subprocess.Popen( # noqa: S603, ASYNC220
324
- cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
325
- )
326
-
327
- except (FileNotFoundError, Exception):
328
- # Silently fail - inspector is optional
329
- hud_console.error("Failed to launch inspector")
330
-
331
- # Launch inspector asynchronously so it doesn't block
332
- asyncio.create_task(launch_inspector()) # noqa: RUF006
496
+ process = subprocess.Popen( # noqa: S603
497
+ cmd, env=env
498
+ )
333
499
 
334
- # Launch interactive mode if requested
335
- if interactive:
336
- if transport != "http":
337
- hud_console.warning("Interactive mode only works with HTTP transport")
338
- else:
339
- server_url = f"http://localhost:{actual_port}/mcp"
500
+ is_first_run = False
501
+
502
+ try:
503
+ stop_event = threading.Event()
504
+
505
+ def _wait_and_set(
506
+ stop_event: threading.Event, process: subprocess.Popen[bytes]
507
+ ) -> None:
508
+ try:
509
+ if process is not None:
510
+ process.wait()
511
+ finally:
512
+ stop_event.set()
513
+
514
+ threading.Thread(target=_wait_and_set, args=(stop_event, process), daemon=True).start()
515
+
516
+ for changes in watchfiles.watch(*resolved_paths, stop_event=stop_event):
517
+ relevant_changes = [
518
+ (change_type, path)
519
+ for change_type, path in changes
520
+ if any(path.endswith(ext) for ext in [".py", ".json", ".toml", ".yaml"])
521
+ and "__pycache__" not in path
522
+ and not Path(path).name.startswith(".")
523
+ ]
524
+
525
+ if relevant_changes:
526
+ hud_console.flow("File changes detected, reloading...")
527
+ if verbose:
528
+ for change_type, path in relevant_changes:
529
+ hud_console.info(f" {change_type}: {path}")
530
+
531
+ if process is not None:
532
+ process.terminate()
533
+ try:
534
+ if process is not None:
535
+ process.wait(timeout=5)
536
+ except subprocess.TimeoutExpired:
537
+ if process is not None:
538
+ process.kill()
539
+ process.wait()
340
540
 
341
- # Function to launch interactive mode in a separate thread
342
- def launch_interactive_thread() -> None:
343
- """Launch interactive testing mode in a separate thread."""
344
541
  import time
345
542
 
346
- # Wait for server to be ready
347
- time.sleep(3)
543
+ time.sleep(0.1)
544
+ break
348
545
 
349
- try:
350
- hud_console.section_title("Interactive Mode")
351
- hud_console.info("Starting interactive testing mode...")
352
- hud_console.info("Press Ctrl+C in the interactive session to exit")
353
-
354
- # Import and run interactive mode in a new event loop
355
- from .utils.interactive import run_interactive_mode
356
-
357
- # Create a new event loop for the thread
358
- loop = asyncio.new_event_loop()
359
- asyncio.set_event_loop(loop)
360
- try:
361
- loop.run_until_complete(run_interactive_mode(server_url, verbose))
362
- finally:
363
- loop.close()
364
-
365
- except Exception as e:
366
- # Log error but don't crash the server
367
- if verbose:
368
- hud_console.error(f"Interactive mode error: {e}")
369
-
370
- # Launch interactive mode in a separate thread
371
- import threading
372
-
373
- interactive_thread = threading.Thread(target=launch_interactive_thread, daemon=True)
374
- interactive_thread.start()
375
-
376
- # Function to stream Docker logs
377
- async def stream_docker_logs() -> None:
378
- """Stream Docker container logs asynchronously.
379
-
380
- Note: The Docker container is created on-demand when the first client connects.
381
- Any environment variables passed via -e flags are included when the container starts.
382
- """
383
- log_hud_console = hud_console
384
-
385
- # Always show waiting message
386
- log_hud_console.info("") # Empty line for spacing
387
- log_hud_console.progress_message(
388
- "⏳ Waiting for first client connection to start container..."
389
- )
390
- log_hud_console.info(f"📋 Looking for container: {container_name}") # noqa: G004
391
-
392
- # Keep trying to stream logs - container is created on demand
393
- has_shown_started = False
394
- while True:
395
- # Check if container exists first (silently)
396
- check_result = await asyncio.create_subprocess_exec(
397
- "docker",
398
- "ps",
399
- "--format",
400
- "{{.Names}}",
401
- "--filter",
402
- f"name={container_name}",
403
- stdout=asyncio.subprocess.PIPE,
404
- stderr=asyncio.subprocess.DEVNULL,
405
- )
406
- stdout, _ = await check_result.communicate()
546
+ except KeyboardInterrupt:
547
+ if process:
548
+ process.terminate()
549
+ process.wait()
550
+ break
407
551
 
408
- # If container doesn't exist, wait and retry
409
- if container_name not in stdout.decode():
410
- await asyncio.sleep(1)
411
- continue
412
552
 
413
- # Container exists! Show success if first time
414
- if not has_shown_started:
415
- log_hud_console.success("Container started! Streaming logs...")
416
- has_shown_started = True
553
+ def run_docker_dev_server(
554
+ port: int,
555
+ verbose: bool,
556
+ inspector: bool,
557
+ interactive: bool,
558
+ docker_args: list[str],
559
+ watch_paths: list[str] | None = None,
560
+ new_trace: bool = False,
561
+ ) -> None:
562
+ """Run MCP server in Docker with volume mounts, expose via local HTTP proxy.
563
+
564
+ Args:
565
+ port: HTTP port to expose
566
+ verbose: Show detailed logs
567
+ inspector: Launch MCP Inspector
568
+ interactive: Launch interactive testing mode
569
+ docker_args: Extra Docker run arguments
570
+ watch_paths: Folders/files to mount for hot-reload (e.g., ["tools", "env.py"]).
571
+ If None, no hot-reload mounts are added.
572
+ new_trace: Create a new dev trace on hud.ai
573
+ """
574
+ import atexit
575
+ import signal
417
576
 
418
- # Now stream the logs
419
- try:
420
- process = await asyncio.create_subprocess_exec(
421
- "docker",
422
- "logs",
423
- "-f",
424
- container_name,
425
- stdout=asyncio.subprocess.PIPE,
426
- stderr=asyncio.subprocess.STDOUT, # Combine streams for simplicity
427
- )
577
+ import typer
578
+ import yaml
428
579
 
429
- if process.stdout:
430
- async for line in process.stdout:
431
- decoded_line = line.decode().rstrip()
432
- if not decoded_line: # Skip empty lines
433
- continue
434
-
435
- # Skip docker daemon errors (these happen when container is removed)
436
- if "Error response from daemon" in decoded_line:
437
- continue
438
-
439
- # Show all logs with gold formatting like hud debug
440
- # Format all logs in gold/dim style like hud debug's stderr
441
- # Use stdout console to avoid stderr redirection when not verbose
442
- log_hud_console._stdout_console.print(
443
- f"[rgb(192,150,12)]■[/rgb(192,150,12)] {decoded_line}", highlight=False
444
- )
445
-
446
- # Process ended - container might have been removed
447
- await process.wait()
448
-
449
- # Check if container still exists
450
- await asyncio.sleep(1)
451
- continue # Loop back to check if container exists
452
-
453
- except Exception as e:
454
- # Some unexpected error - show it so we can debug
455
- log_hud_console.warning(f"Failed to stream Docker logs: {e}") # noqa: G004
456
- if verbose:
457
- import traceback
458
-
459
- log_hud_console.warning(f"Traceback: {traceback.format_exc()}") # noqa: G004
460
- await asyncio.sleep(1)
461
-
462
- # Import contextlib here so it's available in the finally block
463
- import contextlib
464
-
465
- # CRITICAL: Create proxy AFTER all logging setup to prevent it from resetting logging config
466
- # This is important because FastMCP might initialize loggers during creation
467
- proxy = create_proxy_server(
468
- directory, image_name, no_reload, full_reload, verbose, docker_args or [], interactive
469
- )
580
+ from hud.server import MCPServer
470
581
 
471
- # Set up signal handlers for graceful shutdown
472
- shutdown_event = asyncio.Event()
582
+ # Ensure Docker CLI and daemon are available before proceeding
583
+ from .utils.docker import require_docker_running
473
584
 
474
- def signal_handler(signum: int, frame: Any) -> None:
475
- """Handle signals by setting shutdown event."""
476
- hud_console.info(f"\n📡 Received signal {signum}, shutting down gracefully...")
477
- shutdown_event.set()
585
+ require_docker_running()
478
586
 
479
- # Register signal handlers - SIGINT is available on all platforms
480
- signal.signal(signal.SIGINT, signal_handler)
587
+ cwd = Path.cwd()
481
588
 
482
- # SIGTERM is not available on Windows
483
- if hasattr(signal, "SIGTERM"):
484
- signal.signal(signal.SIGTERM, signal_handler)
589
+ # Container name will be set later and used for cleanup
590
+ container_name: str | None = None
591
+ cleanup_done = False
485
592
 
486
- # One more attempt to suppress the FastMCP server log
487
- if not verbose:
488
- # Re-apply the filter in case new handlers were created
489
- class BlockStartingMCPFilter(logging.Filter):
490
- def filter(self, record: logging.LogRecord) -> bool:
491
- return "Starting MCP server" not in record.getMessage()
492
-
493
- block_filter = BlockStartingMCPFilter()
494
-
495
- # Apply to all loggers again - comprehensive list
496
- for logger_name in [
497
- "", # root logger
498
- "fastmcp",
499
- "fastmcp.server",
500
- "fastmcp.server.server",
501
- "FastMCP",
502
- "FastMCP.fastmcp.server.server",
503
- "mcp",
504
- "mcp.server",
505
- "mcp.server.lowlevel",
506
- "mcp.server.lowlevel.server",
507
- "uvicorn",
508
- "uvicorn.access",
509
- "uvicorn.error",
510
- "hud.server",
511
- "hud.server.server",
512
- ]:
513
- logger = logging.getLogger(logger_name)
514
- logger.setLevel(logging.ERROR)
515
- logger.addFilter(block_filter)
516
- for handler in logger.handlers:
517
- handler.addFilter(block_filter)
518
-
519
- # Track if container has been stopped to avoid duplicate stops
520
- container_stopped = False
521
-
522
- # Function to stop the container gracefully
523
- async def stop_container() -> None:
524
- """Stop the Docker container gracefully with SIGTERM, wait 30s, then SIGKILL if needed."""
525
- nonlocal container_stopped
526
- if container_stopped:
527
- return # Already stopped, don't do it again
593
+ def cleanup_container() -> None:
594
+ """Clean up Docker container on exit."""
595
+ nonlocal cleanup_done
596
+ if cleanup_done or not container_name:
597
+ return
528
598
 
599
+ cleanup_done = True
600
+ hud_console.debug(f"Cleaning up container: {container_name}")
601
+
602
+ # Check if container is still running
529
603
  try:
530
- # Check if container exists
531
- check_result = await asyncio.create_subprocess_exec(
532
- "docker",
533
- "ps",
534
- "--format",
535
- "{{.Names}}",
536
- "--filter",
537
- f"name={container_name}",
538
- stdout=asyncio.subprocess.PIPE,
539
- stderr=asyncio.subprocess.DEVNULL,
604
+ result = subprocess.run( # noqa: S603
605
+ ["docker", "ps", "-q", "-f", f"name={container_name}"], # noqa: S607
606
+ stdout=subprocess.PIPE,
607
+ stderr=subprocess.DEVNULL,
608
+ text=True,
609
+ timeout=5,
540
610
  )
541
- stdout, _ = await check_result.communicate()
542
-
543
- if container_name in stdout.decode():
544
- hud_console.info("🛑 Stopping container gracefully...")
545
- # Stop with 30 second timeout before SIGKILL
546
- stop_result = await asyncio.create_subprocess_exec(
547
- "docker",
548
- "stop",
549
- "--time=30",
550
- container_name,
551
- stdout=asyncio.subprocess.DEVNULL,
552
- stderr=asyncio.subprocess.DEVNULL,
611
+ if not result.stdout.strip():
612
+ # Container is not running, just try to remove it
613
+ subprocess.run( # noqa: S603
614
+ ["docker", "rm", "-f", container_name], # noqa: S607
615
+ stdout=subprocess.DEVNULL,
616
+ stderr=subprocess.DEVNULL,
617
+ timeout=5,
553
618
  )
554
- await stop_result.communicate()
555
- hud_console.success("Container stopped successfully")
556
- container_stopped = True
557
- except Exception as e:
558
- hud_console.warning(f"Failed to stop container: {e}")
559
-
560
- try:
561
- # Start Docker logs streaming if enabled
562
- log_task = None
563
- if not no_logs:
564
- log_task = asyncio.create_task(stream_docker_logs())
619
+ return
620
+ except Exception: # noqa: S110
621
+ pass
565
622
 
566
- if transport == "stdio":
567
- # Run with stdio transport
568
- await proxy.run_async(
569
- transport="stdio", log_level="ERROR" if not verbose else "INFO", show_banner=False
623
+ try:
624
+ # First try to stop gracefully
625
+ subprocess.run( # noqa: S603
626
+ ["docker", "stop", container_name], # noqa: S607
627
+ stdout=subprocess.DEVNULL,
628
+ stderr=subprocess.DEVNULL,
629
+ timeout=10,
570
630
  )
631
+ hud_console.debug(f"Container {container_name} stopped successfully")
632
+ except subprocess.TimeoutExpired:
633
+ # Force kill if stop times out
634
+ hud_console.debug(f"Container {container_name} stop timeout, forcing kill")
635
+ with contextlib.suppress(Exception):
636
+ subprocess.run( # noqa: S603
637
+ ["docker", "kill", container_name], # noqa: S607
638
+ stdout=subprocess.DEVNULL,
639
+ stderr=subprocess.DEVNULL,
640
+ timeout=5,
641
+ )
642
+
643
+ # Set up signal handlers for cleanup
644
+ def signal_handler(signum: int, frame: Any) -> None:
645
+ cleanup_container()
646
+ sys.exit(0)
647
+
648
+ signal.signal(signal.SIGTERM, signal_handler)
649
+ if sys.platform != "win32":
650
+ signal.signal(signal.SIGHUP, signal_handler)
651
+
652
+ # Find environment directory (current or parent with hud.lock.yaml)
653
+ env_dir = cwd
654
+ lock_path = env_dir / "hud.lock.yaml"
655
+
656
+ if not lock_path.exists():
657
+ # Try parent directory
658
+ if (cwd.parent / "hud.lock.yaml").exists():
659
+ env_dir = cwd.parent
660
+ lock_path = env_dir / "hud.lock.yaml"
571
661
  else:
572
- # Run with HTTP transport
573
- # Temporarily redirect stderr to suppress uvicorn shutdown messages
574
- import contextlib
575
- import io
576
-
577
- if not verbose:
578
- # Create a dummy file to swallow unwanted stderr output
579
- with contextlib.redirect_stderr(io.StringIO()):
580
- await proxy.run_async(
581
- transport="http",
582
- host="0.0.0.0", # noqa: S104
583
- port=actual_port,
584
- path="/mcp", # Serve at /mcp endpoint
585
- log_level="ERROR",
586
- show_banner=False,
587
- )
662
+ hud_console.error("No hud.lock.yaml found")
663
+ hud_console.info("Run 'hud build' first to create an image")
664
+ raise typer.Exit(1)
665
+
666
+ # Load lock file to get image name
667
+ try:
668
+ with open(lock_path) as f:
669
+ lock_data = yaml.safe_load(f)
670
+
671
+ # Get image from new or legacy format
672
+ images = lock_data.get("images", {})
673
+ image_name = images.get("local") or lock_data.get("image")
674
+
675
+ if not image_name:
676
+ hud_console.error("No image reference found in hud.lock.yaml")
677
+ raise typer.Exit(1)
678
+
679
+ # Strip digest if present
680
+ if "@" in image_name:
681
+ image_name = image_name.split("@")[0]
682
+
683
+ # Extract debugging ports from lock file
684
+ debugging_ports = lock_data.get("environment", {}).get("debuggingPorts", [])
685
+ telemetry = lock_data.get("environment", {}).get("telemetry", {})
686
+
687
+ except Exception as e:
688
+ hud_console.error(f"Failed to read lock file: {e}")
689
+ raise typer.Exit(1) from e
690
+
691
+ # Generate unique container name
692
+ pid = str(os.getpid())[-6:]
693
+ base_name = image_name.replace(":", "-").replace("/", "-")
694
+ container_name = f"{base_name}-dev-{pid}"
695
+
696
+ # Register cleanup function with atexit
697
+ atexit.register(cleanup_container)
698
+
699
+ # Build docker run command with volume mounts and folder-mode envs
700
+ from .utils.docker import create_docker_run_command
701
+
702
+ base_args = [
703
+ "--rm", # Automatically remove container when it stops
704
+ "--name",
705
+ container_name,
706
+ "-e",
707
+ "PYTHONPATH=/app",
708
+ "-e",
709
+ "PYTHONUNBUFFERED=1",
710
+ "-e",
711
+ "HUD_DEV=1",
712
+ ]
713
+
714
+ # Add volume mounts for watch paths (hot-reload)
715
+ if watch_paths:
716
+ hud_console.info(f"Hot-reload enabled for: {', '.join(watch_paths)}")
717
+ for path in watch_paths:
718
+ # Resolve the local path
719
+ local_path = env_dir.absolute() / path
720
+ if local_path.exists():
721
+ # Mount to /app/<path> in container
722
+ container_path = f"/app/{path}"
723
+ base_args.extend(["-v", f"{local_path}:{container_path}:rw"])
588
724
  else:
589
- await proxy.run_async(
590
- transport="http",
591
- host="0.0.0.0", # noqa: S104
592
- port=actual_port,
593
- path="/mcp", # Serve at /mcp endpoint
594
- log_level="INFO",
595
- show_banner=False,
725
+ hud_console.warning(f"Watch path not found: {path}")
726
+ else:
727
+ hud_console.info("No --watch paths specified, running without hot-reload")
728
+ hud_console.dim_info("Tip", "Use -w to enable hot-reload (e.g., -w tools -w env.py)")
729
+
730
+ # Add debugging port mappings if available
731
+ if debugging_ports:
732
+ hud_console.info(f"Exposing debugging ports: {', '.join(map(str, debugging_ports))}")
733
+ for port_num in debugging_ports:
734
+ base_args.extend(["-p", f"{port_num}:{port_num}"])
735
+ combined_args = [*base_args, *docker_args] if docker_args else base_args
736
+ docker_cmd = create_docker_run_command(
737
+ image_name,
738
+ docker_args=combined_args,
739
+ env_dir=env_dir,
740
+ )
741
+
742
+ # Create MCP config pointing to the Docker container's stdio
743
+ mcp_config = {
744
+ "docker": {
745
+ "command": docker_cmd[0],
746
+ "args": docker_cmd[1:],
747
+ }
748
+ }
749
+
750
+ # Attempt to create dynamic trace early (before any UI) if --new flag is set
751
+ import asyncio as _asy
752
+
753
+ from hud.cli.flows.dev import create_dynamic_trace, generate_cursor_deeplink, show_dev_ui
754
+
755
+ live_trace_url: str | None = None
756
+ if new_trace:
757
+ try:
758
+ local_mcp_config: dict[str, dict[str, Any]] = {
759
+ "hud": {
760
+ "url": f"http://localhost:{port}/mcp",
761
+ "headers": {},
762
+ }
763
+ }
764
+ _, live_trace_url = _asy.run(
765
+ create_dynamic_trace(
766
+ mcp_config=local_mcp_config,
767
+ build_status=True,
768
+ environment_name=image_name,
596
769
  )
597
- except (ConnectionError, OSError) as e:
598
- hud_console.error(f"Failed to connect to Docker container: {e}")
599
- hud_console.info("")
600
- hud_console.info("💡 Tip: Run the following command to debug the container:")
601
- hud_console.info(f" hud debug {image_name}")
770
+ )
771
+ except SystemExit:
772
+ raise # Let API key requirement exits through
773
+ except Exception: # noqa: S110
774
+ pass
775
+
776
+ # Show appropriate UI
777
+ if live_trace_url:
778
+ # Minimal UI with live trace
779
+ cursor_deeplink = generate_cursor_deeplink(image_name, port)
780
+ show_dev_ui(
781
+ live_trace_url=live_trace_url,
782
+ server_name=image_name,
783
+ port=port,
784
+ cursor_deeplink=cursor_deeplink,
785
+ is_docker=True,
786
+ )
787
+ else:
788
+ # Full UI
789
+ hud_console.header("HUD Development Mode (Docker)")
790
+ if verbose:
791
+ hud_console.section_title("Docker Command")
792
+ hud_console.info(" ".join(docker_cmd))
793
+ show_dev_server_info(
794
+ server_name=image_name,
795
+ port=port,
796
+ transport="http",
797
+ inspector=inspector,
798
+ interactive=interactive,
799
+ env_dir=env_dir,
800
+ docker_mode=True,
801
+ telemetry=telemetry,
802
+ )
803
+ hud_console.dim_info(
804
+ "",
805
+ "Container restarts on file changes in watched folders (-w), "
806
+ "rebuild with 'hud dev' if changing other files",
807
+ )
602
808
  hud_console.info("")
603
- hud_console.info("Common issues:")
604
- hud_console.info(" • Container failed to start or crashed immediately")
605
- hud_console.info(" • Server initialization failed")
606
- hud_console.info(" • Port binding conflicts")
607
- raise
608
- except KeyboardInterrupt:
609
- hud_console.info("\n👋 Shutting down...")
610
809
 
611
- # Stop the container before showing next steps
612
- await stop_container()
810
+ # Suppress logs unless verbose
811
+ if not verbose:
812
+ logging.getLogger("fastmcp").setLevel(logging.ERROR)
813
+ logging.getLogger("mcp").setLevel(logging.ERROR)
814
+ logging.getLogger("uvicorn").setLevel(logging.ERROR)
815
+ os.environ["FASTMCP_DISABLE_BANNER"] = "1"
613
816
 
614
- # Show next steps tutorial
615
- if not interactive: # Only show if not in interactive mode
616
- hud_console.section_title("Next Steps")
617
- hud_console.info("🏗️ Ready to test with real agents? Run:")
618
- hud_console.info(f" [cyan]hud build {directory}[/cyan]")
619
- hud_console.info("")
620
- hud_console.info("This will:")
621
- hud_console.info(" 1. Build your environment image")
622
- hud_console.info(" 2. Generate a hud.lock.yaml file")
623
- hud_console.info(" 3. Prepare it for testing with agents")
624
- hud_console.info("")
625
- hud_console.info("Then you can:")
626
- hud_console.info(" • Test locally: [cyan]hud run <image>[/cyan]")
627
- hud_console.info(" • Push to registry: [cyan]hud push --image <registry/name>[/cyan]")
628
- except Exception as e:
629
- # Suppress the graceful shutdown error and other FastMCP/uvicorn internal errors
630
- error_msg = str(e)
631
- if not any(
632
- x in error_msg
633
- for x in [
634
- "timeout graceful shutdown exceeded",
635
- "Cancel 0 running task(s)",
636
- "Application shutdown complete",
637
- ]
638
- ):
639
- hud_console.error(f"Unexpected error: {e}")
640
- finally:
641
- # Cancel log streaming task if it exists
642
- if log_task and not log_task.done():
643
- log_task.cancel()
644
- try:
645
- await log_task
646
- except asyncio.CancelledError:
647
- contextlib.suppress(asyncio.CancelledError)
817
+ # Create and run proxy with HUD helpers
818
+ async def run_proxy() -> None:
819
+ from fastmcp import FastMCP
820
+ from fastmcp.server.proxy import ProxyClient
648
821
 
649
- # Always try to stop container on exit
650
- await stop_container()
822
+ # Create ProxyClient without custom log handler since we capture Docker logs directly
823
+ proxy_client = ProxyClient(mcp_config, name="HUD Docker Dev Proxy")
651
824
 
825
+ # Extract container name from docker args and store for logs endpoint
826
+ docker_cmd = mcp_config["docker"]["args"]
827
+ container_name = None
828
+ for i, arg in enumerate(docker_cmd):
829
+ if arg == "--name" and i + 1 < len(docker_cmd):
830
+ container_name = docker_cmd[i + 1]
831
+ break
652
832
 
653
- def run_mcp_dev_server(
654
- directory: str = ".",
655
- image: str | None = None,
656
- build: bool = False,
657
- no_cache: bool = False,
658
- transport: str = "http",
659
- port: int = 8765,
660
- no_reload: bool = False,
661
- full_reload: bool = False,
662
- verbose: bool = False,
663
- inspector: bool = False,
664
- no_logs: bool = False,
665
- interactive: bool = False,
666
- docker_args: list[str] | None = None,
667
- ) -> None:
668
- """Run MCP development server with hot-reload.
669
-
670
- This command starts a development proxy that:
671
- - Auto-detects or builds Docker images
672
- - Mounts local source code for hot-reload
673
- - Exposes an HTTP endpoint for MCP clients
674
-
675
- Examples:
676
- hud dev . # Auto-detect image from directory
677
- hud dev . --build # Build image first
678
- hud dev . --image custom:tag # Use specific image
679
- hud dev . --no-cache # Force clean rebuild
680
- """
681
- # Ensure directory exists
682
- if not Path(directory).exists():
683
- hud_console.error(f"Directory not found: {directory}")
684
- raise click.Abort
833
+ if container_name:
834
+ # Store container name for logs endpoint to use
835
+ os.environ["_HUD_DEV_DOCKER_CONTAINER"] = container_name
836
+ hud_console.debug(f"Docker container: {container_name}")
685
837
 
686
- # No external dependencies needed for hot-reload anymore!
838
+ # Store the docker mcp_config for the eval endpoint
839
+ import json
687
840
 
688
- # Resolve image name
689
- resolved_image, source = get_image_name(directory, image)
841
+ os.environ["_HUD_DEV_DOCKER_MCP_CONFIG"] = json.dumps(mcp_config)
690
842
 
691
- # Update pyproject.toml with auto-generated name if needed
692
- if source == "auto":
693
- update_pyproject_toml(directory, resolved_image)
843
+ # Create FastMCP proxy using the ProxyClient
844
+ fastmcp_proxy = FastMCP.as_proxy(proxy_client)
694
845
 
695
- # Build if requested
696
- if build or no_cache:
697
- build_and_update(directory, resolved_image, no_cache)
846
+ # Wrap in MCPServer to get /docs and REST wrappers
847
+ proxy = MCPServer(name="HUD Docker Dev Proxy")
698
848
 
699
- # Check if image exists
700
- if not image_exists(resolved_image) and not build:
701
- if click.confirm(f"Image {resolved_image} not found. Build it now?"):
702
- build_and_update(directory, resolved_image)
703
- else:
704
- raise click.Abort
849
+ # Enable logs endpoint on HTTP server
850
+ os.environ["_HUD_DEV_LOGS_PROVIDER"] = "enabled"
705
851
 
706
- # Generate server name from image
707
- server_name = resolved_image.split(":")[0] if ":" in resolved_image else resolved_image
852
+ # Import all tools from the FastMCP proxy
853
+ await proxy.import_server(fastmcp_proxy)
708
854
 
709
- # For HTTP transport, find available port first
710
- actual_port = port
711
- if transport == "http":
712
- from .utils.logging import find_free_port
855
+ # Launch inspector if requested
856
+ if inspector:
857
+ await launch_inspector(port)
858
+
859
+ # Launch interactive mode if requested
860
+ if interactive:
861
+ launch_interactive_thread(port, verbose)
862
+
863
+ # Run proxy with HTTP transport
864
+ await proxy.run_async(
865
+ transport="http",
866
+ host="0.0.0.0", # noqa: S104
867
+ port=port,
868
+ path="/mcp",
869
+ log_level="error" if not verbose else "info",
870
+ show_banner=False,
871
+ )
872
+
873
+ try:
874
+ asyncio.run(run_proxy())
875
+ except KeyboardInterrupt:
876
+ hud_console.info("\n\nStopping...")
877
+ cleanup_container()
878
+ raise typer.Exit(0) from None
879
+ except Exception:
880
+ # Ensure cleanup happens on any exception
881
+ cleanup_container()
882
+ raise
883
+ finally:
884
+ # Final cleanup attempt
885
+ cleanup_container()
886
+
887
+
888
+ def run_mcp_dev_server(
889
+ module: str | None,
890
+ stdio: bool,
891
+ port: int,
892
+ verbose: bool,
893
+ inspector: bool,
894
+ interactive: bool,
895
+ watch: list[str] | None,
896
+ docker: bool = False,
897
+ docker_args: list[str] | None = None,
898
+ new_trace: bool = False,
899
+ ) -> None:
900
+ """Run MCP development server with hot-reload."""
901
+ docker_args = docker_args or []
902
+ cwd = Path.cwd()
903
+
904
+ # Find an available port if not using stdio transport
905
+ if not stdio:
906
+ from hud.cli.utils.logging import find_free_port
713
907
 
714
908
  actual_port = find_free_port(port)
715
909
  if actual_port is None:
716
910
  hud_console.error(f"No available ports found starting from {port}")
717
- raise click.Abort
718
- if actual_port != port and verbose:
719
- hud_console.warning(f"Port {port} in use, using port {actual_port}")
720
-
721
- # Create config
722
- if transport == "stdio":
723
- server_config = {"command": "hud", "args": ["dev", directory, "--transport", "stdio"]}
724
- # For stdio, include docker args in the command
725
- if docker_args:
726
- server_config["args"].extend(docker_args)
727
- else:
728
- server_config = {"url": f"http://localhost:{actual_port}/mcp"}
729
- # Note: Environment variables are passed to the Docker container via the proxy,
730
- # not included in the client configuration
911
+ raise typer.Exit(1)
731
912
 
732
- # For the deeplink, we only need the server config
733
- server_config_json = json.dumps(server_config, indent=2)
734
- config_base64 = base64.b64encode(server_config_json.encode()).decode()
913
+ if actual_port != port:
914
+ hud_console.info(f"Port {port} is in use, using port {actual_port} instead")
735
915
 
736
- # Generate deeplink
737
- deeplink = (
738
- f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
739
- )
916
+ port = actual_port
740
917
 
741
- # Show header with gold border
742
- hud_console.info("") # Empty line before header
743
- hud_console.header("HUD Development Server")
744
-
745
- # Always show the Docker image being used as the first thing after header
746
- hud_console.section_title("Docker Image")
747
- if source == "cache":
748
- hud_console.info(f"📦 {resolved_image}")
749
- elif source == "auto":
750
- hud_console.info(f"🔧 {resolved_image} (auto-generated)")
751
- elif source == "override":
752
- hud_console.info(f"🎯 {resolved_image} (specified)")
753
- else:
754
- hud_console.info(f"🐳 {resolved_image}")
918
+ # Auto-detect Docker mode if Dockerfile present and no module specified
919
+ if not docker and module is None and should_use_docker_mode(cwd):
920
+ hud_console.note("Detected Dockerfile - using Docker mode")
921
+ hud_console.dim_info("Tip", "Use 'hud dev --help' to see all options")
922
+ hud_console.info("")
923
+ run_docker_dev_server(port, verbose, inspector, interactive, docker_args, watch, new_trace)
924
+ return
755
925
 
756
- hud_console.progress_message(
757
- f"❗ If any issues arise, run `hud debug {resolved_image}` to debug the container"
758
- )
926
+ # Route to Docker mode if explicitly requested
927
+ if docker:
928
+ run_docker_dev_server(port, verbose, inspector, interactive, docker_args, watch, new_trace)
929
+ return
759
930
 
760
- # Show environment variables if provided
761
- if docker_args and any(arg == "-e" or arg.startswith("--env") for arg in docker_args):
762
- hud_console.section_title("Environment Variables")
763
- hud_console.info(
764
- "The following environment variables will be passed to the Docker container:"
765
- )
766
- i = 0
767
- while i < len(docker_args):
768
- if docker_args[i] == "-e" and i + 1 < len(docker_args):
769
- hud_console.info(f" • {docker_args[i + 1]}")
770
- i += 2
771
- elif docker_args[i].startswith("--env="):
772
- hud_console.info(f" • {docker_args[i][6:]}")
773
- i += 1
774
- elif docker_args[i] == "--env" and i + 1 < len(docker_args):
775
- hud_console.info(f" • {docker_args[i + 1]}")
776
- i += 2
777
- else:
778
- i += 1
931
+ transport = "stdio" if stdio else "http"
779
932
 
780
- # Show hints about inspector and interactive mode
781
- if transport == "http":
782
- if not inspector and not interactive:
783
- hud_console.progress_message("💡 Run with --inspector to launch MCP Inspector")
784
- hud_console.progress_message("🧪 Run with --interactive for interactive testing mode")
785
- elif not inspector:
786
- hud_console.progress_message("💡 Run with --inspector to launch MCP Inspector")
787
- elif not interactive:
788
- hud_console.progress_message("🧪 Run with --interactive for interactive testing mode")
789
-
790
- # Show configuration as JSON (just the server config, not wrapped)
791
- full_config = {}
792
- full_config[server_name] = server_config
793
-
794
- hud_console.section_title("MCP Configuration (add this to any agent/client)")
795
- hud_console.json_config(json.dumps(full_config, indent=2))
796
-
797
- # Show connection info
798
- hud_console.section_title(
799
- "Connect to Cursor (be careful with multiple windows as that may interfere with the proxy)"
800
- )
801
- hud_console.link(deeplink)
933
+ # Auto-detect module if not provided
934
+ if module is None:
935
+ module, extra_path = auto_detect_module()
936
+ if module is None:
937
+ hud_console.error("Could not auto-detect module in current directory")
938
+ hud_console.info("")
939
+ hud_console.info("[bold cyan]Expected:[/bold cyan]")
940
+ hud_console.info(" • __init__.py file in current directory")
941
+ hud_console.info(" Module must define 'mcp' or 'env' variable")
942
+ hud_console.info("")
943
+ hud_console.info("[bold cyan]Examples:[/bold cyan]")
944
+ hud_console.info(" hud dev controller")
945
+ hud_console.info(" cd controller && hud dev")
946
+ hud_console.info(" hud dev --docker # For Docker-based environments")
947
+ hud_console.info("")
948
+ import sys
802
949
 
803
- hud_console.info("") # Empty line
950
+ sys.exit(1)
804
951
 
805
- try:
806
- asyncio.run(
807
- start_mcp_proxy(
808
- directory,
809
- resolved_image,
810
- transport,
811
- port,
812
- no_reload,
813
- full_reload,
814
- verbose,
815
- inspector,
816
- no_logs,
817
- interactive,
818
- docker_args or [],
819
- )
952
+ if verbose:
953
+ hud_console.info(f"Auto-detected: {module}")
954
+ if extra_path:
955
+ hud_console.info(f"Adding to sys.path: {extra_path}")
956
+
957
+ # Add extra path to sys.path if needed (for package imports)
958
+ if extra_path:
959
+ import sys
960
+
961
+ sys.path.insert(0, str(extra_path))
962
+ else:
963
+ extra_path = None
964
+
965
+ # Determine watch paths
966
+ watch_paths = watch if watch else ["."]
967
+
968
+ # Check if child process
969
+ is_child = os.environ.get("_HUD_DEV_CHILD") == "1"
970
+
971
+ if is_child:
972
+ asyncio.run(run_mcp_module(module, transport, port, verbose, False, False, new_trace))
973
+ else:
974
+ run_with_reload(
975
+ module, watch_paths, transport, port, verbose, inspector, interactive, new_trace
820
976
  )
821
- except Exception as e:
822
- hud_console.error(f"Failed to start MCP server: {e}")
823
- hud_console.info("")
824
- hud_console.info("💡 Tip: Run the following command to debug the container:")
825
- hud_console.info(f" hud debug {resolved_image}")
826
- hud_console.info("")
827
- hud_console.info("This will help identify connection issues or initialization failures.")
828
- raise