hud-python 0.4.35__py3-none-any.whl → 0.4.37__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/agents/__init__.py +2 -0
  2. hud/agents/lite_llm.py +72 -0
  3. hud/agents/openai_chat_generic.py +21 -7
  4. hud/agents/tests/test_claude.py +32 -7
  5. hud/agents/tests/test_openai.py +29 -6
  6. hud/cli/__init__.py +228 -79
  7. hud/cli/build.py +26 -6
  8. hud/cli/dev.py +21 -40
  9. hud/cli/eval.py +96 -15
  10. hud/cli/flows/tasks.py +198 -65
  11. hud/cli/init.py +222 -629
  12. hud/cli/pull.py +6 -0
  13. hud/cli/push.py +11 -1
  14. hud/cli/rl/__init__.py +14 -4
  15. hud/cli/rl/celebrate.py +187 -0
  16. hud/cli/rl/config.py +15 -8
  17. hud/cli/rl/local_runner.py +44 -20
  18. hud/cli/rl/remote_runner.py +166 -87
  19. hud/cli/rl/viewer.py +141 -0
  20. hud/cli/rl/wait_utils.py +89 -0
  21. hud/cli/tests/test_build.py +3 -27
  22. hud/cli/tests/test_mcp_server.py +1 -12
  23. hud/cli/utils/config.py +85 -0
  24. hud/cli/utils/docker.py +21 -39
  25. hud/cli/utils/env_check.py +196 -0
  26. hud/cli/utils/environment.py +4 -3
  27. hud/cli/utils/interactive.py +2 -1
  28. hud/cli/utils/local_runner.py +204 -0
  29. hud/cli/utils/metadata.py +3 -1
  30. hud/cli/utils/package_runner.py +292 -0
  31. hud/cli/utils/remote_runner.py +4 -1
  32. hud/cli/utils/source_hash.py +108 -0
  33. hud/clients/base.py +1 -1
  34. hud/clients/fastmcp.py +1 -1
  35. hud/clients/mcp_use.py +30 -7
  36. hud/datasets/parallel.py +3 -1
  37. hud/datasets/runner.py +4 -1
  38. hud/otel/config.py +1 -1
  39. hud/otel/context.py +40 -6
  40. hud/rl/buffer.py +3 -0
  41. hud/rl/tests/test_learner.py +1 -1
  42. hud/rl/vllm_adapter.py +1 -1
  43. hud/server/server.py +234 -7
  44. hud/server/tests/test_add_tool.py +60 -0
  45. hud/server/tests/test_context.py +128 -0
  46. hud/server/tests/test_mcp_server_handlers.py +44 -0
  47. hud/server/tests/test_mcp_server_integration.py +405 -0
  48. hud/server/tests/test_mcp_server_more.py +247 -0
  49. hud/server/tests/test_run_wrapper.py +53 -0
  50. hud/server/tests/test_server_extra.py +166 -0
  51. hud/server/tests/test_sigterm_runner.py +78 -0
  52. hud/settings.py +38 -0
  53. hud/shared/hints.py +2 -2
  54. hud/telemetry/job.py +2 -2
  55. hud/types.py +9 -2
  56. hud/utils/tasks.py +32 -24
  57. hud/utils/tests/test_version.py +1 -1
  58. hud/version.py +1 -1
  59. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/METADATA +43 -23
  60. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/RECORD +63 -46
  61. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
  62. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
  63. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def get_config_dir() -> Path:
