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
@@ -5,12 +5,9 @@ from __future__ import annotations
5
5
  import base64
6
6
  import json
7
7
  import subprocess
8
- from datetime import datetime
9
- from pathlib import Path
10
8
  from unittest import mock
11
9
 
12
10
  import pytest
13
- import requests
14
11
  import typer
15
12
  import yaml
16
13
 
@@ -30,7 +27,7 @@ class TestGetDockerUsername:
30
27
  # Create mock Docker config
31
28
  docker_dir = tmp_path / ".docker"
32
29
  docker_dir.mkdir()
33
-
30
+
34
31
  config_file = docker_dir / "config.json"
35
32
  config = {
36
33
  "auths": {
@@ -40,37 +37,31 @@ class TestGetDockerUsername:
40
37
  }
41
38
  }
42
39
  config_file.write_text(json.dumps(config))
43
-
40
+
44
41
  with mock.patch("pathlib.Path.home", return_value=tmp_path):
45
42
  username = get_docker_username()
46
-
43
+
47
44
  assert username == "testuser"
48
45
 
49
46
  def test_get_username_no_config(self, tmp_path):
50
47
  """Test when no Docker config exists."""
51
48
  with mock.patch("pathlib.Path.home", return_value=tmp_path):
52
49
  username = get_docker_username()
53
-
50
+
54
51
  assert username is None
55
52
 
56
53
  def test_get_username_token_auth(self, tmp_path):
57
54
  """Test skipping token-based auth."""
58
55
  docker_dir = tmp_path / ".docker"
59
56
  docker_dir.mkdir()
60
-
57
+
61
58
  config_file = docker_dir / "config.json"
62
- config = {
63
- "auths": {
64
- "docker.io": {
65
- "auth": base64.b64encode(b"token:xyz").decode()
66
- }
67
- }
68
- }
59
+ config = {"auths": {"docker.io": {"auth": base64.b64encode(b"token:xyz").decode()}}}
69
60
  config_file.write_text(json.dumps(config))
70
-
61
+
71
62
  with mock.patch("pathlib.Path.home", return_value=tmp_path):
72
63
  username = get_docker_username()
73
-
64
+
74
65
  assert username is None
75
66
 
76
67
  @mock.patch("subprocess.run")
@@ -78,20 +69,20 @@ class TestGetDockerUsername:
78
69
  """Test getting username from credential helper."""
79
70
  docker_dir = tmp_path / ".docker"
80
71
  docker_dir.mkdir()
81
-
72
+
82
73
  config_file = docker_dir / "config.json"
83
74
  config = {"credsStore": "desktop"}
84
75
  config_file.write_text(json.dumps(config))
85
-
76
+
86
77
  # Mock credential helper calls
87
78
  mock_run.side_effect = [
88
79
  mock.Mock(returncode=0, stdout='{"https://index.docker.io/v1/": "creds"}'),
89
- mock.Mock(returncode=0, stdout='{"Username": "helperuser", "Secret": "pass"}')
80
+ mock.Mock(returncode=0, stdout='{"Username": "helperuser", "Secret": "pass"}'),
90
81
  ]
91
-
82
+
92
83
  with mock.patch("pathlib.Path.home", return_value=tmp_path):
93
84
  username = get_docker_username()
94
-
85
+
95
86
  assert username == "helperuser"
96
87
 
97
88
 
@@ -101,16 +92,9 @@ class TestGetDockerImageLabels:
101
92
  @mock.patch("subprocess.run")
102
93
  def test_get_labels_success(self, mock_run):
103
94
  """Test successfully getting image labels."""
104
- labels = {
105
- "org.hud.manifest.head": "abc123",
106
- "org.hud.version": "1.0.0"
107
- }
108
- mock_run.return_value = mock.Mock(
109
- returncode=0,
110
- stdout=json.dumps(labels),
111
- stderr=""
112
- )
113
-
95
+ labels = {"org.hud.manifest.head": "abc123", "org.hud.version": "1.0.0"}
96
+ mock_run.return_value = mock.Mock(returncode=0, stdout=json.dumps(labels), stderr="")
97
+
114
98
  result = get_docker_image_labels("test:latest")
115
99
  assert result == labels
116
100
 
@@ -118,7 +102,7 @@ class TestGetDockerImageLabels:
118
102
  def test_get_labels_failure(self, mock_run):
119
103
  """Test handling failure to get labels."""
