hud-python 0.4.11__py3-none-any.whl → 0.4.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (63) hide show
  1. hud/__main__.py +8 -0
  2. hud/agents/base.py +7 -8
  3. hud/agents/langchain.py +2 -2
  4. hud/agents/tests/test_openai.py +3 -1
  5. hud/cli/__init__.py +114 -52
  6. hud/cli/build.py +121 -71
  7. hud/cli/debug.py +2 -2
  8. hud/cli/{mcp_server.py → dev.py} +101 -38
  9. hud/cli/eval.py +175 -90
  10. hud/cli/init.py +442 -64
  11. hud/cli/list_func.py +72 -71
  12. hud/cli/pull.py +1 -2
  13. hud/cli/push.py +35 -23
  14. hud/cli/remove.py +35 -41
  15. hud/cli/tests/test_analyze.py +2 -1
  16. hud/cli/tests/test_analyze_metadata.py +42 -49
  17. hud/cli/tests/test_build.py +28 -52
  18. hud/cli/tests/test_cursor.py +1 -1
  19. hud/cli/tests/test_debug.py +1 -1
  20. hud/cli/tests/test_list_func.py +75 -64
  21. hud/cli/tests/test_main_module.py +30 -0
  22. hud/cli/tests/test_mcp_server.py +3 -3
  23. hud/cli/tests/test_pull.py +30 -61
  24. hud/cli/tests/test_push.py +70 -89
  25. hud/cli/tests/test_registry.py +36 -38
  26. hud/cli/tests/test_utils.py +1 -1
  27. hud/cli/utils/__init__.py +1 -0
  28. hud/cli/{docker_utils.py → utils/docker.py} +36 -0
  29. hud/cli/{env_utils.py → utils/environment.py} +7 -7
  30. hud/cli/{interactive.py → utils/interactive.py} +91 -19
  31. hud/cli/{analyze_metadata.py → utils/metadata.py} +12 -8
  32. hud/cli/{registry.py → utils/registry.py} +28 -30
  33. hud/cli/{remote_runner.py → utils/remote_runner.py} +1 -1
  34. hud/cli/utils/runner.py +134 -0
  35. hud/cli/utils/server.py +250 -0
  36. hud/clients/base.py +1 -1
  37. hud/clients/fastmcp.py +5 -13
  38. hud/clients/mcp_use.py +6 -10
  39. hud/server/server.py +35 -5
  40. hud/shared/exceptions.py +11 -0
  41. hud/shared/tests/test_exceptions.py +22 -0
  42. hud/telemetry/tests/__init__.py +0 -0
  43. hud/telemetry/tests/test_replay.py +40 -0
  44. hud/telemetry/tests/test_trace.py +63 -0
  45. hud/tools/base.py +20 -3
  46. hud/tools/computer/hud.py +15 -6
  47. hud/tools/executors/tests/test_base_executor.py +27 -0
  48. hud/tools/response.py +12 -8
  49. hud/tools/tests/test_response.py +60 -0
  50. hud/tools/tests/test_tools_init.py +49 -0
  51. hud/utils/design.py +19 -8
  52. hud/utils/mcp.py +17 -5
  53. hud/utils/tests/test_mcp.py +112 -0
  54. hud/utils/tests/test_version.py +1 -1
  55. hud/version.py +1 -1
  56. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/METADATA +16 -13
  57. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/RECORD +62 -52
  58. hud/cli/runner.py +0 -160
  59. /hud/cli/{cursor.py → utils/cursor.py} +0 -0
  60. /hud/cli/{utils.py → utils/logging.py} +0 -0
  61. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/WHEEL +0 -0
  62. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/entry_points.txt +0 -0
  63. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/licenses/LICENSE +0 -0
@@ -112,52 +112,103 @@ class InteractiveMCPTester:
112
112
  choices = []
113
113
  tool_map = {}
114
114
 
115
- for _, tool in enumerate(self.tools):
116
- # Create display name
115
+ # Group tools by hub for better organization
116
+ hub_groups = {}
117
+ regular_tools = []
118
+
119
+ for tool in self.tools:
117
120
  if "/" in tool.name:
118
121
  hub, name = tool.name.split("/", 1)
119
- display = f"[{hub}] {name}"
122
+ if hub not in hub_groups:
123
+ hub_groups[hub] = []
124
+ hub_groups[hub].append((name, tool))
120
125
  else:
