hud-python 0.3.5__py3-none-any.whl → 0.4.0__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/__init__.py +22 -89
- hud/agents/__init__.py +17 -0
- hud/agents/art.py +101 -0
- hud/agents/base.py +599 -0
- hud/{mcp → agents}/claude.py +373 -321
- hud/{mcp → agents}/langchain.py +250 -250
- hud/agents/misc/__init__.py +7 -0
- hud/{agent → agents}/misc/response_agent.py +80 -80
- hud/{mcp → agents}/openai.py +352 -334
- hud/agents/openai_chat_generic.py +154 -0
- hud/{mcp → agents}/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -0
- hud/agents/tests/test_claude.py +324 -0
- hud/{mcp → agents}/tests/test_client.py +363 -324
- hud/{mcp → agents}/tests/test_openai.py +237 -238
- hud/cli/__init__.py +617 -0
- hud/cli/__main__.py +8 -0
- hud/cli/analyze.py +371 -0
- hud/cli/analyze_metadata.py +230 -0
- hud/cli/build.py +427 -0
- hud/cli/clone.py +185 -0
- hud/cli/cursor.py +92 -0
- hud/cli/debug.py +392 -0
- hud/cli/docker_utils.py +83 -0
- hud/cli/init.py +281 -0
- hud/cli/interactive.py +353 -0
- hud/cli/mcp_server.py +756 -0
- hud/cli/pull.py +336 -0
- hud/cli/push.py +379 -0
- hud/cli/remote_runner.py +311 -0
- hud/cli/runner.py +160 -0
- hud/cli/tests/__init__.py +3 -0
- hud/cli/tests/test_analyze.py +284 -0
- hud/cli/tests/test_cli_init.py +265 -0
- hud/cli/tests/test_cli_main.py +27 -0
- hud/cli/tests/test_clone.py +142 -0
- hud/cli/tests/test_cursor.py +253 -0
- hud/cli/tests/test_debug.py +453 -0
- hud/cli/tests/test_mcp_server.py +139 -0
- hud/cli/tests/test_utils.py +388 -0
- hud/cli/utils.py +263 -0
- hud/clients/README.md +143 -0
- hud/clients/__init__.py +16 -0
- hud/clients/base.py +354 -0
- hud/clients/fastmcp.py +202 -0
- hud/clients/mcp_use.py +278 -0
- hud/clients/tests/__init__.py +1 -0
- hud/clients/tests/test_client_integration.py +111 -0
- hud/clients/tests/test_fastmcp.py +342 -0
- hud/clients/tests/test_protocol.py +188 -0
- hud/clients/utils/__init__.py +1 -0
- hud/clients/utils/retry_transport.py +160 -0
- hud/datasets.py +322 -192
- hud/misc/__init__.py +1 -0
- hud/{agent → misc}/claude_plays_pokemon.py +292 -283
- hud/otel/__init__.py +35 -0
- hud/otel/collector.py +142 -0
- hud/otel/config.py +164 -0
- hud/otel/context.py +536 -0
- hud/otel/exporters.py +366 -0
- hud/otel/instrumentation.py +97 -0
- hud/otel/processors.py +118 -0
- hud/otel/tests/__init__.py +1 -0
- hud/otel/tests/test_processors.py +197 -0
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -0
- hud/server/helper/__init__.py +5 -0
- hud/server/low_level.py +132 -0
- hud/server/server.py +166 -0
- hud/server/tests/__init__.py +3 -0
- hud/settings.py +73 -79
- hud/shared/__init__.py +5 -0
- hud/{exceptions.py → shared/exceptions.py} +180 -180
- hud/{server → shared}/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -0
- hud/{server → shared}/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -30
- hud/telemetry/instrument.py +379 -0
- hud/telemetry/job.py +309 -141
- hud/telemetry/replay.py +74 -0
- hud/telemetry/trace.py +83 -0
- hud/tools/__init__.py +33 -34
- hud/tools/base.py +365 -65
- hud/tools/bash.py +161 -137
- hud/tools/computer/__init__.py +15 -13
- hud/tools/computer/anthropic.py +437 -420
- hud/tools/computer/hud.py +376 -334
- hud/tools/computer/openai.py +295 -292
- hud/tools/computer/settings.py +82 -0
- hud/tools/edit.py +314 -290
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -532
- hud/tools/executors/pyautogui.py +621 -619
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -503
- hud/tools/{playwright_tool.py → playwright.py} +412 -379
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -0
- hud/tools/tests/test_bash.py +158 -152
- hud/tools/tests/test_bash_extended.py +197 -0
- hud/tools/tests/test_computer.py +425 -52
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -240
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -157
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -0
- hud/tools/utils.py +50 -50
- hud/types.py +136 -89
- hud/utils/__init__.py +10 -16
- hud/utils/async_utils.py +65 -0
- hud/utils/design.py +168 -0
- hud/utils/mcp.py +55 -0
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -0
- hud/utils/tests/test_init.py +17 -21
- hud/utils/tests/test_progress.py +261 -225
- hud/utils/tests/test_telemetry.py +82 -37
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- hud_python-0.4.0.dist-info/METADATA +474 -0
- hud_python-0.4.0.dist-info/RECORD +132 -0
- hud_python-0.4.0.dist-info/entry_points.txt +3 -0
- {hud_python-0.3.5.dist-info → hud_python-0.4.0.dist-info}/licenses/LICENSE +21 -21
- hud/adapters/__init__.py +0 -8
- hud/adapters/claude/__init__.py +0 -5
- hud/adapters/claude/adapter.py +0 -180
- hud/adapters/claude/tests/__init__.py +0 -1
- hud/adapters/claude/tests/test_adapter.py +0 -519
- hud/adapters/common/__init__.py +0 -6
- hud/adapters/common/adapter.py +0 -178
- hud/adapters/common/tests/test_adapter.py +0 -289
- hud/adapters/common/types.py +0 -446
- hud/adapters/operator/__init__.py +0 -5
- hud/adapters/operator/adapter.py +0 -108
- hud/adapters/operator/tests/__init__.py +0 -1
- hud/adapters/operator/tests/test_adapter.py +0 -370
- hud/agent/__init__.py +0 -19
- hud/agent/base.py +0 -126
- hud/agent/claude.py +0 -271
- hud/agent/langchain.py +0 -215
- hud/agent/misc/__init__.py +0 -3
- hud/agent/operator.py +0 -268
- hud/agent/tests/__init__.py +0 -1
- hud/agent/tests/test_base.py +0 -202
- hud/env/__init__.py +0 -11
- hud/env/client.py +0 -35
- hud/env/docker_client.py +0 -349
- hud/env/environment.py +0 -446
- hud/env/local_docker_client.py +0 -358
- hud/env/remote_client.py +0 -212
- hud/env/remote_docker_client.py +0 -292
- hud/gym.py +0 -130
- hud/job.py +0 -773
- hud/mcp/__init__.py +0 -17
- hud/mcp/base.py +0 -631
- hud/mcp/client.py +0 -312
- hud/mcp/tests/test_base.py +0 -512
- hud/mcp/tests/test_claude.py +0 -294
- hud/task.py +0 -149
- hud/taskset.py +0 -237
- hud/telemetry/_trace.py +0 -347
- hud/telemetry/context.py +0 -230
- hud/telemetry/exporter.py +0 -575
- hud/telemetry/instrumentation/__init__.py +0 -3
- hud/telemetry/instrumentation/mcp.py +0 -259
- hud/telemetry/instrumentation/registry.py +0 -59
- hud/telemetry/mcp_models.py +0 -270
- hud/telemetry/tests/__init__.py +0 -1
- hud/telemetry/tests/test_context.py +0 -210
- hud/telemetry/tests/test_trace.py +0 -312
- hud/tools/helper/README.md +0 -56
- hud/tools/helper/__init__.py +0 -9
- hud/tools/helper/mcp_server.py +0 -78
- hud/tools/helper/server_initialization.py +0 -115
- hud/tools/helper/utils.py +0 -58
- hud/trajectory.py +0 -94
- hud/utils/agent.py +0 -37
- hud/utils/common.py +0 -256
- hud/utils/config.py +0 -120
- hud/utils/deprecation.py +0 -115
- hud/utils/misc.py +0 -53
- hud/utils/tests/test_common.py +0 -277
- hud/utils/tests/test_config.py +0 -129
- hud_python-0.3.5.dist-info/METADATA +0 -284
- hud_python-0.3.5.dist-info/RECORD +0 -120
- /hud/{adapters/common → shared}/tests/__init__.py +0 -0
- {hud_python-0.3.5.dist-info → hud_python-0.4.0.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__])
|