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/init.py ADDED
@@ -0,0 +1,281 @@
1
+ """Initialize new HUD environments with minimal templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+
12
+ console = Console()
13
+
14
+ # Embedded templates
15
+ DOCKERFILE_TEMPLATE = """FROM python:3.11-slim
16
+
17
+ WORKDIR /app
18
+
19
+ # Install git for hud-python dependency
20
+ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
21
+
22
+ # Copy and install dependencies
23
+ COPY pyproject.toml ./
24
+ COPY src/ ./src/
25
+ RUN pip install --no-cache-dir -e .
26
+
27
+ # Set logging to stderr
28
+ ENV HUD_LOG_STREAM=stderr
29
+
30
+ # Start context server in background, then MCP server
31
+ CMD ["sh", "-c", "python -m hud_controller.context & sleep 1 && exec python -m hud_controller.server"]
32
+ """ # noqa: E501
33
+
34
+ PYPROJECT_TEMPLATE = """[project]
35
+ name = "{name}"
36
+ version = "0.1.0"
37
+ description = "A minimal HUD environment"
38
+ requires-python = ">=3.11"
39
+ dependencies = [
40
+ "hud-python @ git+https://github.com/hud-evals/hud-python.git",
41
+ ]
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hud]
48
+ image = "{name}:dev"
49
+
50
+ [tool.hatch.metadata]
51
+ allow-direct-references = true
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/hud_controller"]
55
+ """
56
+
57
+ CONTEXT_TEMPLATE = '''"""Minimal context that persists across hot-reloads."""
58
+ from hud.server.context import run_context_server
59
+ import asyncio
60
+
61
+ class Context:
62
+ def __init__(self):
63
+ self.count = 0
64
+
65
+ def act(self):
66
+ self.count += 1
67
+ return self.count
68
+
69
+ def get_count(self):
70
+ return self.count
71
+
72
+ if __name__ == "__main__":
73
+ asyncio.run(run_context_server(Context()))
74
+ '''
75
+
76
+ SERVER_TEMPLATE = '''"""Minimal MCP server for HUD."""
77
+ from hud.server import MCPServer
78
+ from hud.server.context import attach_context
79
+
80
+ mcp = MCPServer(name="{name}")
81
+ ctx = None
82
+
83
+ @mcp.initialize
84
+ async def init(init_ctx):
85
+ global ctx
86
+ ctx = attach_context("/tmp/hud_ctx.sock")
87
+
88
+ @mcp.shutdown
89
+ async def cleanup():
90
+ global ctx
91
+ ctx = None
92
+
93
+ @mcp.tool()
94
+ async def act() -> str:
95
+ """Perform an action."""
96
+ return f"Action #{{ctx.act()}}"
97
+
98
+ @mcp.tool()
99
+ async def setup() -> str:
100
+ """Required for HUD environments."""
101
+ return "Ready"
102
+
103
+ @mcp.tool()
104
+ async def evaluate() -> dict:
105
+ """Required for HUD environments."""
106
+ return {{"count": ctx.get_count()}}
107
+
108
+ if __name__ == "__main__":
109
+ mcp.run()
110
+ '''
111
+
112
+ README_TEMPLATE = '''# {title}
113
+
114
+ A minimal HUD environment created with `hud init`.
115
+
116
+ ## Quick Start
117
+
118
+ ```bash
119
+ # Build and run locally
120
+ hud dev
121
+
122
+ # Or build first
123
+ docker build -t {name}:dev .
124
+ hud dev --image {name}:dev
125
+ ```
126
+
127
+ ## Structure
128
+
129
+ - `src/hud_controller/server.py` - MCP server with tools
130
+ - `src/hud_controller/context.py` - Persistent state across hot-reloads
131
+ - `Dockerfile` - Container configuration
132
+ - `pyproject.toml` - Python dependencies
133
+
134
+ ## Adding Tools
135
+
136
+ Add new tools to `server.py`:
137
+
138
+ ```python
139
+ @mcp.tool()
140
+ async def my_tool(param: str) -> str:
141
+ """Tool description."""
142
+ return f"Result: {{param}}"
143
+ ```
144
+
145
+ ## Adding State
146
+
147
+ Extend the `Context` class in `context.py`:
148
+
149
+ ```python
150
+ class Context:
151
+ def __init__(self):
152
+ self.count = 0
153
+ self.data = {{}} # Add your state
154
+ ```
155
+
156
+ ## Learn More
157
+
158
+ - [HUD Documentation](https://docs.hud.so)
159
+ - [MCP Specification](https://modelcontextprotocol.io)
160
+ '''
161
+
162
+
163
+ def sanitize_name(name: str) -> str:
164
+ """Convert a name to a valid Python package name."""
165
+ # Replace spaces and hyphens with underscores
166
+ name = name.replace(" ", "_").replace("-", "_")
167
+ # Remove any non-alphanumeric characters except underscores
168
+ name = "".join(c for c in name if c.isalnum() or c == "_")
169
+ # Ensure it doesn't start with a number
170
+ if name and name[0].isdigit():
171
+ name = f"env_{name}"
172
+ return name.lower()
173
+
174
+
175
+ def create_environment(name: str | None, directory: str, force: bool) -> None:
176
+ """Create a new HUD environment from templates."""
177
+ from hud.utils.design import HUDDesign
178
+
179
+ design = HUDDesign()
180
+
181
+ # Determine environment name
182
+ if name is None:
183
+ # Use current directory name
184
+ current_dir = Path.cwd()
185
+ name = current_dir.name
186
+ target_dir = current_dir
187
+ design.info(f"Using current directory name: {name}")
188
+ else:
189
+ # Create new directory
190
+ target_dir = Path(directory) / name
191
+
192
+ # Sanitize name for Python package
193
+ package_name = sanitize_name(name)
194
+ if package_name != name:
195
+ design.warning(f"Package name adjusted for Python: {name} → {package_name}")
196
+
197
+ # Check if directory exists
198
+ if target_dir.exists() and any(target_dir.iterdir()):
199
+ if not force:
200
+ design.error(f"Directory {target_dir} already exists and is not empty")
201
+ design.info("Use --force to overwrite existing files")
202
+ raise typer.Exit(1)
203
+ else:
204
+ design.warning(f"Overwriting existing files in {target_dir}")
205
+
206
+ # Create directory structure
207
+ src_dir = target_dir / "src" / "hud_controller"
208
+ src_dir.mkdir(parents=True, exist_ok=True)
209
+
210
+ # Write files with proper formatting
211
+ files_created = []
212
+
213
+ # Dockerfile
214
+ dockerfile_path = target_dir / "Dockerfile"
215
+ dockerfile_path.write_text(DOCKERFILE_TEMPLATE.strip() + "\n")
216
+ files_created.append("Dockerfile")
217
+
218
+ # pyproject.toml
219
+ pyproject_path = target_dir / "pyproject.toml"
220
+ pyproject_content = PYPROJECT_TEMPLATE.format(name=package_name).strip() + "\n"
221
+ pyproject_path.write_text(pyproject_content)
222
+ files_created.append("pyproject.toml")
223
+
224
+ # README.md
225
+ readme_path = target_dir / "README.md"
226
+ readme_content = README_TEMPLATE.format(name=package_name, title=name).strip() + "\n"
227
+ readme_path.write_text(readme_content)
228
+ files_created.append("README.md")
229
+
230
+ # Python files
231
+ # __init__.py
232
+ init_path = src_dir / "__init__.py"
233
+ init_path.write_text('"""HUD Controller Package"""\n')
234
+ files_created.append("src/hud_controller/__init__.py")
235
+
236
+ # context.py
237
+ context_path = src_dir / "context.py"
238
+ context_path.write_text(CONTEXT_TEMPLATE.strip() + "\n")
239
+ files_created.append("src/hud_controller/context.py")
240
+
241
+ # server.py (need to escape the double braces for .format())
242
+ server_path = src_dir / "server.py"
243
+ server_content = SERVER_TEMPLATE.format(name=package_name).strip() + "\n"
244
+ server_path.write_text(server_content)
245
+ files_created.append("src/hud_controller/server.py")
246
+
247
+ # Success message
248
+ design.header(f"Created HUD Environment: {name}")
249
+
250
+ design.section_title("Files created")
251
+ for file in files_created:
252
+ console.print(f" ✓ {file}")
253
+
254
+ design.section_title("Next steps")
255
+
256
+ # Show commands based on where we created the environment
257
+ if target_dir == Path.cwd():
258
+ console.print("1. Start development server:")
259
+ console.print(" [cyan]hud dev[/cyan]")
260
+ else:
261
+ console.print("1. Enter the directory:")
262
+ console.print(f" [cyan]cd {target_dir.relative_to(Path.cwd())}[/cyan]")
263
+ console.print("\n2. Start development server:")
264
+ console.print(" [cyan]hud dev[/cyan]")
265
+
266
+ console.print("\n3. Connect from Cursor:")
267
+ console.print(" Follow the instructions shown by [cyan]hud dev[/cyan]")
268
+
269
+ console.print("\n4. Customize your environment:")
270
+ console.print(" - Add tools to [cyan]src/hud_controller/server.py[/cyan]")
271
+ console.print(" - Add state to [cyan]src/hud_controller/context.py[/cyan]")
272
+
273
+ # Show a sample of the server code
274
+ design.section_title("Your MCP server")
275
+ sample_code = '''@mcp.tool()
276
+ async def act() -> str:
277
+ """Perform an action."""
278
+ return f"Action #{ctx.act()}"'''
279
+
280
+ syntax = Syntax(sample_code, "python", theme="monokai", line_numbers=False)
281
+ console.print(Panel(syntax, border_style="dim"))
hud/cli/interactive.py ADDED
@@ -0,0 +1,353 @@
1
+ """Interactive mode for testing MCP environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import questionary
9
+ from mcp.types import TextContent
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.prompt import Prompt
13
+ from rich.syntax import Syntax
14
+ from rich.tree import Tree
15
+
16
+ from hud.clients import MCPClient
17
+ from hud.utils.design import HUDDesign
18
+
19
+ console = Console()
20
+
21
+
22
+ class InteractiveMCPTester:
23
+ """Interactive MCP environment tester."""
24
+
25
+ def __init__(self, server_url: str, verbose: bool = False) -> None:
26
+ """Initialize the interactive tester.
27
+
28
+ Args:
29
+ server_url: URL of the MCP server (e.g., http://localhost:8765/mcp)
30
+ verbose: Enable verbose output
31
+ """
32
+ self.server_url = server_url
33
+ self.verbose = verbose
34
+ self.client: MCPClient | None = None
35
+ self.tools: list[Any] = []
36
+ self.design = HUDDesign()
37
+
38
+ async def connect(self) -> bool:
39
+ """Connect to the MCP server."""
40
+ try:
41
+ # Create MCP config for HTTP transport
42
+ config = {"server": {"url": self.server_url}}
43
+
44
+ self.client = MCPClient(
45
+ mcp_config=config,
46
+ verbose=self.verbose,
47
+ auto_trace=False, # Disable telemetry for interactive testing
48
+ )
49
+ await self.client.initialize()
50
+
51
+ # Fetch available tools
52
+ self.tools = await self.client.list_tools()
53
+
54
+ return True
55
+ except Exception as e:
56
+ self.design.error(f"Failed to connect: {e}")
57
+ return False
58
+
59
+ async def disconnect(self) -> None:
60
+ """Disconnect from the MCP server."""
61
+ if self.client:
62
+ await self.client.shutdown()
63
+ self.client = None
64
+
65
+ def display_tools(self) -> None:
66
+ """Display available tools in a nice format."""
67
+ if not self.tools:
68
+ console.print("[yellow]No tools available[/yellow]")
69
+ return
70
+
71
+ # Group tools by hub
72
+ regular_tools = []
73
+ hub_tools = {}
74
+
75
+ for tool in self.tools:
76
+ if "/" in tool.name:
77
+ hub, name = tool.name.split("/", 1)
78
+ if hub not in hub_tools:
79
+ hub_tools[hub] = []
80
+ hub_tools[hub].append(tool)
81
+ else:
82
+ regular_tools.append(tool)
83
+
84
+ # Display tools tree
85
+ tree = Tree("🔧 Available Tools")
86
+
87
+ if regular_tools:
88
+ regular_node = tree.add("[cyan]Regular Tools[/cyan]")
89
+ for i, tool in enumerate(regular_tools, 1):
90
+ tool_node = regular_node.add(f"{i}. [white]{tool.name}[/white]")
91
+ if tool.description:
92
+ tool_node.add(f"[dim]{tool.description}[/dim]")
93
+
94
+ # Add hub tools
95
+ tool_index = len(regular_tools) + 1
96
+ for hub_name, tools in hub_tools.items():
97
+ hub_node = tree.add(f"[yellow]{hub_name} Hub[/yellow]")
98
+ for tool in tools:
99
+ tool_node = hub_node.add(f"{tool_index}. [white]{tool.name}[/white]")
100
+ if tool.description:
101
+ tool_node.add(f"[dim]{tool.description}[/dim]")
102
+ tool_index += 1
103
+
104
+ console.print(tree)
105
+
106
+ async def select_tool(self) -> Any | None:
107
+ """Let user select a tool."""
108
+ if not self.tools:
109
+ return None
110
+
111
+ # Build choices list
112
+ choices = []
113
+ tool_map = {}
114
+
115
+ for _, tool in enumerate(self.tools):
116
+ # Create display name
117
+ if "/" in tool.name:
118
+ hub, name = tool.name.split("/", 1)
119
+ display = f"[{hub}] {name}"
120
+ else:
121
+ display = tool.name
122
+
123
+ # Add description if available
124
+ if tool.description:
125
+ display += f" - {tool.description}"
126
+
127
+ choices.append(display)
128
+ tool_map[display] = tool
129
+
130
+ # Add quit option
131
+ choices.append("❌ Quit")
132
+
133
+ # Show selection menu with arrow keys
134
+ console.print("\n[cyan]Select a tool (use arrow keys):[/cyan]")
135
+
136
+ try:
137
+ # Use questionary's async select with custom styling
138
+ selected = await questionary.select(
139
+ "",
140
+ choices=choices,
141
+ style=questionary.Style(
142
+ [
143
+ ("question", ""),
144
+ ("pointer", "fg:#ff9d00 bold"),
145
+ ("highlighted", "fg:#ff9d00 bold"),
146
+ ("selected", "fg:#cc5454"),
147
+ ("separator", "fg:#6c6c6c"),
148
+ ("instruction", "fg:#858585 italic"),
149
+ ]
150
+ ),
151
+ ).unsafe_ask_async()
152
+
153
+ if selected is None:
154
+ console.print("[yellow]No selection made (ESC or Ctrl+C pressed)[/yellow]")
155
+ return None
156
+
157
+ if selected == "❌ Quit":
158
+ return None
159
+
160
+ return tool_map[selected]
161
+
162
+ except KeyboardInterrupt:
163
+ console.print("[yellow]Interrupted by user[/yellow]")
164
+ return None
165
+ except Exception as e:
166
+ console.print(f"[red]Error in tool selection: {e}[/red]")
167
+ return None
168
+
169
+ async def get_tool_arguments(self, tool: Any) -> dict[str, Any] | None:
170
+ """Prompt user for tool arguments."""
171
+ if not hasattr(tool, "inputSchema") or not tool.inputSchema:
172
+ return {}
173
+
174
+ schema = tool.inputSchema
175
+
176
+ # Show schema
177
+ console.print("\n[yellow]Tool Parameters:[/yellow]")
178
+ schema_str = json.dumps(schema, indent=2)
179
+ syntax = Syntax(schema_str, "json", theme="monokai", line_numbers=False)
180
+ console.print(Panel(syntax, title=f"{tool.name} Schema", border_style="dim"))
181
+
182
+ # Handle different schema types
183
+ if schema.get("type") == "object":
184
+ properties = schema.get("properties", {})
185
+ required = schema.get("required", [])
186
+
187
+ if not properties:
188
+ return {}
189
+
190
+ # Prompt for each property
191
+ args = {}
192
+ for prop_name, prop_schema in properties.items():
193
+ prop_type = prop_schema.get("type", "string")
194
+ description = prop_schema.get("description", "")
195
+ is_required = prop_name in required
196
+
197
+ # Build prompt
198
+ prompt = f"{prop_name}"
199
+ if description:
200
+ prompt += f" ({description})"
201
+ if not is_required:
202
+ prompt += " [optional]"
203
+
204
+ # Get value based on type
205
+ if prop_type == "boolean":
206
+ if is_required:
207
+ value = await questionary.confirm(prompt).unsafe_ask_async()
208
+ else:
209
+ # For optional booleans, offer a choice
210
+ choice = await questionary.select(
211
+ prompt, choices=["true", "false", "skip (leave unset)"]
212
+ ).unsafe_ask_async()
213
+ if choice == "skip (leave unset)":
214
+ continue
215
+ value = choice == "true"
216
+ elif prop_type == "number" or prop_type == "integer":
217
+ value_str = await questionary.text(
218
+ prompt,
219
+ default="",
220
+ validate=lambda text, pt=prop_type, req=is_required: True
221
+ if not text and not req
222
+ else (
223
+ text.replace("-", "").replace(".", "").isdigit()
224
+ if pt == "number"
225
+ else text.replace("-", "").isdigit()
226
+ )
227
+ or f"Please enter a valid {pt}",
228
+ ).unsafe_ask_async()
229
+ if not value_str and not is_required:
230
+ continue
231
+ value = int(value_str) if prop_type == "integer" else float(value_str)
232
+ elif prop_type == "array":
233
+ value_str = await questionary.text(
234
+ prompt + " (comma-separated)", default=""
235
+ ).unsafe_ask_async()
236
+ if not value_str and not is_required:
237
+ continue
238
+ value = [v.strip() for v in value_str.split(",")]
239
+ else: # string or unknown
240
+ value = await questionary.text(prompt, default="").unsafe_ask_async()
241
+ if not value and not is_required:
242
+ continue
243
+
244
+ args[prop_name] = value
245
+
246
+ return args
247
+ else:
248
+ # For non-object schemas, just get a single value
249
+ console.print("[yellow]Enter value (or press Enter to skip):[/yellow]")
250
+ value = Prompt.ask("Value", default="")
251
+ return {"value": value} if value else {}
252
+
253
+ async def call_tool(self, tool: Any, arguments: dict[str, Any]) -> None:
254
+ """Call a tool and display results."""
255
+ if not self.client:
256
+ return
257
+
258
+ try:
259
+ # Show what we're calling
260
+ console.print(f"\n[cyan]Calling {tool.name}...[/cyan]")
261
+ if arguments:
262
+ console.print(f"[dim]Arguments: {json.dumps(arguments, indent=2)}[/dim]")
263
+
264
+ # Make the call
265
+ result = await self.client.call_tool(name=tool.name, arguments=arguments)
266
+
267
+ # Display results
268
+ console.print("\n[green]✓ Tool executed successfully[/green]")
269
+
270
+ if result.isError:
271
+ console.print("[red]Error result:[/red]")
272
+
273
+ # Display content blocks
274
+ for content in result.content:
275
+ if isinstance(content, TextContent):
276
+ console.print(
277
+ Panel(
278
+ content.text,
279
+ title="Result",
280
+ border_style="green" if not result.isError else "red",
281
+ )
282
+ )
283
+ else:
284
+ # Handle other content types
285
+ console.print(json.dumps(content, indent=2))
286
+
287
+ except Exception as e:
288
+ console.print(f"[red]✗ Tool execution failed: {e}[/red]")
289
+
290
+ async def run(self) -> None:
291
+ """Run the interactive testing loop."""
292
+ self.design.header("Interactive MCP Tester")
293
+
294
+ # Connect to server
295
+ console.print(f"[cyan]Connecting to {self.server_url}...[/cyan]")
296
+ if not await self.connect():
297
+ return
298
+
299
+ console.print("[green]✓ Connected successfully[/green]")
300
+ console.print(f"[dim]Found {len(self.tools)} tools[/dim]\n")
301
+
302
+ try:
303
+ while True:
304
+ # Select tool
305
+ tool = await self.select_tool()
306
+ if not tool:
307
+ break
308
+
309
+ # Get arguments
310
+ console.print(f"\n[cyan]Selected: {tool.name}[/cyan]")
311
+ arguments = await self.get_tool_arguments(tool)
312
+ if arguments is None:
313
+ console.print("[yellow]Skipping tool call[/yellow]")
314
+ continue
315
+
316
+ # Call tool
317
+ await self.call_tool(tool, arguments)
318
+
319
+ # Just add a separator and continue to tool selection
320
+ console.print("\n" + "─" * 50)
321
+
322
+ finally:
323
+ # Disconnect
324
+ console.print("\n[cyan]Disconnecting...[/cyan]")
325
+ await self.disconnect()
326
+
327
+ # Show next steps tutorial
328
+ self.design.section_title("Next Steps")
329
+ self.design.info("🏗️ Ready to test with real agents? Run:")
330
+ self.design.info(" [cyan]hud build[/cyan]")
331
+ self.design.info("")
332
+ self.design.info("This will:")
333
+ self.design.info(" 1. Build your environment image")
334
+ self.design.info(" 2. Generate a hud.lock.yaml file")
335
+ self.design.info(" 3. Prepare it for testing with agents")
336
+ self.design.info("")
337
+ self.design.info("Then you can:")
338
+ self.design.info(" • Test locally: [cyan]hud run <image>[/cyan]")
339
+ self.design.info(" • Push to registry: [cyan]hud push --image <registry/name>[/cyan]")
340
+ self.design.info(" • Use with agents via the lock file")
341
+
342
+ console.print("\n[dim]Happy testing! 🎉[/dim]")
343
+
344
+
345
+ async def run_interactive_mode(server_url: str, verbose: bool = False) -> None:
346
+ """Run interactive MCP testing mode.
347
+
348
+ Args:
349
+ server_url: URL of the MCP server
350
+ verbose: Enable verbose output
351
+ """
352
+ tester = InteractiveMCPTester(server_url, verbose)
353
+ await tester.run()