hud-python 0.3.5__py3-none-any.whl → 0.4.0__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 (192) hide show
  1. hud/__init__.py +22 -89
  2. hud/agents/__init__.py +17 -0
  3. hud/agents/art.py +101 -0
  4. hud/agents/base.py +599 -0
  5. hud/{mcp → agents}/claude.py +373 -321
  6. hud/{mcp → agents}/langchain.py +250 -250
  7. hud/agents/misc/__init__.py +7 -0
  8. hud/{agent → agents}/misc/response_agent.py +80 -80
  9. hud/{mcp → agents}/openai.py +352 -334
  10. hud/agents/openai_chat_generic.py +154 -0
  11. hud/{mcp → agents}/tests/__init__.py +1 -1
  12. hud/agents/tests/test_base.py +742 -0
  13. hud/agents/tests/test_claude.py +324 -0
  14. hud/{mcp → agents}/tests/test_client.py +363 -324
  15. hud/{mcp → agents}/tests/test_openai.py +237 -238
  16. hud/cli/__init__.py +617 -0
  17. hud/cli/__main__.py +8 -0
  18. hud/cli/analyze.py +371 -0
  19. hud/cli/analyze_metadata.py +230 -0
  20. hud/cli/build.py +427 -0
  21. hud/cli/clone.py +185 -0
  22. hud/cli/cursor.py +92 -0
  23. hud/cli/debug.py +392 -0
  24. hud/cli/docker_utils.py +83 -0
  25. hud/cli/init.py +281 -0
  26. hud/cli/interactive.py +353 -0
  27. hud/cli/mcp_server.py +756 -0
  28. hud/cli/pull.py +336 -0
  29. hud/cli/push.py +379 -0
  30. hud/cli/remote_runner.py +311 -0
  31. hud/cli/runner.py +160 -0
  32. hud/cli/tests/__init__.py +3 -0
  33. hud/cli/tests/test_analyze.py +284 -0
  34. hud/cli/tests/test_cli_init.py +265 -0
  35. hud/cli/tests/test_cli_main.py +27 -0
  36. hud/cli/tests/test_clone.py +142 -0
  37. hud/cli/tests/test_cursor.py +253 -0
  38. hud/cli/tests/test_debug.py +453 -0
  39. hud/cli/tests/test_mcp_server.py +139 -0
  40. hud/cli/tests/test_utils.py +388 -0
  41. hud/cli/utils.py +263 -0
  42. hud/clients/README.md +143 -0
  43. hud/clients/__init__.py +16 -0
  44. hud/clients/base.py +354 -0
  45. hud/clients/fastmcp.py +202 -0
  46. hud/clients/mcp_use.py +278 -0
  47. hud/clients/tests/__init__.py +1 -0
  48. hud/clients/tests/test_client_integration.py +111 -0
  49. hud/clients/tests/test_fastmcp.py +342 -0
  50. hud/clients/tests/test_protocol.py +188 -0
  51. hud/clients/utils/__init__.py +1 -0
  52. hud/clients/utils/retry_transport.py +160 -0
  53. hud/datasets.py +322 -192
  54. hud/misc/__init__.py +1 -0
  55. hud/{agent → misc}/claude_plays_pokemon.py +292 -283
  56. hud/otel/__init__.py +35 -0
  57. hud/otel/collector.py +142 -0
  58. hud/otel/config.py +164 -0
  59. hud/otel/context.py +536 -0
  60. hud/otel/exporters.py +366 -0
  61. hud/otel/instrumentation.py +97 -0
  62. hud/otel/processors.py +118 -0
  63. hud/otel/tests/__init__.py +1 -0
  64. hud/otel/tests/test_processors.py +197 -0
  65. hud/server/__init__.py +5 -5
  66. hud/server/context.py +114 -0
  67. hud/server/helper/__init__.py +5 -0
  68. hud/server/low_level.py +132 -0
  69. hud/server/server.py +166 -0
  70. hud/server/tests/__init__.py +3 -0
  71. hud/settings.py +73 -79
  72. hud/shared/__init__.py +5 -0
  73. hud/{exceptions.py → shared/exceptions.py} +180 -180
  74. hud/{server → shared}/requests.py +264 -264
  75. hud/shared/tests/test_exceptions.py +157 -0
  76. hud/{server → shared}/tests/test_requests.py +275 -275
  77. hud/telemetry/__init__.py +25 -30
  78. hud/telemetry/instrument.py +379 -0
  79. hud/telemetry/job.py +309 -141
  80. hud/telemetry/replay.py +74 -0
  81. hud/telemetry/trace.py +83 -0
  82. hud/tools/__init__.py +33 -34
  83. hud/tools/base.py +365 -65
  84. hud/tools/bash.py +161 -137
  85. hud/tools/computer/__init__.py +15 -13
  86. hud/tools/computer/anthropic.py +437 -420
  87. hud/tools/computer/hud.py +376 -334
  88. hud/tools/computer/openai.py +295 -292
  89. hud/tools/computer/settings.py +82 -0
  90. hud/tools/edit.py +314 -290
  91. hud/tools/executors/__init__.py +30 -30
  92. hud/tools/executors/base.py +539 -532
  93. hud/tools/executors/pyautogui.py +621 -619
  94. hud/tools/executors/tests/__init__.py +1 -1
  95. hud/tools/executors/tests/test_base_executor.py +338 -338
  96. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  97. hud/tools/executors/xdo.py +511 -503
  98. hud/tools/{playwright_tool.py → playwright.py} +412 -379
  99. hud/tools/tests/__init__.py +3 -3
  100. hud/tools/tests/test_base.py +282 -0
  101. hud/tools/tests/test_bash.py +158 -152
  102. hud/tools/tests/test_bash_extended.py +197 -0
  103. hud/tools/tests/test_computer.py +425 -52
  104. hud/tools/tests/test_computer_actions.py +34 -34
  105. hud/tools/tests/test_edit.py +259 -240
  106. hud/tools/tests/test_init.py +27 -27
  107. hud/tools/tests/test_playwright_tool.py +183 -183
  108. hud/tools/tests/test_tools.py +145 -157
  109. hud/tools/tests/test_utils.py +156 -156
  110. hud/tools/types.py +72 -0
  111. hud/tools/utils.py +50 -50
  112. hud/types.py +136 -89
  113. hud/utils/__init__.py +10 -16
  114. hud/utils/async_utils.py +65 -0
  115. hud/utils/design.py +168 -0
  116. hud/utils/mcp.py +55 -0
  117. hud/utils/progress.py +149 -149
  118. hud/utils/telemetry.py +66 -66
  119. hud/utils/tests/test_async_utils.py +173 -0
  120. hud/utils/tests/test_init.py +17 -21
  121. hud/utils/tests/test_progress.py +261 -225
  122. hud/utils/tests/test_telemetry.py +82 -37
  123. hud/utils/tests/test_version.py +8 -8
  124. hud/version.py +7 -7
  125. hud_python-0.4.0.dist-info/METADATA +474 -0
  126. hud_python-0.4.0.dist-info/RECORD +132 -0
  127. hud_python-0.4.0.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.5.dist-info → hud_python-0.4.0.dist-info}/licenses/LICENSE +21 -21
  129. hud/adapters/__init__.py +0 -8
  130. hud/adapters/claude/__init__.py +0 -5
  131. hud/adapters/claude/adapter.py +0 -180
  132. hud/adapters/claude/tests/__init__.py +0 -1
  133. hud/adapters/claude/tests/test_adapter.py +0 -519
  134. hud/adapters/common/__init__.py +0 -6
  135. hud/adapters/common/adapter.py +0 -178
  136. hud/adapters/common/tests/test_adapter.py +0 -289
  137. hud/adapters/common/types.py +0 -446
  138. hud/adapters/operator/__init__.py +0 -5
  139. hud/adapters/operator/adapter.py +0 -108
  140. hud/adapters/operator/tests/__init__.py +0 -1
  141. hud/adapters/operator/tests/test_adapter.py +0 -370
  142. hud/agent/__init__.py +0 -19
  143. hud/agent/base.py +0 -126
  144. hud/agent/claude.py +0 -271
  145. hud/agent/langchain.py +0 -215
  146. hud/agent/misc/__init__.py +0 -3
  147. hud/agent/operator.py +0 -268
  148. hud/agent/tests/__init__.py +0 -1
  149. hud/agent/tests/test_base.py +0 -202
  150. hud/env/__init__.py +0 -11
  151. hud/env/client.py +0 -35
  152. hud/env/docker_client.py +0 -349
  153. hud/env/environment.py +0 -446
  154. hud/env/local_docker_client.py +0 -358
  155. hud/env/remote_client.py +0 -212
  156. hud/env/remote_docker_client.py +0 -292
  157. hud/gym.py +0 -130
  158. hud/job.py +0 -773
  159. hud/mcp/__init__.py +0 -17
  160. hud/mcp/base.py +0 -631
  161. hud/mcp/client.py +0 -312
  162. hud/mcp/tests/test_base.py +0 -512
  163. hud/mcp/tests/test_claude.py +0 -294
  164. hud/task.py +0 -149
  165. hud/taskset.py +0 -237
  166. hud/telemetry/_trace.py +0 -347
  167. hud/telemetry/context.py +0 -230
  168. hud/telemetry/exporter.py +0 -575
  169. hud/telemetry/instrumentation/__init__.py +0 -3
  170. hud/telemetry/instrumentation/mcp.py +0 -259
  171. hud/telemetry/instrumentation/registry.py +0 -59
  172. hud/telemetry/mcp_models.py +0 -270
  173. hud/telemetry/tests/__init__.py +0 -1
  174. hud/telemetry/tests/test_context.py +0 -210
  175. hud/telemetry/tests/test_trace.py +0 -312
  176. hud/tools/helper/README.md +0 -56
  177. hud/tools/helper/__init__.py +0 -9
  178. hud/tools/helper/mcp_server.py +0 -78
  179. hud/tools/helper/server_initialization.py +0 -115
  180. hud/tools/helper/utils.py +0 -58
  181. hud/trajectory.py +0 -94
  182. hud/utils/agent.py +0 -37
  183. hud/utils/common.py +0 -256
  184. hud/utils/config.py +0 -120
  185. hud/utils/deprecation.py +0 -115
  186. hud/utils/misc.py +0 -53
  187. hud/utils/tests/test_common.py +0 -277
  188. hud/utils/tests/test_config.py +0 -129
  189. hud_python-0.3.5.dist-info/METADATA +0 -284
  190. hud_python-0.3.5.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {hud_python-0.3.5.dist-info → hud_python-0.4.0.dist-info}/WHEEL +0 -0