120
104
  mock_run.side_effect = Exception("Command failed")
121
-
105
+
122
106
  result = get_docker_image_labels("test:latest")
123
107
  assert result == {}
124
108
 
@@ -131,10 +115,10 @@ class TestPushEnvironment:
131
115
  """Test pushing when no lock file exists."""
132
116
  mock_design = mock.Mock()
133
117
  mock_design_class.return_value = mock_design
134
-
118
+
135
119
  with pytest.raises(typer.Exit) as exc_info:
136
120
  push_environment(str(tmp_path))
137
-
121
+
138
122
  assert exc_info.value.exit_code == 1
139
123
  mock_design.error.assert_called()
140
124
 
@@ -145,14 +129,14 @@ class TestPushEnvironment:
145
129
  mock_design = mock.Mock()
146
130
  mock_design_class.return_value = mock_design
147
131
  mock_settings.api_key = None
148
-
132
+
149
133
  # Create lock file
150
134
  lock_file = tmp_path / "hud.lock.yaml"
151
135
  lock_file.write_text(yaml.dump({"image": "test:latest"}))
152
-
136
+
153
137
  with pytest.raises(typer.Exit) as exc_info:
154
138
  push_environment(str(tmp_path))
155
-
139
+
156
140
  assert exc_info.value.exit_code == 1
157
141
 
158
142
  @mock.patch("requests.post")
@@ -162,8 +146,14 @@ class TestPushEnvironment:
162
146
  @mock.patch("hud.cli.push.settings")
163
147
  @mock.patch("hud.cli.push.HUDDesign")
164
148
  def test_push_auto_detect_username(
165
- self, mock_design_class, mock_settings, mock_get_username,
166
- mock_run, mock_popen, mock_post, tmp_path
149
+ self,
150
+ mock_design_class,
151
+ mock_settings,
152
+ mock_get_username,
153
+ mock_run,
154
+ mock_popen,
155
+ mock_post,
156
+ tmp_path,
167
157
  ):
168
158
  """Test auto-detecting Docker username and pushing."""
169
159
  # Setup mocks
@@ -172,15 +162,12 @@ class TestPushEnvironment:
172
162
  mock_settings.api_key = "test-key"
173
163
  mock_settings.hud_telemetry_url = "https://api.hud.test"
174
164
  mock_get_username.return_value = "testuser"
175
-
165
+
176
166
  # Create lock file
177
- lock_data = {
178
- "image": "original/image:v1.0",
179
- "build": {"version": "0.1.0"}
180
- }
167
+ lock_data = {"image": "original/image:v1.0", "build": {"version": "0.1.0"}}
181
168
  lock_file = tmp_path / "hud.lock.yaml"
182
169
  lock_file.write_text(yaml.dump(lock_data))
183
-
170
+
184
171
  # Mock docker commands
185
172
  def mock_run_impl(*args, **kwargs):
186
173
  cmd = args[0]
@@ -192,50 +179,48 @@ class TestPushEnvironment:
192
179
  elif cmd[1] == "tag":
193
180
  return mock.Mock(returncode=0)
194
181
  return mock.Mock(returncode=0)
195
-
182
+
196
183
  mock_run.side_effect = mock_run_impl
197
-
184
+
198
185
  # Mock docker push
199
186
  mock_process = mock.Mock()
200
187
  mock_process.stdout = ["Pushing image...", "Push complete"]
201
188
  mock_process.wait.return_value = None
202
189
  mock_process.returncode = 0
203
190
  mock_popen.return_value = mock_process
204
-
191
+
205
192
  # Mock registry upload
206
193
  mock_post.return_value = mock.Mock(status_code=201)
207
-
194
+
208
195
  # Run push
209
196
  push_environment(str(tmp_path), yes=True)
210
-
197
+
211
198
  # Verify docker commands
212
199
  assert mock_run.call_count >= 2
213
200
  mock_popen.assert_called_once()
214
-
201
+
215
202
  # Verify registry upload
216
203
  mock_post.assert_called_once()
217
204
  call_args = mock_post.call_args
218
- assert "testuser/image:0.1.0" in call_args[0][0]
205
+ assert "testuser/image%3A0.1.0" in call_args[0][0]
219
206
 
220
207
  @mock.patch("subprocess.run")
221
208
  @mock.patch("hud.cli.push.settings")
222
209
  @mock.patch("hud.cli.push.HUDDesign")
