hud-python 0.3.4__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.
- hud/__init__.py +22 -89
- hud/agents/__init__.py +17 -0
- hud/agents/art.py +101 -0
- hud/agents/base.py +599 -0
- hud/{mcp → agents}/claude.py +373 -321
- hud/{mcp → agents}/langchain.py +250 -250
- hud/agents/misc/__init__.py +7 -0
- hud/{agent → agents}/misc/response_agent.py +80 -80
- hud/{mcp → agents}/openai.py +352 -334
- hud/agents/openai_chat_generic.py +154 -0
- hud/{mcp → agents}/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -0
- hud/agents/tests/test_claude.py +324 -0
- hud/{mcp → agents}/tests/test_client.py +363 -324
- hud/{mcp → agents}/tests/test_openai.py +237 -238
- hud/cli/__init__.py +617 -0
- hud/cli/__main__.py +8 -0
- hud/cli/analyze.py +371 -0
- hud/cli/analyze_metadata.py +230 -0
- hud/cli/build.py +427 -0
- hud/cli/clone.py +185 -0
- hud/cli/cursor.py +92 -0
- hud/cli/debug.py +392 -0
- hud/cli/docker_utils.py +83 -0
- hud/cli/init.py +281 -0
- hud/cli/interactive.py +353 -0
- hud/cli/mcp_server.py +756 -0
- hud/cli/pull.py +336 -0
- hud/cli/push.py +379 -0
- hud/cli/remote_runner.py +311 -0
- hud/cli/runner.py +160 -0
- hud/cli/tests/__init__.py +3 -0
- hud/cli/tests/test_analyze.py +284 -0
- hud/cli/tests/test_cli_init.py +265 -0
- hud/cli/tests/test_cli_main.py +27 -0
- hud/cli/tests/test_clone.py +142 -0
- hud/cli/tests/test_cursor.py +253 -0
- hud/cli/tests/test_debug.py +453 -0
- hud/cli/tests/test_mcp_server.py +139 -0
- hud/cli/tests/test_utils.py +388 -0
- hud/cli/utils.py +263 -0
- hud/clients/README.md +143 -0
- hud/clients/__init__.py +16 -0
- hud/clients/base.py +354 -0
- hud/clients/fastmcp.py +202 -0
- hud/clients/mcp_use.py +278 -0
- hud/clients/tests/__init__.py +1 -0
- hud/clients/tests/test_client_integration.py +111 -0
- hud/clients/tests/test_fastmcp.py +342 -0
- hud/clients/tests/test_protocol.py +188 -0
- hud/clients/utils/__init__.py +1 -0
- hud/clients/utils/retry_transport.py +160 -0
- hud/datasets.py +322 -192
- hud/misc/__init__.py +1 -0
- hud/{agent → misc}/claude_plays_pokemon.py +292 -283
- hud/otel/__init__.py +35 -0
- hud/otel/collector.py +142 -0
- hud/otel/config.py +164 -0
- hud/otel/context.py +536 -0
- hud/otel/exporters.py +366 -0
- hud/otel/instrumentation.py +97 -0
- hud/otel/processors.py +118 -0
- hud/otel/tests/__init__.py +1 -0
- hud/otel/tests/test_processors.py +197 -0
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -0
- hud/server/helper/__init__.py +5 -0
- hud/server/low_level.py +132 -0
- hud/server/server.py +166 -0
- hud/server/tests/__init__.py +3 -0
- hud/settings.py +73 -79
- hud/shared/__init__.py +5 -0
- hud/{exceptions.py → shared/exceptions.py} +180 -180
- hud/{server → shared}/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -0
- hud/{server → shared}/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -30
- hud/telemetry/instrument.py +379 -0
- hud/telemetry/job.py +309 -141
- hud/telemetry/replay.py +74 -0
- hud/telemetry/trace.py +83 -0
- hud/tools/__init__.py +33 -34
- hud/tools/base.py +365 -65
- hud/tools/bash.py +161 -137
- hud/tools/computer/__init__.py +15 -13
- hud/tools/computer/anthropic.py +437 -414
- hud/tools/computer/hud.py +376 -328
- hud/tools/computer/openai.py +295 -286
- hud/tools/computer/settings.py +82 -0
- hud/tools/edit.py +314 -290
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -532
- hud/tools/executors/pyautogui.py +621 -619
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -503
- hud/tools/{playwright_tool.py → playwright.py} +412 -379
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -0
- hud/tools/tests/test_bash.py +158 -152
- hud/tools/tests/test_bash_extended.py +197 -0
- hud/tools/tests/test_computer.py +425 -52
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -240
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -157
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -0
- hud/tools/utils.py +50 -50
- hud/types.py +136 -89
- hud/utils/__init__.py +10 -16
- hud/utils/async_utils.py +65 -0
- hud/utils/design.py +168 -0
- hud/utils/mcp.py +55 -0
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -0
- hud/utils/tests/test_init.py +17 -21
- hud/utils/tests/test_progress.py +261 -225
- hud/utils/tests/test_telemetry.py +82 -37
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- hud_python-0.4.0.dist-info/METADATA +474 -0
- hud_python-0.4.0.dist-info/RECORD +132 -0
- hud_python-0.4.0.dist-info/entry_points.txt +3 -0
- {hud_python-0.3.4.dist-info → hud_python-0.4.0.dist-info}/licenses/LICENSE +21 -21
- hud/adapters/__init__.py +0 -8
- hud/adapters/claude/__init__.py +0 -5
- hud/adapters/claude/adapter.py +0 -180
- hud/adapters/claude/tests/__init__.py +0 -1
- hud/adapters/claude/tests/test_adapter.py +0 -519
- hud/adapters/common/__init__.py +0 -6
- hud/adapters/common/adapter.py +0 -178
- hud/adapters/common/tests/test_adapter.py +0 -289
- hud/adapters/common/types.py +0 -446
- hud/adapters/operator/__init__.py +0 -5
- hud/adapters/operator/adapter.py +0 -108
- hud/adapters/operator/tests/__init__.py +0 -1
- hud/adapters/operator/tests/test_adapter.py +0 -370
- hud/agent/__init__.py +0 -19
- hud/agent/base.py +0 -126
- hud/agent/claude.py +0 -271
- hud/agent/langchain.py +0 -215
- hud/agent/misc/__init__.py +0 -3
- hud/agent/operator.py +0 -268
- hud/agent/tests/__init__.py +0 -1
- hud/agent/tests/test_base.py +0 -202
- hud/env/__init__.py +0 -11
- hud/env/client.py +0 -35
- hud/env/docker_client.py +0 -349
- hud/env/environment.py +0 -446
- hud/env/local_docker_client.py +0 -358
- hud/env/remote_client.py +0 -212
- hud/env/remote_docker_client.py +0 -292
- hud/gym.py +0 -130
- hud/job.py +0 -773
- hud/mcp/__init__.py +0 -17
- hud/mcp/base.py +0 -631
- hud/mcp/client.py +0 -312
- hud/mcp/tests/test_base.py +0 -512
- hud/mcp/tests/test_claude.py +0 -294
- hud/task.py +0 -149
- hud/taskset.py +0 -237
- hud/telemetry/_trace.py +0 -347
- hud/telemetry/context.py +0 -230
- hud/telemetry/exporter.py +0 -575
- hud/telemetry/instrumentation/__init__.py +0 -3
- hud/telemetry/instrumentation/mcp.py +0 -259
- hud/telemetry/instrumentation/registry.py +0 -59
- hud/telemetry/mcp_models.py +0 -270
- hud/telemetry/tests/__init__.py +0 -1
- hud/telemetry/tests/test_context.py +0 -210
- hud/telemetry/tests/test_trace.py +0 -312
- hud/tools/helper/README.md +0 -56
- hud/tools/helper/__init__.py +0 -9
- hud/tools/helper/mcp_server.py +0 -78
- hud/tools/helper/server_initialization.py +0 -115
- hud/tools/helper/utils.py +0 -58
- hud/trajectory.py +0 -94
- hud/utils/agent.py +0 -37
- hud/utils/common.py +0 -256
- hud/utils/config.py +0 -120
- hud/utils/deprecation.py +0 -115
- hud/utils/misc.py +0 -53
- hud/utils/tests/test_common.py +0 -277
- hud/utils/tests/test_config.py +0 -129
- hud_python-0.3.4.dist-info/METADATA +0 -284
- hud_python-0.3.4.dist-info/RECORD +0 -120
- /hud/{adapters/common → shared}/tests/__init__.py +0 -0
- {hud_python-0.3.4.dist-info → hud_python-0.4.0.dist-info}/WHEEL +0 -0
hud/cli/push.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Push HUD environments to registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import requests
|
|
11
|
+
import typer
|
|
12
|
+
import yaml
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from hud.settings import settings
|
|
16
|
+
from hud.utils.design import HUDDesign
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_docker_username() -> str | None:
|
|
22
|
+
"""Get the current Docker username if logged in."""
|
|
23
|
+
try:
|
|
24
|
+
# Docker config locations
|
|
25
|
+
config_paths = [
|
|
26
|
+
Path.home() / ".docker" / "config.json",
|
|
27
|
+
Path.home() / ".docker" / "plaintext-credentials.json", # Alternative location
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
for config_path in config_paths:
|
|
31
|
+
if config_path.exists():
|
|
32
|
+
try:
|
|
33
|
+
with open(config_path) as f:
|
|
34
|
+
config = json.load(f)
|
|
35
|
+
|
|
36
|
+
# Look for auth entries
|
|
37
|
+
auths = config.get("auths", {})
|
|
38
|
+
for registry_url, auth_info in auths.items():
|
|
39
|
+
if (
|
|
40
|
+
any(
|
|
41
|
+
hub in registry_url
|
|
42
|
+
for hub in ["docker.io", "index.docker.io", "registry-1.docker.io"]
|
|
43
|
+
)
|
|
44
|
+
and "auth" in auth_info
|
|
45
|
+
):
|
|
46
|
+
import base64
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
decoded = base64.b64decode(auth_info["auth"]).decode()
|
|
50
|
+
username = decoded.split(":", 1)[0]
|
|
51
|
+
if username and username != "token": # Skip token-based auth
|
|
52
|
+
return username
|
|
53
|
+
except Exception:
|
|
54
|
+
click.echo("Failed to decode auth info", err=True)
|
|
55
|
+
except Exception:
|
|
56
|
+
click.echo("Failed to get Docker username", err=True)
|
|
57
|
+
|
|
58
|
+
# Alternative: Check credsStore/credHelpers
|
|
59
|
+
for config_path in config_paths:
|
|
60
|
+
if config_path.exists():
|
|
61
|
+
try:
|
|
62
|
+
with open(config_path) as f:
|
|
63
|
+
config = json.load(f)
|
|
64
|
+
|
|
65
|
+
# Check if using credential helpers
|
|
66
|
+
if "credsStore" in config:
|
|
67
|
+
# Try to get credentials from helper
|
|
68
|
+
helper = config["credsStore"]
|
|
69
|
+
try:
|
|
70
|
+
result = subprocess.run( # noqa: S603
|
|
71
|
+
[f"docker-credential-{helper}", "list"],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
)
|
|
75
|
+
if result.returncode == 0:
|
|
76
|
+
creds = json.loads(result.stdout)
|
|
77
|
+
for url in creds:
|
|
78
|
+
if "docker.io" in url:
|
|
79
|
+
# Try to get the username
|
|
80
|
+
get_result = subprocess.run( # noqa: S603
|
|
81
|
+
[f"docker-credential-{helper}", "get"],
|
|
82
|
+
input=url,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
)
|
|
86
|
+
if get_result.returncode == 0:
|
|
87
|
+
cred_data = json.loads(get_result.stdout)
|
|
88
|
+
username = cred_data.get("Username", "")
|
|
89
|
+
if username and username != "token":
|
|
90
|
+
return username
|
|
91
|
+
except Exception:
|
|
92
|
+
click.echo("Failed to get Docker username", err=True)
|
|
93
|
+
except Exception:
|
|
94
|
+
click.echo("Failed to get Docker username", err=True)
|
|
95
|
+
except Exception:
|
|
96
|
+
click.echo("Failed to get Docker username", err=True)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_docker_image_labels(image: str) -> dict:
|
|
101
|
+
"""Get labels from a Docker image."""
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run( # noqa: S603
|
|
104
|
+
["docker", "inspect", "--format", "{{json .Config.Labels}}", image], # noqa: S607
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
check=True,
|
|
108
|
+
)
|
|
109
|
+
return json.loads(result.stdout.strip()) or {}
|
|
110
|
+
except Exception:
|
|
111
|
+
click.echo("Failed to get Docker image labels", err=True)
|
|
112
|
+
return {}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def push_environment(
|
|
116
|
+
directory: str = ".",
|
|
117
|
+
image: str | None = None,
|
|
118
|
+
tag: str | None = None,
|
|
119
|
+
sign: bool = False,
|
|
120
|
+
yes: bool = False,
|
|
121
|
+
verbose: bool = False,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Push HUD environment to registry."""
|
|
124
|
+
design = HUDDesign()
|
|
125
|
+
design.header("HUD Environment Push")
|
|
126
|
+
|
|
127
|
+
# Find hud.lock.yaml in specified directory
|
|
128
|
+
env_dir = Path(directory)
|
|
129
|
+
lock_path = env_dir / "hud.lock.yaml"
|
|
130
|
+
|
|
131
|
+
if not lock_path.exists():
|
|
132
|
+
design.error(f"No hud.lock.yaml found in {directory}")
|
|
133
|
+
design.info("Run 'hud build' first to generate a lock file")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
# Check for API key first
|
|
137
|
+
if not settings.api_key:
|
|
138
|
+
design.error("No HUD API key found")
|
|
139
|
+
console.print("\n[yellow]A HUD API key is required to push environments.[/yellow]")
|
|
140
|
+
console.print("\nTo get started:")
|
|
141
|
+
console.print(" 1. Get your API key at: [link]https://hud.so/settings[/link]")
|
|
142
|
+
console.print(" 2. Set it: [cyan]export HUD_API_KEY=your-key-here[/cyan]")
|
|
143
|
+
console.print(" 3. Try again: [cyan]hud push[/cyan]\n")
|
|
144
|
+
raise typer.Exit(1)
|
|
145
|
+
|
|
146
|
+
# Load lock file
|
|
147
|
+
with open(lock_path) as f:
|
|
148
|
+
lock_data = yaml.safe_load(f)
|
|
149
|
+
|
|
150
|
+
# Handle both old and new lock file formats
|
|
151
|
+
local_image = lock_data.get("image", "")
|
|
152
|
+
if not local_image and "build" in lock_data:
|
|
153
|
+
# New format might have image elsewhere
|
|
154
|
+
local_image = lock_data.get("image", "")
|
|
155
|
+
|
|
156
|
+
# If no image specified, try to be smart
|
|
157
|
+
if not image:
|
|
158
|
+
# Check if user is logged in
|
|
159
|
+
username = get_docker_username()
|
|
160
|
+
if username:
|
|
161
|
+
# Extract image name from lock file (handle @sha256:... format)
|
|
162
|
+
base_image = local_image.split("@")[0] if "@" in local_image else local_image
|
|
163
|
+
|
|
164
|
+
if ":" in base_image:
|
|
165
|
+
base_name = base_image.split(":")[0]
|
|
166
|
+
current_tag = base_image.split(":")[1]
|
|
167
|
+
else:
|
|
168
|
+
base_name = base_image
|
|
169
|
+
current_tag = "latest"
|
|
170
|
+
|
|
171
|
+
# Remove any existing registry prefix
|
|
172
|
+
if "/" in base_name:
|
|
173
|
+
base_name = base_name.split("/")[-1]
|
|
174
|
+
|
|
175
|
+
# Use provided tag or default
|
|
176
|
+
final_tag = tag if tag else current_tag
|
|
177
|
+
|
|
178
|
+
# Suggest a registry image
|
|
179
|
+
image = f"{username}/{base_name}:{final_tag}"
|
|
180
|
+
design.info(f"Auto-detected Docker username: {username}")
|
|
181
|
+
if tag:
|
|
182
|
+
design.info(f"Using specified tag: {tag}")
|
|
183
|
+
design.info(f"Will push to: {image}")
|
|
184
|
+
|
|
185
|
+
if not yes and not typer.confirm(f"\nPush to {image}?"):
|
|
186
|
+
design.info("Aborted.")
|
|
187
|
+
raise typer.Exit(0)
|
|
188
|
+
else:
|
|
189
|
+
design.error(
|
|
190
|
+
"Not logged in to Docker Hub. Please specify --image or run 'docker login'"
|
|
191
|
+
)
|
|
192
|
+
raise typer.Exit(1)
|
|
193
|
+
elif tag:
|
|
194
|
+
# Handle tag when image is provided
|
|
195
|
+
if ":" in image:
|
|
196
|
+
# Image already has a tag
|
|
197
|
+
existing_tag = image.split(":")[-1]
|
|
198
|
+
if existing_tag != tag:
|
|
199
|
+
design.warning(f"Image already has tag '{existing_tag}', overriding with '{tag}'")
|
|
200
|
+
image = image.rsplit(":", 1)[0] + f":{tag}"
|
|
201
|
+
# else: tags match, no action needed
|
|
202
|
+
else:
|
|
203
|
+
# Image has no tag, append the specified one
|
|
204
|
+
image = f"{image}:{tag}"
|
|
205
|
+
design.info(f"Using specified tag: {tag}")
|
|
206
|
+
design.info(f"Will push to: {image}")
|
|
207
|
+
|
|
208
|
+
# Verify local image exists
|
|
209
|
+
# Extract the tag part (before @sha256:...) for Docker operations
|
|
210
|
+
local_tag = local_image.split("@")[0] if "@" in local_image else local_image
|
|
211
|
+
|
|
212
|
+
# Verify the image exists locally
|
|
213
|
+
try:
|
|
214
|
+
subprocess.run(["docker", "inspect", local_tag], capture_output=True, check=True) # noqa: S603, S607
|
|
215
|
+
except subprocess.CalledProcessError:
|
|
216
|
+
design.error(f"Local image not found: {local_tag}")
|
|
217
|
+
design.info("Run 'hud build' first to create the image")
|
|
218
|
+
raise typer.Exit(1) # noqa: B904
|
|
219
|
+
|
|
220
|
+
# Check if local image has the expected label
|
|
221
|
+
labels = get_docker_image_labels(local_tag)
|
|
222
|
+
expected_label = labels.get("org.hud.manifest.head", "")
|
|
223
|
+
|
|
224
|
+
# Skip hash verification - the lock file may have been updated with digest after build
|
|
225
|
+
if verbose and expected_label:
|
|
226
|
+
design.info(f"Image label: {expected_label[:12]}...")
|
|
227
|
+
|
|
228
|
+
# Tag the image for push
|
|
229
|
+
design.progress_message(f"Tagging {local_tag} as {image}")
|
|
230
|
+
subprocess.run(["docker", "tag", local_tag, image], check=True) # noqa: S603, S607
|
|
231
|
+
|
|
232
|
+
# Push the image
|
|
233
|
+
design.progress_message(f"Pushing {image} to registry...")
|
|
234
|
+
|
|
235
|
+
# Show push output
|
|
236
|
+
process = subprocess.Popen( # noqa: S603
|
|
237
|
+
["docker", "push", image], # noqa: S607
|
|
238
|
+
stdout=subprocess.PIPE,
|
|
239
|
+
stderr=subprocess.STDOUT,
|
|
240
|
+
text=True,
|
|
241
|
+
encoding="utf-8",
|
|
242
|
+
errors="replace",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
for line in process.stdout or []:
|
|
246
|
+
click.echo(line.rstrip(), err=True)
|
|
247
|
+
|
|
248
|
+
process.wait()
|
|
249
|
+
|
|
250
|
+
if process.returncode != 0:
|
|
251
|
+
design.error("Push failed")
|
|
252
|
+
raise typer.Exit(1)
|
|
253
|
+
|
|
254
|
+
# Get the digest of the pushed image
|
|
255
|
+
result = subprocess.run( # noqa: S603
|
|
256
|
+
["docker", "inspect", "--format", "{{index .RepoDigests 0}}", image], # noqa: S607
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
262
|
+
pushed_digest = result.stdout.strip()
|
|
263
|
+
else:
|
|
264
|
+
pushed_digest = image
|
|
265
|
+
|
|
266
|
+
# Success!
|
|
267
|
+
design.success("Push complete!")
|
|
268
|
+
|
|
269
|
+
# Show the final image reference
|
|
270
|
+
console.print("\n[bold green]✓ Pushed image:[/bold green]")
|
|
271
|
+
console.print(f" [bold cyan]{pushed_digest}[/bold cyan]\n")
|
|
272
|
+
|
|
273
|
+
# Update the lock file with registry information
|
|
274
|
+
lock_data["image"] = pushed_digest
|
|
275
|
+
|
|
276
|
+
# Add push information
|
|
277
|
+
from datetime import datetime
|
|
278
|
+
|
|
279
|
+
lock_data["push"] = {
|
|
280
|
+
"source": local_image,
|
|
281
|
+
"pushedAt": datetime.utcnow().isoformat() + "Z",
|
|
282
|
+
"registry": pushed_digest.split("/")[0] if "/" in pushed_digest else "docker.io",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# Save updated lock file
|
|
286
|
+
with open(lock_path, "w") as f:
|
|
287
|
+
yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
|
|
288
|
+
|
|
289
|
+
console.print("[green]✓[/green] Updated lock file with registry image")
|
|
290
|
+
|
|
291
|
+
# Upload lock file to HUD registry
|
|
292
|
+
try:
|
|
293
|
+
# Extract org/name:tag from the pushed image
|
|
294
|
+
# e.g., "docker.io/hudpython/test_init:latest@sha256:..." -> "hudpython/test_init:latest"
|
|
295
|
+
# e.g., "hudpython/test_init:v1.0" -> "hudpython/test_init:v1.0"
|
|
296
|
+
registry_parts = pushed_digest.split("/")
|
|
297
|
+
if len(registry_parts) >= 2:
|
|
298
|
+
# Handle docker.io/org/name or just org/name
|
|
299
|
+
if registry_parts[0] in ["docker.io", "registry-1.docker.io", "index.docker.io"]:
|
|
300
|
+
# Remove registry prefix and get org/name:tag
|
|
301
|
+
name_with_tag = "/".join(registry_parts[1:]).split("@")[0]
|
|
302
|
+
else:
|
|
303
|
+
# Just org/name:tag
|
|
304
|
+
name_with_tag = "/".join(registry_parts[:2]).split("@")[0]
|
|
305
|
+
|
|
306
|
+
# If no tag specified, use "latest"
|
|
307
|
+
if ":" not in name_with_tag:
|
|
308
|
+
name_with_tag = f"{name_with_tag}:latest"
|
|
309
|
+
|
|
310
|
+
# Upload to HUD registry
|
|
311
|
+
design.progress_message("Uploading metadata to HUD registry...")
|
|
312
|
+
|
|
313
|
+
registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{name_with_tag}"
|
|
314
|
+
|
|
315
|
+
# Prepare the payload
|
|
316
|
+
payload = {
|
|
317
|
+
"lock": yaml.dump(lock_data, default_flow_style=False, sort_keys=False),
|
|
318
|
+
"digest": pushed_digest.split("@")[-1] if "@" in pushed_digest else "latest",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
headers = {"Authorization": f"Bearer {settings.api_key}"}
|
|
322
|
+
|
|
323
|
+
response = requests.post(registry_url, json=payload, headers=headers, timeout=10)
|
|
324
|
+
|
|
325
|
+
if response.status_code in [200, 201]:
|
|
326
|
+
design.success("Metadata uploaded to HUD registry")
|
|
327
|
+
console.print(
|
|
328
|
+
f" Others can now pull with: [cyan]hud pull {name_with_tag}[/cyan]\n"
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
design.warning(f"Could not upload to registry: {response.status_code}")
|
|
332
|
+
if verbose:
|
|
333
|
+
design.info(f"Response: {response.text}")
|
|
334
|
+
console.print(" Share [cyan]hud.lock.yaml[/cyan] manually\n")
|
|
335
|
+
else:
|
|
336
|
+
if verbose:
|
|
337
|
+
design.info("Could not parse registry path for upload")
|
|
338
|
+
console.print(
|
|
339
|
+
" Share [cyan]hud.lock.yaml[/cyan] to let others reproduce your exact environment\n" # noqa: E501
|
|
340
|
+
)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
design.warning(f"Registry upload failed: {e}")
|
|
343
|
+
console.print(" Share [cyan]hud.lock.yaml[/cyan] manually\n")
|
|
344
|
+
|
|
345
|
+
# Show usage examples
|
|
346
|
+
design.section_title("What's Next?")
|
|
347
|
+
|
|
348
|
+
console.print("Test locally:")
|
|
349
|
+
console.print(f" [cyan]hud run {image}[/cyan]\n")
|
|
350
|
+
|
|
351
|
+
console.print("Use in MCP configs:")
|
|
352
|
+
console.print(" Claude Desktop:")
|
|
353
|
+
console.print(
|
|
354
|
+
f' [cyan]{{"docker": {{"image": "{pushed_digest}", "command": "auto"}}}}[/cyan]\n'
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
console.print(" Via HUD (recommended):")
|
|
358
|
+
console.print(f' [cyan]{{"hud": {{"registry": "{pushed_digest}"}}}}[/cyan]\n')
|
|
359
|
+
|
|
360
|
+
console.print("Share environment:")
|
|
361
|
+
console.print(
|
|
362
|
+
" Share the updated [cyan]hud.lock.yaml[/cyan] for others to reproduce your exact environment" # noqa: E501
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# TODO: Upload lock file to HUD registry
|
|
366
|
+
if sign:
|
|
367
|
+
design.warning("Signing not yet implemented")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def push_command(
|
|
371
|
+
directory: str = ".",
|
|
372
|
+
image: str | None = None,
|
|
373
|
+
tag: str | None = None,
|
|
374
|
+
sign: bool = False,
|
|
375
|
+
yes: bool = False,
|
|
376
|
+
verbose: bool = False,
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Push HUD environment to registry."""
|
|
379
|
+
push_environment(directory, image, tag, sign, yes, verbose)
|
hud/cli/remote_runner.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Remote runner for HUD MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from hud.settings import settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_headers(header_args: list[str]) -> dict[str, str]:
|
|
16
|
+
"""Parse header arguments into a dictionary.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
header_args: List of header strings in format "Key:Value" or "Key=Value"
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Dictionary of headers
|
|
23
|
+
"""
|
|
24
|
+
headers = {}
|
|
25
|
+
for header in header_args:
|
|
26
|
+
if ":" in header:
|
|
27
|
+
key, value = header.split(":", 1)
|
|
28
|
+
elif "=" in header:
|
|
29
|
+
key, value = header.split("=", 1)
|
|
30
|
+
else:
|
|
31
|
+
click.echo(f"⚠️ Invalid header format: {header} (use Key:Value or Key=Value)")
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
headers[key.strip()] = value.strip()
|
|
35
|
+
|
|
36
|
+
return headers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_env_vars(env_args: list[str]) -> dict[str, str]:
|
|
40
|
+
"""Parse environment variable arguments into headers.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
env_args: List of env var strings in format "KEY=VALUE"
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Dictionary of headers with Env- prefix
|
|
47
|
+
"""
|
|
48
|
+
env_headers = {}
|
|
49
|
+
for env in env_args:
|
|
50
|
+
if "=" not in env:
|
|
51
|
+
click.echo(f"⚠️ Invalid env format: {env} (use KEY=VALUE)")
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
key, value = env.split("=", 1)
|
|
55
|
+
# Convert KEY_NAME to Env-Key-Name header format
|
|
56
|
+
# e.g., API_KEY=xxx becomes Env-Api-Key: xxx
|
|
57
|
+
# e.g., OPENAI_API_KEY=xxx becomes Env-Openai-Api-Key: xxx
|
|
58
|
+
header_parts = key.split("_")
|
|
59
|
+
header_key = f"Env-{'-'.join(part.capitalize() for part in header_parts)}"
|
|
60
|
+
env_headers[header_key] = value
|
|
61
|
+
|
|
62
|
+
return env_headers
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_remote_headers(
|
|
66
|
+
image: str,
|
|
67
|
+
env_args: list[str],
|
|
68
|
+
header_args: list[str],
|
|
69
|
+
api_key: str | None = None,
|
|
70
|
+
run_id: str | None = None,
|
|
71
|
+
) -> dict[str, str]:
|
|
72
|
+
"""Build headers for remote MCP server.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
image: Docker image name
|
|
76
|
+
env_args: Environment variable arguments
|
|
77
|
+
header_args: Additional header arguments
|
|
78
|
+
api_key: API key (from env or arg)
|
|
79
|
+
run_id: Run ID (optional)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Complete headers dictionary
|
|
83
|
+
"""
|
|
84
|
+
headers = {}
|
|
85
|
+
|
|
86
|
+
# Required headers
|
|
87
|
+
headers["Mcp-Image"] = image
|
|
88
|
+
|
|
89
|
+
# API key
|
|
90
|
+
if api_key:
|
|
91
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
92
|
+
|
|
93
|
+
# Run ID if provided
|
|
94
|
+
if run_id:
|
|
95
|
+
headers["Run-Id"] = run_id
|
|
96
|
+
|
|
97
|
+
# Environment variables as headers
|
|
98
|
+
env_headers = parse_env_vars(env_args)
|
|
99
|
+
headers.update(env_headers)
|
|
100
|
+
|
|
101
|
+
# Additional headers
|
|
102
|
+
extra_headers = parse_headers(header_args)
|
|
103
|
+
headers.update(extra_headers)
|
|
104
|
+
|
|
105
|
+
return headers
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_remote_stdio(
|
|
109
|
+
url: str,
|
|
110
|
+
headers: dict[str, str],
|
|
111
|
+
verbose: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Run remote MCP server with stdio transport."""
|
|
114
|
+
# CRITICAL: Configure ALL output to go to stderr to keep stdout clean for MCP protocol
|
|
115
|
+
import logging
|
|
116
|
+
import warnings
|
|
117
|
+
|
|
118
|
+
# Force all output to stderr
|
|
119
|
+
sys.stdout = sys.stderr
|
|
120
|
+
|
|
121
|
+
# Always disable FastMCP banner for stdio
|
|
122
|
+
os.environ["FASTMCP_DISABLE_BANNER"] = "1"
|
|
123
|
+
|
|
124
|
+
# Configure root logger to use stderr
|
|
125
|
+
root_logger = logging.getLogger()
|
|
126
|
+
root_logger.handlers.clear()
|
|
127
|
+
|
|
128
|
+
if not verbose:
|
|
129
|
+
# Suppress all logs and warnings for clean stdio
|
|
130
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
131
|
+
stderr_handler.setLevel(logging.CRITICAL)
|
|
132
|
+
root_logger.addHandler(stderr_handler)
|
|
133
|
+
root_logger.setLevel(logging.CRITICAL)
|
|
134
|
+
|
|
135
|
+
# Set all known loggers to CRITICAL
|
|
136
|
+
for logger_name in ["fastmcp", "mcp", "httpx", "httpcore", "anyio", "asyncio", "uvicorn"]:
|
|
137
|
+
logging.getLogger(logger_name).setLevel(logging.CRITICAL)
|
|
138
|
+
|
|
139
|
+
# Suppress warnings
|
|
140
|
+
warnings.filterwarnings("ignore")
|
|
141
|
+
else:
|
|
142
|
+
# Only show important logs to stderr
|
|
143
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
144
|
+
stderr_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
145
|
+
root_logger.addHandler(stderr_handler)
|
|
146
|
+
root_logger.setLevel(logging.INFO)
|
|
147
|
+
|
|
148
|
+
async def run() -> None:
|
|
149
|
+
# Save the real stdout before we redirected it
|
|
150
|
+
real_stdout = sys.__stdout__
|
|
151
|
+
|
|
152
|
+
if verbose:
|
|
153
|
+
click.echo(f"🔗 Connecting to: {url}", err=True)
|
|
154
|
+
click.echo(f"📦 Image: {headers.get('Mcp-Image', 'unknown')}", err=True)
|
|
155
|
+
click.echo(f"🔑 Headers: {list(headers.keys())}", err=True)
|
|
156
|
+
|
|
157
|
+
# Create proxy configuration
|
|
158
|
+
proxy_config = {
|
|
159
|
+
"mcpServers": {
|
|
160
|
+
"remote": {"transport": "streamable-http", "url": url, "headers": headers}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
# Restore stdout for the proxy to use
|
|
166
|
+
sys.stdout = real_stdout
|
|
167
|
+
|
|
168
|
+
# Create proxy that forwards remote HTTP to local stdio
|
|
169
|
+
proxy = FastMCP.as_proxy(proxy_config, name="HUD Remote Proxy")
|
|
170
|
+
|
|
171
|
+
# Run with stdio transport - this will handle stdin/stdout properly
|
|
172
|
+
await proxy.run_async(transport="stdio", show_banner=False)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
# Ensure errors go to stderr
|
|
175
|
+
sys.stdout = sys.stderr
|
|
176
|
+
if verbose:
|
|
177
|
+
import traceback
|
|
178
|
+
|
|
179
|
+
click.echo(f"❌ Proxy error: {e}", err=True)
|
|
180
|
+
click.echo(traceback.format_exc(), err=True)
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
asyncio.run(run())
|
|
185
|
+
except KeyboardInterrupt:
|
|
186
|
+
if verbose:
|
|
187
|
+
click.echo("\n✅ Remote proxy stopped", err=True)
|
|
188
|
+
sys.exit(0)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
if verbose:
|
|
191
|
+
click.echo(f"❌ Error: {e}", err=True)
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def run_remote_http(
|
|
196
|
+
url: str,
|
|
197
|
+
headers: dict[str, str],
|
|
198
|
+
port: int,
|
|
199
|
+
verbose: bool = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Run remote MCP server with HTTP transport."""
|
|
202
|
+
from .utils import find_free_port
|
|
203
|
+
|
|
204
|
+
# Find available port
|
|
205
|
+
actual_port = find_free_port(port)
|
|
206
|
+
if actual_port is None:
|
|
207
|
+
click.echo(f"❌ No available ports found starting from {port}")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if actual_port != port:
|
|
211
|
+
click.echo(f"⚠️ Port {port} in use, using port {actual_port} instead")
|
|
212
|
+
|
|
213
|
+
# Suppress logs unless verbose
|
|
214
|
+
if not verbose:
|
|
215
|
+
import logging
|
|
216
|
+
import os
|
|
217
|
+
|
|
218
|
+
os.environ["FASTMCP_DISABLE_BANNER"] = "1"
|
|
219
|
+
logging.getLogger("fastmcp").setLevel(logging.ERROR)
|
|
220
|
+
logging.getLogger("mcp").setLevel(logging.ERROR)
|
|
221
|
+
logging.getLogger("uvicorn").setLevel(logging.ERROR)
|
|
222
|
+
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
|
|
223
|
+
logging.getLogger("uvicorn.error").setLevel(logging.ERROR)
|
|
224
|
+
logging.getLogger("httpx").setLevel(logging.ERROR)
|
|
225
|
+
logging.getLogger("httpcore").setLevel(logging.ERROR)
|
|
226
|
+
|
|
227
|
+
# Create the MCP config for the proxy
|
|
228
|
+
config = {"remote": {"transport": "streamable-http", "url": url, "headers": headers}}
|
|
229
|
+
|
|
230
|
+
# Create proxy that forwards remote HTTP to local HTTP
|
|
231
|
+
proxy = FastMCP.as_proxy(config, name="HUD Remote Proxy")
|
|
232
|
+
|
|
233
|
+
click.echo(f"🌐 Starting HTTP proxy on port {actual_port}")
|
|
234
|
+
click.echo(f"🔗 Server URL: http://localhost:{actual_port}/mcp")
|
|
235
|
+
click.echo(f"☁️ Proxying to: {url}")
|
|
236
|
+
click.echo("⏹️ Press Ctrl+C to stop")
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Run with HTTP transport
|
|
240
|
+
await proxy.run_async(
|
|
241
|
+
transport="http",
|
|
242
|
+
host="0.0.0.0", # noqa: S104
|
|
243
|
+
port=actual_port,
|
|
244
|
+
path="/mcp",
|
|
245
|
+
log_level="error" if not verbose else "info",
|
|
246
|
+
show_banner=False,
|
|
247
|
+
)
|
|
248
|
+
except KeyboardInterrupt:
|
|
249
|
+
click.echo("\n👋 Shutting down...")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_remote_server(
|
|
253
|
+
image: str,
|
|
254
|
+
docker_args: list[str],
|
|
255
|
+
transport: str,
|
|
256
|
+
port: int,
|
|
257
|
+
url: str,
|
|
258
|
+
api_key: str | None,
|
|
259
|
+
run_id: str | None,
|
|
260
|
+
verbose: bool,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Run remote MCP server via proxy.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
image: Docker image name
|
|
266
|
+
docker_args: Docker-style arguments (-e, -h)
|
|
267
|
+
transport: Output transport (stdio or http)
|
|
268
|
+
port: Port for HTTP transport
|
|
269
|
+
url: Remote MCP server URL
|
|
270
|
+
api_key: API key for authentication
|
|
271
|
+
run_id: Optional run ID
|
|
272
|
+
verbose: Show detailed logs
|
|
273
|
+
"""
|
|
274
|
+
# Parse docker args into env vars and headers
|
|
275
|
+
env_args = []
|
|
276
|
+
header_args = []
|
|
277
|
+
|
|
278
|
+
i = 0
|
|
279
|
+
while i < len(docker_args):
|
|
280
|
+
arg = docker_args[i]
|
|
281
|
+
|
|
282
|
+
if arg == "-e" and i + 1 < len(docker_args):
|
|
283
|
+
env_args.append(docker_args[i + 1])
|
|
284
|
+
i += 2
|
|
285
|
+
elif arg == "-h" and i + 1 < len(docker_args):
|
|
286
|
+
header_args.append(docker_args[i + 1])
|
|
287
|
+
i += 2
|
|
288
|
+
else:
|
|
289
|
+
click.echo(f"⚠️ Unknown argument: {arg}", err=True)
|
|
290
|
+
i += 1
|
|
291
|
+
|
|
292
|
+
# Get API key from env if not provided
|
|
293
|
+
if not api_key:
|
|
294
|
+
api_key = settings.api_key
|
|
295
|
+
if not api_key:
|
|
296
|
+
click.echo("❌ API key required. Set HUD_API_KEY env var or use --api-key", err=True)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
|
|
299
|
+
# Build headers
|
|
300
|
+
headers = build_remote_headers(image, env_args, header_args, api_key, run_id)
|
|
301
|
+
|
|
302
|
+
if verbose:
|
|
303
|
+
click.echo(f"🔧 Remote URL: {url}", err=True)
|
|
304
|
+
click.echo(f"📦 Image: {image}", err=True)
|
|
305
|
+
click.echo(f"🔑 Headers: {list(headers.keys())}", err=True)
|
|
306
|
+
|
|
307
|
+
# Run based on transport
|
|
308
|
+
if transport == "stdio":
|
|
309
|
+
run_remote_stdio(url, headers, verbose)
|
|
310
|
+
else:
|
|
311
|
+
asyncio.run(run_remote_http(url, headers, port, verbose))
|