hud-python 0.4.10__py3-none-any.whl → 0.4.12__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/__main__.py +8 -0
- hud/agents/base.py +7 -8
- hud/agents/langchain.py +2 -2
- hud/agents/tests/test_openai.py +3 -1
- hud/cli/__init__.py +106 -51
- hud/cli/build.py +121 -71
- hud/cli/debug.py +2 -2
- hud/cli/{mcp_server.py → dev.py} +60 -25
- hud/cli/eval.py +148 -68
- hud/cli/init.py +0 -1
- hud/cli/list_func.py +72 -71
- hud/cli/pull.py +1 -2
- hud/cli/push.py +35 -23
- hud/cli/remove.py +35 -41
- hud/cli/tests/test_analyze.py +2 -1
- hud/cli/tests/test_analyze_metadata.py +42 -49
- hud/cli/tests/test_build.py +28 -52
- hud/cli/tests/test_cursor.py +1 -1
- hud/cli/tests/test_debug.py +1 -1
- hud/cli/tests/test_list_func.py +75 -64
- hud/cli/tests/test_main_module.py +30 -0
- hud/cli/tests/test_mcp_server.py +3 -3
- hud/cli/tests/test_pull.py +30 -61
- hud/cli/tests/test_push.py +70 -89
- hud/cli/tests/test_registry.py +36 -38
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/utils/__init__.py +1 -0
- hud/cli/{docker_utils.py → utils/docker.py} +36 -0
- hud/cli/{env_utils.py → utils/environment.py} +7 -7
- hud/cli/{interactive.py → utils/interactive.py} +91 -19
- hud/cli/{analyze_metadata.py → utils/metadata.py} +12 -8
- hud/cli/{registry.py → utils/registry.py} +28 -30
- hud/cli/{remote_runner.py → utils/remote_runner.py} +1 -1
- hud/cli/utils/runner.py +134 -0
- hud/cli/utils/server.py +250 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +7 -5
- hud/clients/mcp_use.py +8 -6
- hud/server/server.py +34 -4
- hud/shared/exceptions.py +11 -0
- hud/shared/tests/test_exceptions.py +22 -0
- hud/telemetry/tests/__init__.py +0 -0
- hud/telemetry/tests/test_replay.py +40 -0
- hud/telemetry/tests/test_trace.py +63 -0
- hud/tools/base.py +20 -3
- hud/tools/computer/hud.py +15 -6
- hud/tools/executors/tests/test_base_executor.py +27 -0
- hud/tools/response.py +15 -4
- hud/tools/tests/test_response.py +60 -0
- hud/tools/tests/test_tools_init.py +49 -0
- hud/utils/design.py +19 -8
- hud/utils/mcp.py +17 -5
- hud/utils/tests/test_mcp.py +112 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.10.dist-info → hud_python-0.4.12.dist-info}/METADATA +14 -10
- {hud_python-0.4.10.dist-info → hud_python-0.4.12.dist-info}/RECORD +62 -52
- hud/cli/runner.py +0 -160
- /hud/cli/{cursor.py → utils/cursor.py} +0 -0
- /hud/cli/{utils.py → utils/logging.py} +0 -0
- {hud_python-0.4.10.dist-info → hud_python-0.4.12.dist-info}/WHEEL +0 -0
- {hud_python-0.4.10.dist-info → hud_python-0.4.12.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.10.dist-info → hud_python-0.4.12.dist-info}/licenses/LICENSE +0 -0
hud/cli/tests/test_registry.py
CHANGED
|
@@ -5,10 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from unittest import mock
|
|
7
7
|
|
|
8
|
-
import pytest
|
|
9
8
|
import yaml
|
|
10
9
|
|
|
11
|
-
from hud.cli.registry import (
|
|
10
|
+
from hud.cli.utils.registry import (
|
|
12
11
|
extract_digest_from_image,
|
|
13
12
|
extract_name_and_tag,
|
|
14
13
|
get_registry_dir,
|
|
@@ -25,7 +24,7 @@ class TestGetRegistryDir:
|
|
|
25
24
|
"""Test default registry directory."""
|
|
26
25
|
with mock.patch("pathlib.Path.home") as mock_home:
|
|
27
26
|
mock_home.return_value = Path("/home/user")
|
|
28
|
-
|
|
27
|
+
|
|
29
28
|
registry_dir = get_registry_dir()
|
|
30
29
|
assert registry_dir == Path("/home/user/.hud/envs")
|
|
31
30
|
|
|
@@ -119,65 +118,64 @@ class TestExtractNameAndTag:
|
|
|
119
118
|
class TestSaveToRegistry:
|
|
120
119
|
"""Test saving to local registry."""
|
|
121
120
|
|
|
122
|
-
@mock.patch("hud.cli.registry.HUDDesign")
|
|
121
|
+
@mock.patch("hud.cli.utils.registry.HUDDesign")
|
|
123
122
|
def test_save_success(self, mock_design_class, tmp_path):
|
|
124
123
|
"""Test successful save to registry."""
|
|
125
124
|
mock_design = mock.Mock()
|
|
126
125
|
mock_design_class.return_value = mock_design
|
|
127
|
-
|
|
126
|
+
|
|
128
127
|
# Mock home directory
|
|
129
128
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
130
|
-
lock_data = {
|
|
131
|
-
|
|
132
|
-
"tools": ["tool1", "tool2"]
|
|
133
|
-
}
|
|
134
|
-
|
|
129
|
+
lock_data = {"image": "test:latest@sha256:abc123", "tools": ["tool1", "tool2"]}
|
|
130
|
+
|
|
135
131
|
result = save_to_registry(lock_data, "test:latest@sha256:abc123def456789")
|
|
136
|
-
|
|
132
|
+
|
|
137
133
|
assert result is not None
|
|
138
134
|
assert result.exists()
|
|
139
135
|
assert result.name == "hud.lock.yaml"
|
|
140
|
-
|
|
136
|
+
|
|
141
137
|
# Verify content
|
|
142
138
|
with open(result) as f:
|
|
143
139
|
saved_data = yaml.safe_load(f)
|
|
144
140
|
assert saved_data == lock_data
|
|
145
|
-
|
|
141
|
+
|
|
146
142
|
# Verify directory structure
|
|
147
143
|
assert result.parent.name == "abc123def456"
|
|
148
|
-
|
|
144
|
+
|
|
149
145
|
mock_design.success.assert_called_once()
|
|
150
146
|
|
|
151
|
-
@mock.patch("hud.cli.registry.HUDDesign")
|
|
147
|
+
@mock.patch("hud.cli.utils.registry.HUDDesign")
|
|
152
148
|
def test_save_verbose(self, mock_design_class, tmp_path):
|
|
153
149
|
"""Test save with verbose output."""
|
|
154
150
|
mock_design = mock.Mock()
|
|
155
151
|
mock_design_class.return_value = mock_design
|
|
156
|
-
|
|
152
|
+
|
|
157
153
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
158
154
|
lock_data = {"image": "test:v1"}
|
|
159
|
-
|
|
155
|
+
|
|
160
156
|
result = save_to_registry(lock_data, "test:v1", verbose=True)
|
|
161
|
-
|
|
157
|
+
|
|
162
158
|
assert result is not None
|
|
163
159
|
# Should show verbose info
|
|
164
160
|
assert mock_design.info.call_count >= 1
|
|
165
161
|
|
|
166
|
-
@mock.patch("hud.cli.registry.HUDDesign")
|
|
162
|
+
@mock.patch("hud.cli.utils.registry.HUDDesign")
|
|
167
163
|
def test_save_failure(self, mock_design_class):
|
|
168
164
|
"""Test handling save failure."""
|
|
169
165
|
mock_design = mock.Mock()
|
|
170
166
|
mock_design_class.return_value = mock_design
|
|
171
|
-
|
|
167
|
+
|
|
172
168
|
# Mock file operations to fail
|
|
173
|
-
with
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
169
|
+
with (
|
|
170
|
+
mock.patch("builtins.open", side_effect=OSError("Permission denied")),
|
|
171
|
+
mock.patch("pathlib.Path.home", return_value=Path("/tmp")),
|
|
172
|
+
):
|
|
173
|
+
lock_data = {"image": "test:latest"}
|
|
174
|
+
|
|
175
|
+
result = save_to_registry(lock_data, "test:latest", verbose=True)
|
|
176
|
+
|
|
177
|
+
assert result is None
|
|
178
|
+
mock_design.warning.assert_called_once()
|
|
181
179
|
|
|
182
180
|
|
|
183
181
|
class TestLoadFromRegistry:
|
|
@@ -190,11 +188,11 @@ class TestLoadFromRegistry:
|
|
|
190
188
|
registry_dir = get_registry_dir()
|
|
191
189
|
digest_dir = registry_dir / "abc123"
|
|
192
190
|
digest_dir.mkdir(parents=True)
|
|
193
|
-
|
|
191
|
+
|
|
194
192
|
lock_data = {"image": "test:latest", "version": "1.0"}
|
|
195
193
|
lock_file = digest_dir / "hud.lock.yaml"
|
|
196
194
|
lock_file.write_text(yaml.dump(lock_data))
|
|
197
|
-
|
|
195
|
+
|
|
198
196
|
# Load it back
|
|
199
197
|
loaded = load_from_registry("abc123")
|
|
200
198
|
assert loaded == lock_data
|
|
@@ -211,10 +209,10 @@ class TestLoadFromRegistry:
|
|
|
211
209
|
registry_dir = get_registry_dir()
|
|
212
210
|
digest_dir = registry_dir / "bad"
|
|
213
211
|
digest_dir.mkdir(parents=True)
|
|
214
|
-
|
|
212
|
+
|
|
215
213
|
lock_file = digest_dir / "hud.lock.yaml"
|
|
216
214
|
lock_file.write_text("invalid: yaml: content:")
|
|
217
|
-
|
|
215
|
+
|
|
218
216
|
loaded = load_from_registry("bad")
|
|
219
217
|
assert loaded is None
|
|
220
218
|
|
|
@@ -232,26 +230,26 @@ class TestListRegistryEntries:
|
|
|
232
230
|
"""Test listing multiple entries."""
|
|
233
231
|
with mock.patch("pathlib.Path.home", return_value=tmp_path):
|
|
234
232
|
registry_dir = get_registry_dir()
|
|
235
|
-
|
|
233
|
+
|
|
236
234
|
# Create several entries
|
|
237
235
|
for digest in ["abc123", "def456", "ghi789"]:
|
|
238
236
|
digest_dir = registry_dir / digest
|
|
239
237
|
digest_dir.mkdir(parents=True)
|
|
240
238
|
lock_file = digest_dir / "hud.lock.yaml"
|
|
241
239
|
lock_file.write_text(f"image: test:{digest}")
|
|
242
|
-
|
|
240
|
+
|
|
243
241
|
# Create a directory without lock file (should be ignored)
|
|
244
242
|
(registry_dir / "nolockfile").mkdir(parents=True)
|
|
245
|
-
|
|
243
|
+
|
|
246
244
|
# Create a file in registry dir (should be ignored)
|
|
247
245
|
(registry_dir / "README.txt").write_text("info")
|
|
248
|
-
|
|
246
|
+
|
|
249
247
|
entries = list_registry_entries()
|
|
250
|
-
|
|
248
|
+
|
|
251
249
|
assert len(entries) == 3
|
|
252
250
|
digests = [entry[0] for entry in entries]
|
|
253
251
|
assert set(digests) == {"abc123", "def456", "ghi789"}
|
|
254
|
-
|
|
252
|
+
|
|
255
253
|
# Verify all paths are lock files
|
|
256
254
|
for _, lock_path in entries:
|
|
257
255
|
assert lock_path.name == "hud.lock.yaml"
|
hud/cli/tests/test_utils.py
CHANGED
|
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from hud.cli.utils import HINT_REGISTRY, CaptureLogger, Colors, analyze_error_for_hints
|
|
10
|
+
from hud.cli.utils.logging import HINT_REGISTRY, CaptureLogger, Colors, analyze_error_for_hints
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class TestColors:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules for HUD CLI."""
|
|
@@ -81,3 +81,39 @@ def image_exists(image_name: str) -> bool:
|
|
|
81
81
|
stderr=subprocess.DEVNULL,
|
|
82
82
|
)
|
|
83
83
|
return result.returncode == 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def remove_container(container_name: str) -> bool:
|
|
87
|
+
"""Remove a Docker container by name.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
container_name: Name of the container to remove
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if successful or container doesn't exist, False on error
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
subprocess.run( # noqa: S603
|
|
97
|
+
["docker", "rm", "-f", container_name], # noqa: S607
|
|
98
|
+
stdout=subprocess.DEVNULL,
|
|
99
|
+
stderr=subprocess.DEVNULL,
|
|
100
|
+
check=False, # Don't raise error if container doesn't exist
|
|
101
|
+
)
|
|
102
|
+
return True
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_container_name(identifier: str, prefix: str = "hud") -> str:
|
|
108
|
+
"""Generate a safe container name from an identifier.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
identifier: Image name or other identifier
|
|
112
|
+
prefix: Prefix for the container name
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Safe container name with special characters replaced
|
|
116
|
+
"""
|
|
117
|
+
# Replace special characters with hyphens
|
|
118
|
+
safe_name = identifier.replace(":", "-").replace("/", "-").replace("\\", "-")
|
|
119
|
+
return f"{prefix}-{safe_name}"
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import subprocess
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
7
|
|
|
9
8
|
import toml
|
|
10
9
|
|
|
@@ -32,7 +31,7 @@ def get_image_name(directory: str | Path, image_override: str | None = None) ->
|
|
|
32
31
|
if config.get("tool", {}).get("hud", {}).get("image"):
|
|
33
32
|
return config["tool"]["hud"]["image"], "cache"
|
|
34
33
|
except Exception:
|
|
35
|
-
|
|
34
|
+
design.error("Error loading pyproject.toml")
|
|
36
35
|
|
|
37
36
|
# Auto-generate with :dev tag
|
|
38
37
|
dir_path = Path(directory).resolve() # Get absolute path first
|
|
@@ -74,7 +73,7 @@ def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool =
|
|
|
74
73
|
|
|
75
74
|
def build_environment(directory: str | Path, image_name: str, no_cache: bool = False) -> bool:
|
|
76
75
|
"""Build Docker image for an environment.
|
|
77
|
-
|
|
76
|
+
|
|
78
77
|
Returns:
|
|
79
78
|
True if build succeeded, False otherwise
|
|
80
79
|
"""
|
|
@@ -112,7 +111,7 @@ def image_exists(image_name: str) -> bool:
|
|
|
112
111
|
|
|
113
112
|
def is_environment_directory(path: str | Path) -> bool:
|
|
114
113
|
"""Check if a path looks like an environment directory.
|
|
115
|
-
|
|
114
|
+
|
|
116
115
|
An environment directory should have:
|
|
117
116
|
- A Dockerfile
|
|
118
117
|
- A pyproject.toml file
|
|
@@ -121,13 +120,14 @@ def is_environment_directory(path: str | Path) -> bool:
|
|
|
121
120
|
dir_path = Path(path)
|
|
122
121
|
if not dir_path.is_dir():
|
|
123
122
|
return False
|
|
124
|
-
|
|
123
|
+
|
|
125
124
|
# Must have Dockerfile
|
|
126
125
|
if not (dir_path / "Dockerfile").exists():
|
|
127
126
|
return False
|
|
128
|
-
|
|
127
|
+
|
|
129
128
|
# Must have pyproject.toml
|
|
130
129
|
if not (dir_path / "pyproject.toml").exists():
|
|
130
|
+
design.error("pyproject.toml not found")
|
|
131
131
|
return False
|
|
132
|
-
|
|
132
|
+
|
|
133
133
|
return True
|
|
@@ -112,52 +112,103 @@ class InteractiveMCPTester:
|
|
|
112
112
|
choices = []
|
|
113
113
|
tool_map = {}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
+
if hub not in hub_groups:
|
|
123
|
+
hub_groups[hub] = []
|
|
124
|
+
hub_groups[hub].append((name, tool))
|
|
120
125
|
else:
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
#
|
|
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=
|
|
188
|
+
choices=formatted_choices,
|
|
141
189
|
style=questionary.Style(
|
|
142
190
|
[
|
|
143
191
|
("question", ""),
|
|
144
|
-
("pointer", "fg:#ff9d00 bold"),
|
|
145
|
-
("highlighted", "fg:#
|
|
146
|
-
("selected", "fg:#
|
|
147
|
-
("separator", "fg:#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
76
|
-
|
|
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) ->
|
|
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 .
|
|
202
|
+
from .logging import find_free_port
|
|
203
203
|
|
|
204
204
|
# Find available port
|
|
205
205
|
actual_port = find_free_port(port)
|