ha-mcp-dev 6.3.1.dev137__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.
Files changed (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. tests/test_env_manager.py +336 -0
@@ -0,0 +1,787 @@
1
+ """
2
+ HACS (Home Assistant Community Store) integration tools for Home Assistant MCP server.
3
+
4
+ This module provides tools to interact with HACS via the WebSocket API, enabling AI agents
5
+ to discover custom integrations, Lovelace cards, themes, and more.
6
+ """
7
+
8
+ import logging
9
+ from typing import Annotated, Any, Literal
10
+
11
+ from pydantic import Field
12
+
13
+ from .helpers import exception_to_structured_error, log_tool_usage
14
+ from .util_helpers import add_timezone_metadata, coerce_int_param
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # HACS uses different category names internally vs what users expect
19
+ # User-friendly name -> HACS internal name
20
+ CATEGORY_MAP = {
21
+ "lovelace": "plugin", # HACS calls Lovelace cards "plugin"
22
+ "integration": "integration",
23
+ "theme": "theme",
24
+ "appdaemon": "appdaemon",
25
+ "python_script": "python_script",
26
+ "template": "template",
27
+ }
28
+
29
+ # Reverse mapping for display
30
+ CATEGORY_DISPLAY = {v: k for k, v in CATEGORY_MAP.items()}
31
+ CATEGORY_DISPLAY["plugin"] = "lovelace" # Display as lovelace for users
32
+
33
+
34
+ async def _check_hacs_available(client) -> tuple[bool, str | None]:
35
+ """
36
+ Check if HACS is installed and available via WebSocket.
37
+
38
+ Returns:
39
+ Tuple of (is_available, error_message)
40
+ """
41
+ try:
42
+ from ..client.websocket_client import get_websocket_client
43
+ ws_client = await get_websocket_client()
44
+
45
+ # Try to get HACS info to verify it's installed
46
+ response = await ws_client.send_command("hacs/info")
47
+
48
+ if response.get("success"):
49
+ return True, None
50
+ else:
51
+ return False, "HACS is installed but returned an error"
52
+ except Exception as e:
53
+ error_str = str(e).lower()
54
+ if "unknown command" in error_str or "not found" in error_str:
55
+ return False, "HACS is not installed or not loaded. Please install HACS from https://hacs.xyz/"
56
+ return False, f"Failed to connect to HACS: {str(e)}"
57
+
58
+
59
+ def register_hacs_tools(mcp, client, **kwargs):
60
+ """Register HACS integration tools with the MCP server."""
61
+
62
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["hacs", "info"], "title": "Get HACS Info"})
63
+ @log_tool_usage
64
+ async def ha_hacs_info() -> dict[str, Any]:
65
+ """Get HACS status, version, and enabled categories.
66
+
67
+ Returns information about the HACS installation including:
68
+ - Version number
69
+ - Enabled categories (integration, lovelace, theme, etc.)
70
+ - Stage (running, startup, etc.)
71
+ - Lovelace mode
72
+ - Disabled reason (if any)
73
+
74
+ This is useful for validating that HACS is installed and operational
75
+ before using other HACS tools.
76
+
77
+ **HACS Installation:**
78
+ If HACS is not installed, visit https://hacs.xyz/ for installation instructions.
79
+
80
+ Returns:
81
+ Dictionary with HACS status information or error if HACS is not available.
82
+ """
83
+ try:
84
+ # Check if HACS is available
85
+ is_available, error_msg = await _check_hacs_available(client)
86
+ if not is_available:
87
+ return await add_timezone_metadata(client, {
88
+ "success": False,
89
+ "error": error_msg,
90
+ "error_code": "HACS_NOT_AVAILABLE",
91
+ "suggestions": [
92
+ "Install HACS from https://hacs.xyz/",
93
+ "Ensure Home Assistant has been restarted after HACS installation",
94
+ "Check Home Assistant logs for HACS errors",
95
+ ],
96
+ })
97
+
98
+ # Get HACS info via WebSocket
99
+ from ..client.websocket_client import get_websocket_client
100
+ ws_client = await get_websocket_client()
101
+ response = await ws_client.send_command("hacs/info")
102
+
103
+ if not response.get("success"):
104
+ error_response = exception_to_structured_error(
105
+ Exception(f"HACS info request failed: {response}"),
106
+ context={"command": "hacs/info"},
107
+ )
108
+ return await add_timezone_metadata(client, error_response)
109
+
110
+ result = response.get("result", {})
111
+
112
+ return await add_timezone_metadata(client, {
113
+ "success": True,
114
+ "version": result.get("version"),
115
+ "categories": result.get("categories", []),
116
+ "stage": result.get("stage"),
117
+ "lovelace_mode": result.get("lovelace_mode"),
118
+ "disabled_reason": result.get("disabled_reason"),
119
+ "data": result,
120
+ })
121
+
122
+ except Exception as e:
123
+ error_response = exception_to_structured_error(
124
+ e,
125
+ context={"tool": "ha_hacs_info"},
126
+ )
127
+ if "error" in error_response and isinstance(error_response["error"], dict):
128
+ error_response["error"]["suggestions"] = [
129
+ "Verify HACS is installed: https://hacs.xyz/",
130
+ "Check Home Assistant connection",
131
+ "Restart Home Assistant if HACS was recently installed",
132
+ ]
133
+ return await add_timezone_metadata(client, error_response)
134
+
135
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["hacs", "search"], "title": "List HACS Installed"})
136
+ @log_tool_usage
137
+ async def ha_hacs_list_installed(
138
+ category: Annotated[
139
+ Literal["integration", "lovelace", "theme", "appdaemon", "python_script"] | None,
140
+ Field(
141
+ default=None,
142
+ description=(
143
+ "Filter by category: 'integration', 'lovelace', 'theme', "
144
+ "'appdaemon', or 'python_script'. Use None for all categories."
145
+ ),
146
+ ),
147
+ ] = None,
148
+ ) -> dict[str, Any]:
149
+ """List installed HACS repositories with focused, small response.
150
+
151
+ **DASHBOARD TIP:** Use `category="lovelace"` to discover installed custom cards
152
+ for use with `ha_config_set_dashboard()`.
153
+
154
+ Returns a list of installed repositories with key information:
155
+ - name: Repository name
156
+ - full_name: Full GitHub repository name (owner/repo)
157
+ - category: Type of repository (integration, lovelace, theme, etc.)
158
+ - installed_version: Currently installed version
159
+ - available_version: Latest available version
160
+ - pending_update: Whether an update is available
161
+ - description: Repository description
162
+
163
+ **Categories:**
164
+ - `integration`: Custom integrations and components
165
+ - `lovelace`: Custom dashboard cards and panels
166
+ - `theme`: Custom themes for the UI
167
+ - `appdaemon`: AppDaemon apps
168
+ - `python_script`: Python scripts
169
+
170
+ Args:
171
+ category: Filter results by category (default: all categories)
172
+
173
+ Returns:
174
+ List of installed HACS repositories or error if HACS is not available.
175
+ """
176
+ try:
177
+ # Check if HACS is available
178
+ is_available, error_msg = await _check_hacs_available(client)
179
+ if not is_available:
180
+ return await add_timezone_metadata(client, {
181
+ "success": False,
182
+ "error": error_msg,
183
+ "error_code": "HACS_NOT_AVAILABLE",
184
+ "suggestions": [
185
+ "Install HACS from https://hacs.xyz/",
186
+ "Ensure Home Assistant has been restarted after HACS installation",
187
+ "Check Home Assistant logs for HACS errors",
188
+ ],
189
+ })
190
+
191
+ # Get installed repositories via WebSocket
192
+ from ..client.websocket_client import get_websocket_client
193
+ ws_client = await get_websocket_client()
194
+
195
+ # Build command parameters - map user-friendly category to HACS internal name
196
+ kwargs_cmd: dict[str, Any] = {}
197
+ if category:
198
+ hacs_category = CATEGORY_MAP.get(category, category)
199
+ kwargs_cmd["categories"] = [hacs_category]
200
+
201
+ response = await ws_client.send_command("hacs/repositories/list", **kwargs_cmd)
202
+
203
+ if not response.get("success"):
204
+ error_response = exception_to_structured_error(
205
+ Exception(f"HACS repositories list request failed: {response}"),
206
+ context={"command": "hacs/repositories/list", "category": category},
207
+ )
208
+ return await add_timezone_metadata(client, error_response)
209
+
210
+ repositories = response.get("result", [])
211
+
212
+ # Filter to only installed repositories and extract key info
213
+ installed = []
214
+ for repo in repositories:
215
+ if repo.get("installed", False):
216
+ # Map HACS internal category back to user-friendly name
217
+ repo_category = repo.get("category", "")
218
+ display_category = CATEGORY_DISPLAY.get(repo_category, repo_category)
219
+ installed.append({
220
+ "name": repo.get("name"),
221
+ "full_name": repo.get("full_name"),
222
+ "category": display_category,
223
+ "id": repo.get("id"), # Include numeric ID for repository_info
224
+ "installed_version": repo.get("installed_version"),
225
+ "available_version": repo.get("available_version"),
226
+ "pending_update": repo.get("pending_upgrade", False),
227
+ "description": repo.get("description"),
228
+ "authors": repo.get("authors", []),
229
+ "domain": repo.get("domain"), # For integrations
230
+ "stars": repo.get("stars", 0),
231
+ })
232
+
233
+ return await add_timezone_metadata(client, {
234
+ "success": True,
235
+ "category_filter": category,
236
+ "total_installed": len(installed),
237
+ "repositories": installed,
238
+ })
239
+
240
+ except Exception as e:
241
+ error_response = exception_to_structured_error(
242
+ e,
243
+ context={"tool": "ha_hacs_list_installed", "category": category},
244
+ )
245
+ if "error" in error_response and isinstance(error_response["error"], dict):
246
+ error_response["error"]["suggestions"] = [
247
+ "Verify HACS is installed: https://hacs.xyz/",
248
+ "Check category name is valid: integration, lovelace, theme, appdaemon, python_script",
249
+ "Check Home Assistant connection",
250
+ ]
251
+ return await add_timezone_metadata(client, error_response)
252
+
253
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["hacs", "search"], "title": "Search HACS Store"})
254
+ @log_tool_usage
255
+ async def ha_hacs_search(
256
+ query: str,
257
+ category: Annotated[
258
+ Literal["integration", "lovelace", "theme", "appdaemon", "python_script"] | None,
259
+ Field(
260
+ default=None,
261
+ description="Filter by category (optional)",
262
+ ),
263
+ ] = None,
264
+ max_results: Annotated[
265
+ int | str,
266
+ Field(
267
+ default=10,
268
+ description="Maximum number of results to return (default: 10, max: 100)",
269
+ ),
270
+ ] = 10,
271
+ ) -> dict[str, Any]:
272
+ """Search HACS store for repositories by keyword with pagination.
273
+
274
+ Searches the HACS store for repositories matching the query string.
275
+ Returns repository information including stars, downloads, and descriptions.
276
+
277
+ **Use Cases:**
278
+ - Find custom cards: `ha_hacs_search("mushroom", category="lovelace")`
279
+ - Find integrations: `ha_hacs_search("nest", category="integration")`
280
+ - Browse themes: `ha_hacs_search("dark", category="theme")`
281
+
282
+ Results include:
283
+ - name: Repository name
284
+ - full_name: Full GitHub repository name
285
+ - description: Repository description
286
+ - category: Type of repository
287
+ - stars: GitHub stars count
288
+ - downloads: Number of HACS installations
289
+ - authors: Repository authors
290
+ - installed: Whether already installed
291
+
292
+ Args:
293
+ query: Search query (repository name, description, author)
294
+ category: Filter by category (optional)
295
+ max_results: Maximum results to return (default: 10, max: 100)
296
+
297
+ Returns:
298
+ Search results from HACS store or error if HACS is not available.
299
+ """
300
+ try:
301
+ # Coerce max_results to int
302
+ max_results_int = coerce_int_param(
303
+ max_results,
304
+ "max_results",
305
+ default=10,
306
+ min_value=1,
307
+ max_value=100,
308
+ ) or 10
309
+
310
+ # Check if HACS is available
311
+ is_available, error_msg = await _check_hacs_available(client)
312
+ if not is_available:
313
+ return await add_timezone_metadata(client, {
314
+ "success": False,
315
+ "error": error_msg,
316
+ "error_code": "HACS_NOT_AVAILABLE",
317
+ "suggestions": [
318
+ "Install HACS from https://hacs.xyz/",
319
+ "Ensure Home Assistant has been restarted after HACS installation",
320
+ "Check Home Assistant logs for HACS errors",
321
+ ],
322
+ })
323
+
324
+ # Get all repositories via WebSocket
325
+ from ..client.websocket_client import get_websocket_client
326
+ ws_client = await get_websocket_client()
327
+
328
+ # Build command parameters - map user-friendly category to HACS internal name
329
+ kwargs_cmd: dict[str, Any] = {}
330
+ if category:
331
+ hacs_category = CATEGORY_MAP.get(category, category)
332
+ kwargs_cmd["categories"] = [hacs_category]
333
+
334
+ response = await ws_client.send_command("hacs/repositories/list", **kwargs_cmd)
335
+
336
+ if not response.get("success"):
337
+ error_response = exception_to_structured_error(
338
+ Exception(f"HACS search request failed: {response}"),
339
+ context={"command": "hacs/repositories/list", "query": query, "category": category},
340
+ )
341
+ return await add_timezone_metadata(client, error_response)
342
+
343
+ all_repositories = response.get("result", [])
344
+
345
+ # Simple search: filter by query string in name, description, or authors
346
+ query_lower = query.lower().strip()
347
+ matches = []
348
+
349
+ for repo in all_repositories:
350
+ # Handle None values safely
351
+ name = (repo.get("name") or "").lower()
352
+ description = (repo.get("description") or "").lower()
353
+ full_name = (repo.get("full_name") or "").lower()
354
+ authors_list = repo.get("authors") or []
355
+ authors = " ".join(authors_list).lower()
356
+
357
+ # Calculate relevance score
358
+ score = 0
359
+ if query_lower in name:
360
+ score += 100
361
+ if query_lower in full_name:
362
+ score += 50
363
+ if query_lower in description:
364
+ score += 30
365
+ if query_lower in authors:
366
+ score += 20
367
+
368
+ if score > 0:
369
+ # Map HACS internal category back to user-friendly name
370
+ repo_category = repo.get("category", "")
371
+ display_category = CATEGORY_DISPLAY.get(repo_category, repo_category)
372
+ matches.append({
373
+ "name": repo.get("name"),
374
+ "full_name": repo.get("full_name"),
375
+ "description": repo.get("description"),
376
+ "category": display_category,
377
+ "id": repo.get("id"), # Include numeric ID for repository_info
378
+ "stars": repo.get("stars", 0),
379
+ "downloads": repo.get("downloads", 0),
380
+ "authors": authors_list,
381
+ "installed": repo.get("installed", False),
382
+ "installed_version": repo.get("installed_version") if repo.get("installed") else None,
383
+ "available_version": repo.get("available_version"),
384
+ "score": score,
385
+ })
386
+
387
+ # Sort by score (descending) and limit results
388
+ matches.sort(key=lambda x: x["score"], reverse=True)
389
+ limited_matches = matches[:max_results_int]
390
+
391
+ return await add_timezone_metadata(client, {
392
+ "success": True,
393
+ "query": query,
394
+ "category_filter": category,
395
+ "total_matches": len(matches),
396
+ "results_returned": len(limited_matches),
397
+ "results": limited_matches,
398
+ })
399
+
400
+ except Exception as e:
401
+ error_response = exception_to_structured_error(
402
+ e,
403
+ context={"tool": "ha_hacs_search", "query": query, "category": category},
404
+ )
405
+ if "error" in error_response and isinstance(error_response["error"], dict):
406
+ error_response["error"]["suggestions"] = [
407
+ "Verify HACS is installed: https://hacs.xyz/",
408
+ "Try a simpler search query",
409
+ "Check category name is valid: integration, lovelace, theme, appdaemon, python_script",
410
+ ]
411
+ return await add_timezone_metadata(client, error_response)
412
+
413
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["hacs", "info"], "title": "Get HACS Repository Info"})
414
+ @log_tool_usage
415
+ async def ha_hacs_repository_info(repository_id: str) -> dict[str, Any]:
416
+ """Get detailed repository information including README and documentation.
417
+
418
+ Returns comprehensive information about a HACS repository:
419
+ - Basic info (name, description, category, authors)
420
+ - Installation status and versions
421
+ - README content (useful for configuration examples)
422
+ - Available releases and versions
423
+ - GitHub stats (stars, issues)
424
+ - Configuration examples (if available)
425
+
426
+ **Use Cases:**
427
+ - Get card configuration examples: `ha_hacs_repository_info("441028036")`
428
+ - Check integration setup instructions
429
+ - Find theme customization options
430
+
431
+ **Note:** The repository_id is the numeric ID from HACS, not the GitHub path.
432
+ Use `ha_hacs_list_installed()` or `ha_hacs_search()` to find the numeric ID.
433
+
434
+ Args:
435
+ repository_id: Repository numeric ID (e.g., "441028036") or GitHub path (e.g., "dvd-dev/hilo")
436
+
437
+ Returns:
438
+ Detailed repository information or error if not found.
439
+ """
440
+ try:
441
+ # Check if HACS is available
442
+ is_available, error_msg = await _check_hacs_available(client)
443
+ if not is_available:
444
+ return await add_timezone_metadata(client, {
445
+ "success": False,
446
+ "error": error_msg,
447
+ "error_code": "HACS_NOT_AVAILABLE",
448
+ "suggestions": [
449
+ "Install HACS from https://hacs.xyz/",
450
+ "Ensure Home Assistant has been restarted after HACS installation",
451
+ "Check Home Assistant logs for HACS errors",
452
+ ],
453
+ })
454
+
455
+ from ..client.websocket_client import get_websocket_client
456
+ ws_client = await get_websocket_client()
457
+
458
+ # If repository_id contains a slash, it's a GitHub path - need to look up numeric ID
459
+ actual_id = repository_id
460
+ if "/" in repository_id:
461
+ # Look up the numeric ID from the repository list
462
+ list_response = await ws_client.send_command("hacs/repositories/list")
463
+ if list_response.get("success"):
464
+ repos = list_response.get("result", [])
465
+ for repo in repos:
466
+ if repo.get("full_name", "").lower() == repository_id.lower():
467
+ actual_id = str(repo.get("id"))
468
+ break
469
+ else:
470
+ return await add_timezone_metadata(client, {
471
+ "success": False,
472
+ "error": f"Repository '{repository_id}' not found in HACS",
473
+ "error_code": "REPOSITORY_NOT_FOUND",
474
+ "suggestions": [
475
+ "Use ha_hacs_search() to find the repository",
476
+ "Check the repository name is correct (case-insensitive)",
477
+ "The repository may need to be added to HACS first",
478
+ ],
479
+ })
480
+
481
+ # Get repository info via WebSocket using numeric ID
482
+ response = await ws_client.send_command("hacs/repository/info", repository_id=actual_id)
483
+
484
+ if not response.get("success"):
485
+ error_response = exception_to_structured_error(
486
+ Exception(f"HACS repository info request failed: {response}"),
487
+ context={"command": "hacs/repository/info", "repository_id": repository_id},
488
+ )
489
+ return await add_timezone_metadata(client, error_response)
490
+
491
+ result = response.get("result", {})
492
+
493
+ # Extract and structure the most useful information
494
+ return await add_timezone_metadata(client, {
495
+ "success": True,
496
+ "repository_id": repository_id,
497
+ "name": result.get("name"),
498
+ "full_name": result.get("full_name"),
499
+ "description": result.get("description"),
500
+ "category": result.get("category"),
501
+ "authors": result.get("authors", []),
502
+ "domain": result.get("domain"), # For integrations
503
+ "installed": result.get("installed", False),
504
+ "installed_version": result.get("installed_version"),
505
+ "available_version": result.get("available_version"),
506
+ "pending_update": result.get("pending_upgrade", False),
507
+ "stars": result.get("stars", 0),
508
+ "downloads": result.get("downloads", 0),
509
+ "topics": result.get("topics", []),
510
+ "releases": result.get("releases", []),
511
+ "default_branch": result.get("default_branch"),
512
+ "readme": result.get("readme"), # Full README content
513
+ "data": result, # Full response for advanced use
514
+ })
515
+
516
+ except Exception as e:
517
+ error_response = exception_to_structured_error(
518
+ e,
519
+ context={"tool": "ha_hacs_repository_info", "repository_id": repository_id},
520
+ )
521
+ if "error" in error_response and isinstance(error_response["error"], dict):
522
+ error_response["error"]["suggestions"] = [
523
+ "Verify HACS is installed: https://hacs.xyz/",
524
+ "Check repository ID format (e.g., 'hacs/integration' or 'owner/repo')",
525
+ "Use ha_hacs_search() to find the correct repository ID",
526
+ ]
527
+ return await add_timezone_metadata(client, error_response)
528
+
529
+ @mcp.tool(annotations={"destructiveHint": True, "tags": ["hacs", "management"], "title": "Add HACS Repository"})
530
+ @log_tool_usage
531
+ async def ha_hacs_add_repository(
532
+ repository: str,
533
+ category: Annotated[
534
+ Literal["integration", "lovelace", "theme", "appdaemon", "python_script"],
535
+ Field(
536
+ description="Repository category (required)",
537
+ ),
538
+ ],
539
+ ) -> dict[str, Any]:
540
+ """Add a custom GitHub repository to HACS.
541
+
542
+ Allows adding custom repositories that are not in the default HACS store.
543
+ This is useful for:
544
+ - Adding custom integrations from GitHub
545
+ - Installing custom Lovelace cards
546
+ - Adding custom themes
547
+ - Installing beta/development versions
548
+
549
+ **Requirements:**
550
+ - Repository must be a valid GitHub repository
551
+ - Repository must follow HACS structure guidelines
552
+ - Category must match the repository type
553
+
554
+ **Examples:**
555
+ ```python
556
+ # Add custom integration
557
+ ha_hacs_add_repository("owner/custom-integration", category="integration")
558
+
559
+ # Add custom card
560
+ ha_hacs_add_repository("owner/custom-card", category="lovelace")
561
+
562
+ # Add custom theme
563
+ ha_hacs_add_repository("owner/custom-theme", category="theme")
564
+ ```
565
+
566
+ Args:
567
+ repository: GitHub repository in format "owner/repo"
568
+ category: Repository category (integration, lovelace, theme, appdaemon, python_script)
569
+
570
+ Returns:
571
+ Success status and repository ID if added successfully.
572
+ """
573
+ try:
574
+ # Check if HACS is available
575
+ is_available, error_msg = await _check_hacs_available(client)
576
+ if not is_available:
577
+ return await add_timezone_metadata(client, {
578
+ "success": False,
579
+ "error": error_msg,
580
+ "error_code": "HACS_NOT_AVAILABLE",
581
+ "suggestions": [
582
+ "Install HACS from https://hacs.xyz/",
583
+ "Ensure Home Assistant has been restarted after HACS installation",
584
+ "Check Home Assistant logs for HACS errors",
585
+ ],
586
+ })
587
+
588
+ # Validate repository format
589
+ if "/" not in repository:
590
+ return await add_timezone_metadata(client, {
591
+ "success": False,
592
+ "error": "Invalid repository format. Must be 'owner/repo'",
593
+ "error_code": "INVALID_REPOSITORY_FORMAT",
594
+ "suggestions": [
595
+ "Use format: 'owner/repo' (e.g., 'hacs/integration')",
596
+ "Check the repository exists on GitHub",
597
+ ],
598
+ })
599
+
600
+ # Add repository via WebSocket
601
+ from ..client.websocket_client import get_websocket_client
602
+ ws_client = await get_websocket_client()
603
+
604
+ # Map user-friendly category to HACS internal name
605
+ hacs_category = CATEGORY_MAP.get(category, category)
606
+
607
+ response = await ws_client.send_command(
608
+ "hacs/repositories/add",
609
+ repository=repository,
610
+ category=hacs_category,
611
+ )
612
+
613
+ if not response.get("success"):
614
+ error_response = exception_to_structured_error(
615
+ Exception(f"HACS add repository request failed: {response}"),
616
+ context={
617
+ "command": "hacs/repositories/add",
618
+ "repository": repository,
619
+ "category": category,
620
+ },
621
+ )
622
+ return await add_timezone_metadata(client, error_response)
623
+
624
+ result = response.get("result", {})
625
+
626
+ return await add_timezone_metadata(client, {
627
+ "success": True,
628
+ "repository": repository,
629
+ "category": category,
630
+ "repository_id": result.get("id"),
631
+ "message": f"Successfully added {repository} to HACS",
632
+ "data": result,
633
+ })
634
+
635
+ except Exception as e:
636
+ error_response = exception_to_structured_error(
637
+ e,
638
+ context={
639
+ "tool": "ha_hacs_add_repository",
640
+ "repository": repository,
641
+ "category": category,
642
+ },
643
+ )
644
+ if "error" in error_response and isinstance(error_response["error"], dict):
645
+ error_response["error"]["suggestions"] = [
646
+ "Verify HACS is installed: https://hacs.xyz/",
647
+ "Check repository format: 'owner/repo'",
648
+ "Verify the repository exists on GitHub",
649
+ "Ensure category matches repository type",
650
+ "Check repository follows HACS guidelines: https://hacs.xyz/docs/publish/start",
651
+ ]
652
+ return await add_timezone_metadata(client, error_response)
653
+
654
+ @mcp.tool(annotations={"destructiveHint": True, "tags": ["hacs", "management"], "title": "Download/Install HACS Repository"})
655
+ @log_tool_usage
656
+ async def ha_hacs_download(
657
+ repository_id: str,
658
+ version: Annotated[
659
+ str | None,
660
+ Field(
661
+ default=None,
662
+ description="Specific version to install (e.g., 'v1.2.3'). If not specified, installs the latest version.",
663
+ ),
664
+ ] = None,
665
+ ) -> dict[str, Any]:
666
+ """Download and install a HACS repository.
667
+
668
+ This installs a repository from HACS to your Home Assistant instance.
669
+ For integrations, a restart of Home Assistant may be required after installation.
670
+
671
+ **Prerequisites:**
672
+ - The repository must already be in HACS (either from the default store or added via `ha_hacs_add_repository`)
673
+ - Use `ha_hacs_search()` or `ha_hacs_list_installed()` to find the repository ID
674
+
675
+ **Examples:**
676
+ ```python
677
+ # Install latest version of a repository
678
+ ha_hacs_download("441028036")
679
+
680
+ # Install specific version
681
+ ha_hacs_download("441028036", version="v2.0.0")
682
+
683
+ # Install by GitHub path (will look up the numeric ID)
684
+ ha_hacs_download("piitaya/lovelace-mushroom", version="v4.0.0")
685
+ ```
686
+
687
+ **Note:** For integrations, you may need to restart Home Assistant after installation.
688
+ For Lovelace cards, clear your browser cache to see the new card.
689
+
690
+ Args:
691
+ repository_id: Repository numeric ID or GitHub path (e.g., "441028036" or "owner/repo")
692
+ version: Specific version to install (optional, defaults to latest)
693
+
694
+ Returns:
695
+ Success status and installation details.
696
+ """
697
+ try:
698
+ # Check if HACS is available
699
+ is_available, error_msg = await _check_hacs_available(client)
700
+ if not is_available:
701
+ return await add_timezone_metadata(client, {
702
+ "success": False,
703
+ "error": error_msg,
704
+ "error_code": "HACS_NOT_AVAILABLE",
705
+ "suggestions": [
706
+ "Install HACS from https://hacs.xyz/",
707
+ "Ensure Home Assistant has been restarted after HACS installation",
708
+ "Check Home Assistant logs for HACS errors",
709
+ ],
710
+ })
711
+
712
+ from ..client.websocket_client import get_websocket_client
713
+ ws_client = await get_websocket_client()
714
+
715
+ # If repository_id contains a slash, it's a GitHub path - need to look up numeric ID
716
+ actual_id = repository_id
717
+ repo_name = repository_id
718
+ if "/" in repository_id:
719
+ # Look up the numeric ID from the repository list
720
+ list_response = await ws_client.send_command("hacs/repositories/list")
721
+ if list_response.get("success"):
722
+ repos = list_response.get("result", [])
723
+ for repo in repos:
724
+ if repo.get("full_name", "").lower() == repository_id.lower():
725
+ actual_id = str(repo.get("id"))
726
+ repo_name = repo.get("name") or repository_id
727
+ break
728
+ else:
729
+ return await add_timezone_metadata(client, {
730
+ "success": False,
731
+ "error": f"Repository '{repository_id}' not found in HACS",
732
+ "error_code": "REPOSITORY_NOT_FOUND",
733
+ "suggestions": [
734
+ "Use ha_hacs_add_repository() to add the repository first",
735
+ "Use ha_hacs_search() to find available repositories",
736
+ "Check the repository name is correct (case-insensitive)",
737
+ ],
738
+ })
739
+
740
+ # Build download command parameters
741
+ download_kwargs: dict[str, Any] = {"repository": actual_id}
742
+ if version:
743
+ download_kwargs["version"] = version
744
+
745
+ # Download/install the repository
746
+ response = await ws_client.send_command("hacs/repository/download", **download_kwargs)
747
+
748
+ if not response.get("success"):
749
+ error_response = exception_to_structured_error(
750
+ Exception(f"HACS download request failed: {response}"),
751
+ context={
752
+ "command": "hacs/repository/download",
753
+ "repository_id": repository_id,
754
+ "version": version,
755
+ },
756
+ )
757
+ return await add_timezone_metadata(client, error_response)
758
+
759
+ result = response.get("result", {})
760
+
761
+ return await add_timezone_metadata(client, {
762
+ "success": True,
763
+ "repository_id": actual_id,
764
+ "repository": repo_name,
765
+ "version": version or "latest",
766
+ "message": f"Successfully installed {repo_name}" + (f" version {version}" if version else ""),
767
+ "note": "For integrations, restart Home Assistant to activate. For Lovelace cards, clear browser cache.",
768
+ "data": result,
769
+ })
770
+
771
+ except Exception as e:
772
+ error_response = exception_to_structured_error(
773
+ e,
774
+ context={
775
+ "tool": "ha_hacs_download",
776
+ "repository_id": repository_id,
777
+ "version": version,
778
+ },
779
+ )
780
+ if "error" in error_response and isinstance(error_response["error"], dict):
781
+ error_response["error"]["suggestions"] = [
782
+ "Verify HACS is installed: https://hacs.xyz/",
783
+ "Check repository ID is valid (use ha_hacs_search() to find it)",
784
+ "Ensure the repository is in HACS (use ha_hacs_add_repository() if needed)",
785
+ "Check version format (e.g., 'v1.2.3' or '1.2.3')",
786
+ ]
787
+ return await add_timezone_metadata(client, error_response)