223
- def test_push_explicit_image(
224
- self, mock_design_class, mock_settings, mock_run, tmp_path
225
- ):
210
+ def test_push_explicit_image(self, mock_design_class, mock_settings, mock_run, tmp_path):
226
211
  """Test pushing with explicit image name."""
227
212
  mock_design = mock.Mock()
228
213
  mock_design_class.return_value = mock_design
229
214
  mock_settings.api_key = "test-key"
230
-
215
+
231
216
  # Create lock file
232
217
  lock_data = {"image": "local:latest"}
233
218
  lock_file = tmp_path / "hud.lock.yaml"
234
219
  lock_file.write_text(yaml.dump(lock_data))
235
-
220
+
236
221
  # Mock docker inspect for non-existent local image
237
222
  mock_run.side_effect = subprocess.CalledProcessError(1, "docker")
238
-
223
+
239
224
  with pytest.raises(typer.Exit):
240
225
  push_environment(str(tmp_path), image="myrepo/myimage:v2")
241
226
 
@@ -243,19 +228,17 @@ class TestPushEnvironment:
243
228
  @mock.patch("subprocess.run")
244
229
  @mock.patch("hud.cli.push.settings")
245
230
  @mock.patch("hud.cli.push.HUDDesign")
246
- def test_push_with_tag(
247
- self, mock_design_class, mock_settings, mock_run, mock_popen, tmp_path
248
- ):
231
+ def test_push_with_tag(self, mock_design_class, mock_settings, mock_run, mock_popen, tmp_path):
249
232
  """Test pushing with explicit tag."""
250
233
  mock_design = mock.Mock()
251
234
  mock_design_class.return_value = mock_design
252
235
  mock_settings.api_key = "test-key"
253
-
236
+
254
237
  # Create lock file
255
238
  lock_data = {"image": "test:latest"}
256
239
  lock_file = tmp_path / "hud.lock.yaml"
257
240
  lock_file.write_text(yaml.dump(lock_data))
258
-
241
+
259
242
  # Mock docker commands
260
243
  def mock_run_impl(*args, **kwargs):
261
244
  cmd = args[0]
@@ -267,19 +250,19 @@ class TestPushEnvironment:
267
250
  elif cmd[1] == "tag":
268
251
  return mock.Mock(returncode=0)
269
252
  return mock.Mock(returncode=0)
270
-
253
+
271
254
  mock_run.side_effect = mock_run_impl
272
-
255
+
273
256
  # Mock docker push
274
257
  mock_process = mock.Mock()
275
258
  mock_process.stdout = []
276
259
  mock_process.wait.return_value = None
277
260
  mock_process.returncode = 0
278
261
  mock_popen.return_value = mock_process
279
-
262
+
280
263
  # Run push
281
264
  push_environment(str(tmp_path), image="user/test", tag="v2.0", yes=True)
282
-
265
+
283
266
  # Verify tag was used
284
267
  tag_call = [c for c in mock_run.call_args_list if c[0][0][1] == "tag"]
285
268
  assert len(tag_call) > 0
@@ -291,19 +274,21 @@ class TestPushEnvironment:
291
274
  """Test handling Docker push failure."""
292
275
  mock_design = mock.Mock()
293
276
  mock_design_class.return_value = mock_design
294
-
277
+
295
278
  # Mock docker push failure
296
279
  mock_process = mock.Mock()
297
280
  mock_process.stdout = ["Error: access denied"]
298
281
  mock_process.wait.return_value = None
299
282
  mock_process.returncode = 1
300
283
  mock_popen.return_value = mock_process
301
-
284
+
302
285
  with mock.patch("hud.cli.push.settings") as mock_settings:
303
286
  mock_settings.api_key = "test-key"
304
- with mock.patch("subprocess.run"):
305
- with pytest.raises(typer.Exit):
306
- push_environment(".", image="test:latest", yes=True)
287
+ with (
288
+ mock.patch("subprocess.run"),
289
+ pytest.raises(typer.Exit),
290
+ ):
291
+ push_environment(".", image="test:latest", yes=True)
307
292
 
308
293
  @mock.patch("hud.cli.push.get_docker_image_labels")
309
294
  @mock.patch("subprocess.run")
@@ -316,18 +301,18 @@ class TestPushEnvironment:
316
301
  mock_design = mock.Mock()
317
302
  mock_design_class.return_value = mock_design
