hud-python 0.4.11__py3-none-any.whl → 0.4.13__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.

Files changed (63) hide show
  1. hud/__main__.py +8 -0
  2. hud/agents/base.py +7 -8
  3. hud/agents/langchain.py +2 -2
  4. hud/agents/tests/test_openai.py +3 -1
  5. hud/cli/__init__.py +114 -52
  6. hud/cli/build.py +121 -71
  7. hud/cli/debug.py +2 -2
  8. hud/cli/{mcp_server.py → dev.py} +101 -38
  9. hud/cli/eval.py +175 -90
  10. hud/cli/init.py +442 -64
  11. hud/cli/list_func.py +72 -71
  12. hud/cli/pull.py +1 -2
  13. hud/cli/push.py +35 -23
  14. hud/cli/remove.py +35 -41
  15. hud/cli/tests/test_analyze.py +2 -1
  16. hud/cli/tests/test_analyze_metadata.py +42 -49
  17. hud/cli/tests/test_build.py +28 -52
  18. hud/cli/tests/test_cursor.py +1 -1
  19. hud/cli/tests/test_debug.py +1 -1
  20. hud/cli/tests/test_list_func.py +75 -64
  21. hud/cli/tests/test_main_module.py +30 -0
  22. hud/cli/tests/test_mcp_server.py +3 -3
  23. hud/cli/tests/test_pull.py +30 -61
  24. hud/cli/tests/test_push.py +70 -89
  25. hud/cli/tests/test_registry.py +36 -38
  26. hud/cli/tests/test_utils.py +1 -1
  27. hud/cli/utils/__init__.py +1 -0
  28. hud/cli/{docker_utils.py → utils/docker.py} +36 -0
  29. hud/cli/{env_utils.py → utils/environment.py} +7 -7
  30. hud/cli/{interactive.py → utils/interactive.py} +91 -19
  31. hud/cli/{analyze_metadata.py → utils/metadata.py} +12 -8
  32. hud/cli/{registry.py → utils/registry.py} +28 -30
  33. hud/cli/{remote_runner.py → utils/remote_runner.py} +1 -1
  34. hud/cli/utils/runner.py +134 -0
  35. hud/cli/utils/server.py +250 -0
  36. hud/clients/base.py +1 -1
  37. hud/clients/fastmcp.py +5 -13
  38. hud/clients/mcp_use.py +6 -10
  39. hud/server/server.py +35 -5
  40. hud/shared/exceptions.py +11 -0
  41. hud/shared/tests/test_exceptions.py +22 -0
  42. hud/telemetry/tests/__init__.py +0 -0
  43. hud/telemetry/tests/test_replay.py +40 -0
  44. hud/telemetry/tests/test_trace.py +63 -0
  45. hud/tools/base.py +20 -3
  46. hud/tools/computer/hud.py +15 -6
  47. hud/tools/executors/tests/test_base_executor.py +27 -0
  48. hud/tools/response.py +12 -8
  49. hud/tools/tests/test_response.py +60 -0
  50. hud/tools/tests/test_tools_init.py +49 -0
  51. hud/utils/design.py +19 -8
  52. hud/utils/mcp.py +17 -5
  53. hud/utils/tests/test_mcp.py +112 -0
  54. hud/utils/tests/test_version.py +1 -1
  55. hud/version.py +1 -1
  56. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/METADATA +16 -13
  57. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/RECORD +62 -52
  58. hud/cli/runner.py +0 -160
  59. /hud/cli/{cursor.py → utils/cursor.py} +0 -0
  60. /hud/cli/{utils.py → utils/logging.py} +0 -0
  61. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/WHEEL +0 -0
  62. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/entry_points.txt +0 -0
  63. {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/licenses/LICENSE +0 -0
hud/cli/list_func.py CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
6
5
  from datetime import datetime
7
- from pathlib import Path
8
6
 
9
7
  import typer
10
8
  import yaml
@@ -12,30 +10,33 @@ from rich.table import Table
12
10
 
13
11
  from hud.utils.design import HUDDesign
14
12
 
15
- from .registry import get_registry_dir, list_registry_entries, extract_name_and_tag
13
+ from .utils.registry import extract_name_and_tag, get_registry_dir, list_registry_entries
16
14
 
17
15
 
18
16
  def format_timestamp(timestamp: float | None) -> str:
19
17
  """Format timestamp to human-readable relative time."""
20
18
  if not timestamp:
21
19
  return "unknown"
22
-
20
+
23
21
  dt = datetime.fromtimestamp(timestamp)
24
22
  now = datetime.now()
25
23
  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:
24
+
25
+ # Get total seconds to handle edge cases properly
26
+ total_seconds = delta.total_seconds()
27
+
28
+ if total_seconds < 60:
29
+ return "just now"
30
+ elif total_seconds < 3600:
31
+ return f"{int(total_seconds // 60)}m ago"
32
+ elif total_seconds < 86400: # Less than 24 hours
33
+ return f"{int(total_seconds // 3600)}h ago"
34
+ elif delta.days < 30:
32
35
  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"
36
+ elif delta.days < 365:
37
+ return f"{delta.days // 30}mo ago"
37
38
  else:
38
- return "just now"
39
+ return f"{delta.days // 365}y ago"
39
40
 
40
41
 
41
42
  def list_environments(
@@ -46,71 +47,73 @@ def list_environments(
46
47
  ) -> None:
47
48
  """List all HUD environments in the local registry."""
48
49
  design = HUDDesign()
49
-
50
+
50
51
  if not json_output:
51
52
  design.header("HUD Environment Registry")
52
-
53
+
53
54
  # Check for environment directory
54
55
  env_dir = get_registry_dir()
55
56
  if not env_dir.exists():
56
57
  if json_output:
57
- print("[]")
58
+ print("[]") # noqa: T201
58
59
  else:
59
60
  design.info("No environments found in local registry.")
60
61
  design.info("")
61
62
  design.info("Pull environments with: [cyan]hud pull <org/name:tag>[/cyan]")
62
63
  design.info("Build environments with: [cyan]hud build[/cyan]")
63
64
  return
64
-
65
+
65
66
  # Collect all environments using the registry helper
66
67
  environments = []
67
-
68
+
68
69
  for digest, lock_file in list_registry_entries():
69
70
  try:
70
71
  # Read lock file
71
72
  with open(lock_file) as f:
72
73
  lock_data = yaml.safe_load(f)
73
-
74
+
74
75
  # Extract metadata
75
76
  image = lock_data.get("image", "unknown")
76
77
  name, tag = extract_name_and_tag(image)
77
-
78
+
78
79
  # Apply filter if specified
79
80
  if filter_name and filter_name.lower() not in name.lower():
80
81
  continue
81
-
82
+
82
83
  # Get additional metadata
83
84
  metadata = lock_data.get("metadata", {})
84
85
  description = metadata.get("description", "")
85
86
  tools_count = len(metadata.get("tools", []))
86
-
87
+
87
88
  # Get file modification time as pulled time
88
89
  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
-
90
+
91
+ environments.append(
92
+ {
93
+ "name": name,
94
+ "tag": tag,
95
+ "digest": digest,
96
+ "description": description,
97
+ "tools_count": tools_count,
98
+ "pulled_time": pulled_time,
99
+ "image": image,
100
+ "path": str(lock_file),
101
+ }
102
+ )
103
+
101
104
  except Exception as e:
102
105
  if verbose:
103
106
  design.warning(f"Failed to read {lock_file}: {e}")
104
-
107
+
105
108
  # Sort by pulled time (newest first)
106
109
  environments.sort(key=lambda x: x["pulled_time"], reverse=True)
107
-
110
+
108
111
  if json_output:
109
112
  # Output as JSON
110
113
  import json
111
- json_data = []
112
- for env in environments:
113
- json_data.append({
114
+
115
+ json_data = [
116
+ {
114
117
  "name": env["name"],
115
118
  "tag": env["tag"],
116
119
  "digest": env["digest"],
@@ -118,67 +121,71 @@ def list_environments(
118
121
  "tools_count": env["tools_count"],
119
122
  "pulled_time": env["pulled_time"],
120
123
  "image": env["image"],
121
- "path": env["path"],
122
- })
123
- print(json.dumps(json_data, indent=2))
124
+ "path": str(env["path"]).replace("\\", "/"), # Normalize path separators for JSON
125
+ }
126
+ for env in environments
127
+ ]
128
+ print(json.dumps(json_data, indent=2)) # noqa: T201
124
129
  return
125
-
130
+
126
131
  if not environments:
127
132
  design.info("No environments found matching criteria.")
128
133
  design.info("")
129
134
  design.info("Pull environments with: [cyan]hud pull <org/name:tag>[/cyan]")
130
135
  design.info("Build environments with: [cyan]hud build[/cyan]")
131
136
  return
132
-
137
+
133
138
  # Create table
134
- table = Table(title=f"Found {len(environments)} environment{'s' if len(environments) != 1 else ''}")
139
+ table = Table(
140
+ title=f"Found {len(environments)} environment{'s' if len(environments) != 1 else ''}"
141
+ )
135
142
  table.add_column("Environment", style="cyan", no_wrap=True)
136
143
  table.add_column("Description", style="white")
137
144
  table.add_column("Tools", justify="right", style="yellow")
138
145
  table.add_column("Pulled", style="dim")
139
-
146
+
140
147
  if show_all or verbose:
141
148
  table.add_column("Digest", style="dim")
142
-
149
+
143
150
  # Add rows
144
151
  for env in environments:
145
152
  # Truncate description if too long
146
153
  desc = env["description"]
147
154
  if desc and len(desc) > 50 and not verbose:
148
155
  desc = desc[:47] + "..."
149
-
156
+
150
157
  # Combine name and tag for easy copying
151
158
  full_ref = f"{env['name']}:{env['tag']}"
152
-
159
+
153
160
  row = [
154
161
  full_ref,
155
162
  desc or "[dim]No description[/dim]",
156
163
  str(env["tools_count"]),
157
164
  format_timestamp(env["pulled_time"]),
158
165
  ]
159
-
166
+
160
167
  if show_all or verbose:
161
168
  row.append(env["digest"][:12])
162
-
169
+
163
170
  table.add_row(*row)
164
-
165
- design.print(table)
171
+
172
+ design.print(table) # type: ignore
166
173
  design.info("")
167
-
174
+
168
175
  # Show usage hints
169
176
  design.section_title("Usage")
170
177
  if environments:
171
178
  # Use the most recently pulled environment as example
172
179
  example_env = environments[0]
173
180
  example_ref = f"{example_env['name']}:{example_env['tag']}"
174
-
181
+
175
182
  design.info(f"Run an environment: [cyan]hud run {example_ref}[/cyan]")
176
183
  design.info(f"Analyze tools: [cyan]hud analyze {example_ref}[/cyan]")
177
184
  design.info(f"Debug server: [cyan]hud debug {example_ref}[/cyan]")
178
-
185
+
179
186
  design.info("Pull more environments: [cyan]hud pull <org/name:tag>[/cyan]")
180
187
  design.info("Build new environments: [cyan]hud build[/cyan]")
181
-
188
+
182
189
  if verbose:
183
190
  design.info("")
184
191
  design.info(f"[dim]Registry location: {env_dir}[/dim]")
@@ -188,21 +195,15 @@ def list_command(
188
195
  filter_name: str | None = typer.Option(
189
196
  None, "--filter", "-f", help="Filter environments by name (case-insensitive)"
190
197
  ),
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
- ),
198
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
199
+ show_all: bool = typer.Option(False, "--all", "-a", help="Show all columns including digest"),
200
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
200
201
  ) -> None:
201
202
  """📋 List all HUD environments in local registry.
202
-
203
+
203
204
  Shows environments pulled with 'hud pull' or built with 'hud build',
204
205
  stored in ~/.hud/envs/
205
-
206
+
206
207
  Examples:
207
208
  hud list # List all environments
208
209
  hud list --filter text # Filter by name
@@ -210,4 +211,4 @@ def list_command(
210
211
  hud list --all # Show digest column
211
212
  hud list --verbose # Show full descriptions
212
213
  """
213
- list_environments(filter_name, json_output, show_all, verbose)
214
+ list_environments(filter_name, json_output, show_all, verbose)
hud/cli/pull.py CHANGED
@@ -6,7 +6,6 @@ import subprocess
6
6
  from pathlib import Path
7
7
  from urllib.parse import quote
8
8
 
9
- import click
10
9
  import requests
11
10
  import typer
12
11
  import yaml
@@ -15,7 +14,7 @@ from rich.table import Table
15
14
  from hud.settings import settings
16
15
  from hud.utils.design import HUDDesign
17
16
 
18
- from .registry import save_to_registry
17
+ from .utils.registry import save_to_registry
19
18
 
20
19
 
21
20
  def get_docker_manifest(image: str) -> dict | None:
hud/cli/push.py CHANGED
@@ -21,6 +21,7 @@ def _get_response_text(response: requests.Response) -> str:
21
21
  except Exception:
22
22
  return response.text
23
23
 
24
+
24
25
  def get_docker_username() -> str | None:
25
26
  """Get the current Docker username if logged in."""
26
27
  try:
@@ -53,10 +54,10 @@ def get_docker_username() -> str | None:
53
54
  username = decoded.split(":", 1)[0]
54
55
  if username and username != "token": # Skip token-based auth
55
56
  return username
56
- except Exception:
57
- pass # Silent failure, try other methods
58
- except Exception:
59
- pass # Silent failure, try other methods
57
+ except Exception: # noqa: S110
58
+ pass
59
+ except Exception: # noqa: S110
60
+ pass
60
61
 
61
62
  # Alternative: Check credsStore/credHelpers
62
63
  for config_path in config_paths:
@@ -91,12 +92,12 @@ def get_docker_username() -> str | None:
91
92
  username = cred_data.get("Username", "")
92
93
  if username and username != "token":
93
94
  return username
94
- except Exception:
95
- pass # Silent failure, try other methods
96
- except Exception:
97
- pass # Silent failure, try other methods
98
- except Exception:
99
- pass # Silent failure, try other methods
95
+ except Exception: # noqa: S110
96
+ pass
97
+ except Exception: # noqa: S110
98
+ pass
99
+ except Exception: # noqa: S110
100
+ pass
100
101
  return None
101
102
 
102
103
 
@@ -155,7 +156,7 @@ def push_environment(
155
156
  if not local_image and "build" in lock_data:
156
157
  # New format might have image elsewhere
157
158
  local_image = lock_data.get("image", "")
158
-
159
+
159
160
  # Get internal version from lock file
160
161
  internal_version = lock_data.get("build", {}).get("version", None)
161
162
 
@@ -206,21 +207,25 @@ def push_environment(
206
207
  # Handle tag when image is provided
207
208
  # Prefer explicit tag over internal version
208
209
  final_tag = tag if tag else internal_version
209
-
210
+
210
211
  if ":" in image:
211
212
  # Image already has a tag
212
213
  existing_tag = image.split(":")[-1]
213
214
  if existing_tag != final_tag:
214
215
  if tag:
215
- design.warning(f"Image already has tag '{existing_tag}', overriding with '{final_tag}'")
216
+ design.warning(
217
+ f"Image already has tag '{existing_tag}', overriding with '{final_tag}'"
218
+ )
216
219
  else:
217
- design.info(f"Image has tag '{existing_tag}', but using internal version '{final_tag}'")
220
+ design.info(
221
+ f"Image has tag '{existing_tag}', but using internal version '{final_tag}'"
222
+ )
218
223
  image = image.rsplit(":", 1)[0] + f":{final_tag}"
219
224
  # else: tags match, no action needed
220
225
  else:
221
226
  # Image has no tag, append the appropriate one
222
227
  image = f"{image}:{final_tag}"
223
-
228
+
224
229
  if tag:
225
230
  design.info(f"Using specified tag: {tag}")
226
231
  else:
@@ -230,7 +235,7 @@ def push_environment(
230
235
  # Verify local image exists
231
236
  # Extract the tag part (before @sha256:...) for Docker operations
232
237
  local_tag = local_image.split("@")[0] if "@" in local_image else local_image
233
-
238
+
234
239
  # Also check for version-tagged image if we have internal version
235
240
  version_tag = None
236
241
  if internal_version and ":" in local_tag:
@@ -246,7 +251,7 @@ def push_environment(
246
251
  design.info(f"Found version-tagged image: {version_tag}")
247
252
  except subprocess.CalledProcessError:
248
253
  pass
249
-
254
+
250
255
  if not image_to_push:
251
256
  try:
252
257
  subprocess.run(["docker", "inspect", local_tag], capture_output=True, check=True) # noqa: S603, S607
@@ -319,7 +324,7 @@ def push_environment(
319
324
  lock_data["image"] = pushed_digest
320
325
 
321
326
  # Add push information
322
- from datetime import datetime, UTC
327
+ from datetime import UTC, datetime
323
328
 
324
329
  lock_data["push"] = {
325
330
  "source": local_image,
@@ -340,13 +345,20 @@ def push_environment(
340
345
  # e.g., "hudpython/test_init:v1.0" -> "hudpython/test_init:v1.0"
341
346
  # Use the original image name for the registry path, not the digest
342
347
  # The digest might not contain the tag information
343
- registry_image = image # This is the image we tagged and pushed (e.g., hudpython/hud-text-2048:0.1.2)
344
-
348
+ registry_image = (
349
+ image # This is the image we tagged and pushed (e.g., hudpython/hud-text-2048:0.1.2)
350
+ )
351
+
345
352
  # Remove any registry prefix for the HUD registry path
346
353
  registry_parts = registry_image.split("/")
347
354
  if len(registry_parts) >= 2:
348
355
  # Handle docker.io/org/name or just org/name
349
- if registry_parts[0] in ["docker.io", "registry-1.docker.io", "index.docker.io", "ghcr.io"]:
356
+ if registry_parts[0] in [
357
+ "docker.io",
358
+ "registry-1.docker.io",
359
+ "index.docker.io",
360
+ "ghcr.io",
361
+ ]:
350
362
  # Remove registry prefix
351
363
  name_with_tag = "/".join(registry_parts[1:])
352
364
  elif "." in registry_parts[0] or ":" in registry_parts[0]:
@@ -359,12 +371,12 @@ def push_environment(
359
371
  name_with_tag = registry_image
360
372
 
361
373
  # The image variable already has the tag, no need to add :latest
362
-
374
+
363
375
  # Validate the image format
364
376
  if not name_with_tag:
365
377
  design.warning("Could not determine image name for registry upload")
366
378
  raise typer.Exit(0)
367
-
379
+
368
380
  # For HUD registry, we need org/name format
369
381
  if "/" not in name_with_tag:
370
382
  design.warning("Image name must include organization/namespace for HUD registry")
hud/cli/remove.py CHANGED
@@ -3,14 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import shutil
6
- from pathlib import Path
7
6
 
8
7
  import typer
9
- import yaml
10
8
 
11
9
  from hud.utils.design import HUDDesign
12
10
 
13
- from .registry import get_registry_dir, list_registry_entries, load_from_registry
11
+ from .utils.registry import get_registry_dir, list_registry_entries, load_from_registry
14
12
 
15
13
 
16
14
  def remove_environment(
@@ -21,18 +19,18 @@ def remove_environment(
21
19
  """Remove an environment from the local registry."""
22
20
  design = HUDDesign()
23
21
  design.header("HUD Environment Removal")
24
-
22
+
25
23
  # Find the environment to remove
26
24
  found_entry = None
27
25
  found_digest = None
28
-
26
+
29
27
  # First check if target is a digest
30
28
  for digest, lock_file in list_registry_entries():
31
29
  if digest.startswith(target):
32
30
  found_entry = lock_file
33
31
  found_digest = digest
34
32
  break
35
-
33
+
36
34
  # If not found by digest, search by name
37
35
  if not found_entry:
38
36
  for digest, lock_file in list_registry_entries():
@@ -42,28 +40,29 @@ def remove_environment(
42
40
  image = lock_data["image"]
43
41
  # Extract name and tag
44
42
  name = image.split("@")[0] if "@" in image else image
45
- if "/" in name:
46
- # Match by repo/name or just name
47
- if target in name or name.endswith(f"/{target}"):
48
- found_entry = lock_file
49
- found_digest = digest
50
- break
51
- except Exception:
43
+ if "/" in name and (target in name or name.endswith(f"/{target}")):
44
+ found_entry = lock_file
45
+ found_digest = digest
46
+ break
47
+ except Exception as e:
48
+ design.error(f"Error loading lock file: {e}")
52
49
  continue
53
-
50
+
54
51
  if not found_entry:
55
52
  design.error(f"Environment not found: {target}")
56
53
  design.info("Use 'hud list' to see available environments")
57
54
  raise typer.Exit(1)
58
-
55
+
59
56
  # Load and display environment info
60
57
  try:
58
+ if found_digest is None:
59
+ raise ValueError("Found digest is None")
61
60
  lock_data = load_from_registry(found_digest)
62
61
  if lock_data:
63
62
  image = lock_data.get("image", "unknown")
64
63
  metadata = lock_data.get("metadata", {})
65
64
  description = metadata.get("description", "No description")
66
-
65
+
67
66
  design.section_title("Environment Details")
68
67
  design.status_item("Image", image)
69
68
  design.status_item("Digest", found_digest)
@@ -72,20 +71,20 @@ def remove_environment(
72
71
  except Exception as e:
73
72
  if verbose:
74
73
  design.warning(f"Could not read environment details: {e}")
75
-
74
+
76
75
  # Confirm deletion
77
76
  if not yes:
78
77
  design.info("")
79
78
  if not typer.confirm(f"Remove environment {found_digest}?"):
80
79
  design.info("Aborted")
81
80
  raise typer.Exit(0)
82
-
81
+
83
82
  # Remove the environment directory
84
83
  try:
85
84
  env_dir = found_entry.parent
86
85
  shutil.rmtree(env_dir)
87
86
  design.success(f"Removed environment: {found_digest}")
88
-
87
+
89
88
  # Check if the image is still available locally
90
89
  if lock_data:
91
90
  image = lock_data.get("image", "")
@@ -95,7 +94,7 @@ def remove_environment(
95
94
  design.info(f"To remove it, run: [cyan]docker rmi {image.split('@')[0]}[/cyan]")
96
95
  except Exception as e:
97
96
  design.error(f"Failed to remove environment: {e}")
98
- raise typer.Exit(1)
97
+ raise typer.Exit(1) from e
99
98
 
100
99
 
101
100
  def remove_all_environments(
@@ -105,23 +104,23 @@ def remove_all_environments(
105
104
  """Remove all environments from the local registry."""
106
105
  design = HUDDesign()
107
106
  design.header("Remove All HUD Environments")
108
-
107
+
109
108
  registry_dir = get_registry_dir()
110
109
  if not registry_dir.exists():
111
110
  design.info("No environments found in local registry.")
112
111
  return
113
-
112
+
114
113
  # Count environments
115
114
  entries = list(list_registry_entries())
116
115
  if not entries:
117
116
  design.info("No environments found in local registry.")
118
117
  return
119
-
118
+
120
119
  design.warning(f"This will remove {len(entries)} environment(s) from the local registry!")
121
-
120
+
122
121
  # List environments that will be removed
123
122
  design.section_title("Environments to Remove")
124
- for digest, lock_file in entries:
123
+ for digest, _ in entries:
125
124
  try:
126
125
  lock_data = load_from_registry(digest)
127
126
  if lock_data:
@@ -129,18 +128,18 @@ def remove_all_environments(
129
128
  design.info(f" • {digest[:12]} - {image}")
130
129
  except Exception:
131
130
  design.info(f" • {digest[:12]}")
132
-
131
+
133
132
  # Confirm deletion
134
133
  if not yes:
135
134
  design.info("")
136
135
  if not typer.confirm("Remove ALL environments?", default=False):
137
136
  design.info("Aborted")
138
137
  raise typer.Exit(0)
139
-
138
+
140
139
  # Remove all environments
141
140
  removed = 0
142
141
  failed = 0
143
-
142
+
144
143
  for digest, lock_file in entries:
145
144
  try:
146
145
  env_dir = lock_file.parent
@@ -152,13 +151,13 @@ def remove_all_environments(
152
151
  failed += 1
153
152
  if verbose:
154
153
  design.error(f"Failed to remove {digest}: {e}")
155
-
154
+
156
155
  design.info("")
157
156
  if failed == 0:
158
157
  design.success(f"Successfully removed {removed} environment(s)")
159
158
  else:
160
159
  design.warning(f"Removed {removed} environment(s), failed to remove {failed}")
161
-
160
+
162
161
  design.info("")
163
162
  design.info("Note: Docker images may still exist locally.")
164
163
  design.info("To remove them, use: [cyan]docker image prune[/cyan]")
@@ -166,21 +165,16 @@ def remove_all_environments(
166
165
 
167
166
  def remove_command(
168
167
  target: str | None = typer.Argument(
169
- None,
170
- help="Environment to remove (digest, name, or 'all' for all environments)"
171
- ),
172
- yes: bool = typer.Option(
173
- False, "--yes", "-y", help="Skip confirmation prompt"
174
- ),
175
- verbose: bool = typer.Option(
176
- False, "--verbose", "-v", help="Show detailed output"
168
+ None, help="Environment to remove (digest, name, or 'all' for all environments)"
177
169
  ),
170
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
171
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
178
172
  ) -> None:
179
173
  """🗑️ Remove HUD environments from local registry.
180
-
174
+
181
175
  Removes environment metadata from ~/.hud/envs/
182
176
  Note: This does not remove the Docker images.
183
-
177
+
184
178
  Examples:
185
179
  hud remove abc123 # Remove by digest
186
180
  hud remove text_2048 # Remove by name
@@ -193,7 +187,7 @@ def remove_command(
193
187
  design.error("Please specify an environment to remove or 'all'")
194
188
  design.info("Use 'hud list' to see available environments")
195
189
  raise typer.Exit(1)
196
-
190
+
197
191
  if target.lower() == "all":
198
192
  remove_all_environments(yes, verbose)
199
193
  else:
@@ -82,6 +82,7 @@ class TestAnalyzeEnvironment:
82
82
  with (
83
83
  patch("hud.cli.analyze.MCPClient") as MockClient,
84
84
  patch("hud.cli.analyze.console") as mock_console,
85
+ patch("platform.system", return_value="Windows"),
85
86
  ):
86
87
  # Setup mock client that will raise exception during initialization
87
88
  mock_client = MagicMock()
@@ -100,7 +101,7 @@ class TestAnalyzeEnvironment:
100
101
  mock_client.initialize.assert_called_once()
101
102
  mock_client.shutdown.assert_called_once()
102
103
 
103
- # Check console printed error hints
104
+ # Check console printed Windows-specific error hints
104
105
  calls = mock_console.print.call_args_list
105
106
  assert any("Docker logs may not show on Windows" in str(call) for call in calls)
106
107