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/cli/eval.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""HUD evaluation command for running tasks and datasets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
import hud
|
|
14
|
+
from hud.utils.design import HUDDesign
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from datasets import Dataset
|
|
18
|
+
from hud.agents import ClaudeAgent, OperatorAgent
|
|
19
|
+
from hud.agents.misc.response_agent import ResponseAgent
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
design = HUDDesign()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_agent(
|
|
26
|
+
agent_type: Literal["claude", "openai"],
|
|
27
|
+
*,
|
|
28
|
+
model: str | None = None,
|
|
29
|
+
allowed_tools: list[str] | None = None,
|
|
30
|
+
) -> Any:
|
|
31
|
+
"""Create and return the requested agent type."""
|
|
32
|
+
|
|
33
|
+
# Import agents lazily to avoid dependency issues
|
|
34
|
+
try:
|
|
35
|
+
from hud.agents.misc.response_agent import ResponseAgent
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
design.error(
|
|
38
|
+
"Agent dependencies are not installed. "
|
|
39
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
40
|
+
)
|
|
41
|
+
raise typer.Exit(1) from e
|
|
42
|
+
|
|
43
|
+
if agent_type == "openai":
|
|
44
|
+
try:
|
|
45
|
+
from hud.agents import OperatorAgent
|
|
46
|
+
except ImportError as e:
|
|
47
|
+
design.error(
|
|
48
|
+
"OpenAI agent dependencies are not installed. "
|
|
49
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
50
|
+
)
|
|
51
|
+
raise typer.Exit(1) from e
|
|
52
|
+
|
|
53
|
+
allowed_tools = allowed_tools or ["openai_computer"]
|
|
54
|
+
|
|
55
|
+
return OperatorAgent(
|
|
56
|
+
allowed_tools=allowed_tools,
|
|
57
|
+
response_agent=ResponseAgent(),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Fallback Claude agent (Anthropic)
|
|
61
|
+
try:
|
|
62
|
+
from hud.agents import ClaudeAgent
|
|
63
|
+
except ImportError as e:
|
|
64
|
+
design.error(
|
|
65
|
+
"Claude agent dependencies are not installed. "
|
|
66
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
67
|
+
)
|
|
68
|
+
raise typer.Exit(1) from e
|
|
69
|
+
|
|
70
|
+
model = model or "claude-sonnet-4-20250514"
|
|
71
|
+
allowed_tools = allowed_tools or ["anthropic_computer"]
|
|
72
|
+
|
|
73
|
+
return ClaudeAgent(
|
|
74
|
+
model=model,
|
|
75
|
+
allowed_tools=allowed_tools,
|
|
76
|
+
response_agent=ResponseAgent(),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def run_single_task(
|
|
81
|
+
source: str,
|
|
82
|
+
*,
|
|
83
|
+
agent_type: Literal["claude", "openai"] = "claude",
|
|
84
|
+
model: str | None = None,
|
|
85
|
+
allowed_tools: list[str] | None = None,
|
|
86
|
+
max_steps: int = 10,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Load one task and execute it."""
|
|
89
|
+
|
|
90
|
+
design.info("📊 Loading dataset…")
|
|
91
|
+
|
|
92
|
+
# Import Task lazily
|
|
93
|
+
try:
|
|
94
|
+
from hud.datasets import Task
|
|
95
|
+
except ImportError as e:
|
|
96
|
+
design.error(
|
|
97
|
+
"Dataset dependencies are not installed. "
|
|
98
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
99
|
+
)
|
|
100
|
+
raise typer.Exit(1) from e
|
|
101
|
+
|
|
102
|
+
# Check if it's a single task JSON file
|
|
103
|
+
path = Path(source)
|
|
104
|
+
if path.exists() and path.suffix == ".json":
|
|
105
|
+
with open(path, "r") as f:
|
|
106
|
+
task_data = json.load(f)
|
|
107
|
+
task = Task(**task_data)
|
|
108
|
+
else:
|
|
109
|
+
# Load from HuggingFace dataset
|
|
110
|
+
try:
|
|
111
|
+
from datasets import load_dataset
|
|
112
|
+
except ImportError as e:
|
|
113
|
+
design.error(
|
|
114
|
+
"Datasets library is not installed. "
|
|
115
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
116
|
+
)
|
|
117
|
+
raise typer.Exit(1) from e
|
|
118
|
+
|
|
119
|
+
dataset = load_dataset(source, split="train")
|
|
120
|
+
|
|
121
|
+
# Get first task from dataset
|
|
122
|
+
sample_task = dataset[0] # type: ignore[index]
|
|
123
|
+
task = Task(**sample_task) # type: ignore[arg-type]
|
|
124
|
+
|
|
125
|
+
task_prompt = task.prompt[:50] + "..." if len(task.prompt) > 50 else task.prompt
|
|
126
|
+
|
|
127
|
+
with hud.trace(name=task_prompt):
|
|
128
|
+
agent = build_agent(
|
|
129
|
+
agent_type,
|
|
130
|
+
model=model,
|
|
131
|
+
allowed_tools=allowed_tools,
|
|
132
|
+
)
|
|
133
|
+
design.info(task.prompt)
|
|
134
|
+
result = await agent.run(task, max_steps=max_steps)
|
|
135
|
+
design.success(f"Reward: {result.reward}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def run_full_dataset(
|
|
139
|
+
source: str,
|
|
140
|
+
*,
|
|
141
|
+
agent_type: Literal["claude", "openai"] = "claude",
|
|
142
|
+
model: str | None = None,
|
|
143
|
+
allowed_tools: list[str] | None = None,
|
|
144
|
+
max_concurrent: int = 30,
|
|
145
|
+
max_steps: int = 50,
|
|
146
|
+
) -> list[Any]:
|
|
147
|
+
"""Run evaluation across the entire dataset using hud.datasets.run_dataset."""
|
|
148
|
+
|
|
149
|
+
# Import run_dataset lazily
|
|
150
|
+
try:
|
|
151
|
+
from hud.datasets import run_dataset
|
|
152
|
+
except ImportError as e:
|
|
153
|
+
design.error(
|
|
154
|
+
"Dataset dependencies are not installed. "
|
|
155
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
156
|
+
)
|
|
157
|
+
raise typer.Exit(1) from e
|
|
158
|
+
|
|
159
|
+
# Build agent class + config for run_dataset
|
|
160
|
+
if agent_type == "openai":
|
|
161
|
+
try:
|
|
162
|
+
from hud.agents import OperatorAgent
|
|
163
|
+
agent_class = OperatorAgent
|
|
164
|
+
except ImportError as e:
|
|
165
|
+
design.error(
|
|
166
|
+
"OpenAI agent dependencies are not installed. "
|
|
167
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
168
|
+
)
|
|
169
|
+
raise typer.Exit(1) from e
|
|
170
|
+
|
|
171
|
+
agent_config: dict[str, Any] = {
|
|
172
|
+
"allowed_tools": allowed_tools or ["openai_computer"],
|
|
173
|
+
}
|
|
174
|
+
else:
|
|
175
|
+
try:
|
|
176
|
+
from hud.agents import ClaudeAgent
|
|
177
|
+
agent_class = ClaudeAgent
|
|
178
|
+
except ImportError as e:
|
|
179
|
+
design.error(
|
|
180
|
+
"Claude agent dependencies are not installed. "
|
|
181
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
182
|
+
)
|
|
183
|
+
raise typer.Exit(1) from e
|
|
184
|
+
|
|
185
|
+
agent_config = {
|
|
186
|
+
"model": model or "claude-sonnet-4-20250514",
|
|
187
|
+
"allowed_tools": allowed_tools or ["anthropic_computer"],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
design.info("🚀 Running evaluation…")
|
|
191
|
+
return await run_dataset(
|
|
192
|
+
name=f"Evaluation {source.split('/')[-1]}",
|
|
193
|
+
dataset=source,
|
|
194
|
+
agent_class=agent_class,
|
|
195
|
+
agent_config=agent_config,
|
|
196
|
+
max_concurrent=max_concurrent,
|
|
197
|
+
metadata={"dataset": source},
|
|
198
|
+
max_steps=max_steps,
|
|
199
|
+
auto_respond=True,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def eval_command(
|
|
204
|
+
source: str = typer.Argument(
|
|
205
|
+
...,
|
|
206
|
+
help="HuggingFace dataset identifier (e.g. 'hud-evals/SheetBench-50') or task JSON file",
|
|
207
|
+
),
|
|
208
|
+
full: bool = typer.Option(
|
|
209
|
+
False,
|
|
210
|
+
"--full",
|
|
211
|
+
help="Run the entire dataset (omit for single-task debug mode)",
|
|
212
|
+
),
|
|
213
|
+
agent: Literal["claude", "openai"] = typer.Option(
|
|
214
|
+
"claude",
|
|
215
|
+
"--agent",
|
|
216
|
+
help="Agent backend to use",
|
|
217
|
+
),
|
|
218
|
+
model: str | None = typer.Option(
|
|
219
|
+
None,
|
|
220
|
+
"--model",
|
|
221
|
+
help="Model name for the chosen agent",
|
|
222
|
+
),
|
|
223
|
+
allowed_tools: str | None = typer.Option(
|
|
224
|
+
None,
|
|
225
|
+
"--allowed-tools",
|
|
226
|
+
help="Comma-separated list of allowed tools",
|
|
227
|
+
),
|
|
228
|
+
max_concurrent: int = typer.Option(
|
|
229
|
+
50,
|
|
230
|
+
"--max-concurrent",
|
|
231
|
+
help="Concurrency level for full-dataset mode",
|
|
232
|
+
),
|
|
233
|
+
max_steps: int = typer.Option(
|
|
234
|
+
None,
|
|
235
|
+
"--max-steps",
|
|
236
|
+
help="Maximum steps per task (default: 10 for single, 50 for full)",
|
|
237
|
+
),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""🚀 Run evaluation on datasets or individual tasks with agents.
|
|
240
|
+
|
|
241
|
+
Examples:
|
|
242
|
+
# Evaluate a single task from SheetBench
|
|
243
|
+
hud eval hud-evals/SheetBench-50
|
|
244
|
+
|
|
245
|
+
# Evaluate the FULL SheetBench dataset with Claude
|
|
246
|
+
hud eval hud-evals/SheetBench-50 --full --agent claude
|
|
247
|
+
|
|
248
|
+
# Run a single task from a JSON file
|
|
249
|
+
hud eval task.json
|
|
250
|
+
|
|
251
|
+
# Run with OpenAI Operator agent
|
|
252
|
+
hud eval hud-evals/OSWorld-Gold-Beta --agent openai
|
|
253
|
+
"""
|
|
254
|
+
from hud.settings import settings
|
|
255
|
+
import os
|
|
256
|
+
|
|
257
|
+
# Check for required API keys
|
|
258
|
+
if agent == "claude":
|
|
259
|
+
if not settings.anthropic_api_key or not os.environ.get("ANTHROPIC_API_KEY"):
|
|
260
|
+
design.error("ANTHROPIC_API_KEY is required for Claude agent")
|
|
261
|
+
design.info("Set it in your environment or .env file: ANTHROPIC_API_KEY=your-key-here")
|
|
262
|
+
raise typer.Exit(1)
|
|
263
|
+
elif agent == "openai":
|
|
264
|
+
if not settings.openai_api_key or not os.environ.get("OPENAI_API_KEY"):
|
|
265
|
+
design.error("OPENAI_API_KEY is required for OpenAI agent")
|
|
266
|
+
design.info("Set it in your environment or .env file: OPENAI_API_KEY=your-key-here")
|
|
267
|
+
raise typer.Exit(1)
|
|
268
|
+
|
|
269
|
+
# Check for HUD_API_KEY if using HUD services
|
|
270
|
+
if not settings.api_key or not os.environ.get("HUD_API_KEY"):
|
|
271
|
+
design.warning("HUD_API_KEY not set. Some features may be limited.")
|
|
272
|
+
design.info("Get your API key at: https://app.hud.so")
|
|
273
|
+
|
|
274
|
+
# Parse allowed tools
|
|
275
|
+
allowed_tools_list = (
|
|
276
|
+
[t.strip() for t in allowed_tools.split(",") if t.strip()]
|
|
277
|
+
if allowed_tools
|
|
278
|
+
else None
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Set default max_steps if not provided
|
|
282
|
+
if max_steps is None:
|
|
283
|
+
max_steps = 50 if full else 10
|
|
284
|
+
|
|
285
|
+
# Run evaluation
|
|
286
|
+
if full:
|
|
287
|
+
asyncio.run(run_full_dataset(
|
|
288
|
+
source,
|
|
289
|
+
agent_type=agent,
|
|
290
|
+
model=model,
|
|
291
|
+
allowed_tools=allowed_tools_list,
|
|
292
|
+
max_concurrent=max_concurrent,
|
|
293
|
+
max_steps=max_steps,
|
|
294
|
+
))
|
|
295
|
+
else:
|
|
296
|
+
asyncio.run(run_single_task(
|
|
297
|
+
source,
|
|
298
|
+
agent_type=agent,
|
|
299
|
+
model=model,
|
|
300
|
+
allowed_tools=allowed_tools_list,
|
|
301
|
+
max_steps=max_steps,
|
|
302
|
+
))
|
hud/cli/list_func.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""List HUD environments from local registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from hud.utils.design import HUDDesign
|
|
14
|
+
|
|
15
|
+
from .registry import get_registry_dir, list_registry_entries, extract_name_and_tag
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_timestamp(timestamp: float | None) -> str:
|
|
19
|
+
"""Format timestamp to human-readable relative time."""
|
|
20
|
+
if not timestamp:
|
|
21
|
+
return "unknown"
|
|
22
|
+
|
|
23
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
24
|
+
now = datetime.now()
|
|
25
|
+
delta = now - dt
|
|
26
|
+
|
|
27
|
+
if delta.days > 365:
|
|
28
|
+
return f"{delta.days // 365}y ago"
|
|
29
|
+
elif delta.days > 30:
|
|
30
|
+
return f"{delta.days // 30}mo ago"
|
|
31
|
+
elif delta.days > 0:
|
|
32
|
+
return f"{delta.days}d ago"
|
|
33
|
+
elif delta.seconds > 3600:
|
|
34
|
+
return f"{delta.seconds // 3600}h ago"
|
|
35
|
+
elif delta.seconds > 60:
|
|
36
|
+
return f"{delta.seconds // 60}m ago"
|
|
37
|
+
else:
|
|
38
|
+
return "just now"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def list_environments(
|
|
42
|
+
filter_name: str | None = None,
|
|
43
|
+
json_output: bool = False,
|
|
44
|
+
show_all: bool = False,
|
|
45
|
+
verbose: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""List all HUD environments in the local registry."""
|
|
48
|
+
design = HUDDesign()
|
|
49
|
+
|
|
50
|
+
if not json_output:
|
|
51
|
+
design.header("HUD Environment Registry")
|
|
52
|
+
|
|
53
|
+
# Check for environment directory
|
|
54
|
+
env_dir = get_registry_dir()
|
|
55
|
+
if not env_dir.exists():
|
|
56
|
+
if json_output:
|
|
57
|
+
print("[]")
|
|
58
|
+
else:
|
|
59
|
+
design.info("No environments found in local registry.")
|
|
60
|
+
design.info("")
|
|
61
|
+
design.info("Pull environments with: [cyan]hud pull <org/name:tag>[/cyan]")
|
|
62
|
+
design.info("Build environments with: [cyan]hud build[/cyan]")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Collect all environments using the registry helper
|
|
66
|
+
environments = []
|
|
67
|
+
|
|
68
|
+
for digest, lock_file in list_registry_entries():
|
|
69
|
+
try:
|
|
70
|
+
# Read lock file
|
|
71
|
+
with open(lock_file) as f:
|
|
72
|
+
lock_data = yaml.safe_load(f)
|
|
73
|
+
|
|
74
|
+
# Extract metadata
|
|
75
|
+
image = lock_data.get("image", "unknown")
|
|
76
|
+
name, tag = extract_name_and_tag(image)
|
|
77
|
+
|
|
78
|
+
# Apply filter if specified
|
|
79
|
+
if filter_name and filter_name.lower() not in name.lower():
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Get additional metadata
|
|
83
|
+
metadata = lock_data.get("metadata", {})
|
|
84
|
+
description = metadata.get("description", "")
|
|
85
|
+
tools_count = len(metadata.get("tools", []))
|
|
86
|
+
|
|
87
|
+
# Get file modification time as pulled time
|
|
88
|
+
pulled_time = lock_file.stat().st_mtime
|
|
89
|
+
|
|
90
|
+
environments.append({
|
|
91
|
+
"name": name,
|
|
92
|
+
"tag": tag,
|
|
93
|
+
"digest": digest,
|
|
94
|
+
"description": description,
|
|
95
|
+
"tools_count": tools_count,
|
|
96
|
+
"pulled_time": pulled_time,
|
|
97
|
+
"image": image,
|
|
98
|
+
"path": str(lock_file),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
if verbose:
|
|
103
|
+
design.warning(f"Failed to read {lock_file}: {e}")
|
|
104
|
+
|
|
105
|
+
# Sort by pulled time (newest first)
|
|
106
|
+
environments.sort(key=lambda x: x["pulled_time"], reverse=True)
|
|
107
|
+
|
|
108
|
+
if json_output:
|
|
109
|
+
# Output as JSON
|
|
110
|
+
import json
|
|
111
|
+
json_data = []
|
|
112
|
+
for env in environments:
|
|
113
|
+
json_data.append({
|
|
114
|
+
"name": env["name"],
|
|
115
|
+
"tag": env["tag"],
|
|
116
|
+
"digest": env["digest"],
|
|
117
|
+
"description": env["description"],
|
|
118
|
+
"tools_count": env["tools_count"],
|
|
119
|
+
"pulled_time": env["pulled_time"],
|
|
120
|
+
"image": env["image"],
|
|
121
|
+
"path": env["path"],
|
|
122
|
+
})
|
|
123
|
+
print(json.dumps(json_data, indent=2))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if not environments:
|
|
127
|
+
design.info("No environments found matching criteria.")
|
|
128
|
+
design.info("")
|
|
129
|
+
design.info("Pull environments with: [cyan]hud pull <org/name:tag>[/cyan]")
|
|
130
|
+
design.info("Build environments with: [cyan]hud build[/cyan]")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Create table
|
|
134
|
+
table = Table(title=f"Found {len(environments)} environment{'s' if len(environments) != 1 else ''}")
|
|
135
|
+
table.add_column("Environment", style="cyan", no_wrap=True)
|
|
136
|
+
table.add_column("Description", style="white")
|
|
137
|
+
table.add_column("Tools", justify="right", style="yellow")
|
|
138
|
+
table.add_column("Pulled", style="dim")
|
|
139
|
+
|
|
140
|
+
if show_all or verbose:
|
|
141
|
+
table.add_column("Digest", style="dim")
|
|
142
|
+
|
|
143
|
+
# Add rows
|
|
144
|
+
for env in environments:
|
|
145
|
+
# Truncate description if too long
|
|
146
|
+
desc = env["description"]
|
|
147
|
+
if desc and len(desc) > 50 and not verbose:
|
|
148
|
+
desc = desc[:47] + "..."
|
|
149
|
+
|
|
150
|
+
# Combine name and tag for easy copying
|
|
151
|
+
full_ref = f"{env['name']}:{env['tag']}"
|
|
152
|
+
|
|
153
|
+
row = [
|
|
154
|
+
full_ref,
|
|
155
|
+
desc or "[dim]No description[/dim]",
|
|
156
|
+
str(env["tools_count"]),
|
|
157
|
+
format_timestamp(env["pulled_time"]),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
if show_all or verbose:
|
|
161
|
+
row.append(env["digest"][:12])
|
|
162
|
+
|
|
163
|
+
table.add_row(*row)
|
|
164
|
+
|
|
165
|
+
design.print(table)
|
|
166
|
+
design.info("")
|
|
167
|
+
|
|
168
|
+
# Show usage hints
|
|
169
|
+
design.section_title("Usage")
|
|
170
|
+
if environments:
|
|
171
|
+
# Use the most recently pulled environment as example
|
|
172
|
+
example_env = environments[0]
|
|
173
|
+
example_ref = f"{example_env['name']}:{example_env['tag']}"
|
|
174
|
+
|
|
175
|
+
design.info(f"Run an environment: [cyan]hud run {example_ref}[/cyan]")
|
|
176
|
+
design.info(f"Analyze tools: [cyan]hud analyze {example_ref}[/cyan]")
|
|
177
|
+
design.info(f"Debug server: [cyan]hud debug {example_ref}[/cyan]")
|
|
178
|
+
|
|
179
|
+
design.info("Pull more environments: [cyan]hud pull <org/name:tag>[/cyan]")
|
|
180
|
+
design.info("Build new environments: [cyan]hud build[/cyan]")
|
|
181
|
+
|
|
182
|
+
if verbose:
|
|
183
|
+
design.info("")
|
|
184
|
+
design.info(f"[dim]Registry location: {env_dir}[/dim]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def list_command(
|
|
188
|
+
filter_name: str | None = typer.Option(
|
|
189
|
+
None, "--filter", "-f", help="Filter environments by name (case-insensitive)"
|
|
190
|
+
),
|
|
191
|
+
json_output: bool = typer.Option(
|
|
192
|
+
False, "--json", help="Output as JSON"
|
|
193
|
+
),
|
|
194
|
+
show_all: bool = typer.Option(
|
|
195
|
+
False, "--all", "-a", help="Show all columns including digest"
|
|
196
|
+
),
|
|
197
|
+
verbose: bool = typer.Option(
|
|
198
|
+
False, "--verbose", "-v", help="Show detailed output"
|
|
199
|
+
),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""📋 List all HUD environments in local registry.
|
|
202
|
+
|
|
203
|
+
Shows environments pulled with 'hud pull' or built with 'hud build',
|
|
204
|
+
stored in ~/.hud/envs/
|
|
205
|
+
|
|
206
|
+
Examples:
|
|
207
|
+
hud list # List all environments
|
|
208
|
+
hud list --filter text # Filter by name
|
|
209
|
+
hud list --json # Output as JSON
|
|
210
|
+
hud list --all # Show digest column
|
|
211
|
+
hud list --verbose # Show full descriptions
|
|
212
|
+
"""
|
|
213
|
+
list_environments(filter_name, json_output, show_all, verbose)
|
hud/cli/mcp_server.py
CHANGED
|
@@ -13,92 +13,16 @@ import toml
|
|
|
13
13
|
from fastmcp import FastMCP
|
|
14
14
|
|
|
15
15
|
from hud.utils.design import HUDDesign
|
|
16
|
-
from .docker_utils import get_docker_cmd,
|
|
16
|
+
from .docker_utils import get_docker_cmd, inject_supervisor
|
|
17
|
+
from .env_utils import get_image_name, update_pyproject_toml, build_environment, image_exists
|
|
17
18
|
|
|
18
19
|
# Global design instance
|
|
19
20
|
design = HUDDesign()
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def get_image_name(directory: str | Path, image_override: str | None = None) -> tuple[str, str]:
|
|
23
|
-
"""
|
|
24
|
-
Resolve image name with source tracking.
|
|
25
|
-
|
|
26
|
-
Returns:
|
|
27
|
-
Tuple of (image_name, source) where source is "override", "cache", or "auto"
|
|
28
|
-
"""
|
|
29
|
-
if image_override:
|
|
30
|
-
return image_override, "override"
|
|
31
|
-
|
|
32
|
-
# Check pyproject.toml
|
|
33
|
-
pyproject_path = Path(directory) / "pyproject.toml"
|
|
34
|
-
if pyproject_path.exists():
|
|
35
|
-
try:
|
|
36
|
-
with open(pyproject_path) as f:
|
|
37
|
-
config = toml.load(f)
|
|
38
|
-
if config.get("tool", {}).get("hud", {}).get("image"):
|
|
39
|
-
return config["tool"]["hud"]["image"], "cache"
|
|
40
|
-
except Exception:
|
|
41
|
-
pass # Silent failure, will use auto-generated name
|
|
42
|
-
|
|
43
|
-
# Auto-generate with :dev tag
|
|
44
|
-
dir_path = Path(directory).resolve() # Get absolute path first
|
|
45
|
-
dir_name = dir_path.name
|
|
46
|
-
if not dir_name or dir_name == ".":
|
|
47
|
-
# If we're in root or have empty name, use parent directory
|
|
48
|
-
dir_name = dir_path.parent.name
|
|
49
|
-
clean_name = dir_name.replace("_", "-")
|
|
50
|
-
return f"hud-{clean_name}:dev", "auto"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool = False) -> None:
|
|
54
|
-
"""Update pyproject.toml with image name."""
|
|
55
|
-
pyproject_path = Path(directory) / "pyproject.toml"
|
|
56
|
-
if pyproject_path.exists():
|
|
57
|
-
try:
|
|
58
|
-
with open(pyproject_path) as f:
|
|
59
|
-
config = toml.load(f)
|
|
60
|
-
|
|
61
|
-
# Ensure [tool.hud] exists
|
|
62
|
-
if "tool" not in config:
|
|
63
|
-
config["tool"] = {}
|
|
64
|
-
if "hud" not in config["tool"]:
|
|
65
|
-
config["tool"]["hud"] = {}
|
|
66
|
-
|
|
67
|
-
# Update image name
|
|
68
|
-
config["tool"]["hud"]["image"] = image_name
|
|
69
|
-
|
|
70
|
-
# Write back
|
|
71
|
-
with open(pyproject_path, "w") as f:
|
|
72
|
-
toml.dump(config, f)
|
|
73
|
-
|
|
74
|
-
if not silent:
|
|
75
|
-
design.success(f"Updated pyproject.toml with image: {image_name}")
|
|
76
|
-
except Exception as e:
|
|
77
|
-
if not silent:
|
|
78
|
-
design.warning(f"Could not update pyproject.toml: {e}")
|
|
79
|
-
|
|
80
|
-
|
|
81
23
|
def build_and_update(directory: str | Path, image_name: str, no_cache: bool = False) -> None:
|
|
82
24
|
"""Build Docker image and update pyproject.toml."""
|
|
83
|
-
|
|
84
|
-
build_cmd = ["docker", "build", "-t", image_name]
|
|
85
|
-
if no_cache:
|
|
86
|
-
build_cmd.append("--no-cache")
|
|
87
|
-
build_cmd.append(str(directory))
|
|
88
|
-
|
|
89
|
-
design.info(f"🔨 Building image: {image_name}{' (no cache)' if no_cache else ''}")
|
|
90
|
-
design.info("") # Empty line before Docker output
|
|
91
|
-
|
|
92
|
-
# Just run Docker build directly - it has its own nice live display
|
|
93
|
-
result = subprocess.run(build_cmd) # noqa: S603
|
|
94
|
-
|
|
95
|
-
if result.returncode == 0:
|
|
96
|
-
design.info("") # Empty line after Docker output
|
|
97
|
-
design.success(f"Build successful! Image: {image_name}")
|
|
98
|
-
# Update pyproject.toml (silently since we already showed success)
|
|
99
|
-
update_pyproject_toml(directory, image_name, silent=True)
|
|
100
|
-
else:
|
|
101
|
-
design.error("Build failed!")
|
|
25
|
+
if not build_environment(directory, image_name, no_cache):
|
|
102
26
|
raise click.Abort
|
|
103
27
|
|
|
104
28
|
|
hud/cli/pull.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import subprocess
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote
|
|
7
8
|
|
|
8
9
|
import click
|
|
9
10
|
import requests
|
|
@@ -14,6 +15,8 @@ from rich.table import Table
|
|
|
14
15
|
from hud.settings import settings
|
|
15
16
|
from hud.utils.design import HUDDesign
|
|
16
17
|
|
|
18
|
+
from .registry import save_to_registry
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
def get_docker_manifest(image: str) -> dict | None:
|
|
19
22
|
"""Get manifest from Docker registry without pulling the image."""
|
|
@@ -59,7 +62,9 @@ def fetch_lock_from_registry(reference: str) -> dict | None:
|
|
|
59
62
|
if "/" in reference and ":" not in reference:
|
|
60
63
|
reference = f"{reference}:latest"
|
|
61
64
|
|
|
62
|
-
|
|
65
|
+
# URL-encode the path segments to handle special characters in tags
|
|
66
|
+
url_safe_path = "/".join(quote(part, safe="") for part in reference.split("/"))
|
|
67
|
+
registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{url_safe_path}"
|
|
63
68
|
|
|
64
69
|
headers = {}
|
|
65
70
|
if settings.api_key:
|
|
@@ -77,7 +82,18 @@ def fetch_lock_from_registry(reference: str) -> dict | None:
|
|
|
77
82
|
else:
|
|
78
83
|
# Try to treat the whole response as lock data
|
|
79
84
|
return data
|
|
80
|
-
|
|
85
|
+
elif response.status_code == 404:
|
|
86
|
+
# Not found - expected error, return None silently
|
|
87
|
+
return None
|
|
88
|
+
elif response.status_code == 401:
|
|
89
|
+
# Authentication issue - might be a private environment
|
|
90
|
+
return None
|
|
91
|
+
else:
|
|
92
|
+
# Other errors - also return None but could log if verbose
|
|
93
|
+
return None
|
|
94
|
+
except requests.exceptions.Timeout:
|
|
95
|
+
return None
|
|
96
|
+
except requests.exceptions.ConnectionError:
|
|
81
97
|
return None
|
|
82
98
|
except Exception:
|
|
83
99
|
return None
|
|
@@ -282,19 +298,8 @@ def pull_environment(
|
|
|
282
298
|
|
|
283
299
|
# Store lock file locally if we have full lock data (not minimal manifest data)
|
|
284
300
|
if lock_data and lock_data.get("source") != "docker-manifest":
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
# Store under ~/.hud/envs/<digest>/
|
|
289
|
-
local_env_dir = Path.home() / ".hud" / "envs" / digest
|
|
290
|
-
local_env_dir.mkdir(parents=True, exist_ok=True)
|
|
291
|
-
|
|
292
|
-
local_lock_path = local_env_dir / "hud.lock.yaml"
|
|
293
|
-
with open(local_lock_path, "w") as f:
|
|
294
|
-
yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
|
|
295
|
-
|
|
296
|
-
if verbose:
|
|
297
|
-
design.info(f"Stored lock file: {local_lock_path}")
|
|
301
|
+
# Save to local registry using the helper
|
|
302
|
+
save_to_registry(lock_data, image_ref, verbose)
|
|
298
303
|
|
|
299
304
|
# Success!
|
|
300
305
|
design.success("Pull complete!")
|