hud-python 0.3.5__py3-none-any.whl → 0.4.1__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 (192) hide show
  1. hud/__init__.py +22 -89
  2. hud/agents/__init__.py +15 -0
  3. hud/agents/art.py +101 -0
  4. hud/agents/base.py +599 -0
  5. hud/{mcp → agents}/claude.py +373 -321
  6. hud/{mcp → agents}/langchain.py +250 -250
  7. hud/agents/misc/__init__.py +7 -0
  8. hud/{agent → agents}/misc/response_agent.py +80 -80
  9. hud/{mcp → agents}/openai.py +352 -334
  10. hud/agents/openai_chat_generic.py +154 -0
  11. hud/{mcp → agents}/tests/__init__.py +1 -1
  12. hud/agents/tests/test_base.py +742 -0
  13. hud/agents/tests/test_claude.py +324 -0
  14. hud/{mcp → agents}/tests/test_client.py +363 -324
  15. hud/{mcp → agents}/tests/test_openai.py +237 -238
  16. hud/cli/__init__.py +617 -0
  17. hud/cli/__main__.py +8 -0
  18. hud/cli/analyze.py +371 -0
  19. hud/cli/analyze_metadata.py +230 -0
  20. hud/cli/build.py +427 -0
  21. hud/cli/clone.py +185 -0
  22. hud/cli/cursor.py +92 -0
  23. hud/cli/debug.py +392 -0
  24. hud/cli/docker_utils.py +83 -0
  25. hud/cli/init.py +281 -0
  26. hud/cli/interactive.py +353 -0
  27. hud/cli/mcp_server.py +756 -0
  28. hud/cli/pull.py +336 -0
  29. hud/cli/push.py +370 -0
  30. hud/cli/remote_runner.py +311 -0
  31. hud/cli/runner.py +160 -0
  32. hud/cli/tests/__init__.py +3 -0
  33. hud/cli/tests/test_analyze.py +284 -0
  34. hud/cli/tests/test_cli_init.py +265 -0
  35. hud/cli/tests/test_cli_main.py +27 -0
  36. hud/cli/tests/test_clone.py +142 -0
  37. hud/cli/tests/test_cursor.py +253 -0
  38. hud/cli/tests/test_debug.py +453 -0
  39. hud/cli/tests/test_mcp_server.py +139 -0
  40. hud/cli/tests/test_utils.py +388 -0
  41. hud/cli/utils.py +263 -0
  42. hud/clients/README.md +143 -0
  43. hud/clients/__init__.py +16 -0
  44. hud/clients/base.py +379 -0
  45. hud/clients/fastmcp.py +222 -0
  46. hud/clients/mcp_use.py +278 -0
  47. hud/clients/tests/__init__.py +1 -0
  48. hud/clients/tests/test_client_integration.py +111 -0
  49. hud/clients/tests/test_fastmcp.py +342 -0
  50. hud/clients/tests/test_protocol.py +188 -0
  51. hud/clients/utils/__init__.py +1 -0
  52. hud/clients/utils/retry_transport.py +160 -0
  53. hud/datasets.py +322 -192
  54. hud/misc/__init__.py +1 -0
  55. hud/{agent → misc}/claude_plays_pokemon.py +292 -283
  56. hud/otel/__init__.py +35 -0
  57. hud/otel/collector.py +142 -0
  58. hud/otel/config.py +164 -0
  59. hud/otel/context.py +536 -0
  60. hud/otel/exporters.py +366 -0
  61. hud/otel/instrumentation.py +97 -0
  62. hud/otel/processors.py +118 -0
  63. hud/otel/tests/__init__.py +1 -0
  64. hud/otel/tests/test_processors.py +197 -0
  65. hud/server/__init__.py +5 -5
  66. hud/server/context.py +114 -0
  67. hud/server/helper/__init__.py +5 -0
  68. hud/server/low_level.py +132 -0
  69. hud/server/server.py +166 -0
  70. hud/server/tests/__init__.py +3 -0
  71. hud/settings.py +73 -79
  72. hud/shared/__init__.py +5 -0
  73. hud/{exceptions.py → shared/exceptions.py} +180 -180
  74. hud/{server → shared}/requests.py +264 -264
  75. hud/shared/tests/test_exceptions.py +157 -0
  76. hud/{server → shared}/tests/test_requests.py +275 -275
  77. hud/telemetry/__init__.py +25 -30
  78. hud/telemetry/instrument.py +379 -0
  79. hud/telemetry/job.py +309 -141
  80. hud/telemetry/replay.py +74 -0
  81. hud/telemetry/trace.py +83 -0
  82. hud/tools/__init__.py +33 -34
  83. hud/tools/base.py +365 -65
  84. hud/tools/bash.py +161 -137
  85. hud/tools/computer/__init__.py +15 -13
  86. hud/tools/computer/anthropic.py +437 -420
  87. hud/tools/computer/hud.py +376 -334
  88. hud/tools/computer/openai.py +295 -292
  89. hud/tools/computer/settings.py +82 -0
  90. hud/tools/edit.py +314 -290
  91. hud/tools/executors/__init__.py +30 -30
  92. hud/tools/executors/base.py +539 -532
  93. hud/tools/executors/pyautogui.py +621 -619
  94. hud/tools/executors/tests/__init__.py +1 -1
  95. hud/tools/executors/tests/test_base_executor.py +338 -338
  96. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  97. hud/tools/executors/xdo.py +511 -503
  98. hud/tools/{playwright_tool.py → playwright.py} +412 -379
  99. hud/tools/tests/__init__.py +3 -3
  100. hud/tools/tests/test_base.py +282 -0
  101. hud/tools/tests/test_bash.py +158 -152
  102. hud/tools/tests/test_bash_extended.py +197 -0
  103. hud/tools/tests/test_computer.py +425 -52
  104. hud/tools/tests/test_computer_actions.py +34 -34
  105. hud/tools/tests/test_edit.py +259 -240
  106. hud/tools/tests/test_init.py +27 -27
  107. hud/tools/tests/test_playwright_tool.py +183 -183
  108. hud/tools/tests/test_tools.py +145 -157
  109. hud/tools/tests/test_utils.py +156 -156
  110. hud/tools/types.py +72 -0
  111. hud/tools/utils.py +50 -50
  112. hud/types.py +136 -89
  113. hud/utils/__init__.py +10 -16
  114. hud/utils/async_utils.py +65 -0
  115. hud/utils/design.py +168 -0
  116. hud/utils/mcp.py +55 -0
  117. hud/utils/progress.py +149 -149
  118. hud/utils/telemetry.py +66 -66
  119. hud/utils/tests/test_async_utils.py +173 -0
  120. hud/utils/tests/test_init.py +17 -21
  121. hud/utils/tests/test_progress.py +261 -225
  122. hud/utils/tests/test_telemetry.py +82 -37
  123. hud/utils/tests/test_version.py +8 -8
  124. hud/version.py +7 -7
  125. hud_python-0.4.1.dist-info/METADATA +476 -0
  126. hud_python-0.4.1.dist-info/RECORD +132 -0
  127. hud_python-0.4.1.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/licenses/LICENSE +21 -21
  129. hud/adapters/__init__.py +0 -8
  130. hud/adapters/claude/__init__.py +0 -5
  131. hud/adapters/claude/adapter.py +0 -180
  132. hud/adapters/claude/tests/__init__.py +0 -1
  133. hud/adapters/claude/tests/test_adapter.py +0 -519
  134. hud/adapters/common/__init__.py +0 -6
  135. hud/adapters/common/adapter.py +0 -178
  136. hud/adapters/common/tests/test_adapter.py +0 -289
  137. hud/adapters/common/types.py +0 -446
  138. hud/adapters/operator/__init__.py +0 -5
  139. hud/adapters/operator/adapter.py +0 -108
  140. hud/adapters/operator/tests/__init__.py +0 -1
  141. hud/adapters/operator/tests/test_adapter.py +0 -370
  142. hud/agent/__init__.py +0 -19
  143. hud/agent/base.py +0 -126
  144. hud/agent/claude.py +0 -271
  145. hud/agent/langchain.py +0 -215
  146. hud/agent/misc/__init__.py +0 -3
  147. hud/agent/operator.py +0 -268
  148. hud/agent/tests/__init__.py +0 -1
  149. hud/agent/tests/test_base.py +0 -202
  150. hud/env/__init__.py +0 -11
  151. hud/env/client.py +0 -35
  152. hud/env/docker_client.py +0 -349
  153. hud/env/environment.py +0 -446
  154. hud/env/local_docker_client.py +0 -358
  155. hud/env/remote_client.py +0 -212
  156. hud/env/remote_docker_client.py +0 -292
  157. hud/gym.py +0 -130
  158. hud/job.py +0 -773
  159. hud/mcp/__init__.py +0 -17
  160. hud/mcp/base.py +0 -631
  161. hud/mcp/client.py +0 -312
  162. hud/mcp/tests/test_base.py +0 -512
  163. hud/mcp/tests/test_claude.py +0 -294
  164. hud/task.py +0 -149
  165. hud/taskset.py +0 -237
  166. hud/telemetry/_trace.py +0 -347
  167. hud/telemetry/context.py +0 -230
  168. hud/telemetry/exporter.py +0 -575
  169. hud/telemetry/instrumentation/__init__.py +0 -3
  170. hud/telemetry/instrumentation/mcp.py +0 -259
  171. hud/telemetry/instrumentation/registry.py +0 -59
  172. hud/telemetry/mcp_models.py +0 -270
  173. hud/telemetry/tests/__init__.py +0 -1
  174. hud/telemetry/tests/test_context.py +0 -210
  175. hud/telemetry/tests/test_trace.py +0 -312
  176. hud/tools/helper/README.md +0 -56
  177. hud/tools/helper/__init__.py +0 -9
  178. hud/tools/helper/mcp_server.py +0 -78
  179. hud/tools/helper/server_initialization.py +0 -115
  180. hud/tools/helper/utils.py +0 -58
  181. hud/trajectory.py +0 -94
  182. hud/utils/agent.py +0 -37
  183. hud/utils/common.py +0 -256
  184. hud/utils/config.py +0 -120
  185. hud/utils/deprecation.py +0 -115
  186. hud/utils/misc.py +0 -53
  187. hud/utils/tests/test_common.py +0 -277
  188. hud/utils/tests/test_config.py +0 -129
  189. hud_python-0.3.5.dist-info/METADATA +0 -284
  190. hud_python-0.3.5.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,265 @@