7
+ """Return the base HUD config directory in the user's home.
8
+
9
+ Uses ~/.hud across platforms for consistency with existing registry data.
10
+ """
11
+ return Path.home() / ".hud"
12
+
13
+
14
+ def get_user_env_path() -> Path:
15
+ """Return the path to the persistent user-level env file (~/.hud/.env)."""
16
+ return get_config_dir() / ".env"
17
+
18
+
19
+ def ensure_config_dir() -> Path:
20
+ """Ensure the HUD config directory exists and return it."""
21
+ config_dir = get_config_dir()
22
+ config_dir.mkdir(parents=True, exist_ok=True)
23
+ return config_dir
24
+
25
+
26
+ def parse_env_file(contents: str) -> dict[str, str]:
27
+ """Parse simple KEY=VALUE lines into a dict.
28
+
29
+ - Ignores blank lines and lines starting with '#'.
30
+ - Does not perform variable substitution or quoting.
31
+ """
32
+ data: dict[str, str] = {}
33
+ for raw_line in contents.splitlines():
34
+ line = raw_line.strip()
35
+ if not line or line.startswith("#"):
36
+ continue
37
+ if "=" not in line:
38
+ continue
39
+ key, value = line.split("=", 1)
40
+ key = key.strip()
41
+ value = value.strip()
42
+ if key:
43
+ data[key] = value
44
+ return data
45
+
46
+
47
+ def render_env_file(env: dict[str, str]) -> str:
48
+ """Render a dict of env values to KEY=VALUE lines with a header."""
49
+ header = [
50
+ "# HUD CLI persistent environment file",
51
+ "# Keys set via `hud set KEY=VALUE`",
52
+ "# This file is read after process env and project .env",
53
+ "# so project overrides take precedence over these defaults.",
54
+ "",
55
+ ]
56
+ body = [f"{key}={env[key]}" for key in sorted(env.keys())]
57
+ return "\n".join([*header, *body, ""])
58
+
59
+
60
+ def load_env_file(path: Path | None = None) -> dict[str, str]:
61
+ """Load env assignments from the given path (defaults to ~/.hud/.env)."""
62
+ env_path = path or get_user_env_path()
63
+ if not env_path.exists():
64
+ return {}
65
+ try:
66
+ contents = env_path.read_text(encoding="utf-8")
67
+ except Exception:
68
+ return {}
69
+ return parse_env_file(contents)
70
+
71
+
72
+ def save_env_file(env: dict[str, str], path: Path | None = None) -> Path:
73
+ """Write env assignments to the given path and return the path."""
74
+ ensure_config_dir()
75
+ env_path = path or get_user_env_path()
76
+ rendered = render_env_file(env)
77
+ env_path.write_text(rendered, encoding="utf-8")
78
+ return env_path
79
+
80
+
81
+ def set_env_values(values: dict[str, str]) -> Path:
82
+ """Persist provided KEY=VALUE pairs into ~/.hud/.env and return the path."""
83
+ current = load_env_file()
84
+ current.update(values)
85
+ return save_env_file(current)
hud/cli/utils/docker.py CHANGED
@@ -36,45 +36,6 @@ def get_docker_cmd(image: str) -> list[str] | None:
36
36
  return None
37
37
 
38
38
 
39
- def inject_supervisor(cmd: list[str]) -> list[str]:
40
- """
41
- Inject watchfiles CLI supervisor into a Docker CMD.
42
-
43
- For shell commands, we inject before the last exec command.
44
- For direct commands, we wrap the entire command.
45
-
46
- Args:
47
- cmd: Original Docker CMD
48
-
49
- Returns:
50
- Modified CMD with watchfiles supervisor injected
51
- """
52
- if not cmd:
53
- return cmd
54
-
55
- # Handle shell commands that might have background processes
56
- if cmd[0] in ["sh", "bash"] and len(cmd) >= 3 and cmd[1] == "-c":
57
- shell_cmd = cmd[2]
58
-
59
- # Look for 'exec' in the shell command - this is the last command
60
- if " exec " in shell_cmd:
61
- # Replace only the exec'd command with watchfiles
62
- parts = shell_cmd.rsplit(" exec ", 1)
63
- if len(parts) == 2:
64
- # Extract the actual command after exec
65
- last_cmd = parts[1].strip()
66
- # Use watchfiles with logs redirected to stderr (which won't interfere with MCP on stdout) # noqa: E501
67
- new_shell_cmd = f"{parts[0]} exec watchfiles --verbose '{last_cmd}' /app/src"
68
- return [cmd[0], cmd[1], new_shell_cmd]
69
- else:
70
- # No exec, the whole thing is the command
71
- return ["sh", "-c", f"watchfiles --verbose '{shell_cmd}' /app/src"]
72
-
73
- # Direct command - wrap with watchfiles
74
- watchfiles_cmd = " ".join(cmd)
75
- return ["sh", "-c", f"watchfiles --verbose '{watchfiles_cmd}' /app/src"]
76
-
77
-
78
39
  def image_exists(image_name: str) -> bool:
79
40
  """Check if a Docker image exists locally."""
