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 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 run arguments (e.g., 'hud-image:latest -e KEY=value')", # noqa: E501
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 hud-text-2048:latest
161
- hud debug my-mcp-server:v1 -e API_KEY=xxx -p 8080:8080
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 hud-browser:dev --max-phase 3
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
- image, *docker_args = params
187
- # Build Docker command
188
- command = ["docker", "run", "--rm", "-i", *docker_args, image]
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 either a Docker image, --config, or --cursor[/red]")
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 hud-text-2048:latest")
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
- url = os.getenv("HUD_MCP_URL", "https://mcp.hud.so/v3/mcp")
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
@@ -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
- registry_url = f"{settings.hud_telemetry_url.rstrip('/')}/registry/envs/{reference}"
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
- # Extract digest if present
54
- if "@sha256:" in reference:
55
- digest = reference.split("@sha256:")[-1][:12]
56
- elif "/" in reference:
57
- # Try to find by name pattern
58
- cache_dir = Path.home() / ".hud" / "envs"
59
- if cache_dir.exists():
60
- # Look for any cached version of this image
61
- for env_dir in cache_dir.iterdir():
62
- if env_dir.is_dir():
63
- lock_file = env_dir / "hud.lock.yaml"
64
- if lock_file.exists():
65
- with open(lock_file) as f:
66
- lock_data = yaml.safe_load(f)
67
- # Check if this matches our reference
68
- if lock_data and "image" in lock_data:
69
- image = lock_data["image"]
70
- # Match by name (ignoring tag/digest)
71
- ref_base = reference.split("@")[0].split(":")[0]
72
- img_base = image.split("@")[0].split(":")[0]
73
- if ref_base in img_base or img_base in ref_base:
74
- return lock_data
75
- return None
76
- else:
77
- digest = "latest"
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
- if "@sha256:" in lock_data.get("image", ""):
151
- digest = lock_data["image"].split("@sha256:")[-1][:12]
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
- continue
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