1
+ """Tests for hud.cli.__init__ module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import tempfile
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+ from typer.testing import CliRunner
13
+
14
+ from hud.cli import app, main
15
+
16
+ runner = CliRunner()
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class TestCLICommands:
22
+ """Test CLI command handling."""
23
+
24
+ def test_main_shows_help_when_no_args(self) -> None:
25
+ """Test that main() shows help when no arguments provided."""
26
+ result = runner.invoke(app)
27
+ # When no args, typer shows help but exits with code 2 (usage error)
28
+ assert result.exit_code == 2
29
+ assert "Usage:" in result.output
30
+
31
+ def test_analyze_docker_image(self) -> None:
32
+ """Test analyze command with Docker image."""
33
+ with patch("hud.cli.asyncio.run") as mock_run:
34
+ result = runner.invoke(app, ["analyze", "test-image:latest"])
35
+ assert result.exit_code == 0
36
+ mock_run.assert_called_once()
37
+ # Get the coroutine that was passed to asyncio.run
38
+ coro = mock_run.call_args[0][0]
39
+ assert coro.__name__ == "analyze_from_metadata"
40
+
41
+ def test_analyze_with_docker_args(self) -> None:
42
+ """Test analyze command with additional Docker arguments."""
43
+ with patch("hud.cli.asyncio.run") as mock_run:
44
+ # Docker args need to come after -- to avoid being parsed as CLI options
45
+ result = runner.invoke(
46
+ app, ["analyze", "test-image", "--", "-e", "KEY=value", "-p", "8080:8080"]
47
+ )
48
+ assert result.exit_code == 0
49
+ mock_run.assert_called_once()
50
+
51
+ def test_analyze_with_config_file(self) -> None:
52
+ """Test analyze command with config file."""
53
+ import os
54
+
55
+ fd, temp_path = tempfile.mkstemp(suffix=".json")
56
+ try:
57
+ with os.fdopen(fd, "w") as f:
58
+ json.dump({"test": {"command": "python", "args": ["server.py"]}}, f)
59
+
60
+ with patch("hud.cli.asyncio.run") as mock_run:
61
+ # Need to provide a dummy positional arg since params is required
62
+ result = runner.invoke(app, ["analyze", "dummy", "--config", temp_path])
63
+ assert result.exit_code == 0
64
+ mock_run.assert_called_once()
65
+ coro = mock_run.call_args[0][0]
66
+ assert coro.__name__ == "analyze_environment_from_config"
67
+ finally:
68
+ try:
69
+ os.unlink(temp_path)
70
+ except Exception:
71
+ logger.exception("Error deleting temp file")
72
+
73
+ def test_analyze_with_cursor_server(self) -> None:
74
+ """Test analyze command with Cursor server."""
75
+ with patch("hud.cli.parse_cursor_config") as mock_parse:
76
+ mock_parse.return_value = (["python", "server.py"], None)
77
+ with patch("hud.cli.asyncio.run") as mock_run:
78
+ # Need to provide a dummy positional arg since params is required
79
+ result = runner.invoke(app, ["analyze", "dummy", "--cursor", "test-server"])
80
+ assert result.exit_code == 0
81
+ mock_run.assert_called_once()
82
+
83
+ def test_analyze_cursor_server_not_found(self) -> None:
84
+ """Test analyze with non-existent Cursor server."""
85
+ with patch("hud.cli.parse_cursor_config") as mock_parse:
86
+ mock_parse.return_value = (None, "Server 'test' not found")
87
+ result = runner.invoke(app, ["analyze", "--cursor", "test"])
88
+ assert result.exit_code == 1
89
+ assert "Server 'test' not found" in result.output
90
+
91
+ def test_analyze_no_arguments_shows_error(self) -> None:
92
+ """Test analyze without arguments shows error."""
93
+ result = runner.invoke(app, ["analyze"])
94
+ assert result.exit_code == 1
95
+ assert "Error" in result.output
96
+
97
+ def test_analyze_output_formats(self) -> None:
98
+ """Test analyze with different output formats."""
99
+ for format_type in ["interactive", "json", "markdown"]:
100
+ with patch("hud.cli.asyncio.run"):
101
+ result = runner.invoke(app, ["analyze", "test-image", "--format", format_type])
102
+ assert result.exit_code == 0
103
+
104
+ def test_debug_docker_image(self) -> None:
105
+ """Test debug command with Docker image."""
106
+ with patch("hud.cli.asyncio.run") as mock_run:
107
+ mock_run.return_value = 5 # All phases completed
108
+ result = runner.invoke(app, ["debug", "test-image:latest"])
109
+ assert result.exit_code == 0
110
+ mock_run.assert_called_once()
111
+
112
+ def test_debug_with_max_phase(self) -> None:
113
+ """Test debug command with max phase limit."""
114
+ with patch("hud.cli.asyncio.run") as mock_run:
115
+ mock_run.return_value = 3 # Completed 3 phases
116
+ result = runner.invoke(app, ["debug", "test-image", "--max-phase", "3"])
117
+ assert result.exit_code == 0 # Exit code 0 when phases_completed == max_phase
118
+
119
+ def test_debug_with_config_file(self) -> None:
120
+ """Test debug command with config file."""
121
+ import os
122
+
123
+ fd, temp_path = tempfile.mkstemp(suffix=".json")
124
+ try:
125
+ with os.fdopen(fd, "w") as f:
126
+ json.dump({"test": {"command": "python", "args": ["server.py"]}}, f)
127
+
128
+ with patch("hud.cli.asyncio.run") as mock_run:
129
+ mock_run.return_value = 5
130
+ # Need to provide a dummy positional arg since params is required
131
+ result = runner.invoke(app, ["debug", "dummy", "--config", temp_path])
132
+ assert result.exit_code == 0
133
+ finally:
134
+ try:
135
+ os.unlink(temp_path)
136
+ except Exception:
137
+ logger.exception("Error deleting temp file")
138
+
139
+ def test_debug_with_cursor_server(self) -> None:
140
+ """Test debug command with Cursor server."""
141
+ with patch("hud.cli.parse_cursor_config") as mock_parse:
142
+ mock_parse.return_value = (["python", "server.py"], None)
143
+ with patch("hud.cli.asyncio.run") as mock_run:
144
+ mock_run.return_value = 5
145
+ # Need to provide a dummy positional arg since params is required
146
+ result = runner.invoke(app, ["debug", "dummy", "--cursor", "test-server"])
147
+ assert result.exit_code == 0
148
+
149
+ def test_debug_no_arguments_shows_error(self) -> None:
150
+ """Test debug without arguments shows error."""
151
+ result = runner.invoke(app, ["debug"])
152
+ assert result.exit_code == 1
153
+ assert "Error" in result.output
154
+
155
+ def test_cursor_list_command(self) -> None:
156
+ """Test cursor-list command."""
157
+ with patch("hud.cli.list_cursor_servers") as mock_list:
158
+ mock_list.return_value = (["server1", "server2"], None)
159
+ with patch("hud.cli.get_cursor_config_path") as mock_path:
160
+ mock_path.return_value = Path("/home/user/.cursor/mcp.json")
161
+ with patch("pathlib.Path.exists") as mock_exists:
162
+ mock_exists.return_value = True
163
+ with patch("builtins.open", create=True) as mock_open:
164
+ mock_open.return_value.__enter__.return_value.read.return_value = (
165
+ json.dumps(
166
+ {
167
+ "mcpServers": {
168
+ "server1": {"command": "python", "args": ["srv1.py"]},
169
+ "server2": {"command": "node", "args": ["srv2.js"]},
170
+ }
171
+ }
172
+ )
173
+ )
174
+ result = runner.invoke(app, ["cursor-list"])
175
+ assert result.exit_code == 0
176
+ assert "Available Servers" in result.output
177
+
178
+ def test_cursor_list_no_servers(self) -> None:
179
+ """Test cursor-list with no servers."""
180
+ with patch("hud.cli.list_cursor_servers") as mock_list:
181
+ mock_list.return_value = ([], None)
182
+ result = runner.invoke(app, ["cursor-list"])
183
+ assert result.exit_code == 0
184
+ assert "No servers found" in result.output
185
+
186
+ def test_cursor_list_error(self) -> None:
187
+ """Test cursor-list with error."""
188
+ with patch("hud.cli.list_cursor_servers") as mock_list:
189
+ mock_list.return_value = (None, "Config not found")
190
+ result = runner.invoke(app, ["cursor-list"])
191
+ assert result.exit_code == 1
192
+ assert "Config not found" in result.output
193
+
194
+ def test_version_command(self) -> None:
195
+ """Test version command."""
196
+ with patch("hud.__version__", "1.2.3"):
197
+ result = runner.invoke(app, ["version"])
198
+ assert result.exit_code == 0
199
+ assert "1.2.3" in result.output
200
+
201
+ def test_version_import_error(self) -> None:
202
+ """Test version command when version unavailable."""
203
+ # Patch the specific import of __version__ from hud
204
+ with patch.dict("sys.modules", {"hud": None}):
205
+ result = runner.invoke(app, ["version"])
206
+ assert result.exit_code == 0
207
+ assert "HUD CLI version: unknown" in result.output
208
+
209
+ def test_mcp_command(self) -> None:
210
+ """Test mcp server command."""
211
+ # MCP command has been removed from the CLI
212
+ result = runner.invoke(app, ["mcp"])
213
+ assert result.exit_code == 2 # Command not found
214
+
215
+ def test_help_command(self) -> None:
216
+ """Test help command shows proper info."""
217
+ result = runner.invoke(app, ["--help"])
218
+ assert result.exit_code == 0
219
+ assert "HUD CLI for MCP environment analysis" in result.output
220
+ assert "analyze" in result.output
221
+ assert "debug" in result.output
222
+ # assert "mcp" in result.output # mcp command has been removed
223
+
224
+
225
+ class TestMainFunction:
226
+ """Test the main() function specifically."""
227
+
228
+ def test_main_with_help_flag(self) -> None:
229
+ """Test main() with --help flag."""
230
+ import sys
231
+
232
+ original_argv = sys.argv
233
+ try:
234
+ sys.argv = ["hud", "--help"]
235
+ with (
236
+ patch("hud.cli.console") as mock_console,
237
+ patch("hud.cli.app") as mock_app,
238
+ ):
239
+ main()
240
+ # Should print the header panel
241
+ # Check that either console was used or app was called
242
+ assert mock_console.print.called or mock_app.called
243
+ finally:
244
+ sys.argv = original_argv
245
+
246
+ def test_main_with_no_args(self) -> None:
247
+ """Test main() with no arguments."""
248
+ import sys
249
+
250
+ original_argv = sys.argv
251
+ try:
252
+ sys.argv = ["hud"]
253
+ with patch("hud.cli.console") as mock_console:
254
+ with pytest.raises(SystemExit) as exc_info:
255
+ main()
256
+ # Should exit with code 2 (missing command)
257
+ assert exc_info.value.code == 2
258
+ # Should print Quick Start guide before exiting
259
+ assert any("Quick Start" in str(call) for call in mock_console.print.call_args_list)
260
+ finally:
261
+ sys.argv = original_argv
262
+
263
+
264
+ if __name__ == "__main__":
265
+ pytest.main([__file__])
@@ -0,0 +1,27 @@
1
+ """Tests for hud.cli.__main__ module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+
8
+ class TestCLIMain:
9
+ """Test the __main__ module."""
10
+
11
+ def test_main_module_exists(self) -> None:
12
+ """Test that __main__.py exists and can be imported."""
13
+ # Just verify the module can be imported
14
+ import hud.cli.__main__
15
+
16
+ assert hud.cli.__main__ is not None
17
+
18
+ def test_main_module_has_main_import(self) -> None:
19
+ """Test that __main__.py imports main from the package."""
20
+ import hud.cli.__main__
21
+
22
+ # The module should have imported main
23
+ assert hasattr(hud.cli.__main__, "main")
24
+
25
+
26
+ if __name__ == "__main__":
27
+ pytest.main([__file__])
@@ -0,0 +1,142 @@
1
+ """Tests for the clone command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from unittest.mock import MagicMock, mock_open, patch
7
+
8
+ from hud.cli.clone import clone_repository, get_clone_message
9
+
10
+
11
+ def test_clone_repository_success():
12
+ """Test successful repository cloning."""
13
+ with patch("subprocess.run") as mock_run:
14
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
15
+
16
+ success, result = clone_repository("https://github.com/user/repo.git")
17
+
18
+ assert success is True
19
+ assert "repo" in result
20
+ mock_run.assert_called_once()
21
+
22
+ # Check command includes quiet flag
23
+ cmd = mock_run.call_args[0][0]
24
+ assert "git" in cmd
25
+ assert "clone" in cmd
26
+ assert "--quiet" in cmd
27
+ assert "https://github.com/user/repo.git" in cmd
28
+
29
+
30
+ def test_clone_repository_failure():
31
+ """Test failed repository cloning."""
32
+ with patch("subprocess.run") as mock_run:
33
+ mock_run.side_effect = subprocess.CalledProcessError(
34
+ 128, ["git", "clone"], stderr="fatal: repository not found"
35
+ )
36
+
37
+ success, result = clone_repository("https://github.com/user/nonexistent.git")
38
+
39
+ assert success is False
40
+ assert "repository not found" in result
41
+
42
+
43
+ def test_get_clone_message_from_pyproject():
44
+ """Test reading clone message from pyproject.toml."""
45
+ toml_content = """
46
+ [tool.hud.clone]
47
+ title = "Test Project"
48
+ message = "Welcome to the test project!"
49
+ """
50
+
51
+ with (
52
+ patch("pathlib.Path.exists") as mock_exists,
53
+ patch("builtins.open", mock_open(read_data=toml_content.encode())),
54
+ patch("tomllib.load") as mock_load,
55
+ ):
56
+ mock_exists.return_value = True
57
+ mock_load.return_value = {
58
+ "tool": {
59
+ "hud": {
60
+ "clone": {"title": "Test Project", "message": "Welcome to the test project!"}
61
+ }
62
+ }
63
+ }
64
+
65
+ config = get_clone_message("/path/to/repo")
66
+
67
+ assert config is not None
68
+ assert config["title"] == "Test Project"
69
+ assert config["message"] == "Welcome to the test project!"
70
+
71
+
72
+ def test_get_clone_message_from_hud_toml():
73
+ """Test reading clone message from .hud.toml."""
74
+ toml_content = """
75
+ [clone]
76
+ title = "HUD Project"
77
+ markdown = "## Welcome!"
78
+ style = "cyan"
79
+ """
80
+
81
+ with (
82
+ patch("pathlib.Path.exists") as mock_exists,
83
+ patch("builtins.open", mock_open(read_data=toml_content.encode())),
84
+ patch("tomllib.load") as mock_load,
85
+ ):
86
+ # First call for pyproject.toml returns False
87
+ # Second call for .hud.toml returns True
88
+ mock_exists.side_effect = [False, True]
89
+ mock_load.return_value = {
90
+ "clone": {"title": "HUD Project", "markdown": "## Welcome!", "style": "cyan"}
91
+ }
92
+
93
+ config = get_clone_message("/path/to/repo")
94
+
95
+ assert config is not None
96
+ assert config["title"] == "HUD Project"
97
+ assert config["markdown"] == "## Welcome!"
98
+ assert config["style"] == "cyan"
99
+
100
+
101
+ def test_get_clone_message_none():
102
+ """Test when no clone message configuration exists."""
103
+ with patch("pathlib.Path.exists") as mock_exists:
104
+ mock_exists.return_value = False
105
+
106
+ config = get_clone_message("/path/to/repo")
107
+
108
+ assert config is None
109
+
110
+
111
+ # The following tests are commented out as print_success and print_error
112
+ # functions are no longer part of the clone module
113
+
114
+ # def test_print_success(capsys):
115
+ # """Test success message printing."""
116
+ # print_success("https://github.com/user/repo.git", "/home/user/repo")
117
+
118
+ # captured = capsys.readouterr()
119
+ # assert "Successfully cloned" in captured.out
120
+ # assert "repo" in captured.out
121
+ # assert "/home/user/repo" in captured.out
122
+
123
+
124
+ # def test_print_success_with_config(capsys):
125
+ # """Test success message with configuration."""
126
+ # config = {"title": "My Project", "message": "Thanks for cloning!"}
127
+
128
+ # print_success("https://github.com/user/repo.git", "/home/user/repo", config)
129
+
130
+ # captured = capsys.readouterr()
131
+ # assert "Successfully cloned" in captured.out
132
+ # assert "My Project" in captured.out
133
+ # assert "Thanks for cloning!" in captured.out
134
+
135
+
136
+ # def test_print_error(capsys):
137
+ # """Test error message printing."""
138
+ # print_error("Repository not found")
139
+
140
+ # captured = capsys.readouterr()
141
+ # assert "Repository not found" in captured.out
142
+ # assert "Clone Failed" in captured.out
@@ -0,0 +1,253 @@
1
+ """Tests for hud.cli.cursor module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from unittest.mock import mock_open, patch
9
+
10
+ import pytest
11
+
12
+ from hud.cli.cursor import get_cursor_config_path, list_cursor_servers, parse_cursor_config
13
+
14
+
15
+ class TestParseCursorConfig:
16
+ """Test Cursor config parsing."""
17
+
18
+ def test_parse_cursor_config_success(self) -> None:
19
+ """Test successful parsing of Cursor config."""
20
+ config_data = {
21
+ "mcpServers": {
22
+ "test-server": {
23
+ "command": "python",
24
+ "args": ["server.py", "--port", "8080"],
25
+ "env": {"KEY": "value"},
26
+ }
27
+ }
28
+ }
29
+
30
+ with (
31
+ patch("pathlib.Path.exists", return_value=True),
32
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
33
+ ):
34
+ command, error = parse_cursor_config("test-server")
35
+ assert error is None
36
+ assert command == ["python", "server.py", "--port", "8080"]
37
+
38
+ def test_parse_cursor_config_not_found(self) -> None:
39
+ """Test parsing when config file doesn't exist."""
40
+ with patch("pathlib.Path.exists", return_value=False):
41
+ command, error = parse_cursor_config("test-server")
42
+ assert command is None
43
+ assert error is not None
44
+ assert "Cursor config not found" in error
45
+
46
+ def test_parse_cursor_config_server_not_found(self) -> None:
47
+ """Test parsing when server doesn't exist in config."""
48
+ config_data = {"mcpServers": {"other-server": {"command": "node", "args": ["server.js"]}}}
49
+
50
+ with (
51
+ patch("pathlib.Path.exists", return_value=True),
52
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
53
+ ):
54
+ command, error = parse_cursor_config("test-server")
55
+ assert command is None
56
+ assert error is not None
57
+ assert "Server 'test-server' not found" in error
58
+ assert "Available: other-server" in error
59
+
60
+ def test_parse_cursor_config_reloaderoo(self) -> None:
61
+ """Test parsing config with reloaderoo wrapper."""
62
+ config_data = {
63
+ "mcpServers": {
64
+ "test-server": {
65
+ "command": "npx",
66
+ "args": ["reloaderoo", "--watch", "src", "--", "python", "server.py"],
67
+ }
68
+ }
69
+ }
70
+
71
+ with (
72
+ patch("pathlib.Path.exists", return_value=True),
73
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
74
+ ):
75
+ command, error = parse_cursor_config("test-server")
76
+ assert error is None
77
+ # Should extract command after --
78
+ assert command == ["python", "server.py"]
79
+
80
+ def test_parse_cursor_config_reloaderoo_no_dash(self) -> None:
81
+ """Test parsing reloaderoo without -- separator."""
82
+ config_data = {
83
+ "mcpServers": {
84
+ "test-server": {"command": "npx", "args": ["reloaderoo", "python", "server.py"]}
85
+ }
86
+ }
87
+
88
+ with (
89
+ patch("pathlib.Path.exists", return_value=True),
90
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
91
+ ):
92
+ command, error = parse_cursor_config("test-server")
93
+ assert error is None
94
+ # Should return full command since no -- found
95
+ assert command == ["npx", "reloaderoo", "python", "server.py"]
96
+
97
+ def test_parse_cursor_config_windows_path(self) -> None:
98
+ """Test parsing with Windows user profile path."""
99
+ config_data = {"mcpServers": {"test": {"command": "cmd"}}}
100
+
101
+ # First path doesn't exist, try Windows path
102
+ with (
103
+ patch("pathlib.Path.exists", side_effect=[False, True]),
104
+ patch.dict(os.environ, {"USERPROFILE": "C:\\Users\\Test"}),
105
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
106
+ ):
107
+ command, error = parse_cursor_config("test")
108
+ assert error is None
109
+ assert command == ["cmd"]
110
+
111
+ def test_parse_cursor_config_json_error(self) -> None:
112
+ """Test parsing with invalid JSON."""
113
+ with (
114
+ patch("pathlib.Path.exists", return_value=True),
115
+ patch("builtins.open", mock_open(read_data="invalid json")),
116
+ ):
117
+ command, error = parse_cursor_config("test-server")
118
+ assert command is None
119
+ assert error is not None
120
+ assert "Error reading config" in error
121
+
122
+ def test_parse_cursor_config_no_command(self) -> None:
123
+ """Test parsing server with no command."""
124
+ config_data = {"mcpServers": {"test-server": {"args": ["--port", "8080"]}}}
125
+
126
+ with (
127
+ patch("pathlib.Path.exists", return_value=True),
128
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
129
+ ):
130
+ command, error = parse_cursor_config("test-server")
131
+ assert error is None
132
+ assert command == ["", "--port", "8080"] # Empty command
133
+
134
+
135
+ class TestListCursorServers:
136
+ """Test listing Cursor servers."""
137
+
138
+ def test_list_cursor_servers_success(self) -> None:
139
+ """Test successful listing of servers."""
140
+ config_data = {
141
+ "mcpServers": {
142
+ "server1": {"command": "python"},
143
+ "server2": {"command": "node"},
144
+ "server3": {"command": "ruby"},
145
+ }
146
+ }
147
+
148
+ with (
149
+ patch("pathlib.Path.exists", return_value=True),
150
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
151
+ ):
152
+ servers, error = list_cursor_servers()
153
+ assert error is None
154
+ assert servers == ["server1", "server2", "server3"]
155
+
156
+ def test_list_cursor_servers_empty(self) -> None:
157
+ """Test listing when no servers configured."""
158
+ config_data = {"mcpServers": {}}
159
+
160
+ with (
161
+ patch("pathlib.Path.exists", return_value=True),
162
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
163
+ ):
164
+ servers, error = list_cursor_servers()
165
+ assert error is None
166
+ assert servers == []
167
+
168
+ def test_list_cursor_servers_no_mcp_section(self) -> None:
169
+ """Test listing when mcpServers section missing."""
170
+ config_data = {"otherConfig": {}}
171
+
172
+ with (
173
+ patch("pathlib.Path.exists", return_value=True),
174
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
175
+ ):
176
+ servers, error = list_cursor_servers()
177
+ assert error is None
178
+ assert servers == []
179
+
180
+ def test_list_cursor_servers_file_not_found(self) -> None:
181
+ """Test listing when config file doesn't exist."""
182
+ with patch("pathlib.Path.exists", return_value=False):
183
+ servers, error = list_cursor_servers()
184
+ assert servers is None
185
+ assert error is not None
186
+ assert "Cursor config not found" in error
187
+
188
+ def test_list_cursor_servers_windows_path(self) -> None:
189
+ """Test listing with Windows path fallback."""
190
+ config_data = {"mcpServers": {"winserver": {"command": "cmd"}}}
191
+
192
+ # First path doesn't exist, second (Windows) does
193
+ with (
194
+ patch("pathlib.Path.exists", side_effect=[False, True]),
195
+ patch.dict(os.environ, {"USERPROFILE": "C:\\Users\\Test"}),
196
+ patch("builtins.open", mock_open(read_data=json.dumps(config_data))),
197
+ ):
198
+ servers, error = list_cursor_servers()
199
+ assert error is None
200
+ assert servers == ["winserver"]
201
+
202
+ def test_list_cursor_servers_read_error(self) -> None:
203
+ """Test listing with file read error."""
204
+ with (
205
+ patch("pathlib.Path.exists", return_value=True),
206
+ patch("builtins.open", side_effect=PermissionError("Access denied")),
207
+ ):
208
+ servers, error = list_cursor_servers()
209
+ assert servers is None
210
+ assert error is not None
211
+ assert "Error reading config" in error
212
+ assert "Access denied" in error
213
+
214
+
215
+ class TestGetCursorConfigPath:
216
+ """Test getting Cursor config path."""
217
+
218
+ def test_get_cursor_config_path_unix(self) -> None:
219
+ """Test getting config path on Unix-like systems."""
220
+ with (
221
+ patch("pathlib.Path.home", return_value=Path("/home/user")),
222
+ patch("pathlib.Path.exists", return_value=True),
223
+ ):
224
+ path = get_cursor_config_path()
225
+ assert str(path) == str(Path("/home/user/.cursor/mcp.json"))
226
+
227
+ def test_get_cursor_config_path_windows(self) -> None:
228
+ """Test getting config path on Windows."""
229
+ with (
230
+ patch("pathlib.Path.home", return_value=Path("/home/user")),
231
+ patch("pathlib.Path.exists", return_value=False),
232
+ patch.dict(os.environ, {"USERPROFILE": "C:\\Users\\Test"}),
233
+ ):
234
+ path = get_cursor_config_path()
235
+ assert "Test" in str(path)
236
+ assert ".cursor" in str(path)
237
+ assert "mcp.json" in str(path)
238
+
239
+ def test_get_cursor_config_path_no_userprofile(self) -> None:
240
+ """Test getting config path when USERPROFILE not set."""
241
+ with (
242
+ patch("pathlib.Path.home", return_value=Path("/home/user")),
243
+ patch("pathlib.Path.exists", return_value=False),
244
+ patch.dict(os.environ, {}, clear=True),
245
+ ):
246
+ path = get_cursor_config_path()
247
+ # Should still return something based on empty USERPROFILE
248
+ assert ".cursor" in str(path)
249
+ assert "mcp.json" in str(path)
250
+
251
+
252
+ if __name__ == "__main__":
253
+ pytest.main([__file__])