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
hud/cli/build.py ADDED
@@ -0,0 +1,427 @@
1
+ """Build HUD environments and generate lock files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import subprocess
8
+ import time
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import typer
14
+ import yaml
15
+ from rich.console import Console
16
+
17
+ from hud.clients import MCPClient
18
+ from hud.utils.design import HUDDesign
19
+ from hud.version import __version__ as hud_version
20
+
21
+ console = Console()
22
+
23
+
24
+ def get_docker_image_digest(image: str) -> str | None:
25
+ """Get the digest of a Docker image."""
26
+ try:
27
+ result = subprocess.run( # noqa: S603
28
+ ["docker", "inspect", "--format", "{{.RepoDigests}}", image], # noqa: S607
29
+ capture_output=True,
30
+ text=True,
31
+ check=True,
32
+ )
33
+ # Parse the output - it's in format [repo@sha256:digest]
34
+ digests = result.stdout.strip()
35
+ if digests and digests != "[]":
36
+ # Extract the first digest
37
+ digest_list = eval(digests) # noqa: S307 # Safe since it's from docker
38
+ if digest_list:
39
+ # Return full image reference with digest
40
+ return digest_list[0]
41
+ except Exception:
42
+ console.print("Failed to get Docker image digest")
43
+ return None
44
+
45
+
46
+ def get_docker_image_id(image: str) -> str | None:
47
+ """Get the ID of a Docker image."""
48
+ try:
49
+ result = subprocess.run( # noqa: S603
50
+ ["docker", "inspect", "--format", "{{.Id}}", image], # noqa: S607
51
+ capture_output=True,
52
+ text=True,
53
+ check=True,
54
+ )
55
+ image_id = result.stdout.strip()
56
+ if image_id:
57
+ return image_id
58
+ return None
59
+ except Exception:
60
+ # Don't log here to avoid import issues
61
+ return None
62
+
63
+
64
+ def extract_env_vars_from_dockerfile(dockerfile_path: Path) -> tuple[list[str], list[str]]:
65
+ """Extract required and optional environment variables from Dockerfile."""
66
+ required = []
67
+ optional = []
68
+
69
+ if not dockerfile_path.exists():
70
+ return required, optional
71
+
72
+ # Simple parsing - look for ENV directives
73
+ # This is a basic implementation - could be enhanced
74
+ content = dockerfile_path.read_text()
75
+ for line in content.splitlines():
76
+ line = line.strip()
77
+ if line.startswith("ENV "):
78
+ # Basic parsing - this could be more sophisticated
79
+ parts = line[4:].strip().split("=", 1)
80
+ if len(parts) == 2 and not parts[1].strip():
81
+ # No default value = required
82
+ required.append(parts[0])
83
+ elif len(parts) == 1:
84
+ # No equals sign = required
85
+ required.append(parts[0])
86
+
87
+ return required, optional
88
+
89
+
90
+ async def analyze_mcp_environment(image: str, verbose: bool = False) -> dict[str, Any]:
91
+ """Analyze an MCP environment to extract metadata."""
92
+ design = HUDDesign()
93
+
94
+ # Build Docker command to run the image
95
+ docker_cmd = ["docker", "run", "--rm", "-i", image]
96
+
97
+ # Create MCP config
98
+ config = {
99
+ "server": {"command": docker_cmd[0], "args": docker_cmd[1:] if len(docker_cmd) > 1 else []}
100
+ }
101
+
102
+ # Initialize client and measure timing
103
+ start_time = time.time()
104
+ client = MCPClient(mcp_config=config, verbose=verbose, auto_trace=False)
105
+ initialized = False
106
+
107
+ try:
108
+ if verbose:
109
+ design.info(f"Initializing MCP client with command: {' '.join(docker_cmd)}")
110
+
111
+ await client.initialize()
112
+ initialized = True
113
+ initialize_ms = int((time.time() - start_time) * 1000)
114
+
115
+ # Get tools
116
+ tools = await client.list_tools()
117
+
118
+ # Extract tool information
119
+ tool_info = []
120
+ for tool in tools:
121
+ tool_dict = {"name": tool.name, "description": tool.description}
122
+ if hasattr(tool, "inputSchema") and tool.inputSchema:
123
+ tool_dict["inputSchema"] = tool.inputSchema
124
+ tool_info.append(tool_dict)
125
+
126
+ return {
127
+ "initializeMs": initialize_ms,
128
+ "toolCount": len(tools),
129
+ "tools": tool_info,
130
+ "success": True,
131
+ }
132
+ except Exception as e:
133
+ import traceback
134
+
135
+ error_msg = str(e)
136
+ if verbose:
137
+ design.error(f"Failed to analyze environment: {error_msg}")
138
+ design.error(f"Traceback:\n{traceback.format_exc()}")
139
+
140
+ # Common issues
141
+ if "Connection reset" in error_msg or "EOF" in error_msg:
142
+ design.warning(
143
+ "The MCP server may have crashed on startup. Check your server.py for errors."
144
+ )
145
+ elif "timeout" in error_msg:
146
+ design.warning(
147
+ "The MCP server took too long to initialize. It might need more startup time."
148
+ )
149
+
150
+ return {
151
+ "initializeMs": 0,
152
+ "toolCount": 0,
153
+ "tools": [],
154
+ "success": False,
155
+ "error": error_msg,
156
+ }
157
+ finally:
158
+ # Only shutdown if we successfully initialized
159
+ if initialized:
160
+ try:
161
+ await client.shutdown()
162
+ except Exception:
163
+ # Ignore shutdown errors
164
+ design.warning("Failed to shutdown MCP client")
165
+
166
+
167
+ def build_docker_image(
168
+ directory: Path, tag: str, no_cache: bool = False, verbose: bool = False
169
+ ) -> bool:
170
+ """Build a Docker image from a directory."""
171
+ design = HUDDesign()
172
+
173
+ # Check if Dockerfile exists
174
+ dockerfile = directory / "Dockerfile"
175
+ if not dockerfile.exists():
176
+ design.error(f"No Dockerfile found in {directory}")
177
+ return False
178
+
179
+ # Build command
180
+ cmd = ["docker", "build", "-t", tag]
181
+ if no_cache:
182
+ cmd.append("--no-cache")
183
+ cmd.append(str(directory))
184
+
185
+ # Always show build output
186
+ design.info(f"Running: {' '.join(cmd)}")
187
+
188
+ try:
189
+ # Run with real-time output, handling encoding issues on Windows
190
+ process = subprocess.Popen( # noqa: S603
191
+ cmd,
192
+ stdout=subprocess.PIPE,
193
+ stderr=subprocess.STDOUT,
194
+ text=True,
195
+ encoding="utf-8",
196
+ errors="replace", # Replace invalid chars instead of failing
197
+ )
198
+
199
+ # Stream output
200
+ for line in process.stdout or []:
201
+ design.info(line.rstrip())
202
+
203
+ process.wait()
204
+
205
+ return process.returncode == 0
206
+ except Exception as e:
207
+ design.error(f"Build error: {e}")
208
+ return False
209
+
210
+
211
+ def build_environment(
212
+ directory: str = ".", tag: str | None = None, no_cache: bool = False, verbose: bool = False
213
+ ) -> None:
214
+ """Build a HUD environment and generate lock file."""
215
+ design = HUDDesign()
216
+ design.header("HUD Environment Build")
217
+
218
+ # Resolve directory
219
+ env_dir = Path(directory).resolve()
220
+ if not env_dir.exists():
221
+ design.error(f"Directory not found: {directory}")
222
+ raise typer.Exit(1)
223
+
224
+ # Check for pyproject.toml
225
+ pyproject_path = env_dir / "pyproject.toml"
226
+ if not pyproject_path.exists():
227
+ design.error(f"No pyproject.toml found in {directory}")
228
+ raise typer.Exit(1)
229
+
230
+ # Read pyproject.toml to get image name
231
+ try:
232
+ import toml
233
+
234
+ pyproject = toml.load(pyproject_path)
235
+ default_image = pyproject.get("tool", {}).get("hud", {}).get("image", None)
236
+ if not default_image:
237
+ # Generate default from directory name
238
+ default_image = f"{env_dir.name}:dev"
239
+ except Exception:
240
+ default_image = f"{env_dir.name}:dev"
241
+
242
+ # Use provided tag or default
243
+ if not tag:
244
+ tag = default_image
245
+
246
+ # Build temporary image first
247
+ temp_tag = f"hud-build-temp:{int(time.time())}"
248
+
249
+ design.progress_message(f"Building Docker image: {temp_tag}")
250
+
251
+ # Build the image
252
+ if not build_docker_image(env_dir, temp_tag, no_cache, verbose):
253
+ design.error("Docker build failed")
254
+ raise typer.Exit(1)
255
+
256
+ design.success(f"Built temporary image: {temp_tag}")
257
+
258
+ # Analyze the environment
259
+ design.progress_message("Analyzing MCP environment...")
260
+
261
+ loop = asyncio.new_event_loop()
262
+ asyncio.set_event_loop(loop)
263
+ try:
264
+ analysis = loop.run_until_complete(analyze_mcp_environment(temp_tag, verbose))
265
+ finally:
266
+ loop.close()
267
+
268
+ if not analysis["success"]:
269
+ design.error("Failed to analyze MCP environment")
270
+ if "error" in analysis:
271
+ design.error(f"Error: {analysis['error']}")
272
+
273
+ # Provide helpful debugging tips
274
+ design.section_title("Debugging Tips")
275
+ console.print("1. Test your server directly:")
276
+ console.print(f" [cyan]docker run --rm -it {temp_tag}[/cyan]")
277
+ console.print(" (Should see MCP initialization output)")
278
+ console.print("")
279
+ console.print("2. Check for common issues:")
280
+ console.print(" - Server crashes on startup")
281
+ console.print(" - Missing dependencies")
282
+ console.print(" - Syntax errors in server.py")
283
+ console.print("")
284
+ console.print("3. Run with verbose mode:")
285
+ console.print(" [cyan]hud build . --verbose[/cyan]")
286
+
287
+ raise typer.Exit(1)
288
+
289
+ design.success(f"Analyzed environment: {analysis['toolCount']} tools found")
290
+
291
+ # Extract environment variables from Dockerfile
292
+ dockerfile_path = env_dir / "Dockerfile"
293
+ required_env, optional_env = extract_env_vars_from_dockerfile(dockerfile_path)
294
+
295
+ # Create lock file content - minimal and useful
296
+ lock_content = {
297
+ "version": "1.0", # Lock file format version
298
+ "image": tag, # Will be updated with ID/digest later
299
+ "build": {
300
+ "generatedAt": datetime.utcnow().isoformat() + "Z",
301
+ "hudVersion": hud_version,
302
+ "directory": str(env_dir.name),
303
+ },
304
+ "environment": {
305
+ "initializeMs": analysis["initializeMs"],
306
+ "toolCount": analysis["toolCount"],
307
+ },
308
+ }
309
+
310
+ # Only add environment variables if they exist
311
+ if required_env or optional_env:
312
+ lock_content["environment"]["variables"] = {}
313
+ if required_env:
314
+ lock_content["environment"]["variables"]["required"] = required_env
315
+ if optional_env:
316
+ lock_content["environment"]["variables"]["optional"] = optional_env
317
+
318
+ # Add tool summary (not full schemas to keep it concise)
319
+ if analysis["tools"]:
320
+ lock_content["tools"] = [
321
+ {"name": tool["name"], "description": tool.get("description", "")}
322
+ for tool in analysis["tools"]
323
+ ]
324
+
325
+ # Write lock file
326
+ lock_path = env_dir / "hud.lock.yaml"
327
+ with open(lock_path, "w") as f:
328
+ yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
329
+
330
+ design.success("Created lock file: hud.lock.yaml")
331
+
332
+ # Calculate lock file hash
333
+ lock_content_str = yaml.dump(lock_content, default_flow_style=False, sort_keys=True)
334
+ lock_hash = hashlib.sha256(lock_content_str.encode()).hexdigest()
335
+ lock_size = len(lock_content_str)
336
+
337
+ # Rebuild with label containing lock file hash
338
+ design.progress_message("Rebuilding with lock file metadata...")
339
+
340
+ # Build final image with label (uses cache from first build)
341
+ label_cmd = [
342
+ "docker",
343
+ "build",
344
+ "--label",
345
+ f"org.hud.manifest.head={lock_hash}:{lock_size}",
346
+ "-t",
347
+ tag,
348
+ str(env_dir),
349
+ ]
350
+
351
+ # Run rebuild with proper encoding
352
+ process = subprocess.Popen( # noqa: S603
353
+ label_cmd,
354
+ stdout=subprocess.PIPE,
355
+ stderr=subprocess.STDOUT,
356
+ text=True,
357
+ encoding="utf-8",
358
+ errors="replace",
359
+ )
360
+
361
+ # Stream output if verbose
362
+ if verbose:
363
+ for line in process.stdout or []:
364
+ design.info(line.rstrip())
365
+ else:
366
+ # Just consume output to avoid blocking
367
+ process.stdout.read() # type: ignore
368
+
369
+ process.wait()
370
+
371
+ if process.returncode != 0:
372
+ design.error("Failed to rebuild with label")
373
+ raise typer.Exit(1)
374
+
375
+ design.success("Built final image with lock file metadata")
376
+
377
+ # NOW get the image ID after the final build
378
+ image_id = get_docker_image_id(tag) # type: ignore
379
+ if image_id:
380
+ # For local builds, store the image ID
381
+ # Docker IDs come as sha256:hash, we want tag@sha256:hash
382
+ if image_id.startswith("sha256:"):
383
+ lock_content["image"] = f"{tag}@{image_id}"
384
+ else:
385
+ lock_content["image"] = f"{tag}@sha256:{image_id}"
386
+
387
+ # Update the lock file with the new image reference
388
+ with open(lock_path, "w") as f:
389
+ yaml.dump(lock_content, f, default_flow_style=False, sort_keys=False)
390
+
391
+ design.success("Updated lock file with image ID")
392
+ else:
393
+ design.warning("Could not retrieve image ID for lock file")
394
+
395
+ # Remove temp image after we're done
396
+ subprocess.run(["docker", "rmi", temp_tag], capture_output=True) # noqa: S603, S607
397
+
398
+ # Print summary
399
+ design.section_title("Build Complete")
400
+
401
+ # Show the actual image reference from the lock file
402
+ final_image_ref = lock_content.get("image", tag)
403
+ console.print(f"[green]✓[/green] Local image : {final_image_ref}")
404
+ console.print("[green]✓[/green] Lock file : hud.lock.yaml")
405
+ console.print(f"[green]✓[/green] Tools found : {analysis['toolCount']}")
406
+
407
+ design.section_title("Next Steps")
408
+ console.print("Test locally:")
409
+ console.print(" [cyan]hud dev[/cyan] # Hot-reload development")
410
+ console.print(f" [cyan]hud run {tag}[/cyan] # Run the built image")
411
+ console.print("")
412
+ console.print("Publish to registry:")
413
+ console.print(f" [cyan]hud push --image <registry>/{tag}[/cyan]")
414
+ console.print("")
415
+ console.print("The lock file can be used to reproduce this exact environment.")
416
+
417
+
418
+ def build_command(
419
+ directory: str = typer.Argument(".", help="Environment directory to build"),
420
+ tag: str | None = typer.Option(
421
+ None, "--tag", "-t", help="Docker image tag (default: from pyproject.toml)"
422
+ ),
423
+ no_cache: bool = typer.Option(False, "--no-cache", help="Build without Docker cache"),
424
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
425
+ ) -> None:
426
+ """Build a HUD environment and generate lock file."""
427
+ build_environment(directory, tag, no_cache, verbose)
hud/cli/clone.py ADDED
@@ -0,0 +1,185 @@
1
+ """Git clone wrapper with quiet mode and rich output.
2
+
3
+ This module provides a CLI command that wraps 'git clone' to provide a better user experience:
4
+ - Runs git clone quietly (no verbose output)
5
+ - Displays a rich formatted success message
6
+ - Shows optional tutorial/getting started messages from the cloned repository
7
+
8
+ Usage:
9
+ hud clone https://github.com/user/repo.git
10
+
11
+ The clone command will look for a [tool.hud.clone] section in the cloned repository's
12
+ pyproject.toml file. If found, it will display the configured message after cloning.
13
+
14
+ Configuration in pyproject.toml:
15
+ [tool.hud.clone]
16
+ title = "🚀 My Project" # Optional title for the message panel
17
+ style = "blue" # Optional border style (any Rich color)
18
+
19
+ # Option 1: Plain text message with Rich markup support
20
+ message = "[bold]Welcome![/bold] Run [cyan]pip install -e .[/cyan] to start."
21
+
22
+ # Option 2: Markdown formatted message
23
+ markdown = "## Welcome\\n\\nThis supports **markdown** formatting."
24
+
25
+ # Option 3: Step-by-step instructions
26
+ steps = [
27
+ "Install dependencies: [green]pip install -e .[/green]",
28
+ "Run tests: [green]pytest[/green]",
29
+ "Start coding!"
30
+ ]
31
+
32
+ Rich Markup Examples:
33
+ - Colors: [red]text[/red], [green]text[/green], [blue]text[/blue], [cyan]text[/cyan]
34
+ - Styles: [bold]text[/bold], [italic]text[/italic], [underline]text[/underline]
35
+ - Combined: [bold cyan]text[/bold cyan]
36
+ - Background: [on red]text[/on red]
37
+ - Links: [link=https://example.com]clickable[/link]
38
+
39
+ See Rich documentation for more markup options: https://rich.readthedocs.io/en/stable/markup.html
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import subprocess
46
+ import tomllib
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ from rich.console import Console
51
+ from rich.markdown import Markdown
52
+ from rich.panel import Panel
53
+ from rich.text import Text
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ console = Console()
58
+
59
+
60
+ def clone_repository(url: str) -> tuple[bool, str]:
61
+ """
62
+ Clone a git repository quietly and return status.
63
+
64
+ Args:
65
+ url: Git repository URL
66
+
67
+ Returns:
68
+ Tuple of (success, directory_path or error_message)
69
+ """
70
+ # Extract repo name from URL
71
+ repo_name = Path(url).stem
72
+ if repo_name.endswith(".git"):
73
+ repo_name = repo_name[:-4]
74
+
75
+ # Build git clone command (simple, no options)
76
+ cmd = ["git", "clone", "--quiet", url]
77
+
78
+ try:
79
+ # Run git clone with quiet flag
80
+ subprocess.run( # noqa: S603
81
+ cmd,
82
+ capture_output=True,
83
+ text=True,
84
+ check=True,
85
+ )
86
+
87
+ # Get the absolute path of the cloned directory
88
+ clone_path = Path(repo_name).resolve()
89
+
90
+ return True, str(clone_path)
91
+
92
+ except subprocess.CalledProcessError as e:
93
+ error_msg = (
94
+ e.stderr.strip() if e.stderr else f"Git clone failed with exit code {e.returncode}"
95
+ )
96
+ return False, error_msg
97
+ except Exception as e:
98
+ return False, f"Unexpected error: {e!s}"
99
+
100
+
101
+ def get_clone_message(clone_path: str) -> dict[str, Any] | None:
102
+ """
103
+ Look for a clone message configuration in the repository's pyproject.toml or .hud.toml.
104
+
105
+ Checks for:
106
+ 1. [tool.hud.clone] section in pyproject.toml
107
+ 2. [clone] section in .hud.toml
108
+
109
+ Args:
110
+ clone_path: Path to the cloned repository
111
+
112
+ Returns:
113
+ Dictionary with message configuration or None
114
+ """
115
+ repo_path = Path(clone_path)
116
+
117
+ # Check pyproject.toml first
118
+ pyproject_path = repo_path / "pyproject.toml"
119
+ if pyproject_path.exists():
120
+ try:
121
+ with open(pyproject_path, "rb") as f:
122
+ data = tomllib.load(f)
123
+ if "tool" in data and "hud" in data["tool"] and "clone" in data["tool"]["hud"]:
124
+ return data["tool"]["hud"]["clone"]
125
+ except Exception:
126
+ logger.warning("Failed to load clone config from %s", pyproject_path)
127
+
128
+ # Check .hud.toml as fallback
129
+ hud_toml_path = repo_path / ".hud.toml"
130
+ if hud_toml_path.exists():
131
+ try:
132
+ with open(hud_toml_path, "rb") as f:
133
+ data = tomllib.load(f)
134
+ if "clone" in data:
135
+ return data["clone"]
136
+ except Exception:
137
+ logger.warning("Failed to load clone config from %s", hud_toml_path)
138
+
139
+ return None
140
+
141
+
142
+ def print_tutorial(clone_config: dict[str, Any] | None = None) -> None:
143
+ """Print a rich formatted success message with optional tutorial."""
144
+ # Display custom message if configured
145
+ if clone_config:
146
+ # Handle different message formats
147
+ if "message" in clone_config:
148
+ # Message with Rich markup support
149
+ console.print(
150
+ Panel(
151
+ clone_config["message"], # Rich will parse markup automatically
152
+ title=clone_config.get("title", "📋 Getting Started"),
153
+ border_style=clone_config.get("style", "blue"),
154
+ padding=(1, 2),
155
+ )
156
+ )
157
+ elif "markdown" in clone_config:
158
+ # Markdown message
159
+ console.print(
160
+ Panel(
161
+ Markdown(clone_config["markdown"]),
162
+ title=clone_config.get("title", "📋 Getting Started"),
163
+ border_style=clone_config.get("style", "blue"),
164
+ padding=(1, 2),
165
+ )
166
+ )
167
+ elif "steps" in clone_config:
168
+ # Step-by-step instructions
169
+ title = clone_config.get("title", "📋 Getting Started")
170
+ console.print(f"\n[bold]{title}[/bold]\n")
171
+ for i, step in enumerate(clone_config["steps"], 1):
172
+ console.print(f" {i}. {step}")
173
+ console.print()
174
+
175
+
176
+ def print_error(error_msg: str) -> None:
177
+ """Print a rich formatted error message."""
178
+ console.print(
179
+ Panel(
180
+ Text(f"❌ {error_msg}", style="red"),
181
+ title="[bold red]Clone Failed[/bold red]",
182
+ border_style="red",
183
+ padding=(1, 2),
184
+ )
185
+ )
hud/cli/cursor.py ADDED
@@ -0,0 +1,92 @@
1
+ """Cursor config parsing utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def parse_cursor_config(server_name: str) -> tuple[list[str] | None, str | None]:
11
+ """
12
+ Parse cursor config to get command for a server.
13
+
14
+ Args:
15
+ server_name: Name of the server in Cursor config
16
+
17
+ Returns:
18
+ Tuple of (command_list, error_message). If successful, error_message is None.
19
+ If failed, command_list is None and error_message contains the error.
20
+ """
21
+ # Find cursor config
22
+ cursor_config_path = Path.home() / ".cursor" / "mcp.json"
23
+ if not cursor_config_path.exists():
24
+ # Try Windows path
25
+ cursor_config_path = Path(os.environ.get("USERPROFILE", "")) / ".cursor" / "mcp.json"
26
+
27
+ if not cursor_config_path.exists():
28
+ return None, f"Cursor config not found at {cursor_config_path}"
29
+
30
+ try:
31
+ with open(cursor_config_path) as f:
32
+ config = json.load(f)
33
+
34
+ servers = config.get("mcpServers", {})
35
+ if server_name not in servers:
36
+ available = ", ".join(servers.keys())
37
+ return None, f"Server '{server_name}' not found. Available: {available}"
38
+
39
+ server_config = servers[server_name]
40
+ command = server_config.get("command", "")
41
+ args = server_config.get("args", [])
42
+ _ = server_config.get("env", {})
43
+
44
+ # Combine command and args
45
+ full_command = [command, *args]
46
+
47
+ # Handle reloaderoo wrapper
48
+ if command == "npx" and "reloaderoo" in args and "--" in args:
49
+ # Extract the actual command after --
50
+ dash_index = args.index("--")
51
+ full_command = args[dash_index + 1 :]
52
+
53
+ return full_command, None
54
+
55
+ except Exception as e:
56
+ return None, f"Error reading config: {e}"
57
+
58
+
59
+ def list_cursor_servers() -> tuple[list[str] | None, str | None]:
60
+ """
61
+ List all available servers in Cursor config.
62
+
63
+ Returns:
64
+ Tuple of (server_list, error_message). If successful, error_message is None.
65
+ """
66
+ # Find cursor config
67
+ cursor_config_path = Path.home() / ".cursor" / "mcp.json"
68
+ if not cursor_config_path.exists():
69
+ # Try Windows path
70
+ cursor_config_path = Path(os.environ.get("USERPROFILE", "")) / ".cursor" / "mcp.json"
71
+
72
+ if not cursor_config_path.exists():
73
+ return None, f"Cursor config not found at {cursor_config_path}"
74
+
75
+ try:
76
+ with open(cursor_config_path) as f:
77
+ config = json.load(f)
78
+
79
+ servers = config.get("mcpServers", {})
80
+ return list(servers.keys()), None
81
+
82
+ except Exception as e:
83
+ return None, f"Error reading config: {e}"
84
+
85
+
86
+ def get_cursor_config_path() -> Path:
87
+ """Get the path to Cursor's MCP config file."""
88
+ cursor_config_path = Path.home() / ".cursor" / "mcp.json"
89
+ if not cursor_config_path.exists():
90
+ # Try Windows path
91
+ cursor_config_path = Path(os.environ.get("USERPROFILE", "")) / ".cursor" / "mcp.json"
92
+ return cursor_config_path