hud/cli/mcp_server.py ADDED
@@ -0,0 +1,756 @@
1
+ """MCP Development Proxy - Hot-reload environments with MCP over HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import toml
13
+ from fastmcp import FastMCP
14
+
15
+ from .docker_utils import get_docker_cmd, image_exists, inject_supervisor
16
+
17
+
18
+ def get_image_name(directory: str | Path, image_override: str | None = None) -> tuple[str, str]:
19
+ """
20
+ Resolve image name with source tracking.
21
+
22
+ Returns:
23
+ Tuple of (image_name, source) where source is "override", "cache", or "auto"
24
+ """
25
+ if image_override:
26
+ return image_override, "override"
27
+
28
+ # Check pyproject.toml
29
+ pyproject_path = Path(directory) / "pyproject.toml"
30
+ if pyproject_path.exists():
31
+ try:
32
+ with open(pyproject_path) as f:
33
+ config = toml.load(f)
34
+ if config.get("tool", {}).get("hud", {}).get("image"):
35
+ return config["tool"]["hud"]["image"], "cache"
36
+ except Exception:
37
+ click.echo("Failed to load pyproject.toml", err=True)
38
+
39
+ # Auto-generate with :dev tag
40
+ dir_path = Path(directory).resolve() # Get absolute path first
41
+ dir_name = dir_path.name
42
+ if not dir_name or dir_name == ".":
43
+ # If we're in root or have empty name, use parent directory
44
+ dir_name = dir_path.parent.name
45
+ clean_name = dir_name.replace("_", "-")
46
+ return f"hud-{clean_name}:dev", "auto"
47
+
48
+
49
+ def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool = False) -> None:
50
+ """Update pyproject.toml with image name."""
51
+ pyproject_path = Path(directory) / "pyproject.toml"
52
+ if pyproject_path.exists():
53
+ try:
54
+ with open(pyproject_path) as f:
55
+ config = toml.load(f)
56
+
57
+ # Ensure [tool.hud] exists
58
+ if "tool" not in config:
59
+ config["tool"] = {}
60
+ if "hud" not in config["tool"]:
61
+ config["tool"]["hud"] = {}
62
+
63
+ # Update image name
64
+ config["tool"]["hud"]["image"] = image_name
65
+
66
+ # Write back
67
+ with open(pyproject_path, "w") as f:
68
+ toml.dump(config, f)
69
+
70
+ if not silent:
71
+ click.echo(f"✅ Updated pyproject.toml with image: {image_name}")
72
+ except Exception as e:
73
+ if not silent:
74
+ click.echo(f"⚠️ Could not update pyproject.toml: {e}")
75
+
76
+
77
+ def build_and_update(directory: str | Path, image_name: str, no_cache: bool = False) -> None:
78
+ """Build Docker image and update pyproject.toml."""
79
+ from hud.utils.design import HUDDesign
80
+
81
+ design = HUDDesign()
82
+
83
+ build_cmd = ["docker", "build", "-t", image_name]
84
+ if no_cache:
85
+ build_cmd.append("--no-cache")
86
+ build_cmd.append(str(directory))
87
+
88
+ design.info(f"🔨 Building image: {image_name}{' (no cache)' if no_cache else ''}")
89
+ design.info("") # Empty line before Docker output
90
+
91
+ # Just run Docker build directly - it has its own nice live display
92
+ result = subprocess.run(build_cmd) # noqa: S603
93
+
94
+ if result.returncode == 0:
95
+ design.info("") # Empty line after Docker output
96
+ design.success(f"Build successful! Image: {image_name}")
97
+ # Update pyproject.toml (silently since we already showed success)
98
+ update_pyproject_toml(directory, image_name, silent=True)
99
+ else:
100
+ design.error("Build failed!")
101
+ raise click.Abort
102
+
103
+
104
+ def create_proxy_server(
105
+ directory: str | Path,
106
+ image_name: str,
107
+ no_reload: bool = False,
108
+ verbose: bool = False,
109
+ docker_args: list[str] | None = None,
110
+ interactive: bool = False,
111
+ ) -> FastMCP:
112
+ """Create an HTTP proxy server that forwards to Docker container with hot-reload."""
113
+ src_path = Path(directory) / "src"
114
+
115
+ # Get the original CMD from the image
116
+ original_cmd = get_docker_cmd(image_name)
117
+ if not original_cmd:
118
+ click.echo(f"⚠️ Could not extract CMD from {image_name}, using default")
119
+ original_cmd = ["python", "-m", "hud_controller.server"]
120
+
121
+ # Generate container name from image
122
+ container_name = f"{image_name.replace(':', '-').replace('/', '-')}"
123
+
124
+ # Build the docker run command
125
+ docker_cmd = [
126
+ "docker",
127
+ "run",
128
+ "--rm",
129
+ "-i",
130
+ "--name",
131
+ container_name,
132
+ "-v",
133
+ f"{src_path.absolute()}:/app/src:rw",
134
+ "-e",
135
+ "PYTHONPATH=/app/src",
136
+ ]
137
+
138
+ # Add user-provided Docker arguments
139
+ if docker_args:
140
+ docker_cmd.extend(docker_args)
141
+
142
+ # Disable hot-reload if interactive mode is enabled
143
+ if interactive:
144
+ no_reload = True
145
+
146
+ if not no_reload:
147
+ # Inject our supervisor into the CMD
148
+ modified_cmd = inject_supervisor(original_cmd)
149
+ docker_cmd.extend(["--entrypoint", modified_cmd[0]])
150
+ docker_cmd.append(image_name)
151
+ docker_cmd.extend(modified_cmd[1:])
152
+ else:
153
+ # No reload - use original CMD
154
+ docker_cmd.append(image_name)
155
+
156
+ # Create configuration following MCPConfig schema
157
+ config = {
158
+ "mcpServers": {
159
+ "default": {
160
+ "command": docker_cmd[0],
161
+ "args": docker_cmd[1:] if len(docker_cmd) > 1 else [],
162
+ # transport defaults to stdio
163
+ }
164
+ }
165
+ }
166
+
167
+ # Debug output - only if verbose
168
+ if verbose:
169
+ if not no_reload:
170
+ click.echo("📁 Watching: /app/src for changes", err=True)
171
+ else:
172
+ click.echo("🔧 Container will run without hot-reload", err=True)
173
+ click.echo(f"📊 docker logs -f {container_name}", err=True)
174
+
175
+ # Create the HTTP proxy server using config
176
+ proxy = FastMCP.as_proxy(config, name=f"HUD Dev Proxy - {image_name}")
177
+
178
+ return proxy
179
+
180
+
181
+ async def start_mcp_proxy(
182
+ directory: str | Path,
183
+ image_name: str,
184
+ transport: str,
185
+ port: int,
186
+ no_reload: bool = False,
187
+ verbose: bool = False,
188
+ inspector: bool = False,
189
+ no_logs: bool = False,
190
+ interactive: bool = False,
191
+ docker_args: list[str] | None = None,
192
+ ) -> None:
193
+ """Start the MCP development proxy server."""
194
+ # Suppress FastMCP's verbose output FIRST
195
+ import asyncio
196
+ import logging
197
+ import os
198
+ import subprocess
199
+ import sys
200
+
201
+ from .utils import find_free_port
202
+
203
+ # Always disable the banner - we have our own output
204
+ os.environ["FASTMCP_DISABLE_BANNER"] = "1"
205
+
206
+ # Configure logging BEFORE creating proxy
207
+ if not verbose:
208
+ # Create a filter to block the specific "Starting MCP server" message
209
+ class _BlockStartingMCPFilter(logging.Filter):
210
+ def filter(self, record: logging.LogRecord) -> bool:
211
+ return "Starting MCP server" not in record.getMessage()
212
+
213
+ # Set environment variable for FastMCP logging
214
+ os.environ["FASTMCP_LOG_LEVEL"] = "ERROR"
215
+ os.environ["LOG_LEVEL"] = "ERROR"
216
+ os.environ["UVICORN_LOG_LEVEL"] = "ERROR"
217
+ # Suppress uvicorn's annoying shutdown messages
218
+ os.environ["UVICORN_ACCESS_LOG"] = "0"
219
+
220
+ # Configure logging to suppress INFO
221
+ logging.basicConfig(level=logging.ERROR, force=True)
222
+
223
+ # Set root logger to ERROR to suppress all INFO messages
224
+ root_logger = logging.getLogger()
225
+ root_logger.setLevel(logging.ERROR)
226
+
227
+ # Add filter to all handlers
228
+ block_filter = _BlockStartingMCPFilter()
229
+ for handler in root_logger.handlers:
230
+ handler.addFilter(block_filter)
231
+
232
+ # Also specifically suppress these loggers
233
+ for logger_name in [
234
+ "fastmcp",
235
+ "fastmcp.server",
236
+ "fastmcp.server.server",
237
+ "FastMCP",
238
+ "FastMCP.fastmcp.server.server",
239
+ "mcp",
240
+ "mcp.server",
241
+ "mcp.server.lowlevel",
242
+ "mcp.server.lowlevel.server",
243
+ "uvicorn",
244
+ "uvicorn.access",
245
+ "uvicorn.error",
246
+ "hud.server",
247
+ "hud.server.server",
248
+ ]:
249
+ logger = logging.getLogger(logger_name)
250
+ logger.setLevel(logging.ERROR)
251
+ # Add filter to this logger too
252
+ logger.addFilter(block_filter)
253
+
254
+ # Suppress deprecation warnings
255
+ import warnings
256
+
257
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
258
+
259
+ # CRITICAL: For stdio transport, ALL output must go to stderr
260
+ if transport == "stdio":
261
+ # Configure root logger to use stderr
262
+ root_logger = logging.getLogger()
263
+ root_logger.handlers.clear()
264
+ stderr_handler = logging.StreamHandler(sys.stderr)
265
+ root_logger.addHandler(stderr_handler)
266
+
267
+ # Now check for src directory
268
+ src_path = Path(directory) / "src"
269
+ if not src_path.exists():
270
+ click.echo(f"❌ Source directory not found: {src_path}", err=(transport == "stdio"))
271
+ raise click.Abort
272
+
273
+ # Extract container name from the proxy configuration
274
+ container_name = f"{image_name.replace(':', '-').replace('/', '-')}"
275
+
276
+ # Remove any existing container with the same name (silently)
277
+ # Note: The proxy creates containers on-demand when clients connect
278
+ try:
279
+ subprocess.run( # noqa: S603, ASYNC221
280
+ ["docker", "rm", "-f", container_name], # noqa: S607
281
+ stdout=subprocess.DEVNULL,
282
+ stderr=subprocess.DEVNULL,
283
+ check=False, # Don't raise error if container doesn't exist
284
+ )
285
+ except Exception:
286
+ click.echo(f"Failed to remove existing container {container_name}", err=True)
287
+
288
+ if transport == "stdio":
289
+ if verbose:
290
+ click.echo("🔌 Starting stdio proxy (each connection gets its own container)", err=True)
291
+ else:
292
+ # Find available port for HTTP
293
+ actual_port = find_free_port(port)
294
+ if actual_port is None:
295
+ click.echo(f"❌ No available ports found starting from {port}")
296
+ raise click.Abort
297
+
298
+ if actual_port != port and verbose:
299
+ click.echo(f"⚠️ Port {port} in use, using port {actual_port} instead")
300
+
301
+ # Launch MCP Inspector if requested
302
+ if inspector:
303
+ server_url = f"http://localhost:{actual_port}/mcp"
304
+
305
+ # Function to launch inspector in background
306
+ async def launch_inspector() -> None:
307
+ """Launch MCP Inspector and capture its output to extract the URL."""
308
+ # Wait for server to be ready
309
+ await asyncio.sleep(3)
310
+
311
+ try:
312
+ import platform
313
+ import urllib.parse
314
+
315
+ # Build the direct URL with query params to auto-connect
316
+ encoded_url = urllib.parse.quote(server_url)
317
+ inspector_url = (
318
+ f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
319
+ )
320
+
321
+ # Print inspector info cleanly
322
+ from hud.utils.design import HUDDesign
323
+
324
+ inspector_design = HUDDesign(stderr=(transport == "stdio"))
325
+ inspector_design.section_title("MCP Inspector")
326
+ inspector_design.link(inspector_url)
327
+
328
+ # Set environment to disable auth (for development only)
329
+ env = os.environ.copy()
330
+ env["DANGEROUSLY_OMIT_AUTH"] = "true"
331
+ env["MCP_AUTO_OPEN_ENABLED"] = "true"
332
+
333
+ # Launch inspector
334
+ cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
335
+
336
+ # Run in background, suppressing output to avoid log interference
337
+ if platform.system() == "Windows":
338
+ subprocess.Popen( # noqa: S602, ASYNC220
339
+ cmd,
340
+ env=env,
341
+ shell=True,
342
+ stdout=subprocess.DEVNULL,
343
+ stderr=subprocess.DEVNULL,
344
+ )
345
+ else:
346
+ subprocess.Popen( # noqa: S603, ASYNC220
347
+ cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
348
+ )
349
+
350
+ except (FileNotFoundError, Exception):
351
+ # Silently fail - inspector is optional
352
+ click.echo("Failed to launch inspector", err=True)
353
+
354
+ # Launch inspector asynchronously so it doesn't block
355
+ result = asyncio.create_task(launch_inspector())
356
+ if result.exception():
357
+ click.echo("Failed to launch inspector", err=True)
358
+
359
+ # Launch interactive mode if requested
360
+ if interactive:
361
+ if transport != "http":
362
+ from hud.utils.design import HUDDesign
363
+
364
+ interactive_design = HUDDesign(stderr=True)
365
+ interactive_design.warning("Interactive mode only works with HTTP transport")
366
+ else:
367
+ server_url = f"http://localhost:{actual_port}/mcp"
368
+
369
+ # Function to launch interactive mode in a separate thread
370
+ def launch_interactive_thread() -> None:
371
+ """Launch interactive testing mode in a separate thread."""
372
+ import time
373
+
374
+ # Wait for server to be ready
375
+ time.sleep(3)
376
+
377
+ try:
378
+ from hud.utils.design import HUDDesign
379
+
380
+ interactive_design = HUDDesign(stderr=(transport == "stdio"))
381
+ interactive_design.section_title("Interactive Mode")
382
+ interactive_design.info("Starting interactive testing mode...")
383
+ interactive_design.info("Press Ctrl+C in the interactive session to exit")
384
+
385
+ # Import and run interactive mode in a new event loop
386
+ from .interactive import run_interactive_mode
387
+
388
+ # Create a new event loop for the thread
389
+ loop = asyncio.new_event_loop()
390
+ asyncio.set_event_loop(loop)
391
+ try:
392
+ loop.run_until_complete(run_interactive_mode(server_url, verbose))
393
+ finally:
394
+ loop.close()
395
+
396
+ except Exception as e:
397
+ # Log error but don't crash the server
398
+ if verbose:
399
+ click.echo(f"Interactive mode error: {e}", err=True)
400
+
401
+ # Launch interactive mode in a separate thread
402
+ import threading
403
+
404
+ interactive_thread = threading.Thread(target=launch_interactive_thread, daemon=True)
405
+ interactive_thread.start()
406
+
407
+ # Function to stream Docker logs
408
+ async def stream_docker_logs() -> None:
409
+ """Stream Docker container logs asynchronously."""
410
+ # Import design system for consistent output
411
+ from hud.utils.design import HUDDesign
412
+
413
+ log_design = HUDDesign(stderr=(transport == "stdio"))
414
+
415
+ # Always show waiting message
416
+ log_design.info("") # Empty line for spacing
417
+ log_design.progress_message("⏳ Waiting for first client connection to start container...")
418
+
419
+ # Keep trying to stream logs - container is created on demand
420
+ has_shown_started = False
421
+ while True:
422
+ # Check if container exists first (silently)
423
+ check_result = await asyncio.create_subprocess_exec(
424
+ "docker",
425
+ "ps",
426
+ "--format",
427
+ "{{.Names}}",
428
+ "--filter",
429
+ f"name={container_name}",
430
+ stdout=asyncio.subprocess.PIPE,
431
+ stderr=asyncio.subprocess.DEVNULL,
432
+ )
433
+ stdout, _ = await check_result.communicate()
434
+
435
+ # If container doesn't exist, wait and retry
436
+ if container_name not in stdout.decode():
437
+ await asyncio.sleep(1)
438
+ continue
439
+
440
+ # Container exists! Show success if first time
441
+ if not has_shown_started:
442
+ log_design.success("Container started! Streaming logs...")
443
+ has_shown_started = True
444
+
445
+ # Now stream the logs
446
+ try:
447
+ process = await asyncio.create_subprocess_exec(
448
+ "docker",
449
+ "logs",
450
+ "-f",
451
+ container_name,
452
+ stdout=asyncio.subprocess.PIPE,
453
+ stderr=asyncio.subprocess.STDOUT, # Combine streams for simplicity
454
+ )
455
+
456
+ if process.stdout:
457
+ async for line in process.stdout:
458
+ decoded_line = line.decode().rstrip()
459
+ if not decoded_line: # Skip empty lines
460
+ continue
461
+
462
+ # Skip docker daemon errors (these happen when container is removed)
463
+ if "Error response from daemon" in decoded_line:
464
+ continue
465
+
466
+ # Show all logs with gold formatting like hud debug
467
+ # Format all logs in gold/dim style like hud debug's stderr
468
+ log_design.console.print(
469
+ f"[rgb(192,150,12)]■[/rgb(192,150,12)] {decoded_line}", highlight=False
470
+ )
471
+
472
+ # Process ended - container might have been removed
473
+ await process.wait()
474
+
475
+ # Check if container still exists
476
+ await asyncio.sleep(1)
477
+ continue # Loop back to check if container exists
478
+
479
+ except Exception:
480
+ # Some unexpected error
481
+ if verbose:
482
+ log_design.warning("Failed to stream logs")
483
+ await asyncio.sleep(1)
484
+
485
+ # CRITICAL: Create proxy AFTER all logging setup to prevent it from resetting logging config
486
+ # This is important because FastMCP might initialize loggers during creation
487
+ proxy = create_proxy_server(
488
+ directory, image_name, no_reload, verbose, docker_args or [], interactive
489
+ )
490
+
491
+ # One more attempt to suppress the FastMCP server log
492
+ if not verbose:
493
+ # Re-apply the filter in case new handlers were created
494
+ class BlockStartingMCPFilter(logging.Filter):
495
+ def filter(self, record: logging.LogRecord) -> bool:
496
+ return "Starting MCP server" not in record.getMessage()
497
+
498
+ block_filter = BlockStartingMCPFilter()
499
+
500
+ # Apply to all loggers again - comprehensive list
501
+ for logger_name in [
502
+ "", # root logger
503
+ "fastmcp",
504
+ "fastmcp.server",
505
+ "fastmcp.server.server",
506
+ "FastMCP",
507
+ "FastMCP.fastmcp.server.server",
508
+ "mcp",
509
+ "mcp.server",
510
+ "mcp.server.lowlevel",
511
+ "mcp.server.lowlevel.server",
512
+ "uvicorn",
513
+ "uvicorn.access",
514
+ "uvicorn.error",
515
+ "hud.server",
516
+ "hud.server.server",
517
+ ]:
518
+ logger = logging.getLogger(logger_name)
519
+ logger.setLevel(logging.ERROR)
520
+ logger.addFilter(block_filter)
521
+ for handler in logger.handlers:
522
+ handler.addFilter(block_filter)
523
+
524
+ try:
525
+ # Start Docker logs streaming if enabled
526
+ log_task = None
527
+ if not no_logs:
528
+ log_task = asyncio.create_task(stream_docker_logs())
529
+
530
+ if transport == "stdio":
531
+ # Run with stdio transport
532
+ await proxy.run_async(
533
+ transport="stdio", log_level="ERROR" if not verbose else "INFO", show_banner=False
534
+ )
535
+ else:
536
+ # Run with HTTP transport
537
+ # Temporarily redirect stderr to suppress uvicorn shutdown messages
538
+ import contextlib
539
+ import io
540
+
541
+ if not verbose:
542
+ # Create a dummy file to swallow unwanted stderr output
543
+ with contextlib.redirect_stderr(io.StringIO()):
544
+ await proxy.run_async(
545
+ transport="http",
546
+ host="0.0.0.0", # noqa: S104
547
+ port=actual_port,
548
+ path="/mcp", # Serve at /mcp endpoint
549
+ log_level="ERROR",
550
+ show_banner=False,
551
+ )
552
+ else:
553
+ await proxy.run_async(
554
+ transport="http",
555
+ host="0.0.0.0", # noqa: S104
556
+ port=actual_port,
557
+ path="/mcp", # Serve at /mcp endpoint
558
+ log_level="INFO",
559
+ show_banner=False,
560
+ )
561
+ except KeyboardInterrupt:
562
+ from hud.utils.design import HUDDesign
563
+
564
+ shutdown_design = HUDDesign(stderr=(transport == "stdio"))
565
+ shutdown_design.info("\n👋 Shutting down...")
566
+
567
+ # Show next steps tutorial
568
+ if not interactive: # Only show if not in interactive mode
569
+ shutdown_design.section_title("Next Steps")
570
+ shutdown_design.info("🏗️ Ready to test with real agents? Run:")
571
+ shutdown_design.info(f" [cyan]hud build {directory}[/cyan]")
572
+ shutdown_design.info("")
573
+ shutdown_design.info("This will:")
574
+ shutdown_design.info(" 1. Build your environment image")
575
+ shutdown_design.info(" 2. Generate a hud.lock.yaml file")
576
+ shutdown_design.info(" 3. Prepare it for testing with agents")
577
+ shutdown_design.info("")
578
+ shutdown_design.info("Then you can:")
579
+ shutdown_design.info(" • Test locally: [cyan]hud run <image>[/cyan]")
580
+ shutdown_design.info(
581
+ " • Push to registry: [cyan]hud push --image <registry/name>[/cyan]"
582
+ )
583
+ except Exception as e:
584
+ # Suppress the graceful shutdown error and other FastMCP/uvicorn internal errors
585
+ error_msg = str(e)
586
+ if not any(
587
+ x in error_msg
588
+ for x in [
589
+ "timeout graceful shutdown exceeded",
590
+ "Cancel 0 running task(s)",
591
+ "Application shutdown complete",
592
+ ]
593
+ ):
594
+ from hud.utils.design import HUDDesign
595
+
596
+ shutdown_design = HUDDesign(stderr=(transport == "stdio"))
597
+ shutdown_design.error(f"Unexpected error: {e}")
598
+ finally:
599
+ # Cancel log streaming task if it exists
600
+ if log_task and not log_task.done():
601
+ log_task.cancel()
602
+ try:
603
+ await log_task
604
+ except asyncio.CancelledError:
605
+ click.echo("Log streaming task cancelled", err=True)
606
+
607
+
608
+ def run_mcp_dev_server(
609
+ directory: str = ".",
610
+ image: str | None = None,
611
+ build: bool = False,
612
+ no_cache: bool = False,
613
+ transport: str = "http",
614
+ port: int = 8765,
615
+ no_reload: bool = False,
616
+ verbose: bool = False,
617
+ inspector: bool = False,
618
+ no_logs: bool = False,
619
+ interactive: bool = False,
620
+ docker_args: list[str] | None = None,
621
+ ) -> None:
622
+ """Run MCP development server with hot-reload.
623
+
624
+ This command starts a development proxy that:
625
+ - Auto-detects or builds Docker images
626
+ - Mounts local source code for hot-reload
627
+ - Exposes an HTTP endpoint for MCP clients
628
+
629
+ Examples:
630
+ hud dev . # Auto-detect image from directory
631
+ hud dev . --build # Build image first
632
+ hud dev . --image custom:tag # Use specific image
633
+ hud dev . --no-cache # Force clean rebuild
634
+ """
635
+ from hud.utils.design import HUDDesign
636
+
637
+ design = HUDDesign(stderr=(transport == "stdio"))
638
+
639
+ # Ensure directory exists
640
+ if not Path(directory).exists():
641
+ design.error(f"Directory not found: {directory}")
642
+ raise click.Abort
643
+
644
+ # No external dependencies needed for hot-reload anymore!
645
+
646
+ # Resolve image name
647
+ resolved_image, source = get_image_name(directory, image)
648
+
649
+ # Update pyproject.toml with auto-generated name if needed
650
+ if source == "auto":
651
+ update_pyproject_toml(directory, resolved_image)
652
+
653
+ # Build if requested
654
+ if build or no_cache:
655
+ build_and_update(directory, resolved_image, no_cache)
656
+
657
+ # Check if image exists
658
+ if not image_exists(resolved_image) and not build:
659
+ if click.confirm(f"Image {resolved_image} not found. Build it now?"):
660
+ build_and_update(directory, resolved_image)
661
+ else:
662
+ raise click.Abort
663
+
664
+ # Generate server name from image
665
+ server_name = resolved_image.split(":")[0] if ":" in resolved_image else resolved_image
666
+
667
+ # For HTTP transport, find available port first
668
+ actual_port = port
669
+ if transport == "http":
670
+ from .utils import find_free_port
671
+
672
+ actual_port = find_free_port(port)
673
+ if actual_port is None:
674
+ design.error(f"No available ports found starting from {port}")
675
+ raise click.Abort
676
+ if actual_port != port and verbose:
677
+ design.warning(f"Port {port} in use, using port {actual_port}")
678
+
679
+ # Create config
680
+ if transport == "stdio":
681
+ server_config = {"command": "hud", "args": ["dev", directory, "--transport", "stdio"]}
682
+ else:
683
+ server_config = {"url": f"http://localhost:{actual_port}/mcp"}
684
+
685
+ # For the deeplink, we only need the server config
686
+ server_config_json = json.dumps(server_config, indent=2)
687
+ config_base64 = base64.b64encode(server_config_json.encode()).decode()
688
+
689
+ # Generate deeplink
690
+ deeplink = (
691
+ f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
692
+ )
693
+
694
+ # Show header with gold border
695
+ design.info("") # Empty line before header
696
+ design.header("HUD Development Server")
697
+
698
+ # Always show the Docker image being used as the first thing after header
699
+ design.section_title("Docker Image")
700
+ if source == "cache":
701
+ design.info(f"📦 {resolved_image}")
702
+ elif source == "auto":
703
+ design.info(f"🔧 {resolved_image} (auto-generated)")
704
+ elif source == "override":
705
+ design.info(f"🎯 {resolved_image} (specified)")
706
+ else:
707
+ design.info(f"🐳 {resolved_image}")
708
+
709
+ # Show hints about inspector and interactive mode
710
+ if transport == "http":
711
+ if not inspector and not interactive:
712
+ design.progress_message("💡 Run with --inspector to launch MCP Inspector")
713
+ design.progress_message("🧪 Run with --interactive for interactive testing mode")
714
+ elif not inspector:
715
+ design.progress_message("💡 Run with --inspector to launch MCP Inspector")
716
+ elif not interactive:
717
+ design.progress_message("🧪 Run with --interactive for interactive testing mode")
718
+
719
+ # Disable logs and hot-reload if interactive mode is enabled
720
+ if interactive:
721
+ if not no_logs:
722
+ design.warning("Docker logs disabled in interactive mode for better UI experience")
723
+ no_logs = True
724
+ if not no_reload:
725
+ design.warning("Hot-reload disabled in interactive mode to prevent output interference")
726
+ no_reload = True
727
+
728
+ # Show configuration as JSON (just the server config, not wrapped)
729
+ full_config = {}
730
+ full_config[server_name] = server_config
731
+
732
+ design.section_title("MCP Configuration (add this to any agent/client)")
733
+ design.json_config(json.dumps(full_config, indent=2))
734
+
735
+ # Show connection info
736
+ design.section_title(
737
+ "Connect to Cursor (be careful with multiple windows as that may interfere with the proxy)"
738
+ )
739
+ design.link(deeplink)
740
+ design.info("") # Empty line
741
+
742
+ # Start the proxy (pass original port, start_mcp_proxy will find actual port again)
743
+ asyncio.run(
744
+ start_mcp_proxy(
745
+ directory,
746
+ resolved_image,
747
+ transport,
748
+ port,
749
+ no_reload,
750
+ verbose,
751
+ inspector,
752
+ no_logs,
753
+ interactive,
754
+ docker_args or [],
755
+ )
756
+ )