318
303
  mock_settings.api_key = "test-key"
319
-
304
+
320
305
  # Create lock file
321
306
  lock_data = {"image": "test:latest"}
322
307
  lock_file = tmp_path / "hud.lock.yaml"
323
308
  lock_file.write_text(yaml.dump(lock_data))
324
-
309
+
325
310
  # Mock labels
326
311
  mock_get_labels.return_value = {
327
312
  "org.hud.manifest.head": "abc123def456",
328
- "org.hud.version": "1.2.3"
313
+ "org.hud.version": "1.2.3",
329
314
  }
330
-
315
+
331
316
  # Mock docker commands - first inspect succeeds to get to label check
332
317
  # Provide explicit image to bypass username check
333
318
  def mock_run_impl(*args, **kwargs):
@@ -339,13 +324,13 @@ class TestPushEnvironment:
339
324
  # Fail on tag to exit after labels are checked
340
325
  raise subprocess.CalledProcessError(1, cmd)
341
326
  return mock.Mock(returncode=0)
342
-
327
+
343
328
  mock_run.side_effect = mock_run_impl
344
-
329
+
345
330
  # Provide explicit image to ensure we reach label check
346
331
  with pytest.raises(subprocess.CalledProcessError):
347
332
  push_environment(str(tmp_path), image="test:v2", verbose=True)
348
-
333
+
349
334
  # Verify labels were checked
350
335
  mock_get_labels.assert_called_once_with("test:latest")
351
336
 
@@ -357,10 +342,8 @@ class TestPushCommand:
357
342
  """Test basic push command."""
358
343
  with mock.patch("hud.cli.push.push_environment") as mock_push:
359
344
  push_command()
360
-
361
- mock_push.assert_called_once_with(
362
- ".", None, None, False, False, False
363
- )
345
+
346
+ mock_push.assert_called_once_with(".", None, None, False, False, False)
364
347
 
365
348
  def test_push_command_with_options(self):
366
349
  """Test push command with all options."""
@@ -371,9 +354,7 @@ class TestPushCommand:
371
354
  tag="v1.0",
372
355
  sign=True,
373
356
  yes=True,
374
- verbose=True
375
- )
376
-
377
- mock_push.assert_called_once_with(
378
- "./myenv", "myrepo/myimage", "v1.0", True, True, True
357
+ verbose=True,
379
358
  )
359
+
360
+ mock_push.assert_called_once_with("./myenv", "myrepo/myimage", "v1.0", True, True, True)
@@ -5,10 +5,9 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
  from unittest import mock
7
7
 
8
- import pytest
9
8
  import yaml
10
9
 
