hud-python 0.4.1__py3-none-any.whl → 0.4.3__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 (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/cli/interactive.py CHANGED
@@ -1,353 +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()
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()