80
41
  result = subprocess.run( # noqa: S603
@@ -121,6 +82,27 @@ def generate_container_name(identifier: str, prefix: str = "hud") -> str:
121
82
  return f"{prefix}-{safe_name}"
122
83
 
123
84
 
85
+ def build_run_command(image: str, docker_args: list[str] | None = None) -> list[str]:
86
+ """Construct a standard docker run command used across CLI commands.
87
+
88
+ Args:
89
+ image: Docker image name to run
90
+ docker_args: Additional docker args to pass before the image
91
+
92
+ Returns:
93
+ The docker run command list
94
+ """
95
+ args = docker_args or []
96
+ return [
97
+ "docker",
98
+ "run",
99
+ "--rm",
100
+ "-i",
101
+ *args,
102
+ image,
103
+ ]
104
+
105
+
124
106
  def _emit_docker_hints(error_text: str) -> None:
125
107
  """Parse common Docker connectivity errors and print platform-specific hints."""
126
108
  from hud.utils.hud_console import hud_console
@@ -0,0 +1,196 @@
1
+ """Environment build checks and discovery helpers.
2
+
3
+ Shared utilities to:
4
+ - locate an environment directory related to a tasks file
5
+ - ensure the environment is built and up-to-date via source hash comparison
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ import yaml
17
+
18
+ from hud.utils.hud_console import hud_console
19
+
20
+ from .docker import require_docker_running
21
+ from .source_hash import compute_source_hash, list_source_files
22
+
23
+
24
+ def _parse_generated_at(lock_data: dict[str, Any]) -> float | None:
25
+ """Parse build.generatedAt into a POSIX timestamp (seconds).
26
+
27
+ Returns None if missing or unparsable.
28
+ """
29
+ try:
30
+ generated_at = (lock_data.get("build") or {}).get("generatedAt")
31
+ if not isinstance(generated_at, str):
32
+ return None
33
+ # Support ...Z and offsets
34
+ iso = generated_at.replace("Z", "+00:00")
35
+ dt = datetime.fromisoformat(iso)
36
+ if dt.tzinfo is None:
37
+ dt = dt.replace(tzinfo=UTC)
38
+ return dt.timestamp()
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def _collect_source_diffs(env_dir: Path, lock_data: dict[str, Any]) -> dict[str, list[str]]:
44
+ """Compute added/removed/modified files since last build using names + mtimes.
45
+
46
+ - added/removed are based on the stored build.sourceFiles list vs current file list
47
+ - modified is based on mtime newer than build.generatedAt for files present now
48
+ """
49
+ try:
50
+ stored_files = (
51
+ (lock_data.get("build") or {}).get("sourceFiles") if isinstance(lock_data, dict) else []
52
+ )
53
+ stored_set = set(str(p) for p in (stored_files or []))
54
+ except Exception:
55
+ stored_set = set()
56
+
57
+ current_paths = list_source_files(env_dir)
58
+ # Normalize to POSIX-style relative strings
59
+ current_list = [str(p.resolve().relative_to(env_dir)).replace("\\", "/") for p in current_paths]
60
+ current_set = set(current_list)
61
+
62
+ added = sorted(current_set - stored_set)
63
+ removed = sorted(stored_set - current_set)
64
+
65
+ # Modified: mtime newer than build.generatedAt
66
+ modified: list[str] = []
67
+ built_ts = _parse_generated_at(lock_data)
68
+ if built_ts is not None:
69
+ for rel in sorted(current_set & (stored_set or current_set)):
70
+ with contextlib.suppress(Exception):
71
+ p = env_dir / rel
72
+ if p.exists() and p.stat().st_mtime > built_ts:
73
+ modified.append(rel)
74
+
75
+ return {"added": added, "removed": removed, "modified": modified}
76
+
77
+
78
+ def find_environment_dir(tasks_path: Path) -> Path | None:
79
+ """Best-effort discovery of a nearby environment directory.
80
+
81
+ Preference order:
82
+ - directory with hud.lock.yaml
83
+ - directory that looks like an environment (Dockerfile + pyproject.toml)
84
+ - searches tasks dir, CWD, and a couple of parents
85
+ """
86
+ from .environment import is_environment_directory # local import to avoid cycles
87
+
88
+ candidates: list[Path] = []
89
+ cwd = Path.cwd()
90
+ candidates.extend([tasks_path.parent, cwd])
91
+
92
+ # Add parents (up to 2 levels for each)
93
+ for base in list(candidates):
94
+ p = base
95
+ for _ in range(2):
96
+ p = p.parent
97
+ if p not in candidates:
98
+ candidates.append(p)
99
+
100
+ # Prefer those with hud.lock.yaml
101
+ for d in candidates:
102
+ if (d / "hud.lock.yaml").exists():
103
+ return d
104
+
105
+ # Otherwise, find a plausible environment dir
106
+ for d in candidates:
107
+ try:
108
+ if is_environment_directory(d):
109
+ return d
110
+ except Exception as e:
111
+ hud_console.debug(f"Skipping path {d}: {e}")
112
+ continue
113
+
114
+ return None
115
+
116
+
117
+ def ensure_built(env_dir: Path, *, interactive: bool = True) -> dict[str, Any]:
118
+ """Ensure env has a lock and matches current sources via source hash.
119
+
120
+ If interactive is True, prompts to build/rebuild as needed. If False, only warns.
121
+ Returns the loaded lock data (empty dict if unreadable/missing).
122
+ """
123
+ from hud.cli.build import build_environment # local import to avoid import cycles
124
+
125
+ lock_path = env_dir / "hud.lock.yaml"
126
+ if not lock_path.exists():
127
+ if interactive:
128
+ hud_console.warning("No hud.lock.yaml found. The environment hasn't been built.")
129
+ ok = hud_console.confirm("Build the environment now (runs 'hud build')?", default=True)
130
+ if not ok:
131
+ raise typer.Exit(1)
132
+ require_docker_running()
133
+ build_environment(str(env_dir), platform="linux/amd64")
134
+ else:
135
+ hud_console.dim_info(
136
+ "Info",
137
+ "No hud.lock.yaml found nearby; skipping environment change checks.",
138
+ )
139
+ return {}
140
+
141
+ # Load lock file
142
+ try:
143
+ with open(lock_path) as f:
144
+ lock_data: dict[str, Any] = yaml.safe_load(f) or {}
145
+ except Exception:
146
+ lock_data = {}
147
+
148
+ # Fast change detection: recompute source hash and compare
149
+ try:
150
+ current_hash = compute_source_hash(env_dir)
151
+ stored_hash = (
152
+ (lock_data.get("build") or {}).get("sourceHash")
153
+ if isinstance(lock_data, dict)
154
+ else None
155
+ )
156
+ if stored_hash and current_hash and stored_hash != current_hash:
157
+ hud_console.warning("Environment sources changed since last build.")
158
+
159
+ # Show a brief diff summary to help users understand changes
160
+ diffs = _collect_source_diffs(env_dir, lock_data)
161
+
162
+ def _print_section(name: str, items: list[str]) -> None:
163
+ if not items:
164
+ return
165
+ # Limit output to avoid flooding the console
166
+ preview = items[:20]
167
+ more = len(items) - len(preview)
168
+ hud_console.section_title(name)
169
+ for rel in preview:
170
+ hud_console.dim_info("", rel)
171
+ if more > 0:
172
+ hud_console.dim_info("", f"... and {more} more")
173
+
174
+ _print_section("Modified files", diffs.get("modified", []))
175
+ _print_section("Added files", diffs.get("added", []))
176
+ _print_section("Removed files", diffs.get("removed", []))
177
+
178
+ if interactive:
179
+ if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
180
+ require_docker_running()
181
+ build_environment(str(env_dir), platform="linux/amd64")
182
+ with open(lock_path) as f:
183
+ lock_data = yaml.safe_load(f) or {}
184
+ else:
185
+ hud_console.hint("Continuing without rebuild; this may use an outdated image.")
186
+ else:
187
+ hud_console.hint("Run 'hud build' to update the image before proceeding.")
188
+ elif not stored_hash:
189
+ hud_console.dim_info(
190
+ "Info",
191
+ "No source hash in lock; rebuild to enable change checks.",
192
+ )
193
+ except Exception as e:
194
+ hud_console.debug(f"Source hash check skipped: {e}")
195
+
196
+ return lock_data
@@ -33,14 +33,15 @@ def get_image_name(directory: str | Path, image_override: str | None = None) ->
33
33
  except Exception:
34
34
  hud_console.error("Error loading pyproject.toml")
35
35
 
36
- # Auto-generate with :dev tag
36
+ # Auto-generate with :dev tag (replace underscores with hyphens)
37
37
  dir_path = Path(directory).resolve() # Get absolute path first
38
38
  dir_name = dir_path.name
39
39
  if not dir_name or dir_name == ".":
40
40
  # If we're in root or have empty name, use parent directory
41
41
  dir_name = dir_path.parent.name
42
- clean_name = dir_name.replace("_", "-")
43
- return f"hud-{clean_name}:dev", "auto"
42
+ # Replace underscores with hyphens for Docker image names
43
+ dir_name = dir_name.replace("_", "-")
44
+ return f"{dir_name}:dev", "auto"
44
45
 
45
46
 
46
47
  def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool = False) -> None:
@@ -39,7 +39,8 @@ class InteractiveMCPTester:
39
39
  """Connect to the MCP server."""
40
40
  try:
41
41
  # Create MCP config for HTTP transport
42
- config = {"server": {"url": self.server_url}}
42
+ # Note: We explicitly set auth to None to prevent OAuth discovery attempts
43
+ config = {"server": {"url": self.server_url, "auth": None}}
43
44
 
44
45
  self.client = MCPClient(
45
46
  mcp_config=config,
@@ -0,0 +1,204 @@
1
+ """Run local Python files as MCP servers with reload support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from hud.utils.hud_console import HUDConsole
14
+
15
+ hud_console = HUDConsole()
16
+
17
+
18
+ async def run_with_reload(
19
+ server_file: str,
20
+ transport: str = "stdio",
21
+ port: int | None = None,
22
+ verbose: bool = False,
23
+ extra_args: list[str] | None = None,
24
+ ) -> None:
25
+ """Run Python server with auto-reload functionality."""
26
+ try:
27
+ import watchfiles
28
+ except ImportError:
29
+ hud_console.error(
30
+ "watchfiles is required for --reload. Install with: pip install watchfiles"
31
+ )
32
+ sys.exit(1)
33
+
34
+ # Parse server file path
35
+ if ":" in server_file:
36
+ file_path, _ = server_file.split(":", 1)
37
+ else:
38
+ file_path = server_file
39
+
40
+ file_path = Path(file_path).resolve()
41
+ if not file_path.exists():
42
+ hud_console.error(f"Server file not found: {file_path}")
43
+ sys.exit(1)
44
+
45
+ # Watch the directory containing the server file (like uvicorn)
46
+ watch_dir = file_path.parent
47
+
48
+ # Build command
49
+ cmd = [sys.executable, "-m", "fastmcp.cli", "run", server_file]
50
+ if transport:
51
+ cmd.extend(["--transport", transport])
52
+ if port and transport == "http":
53
+ cmd.extend(["--port", str(port)])
54
+ cmd.append("--no-banner")
55
+ if verbose:
56
+ cmd.extend(["--log-level", "DEBUG"])
57
+ if extra_args:
58
+ cmd.append("--")
59
+ cmd.extend(extra_args)
60
+
61
+ # Filter for Python files and important config files
62
+ def should_reload(change: watchfiles.Change, path: str) -> bool:
63
+ path_obj = Path(path)
64
+ # Ignore common non-code files
65
+ if any(part.startswith(".") for part in path_obj.parts):
66
+ return False
67
+ if "__pycache__" in path_obj.parts:
68
+ return False
69
+ return path_obj.suffix in {".py", ".json", ".toml", ".yaml", ".yml"}
70
+
71
+ process = None
72
+
73
+ async def run_server() -> int:
74
+ """Run the server process."""
75
+ nonlocal process
76
+
77
+ # For stdio transport, we need special handling to preserve stdout
78
+ if transport == "stdio":
79
+ # All server logs must go to stderr
80
+ env = os.environ.copy()
81
+ env["PYTHONUNBUFFERED"] = "1"
82
+
83
+ process = await asyncio.create_subprocess_exec(
84
+ *cmd,
85
+ stdin=sys.stdin,
86
+ stdout=sys.stdout, # Direct passthrough for MCP
87
+ stderr=sys.stderr, # Logs and errors
88
+ env=env,
89
+ )
90
+ else:
91
+ # For HTTP transport, normal subprocess
92
+ process = await asyncio.create_subprocess_exec(
93
+ *cmd,
94
+ stdin=sys.stdin,
95
+ stdout=sys.stdout,
96
+ stderr=sys.stderr,
97
+ )
98
+
99
+ return await process.wait()
100
+
101
+ async def stop_server() -> None:
102
+ """Stop the server process gracefully."""
103
+ if process and process.returncode is None:
104
+ if sys.platform == "win32":
105
+ # Windows: terminate directly
106
+ process.terminate()
107
+ else:
108
+ # Unix: send SIGINT for hot reload
109
+ process.send_signal(signal.SIGINT)
110
+
111
+ # Wait for graceful shutdown
112
+ try:
113
+ await asyncio.wait_for(process.wait(), timeout=5.0)
114
+ except TimeoutError:
115
+ # Force kill if not responding
116
+ if verbose:
117
+ hud_console.warning("Server didn't stop gracefully, forcing shutdown...")
118
+ process.kill()
119
+ await process.wait()
120
+
121
+ # Initial server start
122
+ server_task = asyncio.create_task(run_server())
123
+
124
+ try:
125
+ # Watch for file changes
126
+ async for changes in watchfiles.awatch(watch_dir):
127
+ # Check if any change should trigger reload
128
+ if any(should_reload(change, path) for change, path in changes):
129
+ changed_files = [
130
+ path for _, path in changes if should_reload(watchfiles.Change.modified, path)
131
+ ]
132
+ if verbose:
133
+ for file in changed_files[:3]: # Show first 3 files
134
+ hud_console.info(f"File changed: {Path(file).relative_to(watch_dir)}")
135
+ if len(changed_files) > 3:
136
+ hud_console.info(f"... and {len(changed_files) - 3} more files")
137
+
138
+ hud_console.info("🔄 Reloading server...")
139
+
140
+ # Stop current server
141
+ await stop_server()
142
+
143
+ # Small delay to ensure clean restart
144
+ await asyncio.sleep(0.1)
145
+
146
+ # Start new server
147
+ server_task = asyncio.create_task(run_server())
148
+
149
+ except KeyboardInterrupt:
150
+ hud_console.info("\n👋 Shutting down...")
151
+ await stop_server()
152
+ except Exception as e:
153
+ if verbose:
154
+ hud_console.error(f"Reload error: {e}")
155
+ await stop_server()
156
+ raise
157
+ finally:
158
+ # Ensure server is stopped
159
+ if server_task and not server_task.done():
160
+ server_task.cancel()
161
+ with contextlib.suppress(asyncio.CancelledError):
162
+ await server_task
163
+
164
+
165
+ def run_local_server(
166
+ server_file: str,
167
+ transport: str = "stdio",
168
+ port: int | None = None,
169
+ verbose: bool = False,
170
+ reload: bool = False,
171
+ extra_args: list[str] | None = None,
172
+ ) -> None:
173
+ """Run a local Python file as an MCP server."""
174
+ if reload:
175
+ # Run with reload support
176
+ asyncio.run(
177
+ run_with_reload(
178
+ server_file,
179
+ transport=transport,
180
+ port=port,
181
+ verbose=verbose,
182
+ extra_args=extra_args,
183
+ )
184
+ )
185
+ else:
186
+ # Run directly without reload
187
+ cmd = [sys.executable, "-m", "fastmcp.cli", "run", server_file]
188
+ if transport:
189
+ cmd.extend(["--transport", transport])
190
+ if port and transport == "http":
191
+ cmd.extend(["--port", str(port)])
192
+ cmd.append("--no-banner")
193
+ if verbose:
194
+ cmd.extend(["--log-level", "DEBUG"])
195
+ if extra_args:
196
+ cmd.append("--")
197
+ cmd.extend(extra_args)
198
+
199
+ try:
200
+ result = subprocess.run(cmd) # noqa: S603
201
+ sys.exit(result.returncode)
202
+ except KeyboardInterrupt:
203
+ hud_console.info("\n👋 Shutting down...")
204
+ sys.exit(0)
hud/cli/utils/metadata.py CHANGED
@@ -161,7 +161,9 @@ async def analyze_from_metadata(reference: str, output_format: str, verbose: boo
161
161
  console.print(f" 1. Pull it first: [cyan]hud pull {reference}[/cyan]")
162
162
  console.print(f" 2. Run live analysis: [cyan]hud analyze {reference} --live[/cyan]")
163
163
  if not settings.api_key:
164
- console.print(" 3. Set HUD_API_KEY for private environments")
164
+ console.print(
165
+ " 3. Set HUD_API_KEY in your environment or run: hud set HUD_API_KEY=your-key-here"
166
+ )
165
167
  return
166
168
 
167
169
  # Convert lock data to analysis format