11
- from hud.cli.registry import (
10
+ from hud.cli.utils.registry import (
12
11
  extract_digest_from_image,
13
12
  extract_name_and_tag,
14
13
  get_registry_dir,
@@ -25,7 +24,7 @@ class TestGetRegistryDir:
25
24
  """Test default registry directory."""
26
25
  with mock.patch("pathlib.Path.home") as mock_home:
27
26
  mock_home.return_value = Path("/home/user")
28
-
27
+
29
28
  registry_dir = get_registry_dir()
30
29
  assert registry_dir == Path("/home/user/.hud/envs")
31
30
 
@@ -119,65 +118,64 @@ class TestExtractNameAndTag:
119
118
  class TestSaveToRegistry:
120
119
  """Test saving to local registry."""
121
120
 
122
- @mock.patch("hud.cli.registry.HUDDesign")
121
+ @mock.patch("hud.cli.utils.registry.HUDDesign")
123
122
  def test_save_success(self, mock_design_class, tmp_path):
124
123
  """Test successful save to registry."""
125
124
  mock_design = mock.Mock()
126
125
  mock_design_class.return_value = mock_design
127
-
126
+
128
127
  # Mock home directory
129
128
  with mock.patch("pathlib.Path.home", return_value=tmp_path):
130
- lock_data = {
131
- "image": "test:latest@sha256:abc123",
132
- "tools": ["tool1", "tool2"]
133
- }
134
-
129
+ lock_data = {"image": "test:latest@sha256:abc123", "tools": ["tool1", "tool2"]}
130
+
135
131
  result = save_to_registry(lock_data, "test:latest@sha256:abc123def456789")
136
-
132
+
137
133
  assert result is not None
138
134
  assert result.exists()
139
135
  assert result.name == "hud.lock.yaml"
140
-
136
+
141
137
  # Verify content
142
138
  with open(result) as f:
143
139
  saved_data = yaml.safe_load(f)
144
140
  assert saved_data == lock_data
145
-
141
+
146
142
  # Verify directory structure
147
143
  assert result.parent.name == "abc123def456"
148
-
144
+
149
145
  mock_design.success.assert_called_once()
150
146
 
151
- @mock.patch("hud.cli.registry.HUDDesign")
147
+ @mock.patch("hud.cli.utils.registry.HUDDesign")
152
148
  def test_save_verbose(self, mock_design_class, tmp_path):
153
149
  """Test save with verbose output."""
154
150
  mock_design = mock.Mock()
155
151
  mock_design_class.return_value = mock_design
156
-
152
+
157
153
  with mock.patch("pathlib.Path.home", return_value=tmp_path):
158
154
  lock_data = {"image": "test:v1"}
159
-
155
+
160
156
  result = save_to_registry(lock_data, "test:v1", verbose=True)
161
-
157
+
162
158
  assert result is not None
163
159
  # Should show verbose info
164
160
  assert mock_design.info.call_count >= 1
165
161
 
166
- @mock.patch("hud.cli.registry.HUDDesign")
162
+ @mock.patch("hud.cli.utils.registry.HUDDesign")
167
163
  def test_save_failure(self, mock_design_class):
168
164
  """Test handling save failure."""
169
165
  mock_design = mock.Mock()
170
166
  mock_design_class.return_value = mock_design
171
-
167
+
172
168
  # Mock file operations to fail
173
- with mock.patch("builtins.open", side_effect=IOError("Permission denied")):
174
- with mock.patch("pathlib.Path.home", return_value=Path("/tmp")):
175
- lock_data = {"image": "test:latest"}
176
-
177
- result = save_to_registry(lock_data, "test:latest", verbose=True)
178
-
179
- assert result is None
180
- mock_design.warning.assert_called_once()
169
+ with (
170
+ mock.patch("builtins.open", side_effect=OSError("Permission denied")),
171
+ mock.patch("pathlib.Path.home", return_value=Path("/tmp")),
172
+ ):
173
+ lock_data = {"image": "test:latest"}
174
+
175
+ result = save_to_registry(lock_data, "test:latest", verbose=True)
176
+
177
+ assert result is None
178
+ mock_design.warning.assert_called_once()
181
179
 
182
180
 
183
181
  class TestLoadFromRegistry:
@@ -190,11 +188,11 @@ class TestLoadFromRegistry:
190
188
  registry_dir = get_registry_dir()
191
189
  digest_dir = registry_dir / "abc123"
192
190
  digest_dir.mkdir(parents=True)
193
-
191
+
194
192
  lock_data = {"image": "test:latest", "version": "1.0"}
195
193
  lock_file = digest_dir / "hud.lock.yaml"
196
194
  lock_file.write_text(yaml.dump(lock_data))
197
-
195
+
198
196
  # Load it back
199
197
  loaded = load_from_registry("abc123")
200
198
  assert loaded == lock_data
@@ -211,10 +209,10 @@ class TestLoadFromRegistry:
211
209
  registry_dir = get_registry_dir()
212
210
  digest_dir = registry_dir / "bad"
213
211
  digest_dir.mkdir(parents=True)
214
-
212
+
215
213
  lock_file = digest_dir / "hud.lock.yaml"
216
214
  lock_file.write_text("invalid: yaml: content:")
217
-
215
+
218
216
  loaded = load_from_registry("bad")
219
217
  assert loaded is None
220
218
 
@@ -232,26 +230,26 @@ class TestListRegistryEntries:
232
230
  """Test listing multiple entries."""
233
231
  with mock.patch("pathlib.Path.home", return_value=tmp_path):
234
232
  registry_dir = get_registry_dir()
235
-
233
+
236
234
  # Create several entries
237
235
  for digest in ["abc123", "def456", "ghi789"]:
238
236
  digest_dir = registry_dir / digest
239
237
  digest_dir.mkdir(parents=True)
240
238
  lock_file = digest_dir / "hud.lock.yaml"
241
239
  lock_file.write_text(f"image: test:{digest}")
242
-
240
+
243
241
  # Create a directory without lock file (should be ignored)
244
242
  (registry_dir / "nolockfile").mkdir(parents=True)
245
-
243
+
246
244
  # Create a file in registry dir (should be ignored)
247
245
  (registry_dir / "README.txt").write_text("info")
248
-
246
+
249
247
  entries = list_registry_entries()
250
-
248
+
251
249
  assert len(entries) == 3
252
250
  digests = [entry[0] for entry in entries]
253
251
  assert set(digests) == {"abc123", "def456", "ghi789"}
254
-
252
+
255
253
  # Verify all paths are lock files
256
254
  for _, lock_path in entries:
257
255
  assert lock_path.name == "hud.lock.yaml"
@@ -7,7 +7,7 @@ from unittest.mock import patch
7
7
 
8
8
  import pytest
9
9
 
10
- from hud.cli.utils import HINT_REGISTRY, CaptureLogger, Colors, analyze_error_for_hints
10
+ from hud.cli.utils.logging import HINT_REGISTRY, CaptureLogger, Colors, analyze_error_for_hints
11
11
 
12
12
 
13
13
  class TestColors:
@@ -0,0 +1 @@
1
+ """Utility modules for HUD CLI."""
@@ -81,3 +81,39 @@ def image_exists(image_name: str) -> bool:
81
81
  stderr=subprocess.DEVNULL,
82
82
  )
83
83
  return result.returncode == 0
84
+
85
+
86
+ def remove_container(container_name: str) -> bool:
87
+ """Remove a Docker container by name.
88
+
89
+ Args:
90
+ container_name: Name of the container to remove
91
+
92
+ Returns:
93
+ True if successful or container doesn't exist, False on error
94
+ """
95
+ try:
96
+ subprocess.run( # noqa: S603
97
+ ["docker", "rm", "-f", container_name], # noqa: S607
98
+ stdout=subprocess.DEVNULL,
99
+ stderr=subprocess.DEVNULL,
100
+ check=False, # Don't raise error if container doesn't exist
101
+ )
102
+ return True
103
+ except Exception:
104
+ return False
105
+
106
+
107
+ def generate_container_name(identifier: str, prefix: str = "hud") -> str:
108
+ """Generate a safe container name from an identifier.
109
+
110
+ Args:
111
+ identifier: Image name or other identifier
112
+ prefix: Prefix for the container name
113
+
114
+ Returns:
115
+ Safe container name with special characters replaced
116
+ """
117
+ # Replace special characters with hyphens
118
+ safe_name = identifier.replace(":", "-").replace("/", "-").replace("\\", "-")
119
+ return f"{prefix}-{safe_name}"
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import subprocess
6
6
  from pathlib import Path
7
- from typing import Any
8
7
 
9
8
  import toml
10
9
 
@@ -32,7 +31,7 @@ def get_image_name(directory: str | Path, image_override: str | None = None) ->
32
31
  if config.get("tool", {}).get("hud", {}).get("image"):
33
32
  return config["tool"]["hud"]["image"], "cache"
34
33
  except Exception:
35
- pass # Silent failure, will use auto-generated name
34
+ design.error("Error loading pyproject.toml")
36
35
 
37
36
  # Auto-generate with :dev tag
38
37
  dir_path = Path(directory).resolve() # Get absolute path first
@@ -74,7 +73,7 @@ def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool =
74
73
 
75
74
  def build_environment(directory: str | Path, image_name: str, no_cache: bool = False) -> bool:
76
75
  """Build Docker image for an environment.
77
-
76
+
78
77
  Returns:
79
78
  True if build succeeded, False otherwise
80
79
  """
@@ -112,7 +111,7 @@ def image_exists(image_name: str) -> bool:
112
111
 
113
112
  def is_environment_directory(path: str | Path) -> bool:
114
113
  """Check if a path looks like an environment directory.
115
-
114
+
116
115
  An environment directory should have:
117
116
  - A Dockerfile
118
117
  - A pyproject.toml file
@@ -121,13 +120,14 @@ def is_environment_directory(path: str | Path) -> bool:
121
120
  dir_path = Path(path)
122
121
  if not dir_path.is_dir():
123
122
  return False
124
-
123
+
125
124
  # Must have Dockerfile
126
125
  if not (dir_path / "Dockerfile").exists():
127
126
  return False
128
-
127
+
129
128
  # Must have pyproject.toml
130
129
  if not (dir_path / "pyproject.toml").exists():
130
+ design.error("pyproject.toml not found")
131
131
  return False
132
-
132
+
133
133
  return True