121
- display = tool.name
126
+ regular_tools.append(tool)
127
+
128
+ # Add regular tools first
129
+ if regular_tools:
130
+ # Add a separator for regular tools section
131
+ if len(hub_groups) > 0:
132
+ choices.append("───── Regular Tools ─────")
133
+
134
+ for tool in regular_tools:
135
+ # Format: Bold tool name with color + dim description
136
+ if tool.description:
137
+ display = f"• {tool.name} │ {tool.description}"
138
+ else:
139
+ display = f"• {tool.name}"
140
+
141
+ choices.append(display)
142
+ tool_map[display] = tool
122
143
 
123
- # Add description if available
124
- if tool.description:
125
- display += f" - {tool.description}"
144
+ # Add hub-grouped tools with visual separation
145
+ for hub_name, tools in sorted(hub_groups.items()):
146
+ # Add a visual separator for each hub
147
+ choices.append(f"───── {hub_name} ─────")
126
148
 
127
- choices.append(display)
128
- tool_map[display] = tool
149
+ for name, tool in sorted(tools, key=lambda x: x[0]):
150
+ # Format with hub indicator and better separation
151
+ if tool.description:
152
+ # Remove redundant description text
153
+ desc = tool.description
154
+ # Truncate long descriptions
155
+ if len(desc) > 60:
156
+ desc = desc[:57] + "..."
157
+ display = f"• {name} │ {desc}"
158
+ else:
159
+ display = f"• {name}"
160
+
161
+ choices.append(display)
162
+ tool_map[display] = tool
129
163
 
130
- # Add quit option
164
+ # Add quit option with spacing
165
+ choices.append("─────────────────────")
131
166
  choices.append("❌ Quit")
132
167
 
133
168
  # Show selection menu with arrow keys
134
169
  console.print("\n[cyan]Select a tool (use arrow keys):[/cyan]")
135
170
 
136
171
  try:
137
- # Use questionary's async select with custom styling
172
+ # Create custom Choice objects for better formatting
173
+ from questionary import Choice
174
+
175
+ formatted_choices = []
176
+ for choice in choices:
177
+ if choice.startswith("─────"):
178
+ # Separator - make it unselectable and styled
179
+ formatted_choices.append(Choice(title=choice, disabled=True, shortcut_key=None)) # type: ignore[arg-type]
180
+ elif choice == "❌ Quit":
181
+ formatted_choices.append(choice)
182
+ else:
183
+ formatted_choices.append(choice)
184
+
185
+ # Use questionary's async select with enhanced styling
138
186
  selected = await questionary.select(
139
187
  "",
140
- choices=choices,
188
+ choices=formatted_choices,
141
189
  style=questionary.Style(
142
190
  [
143
191
  ("question", ""),
144
- ("pointer", "fg:#ff9d00 bold"),
145
- ("highlighted", "fg:#ff9d00 bold"),
146
- ("selected", "fg:#cc5454"),
147
- ("separator", "fg:#6c6c6c"),
148
- ("instruction", "fg:#858585 italic"),
192
+ ("pointer", "fg:#ff9d00 bold"), # Orange pointer
193
+ ("highlighted", "fg:#00d7ff bold"), # Bright cyan for highlighted
194
+ ("selected", "fg:#00ff00 bold"), # Green for selected
195
+ ("separator", "fg:#666666"), # Gray for separators
196
+ ("instruction", "fg:#858585 italic"), # Dim instructions
197
+ ("disabled", "fg:#666666"), # Gray for disabled items
198
+ ("text", "fg:#ffffff"), # White text
149
199
  ]
150
200
  ),
201
+ instruction="(Use ↑/↓ arrows, Enter to select, Esc to cancel)",
151
202
  ).unsafe_ask_async()
152
203
 
153
204
  if selected is None:
154
205
  console.print("[yellow]No selection made (ESC or Ctrl+C pressed)[/yellow]")
155
206
  return None
156
207
 
157
- if selected == "❌ Quit":
208
+ if selected == "❌ Quit" or selected.startswith("─────"):
158
209
  return None
159
210
 
160
- return tool_map[selected]
211
+ return tool_map.get(selected)
161
212
 
162
213
  except KeyboardInterrupt:
163
214
  console.print("[yellow]Interrupted by user[/yellow]")
@@ -236,6 +287,27 @@ class InteractiveMCPTester:
236
287
  if not value_str and not is_required:
237
288
  continue
238
289
  value = [v.strip() for v in value_str.split(",")]
