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/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, image_exists, inject_supervisor
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
- registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{reference}"
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
- # Extract digest from image ref
286
- digest = image_ref.split("@sha256:")[-1][:12] if "@sha256:" in image_ref else "latest"
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!")