hud-python 0.4.8__py3-none-any.whl → 0.4.10__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/agents/base.py +50 -1
- hud/cli/__init__.py +187 -11
- hud/cli/analyze_metadata.py +33 -42
- hud/cli/build.py +7 -0
- hud/cli/debug.py +8 -1
- hud/cli/env_utils.py +133 -0
- hud/cli/eval.py +302 -0
- hud/cli/list_func.py +213 -0
- hud/cli/mcp_server.py +3 -79
- hud/cli/pull.py +20 -15
- hud/cli/push.py +84 -41
- hud/cli/registry.py +155 -0
- hud/cli/remove.py +200 -0
- hud/cli/runner.py +1 -1
- hud/cli/tests/test_analyze_metadata.py +277 -0
- hud/cli/tests/test_build.py +450 -0
- hud/cli/tests/test_list_func.py +288 -0
- hud/cli/tests/test_pull.py +400 -0
- hud/cli/tests/test_push.py +379 -0
- hud/cli/tests/test_registry.py +264 -0
- hud/clients/base.py +13 -1
- hud/tools/__init__.py +2 -0
- hud/tools/response.py +54 -0
- hud/utils/design.py +10 -0
- hud/utils/mcp.py +14 -2
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/METADATA +12 -1
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/RECORD +32 -20
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/WHEEL +0 -0
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.8.dist-info → hud_python-0.4.10.dist-info}/licenses/LICENSE +0 -0
hud/agents/base.py
CHANGED
|
@@ -85,6 +85,7 @@ class MCPAgent(ABC):
|
|
|
85
85
|
self._tool_map: dict[str, types.Tool] = {} # Simplified: just name to tool
|
|
86
86
|
self.screenshot_history: list[str] = []
|
|
87
87
|
self._auto_trace = auto_trace
|
|
88
|
+
self._auto_trace_cm: Any | None = None # Store auto-created trace context manager
|
|
88
89
|
self.initialization_complete = False
|
|
89
90
|
|
|
90
91
|
# Response agent to automatically interact with the model
|
|
@@ -303,6 +304,9 @@ class MCPAgent(ABC):
|
|
|
303
304
|
except Exception as e:
|
|
304
305
|
logger.warning("ResponseAgent failed: %s", e)
|
|
305
306
|
if decision == "STOP":
|
|
307
|
+
# Try to submit response through lifecycle tool
|
|
308
|
+
await self._maybe_submit_response(response, messages)
|
|
309
|
+
|
|
306
310
|
logger.info("Stopping execution")
|
|
307
311
|
final_response = response
|
|
308
312
|
break
|
|
@@ -483,6 +487,40 @@ class MCPAgent(ABC):
|
|
|
483
487
|
self._available_tools.append(tool)
|
|
484
488
|
# Simplified mapping - just tool name to tool
|
|
485
489
|
self._tool_map[tool.name] = tool
|
|
490
|
+
|
|
491
|
+
# Auto-detect response tool as a lifecycle tool
|
|
492
|
+
if tool.name == "response" and "response" not in self.lifecycle_tools:
|
|
493
|
+
logger.debug("Auto-detected 'response' tool as a lifecycle tool")
|
|
494
|
+
self.lifecycle_tools.append("response")
|
|
495
|
+
|
|
496
|
+
async def _maybe_submit_response(self, response: AgentResponse, messages: list[Any]) -> None:
|
|
497
|
+
"""Submit response through lifecycle tool if available.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
response: The agent's response
|
|
501
|
+
messages: The current message history (will be modified in-place)
|
|
502
|
+
"""
|
|
503
|
+
# Check if we have a response lifecycle tool
|
|
504
|
+
if "response" in self.lifecycle_tools and "response" in self._tool_map:
|
|
505
|
+
logger.debug("Calling response lifecycle tool")
|
|
506
|
+
try:
|
|
507
|
+
# Call the response tool with the agent's response
|
|
508
|
+
response_tool_call = MCPToolCall(
|
|
509
|
+
name="response",
|
|
510
|
+
arguments={"response": response.content, "messages": messages}
|
|
511
|
+
)
|
|
512
|
+
response_results = await self.call_tools(response_tool_call)
|
|
513
|
+
|
|
514
|
+
# Format and add the response tool results to messages
|
|
515
|
+
response_messages = await self.format_tool_results(
|
|
516
|
+
[response_tool_call], response_results
|
|
517
|
+
)
|
|
518
|
+
messages.extend(response_messages)
|
|
519
|
+
|
|
520
|
+
# Mark the task as done
|
|
521
|
+
logger.info("Response lifecycle tool executed, marking task as done")
|
|
522
|
+
except Exception as e:
|
|
523
|
+
logger.error("Response lifecycle tool failed: %s", e)
|
|
486
524
|
|
|
487
525
|
async def _setup_config(self, mcp_config: dict[str, dict[str, Any]]) -> None:
|
|
488
526
|
"""Inject metadata into the metadata of the initialize request."""
|
|
@@ -491,7 +529,7 @@ class MCPAgent(ABC):
|
|
|
491
529
|
mcp_config,
|
|
492
530
|
MCPConfigPatch(meta=self.metadata),
|
|
493
531
|
)
|
|
494
|
-
setup_hud_telemetry(mcp_config, auto_trace=self._auto_trace)
|
|
532
|
+
self._auto_trace_cm = setup_hud_telemetry(mcp_config, auto_trace=self._auto_trace)
|
|
495
533
|
|
|
496
534
|
def get_available_tools(self) -> list[types.Tool]:
|
|
497
535
|
"""Get list of available MCP tools for LLM use (excludes lifecycle tools)."""
|
|
@@ -532,6 +570,17 @@ class MCPAgent(ABC):
|
|
|
532
570
|
|
|
533
571
|
async def _cleanup(self) -> None:
|
|
534
572
|
"""Cleanup resources."""
|
|
573
|
+
# Clean up auto-created trace if any
|
|
574
|
+
if self._auto_trace_cm:
|
|
575
|
+
try:
|
|
576
|
+
self._auto_trace_cm.__exit__(None, None, None)
|
|
577
|
+
logger.info("Closed auto-created trace")
|
|
578
|
+
except Exception as e:
|
|
579
|
+
logger.warning("Failed to close auto-created trace: %s", e)
|
|
580
|
+
finally:
|
|
581
|
+
self._auto_trace_cm = None
|
|
582
|
+
|
|
583
|
+
# Clean up auto-created client
|
|
535
584
|
if self._auto_created_client and self.mcp_client:
|
|
536
585
|
try:
|
|
537
586
|
await self.mcp_client.shutdown()
|
hud/cli/__init__.py
CHANGED
|
@@ -23,9 +23,11 @@ from .clone import clone_repository, get_clone_message, print_error, print_tutor
|
|
|
23
23
|
from .cursor import get_cursor_config_path, list_cursor_servers, parse_cursor_config
|
|
24
24
|
from .debug import debug_mcp_stdio
|
|
25
25
|
from .init import create_environment
|
|
26
|
+
from . import list_func as list_module
|
|
26
27
|
from .mcp_server import run_mcp_dev_server
|
|
27
28
|
from .pull import pull_command
|
|
28
29
|
from .push import push_command
|
|
30
|
+
from .remove import remove_command
|
|
29
31
|
from .utils import CaptureLogger
|
|
30
32
|
|
|
31
33
|
# Create the main Typer app
|
|
@@ -129,7 +131,7 @@ def analyze(
|
|
|
129
131
|
def debug(
|
|
130
132
|
params: list[str] = typer.Argument( # type: ignore[arg-type] # noqa: B008
|
|
131
133
|
None,
|
|
132
|
-
help="Docker image followed by optional Docker
|
|
134
|
+
help="Docker image, environment directory, or config file followed by optional Docker arguments", # noqa: E501
|
|
133
135
|
),
|
|
134
136
|
config: Path = typer.Option( # noqa: B008
|
|
135
137
|
None,
|
|
@@ -145,6 +147,12 @@ def debug(
|
|
|
145
147
|
"--cursor",
|
|
146
148
|
help="Debug a server from Cursor config",
|
|
147
149
|
),
|
|
150
|
+
build: bool = typer.Option(
|
|
151
|
+
False,
|
|
152
|
+
"--build",
|
|
153
|
+
"-b",
|
|
154
|
+
help="Build image before debugging (for directory mode)",
|
|
155
|
+
),
|
|
148
156
|
max_phase: int = typer.Option(
|
|
149
157
|
5,
|
|
150
158
|
"--max-phase",
|
|
@@ -157,15 +165,24 @@ def debug(
|
|
|
157
165
|
"""🐛 Debug MCP environment - test initialization, tools, and readiness.
|
|
158
166
|
|
|
159
167
|
Examples:
|
|
160
|
-
hud debug
|
|
161
|
-
hud debug
|
|
168
|
+
hud debug . # Debug current directory
|
|
169
|
+
hud debug environments/browser # Debug specific directory
|
|
170
|
+
hud debug . --build # Build then debug
|
|
171
|
+
hud debug hud-text-2048:latest # Debug Docker image
|
|
172
|
+
hud debug my-mcp-server:v1 -e API_KEY=xxx
|
|
162
173
|
hud debug --config mcp-config.json
|
|
163
174
|
hud debug --cursor text-2048-dev
|
|
164
|
-
hud debug
|
|
175
|
+
hud debug . --max-phase 3 # Stop after phase 3
|
|
165
176
|
"""
|
|
166
|
-
|
|
177
|
+
# Import here to avoid circular imports
|
|
178
|
+
from .env_utils import get_image_name, is_environment_directory, build_environment, image_exists
|
|
179
|
+
from hud.utils.design import HUDDesign
|
|
180
|
+
|
|
181
|
+
design = HUDDesign()
|
|
182
|
+
|
|
167
183
|
# Determine the command to run
|
|
168
184
|
command = None
|
|
185
|
+
docker_args = []
|
|
169
186
|
|
|
170
187
|
if config:
|
|
171
188
|
# Load config from JSON file
|
|
@@ -183,13 +200,44 @@ def debug(
|
|
|
183
200
|
console.print(f"[red]❌ {error or 'Failed to parse cursor config'}[/red]")
|
|
184
201
|
raise typer.Exit(1)
|
|
185
202
|
elif params:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
203
|
+
first_param = params[0]
|
|
204
|
+
docker_args = params[1:] if len(params) > 1 else []
|
|
205
|
+
|
|
206
|
+
# Check if it's a directory
|
|
207
|
+
if Path(first_param).exists() and is_environment_directory(first_param):
|
|
208
|
+
# Directory mode - like hud dev
|
|
209
|
+
directory = first_param
|
|
210
|
+
|
|
211
|
+
# Get or generate image name
|
|
212
|
+
image_name, source = get_image_name(directory)
|
|
213
|
+
|
|
214
|
+
if source == "auto":
|
|
215
|
+
design.info(f"Auto-generated image name: {image_name}")
|
|
216
|
+
|
|
217
|
+
# Build if requested or if image doesn't exist
|
|
218
|
+
if build or not image_exists(image_name):
|
|
219
|
+
if not build and not image_exists(image_name):
|
|
220
|
+
if typer.confirm(f"Image {image_name} not found. Build it now?"):
|
|
221
|
+
build = True
|
|
222
|
+
else:
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
if build:
|
|
226
|
+
if not build_environment(directory, image_name):
|
|
227
|
+
raise typer.Exit(1)
|
|
228
|
+
|
|
229
|
+
# Build Docker command
|
|
230
|
+
command = ["docker", "run", "--rm", "-i", *docker_args, image_name]
|
|
231
|
+
else:
|
|
232
|
+
# Assume it's an image name
|
|
233
|
+
image = first_param
|
|
234
|
+
command = ["docker", "run", "--rm", "-i", *docker_args, image]
|
|
189
235
|
else:
|
|
190
|
-
console.print("[red]Error: Must specify
|
|
236
|
+
console.print("[red]Error: Must specify a directory, Docker image, --config, or --cursor[/red]")
|
|
191
237
|
console.print("\nExamples:")
|
|
192
|
-
console.print(" hud debug
|
|
238
|
+
console.print(" hud debug . # Debug current directory")
|
|
239
|
+
console.print(" hud debug environments/browser # Debug specific directory")
|
|
240
|
+
console.print(" hud debug hud-text-2048:latest # Debug Docker image")
|
|
193
241
|
console.print(" hud debug --config mcp-config.json")
|
|
194
242
|
console.print(" hud debug --cursor my-server")
|
|
195
243
|
raise typer.Exit(1)
|
|
@@ -442,7 +490,8 @@ def run(
|
|
|
442
490
|
|
|
443
491
|
# Get URL from options or environment
|
|
444
492
|
if not url:
|
|
445
|
-
|
|
493
|
+
from hud.settings import settings
|
|
494
|
+
url = settings.hud_mcp_url
|
|
446
495
|
|
|
447
496
|
run_remote_server(image, docker_args, transport, port, url, api_key, run_id, verbose)
|
|
448
497
|
|
|
@@ -561,6 +610,63 @@ def pull(
|
|
|
561
610
|
pull_command(target, lock_file, yes, verify_only, verbose)
|
|
562
611
|
|
|
563
612
|
|
|
613
|
+
@app.command(name="list")
|
|
614
|
+
def list_environments(
|
|
615
|
+
filter_name: str | None = typer.Option(
|
|
616
|
+
None, "--filter", "-f", help="Filter environments by name (case-insensitive)"
|
|
617
|
+
),
|
|
618
|
+
json_output: bool = typer.Option(
|
|
619
|
+
False, "--json", help="Output as JSON"
|
|
620
|
+
),
|
|
621
|
+
show_all: bool = typer.Option(
|
|
622
|
+
False, "--all", "-a", help="Show all columns including digest"
|
|
623
|
+
),
|
|
624
|
+
verbose: bool = typer.Option(
|
|
625
|
+
False, "--verbose", "-v", help="Show detailed output"
|
|
626
|
+
),
|
|
627
|
+
) -> None:
|
|
628
|
+
"""📋 List all HUD environments in local registry.
|
|
629
|
+
|
|
630
|
+
Shows environments pulled with 'hud pull' stored in ~/.hud/envs/
|
|
631
|
+
|
|
632
|
+
Examples:
|
|
633
|
+
hud list # List all environments
|
|
634
|
+
hud list --filter text # Filter by name
|
|
635
|
+
hud list --json # Output as JSON
|
|
636
|
+
hud list --all # Show digest column
|
|
637
|
+
hud list --verbose # Show full descriptions
|
|
638
|
+
"""
|
|
639
|
+
list_module.list_command(filter_name, json_output, show_all, verbose)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@app.command()
|
|
643
|
+
def remove(
|
|
644
|
+
target: str | None = typer.Argument(
|
|
645
|
+
None,
|
|
646
|
+
help="Environment to remove (digest, name, or 'all' for all environments)"
|
|
647
|
+
),
|
|
648
|
+
yes: bool = typer.Option(
|
|
649
|
+
False, "--yes", "-y", help="Skip confirmation prompt"
|
|
650
|
+
),
|
|
651
|
+
verbose: bool = typer.Option(
|
|
652
|
+
False, "--verbose", "-v", help="Show detailed output"
|
|
653
|
+
),
|
|
654
|
+
) -> None:
|
|
655
|
+
"""🗑️ Remove HUD environments from local registry.
|
|
656
|
+
|
|
657
|
+
Removes environment metadata from ~/.hud/envs/
|
|
658
|
+
Note: This does not remove the Docker images.
|
|
659
|
+
|
|
660
|
+
Examples:
|
|
661
|
+
hud remove abc123 # Remove by digest
|
|
662
|
+
hud remove text_2048 # Remove by name
|
|
663
|
+
hud remove hudpython/test_init # Remove by full name
|
|
664
|
+
hud remove all # Remove all environments
|
|
665
|
+
hud remove all --yes # Remove all without confirmation
|
|
666
|
+
"""
|
|
667
|
+
remove_command(target, yes, verbose)
|
|
668
|
+
|
|
669
|
+
|
|
564
670
|
@app.command()
|
|
565
671
|
def init(
|
|
566
672
|
name: str = typer.Argument(None, help="Environment name (default: current directory name)"),
|
|
@@ -592,6 +698,76 @@ def quickstart() -> None:
|
|
|
592
698
|
clone("https://github.com/hud-evals/quickstart.git")
|
|
593
699
|
|
|
594
700
|
|
|
701
|
+
@app.command()
|
|
702
|
+
def eval(
|
|
703
|
+
source: str = typer.Argument(
|
|
704
|
+
...,
|
|
705
|
+
help="HuggingFace dataset identifier (e.g. 'hud-evals/SheetBench-50') or task JSON file",
|
|
706
|
+
),
|
|
707
|
+
full: bool = typer.Option(
|
|
708
|
+
False,
|
|
709
|
+
"--full",
|
|
710
|
+
help="Run the entire dataset (omit for single-task debug mode)",
|
|
711
|
+
),
|
|
712
|
+
agent: str = typer.Option(
|
|
713
|
+
"claude",
|
|
714
|
+
"--agent",
|
|
715
|
+
help="Agent backend to use (claude or openai)",
|
|
716
|
+
),
|
|
717
|
+
model: str | None = typer.Option(
|
|
718
|
+
None,
|
|
719
|
+
"--model",
|
|
720
|
+
help="Model name for the chosen agent",
|
|
721
|
+
),
|
|
722
|
+
allowed_tools: str | None = typer.Option(
|
|
723
|
+
None,
|
|
724
|
+
"--allowed-tools",
|
|
725
|
+
help="Comma-separated list of allowed tools",
|
|
726
|
+
),
|
|
727
|
+
max_concurrent: int = typer.Option(
|
|
728
|
+
30,
|
|
729
|
+
"--max-concurrent",
|
|
730
|
+
help="Concurrency level for full-dataset mode",
|
|
731
|
+
),
|
|
732
|
+
max_steps: int = typer.Option(
|
|
733
|
+
30,
|
|
734
|
+
"--max-steps",
|
|
735
|
+
help="Maximum steps per task (default: 10 for single, 50 for full)",
|
|
736
|
+
),
|
|
737
|
+
) -> None:
|
|
738
|
+
"""🚀 Run evaluation on datasets or individual tasks with agents."""
|
|
739
|
+
# Validate agent choice
|
|
740
|
+
valid_agents = ["claude", "openai"]
|
|
741
|
+
if agent not in valid_agents:
|
|
742
|
+
from hud.utils.design import HUDDesign
|
|
743
|
+
design = HUDDesign()
|
|
744
|
+
design.error(f"Invalid agent: {agent}. Must be one of: {', '.join(valid_agents)}")
|
|
745
|
+
raise typer.Exit(1)
|
|
746
|
+
|
|
747
|
+
# Import eval_command lazily to avoid importing agent dependencies
|
|
748
|
+
try:
|
|
749
|
+
from .eval import eval_command
|
|
750
|
+
except ImportError as e:
|
|
751
|
+
from hud.utils.design import HUDDesign
|
|
752
|
+
design = HUDDesign()
|
|
753
|
+
design.error(
|
|
754
|
+
"Evaluation dependencies are not installed. "
|
|
755
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
756
|
+
)
|
|
757
|
+
raise typer.Exit(1) from e
|
|
758
|
+
|
|
759
|
+
# Run the command
|
|
760
|
+
eval_command(
|
|
761
|
+
source=source,
|
|
762
|
+
full=full,
|
|
763
|
+
agent=agent, # type: ignore
|
|
764
|
+
model=model,
|
|
765
|
+
allowed_tools=allowed_tools,
|
|
766
|
+
max_concurrent=max_concurrent,
|
|
767
|
+
max_steps=max_steps,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
595
771
|
def main() -> None:
|
|
596
772
|
"""Main entry point for the CLI."""
|
|
597
773
|
# Show header for main help
|
hud/cli/analyze_metadata.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
from urllib.parse import quote
|
|
6
7
|
|
|
7
8
|
import requests
|
|
8
9
|
import yaml
|
|
@@ -12,6 +13,8 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
12
13
|
from hud.settings import settings
|
|
13
14
|
from hud.utils.design import HUDDesign
|
|
14
15
|
|
|
16
|
+
from .registry import get_registry_dir, list_registry_entries, extract_digest_from_image, load_from_registry
|
|
17
|
+
|
|
15
18
|
console = Console()
|
|
16
19
|
design = HUDDesign()
|
|
17
20
|
|
|
@@ -24,7 +27,9 @@ def fetch_lock_from_registry(reference: str) -> dict | None:
|
|
|
24
27
|
if "/" in reference and ":" not in reference:
|
|
25
28
|
reference = f"{reference}:latest"
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
# URL-encode the path segments to handle special characters in tags
|
|
31
|
+
url_safe_path = "/".join(quote(part, safe="") for part in reference.split("/"))
|
|
32
|
+
registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{url_safe_path}"
|
|
28
33
|
|
|
29
34
|
headers = {}
|
|
30
35
|
if settings.api_key:
|
|
@@ -50,38 +55,31 @@ def fetch_lock_from_registry(reference: str) -> dict | None:
|
|
|
50
55
|
|
|
51
56
|
def check_local_cache(reference: str) -> dict | None:
|
|
52
57
|
"""Check local cache for lock file."""
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# Check specific digest directory
|
|
80
|
-
lock_file = Path.home() / ".hud" / "envs" / digest / "hud.lock.yaml"
|
|
81
|
-
if lock_file.exists():
|
|
82
|
-
with open(lock_file) as f:
|
|
83
|
-
return yaml.safe_load(f)
|
|
84
|
-
|
|
58
|
+
# First try exact digest match
|
|
59
|
+
digest = extract_digest_from_image(reference)
|
|
60
|
+
lock_data = load_from_registry(digest)
|
|
61
|
+
if lock_data:
|
|
62
|
+
return lock_data
|
|
63
|
+
|
|
64
|
+
# If not found and reference has a name, search by name pattern
|
|
65
|
+
if "/" in reference:
|
|
66
|
+
# Look for any cached version of this image
|
|
67
|
+
ref_base = reference.split("@")[0].split(":")[0]
|
|
68
|
+
|
|
69
|
+
for digest, lock_file in list_registry_entries():
|
|
70
|
+
try:
|
|
71
|
+
with open(lock_file) as f:
|
|
72
|
+
lock_data = yaml.safe_load(f)
|
|
73
|
+
# Check if this matches our reference
|
|
74
|
+
if lock_data and "image" in lock_data:
|
|
75
|
+
image = lock_data["image"]
|
|
76
|
+
# Match by name (ignoring tag/digest)
|
|
77
|
+
img_base = image.split("@")[0].split(":")[0]
|
|
78
|
+
if ref_base in img_base or img_base in ref_base:
|
|
79
|
+
return lock_data
|
|
80
|
+
except Exception:
|
|
81
|
+
continue
|
|
82
|
+
|
|
85
83
|
return None
|
|
86
84
|
|
|
87
85
|
|
|
@@ -147,15 +145,8 @@ async def analyze_from_metadata(reference: str, output_format: str, verbose: boo
|
|
|
147
145
|
source = "registry"
|
|
148
146
|
|
|
149
147
|
# Save to local cache for next time
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
else:
|
|
153
|
-
digest = "latest"
|
|
154
|
-
|
|
155
|
-
cache_dir = Path.home() / ".hud" / "envs" / digest
|
|
156
|
-
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
157
|
-
with open(cache_dir / "hud.lock.yaml", "w") as f: # noqa: ASYNC230
|
|
158
|
-
yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
|
|
148
|
+
from .registry import save_to_registry
|
|
149
|
+
save_to_registry(lock_data, lock_data.get("image", ""), verbose=False)
|
|
159
150
|
else:
|
|
160
151
|
progress.update(task, description="[red]✗ Not found[/red]")
|
|
161
152
|
|
hud/cli/build.py
CHANGED
|
@@ -17,6 +17,8 @@ from hud.clients import MCPClient
|
|
|
17
17
|
from hud.utils.design import HUDDesign
|
|
18
18
|
from hud.version import __version__ as hud_version
|
|
19
19
|
|
|
20
|
+
from .registry import save_to_registry
|
|
21
|
+
|
|
20
22
|
|
|
21
23
|
def parse_version(version_str: str) -> tuple[int, int, int]:
|
|
22
24
|
"""Parse version string like '1.0.0' or '1.0' into tuple of integers."""
|
|
@@ -459,6 +461,11 @@ def build_environment(
|
|
|
459
461
|
# Remove temp image after we're done
|
|
460
462
|
subprocess.run(["docker", "rmi", temp_tag], capture_output=True) # noqa: S603, S607
|
|
461
463
|
|
|
464
|
+
# Add to local registry
|
|
465
|
+
if image_id:
|
|
466
|
+
# Save to local registry using the helper
|
|
467
|
+
save_to_registry(lock_content, lock_content.get("image", tag), verbose)
|
|
468
|
+
|
|
462
469
|
# Print summary
|
|
463
470
|
design.section_title("Build Complete")
|
|
464
471
|
|
hud/cli/debug.py
CHANGED
|
@@ -167,7 +167,14 @@ async def debug_mcp_stdio(command: list[str], logger: CaptureLogger, max_phase:
|
|
|
167
167
|
break
|
|
168
168
|
except Exception as e:
|
|
169
169
|
logger.error(f"Failed to parse MCP response: {e}")
|
|
170
|
-
|
|
170
|
+
logger.error(f"Raw output that caused the error: {repr(line)}")
|
|
171
|
+
logger.hint("This usually means non-JSON output is being sent to STDOUT")
|
|
172
|
+
logger.hint("Common causes:")
|
|
173
|
+
logger.hint(" - Print statements in your server code")
|
|
174
|
+
logger.hint(" - Library warnings (use warnings.filterwarnings)")
|
|
175
|
+
logger.hint(" - Import-time output from dependencies")
|
|
176
|
+
phases_completed = 1 # Mark as failed
|
|
177
|
+
break # Stop trying to parse
|
|
171
178
|
|
|
172
179
|
if response and "result" in response:
|
|
173
180
|
logger.success("MCP server initialized successfully")
|
hud/cli/env_utils.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Shared utilities for environment directory handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import toml
|
|
10
|
+
|
|
11
|
+
from hud.utils.design import HUDDesign
|
|
12
|
+
|
|
13
|
+
design = HUDDesign()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_image_name(directory: str | Path, image_override: str | None = None) -> tuple[str, str]:
|
|
17
|
+
"""
|
|
18
|
+
Resolve image name with source tracking.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Tuple of (image_name, source) where source is "override", "cache", or "auto"
|
|
22
|
+
"""
|
|
23
|
+
if image_override:
|
|
24
|
+
return image_override, "override"
|
|
25
|
+
|
|
26
|
+
# Check pyproject.toml
|
|
27
|
+
pyproject_path = Path(directory) / "pyproject.toml"
|
|
28
|
+
if pyproject_path.exists():
|
|
29
|
+
try:
|
|
30
|
+
with open(pyproject_path) as f:
|
|
31
|
+
config = toml.load(f)
|
|
32
|
+
if config.get("tool", {}).get("hud", {}).get("image"):
|
|
33
|
+
return config["tool"]["hud"]["image"], "cache"
|
|
34
|
+
except Exception:
|
|
35
|
+
pass # Silent failure, will use auto-generated name
|
|
36
|
+
|
|
37
|
+
# Auto-generate with :dev tag
|
|
38
|
+
dir_path = Path(directory).resolve() # Get absolute path first
|
|
39
|
+
dir_name = dir_path.name
|
|
40
|
+
if not dir_name or dir_name == ".":
|
|
41
|
+
# If we're in root or have empty name, use parent directory
|
|
42
|
+
dir_name = dir_path.parent.name
|
|
43
|
+
clean_name = dir_name.replace("_", "-")
|
|
44
|
+
return f"hud-{clean_name}:dev", "auto"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool = False) -> None:
|
|
48
|
+
"""Update pyproject.toml with image name."""
|
|
49
|
+
pyproject_path = Path(directory) / "pyproject.toml"
|
|
50
|
+
if pyproject_path.exists():
|
|
51
|
+
try:
|
|
52
|
+
with open(pyproject_path) as f:
|
|
53
|
+
config = toml.load(f)
|
|
54
|
+
|
|
55
|
+
# Ensure [tool.hud] exists
|
|
56
|
+
if "tool" not in config:
|
|
57
|
+
config["tool"] = {}
|
|
58
|
+
if "hud" not in config["tool"]:
|
|
59
|
+
config["tool"]["hud"] = {}
|
|
60
|
+
|
|
61
|
+
# Update image name
|
|
62
|
+
config["tool"]["hud"]["image"] = image_name
|
|
63
|
+
|
|
64
|
+
# Write back
|
|
65
|
+
with open(pyproject_path, "w") as f:
|
|
66
|
+
toml.dump(config, f)
|
|
67
|
+
|
|
68
|
+
if not silent:
|
|
69
|
+
design.success(f"Updated pyproject.toml with image: {image_name}")
|
|
70
|
+
except Exception as e:
|
|
71
|
+
if not silent:
|
|
72
|
+
design.warning(f"Could not update pyproject.toml: {e}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_environment(directory: str | Path, image_name: str, no_cache: bool = False) -> bool:
|
|
76
|
+
"""Build Docker image for an environment.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if build succeeded, False otherwise
|
|
80
|
+
"""
|
|
81
|
+
build_cmd = ["docker", "build", "-t", image_name]
|
|
82
|
+
if no_cache:
|
|
83
|
+
build_cmd.append("--no-cache")
|
|
84
|
+
build_cmd.append(str(directory))
|
|
85
|
+
|
|
86
|
+
design.info(f"🔨 Building image: {image_name}{' (no cache)' if no_cache else ''}")
|
|
87
|
+
design.info("") # Empty line before Docker output
|
|
88
|
+
|
|
89
|
+
# Just run Docker build directly - it has its own nice live display
|
|
90
|
+
result = subprocess.run(build_cmd) # noqa: S603
|
|
91
|
+
|
|
92
|
+
if result.returncode == 0:
|
|
93
|
+
design.info("") # Empty line after Docker output
|
|
94
|
+
design.success(f"Build successful! Image: {image_name}")
|
|
95
|
+
# Update pyproject.toml (silently since we already showed success)
|
|
96
|
+
update_pyproject_toml(directory, image_name, silent=True)
|
|
97
|
+
return True
|
|
98
|
+
else:
|
|
99
|
+
design.error("Build failed!")
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def image_exists(image_name: str) -> bool:
|
|
104
|
+
"""Check if a Docker image exists locally."""
|
|
105
|
+
result = subprocess.run( # noqa: S603
|
|
106
|
+
["docker", "image", "inspect", image_name], # noqa: S607
|
|
107
|
+
stdout=subprocess.DEVNULL,
|
|
108
|
+
stderr=subprocess.DEVNULL,
|
|
109
|
+
)
|
|
110
|
+
return result.returncode == 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_environment_directory(path: str | Path) -> bool:
|
|
114
|
+
"""Check if a path looks like an environment directory.
|
|
115
|
+
|
|
116
|
+
An environment directory should have:
|
|
117
|
+
- A Dockerfile
|
|
118
|
+
- A pyproject.toml file
|
|
119
|
+
- Optionally a src directory
|
|
120
|
+
"""
|
|
121
|
+
dir_path = Path(path)
|
|
122
|
+
if not dir_path.is_dir():
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
# Must have Dockerfile
|
|
126
|
+
if not (dir_path / "Dockerfile").exists():
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
# Must have pyproject.toml
|
|
130
|
+
if not (dir_path / "pyproject.toml").exists():
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
return True
|