290
+ elif prop_type == "object":
291
+ # For object types, allow JSON input
292
+ console.print(f"[dim]Enter JSON object for {prop_name}:[/dim]")
293
+ value_str = await questionary.text(
294
+ prompt + " (JSON format)", default="{}"
295
+ ).unsafe_ask_async()
296
+ if not value_str and not is_required:
297
+ continue
298
+ try:
299
+ value = json.loads(value_str)
300
+ except json.JSONDecodeError as e:
301
+ console.print(f"[red]Invalid JSON: {e}[/red]")
302
+ # Try again
303
+ value_str = await questionary.text(
304
+ prompt + " (JSON format, please fix the error)", default=value_str
305
+ ).unsafe_ask_async()
306
+ try:
307
+ value = json.loads(value_str)
308
+ except json.JSONDecodeError:
309
+ console.print("[red]Still invalid JSON, using empty object[/red]")
310
+ value = {}
239
311
  else: # string or unknown
240
312
  value = await questionary.text(prompt, default="").unsafe_ask_async()
241
313
  if not value and not is_required:
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path
6
5
  from urllib.parse import quote
7
6
 
8
7
  import requests
@@ -13,7 +12,11 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
13
12
  from hud.settings import settings
14
13
  from hud.utils.design import HUDDesign
15
14
 
16
- from .registry import get_registry_dir, list_registry_entries, extract_digest_from_image, load_from_registry
15
+ from .registry import (
16
+ extract_digest_from_image,
17
+ list_registry_entries,
18
+ load_from_registry,
19
+ )
17
20
 
18
21
  console = Console()
19
22
  design = HUDDesign()
@@ -60,13 +63,13 @@ def check_local_cache(reference: str) -> dict | None:
60
63
  lock_data = load_from_registry(digest)
61
64
  if lock_data:
62
65
  return lock_data
63
-
66
+
64
67
  # If not found and reference has a name, search by name pattern
65
68
  if "/" in reference:
66
69
  # Look for any cached version of this image
67
70
  ref_base = reference.split("@")[0].split(":")[0]
68
-
69
- for digest, lock_file in list_registry_entries():
71
+
72
+ for _, lock_file in list_registry_entries():
70
73
  try:
71
74
  with open(lock_file) as f:
72
75
  lock_data = yaml.safe_load(f)
@@ -78,8 +81,8 @@ def check_local_cache(reference: str) -> dict | None:
78
81
  if ref_base in img_base or img_base in ref_base:
79
82
  return lock_data
80
83
  except Exception:
81
- continue
82
-
84
+ design.error("Error loading lock file")
85
+
83
86
  return None
84
87
 
85
88
 
@@ -87,7 +90,7 @@ async def analyze_from_metadata(reference: str, output_format: str, verbose: boo
87
90
  """Analyze environment from cached or registry metadata."""
88
91
  import json
89
92
 
90
- from .analyze import display_interactive, display_markdown
93
+ from hud.cli.analyze import display_interactive, display_markdown
91
94
 
92
95
  design.header("MCP Environment Analysis", icon="🔍")
93
96
  design.info(f"Looking up: {reference}")
@@ -146,6 +149,7 @@ async def analyze_from_metadata(reference: str, output_format: str, verbose: boo
146
149
 
147
150
  # Save to local cache for next time
148
151
  from .registry import save_to_registry
152
+
149
153
  save_to_registry(lock_data, lock_data.get("image", ""), verbose=False)
150
154
  else:
151
155
  progress.update(task, description="[red]✗ Not found[/red]")
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Dict, Optional, Any
6
+ from typing import Any
7
7
 
8
8
  import yaml
9
9
 
@@ -17,10 +17,10 @@ def get_registry_dir() -> Path:
17
17
 
18
18
  def extract_digest_from_image(image_ref: str) -> str:
19
19
  """Extract a digest identifier from a Docker image reference.
20
-
20
+
21
21
  Args:
22
22
  image_ref: Docker image reference (e.g., "image:tag@sha256:abc123...")
23
-
23
+
24
24
  Returns:
25
25
  Digest string for use as directory name (max 12 chars)
26
26
  """
@@ -41,13 +41,13 @@ def extract_digest_from_image(image_ref: str) -> str:
41
41
 
42
42
  def extract_name_and_tag(image_ref: str) -> tuple[str, str]:
43
43
  """Extract organization/name and tag from Docker image reference.
44
-
44
+
45
45
  Args:
46
46
  image_ref: Docker image reference
47
-
47
+
48
48
  Returns:
49
49
  Tuple of (name, tag) where name includes org/repo
50
-
50
+
51
51
  Examples:
52
52
  docker.io/hudpython/test_init:latest@sha256:... -> (hudpython/test_init, latest)
53
53
  hudpython/myenv:v1.0 -> (hudpython/myenv, v1.0)
@@ -56,54 +56,52 @@ def extract_name_and_tag(image_ref: str) -> tuple[str, str]:
56
56
  # Remove digest if present
57
57
  if "@" in image_ref:
58
58
  image_ref = image_ref.split("@")[0]
59
-
59
+
60
60
  # Remove registry prefix if present
61
61
  if image_ref.startswith(("docker.io/", "registry-1.docker.io/", "index.docker.io/")):
62
62
  image_ref = "/".join(image_ref.split("/")[1:])
63
-
63
+
64
64
  # Extract tag
65
65
  if ":" in image_ref:
66
66
  name, tag = image_ref.rsplit(":", 1)
67
67
  else:
68
68
  name = image_ref
69
69
  tag = "latest"
70
-
70
+
71
71
  return name, tag
72
72
 
73
73
 
74
74
  def save_to_registry(
75
- lock_data: Dict[str, Any],
76
- image_ref: str,
77
- verbose: bool = False
78
- ) -> Optional[Path]:
75
+ lock_data: dict[str, Any], image_ref: str, verbose: bool = False
76
+ ) -> Path | None:
79
77
  """Save environment lock data to the local registry.
80
-
78
+
81
79
  Args:
82
80
  lock_data: The lock file data to save
83
81
  image_ref: Docker image reference for digest extraction
84
82
  verbose: Whether to show verbose output
85
-
83
+
86
84
  Returns:
87
85
  Path to the saved lock file, or None if save failed
88
86
  """
89
87
  design = HUDDesign()
90
-
88
+
91
89
  try:
92
90
  # Extract digest for registry storage
93
91
  digest = extract_digest_from_image(image_ref)
94
-
92
+
95
93
  # Store under ~/.hud/envs/<digest>/
96
94
  local_env_dir = get_registry_dir() / digest
97
95
  local_env_dir.mkdir(parents=True, exist_ok=True)
98
-
96
+
99
97
  local_lock_path = local_env_dir / "hud.lock.yaml"
100
98
  with open(local_lock_path, "w") as f:
101
99
  yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
102
-
100
+
103
101
  design.success(f"Added to local registry: {digest}")
104
102
  if verbose:
105
103
  design.info(f"Registry location: {local_lock_path}")
106
-
104
+
107
105
  return local_lock_path
108
106
  except Exception as e:
109
107
  if verbose:
@@ -111,20 +109,20 @@ def save_to_registry(
111
109
  return None
112
110
 
113
111
 
114
- def load_from_registry(digest: str) -> Optional[Dict[str, Any]]:
112
+ def load_from_registry(digest: str) -> dict[str, Any] | None:
115
113
  """Load environment lock data from the local registry.
116
-
114
+
117
115
  Args:
118
116
  digest: The digest/identifier of the environment
119
-
117
+
120
118
  Returns:
121
119
  Lock data dictionary, or None if not found
122
120
  """
123
121
  lock_path = get_registry_dir() / digest / "hud.lock.yaml"
124
-
122
+
125
123
  if not lock_path.exists():
126
124
  return None
127
-
125
+
128
126
  try:
129
127
  with open(lock_path) as f:
130
128
  return yaml.safe_load(f)
@@ -134,22 +132,22 @@ def load_from_registry(digest: str) -> Optional[Dict[str, Any]]:
134
132
 
135
133
  def list_registry_entries() -> list[tuple[str, Path]]:
136
134
  """List all entries in the local registry.
137
-
135
+
138
136
  Returns:
139
137
  List of (digest, lock_path) tuples
140
138
  """
141
139
  registry_dir = get_registry_dir()
142
-
140
+
143
141
  if not registry_dir.exists():
144
142
  return []
145
-
143
+
146
144
  entries = []
147
145
  for digest_dir in registry_dir.iterdir():
148
146
  if not digest_dir.is_dir():
149
147
  continue
150
-
148
+
151
149
  lock_file = digest_dir / "hud.lock.yaml"
152
150
  if lock_file.exists():
153
151
  entries.append((digest_dir.name, lock_file))
154
-
152
+
155
153
  return entries
@@ -199,7 +199,7 @@ async def run_remote_http(
199
199
  verbose: bool = False,
200
200
  ) -> None:
201
201
  """Run remote MCP server with HTTP transport."""
202
- from .utils import find_free_port
202
+ from .logging import find_free_port
203
203
 
204
204
  # Find available port
205
205
  actual_port = find_free_port(port)
@@ -0,0 +1,134 @@
1
+ """Run Docker images as MCP servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import subprocess
7
+ import sys
8
+
9
+ from hud.utils.design import HUDDesign
10
+
11
+ from .logging import find_free_port
12
+ from .server import MCPServerManager, run_server_with_interactive
13
+
14
+
15
+ def run_stdio_server(image: str, docker_args: list[str], verbose: bool) -> None:
16
+ """Run Docker image as stdio MCP server (direct passthrough)."""
17
+ design = HUDDesign() # Use stderr for stdio mode
18
+
19
+ # Build docker command
20
+ docker_cmd = ["docker", "run", "--rm", "-i", *docker_args, image]
21
+
22
+ if verbose:
23
+ design.info(f"🐳 Running: {' '.join(docker_cmd)}")
24
+
25
+ # Run docker directly with stdio passthrough
26
+ try:
27
+ result = subprocess.run(docker_cmd, stdin=sys.stdin) # noqa: S603
28
+ sys.exit(result.returncode)
29
+ except KeyboardInterrupt:
30
+ design.info("\n👋 Shutting down...")
31
+ sys.exit(0)
32
+ except Exception as e:
33
+ design.error(f"Error: {e}")
34
+ sys.exit(1)
35
+
36
+
37
+ async def run_http_server(image: str, docker_args: list[str], port: int, verbose: bool) -> None:
38
+ """Run Docker image as HTTP MCP server (proxy mode)."""
39
+ design = HUDDesign()
40
+
41
+ # Create server manager
42
+ server_manager = MCPServerManager(image, docker_args)
43
+
44
+ # Find available port
45
+ actual_port = find_free_port(port)
46
+ if actual_port is None:
47
+ design.error(f"No available ports found starting from {port}")
48
+ return
49
+
50
+ if actual_port != port:
51
+ design.warning(f"Port {port} in use, using port {actual_port} instead")
52
+
53
+ # Clean up any existing container
54
+ server_manager.cleanup_container()
55
+
56
+ # Build docker command
57
+ docker_cmd = server_manager.build_docker_command()
58
+
59
+ # Create MCP config
60
+ config = server_manager.create_mcp_config(docker_cmd)
61
+
62
+ # Create proxy
63
+ proxy = server_manager.create_proxy(config)
64
+
65
+ # Show header
66
+ design.info("") # Empty line
67
+ design.header("HUD MCP Server", icon="🌐")
68
+
69
+ # Show configuration
70
+ design.section_title("Server Information")
71
+ design.info(f"Port: {actual_port}")
72
+ design.info(f"URL: http://localhost:{actual_port}/mcp")
73
+ design.info(f"Container: {server_manager.container_name}")
74
+ design.info("")
75
+ design.progress_message("Press Ctrl+C to stop")
76
+
77
+ try:
78
+ await server_manager.run_http_server(proxy, actual_port, verbose)
79
+ except KeyboardInterrupt:
80
+ design.info("\n👋 Shutting down...")
81
+ finally:
82
+ # Clean up container
83
+ server_manager.cleanup_container()
84
+
85
+
86
+ async def run_http_server_interactive(
87
+ image: str, docker_args: list[str], port: int, verbose: bool
88
+ ) -> None:
89
+ """Run Docker image as HTTP MCP server with interactive testing."""
90
+ # Create server manager
91
+ server_manager = MCPServerManager(image, docker_args)
92
+
93
+ # Use the shared utility function
94
+ await run_server_with_interactive(server_manager, port, verbose)
95
+
96
+
97
+ def run_mcp_server(
98
+ image: str,
99
+ docker_args: list[str],
100
+ transport: str,
101
+ port: int,
102
+ verbose: bool,
103
+ interactive: bool = False,
104
+ ) -> None:
105
+ """Run Docker image as MCP server with specified transport."""
106
+ if transport == "stdio":
107
+ if interactive:
108
+ design = HUDDesign()
109
+ design.error("Interactive mode requires HTTP transport")
110
+ sys.exit(1)
111
+ run_stdio_server(image, docker_args, verbose)
112
+ elif transport == "http":
113
+ if interactive:
114
+ # Run in interactive mode
115
+ asyncio.run(run_http_server_interactive(image, docker_args, port, verbose))
116
+ else:
117
+ try:
118
+ asyncio.run(run_http_server(image, docker_args, port, verbose))
119
+ except Exception as e:
120
+ # Suppress the graceful shutdown errors
121
+ if not any(
122
+ x in str(e)
123
+ for x in [
124
+ "timeout graceful shutdown exceeded",
125
+ "Cancel 0 running task(s)",
126
+ "Application shutdown complete",
127
+ ]
128
+ ):
129
+ design = HUDDesign()
130
+ design.error(f"Unexpected error: {e}")
131
+ else:
132
+ design = HUDDesign()
133
+ design.error(f"Unknown transport: {transport}")
134
+ sys.exit(1)