hatch-xclam 0.7.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.
Files changed (93) hide show
  1. hatch/__init__.py +21 -0
  2. hatch/cli_hatch.py +2748 -0
  3. hatch/environment_manager.py +1375 -0
  4. hatch/installers/__init__.py +25 -0
  5. hatch/installers/dependency_installation_orchestrator.py +636 -0
  6. hatch/installers/docker_installer.py +545 -0
  7. hatch/installers/hatch_installer.py +198 -0
  8. hatch/installers/installation_context.py +109 -0
  9. hatch/installers/installer_base.py +195 -0
  10. hatch/installers/python_installer.py +342 -0
  11. hatch/installers/registry.py +179 -0
  12. hatch/installers/system_installer.py +588 -0
  13. hatch/mcp_host_config/__init__.py +38 -0
  14. hatch/mcp_host_config/backup.py +458 -0
  15. hatch/mcp_host_config/host_management.py +572 -0
  16. hatch/mcp_host_config/models.py +602 -0
  17. hatch/mcp_host_config/reporting.py +181 -0
  18. hatch/mcp_host_config/strategies.py +513 -0
  19. hatch/package_loader.py +263 -0
  20. hatch/python_environment_manager.py +734 -0
  21. hatch/registry_explorer.py +171 -0
  22. hatch/registry_retriever.py +335 -0
  23. hatch/template_generator.py +179 -0
  24. hatch_xclam-0.7.0.dist-info/METADATA +150 -0
  25. hatch_xclam-0.7.0.dist-info/RECORD +93 -0
  26. hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
  27. hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
  28. hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
  29. hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
  30. tests/__init__.py +1 -0
  31. tests/run_environment_tests.py +124 -0
  32. tests/test_cli_version.py +122 -0
  33. tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
  34. tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
  35. tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
  36. tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
  37. tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
  38. tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
  39. tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
  40. tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
  41. tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
  42. tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
  43. tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
  44. tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
  45. tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
  46. tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
  47. tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
  48. tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
  49. tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
  50. tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
  51. tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
  52. tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
  53. tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
  54. tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
  55. tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
  56. tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
  57. tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
  58. tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
  59. tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
  60. tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
  61. tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
  62. tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
  63. tests/test_data_utils.py +472 -0
  64. tests/test_dependency_orchestrator_consent.py +266 -0
  65. tests/test_docker_installer.py +524 -0
  66. tests/test_env_manip.py +991 -0
  67. tests/test_hatch_installer.py +179 -0
  68. tests/test_installer_base.py +221 -0
  69. tests/test_mcp_atomic_operations.py +276 -0
  70. tests/test_mcp_backup_integration.py +308 -0
  71. tests/test_mcp_cli_all_host_specific_args.py +303 -0
  72. tests/test_mcp_cli_backup_management.py +295 -0
  73. tests/test_mcp_cli_direct_management.py +453 -0
  74. tests/test_mcp_cli_discovery_listing.py +582 -0
  75. tests/test_mcp_cli_host_config_integration.py +823 -0
  76. tests/test_mcp_cli_package_management.py +360 -0
  77. tests/test_mcp_cli_partial_updates.py +859 -0
  78. tests/test_mcp_environment_integration.py +520 -0
  79. tests/test_mcp_host_config_backup.py +257 -0
  80. tests/test_mcp_host_configuration_manager.py +331 -0
  81. tests/test_mcp_host_registry_decorator.py +348 -0
  82. tests/test_mcp_pydantic_architecture_v4.py +603 -0
  83. tests/test_mcp_server_config_models.py +242 -0
  84. tests/test_mcp_server_config_type_field.py +221 -0
  85. tests/test_mcp_sync_functionality.py +316 -0
  86. tests/test_mcp_user_feedback_reporting.py +359 -0
  87. tests/test_non_tty_integration.py +281 -0
  88. tests/test_online_package_loader.py +202 -0
  89. tests/test_python_environment_manager.py +882 -0
  90. tests/test_python_installer.py +327 -0
  91. tests/test_registry.py +51 -0
  92. tests/test_registry_retriever.py +250 -0
  93. tests/test_system_installer.py +733 -0
hatch/cli_hatch.py ADDED
@@ -0,0 +1,2748 @@
1
+ """Command-line interface for the Hatch package manager.
2
+
3
+ This module provides the CLI functionality for Hatch, allowing users to:
4
+ - Create new package templates
5
+ - Validate packages
6
+ - Manage environments
7
+ - Manage packages within environments
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import logging
13
+ import shlex
14
+ import sys
15
+ from importlib.metadata import PackageNotFoundError, version
16
+ from pathlib import Path
17
+ from typing import List, Optional
18
+
19
+ from hatch_validator import HatchPackageValidator
20
+ from hatch_validator.package.package_service import PackageService
21
+
22
+ from hatch.environment_manager import HatchEnvironmentManager
23
+ from hatch.mcp_host_config import (
24
+ MCPHostConfigurationManager,
25
+ MCPHostRegistry,
26
+ MCPHostType,
27
+ MCPServerConfig,
28
+ )
29
+ from hatch.mcp_host_config.models import HOST_MODEL_REGISTRY, MCPServerConfigOmni
30
+ from hatch.mcp_host_config.reporting import display_report, generate_conversion_report
31
+ from hatch.template_generator import create_package_template
32
+
33
+
34
+ def get_hatch_version() -> str:
35
+ """Get Hatch version from package metadata.
36
+
37
+ Returns:
38
+ str: Version string from package metadata, or 'unknown (development mode)'
39
+ if package is not installed.
40
+ """
41
+ try:
42
+ return version("hatch")
43
+ except PackageNotFoundError:
44
+ return "unknown (development mode)"
45
+
46
+
47
+ def parse_host_list(host_arg: str):
48
+ """Parse comma-separated host list or 'all'."""
49
+ if not host_arg:
50
+ return []
51
+
52
+ if host_arg.lower() == "all":
53
+ return MCPHostRegistry.detect_available_hosts()
54
+
55
+ hosts = []
56
+ for host_str in host_arg.split(","):
57
+ host_str = host_str.strip()
58
+ try:
59
+ host_type = MCPHostType(host_str)
60
+ hosts.append(host_type)
61
+ except ValueError:
62
+ available = [h.value for h in MCPHostType]
63
+ raise ValueError(f"Unknown host '{host_str}'. Available: {available}")
64
+
65
+ return hosts
66
+
67
+
68
+ def request_confirmation(message: str, auto_approve: bool = False) -> bool:
69
+ """Request user confirmation with non-TTY support following Hatch patterns."""
70
+ import os
71
+ import sys
72
+
73
+ # Check for auto-approve first
74
+ if auto_approve or os.getenv("HATCH_AUTO_APPROVE", "").lower() in (
75
+ "1",
76
+ "true",
77
+ "yes",
78
+ ):
79
+ return True
80
+
81
+ # Interactive mode - request user input (works in both TTY and test environments)
82
+ try:
83
+ while True:
84
+ response = input(f"{message} [y/N]: ").strip().lower()
85
+ if response in ["y", "yes"]:
86
+ return True
87
+ elif response in ["n", "no", ""]:
88
+ return False
89
+ else:
90
+ print("Please enter 'y' for yes or 'n' for no.")
91
+ except (EOFError, KeyboardInterrupt):
92
+ # Only auto-approve on EOF/interrupt if not in TTY (non-interactive environment)
93
+ if not sys.stdin.isatty():
94
+ return True
95
+ return False
96
+
97
+
98
+ def get_package_mcp_server_config(
99
+ env_manager: HatchEnvironmentManager, env_name: str, package_name: str
100
+ ) -> MCPServerConfig:
101
+ """Get MCP server configuration for a package using existing APIs."""
102
+ try:
103
+ # Get package info from environment
104
+ packages = env_manager.list_packages(env_name)
105
+ package_info = next(
106
+ (pkg for pkg in packages if pkg["name"] == package_name), None
107
+ )
108
+
109
+ if not package_info:
110
+ raise ValueError(
111
+ f"Package '{package_name}' not found in environment '{env_name}'"
112
+ )
113
+
114
+ # Load package metadata using existing pattern from environment_manager.py:716-727
115
+ package_path = Path(package_info["source"]["path"])
116
+ metadata_path = package_path / "hatch_metadata.json"
117
+
118
+ if not metadata_path.exists():
119
+ raise ValueError(
120
+ f"Package '{package_name}' is not a Hatch package (no hatch_metadata.json)"
121
+ )
122
+
123
+ with open(metadata_path, "r") as f:
124
+ metadata = json.load(f)
125
+
126
+ # Use PackageService for schema-aware access
127
+ from hatch_validator.package.package_service import PackageService
128
+
129
+ package_service = PackageService(metadata)
130
+
131
+ # Get the HatchMCP entry point (this handles both v1.2.0 and v1.2.1 schemas)
132
+ mcp_entry_point = package_service.get_mcp_entry_point()
133
+ if not mcp_entry_point:
134
+ raise ValueError(
135
+ f"Package '{package_name}' does not have a HatchMCP entry point"
136
+ )
137
+
138
+ # Get environment-specific Python executable
139
+ python_executable = env_manager.get_current_python_executable()
140
+ if not python_executable:
141
+ # Fallback to system Python if no environment-specific Python available
142
+ python_executable = "python"
143
+
144
+ # Create server configuration
145
+ server_path = str(package_path / mcp_entry_point)
146
+ server_config = MCPServerConfig(
147
+ name=package_name, command=python_executable, args=[server_path], env={}
148
+ )
149
+
150
+ return server_config
151
+
152
+ except Exception as e:
153
+ raise ValueError(
154
+ f"Failed to get MCP server config for package '{package_name}': {e}"
155
+ )
156
+
157
+
158
+ def handle_mcp_discover_hosts():
159
+ """Handle 'hatch mcp discover hosts' command."""
160
+ try:
161
+ # Import strategies to trigger registration
162
+ import hatch.mcp_host_config.strategies
163
+
164
+ available_hosts = MCPHostRegistry.detect_available_hosts()
165
+ print("Available MCP host platforms:")
166
+
167
+ for host_type in MCPHostType:
168
+ try:
169
+ strategy = MCPHostRegistry.get_strategy(host_type)
170
+ config_path = strategy.get_config_path()
171
+ is_available = host_type in available_hosts
172
+
173
+ status = "✓ Available" if is_available else "✗ Not detected"
174
+ print(f" {host_type.value}: {status}")
175
+ if config_path:
176
+ print(f" Config path: {config_path}")
177
+ except Exception as e:
178
+ print(f" {host_type.value}: Error - {e}")
179
+
180
+ return 0
181
+ except Exception as e:
182
+ print(f"Error discovering hosts: {e}")
183
+ return 1
184
+
185
+
186
+ def handle_mcp_discover_servers(
187
+ env_manager: HatchEnvironmentManager, env_name: Optional[str] = None
188
+ ):
189
+ """Handle 'hatch mcp discover servers' command."""
190
+ try:
191
+ env_name = env_name or env_manager.get_current_environment()
192
+
193
+ if not env_manager.environment_exists(env_name):
194
+ print(f"Error: Environment '{env_name}' does not exist")
195
+ return 1
196
+
197
+ packages = env_manager.list_packages(env_name)
198
+ mcp_packages = []
199
+
200
+ for package in packages:
201
+ try:
202
+ # Check if package has MCP server entry point
203
+ server_config = get_package_mcp_server_config(
204
+ env_manager, env_name, package["name"]
205
+ )
206
+ mcp_packages.append(
207
+ {"package": package, "server_config": server_config}
208
+ )
209
+ except ValueError:
210
+ # Package doesn't have MCP server
211
+ continue
212
+
213
+ if not mcp_packages:
214
+ print(f"No MCP servers found in environment '{env_name}'")
215
+ return 0
216
+
217
+ print(f"MCP servers in environment '{env_name}':")
218
+ for item in mcp_packages:
219
+ package = item["package"]
220
+ server_config = item["server_config"]
221
+ print(f" {server_config.name}:")
222
+ print(
223
+ f" Package: {package['name']} v{package.get('version', 'unknown')}"
224
+ )
225
+ print(f" Command: {server_config.command}")
226
+ print(f" Args: {server_config.args}")
227
+ if server_config.env:
228
+ print(f" Environment: {server_config.env}")
229
+
230
+ return 0
231
+ except Exception as e:
232
+ print(f"Error discovering servers: {e}")
233
+ return 1
234
+
235
+
236
+ def handle_mcp_list_hosts(
237
+ env_manager: HatchEnvironmentManager,
238
+ env_name: Optional[str] = None,
239
+ detailed: bool = False,
240
+ ):
241
+ """Handle 'hatch mcp list hosts' command - shows configured hosts in environment."""
242
+ try:
243
+ from collections import defaultdict
244
+
245
+ # Resolve environment name
246
+ target_env = env_name or env_manager.get_current_environment()
247
+
248
+ # Validate environment exists
249
+ if not env_manager.environment_exists(target_env):
250
+ available_envs = env_manager.list_environments()
251
+ print(f"Error: Environment '{target_env}' does not exist.")
252
+ if available_envs:
253
+ print(f"Available environments: {', '.join(available_envs)}")
254
+ return 1
255
+
256
+ # Collect hosts from configured_hosts across all packages in environment
257
+ hosts = defaultdict(int)
258
+ host_details = defaultdict(list)
259
+
260
+ try:
261
+ env_data = env_manager.get_environment_data(target_env)
262
+ packages = env_data.get("packages", [])
263
+
264
+ for package in packages:
265
+ package_name = package.get("name", "unknown")
266
+ configured_hosts = package.get("configured_hosts", {})
267
+
268
+ for host_name, host_config in configured_hosts.items():
269
+ hosts[host_name] += 1
270
+ if detailed:
271
+ config_path = host_config.get("config_path", "N/A")
272
+ configured_at = host_config.get("configured_at", "N/A")
273
+ host_details[host_name].append(
274
+ {
275
+ "package": package_name,
276
+ "config_path": config_path,
277
+ "configured_at": configured_at,
278
+ }
279
+ )
280
+
281
+ except Exception as e:
282
+ print(f"Error reading environment data: {e}")
283
+ return 1
284
+
285
+ # Display results
286
+ if not hosts:
287
+ print(f"No configured hosts for environment '{target_env}'")
288
+ return 0
289
+
290
+ print(f"Configured hosts for environment '{target_env}':")
291
+
292
+ for host_name, package_count in sorted(hosts.items()):
293
+ if detailed:
294
+ print(f"\n{host_name} ({package_count} packages):")
295
+ for detail in host_details[host_name]:
296
+ print(f" - Package: {detail['package']}")
297
+ print(f" Config path: {detail['config_path']}")
298
+ print(f" Configured at: {detail['configured_at']}")
299
+ else:
300
+ print(f" - {host_name} ({package_count} packages)")
301
+
302
+ return 0
303
+ except Exception as e:
304
+ print(f"Error listing hosts: {e}")
305
+ return 1
306
+
307
+
308
+ def handle_mcp_list_servers(
309
+ env_manager: HatchEnvironmentManager, env_name: Optional[str] = None
310
+ ):
311
+ """Handle 'hatch mcp list servers' command."""
312
+ try:
313
+ env_name = env_name or env_manager.get_current_environment()
314
+
315
+ if not env_manager.environment_exists(env_name):
316
+ print(f"Error: Environment '{env_name}' does not exist")
317
+ return 1
318
+
319
+ packages = env_manager.list_packages(env_name)
320
+ mcp_packages = []
321
+
322
+ for package in packages:
323
+ # Check if package has host configuration tracking (indicating MCP server)
324
+ configured_hosts = package.get("configured_hosts", {})
325
+ if configured_hosts:
326
+ # Use the tracked server configuration from any host
327
+ first_host = next(iter(configured_hosts.values()))
328
+ server_config_data = first_host.get("server_config", {})
329
+
330
+ # Create a simple server config object
331
+ class SimpleServerConfig:
332
+ def __init__(self, data):
333
+ self.name = data.get("name", package["name"])
334
+ self.command = data.get("command", "unknown")
335
+ self.args = data.get("args", [])
336
+
337
+ server_config = SimpleServerConfig(server_config_data)
338
+ mcp_packages.append(
339
+ {"package": package, "server_config": server_config}
340
+ )
341
+ else:
342
+ # Try the original method as fallback
343
+ try:
344
+ server_config = get_package_mcp_server_config(
345
+ env_manager, env_name, package["name"]
346
+ )
347
+ mcp_packages.append(
348
+ {"package": package, "server_config": server_config}
349
+ )
350
+ except:
351
+ # Package doesn't have MCP server or method failed
352
+ continue
353
+
354
+ if not mcp_packages:
355
+ print(f"No MCP servers configured in environment '{env_name}'")
356
+ return 0
357
+
358
+ print(f"MCP servers in environment '{env_name}':")
359
+ print(f"{'Server Name':<20} {'Package':<20} {'Version':<10} {'Command'}")
360
+ print("-" * 80)
361
+
362
+ for item in mcp_packages:
363
+ package = item["package"]
364
+ server_config = item["server_config"]
365
+
366
+ server_name = server_config.name
367
+ package_name = package["name"]
368
+ version = package.get("version", "unknown")
369
+ command = f"{server_config.command} {' '.join(server_config.args)}"
370
+
371
+ print(f"{server_name:<20} {package_name:<20} {version:<10} {command}")
372
+
373
+ # Display host configuration tracking information
374
+ configured_hosts = package.get("configured_hosts", {})
375
+ if configured_hosts:
376
+ print(f"{'':>20} Configured on hosts:")
377
+ for hostname, host_config in configured_hosts.items():
378
+ config_path = host_config.get("config_path", "unknown")
379
+ last_synced = host_config.get("last_synced", "unknown")
380
+ # Format the timestamp for better readability
381
+ if last_synced != "unknown":
382
+ try:
383
+ from datetime import datetime
384
+
385
+ dt = datetime.fromisoformat(
386
+ last_synced.replace("Z", "+00:00")
387
+ )
388
+ last_synced = dt.strftime("%Y-%m-%d %H:%M:%S")
389
+ except:
390
+ pass # Keep original format if parsing fails
391
+ print(
392
+ f"{'':>22} - {hostname}: {config_path} (synced: {last_synced})"
393
+ )
394
+ else:
395
+ print(f"{'':>20} No host configurations tracked")
396
+ print() # Add blank line between servers
397
+
398
+ return 0
399
+ except Exception as e:
400
+ print(f"Error listing servers: {e}")
401
+ return 1
402
+
403
+
404
+ def handle_mcp_backup_restore(
405
+ env_manager: HatchEnvironmentManager,
406
+ host: str,
407
+ backup_file: Optional[str] = None,
408
+ dry_run: bool = False,
409
+ auto_approve: bool = False,
410
+ ):
411
+ """Handle 'hatch mcp backup restore' command."""
412
+ try:
413
+ from hatch.mcp_host_config.backup import MCPHostConfigBackupManager
414
+
415
+ # Validate host type
416
+ try:
417
+ host_type = MCPHostType(host)
418
+ except ValueError:
419
+ print(
420
+ f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}"
421
+ )
422
+ return 1
423
+
424
+ backup_manager = MCPHostConfigBackupManager()
425
+
426
+ # Get backup file path
427
+ if backup_file:
428
+ backup_path = backup_manager.backup_root / host / backup_file
429
+ if not backup_path.exists():
430
+ print(f"Error: Backup file '{backup_file}' not found for host '{host}'")
431
+ return 1
432
+ else:
433
+ backup_path = backup_manager._get_latest_backup(host)
434
+ if not backup_path:
435
+ print(f"Error: No backups found for host '{host}'")
436
+ return 1
437
+ backup_file = backup_path.name
438
+
439
+ if dry_run:
440
+ print(f"[DRY RUN] Would restore backup for host '{host}':")
441
+ print(f"[DRY RUN] Backup file: {backup_file}")
442
+ print(f"[DRY RUN] Backup path: {backup_path}")
443
+ return 0
444
+
445
+ # Confirm operation unless auto-approved
446
+ if not request_confirmation(
447
+ f"Restore backup '{backup_file}' for host '{host}'? This will overwrite current configuration.",
448
+ auto_approve,
449
+ ):
450
+ print("Operation cancelled.")
451
+ return 0
452
+
453
+ # Perform restoration
454
+ success = backup_manager.restore_backup(host, backup_file)
455
+
456
+ if success:
457
+ print(
458
+ f"[SUCCESS] Successfully restored backup '{backup_file}' for host '{host}'"
459
+ )
460
+
461
+ # Read restored configuration to get actual server list
462
+ try:
463
+ # Import strategies to trigger registration
464
+ import hatch.mcp_host_config.strategies
465
+
466
+ host_type = MCPHostType(host)
467
+ strategy = MCPHostRegistry.get_strategy(host_type)
468
+ restored_config = strategy.read_configuration()
469
+
470
+ # Update environment tracking to match restored state
471
+ updates_count = (
472
+ env_manager.apply_restored_host_configuration_to_environments(
473
+ host, restored_config.servers
474
+ )
475
+ )
476
+ if updates_count > 0:
477
+ print(
478
+ f"Synchronized {updates_count} package entries with restored configuration"
479
+ )
480
+
481
+ except Exception as e:
482
+ print(f"Warning: Could not synchronize environment tracking: {e}")
483
+
484
+ return 0
485
+ else:
486
+ print(f"[ERROR] Failed to restore backup '{backup_file}' for host '{host}'")
487
+ return 1
488
+
489
+ except Exception as e:
490
+ print(f"Error restoring backup: {e}")
491
+ return 1
492
+
493
+
494
+ def handle_mcp_backup_list(host: str, detailed: bool = False):
495
+ """Handle 'hatch mcp backup list' command."""
496
+ try:
497
+ from hatch.mcp_host_config.backup import MCPHostConfigBackupManager
498
+
499
+ # Validate host type
500
+ try:
501
+ host_type = MCPHostType(host)
502
+ except ValueError:
503
+ print(
504
+ f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}"
505
+ )
506
+ return 1
507
+
508
+ backup_manager = MCPHostConfigBackupManager()
509
+ backups = backup_manager.list_backups(host)
510
+
511
+ if not backups:
512
+ print(f"No backups found for host '{host}'")
513
+ return 0
514
+
515
+ print(f"Backups for host '{host}' ({len(backups)} found):")
516
+
517
+ if detailed:
518
+ print(f"{'Backup File':<40} {'Created':<20} {'Size':<10} {'Age (days)'}")
519
+ print("-" * 80)
520
+
521
+ for backup in backups:
522
+ created = backup.timestamp.strftime("%Y-%m-%d %H:%M:%S")
523
+ size = f"{backup.file_size:,} B"
524
+ age = backup.age_days
525
+
526
+ print(f"{backup.file_path.name:<40} {created:<20} {size:<10} {age}")
527
+ else:
528
+ for backup in backups:
529
+ created = backup.timestamp.strftime("%Y-%m-%d %H:%M:%S")
530
+ print(
531
+ f" {backup.file_path.name} (created: {created}, {backup.age_days} days ago)"
532
+ )
533
+
534
+ return 0
535
+ except Exception as e:
536
+ print(f"Error listing backups: {e}")
537
+ return 1
538
+
539
+
540
+ def handle_mcp_backup_clean(
541
+ host: str,
542
+ older_than_days: Optional[int] = None,
543
+ keep_count: Optional[int] = None,
544
+ dry_run: bool = False,
545
+ auto_approve: bool = False,
546
+ ):
547
+ """Handle 'hatch mcp backup clean' command."""
548
+ try:
549
+ from hatch.mcp_host_config.backup import MCPHostConfigBackupManager
550
+
551
+ # Validate host type
552
+ try:
553
+ host_type = MCPHostType(host)
554
+ except ValueError:
555
+ print(
556
+ f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}"
557
+ )
558
+ return 1
559
+
560
+ # Validate cleanup criteria
561
+ if not older_than_days and not keep_count:
562
+ print("Error: Must specify either --older-than-days or --keep-count")
563
+ return 1
564
+
565
+ backup_manager = MCPHostConfigBackupManager()
566
+ backups = backup_manager.list_backups(host)
567
+
568
+ if not backups:
569
+ print(f"No backups found for host '{host}'")
570
+ return 0
571
+
572
+ # Determine which backups would be cleaned
573
+ to_clean = []
574
+
575
+ if older_than_days:
576
+ for backup in backups:
577
+ if backup.age_days > older_than_days:
578
+ to_clean.append(backup)
579
+
580
+ if keep_count and len(backups) > keep_count:
581
+ # Keep newest backups, remove oldest
582
+ to_clean.extend(backups[keep_count:])
583
+
584
+ # Remove duplicates while preserving order
585
+ seen = set()
586
+ unique_to_clean = []
587
+ for backup in to_clean:
588
+ if backup.file_path not in seen:
589
+ seen.add(backup.file_path)
590
+ unique_to_clean.append(backup)
591
+
592
+ if not unique_to_clean:
593
+ print(f"No backups match cleanup criteria for host '{host}'")
594
+ return 0
595
+
596
+ if dry_run:
597
+ print(
598
+ f"[DRY RUN] Would clean {len(unique_to_clean)} backup(s) for host '{host}':"
599
+ )
600
+ for backup in unique_to_clean:
601
+ print(
602
+ f"[DRY RUN] {backup.file_path.name} (age: {backup.age_days} days)"
603
+ )
604
+ return 0
605
+
606
+ # Confirm operation unless auto-approved
607
+ if not request_confirmation(
608
+ f"Clean {len(unique_to_clean)} backup(s) for host '{host}'?", auto_approve
609
+ ):
610
+ print("Operation cancelled.")
611
+ return 0
612
+
613
+ # Perform cleanup
614
+ filters = {}
615
+ if older_than_days:
616
+ filters["older_than_days"] = older_than_days
617
+ if keep_count:
618
+ filters["keep_count"] = keep_count
619
+
620
+ cleaned_count = backup_manager.clean_backups(host, **filters)
621
+
622
+ if cleaned_count > 0:
623
+ print(f"✓ Successfully cleaned {cleaned_count} backup(s) for host '{host}'")
624
+ return 0
625
+ else:
626
+ print(f"No backups were cleaned for host '{host}'")
627
+ return 0
628
+
629
+ except Exception as e:
630
+ print(f"Error cleaning backups: {e}")
631
+ return 1
632
+
633
+
634
+ def parse_env_vars(env_list: Optional[list]) -> dict:
635
+ """Parse environment variables from command line format."""
636
+ if not env_list:
637
+ return {}
638
+
639
+ env_dict = {}
640
+ for env_var in env_list:
641
+ if "=" not in env_var:
642
+ print(
643
+ f"Warning: Invalid environment variable format '{env_var}'. Expected KEY=VALUE"
644
+ )
645
+ continue
646
+ key, value = env_var.split("=", 1)
647
+ env_dict[key.strip()] = value.strip()
648
+
649
+ return env_dict
650
+
651
+
652
+ def parse_header(header_list: Optional[list]) -> dict:
653
+ """Parse HTTP headers from command line format."""
654
+ if not header_list:
655
+ return {}
656
+
657
+ headers_dict = {}
658
+ for header in header_list:
659
+ if "=" not in header:
660
+ print(f"Warning: Invalid header format '{header}'. Expected KEY=VALUE")
661
+ continue
662
+ key, value = header.split("=", 1)
663
+ headers_dict[key.strip()] = value.strip()
664
+
665
+ return headers_dict
666
+
667
+
668
+ def parse_input(input_list: Optional[list]) -> Optional[list]:
669
+ """Parse VS Code input variable definitions from command line format.
670
+
671
+ Format: type,id,description[,password=true]
672
+ Example: promptString,api-key,GitHub Personal Access Token,password=true
673
+
674
+ Returns:
675
+ List of input variable definition dictionaries, or None if no inputs provided.
676
+ """
677
+ if not input_list:
678
+ return None
679
+
680
+ parsed_inputs = []
681
+ for input_str in input_list:
682
+ parts = [p.strip() for p in input_str.split(",")]
683
+ if len(parts) < 3:
684
+ print(
685
+ f"Warning: Invalid input format '{input_str}'. Expected: type,id,description[,password=true]"
686
+ )
687
+ continue
688
+
689
+ input_def = {"type": parts[0], "id": parts[1], "description": parts[2]}
690
+
691
+ # Check for optional password flag
692
+ if len(parts) > 3 and parts[3].lower() == "password=true":
693
+ input_def["password"] = True
694
+
695
+ parsed_inputs.append(input_def)
696
+
697
+ return parsed_inputs if parsed_inputs else None
698
+
699
+
700
+ def handle_mcp_configure(
701
+ host: str,
702
+ server_name: str,
703
+ command: str,
704
+ args: list,
705
+ env: Optional[list] = None,
706
+ url: Optional[str] = None,
707
+ header: Optional[list] = None,
708
+ timeout: Optional[int] = None,
709
+ trust: bool = False,
710
+ cwd: Optional[str] = None,
711
+ env_file: Optional[str] = None,
712
+ http_url: Optional[str] = None,
713
+ include_tools: Optional[list] = None,
714
+ exclude_tools: Optional[list] = None,
715
+ input: Optional[list] = None,
716
+ no_backup: bool = False,
717
+ dry_run: bool = False,
718
+ auto_approve: bool = False,
719
+ ):
720
+ """Handle 'hatch mcp configure' command with ALL host-specific arguments.
721
+
722
+ Host-specific arguments are accepted for all hosts. The reporting system will
723
+ show unsupported fields as "UNSUPPORTED" in the conversion report rather than
724
+ rejecting them upfront.
725
+ """
726
+ try:
727
+ # Validate host type
728
+ try:
729
+ host_type = MCPHostType(host)
730
+ except ValueError:
731
+ print(
732
+ f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}"
733
+ )
734
+ return 1
735
+
736
+ # Validate Claude Desktop/Code transport restrictions (Issue 2)
737
+ if host_type in (MCPHostType.CLAUDE_DESKTOP, MCPHostType.CLAUDE_CODE):
738
+ if url is not None:
739
+ print(
740
+ f"Error: {host} does not support remote servers (--url). Only local servers with --command are supported."
741
+ )
742
+ return 1
743
+
744
+ # Validate argument dependencies
745
+ if command and header:
746
+ print(
747
+ "Error: --header can only be used with --url or --http-url (remote servers), not with --command (local servers)"
748
+ )
749
+ return 1
750
+
751
+ if (url or http_url) and args:
752
+ print(
753
+ "Error: --args can only be used with --command (local servers), not with --url or --http-url (remote servers)"
754
+ )
755
+ return 1
756
+
757
+ # NOTE: We do NOT validate host-specific arguments here.
758
+ # The reporting system will show unsupported fields as "UNSUPPORTED" in the conversion report.
759
+ # This allows users to see which fields are not supported by their target host without blocking the operation.
760
+
761
+ # Check if server exists (for partial update support)
762
+ manager = MCPHostConfigurationManager()
763
+ existing_config = manager.get_server_config(host, server_name)
764
+ is_update = existing_config is not None
765
+
766
+ # Conditional validation: Create requires command OR url OR http_url, update does not
767
+ if not is_update:
768
+ # Create operation: require command, url, or http_url
769
+ if not command and not url and not http_url:
770
+ print(
771
+ f"Error: When creating a new server, you must provide either --command (for local servers), --url (for SSE remote servers), or --http-url (for HTTP remote servers, Gemini only)"
772
+ )
773
+ return 1
774
+
775
+ # Parse environment variables, headers, and inputs
776
+ env_dict = parse_env_vars(env)
777
+ headers_dict = parse_header(header)
778
+ inputs_list = parse_input(input)
779
+
780
+ # Create Omni configuration (universal model)
781
+ # Only include fields that have actual values to ensure model_dump(exclude_unset=True) works correctly
782
+ omni_config_data = {"name": server_name}
783
+
784
+ if command is not None:
785
+ omni_config_data["command"] = command
786
+ if args is not None:
787
+ # Process args with shlex.split() to handle quoted strings (Issue 4)
788
+ processed_args = []
789
+ for arg in args:
790
+ if arg: # Skip empty strings
791
+ try:
792
+ # Split quoted strings into individual arguments
793
+ split_args = shlex.split(arg)
794
+ processed_args.extend(split_args)
795
+ except ValueError as e:
796
+ # Handle invalid quotes gracefully
797
+ print(f"Warning: Invalid quote in argument '{arg}': {e}")
798
+ processed_args.append(arg)
799
+ omni_config_data["args"] = processed_args if processed_args else None
800
+ if env_dict:
801
+ omni_config_data["env"] = env_dict
802
+ if url is not None:
803
+ omni_config_data["url"] = url
804
+ if headers_dict:
805
+ omni_config_data["headers"] = headers_dict
806
+
807
+ # Host-specific fields (Gemini)
808
+ if timeout is not None:
809
+ omni_config_data["timeout"] = timeout
810
+ if trust:
811
+ omni_config_data["trust"] = trust
812
+ if cwd is not None:
813
+ omni_config_data["cwd"] = cwd
814
+ if http_url is not None:
815
+ omni_config_data["httpUrl"] = http_url
816
+ if include_tools is not None:
817
+ omni_config_data["includeTools"] = include_tools
818
+ if exclude_tools is not None:
819
+ omni_config_data["excludeTools"] = exclude_tools
820
+
821
+ # Host-specific fields (Cursor/VS Code/LM Studio)
822
+ if env_file is not None:
823
+ omni_config_data["envFile"] = env_file
824
+
825
+ # Host-specific fields (VS Code)
826
+ if inputs_list is not None:
827
+ omni_config_data["inputs"] = inputs_list
828
+
829
+ # Partial update merge logic
830
+ if is_update:
831
+ # Merge with existing configuration
832
+ existing_data = existing_config.model_dump(
833
+ exclude_unset=True, exclude={"name"}
834
+ )
835
+
836
+ # Handle command/URL/httpUrl switching behavior
837
+ # If switching from command to URL or httpUrl: clear command-based fields
838
+ if (
839
+ url is not None or http_url is not None
840
+ ) and existing_config.command is not None:
841
+ existing_data.pop("command", None)
842
+ existing_data.pop("args", None)
843
+ existing_data.pop(
844
+ "type", None
845
+ ) # Clear type field when switching transports (Issue 1)
846
+
847
+ # If switching from URL/httpUrl to command: clear URL-based fields
848
+ if command is not None and (
849
+ existing_config.url is not None
850
+ or getattr(existing_config, "httpUrl", None) is not None
851
+ ):
852
+ existing_data.pop("url", None)
853
+ existing_data.pop("httpUrl", None)
854
+ existing_data.pop("headers", None)
855
+ existing_data.pop(
856
+ "type", None
857
+ ) # Clear type field when switching transports (Issue 1)
858
+
859
+ # Merge: new values override existing values
860
+ merged_data = {**existing_data, **omni_config_data}
861
+ omni_config_data = merged_data
862
+
863
+ # Create Omni model
864
+ omni_config = MCPServerConfigOmni(**omni_config_data)
865
+
866
+ # Convert to host-specific model using HOST_MODEL_REGISTRY
867
+ host_model_class = HOST_MODEL_REGISTRY.get(host_type)
868
+ if not host_model_class:
869
+ print(f"Error: No model registered for host '{host}'")
870
+ return 1
871
+
872
+ # Convert Omni to host-specific model
873
+ server_config = host_model_class.from_omni(omni_config)
874
+
875
+ # Generate conversion report
876
+ report = generate_conversion_report(
877
+ operation="update" if is_update else "create",
878
+ server_name=server_name,
879
+ target_host=host_type,
880
+ omni=omni_config,
881
+ old_config=existing_config if is_update else None,
882
+ dry_run=dry_run,
883
+ )
884
+
885
+ # Display conversion report
886
+ if dry_run:
887
+ print(
888
+ f"[DRY RUN] Would configure MCP server '{server_name}' on host '{host}':"
889
+ )
890
+ print(f"[DRY RUN] Command: {command}")
891
+ if args:
892
+ print(f"[DRY RUN] Args: {args}")
893
+ if env_dict:
894
+ print(f"[DRY RUN] Environment: {env_dict}")
895
+ if url:
896
+ print(f"[DRY RUN] URL: {url}")
897
+ if headers_dict:
898
+ print(f"[DRY RUN] Headers: {headers_dict}")
899
+ print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
900
+ # Display report in dry-run mode
901
+ display_report(report)
902
+ return 0
903
+
904
+ # Display report before confirmation
905
+ display_report(report)
906
+
907
+ # Confirm operation unless auto-approved
908
+ if not request_confirmation(
909
+ f"Configure MCP server '{server_name}' on host '{host}'?", auto_approve
910
+ ):
911
+ print("Operation cancelled.")
912
+ return 0
913
+
914
+ # Perform configuration
915
+ mcp_manager = MCPHostConfigurationManager()
916
+ result = mcp_manager.configure_server(
917
+ server_config=server_config, hostname=host, no_backup=no_backup
918
+ )
919
+
920
+ if result.success:
921
+ print(
922
+ f"[SUCCESS] Successfully configured MCP server '{server_name}' on host '{host}'"
923
+ )
924
+ if result.backup_path:
925
+ print(f" Backup created: {result.backup_path}")
926
+ return 0
927
+ else:
928
+ print(
929
+ f"[ERROR] Failed to configure MCP server '{server_name}' on host '{host}': {result.error_message}"
930
+ )
931
+ return 1
932
+
933
+ except Exception as e:
934
+ print(f"Error configuring MCP server: {e}")
935
+ return 1
936
+
937
+
938
+ def handle_mcp_remove(
939
+ host: str,
940
+ server_name: str,
941
+ no_backup: bool = False,
942
+ dry_run: bool = False,
943
+ auto_approve: bool = False,
944
+ ):
945
+ """Handle 'hatch mcp remove' command."""
946
+ try:
947
+ # Validate host type
948
+ try:
949
+ host_type = MCPHostType(host)
950
+ except ValueError:
951
+ print(
952
+ f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}"
953
+ )
954
+ return 1
955
+
956
+ if dry_run:
957
+ print(
958
+ f"[DRY RUN] Would remove MCP server '{server_name}' from host '{host}'"
959
+ )
960
+ print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
961
+ return 0
962
+
963
+ # Confirm operation unless auto-approved
964
+ if not request_confirmation(
965
+ f"Remove MCP server '{server_name}' from host '{host}'?", auto_approve
966
+ ):
967
+ print("Operation cancelled.")
968
+ return 0
969
+
970
+ # Perform removal
971
+ mcp_manager = MCPHostConfigurationManager()
972
+ result = mcp_manager.remove_server(
973
+ server_name=server_name, hostname=host, no_backup=no_backup
974
+ )
975
+
976
+ if result.success:
977
+ print(
978
+ f"[SUCCESS] Successfully removed MCP server '{server_name}' from host '{host}'"
979
+ )
980
+ if result.backup_path:
981
+ print(f" Backup created: {result.backup_path}")
982
+ return 0
983
+ else:
984
+ print(
985
+ f"[ERROR] Failed to remove MCP server '{server_name}' from host '{host}': {result.error_message}"
986
+ )
987
+ return 1
988
+
989
+ except Exception as e:
990
+ print(f"Error removing MCP server: {e}")
991
+ return 1
992
+
993
+
994
+ def parse_host_list(host_arg: str) -> List[str]:
995
+ """Parse comma-separated host list or 'all'."""
996
+ if not host_arg:
997
+ return []
998
+
999
+ if host_arg.lower() == "all":
1000
+ from hatch.mcp_host_config.host_management import MCPHostRegistry
1001
+
1002
+ available_hosts = MCPHostRegistry.detect_available_hosts()
1003
+ return [host.value for host in available_hosts]
1004
+
1005
+ hosts = []
1006
+ for host_str in host_arg.split(","):
1007
+ host_str = host_str.strip()
1008
+ try:
1009
+ host_type = MCPHostType(host_str)
1010
+ hosts.append(host_type.value)
1011
+ except ValueError:
1012
+ available = [h.value for h in MCPHostType]
1013
+ raise ValueError(f"Unknown host '{host_str}'. Available: {available}")
1014
+
1015
+ return hosts
1016
+
1017
+
1018
+ def handle_mcp_remove_server(
1019
+ env_manager: HatchEnvironmentManager,
1020
+ server_name: str,
1021
+ hosts: Optional[str] = None,
1022
+ env: Optional[str] = None,
1023
+ no_backup: bool = False,
1024
+ dry_run: bool = False,
1025
+ auto_approve: bool = False,
1026
+ ):
1027
+ """Handle 'hatch mcp remove server' command."""
1028
+ try:
1029
+ # Determine target hosts
1030
+ if hosts:
1031
+ target_hosts = parse_host_list(hosts)
1032
+ elif env:
1033
+ # TODO: Implement environment-based server removal
1034
+ print("Error: Environment-based removal not yet implemented")
1035
+ return 1
1036
+ else:
1037
+ print("Error: Must specify either --host or --env")
1038
+ return 1
1039
+
1040
+ if not target_hosts:
1041
+ print("Error: No valid hosts specified")
1042
+ return 1
1043
+
1044
+ if dry_run:
1045
+ print(
1046
+ f"[DRY RUN] Would remove MCP server '{server_name}' from hosts: {', '.join(target_hosts)}"
1047
+ )
1048
+ print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
1049
+ return 0
1050
+
1051
+ # Confirm operation unless auto-approved
1052
+ hosts_str = ", ".join(target_hosts)
1053
+ if not request_confirmation(
1054
+ f"Remove MCP server '{server_name}' from hosts: {hosts_str}?", auto_approve
1055
+ ):
1056
+ print("Operation cancelled.")
1057
+ return 0
1058
+
1059
+ # Perform removal on each host
1060
+ mcp_manager = MCPHostConfigurationManager()
1061
+ success_count = 0
1062
+ total_count = len(target_hosts)
1063
+
1064
+ for host in target_hosts:
1065
+ result = mcp_manager.remove_server(
1066
+ server_name=server_name, hostname=host, no_backup=no_backup
1067
+ )
1068
+
1069
+ if result.success:
1070
+ print(f"[SUCCESS] Successfully removed '{server_name}' from '{host}'")
1071
+ if result.backup_path:
1072
+ print(f" Backup created: {result.backup_path}")
1073
+ success_count += 1
1074
+
1075
+ # Update environment tracking for current environment only
1076
+ current_env = env_manager.get_current_environment()
1077
+ if current_env:
1078
+ env_manager.remove_package_host_configuration(
1079
+ current_env, server_name, host
1080
+ )
1081
+ else:
1082
+ print(
1083
+ f"[ERROR] Failed to remove '{server_name}' from '{host}': {result.error_message}"
1084
+ )
1085
+
1086
+ # Summary
1087
+ if success_count == total_count:
1088
+ print(f"[SUCCESS] Removed '{server_name}' from all {total_count} hosts")
1089
+ return 0
1090
+ elif success_count > 0:
1091
+ print(
1092
+ f"[PARTIAL SUCCESS] Removed '{server_name}' from {success_count}/{total_count} hosts"
1093
+ )
1094
+ return 1
1095
+ else:
1096
+ print(f"[ERROR] Failed to remove '{server_name}' from any hosts")
1097
+ return 1
1098
+
1099
+ except Exception as e:
1100
+ print(f"Error removing MCP server: {e}")
1101
+ return 1
1102
+
1103
+
1104
+ def handle_mcp_remove_host(
1105
+ env_manager: HatchEnvironmentManager,
1106
+ host_name: str,
1107
+ no_backup: bool = False,
1108
+ dry_run: bool = False,
1109
+ auto_approve: bool = False,
1110
+ ):
1111
+ """Handle 'hatch mcp remove host' command."""
1112
+ try:
1113
+ # Validate host type
1114
+ try:
1115
+ host_type = MCPHostType(host_name)
1116
+ except ValueError:
1117
+ print(
1118
+ f"Error: Invalid host '{host_name}'. Supported hosts: {[h.value for h in MCPHostType]}"
1119
+ )
1120
+ return 1
1121
+
1122
+ if dry_run:
1123
+ print(f"[DRY RUN] Would remove entire host configuration for '{host_name}'")
1124
+ print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
1125
+ return 0
1126
+
1127
+ # Confirm operation unless auto-approved
1128
+ if not request_confirmation(
1129
+ f"Remove entire host configuration for '{host_name}'? This will remove ALL MCP servers from this host.",
1130
+ auto_approve,
1131
+ ):
1132
+ print("Operation cancelled.")
1133
+ return 0
1134
+
1135
+ # Perform host configuration removal
1136
+ mcp_manager = MCPHostConfigurationManager()
1137
+ result = mcp_manager.remove_host_configuration(
1138
+ hostname=host_name, no_backup=no_backup
1139
+ )
1140
+
1141
+ if result.success:
1142
+ print(
1143
+ f"[SUCCESS] Successfully removed host configuration for '{host_name}'"
1144
+ )
1145
+ if result.backup_path:
1146
+ print(f" Backup created: {result.backup_path}")
1147
+
1148
+ # Update environment tracking across all environments
1149
+ updates_count = env_manager.clear_host_from_all_packages_all_envs(host_name)
1150
+ if updates_count > 0:
1151
+ print(f"Updated {updates_count} package entries across environments")
1152
+
1153
+ return 0
1154
+ else:
1155
+ print(
1156
+ f"[ERROR] Failed to remove host configuration for '{host_name}': {result.error_message}"
1157
+ )
1158
+ return 1
1159
+
1160
+ except Exception as e:
1161
+ print(f"Error removing host configuration: {e}")
1162
+ return 1
1163
+
1164
+
1165
+ def handle_mcp_sync(
1166
+ from_env: Optional[str] = None,
1167
+ from_host: Optional[str] = None,
1168
+ to_hosts: Optional[str] = None,
1169
+ servers: Optional[str] = None,
1170
+ pattern: Optional[str] = None,
1171
+ dry_run: bool = False,
1172
+ auto_approve: bool = False,
1173
+ no_backup: bool = False,
1174
+ ) -> int:
1175
+ """Handle 'hatch mcp sync' command."""
1176
+ try:
1177
+ # Parse target hosts
1178
+ if not to_hosts:
1179
+ print("Error: Must specify --to-host")
1180
+ return 1
1181
+
1182
+ target_hosts = parse_host_list(to_hosts)
1183
+
1184
+ # Parse server filters
1185
+ server_list = None
1186
+ if servers:
1187
+ server_list = [s.strip() for s in servers.split(",") if s.strip()]
1188
+
1189
+ if dry_run:
1190
+ source_desc = (
1191
+ f"environment '{from_env}'" if from_env else f"host '{from_host}'"
1192
+ )
1193
+ target_desc = f"hosts: {', '.join(target_hosts)}"
1194
+ print(f"[DRY RUN] Would synchronize from {source_desc} to {target_desc}")
1195
+
1196
+ if server_list:
1197
+ print(f"[DRY RUN] Server filter: {', '.join(server_list)}")
1198
+ elif pattern:
1199
+ print(f"[DRY RUN] Pattern filter: {pattern}")
1200
+
1201
+ print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
1202
+ return 0
1203
+
1204
+ # Confirm operation unless auto-approved
1205
+ source_desc = f"environment '{from_env}'" if from_env else f"host '{from_host}'"
1206
+ target_desc = f"{len(target_hosts)} host(s)"
1207
+ if not request_confirmation(
1208
+ f"Synchronize MCP configurations from {source_desc} to {target_desc}?",
1209
+ auto_approve,
1210
+ ):
1211
+ print("Operation cancelled.")
1212
+ return 0
1213
+
1214
+ # Perform synchronization
1215
+ mcp_manager = MCPHostConfigurationManager()
1216
+ result = mcp_manager.sync_configurations(
1217
+ from_env=from_env,
1218
+ from_host=from_host,
1219
+ to_hosts=target_hosts,
1220
+ servers=server_list,
1221
+ pattern=pattern,
1222
+ no_backup=no_backup,
1223
+ )
1224
+
1225
+ if result.success:
1226
+ print(f"[SUCCESS] Synchronization completed")
1227
+ print(f" Servers synced: {result.servers_synced}")
1228
+ print(f" Hosts updated: {result.hosts_updated}")
1229
+
1230
+ # Show detailed results
1231
+ for res in result.results:
1232
+ if res.success:
1233
+ backup_info = (
1234
+ f" (backup: {res.backup_path})" if res.backup_path else ""
1235
+ )
1236
+ print(f" ✓ {res.hostname}{backup_info}")
1237
+ else:
1238
+ print(f" ✗ {res.hostname}: {res.error_message}")
1239
+
1240
+ return 0
1241
+ else:
1242
+ print(f"[ERROR] Synchronization failed")
1243
+ for res in result.results:
1244
+ if not res.success:
1245
+ print(f" ✗ {res.hostname}: {res.error_message}")
1246
+ return 1
1247
+
1248
+ except ValueError as e:
1249
+ print(f"Error: {e}")
1250
+ return 1
1251
+ except Exception as e:
1252
+ print(f"Error during synchronization: {e}")
1253
+ return 1
1254
+
1255
+
1256
+ def main():
1257
+ """Main entry point for Hatch CLI.
1258
+
1259
+ Parses command-line arguments and executes the requested commands for:
1260
+ - Package template creation
1261
+ - Package validation
1262
+ - Environment management (create, remove, list, use, current)
1263
+ - Package management (add, remove, list)
1264
+
1265
+ Returns:
1266
+ int: Exit code (0 for success, 1 for errors)
1267
+ """
1268
+ # Configure logging
1269
+ logging.basicConfig(
1270
+ level=logging.INFO,
1271
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
1272
+ )
1273
+
1274
+ # Create argument parser
1275
+ parser = argparse.ArgumentParser(description="Hatch package manager CLI")
1276
+
1277
+ # Add version argument
1278
+ parser.add_argument(
1279
+ "--version", action="version", version=f"%(prog)s {get_hatch_version()}"
1280
+ )
1281
+
1282
+ subparsers = parser.add_subparsers(dest="command", help="Command to execute")
1283
+
1284
+ # Create template command
1285
+ create_parser = subparsers.add_parser(
1286
+ "create", help="Create a new package template"
1287
+ )
1288
+ create_parser.add_argument("name", help="Package name")
1289
+ create_parser.add_argument(
1290
+ "--dir", "-d", default=".", help="Target directory (default: current directory)"
1291
+ )
1292
+ create_parser.add_argument(
1293
+ "--description", "-D", default="", help="Package description"
1294
+ )
1295
+
1296
+ # Validate package command
1297
+ validate_parser = subparsers.add_parser("validate", help="Validate a package")
1298
+ validate_parser.add_argument("package_dir", help="Path to package directory")
1299
+
1300
+ # Environment management commands
1301
+ env_subparsers = subparsers.add_parser(
1302
+ "env", help="Environment management commands"
1303
+ ).add_subparsers(dest="env_command", help="Environment command to execute")
1304
+
1305
+ # Create environment command
1306
+ env_create_parser = env_subparsers.add_parser(
1307
+ "create", help="Create a new environment"
1308
+ )
1309
+ env_create_parser.add_argument("name", help="Environment name")
1310
+ env_create_parser.add_argument(
1311
+ "--description", "-D", default="", help="Environment description"
1312
+ )
1313
+ env_create_parser.add_argument(
1314
+ "--python-version", help="Python version for the environment (e.g., 3.11, 3.12)"
1315
+ )
1316
+ env_create_parser.add_argument(
1317
+ "--no-python",
1318
+ action="store_true",
1319
+ help="Don't create a Python environment using conda/mamba",
1320
+ )
1321
+ env_create_parser.add_argument(
1322
+ "--no-hatch-mcp-server",
1323
+ action="store_true",
1324
+ help="Don't install hatch_mcp_server wrapper in the new environment",
1325
+ )
1326
+ env_create_parser.add_argument(
1327
+ "--hatch_mcp_server_tag",
1328
+ help="Git tag/branch reference for hatch_mcp_server wrapper installation (e.g., 'dev', 'v0.1.0')",
1329
+ )
1330
+
1331
+ # Remove environment command
1332
+ env_remove_parser = env_subparsers.add_parser(
1333
+ "remove", help="Remove an environment"
1334
+ )
1335
+ env_remove_parser.add_argument("name", help="Environment name")
1336
+
1337
+ # List environments command
1338
+ env_subparsers.add_parser("list", help="List all available environments")
1339
+
1340
+ # Set current environment command
1341
+ env_use_parser = env_subparsers.add_parser(
1342
+ "use", help="Set the current environment"
1343
+ )
1344
+ env_use_parser.add_argument("name", help="Environment name")
1345
+
1346
+ # Show current environment command
1347
+ env_subparsers.add_parser("current", help="Show the current environment")
1348
+
1349
+ # Python environment management commands - advanced subcommands
1350
+ env_python_subparsers = env_subparsers.add_parser(
1351
+ "python", help="Manage Python environments"
1352
+ ).add_subparsers(
1353
+ dest="python_command", help="Python environment command to execute"
1354
+ )
1355
+
1356
+ # Initialize Python environment
1357
+ python_init_parser = env_python_subparsers.add_parser(
1358
+ "init", help="Initialize Python environment"
1359
+ )
1360
+ python_init_parser.add_argument(
1361
+ "--hatch_env",
1362
+ default=None,
1363
+ help="Hatch environment name in which the Python environment is located (default: current environment)",
1364
+ )
1365
+ python_init_parser.add_argument(
1366
+ "--python-version", help="Python version (e.g., 3.11, 3.12)"
1367
+ )
1368
+ python_init_parser.add_argument(
1369
+ "--force", action="store_true", help="Force recreation if exists"
1370
+ )
1371
+ python_init_parser.add_argument(
1372
+ "--no-hatch-mcp-server",
1373
+ action="store_true",
1374
+ help="Don't install hatch_mcp_server wrapper in the Python environment",
1375
+ )
1376
+ python_init_parser.add_argument(
1377
+ "--hatch_mcp_server_tag",
1378
+ help="Git tag/branch reference for hatch_mcp_server wrapper installation (e.g., 'dev', 'v0.1.0')",
1379
+ )
1380
+
1381
+ # Show Python environment info
1382
+ python_info_parser = env_python_subparsers.add_parser(
1383
+ "info", help="Show Python environment information"
1384
+ )
1385
+ python_info_parser.add_argument(
1386
+ "--hatch_env",
1387
+ default=None,
1388
+ help="Hatch environment name in which the Python environment is located (default: current environment)",
1389
+ )
1390
+ python_info_parser.add_argument(
1391
+ "--detailed", action="store_true", help="Show detailed diagnostics"
1392
+ )
1393
+
1394
+ # Hatch MCP server wrapper management commands
1395
+ hatch_mcp_parser = env_python_subparsers.add_parser(
1396
+ "add-hatch-mcp", help="Add hatch_mcp_server wrapper to the environment"
1397
+ )
1398
+ ## Install MCP server command
1399
+ hatch_mcp_parser.add_argument(
1400
+ "--hatch_env",
1401
+ default=None,
1402
+ help="Hatch environment name. It must possess a valid Python environment. (default: current environment)",
1403
+ )
1404
+ hatch_mcp_parser.add_argument(
1405
+ "--tag",
1406
+ default=None,
1407
+ help="Git tag/branch reference for wrapper installation (e.g., 'dev', 'v0.1.0')",
1408
+ )
1409
+
1410
+ # Remove Python environment
1411
+ python_remove_parser = env_python_subparsers.add_parser(
1412
+ "remove", help="Remove Python environment"
1413
+ )
1414
+ python_remove_parser.add_argument(
1415
+ "--hatch_env",
1416
+ default=None,
1417
+ help="Hatch environment name in which the Python environment is located (default: current environment)",
1418
+ )
1419
+ python_remove_parser.add_argument(
1420
+ "--force", action="store_true", help="Force removal without confirmation"
1421
+ )
1422
+
1423
+ # Launch Python shell
1424
+ python_shell_parser = env_python_subparsers.add_parser(
1425
+ "shell", help="Launch Python shell in environment"
1426
+ )
1427
+ python_shell_parser.add_argument(
1428
+ "--hatch_env",
1429
+ default=None,
1430
+ help="Hatch environment name in which the Python environment is located (default: current environment)",
1431
+ )
1432
+ python_shell_parser.add_argument(
1433
+ "--cmd", help="Command to run in the shell (optional)"
1434
+ )
1435
+
1436
+ # MCP host configuration commands
1437
+ mcp_subparsers = subparsers.add_parser(
1438
+ "mcp", help="MCP host configuration commands"
1439
+ ).add_subparsers(dest="mcp_command", help="MCP command to execute")
1440
+
1441
+ # MCP discovery commands
1442
+ mcp_discover_subparsers = mcp_subparsers.add_parser(
1443
+ "discover", help="Discover MCP hosts and servers"
1444
+ ).add_subparsers(dest="discover_command", help="Discovery command to execute")
1445
+
1446
+ # Discover hosts command
1447
+ mcp_discover_hosts_parser = mcp_discover_subparsers.add_parser(
1448
+ "hosts", help="Discover available MCP host platforms"
1449
+ )
1450
+
1451
+ # Discover servers command
1452
+ mcp_discover_servers_parser = mcp_discover_subparsers.add_parser(
1453
+ "servers", help="Discover configured MCP servers"
1454
+ )
1455
+ mcp_discover_servers_parser.add_argument(
1456
+ "--env",
1457
+ "-e",
1458
+ default=None,
1459
+ help="Environment name (default: current environment)",
1460
+ )
1461
+
1462
+ # MCP list commands
1463
+ mcp_list_subparsers = mcp_subparsers.add_parser(
1464
+ "list", help="List MCP hosts and servers"
1465
+ ).add_subparsers(dest="list_command", help="List command to execute")
1466
+
1467
+ # List hosts command
1468
+ mcp_list_hosts_parser = mcp_list_subparsers.add_parser(
1469
+ "hosts", help="List configured MCP hosts from environment"
1470
+ )
1471
+ mcp_list_hosts_parser.add_argument(
1472
+ "--env",
1473
+ "-e",
1474
+ default=None,
1475
+ help="Environment name (default: current environment)",
1476
+ )
1477
+ mcp_list_hosts_parser.add_argument(
1478
+ "--detailed",
1479
+ action="store_true",
1480
+ help="Show detailed host configuration information",
1481
+ )
1482
+
1483
+ # List servers command
1484
+ mcp_list_servers_parser = mcp_list_subparsers.add_parser(
1485
+ "servers", help="List configured MCP servers from environment"
1486
+ )
1487
+ mcp_list_servers_parser.add_argument(
1488
+ "--env",
1489
+ "-e",
1490
+ default=None,
1491
+ help="Environment name (default: current environment)",
1492
+ )
1493
+
1494
+ # MCP backup commands
1495
+ mcp_backup_subparsers = mcp_subparsers.add_parser(
1496
+ "backup", help="Backup management commands"
1497
+ ).add_subparsers(dest="backup_command", help="Backup command to execute")
1498
+
1499
+ # Restore backup command
1500
+ mcp_backup_restore_parser = mcp_backup_subparsers.add_parser(
1501
+ "restore", help="Restore MCP host configuration from backup"
1502
+ )
1503
+ mcp_backup_restore_parser.add_argument(
1504
+ "host", help="Host platform to restore (e.g., claude-desktop, cursor)"
1505
+ )
1506
+ mcp_backup_restore_parser.add_argument(
1507
+ "--backup-file",
1508
+ "-f",
1509
+ default=None,
1510
+ help="Specific backup file to restore (default: latest)",
1511
+ )
1512
+ mcp_backup_restore_parser.add_argument(
1513
+ "--dry-run",
1514
+ action="store_true",
1515
+ help="Preview restore operation without execution",
1516
+ )
1517
+ mcp_backup_restore_parser.add_argument(
1518
+ "--auto-approve", action="store_true", help="Skip confirmation prompts"
1519
+ )
1520
+
1521
+ # List backups command
1522
+ mcp_backup_list_parser = mcp_backup_subparsers.add_parser(
1523
+ "list", help="List available backups for MCP host"
1524
+ )
1525
+ mcp_backup_list_parser.add_argument(
1526
+ "host", help="Host platform to list backups for (e.g., claude-desktop, cursor)"
1527
+ )
1528
+ mcp_backup_list_parser.add_argument(
1529
+ "--detailed", "-d", action="store_true", help="Show detailed backup information"
1530
+ )
1531
+
1532
+ # Clean backups command
1533
+ mcp_backup_clean_parser = mcp_backup_subparsers.add_parser(
1534
+ "clean", help="Clean old backups based on criteria"
1535
+ )
1536
+ mcp_backup_clean_parser.add_argument(
1537
+ "host", help="Host platform to clean backups for (e.g., claude-desktop, cursor)"
1538
+ )
1539
+ mcp_backup_clean_parser.add_argument(
1540
+ "--older-than-days", type=int, help="Remove backups older than specified days"
1541
+ )
1542
+ mcp_backup_clean_parser.add_argument(
1543
+ "--keep-count",
1544
+ type=int,
1545
+ help="Keep only the specified number of newest backups",
1546
+ )
1547
+ mcp_backup_clean_parser.add_argument(
1548
+ "--dry-run",
1549
+ action="store_true",
1550
+ help="Preview cleanup operation without execution",
1551
+ )
1552
+ mcp_backup_clean_parser.add_argument(
1553
+ "--auto-approve", action="store_true", help="Skip confirmation prompts"
1554
+ )
1555
+
1556
+ # MCP direct management commands
1557
+ mcp_configure_parser = mcp_subparsers.add_parser(
1558
+ "configure", help="Configure MCP server directly on host"
1559
+ )
1560
+ mcp_configure_parser.add_argument("server_name", help="Name for the MCP server")
1561
+ mcp_configure_parser.add_argument(
1562
+ "--host",
1563
+ required=True,
1564
+ help="Host platform to configure (e.g., claude-desktop, cursor)",
1565
+ )
1566
+
1567
+ # Create mutually exclusive group for server type
1568
+ server_type_group = mcp_configure_parser.add_mutually_exclusive_group()
1569
+ server_type_group.add_argument(
1570
+ "--command",
1571
+ dest="server_command",
1572
+ help="Command to execute the MCP server (for local servers)",
1573
+ )
1574
+ server_type_group.add_argument(
1575
+ "--url", help="Server URL for remote MCP servers (SSE transport)"
1576
+ )
1577
+ server_type_group.add_argument(
1578
+ "--http-url", help="HTTP streaming endpoint URL (Gemini only)"
1579
+ )
1580
+
1581
+ mcp_configure_parser.add_argument(
1582
+ "--args",
1583
+ nargs="*",
1584
+ help="Arguments for the MCP server command (only with --command)",
1585
+ )
1586
+ mcp_configure_parser.add_argument(
1587
+ "--env-var", action="append", help="Environment variables (format: KEY=VALUE)"
1588
+ )
1589
+ mcp_configure_parser.add_argument(
1590
+ "--header",
1591
+ action="append",
1592
+ help="HTTP headers for remote servers (format: KEY=VALUE, only with --url)",
1593
+ )
1594
+
1595
+ # Host-specific arguments (Gemini)
1596
+ mcp_configure_parser.add_argument(
1597
+ "--timeout", type=int, help="Request timeout in milliseconds (Gemini)"
1598
+ )
1599
+ mcp_configure_parser.add_argument(
1600
+ "--trust", action="store_true", help="Bypass tool call confirmations (Gemini)"
1601
+ )
1602
+ mcp_configure_parser.add_argument(
1603
+ "--cwd", help="Working directory for stdio transport (Gemini)"
1604
+ )
1605
+ mcp_configure_parser.add_argument(
1606
+ "--include-tools",
1607
+ nargs="*",
1608
+ help="Tool allowlist - only these tools will be available (Gemini)",
1609
+ )
1610
+ mcp_configure_parser.add_argument(
1611
+ "--exclude-tools",
1612
+ nargs="*",
1613
+ help="Tool blocklist - these tools will be excluded (Gemini)",
1614
+ )
1615
+
1616
+ # Host-specific arguments (Cursor/VS Code/LM Studio)
1617
+ mcp_configure_parser.add_argument(
1618
+ "--env-file", help="Path to environment file (Cursor, VS Code, LM Studio)"
1619
+ )
1620
+
1621
+ # Host-specific arguments (VS Code)
1622
+ mcp_configure_parser.add_argument(
1623
+ "--input",
1624
+ action="append",
1625
+ help="Input variable definitions in format: type,id,description[,password=true] (VS Code)",
1626
+ )
1627
+
1628
+ mcp_configure_parser.add_argument(
1629
+ "--no-backup",
1630
+ action="store_true",
1631
+ help="Skip backup creation before configuration",
1632
+ )
1633
+ mcp_configure_parser.add_argument(
1634
+ "--dry-run", action="store_true", help="Preview configuration without execution"
1635
+ )
1636
+ mcp_configure_parser.add_argument(
1637
+ "--auto-approve", action="store_true", help="Skip confirmation prompts"
1638
+ )
1639
+
1640
+ # Remove MCP commands (object-action pattern)
1641
+ mcp_remove_subparsers = mcp_subparsers.add_parser(
1642
+ "remove", help="Remove MCP servers or host configurations"
1643
+ ).add_subparsers(dest="remove_command", help="Remove command to execute")
1644
+
1645
+ # Remove server command
1646
+ mcp_remove_server_parser = mcp_remove_subparsers.add_parser(
1647
+ "server", help="Remove MCP server from hosts"
1648
+ )
1649
+ mcp_remove_server_parser.add_argument(
1650
+ "server_name", help="Name of the MCP server to remove"
1651
+ )
1652
+ mcp_remove_server_parser.add_argument(
1653
+ "--host", help="Target hosts (comma-separated or 'all')"
1654
+ )
1655
+ mcp_remove_server_parser.add_argument(
1656
+ "--env", "-e", help="Environment name (for environment-based removal)"
1657
+ )
1658
+ mcp_remove_server_parser.add_argument(
1659
+ "--no-backup", action="store_true", help="Skip backup creation before removal"
1660
+ )
1661
+ mcp_remove_server_parser.add_argument(
1662
+ "--dry-run", action="store_true", help="Preview removal without execution"
1663
+ )
1664
+ mcp_remove_server_parser.add_argument(
1665
+ "--auto-approve", action="store_true", help="Skip confirmation prompts"
1666
+ )
1667
+
1668
+ # Remove host command
1669
+ mcp_remove_host_parser = mcp_remove_subparsers.add_parser(
1670
+ "host", help="Remove entire host configuration"
1671
+ )
1672
+ mcp_remove_host_parser.add_argument(
1673
+ "host_name", help="Host platform to remove (e.g., claude-desktop, cursor)"
1674
+ )
1675
+ mcp_remove_host_parser.add_argument(
1676
+ "--no-backup", action="store_true", help="Skip backup creation before removal"
1677
+ )
1678
+ mcp_remove_host_parser.add_argument(
1679
+ "--dry-run", action="store_true", help="Preview removal without execution"
1680
+ )
1681
+ mcp_remove_host_parser.add_argument(
1682
+ "--auto-approve", action="store_true", help="Skip confirmation prompts"
1683
+ )
1684
+
1685
+ # MCP synchronization command
1686
+ mcp_sync_parser = mcp_subparsers.add_parser(
1687
+ "sync", help="Synchronize MCP configurations between environments and hosts"
1688
+ )
1689
+
1690
+ # Source options (mutually exclusive)
1691
+ sync_source_group = mcp_sync_parser.add_mutually_exclusive_group(required=True)
1692
+ sync_source_group.add_argument("--from-env", help="Source environment name")
1693
+ sync_source_group.add_argument("--from-host", help="Source host platform")
1694
+
1695
+ # Target options
1696
+ mcp_sync_parser.add_argument(
1697
+ "--to-host", required=True, help="Target hosts (comma-separated or 'all')"
1698
+ )
1699
+
1700
+ # Filter options (mutually exclusive)
1701
+ sync_filter_group = mcp_sync_parser.add_mutually_exclusive_group()
1702
+ sync_filter_group.add_argument(
1703
+ "--servers", help="Specific server names to sync (comma-separated)"
1704
+ )
1705
+ sync_filter_group.add_argument(
1706
+ "--pattern", help="Regex pattern for server selection"
1707
+ )
1708
+
1709
+ # Standard options
1710
+ mcp_sync_parser.add_argument(
1711
+ "--dry-run",
1712
+ action="store_true",
1713
+ help="Preview synchronization without execution",
1714
+ )
1715
+ mcp_sync_parser.add_argument(
1716
+ "--auto-approve", action="store_true", help="Skip confirmation prompts"
1717
+ )
1718
+ mcp_sync_parser.add_argument(
1719
+ "--no-backup",
1720
+ action="store_true",
1721
+ help="Skip backup creation before synchronization",
1722
+ )
1723
+
1724
+ # Package management commands
1725
+ pkg_subparsers = subparsers.add_parser(
1726
+ "package", help="Package management commands"
1727
+ ).add_subparsers(dest="pkg_command", help="Package command to execute")
1728
+
1729
+ # Add package command
1730
+ pkg_add_parser = pkg_subparsers.add_parser(
1731
+ "add", help="Add a package to the current environment"
1732
+ )
1733
+ pkg_add_parser.add_argument(
1734
+ "package_path_or_name", help="Path to package directory or name of the package"
1735
+ )
1736
+ pkg_add_parser.add_argument(
1737
+ "--env",
1738
+ "-e",
1739
+ default=None,
1740
+ help="Environment name (default: current environment)",
1741
+ )
1742
+ pkg_add_parser.add_argument(
1743
+ "--version", "-v", default=None, help="Version of the package (optional)"
1744
+ )
1745
+ pkg_add_parser.add_argument(
1746
+ "--force-download",
1747
+ "-f",
1748
+ action="store_true",
1749
+ help="Force download even if package is in cache",
1750
+ )
1751
+ pkg_add_parser.add_argument(
1752
+ "--refresh-registry",
1753
+ "-r",
1754
+ action="store_true",
1755
+ help="Force refresh of registry data",
1756
+ )
1757
+ pkg_add_parser.add_argument(
1758
+ "--auto-approve",
1759
+ action="store_true",
1760
+ help="Automatically approve changes installation of deps for automation scenario",
1761
+ )
1762
+ # MCP host configuration integration
1763
+ pkg_add_parser.add_argument(
1764
+ "--host",
1765
+ help="Comma-separated list of MCP host platforms to configure (e.g., claude-desktop,cursor)",
1766
+ )
1767
+
1768
+ # Remove package command
1769
+ pkg_remove_parser = pkg_subparsers.add_parser(
1770
+ "remove", help="Remove a package from the current environment"
1771
+ )
1772
+ pkg_remove_parser.add_argument("package_name", help="Name of the package to remove")
1773
+ pkg_remove_parser.add_argument(
1774
+ "--env",
1775
+ "-e",
1776
+ default=None,
1777
+ help="Environment name (default: current environment)",
1778
+ )
1779
+
1780
+ # List packages command
1781
+ pkg_list_parser = pkg_subparsers.add_parser(
1782
+ "list", help="List packages in an environment"
1783
+ )
1784
+ pkg_list_parser.add_argument(
1785
+ "--env", "-e", help="Environment name (default: current environment)"
1786
+ )
1787
+
1788
+ # Sync package MCP servers command
1789
+ pkg_sync_parser = pkg_subparsers.add_parser(
1790
+ "sync", help="Synchronize package MCP servers to host platforms"
1791
+ )
1792
+ pkg_sync_parser.add_argument(
1793
+ "package_name", help="Name of the package whose MCP servers to sync"
1794
+ )
1795
+ pkg_sync_parser.add_argument(
1796
+ "--host",
1797
+ required=True,
1798
+ help="Comma-separated list of host platforms to sync to (or 'all')",
1799
+ )
1800
+ pkg_sync_parser.add_argument(
1801
+ "--env",
1802
+ "-e",
1803
+ default=None,
1804
+ help="Environment name (default: current environment)",
1805
+ )
1806
+ pkg_sync_parser.add_argument(
1807
+ "--dry-run", action="store_true", help="Preview changes without execution"
1808
+ )
1809
+ pkg_sync_parser.add_argument(
1810
+ "--auto-approve", action="store_true", help="Skip confirmation prompts"
1811
+ )
1812
+ pkg_sync_parser.add_argument(
1813
+ "--no-backup", action="store_true", help="Disable default backup behavior"
1814
+ )
1815
+
1816
+ # General arguments for the environment manager
1817
+ parser.add_argument(
1818
+ "--envs-dir",
1819
+ default=Path.home() / ".hatch" / "envs",
1820
+ help="Directory to store environments",
1821
+ )
1822
+ parser.add_argument(
1823
+ "--cache-ttl",
1824
+ type=int,
1825
+ default=86400,
1826
+ help="Cache TTL in seconds (default: 86400 seconds --> 1 day)",
1827
+ )
1828
+ parser.add_argument(
1829
+ "--cache-dir",
1830
+ default=Path.home() / ".hatch" / "cache",
1831
+ help="Directory to store cached packages",
1832
+ )
1833
+
1834
+ args = parser.parse_args()
1835
+
1836
+ # Initialize environment manager
1837
+ env_manager = HatchEnvironmentManager(
1838
+ environments_dir=args.envs_dir,
1839
+ cache_ttl=args.cache_ttl,
1840
+ cache_dir=args.cache_dir,
1841
+ )
1842
+
1843
+ # Initialize MCP configuration manager
1844
+ mcp_manager = MCPHostConfigurationManager()
1845
+
1846
+ # Execute commands
1847
+ if args.command == "create":
1848
+ target_dir = Path(args.dir).resolve()
1849
+ package_dir = create_package_template(
1850
+ target_dir=target_dir, package_name=args.name, description=args.description
1851
+ )
1852
+ print(f"Package template created at: {package_dir}")
1853
+
1854
+ elif args.command == "validate":
1855
+ package_path = Path(args.package_dir).resolve()
1856
+
1857
+ # Create validator with registry data from environment manager
1858
+ validator = HatchPackageValidator(
1859
+ version="latest",
1860
+ allow_local_dependencies=True,
1861
+ registry_data=env_manager.registry_data,
1862
+ )
1863
+
1864
+ # Validate the package
1865
+ is_valid, validation_results = validator.validate_package(package_path)
1866
+
1867
+ if is_valid:
1868
+ print(f"Package validation SUCCESSFUL: {package_path}")
1869
+ return 0
1870
+ else:
1871
+ print(f"Package validation FAILED: {package_path}")
1872
+
1873
+ # Print detailed validation results if available
1874
+ if validation_results and isinstance(validation_results, dict):
1875
+ for category, result in validation_results.items():
1876
+ if (
1877
+ category != "valid"
1878
+ and category != "metadata"
1879
+ and isinstance(result, dict)
1880
+ ):
1881
+ if not result.get("valid", True) and result.get("errors"):
1882
+ print(f"\n{category.replace('_', ' ').title()} errors:")
1883
+ for error in result["errors"]:
1884
+ print(f" - {error}")
1885
+
1886
+ return 1
1887
+
1888
+ elif args.command == "env":
1889
+ if args.env_command == "create":
1890
+ # Determine whether to create Python environment
1891
+ create_python_env = not args.no_python
1892
+ python_version = getattr(args, "python_version", None)
1893
+
1894
+ if env_manager.create_environment(
1895
+ args.name,
1896
+ args.description,
1897
+ python_version=python_version,
1898
+ create_python_env=create_python_env,
1899
+ no_hatch_mcp_server=args.no_hatch_mcp_server,
1900
+ hatch_mcp_server_tag=args.hatch_mcp_server_tag,
1901
+ ):
1902
+ print(f"Environment created: {args.name}")
1903
+
1904
+ # Show Python environment status
1905
+ if create_python_env and env_manager.is_python_environment_available():
1906
+ python_exec = env_manager.python_env_manager.get_python_executable(
1907
+ args.name
1908
+ )
1909
+ if python_exec:
1910
+ python_version_info = (
1911
+ env_manager.python_env_manager.get_python_version(args.name)
1912
+ )
1913
+ print(f"Python environment: {python_exec}")
1914
+ if python_version_info:
1915
+ print(f"Python version: {python_version_info}")
1916
+ else:
1917
+ print("Python environment creation failed")
1918
+ elif create_python_env:
1919
+ print("Python environment requested but conda/mamba not available")
1920
+
1921
+ return 0
1922
+ else:
1923
+ print(f"Failed to create environment: {args.name}")
1924
+ return 1
1925
+
1926
+ elif args.env_command == "remove":
1927
+ if env_manager.remove_environment(args.name):
1928
+ print(f"Environment removed: {args.name}")
1929
+ return 0
1930
+ else:
1931
+ print(f"Failed to remove environment: {args.name}")
1932
+ return 1
1933
+
1934
+ elif args.env_command == "list":
1935
+ environments = env_manager.list_environments()
1936
+ print("Available environments:")
1937
+
1938
+ # Check if conda/mamba is available for status info
1939
+ conda_available = env_manager.is_python_environment_available()
1940
+
1941
+ for env in environments:
1942
+ current_marker = "* " if env.get("is_current") else " "
1943
+ description = (
1944
+ f" - {env.get('description')}" if env.get("description") else ""
1945
+ )
1946
+
1947
+ # Show basic environment info
1948
+ print(f"{current_marker}{env.get('name')}{description}")
1949
+
1950
+ # Show Python environment info if available
1951
+ python_env = env.get("python_environment", False)
1952
+ if python_env:
1953
+ python_info = env_manager.get_python_environment_info(
1954
+ env.get("name")
1955
+ )
1956
+ if python_info:
1957
+ python_version = python_info.get("python_version", "Unknown")
1958
+ conda_env = python_info.get("conda_env_name", "N/A")
1959
+ print(f" Python: {python_version} (conda: {conda_env})")
1960
+ else:
1961
+ print(f" Python: Configured but unavailable")
1962
+ elif conda_available:
1963
+ print(f" Python: Not configured")
1964
+ else:
1965
+ print(f" Python: Conda/mamba not available")
1966
+
1967
+ # Show conda/mamba status
1968
+ if conda_available:
1969
+ manager_info = env_manager.python_env_manager.get_manager_info()
1970
+ print(f"\nPython Environment Manager:")
1971
+ print(
1972
+ f" Conda executable: {manager_info.get('conda_executable', 'Not found')}"
1973
+ )
1974
+ print(
1975
+ f" Mamba executable: {manager_info.get('mamba_executable', 'Not found')}"
1976
+ )
1977
+ print(
1978
+ f" Preferred manager: {manager_info.get('preferred_manager', 'N/A')}"
1979
+ )
1980
+ else:
1981
+ print(f"\nPython Environment Manager: Conda/mamba not available")
1982
+
1983
+ return 0
1984
+
1985
+ elif args.env_command == "use":
1986
+ if env_manager.set_current_environment(args.name):
1987
+ print(f"Current environment set to: {args.name}")
1988
+ return 0
1989
+ else:
1990
+ print(f"Failed to set environment: {args.name}")
1991
+ return 1
1992
+
1993
+ elif args.env_command == "current":
1994
+ current_env = env_manager.get_current_environment()
1995
+ print(f"Current environment: {current_env}")
1996
+ return 0
1997
+
1998
+ elif args.env_command == "python":
1999
+ # Advanced Python environment management
2000
+ if args.python_command == "init":
2001
+ python_version = getattr(args, "python_version", None)
2002
+ force = getattr(args, "force", False)
2003
+ no_hatch_mcp_server = getattr(args, "no_hatch_mcp_server", False)
2004
+ hatch_mcp_server_tag = getattr(args, "hatch_mcp_server_tag", None)
2005
+
2006
+ if env_manager.create_python_environment_only(
2007
+ args.hatch_env,
2008
+ python_version,
2009
+ force,
2010
+ no_hatch_mcp_server=no_hatch_mcp_server,
2011
+ hatch_mcp_server_tag=hatch_mcp_server_tag,
2012
+ ):
2013
+ print(f"Python environment initialized for: {args.hatch_env}")
2014
+
2015
+ # Show Python environment info
2016
+ python_info = env_manager.get_python_environment_info(
2017
+ args.hatch_env
2018
+ )
2019
+ if python_info:
2020
+ print(
2021
+ f" Python executable: {python_info['python_executable']}"
2022
+ )
2023
+ print(
2024
+ f" Python version: {python_info.get('python_version', 'Unknown')}"
2025
+ )
2026
+ print(
2027
+ f" Conda environment: {python_info.get('conda_env_name', 'N/A')}"
2028
+ )
2029
+
2030
+ return 0
2031
+ else:
2032
+ env_name = args.hatch_env or env_manager.get_current_environment()
2033
+ print(f"Failed to initialize Python environment for: {env_name}")
2034
+ return 1
2035
+
2036
+ elif args.python_command == "info":
2037
+ detailed = getattr(args, "detailed", False)
2038
+
2039
+ python_info = env_manager.get_python_environment_info(args.hatch_env)
2040
+
2041
+ if python_info:
2042
+ env_name = args.hatch_env or env_manager.get_current_environment()
2043
+ print(f"Python environment info for '{env_name}':")
2044
+ print(
2045
+ f" Status: {'Active' if python_info.get('enabled', False) else 'Inactive'}"
2046
+ )
2047
+ print(f" Python executable: {python_info['python_executable']}")
2048
+ print(
2049
+ f" Python version: {python_info.get('python_version', 'Unknown')}"
2050
+ )
2051
+ print(
2052
+ f" Conda environment: {python_info.get('conda_env_name', 'N/A')}"
2053
+ )
2054
+ print(f" Environment path: {python_info['environment_path']}")
2055
+ print(f" Created: {python_info.get('created_at', 'Unknown')}")
2056
+ print(f" Package count: {python_info.get('package_count', 0)}")
2057
+ print(f" Packages:")
2058
+ for pkg in python_info.get("packages", []):
2059
+ print(f" - {pkg['name']} ({pkg['version']})")
2060
+
2061
+ if detailed:
2062
+ print(f"\nDiagnostics:")
2063
+ diagnostics = env_manager.get_python_environment_diagnostics(
2064
+ args.hatch_env
2065
+ )
2066
+ if diagnostics:
2067
+ for key, value in diagnostics.items():
2068
+ print(f" {key}: {value}")
2069
+ else:
2070
+ print(" No diagnostics available")
2071
+
2072
+ return 0
2073
+ else:
2074
+ env_name = args.hatch_env or env_manager.get_current_environment()
2075
+ print(f"No Python environment found for: {env_name}")
2076
+
2077
+ # Show diagnostics for missing environment
2078
+ if detailed:
2079
+ print("\nDiagnostics:")
2080
+ general_diagnostics = (
2081
+ env_manager.get_python_manager_diagnostics()
2082
+ )
2083
+ for key, value in general_diagnostics.items():
2084
+ print(f" {key}: {value}")
2085
+
2086
+ return 1
2087
+
2088
+ elif args.python_command == "remove":
2089
+ force = getattr(args, "force", False)
2090
+
2091
+ if not force:
2092
+ # Ask for confirmation using TTY-aware function
2093
+ env_name = args.hatch_env or env_manager.get_current_environment()
2094
+ if not request_confirmation(
2095
+ f"Remove Python environment for '{env_name}'?"
2096
+ ):
2097
+ print("Operation cancelled")
2098
+ return 0
2099
+
2100
+ if env_manager.remove_python_environment_only(args.hatch_env):
2101
+ env_name = args.hatch_env or env_manager.get_current_environment()
2102
+ print(f"Python environment removed from: {env_name}")
2103
+ return 0
2104
+ else:
2105
+ env_name = args.hatch_env or env_manager.get_current_environment()
2106
+ print(f"Failed to remove Python environment from: {env_name}")
2107
+ return 1
2108
+
2109
+ elif args.python_command == "shell":
2110
+ cmd = getattr(args, "cmd", None)
2111
+
2112
+ if env_manager.launch_python_shell(args.hatch_env, cmd):
2113
+ return 0
2114
+ else:
2115
+ env_name = args.hatch_env or env_manager.get_current_environment()
2116
+ print(f"Failed to launch Python shell for: {env_name}")
2117
+ return 1
2118
+
2119
+ elif args.python_command == "add-hatch-mcp":
2120
+ env_name = args.hatch_env or env_manager.get_current_environment()
2121
+ tag = args.tag
2122
+
2123
+ if env_manager.install_mcp_server(env_name, tag):
2124
+ print(
2125
+ f"hatch_mcp_server wrapper installed successfully in environment: {env_name}"
2126
+ )
2127
+ return 0
2128
+ else:
2129
+ print(
2130
+ f"Failed to install hatch_mcp_server wrapper in environment: {env_name}"
2131
+ )
2132
+ return 1
2133
+
2134
+ else:
2135
+ print("Unknown Python environment command")
2136
+ return 1
2137
+
2138
+ elif args.command == "package":
2139
+ if args.pkg_command == "add":
2140
+ # Add package to environment
2141
+ if env_manager.add_package_to_environment(
2142
+ args.package_path_or_name,
2143
+ args.env,
2144
+ args.version,
2145
+ args.force_download,
2146
+ args.refresh_registry,
2147
+ args.auto_approve,
2148
+ ):
2149
+ print(f"Successfully added package: {args.package_path_or_name}")
2150
+
2151
+ # Handle MCP host configuration if requested
2152
+ if hasattr(args, "host") and args.host:
2153
+ try:
2154
+ hosts = parse_host_list(args.host)
2155
+ env_name = args.env or env_manager.get_current_environment()
2156
+
2157
+ package_name = args.package_path_or_name
2158
+ package_service = None
2159
+
2160
+ # Check if it's a local package path
2161
+ pkg_path = Path(args.package_path_or_name)
2162
+ if pkg_path.exists() and pkg_path.is_dir():
2163
+ # Local package - load metadata from directory
2164
+ with open(pkg_path / "hatch_metadata.json", "r") as f:
2165
+ metadata = json.load(f)
2166
+ package_service = PackageService(metadata)
2167
+ package_name = package_service.get_field("name")
2168
+ else:
2169
+ # Registry package - get metadata from environment manager
2170
+ try:
2171
+ env_data = env_manager.get_environment_data(env_name)
2172
+ if env_data:
2173
+ # Find the package in the environment
2174
+ for pkg in env_data.packages:
2175
+ if pkg.name == package_name:
2176
+ # Create a minimal metadata structure for PackageService
2177
+ metadata = {
2178
+ "name": pkg.name,
2179
+ "version": pkg.version,
2180
+ "dependencies": {}, # Will be populated if needed
2181
+ }
2182
+ package_service = PackageService(metadata)
2183
+ break
2184
+
2185
+ if package_service is None:
2186
+ print(
2187
+ f"Warning: Could not find package '{package_name}' in environment '{env_name}'. Skipping dependency analysis."
2188
+ )
2189
+ package_service = None
2190
+ except Exception as e:
2191
+ print(
2192
+ f"Warning: Could not load package metadata for '{package_name}': {e}. Skipping dependency analysis."
2193
+ )
2194
+ package_service = None
2195
+
2196
+ # Get dependency names if we have package service
2197
+ package_names = []
2198
+ if package_service:
2199
+ # Get Hatch dependencies
2200
+ dependencies = package_service.get_dependencies()
2201
+ hatch_deps = dependencies.get("hatch", [])
2202
+ package_names = [
2203
+ dep.get("name") for dep in hatch_deps if dep.get("name")
2204
+ ]
2205
+
2206
+ # Resolve local dependency paths to actual names
2207
+ for i in range(len(package_names)):
2208
+ dep_path = Path(package_names[i])
2209
+ if dep_path.exists() and dep_path.is_dir():
2210
+ try:
2211
+ with open(
2212
+ dep_path / "hatch_metadata.json", "r"
2213
+ ) as f:
2214
+ dep_metadata = json.load(f)
2215
+ dep_service = PackageService(dep_metadata)
2216
+ package_names[i] = dep_service.get_field("name")
2217
+ except Exception as e:
2218
+ print(
2219
+ f"Warning: Could not resolve dependency path '{package_names[i]}': {e}"
2220
+ )
2221
+
2222
+ # Add the main package to the list
2223
+ package_names.append(package_name)
2224
+
2225
+ # Get MCP server configuration for all packages
2226
+ server_configs = [
2227
+ get_package_mcp_server_config(
2228
+ env_manager, env_name, pkg_name
2229
+ )
2230
+ for pkg_name in package_names
2231
+ ]
2232
+
2233
+ print(
2234
+ f"Configuring MCP server for package '{package_name}' on {len(hosts)} host(s)..."
2235
+ )
2236
+
2237
+ # Configure on each host
2238
+ success_count = 0
2239
+ for host in hosts: # 'host', here, is a string
2240
+ try:
2241
+ # Convert string to MCPHostType enum
2242
+ host_type = MCPHostType(host)
2243
+ host_model_class = HOST_MODEL_REGISTRY.get(host_type)
2244
+ if not host_model_class:
2245
+ print(
2246
+ f"✗ Error: No model registered for host '{host}'"
2247
+ )
2248
+ continue
2249
+
2250
+ host_success_count = 0
2251
+ for i, server_config in enumerate(server_configs):
2252
+ pkg_name = package_names[i]
2253
+ try:
2254
+ # Convert MCPServerConfig to Omni model
2255
+ # Only include fields that have actual values
2256
+ omni_config_data = {"name": server_config.name}
2257
+ if server_config.command is not None:
2258
+ omni_config_data["command"] = (
2259
+ server_config.command
2260
+ )
2261
+ if server_config.args is not None:
2262
+ omni_config_data["args"] = (
2263
+ server_config.args
2264
+ )
2265
+ if server_config.env:
2266
+ omni_config_data["env"] = server_config.env
2267
+ if server_config.url is not None:
2268
+ omni_config_data["url"] = server_config.url
2269
+ headers = getattr(
2270
+ server_config, "headers", None
2271
+ )
2272
+ if headers is not None:
2273
+ omni_config_data["headers"] = headers
2274
+
2275
+ omni_config = MCPServerConfigOmni(
2276
+ **omni_config_data
2277
+ )
2278
+
2279
+ # Convert to host-specific model
2280
+ host_config = host_model_class.from_omni(
2281
+ omni_config
2282
+ )
2283
+
2284
+ # Generate and display conversion report
2285
+ report = generate_conversion_report(
2286
+ operation="create",
2287
+ server_name=server_config.name,
2288
+ target_host=host_type,
2289
+ omni=omni_config,
2290
+ dry_run=False,
2291
+ )
2292
+ display_report(report)
2293
+
2294
+ result = mcp_manager.configure_server(
2295
+ hostname=host,
2296
+ server_config=host_config,
2297
+ no_backup=False, # Always backup when adding packages
2298
+ )
2299
+
2300
+ if result.success:
2301
+ print(
2302
+ f"✓ Configured {server_config.name} ({pkg_name}) on {host}"
2303
+ )
2304
+ host_success_count += 1
2305
+
2306
+ # Update package metadata with host configuration tracking
2307
+ try:
2308
+ server_config_dict = {
2309
+ "name": server_config.name,
2310
+ "command": server_config.command,
2311
+ "args": server_config.args,
2312
+ }
2313
+
2314
+ env_manager.update_package_host_configuration(
2315
+ env_name=env_name,
2316
+ package_name=pkg_name,
2317
+ hostname=host,
2318
+ server_config=server_config_dict,
2319
+ )
2320
+ except Exception as e:
2321
+ # Log but don't fail the configuration operation
2322
+ print(
2323
+ f"[WARNING] Failed to update package metadata for {pkg_name}: {e}"
2324
+ )
2325
+ else:
2326
+ print(
2327
+ f"✗ Failed to configure {server_config.name} ({pkg_name}) on {host}: {result.error_message}"
2328
+ )
2329
+
2330
+ except Exception as e:
2331
+ print(
2332
+ f"✗ Error configuring {server_config.name} ({pkg_name}) on {host}: {e}"
2333
+ )
2334
+
2335
+ if host_success_count == len(server_configs):
2336
+ success_count += 1
2337
+
2338
+ except ValueError as e:
2339
+ print(f"✗ Invalid host '{host}': {e}")
2340
+ continue
2341
+
2342
+ if success_count > 0:
2343
+ print(
2344
+ f"MCP configuration completed: {success_count}/{len(hosts)} hosts configured"
2345
+ )
2346
+ else:
2347
+ print("Warning: MCP configuration failed on all hosts")
2348
+
2349
+ except ValueError as e:
2350
+ print(f"Warning: MCP host configuration failed: {e}")
2351
+ # Don't fail the entire operation for MCP configuration issues
2352
+
2353
+ return 0
2354
+ else:
2355
+ print(f"Failed to add package: {args.package_path_or_name}")
2356
+ return 1
2357
+
2358
+ elif args.pkg_command == "remove":
2359
+ if env_manager.remove_package(args.package_name, args.env):
2360
+ print(f"Successfully removed package: {args.package_name}")
2361
+ return 0
2362
+ else:
2363
+ print(f"Failed to remove package: {args.package_name}")
2364
+ return 1
2365
+
2366
+ elif args.pkg_command == "list":
2367
+ packages = env_manager.list_packages(args.env)
2368
+
2369
+ if not packages:
2370
+ print(f"No packages found in environment: {args.env}")
2371
+ return 0
2372
+
2373
+ print(f"Packages in environment '{args.env}':")
2374
+ for pkg in packages:
2375
+ print(
2376
+ f"{pkg['name']} ({pkg['version']})\tHatch compliant: {pkg['hatch_compliant']}\tsource: {pkg['source']['uri']}\tlocation: {pkg['source']['path']}"
2377
+ )
2378
+ return 0
2379
+
2380
+ elif args.pkg_command == "sync":
2381
+ try:
2382
+ # Parse host list
2383
+ hosts = parse_host_list(args.host)
2384
+ env_name = args.env or env_manager.get_current_environment()
2385
+
2386
+ # Get all packages to sync (main package + dependencies)
2387
+ package_names = [args.package_name]
2388
+
2389
+ # Try to get dependencies for the main package
2390
+ try:
2391
+ env_data = env_manager.get_environment_data(env_name)
2392
+ if env_data:
2393
+ # Find the main package in the environment
2394
+ main_package = None
2395
+ for pkg in env_data.packages:
2396
+ if pkg.name == args.package_name:
2397
+ main_package = pkg
2398
+ break
2399
+
2400
+ if main_package:
2401
+ # Create a minimal metadata structure for PackageService
2402
+ metadata = {
2403
+ "name": main_package.name,
2404
+ "version": main_package.version,
2405
+ "dependencies": {}, # Will be populated if needed
2406
+ }
2407
+ package_service = PackageService(metadata)
2408
+
2409
+ # Get Hatch dependencies
2410
+ dependencies = package_service.get_dependencies()
2411
+ hatch_deps = dependencies.get("hatch", [])
2412
+ dep_names = [
2413
+ dep.get("name") for dep in hatch_deps if dep.get("name")
2414
+ ]
2415
+
2416
+ # Add dependencies to the sync list (before main package)
2417
+ package_names = dep_names + [args.package_name]
2418
+ else:
2419
+ print(
2420
+ f"Warning: Package '{args.package_name}' not found in environment '{env_name}'. Syncing only the specified package."
2421
+ )
2422
+ else:
2423
+ print(
2424
+ f"Warning: Could not access environment '{env_name}'. Syncing only the specified package."
2425
+ )
2426
+ except Exception as e:
2427
+ print(
2428
+ f"Warning: Could not analyze dependencies for '{args.package_name}': {e}. Syncing only the specified package."
2429
+ )
2430
+
2431
+ # Get MCP server configurations for all packages
2432
+ server_configs = []
2433
+ for pkg_name in package_names:
2434
+ try:
2435
+ config = get_package_mcp_server_config(
2436
+ env_manager, env_name, pkg_name
2437
+ )
2438
+ server_configs.append((pkg_name, config))
2439
+ except Exception as e:
2440
+ print(
2441
+ f"Warning: Could not get MCP configuration for package '{pkg_name}': {e}"
2442
+ )
2443
+
2444
+ if not server_configs:
2445
+ print(
2446
+ f"Error: No MCP server configurations found for package '{args.package_name}' or its dependencies"
2447
+ )
2448
+ return 1
2449
+
2450
+ if args.dry_run:
2451
+ print(
2452
+ f"[DRY RUN] Would synchronize MCP servers for {len(server_configs)} package(s) to hosts: {[h for h in hosts]}"
2453
+ )
2454
+ for pkg_name, config in server_configs:
2455
+ print(
2456
+ f"[DRY RUN] - {pkg_name}: {config.name} -> {' '.join(config.args)}"
2457
+ )
2458
+
2459
+ # Generate and display conversion reports for dry-run mode
2460
+ for host in hosts:
2461
+ try:
2462
+ host_type = MCPHostType(host)
2463
+ host_model_class = HOST_MODEL_REGISTRY.get(host_type)
2464
+ if not host_model_class:
2465
+ print(
2466
+ f"[DRY RUN] ✗ Error: No model registered for host '{host}'"
2467
+ )
2468
+ continue
2469
+
2470
+ # Convert to Omni model
2471
+ # Only include fields that have actual values
2472
+ omni_config_data = {"name": config.name}
2473
+ if config.command is not None:
2474
+ omni_config_data["command"] = config.command
2475
+ if config.args is not None:
2476
+ omni_config_data["args"] = config.args
2477
+ if config.env:
2478
+ omni_config_data["env"] = config.env
2479
+ if config.url is not None:
2480
+ omni_config_data["url"] = config.url
2481
+ headers = getattr(config, "headers", None)
2482
+ if headers is not None:
2483
+ omni_config_data["headers"] = headers
2484
+
2485
+ omni_config = MCPServerConfigOmni(**omni_config_data)
2486
+
2487
+ # Generate report
2488
+ report = generate_conversion_report(
2489
+ operation="create",
2490
+ server_name=config.name,
2491
+ target_host=host_type,
2492
+ omni=omni_config,
2493
+ dry_run=True,
2494
+ )
2495
+ print(f"[DRY RUN] Preview for {pkg_name} on {host}:")
2496
+ display_report(report)
2497
+ except ValueError as e:
2498
+ print(f"[DRY RUN] ✗ Invalid host '{host}': {e}")
2499
+ return 0
2500
+
2501
+ # Confirm operation unless auto-approved
2502
+ package_desc = (
2503
+ f"package '{args.package_name}'"
2504
+ if len(server_configs) == 1
2505
+ else f"{len(server_configs)} packages ('{args.package_name}' + dependencies)"
2506
+ )
2507
+ if not request_confirmation(
2508
+ f"Synchronize MCP servers for {package_desc} to {len(hosts)} host(s)?",
2509
+ args.auto_approve,
2510
+ ):
2511
+ print("Operation cancelled.")
2512
+ return 0
2513
+
2514
+ # Perform synchronization to each host for all packages
2515
+ total_operations = len(server_configs) * len(hosts)
2516
+ success_count = 0
2517
+
2518
+ for host in hosts:
2519
+ try:
2520
+ # Convert string to MCPHostType enum
2521
+ host_type = MCPHostType(host)
2522
+ host_model_class = HOST_MODEL_REGISTRY.get(host_type)
2523
+ if not host_model_class:
2524
+ print(f"✗ Error: No model registered for host '{host}'")
2525
+ continue
2526
+
2527
+ for pkg_name, server_config in server_configs:
2528
+ try:
2529
+ # Convert MCPServerConfig to Omni model
2530
+ # Only include fields that have actual values
2531
+ omni_config_data = {"name": server_config.name}
2532
+ if server_config.command is not None:
2533
+ omni_config_data["command"] = server_config.command
2534
+ if server_config.args is not None:
2535
+ omni_config_data["args"] = server_config.args
2536
+ if server_config.env:
2537
+ omni_config_data["env"] = server_config.env
2538
+ if server_config.url is not None:
2539
+ omni_config_data["url"] = server_config.url
2540
+ headers = getattr(server_config, "headers", None)
2541
+ if headers is not None:
2542
+ omni_config_data["headers"] = headers
2543
+
2544
+ omni_config = MCPServerConfigOmni(**omni_config_data)
2545
+
2546
+ # Convert to host-specific model
2547
+ host_config = host_model_class.from_omni(omni_config)
2548
+
2549
+ # Generate and display conversion report
2550
+ report = generate_conversion_report(
2551
+ operation="create",
2552
+ server_name=server_config.name,
2553
+ target_host=host_type,
2554
+ omni=omni_config,
2555
+ dry_run=False,
2556
+ )
2557
+ display_report(report)
2558
+
2559
+ result = mcp_manager.configure_server(
2560
+ hostname=host,
2561
+ server_config=host_config,
2562
+ no_backup=args.no_backup,
2563
+ )
2564
+
2565
+ if result.success:
2566
+ print(
2567
+ f"[SUCCESS] Successfully configured {server_config.name} ({pkg_name}) on {host}"
2568
+ )
2569
+ success_count += 1
2570
+
2571
+ # Update package metadata with host configuration tracking
2572
+ try:
2573
+ server_config_dict = {
2574
+ "name": server_config.name,
2575
+ "command": server_config.command,
2576
+ "args": server_config.args,
2577
+ }
2578
+
2579
+ env_manager.update_package_host_configuration(
2580
+ env_name=env_name,
2581
+ package_name=pkg_name,
2582
+ hostname=host,
2583
+ server_config=server_config_dict,
2584
+ )
2585
+ except Exception as e:
2586
+ # Log but don't fail the sync operation
2587
+ print(
2588
+ f"[WARNING] Failed to update package metadata for {pkg_name}: {e}"
2589
+ )
2590
+ else:
2591
+ print(
2592
+ f"[ERROR] Failed to configure {server_config.name} ({pkg_name}) on {host}: {result.error_message}"
2593
+ )
2594
+
2595
+ except Exception as e:
2596
+ print(
2597
+ f"[ERROR] Error configuring {server_config.name} ({pkg_name}) on {host}: {e}"
2598
+ )
2599
+
2600
+ except ValueError as e:
2601
+ print(f"✗ Invalid host '{host}': {e}")
2602
+ continue
2603
+
2604
+ # Report results
2605
+ if success_count == total_operations:
2606
+ package_desc = (
2607
+ f"package '{args.package_name}'"
2608
+ if len(server_configs) == 1
2609
+ else f"{len(server_configs)} packages"
2610
+ )
2611
+ print(
2612
+ f"Successfully synchronized {package_desc} to all {len(hosts)} host(s)"
2613
+ )
2614
+ return 0
2615
+ elif success_count > 0:
2616
+ print(
2617
+ f"Partially synchronized: {success_count}/{total_operations} operations succeeded"
2618
+ )
2619
+ return 1
2620
+ else:
2621
+ package_desc = (
2622
+ f"package '{args.package_name}'"
2623
+ if len(server_configs) == 1
2624
+ else f"{len(server_configs)} packages"
2625
+ )
2626
+ print(f"Failed to synchronize {package_desc} to any hosts")
2627
+ return 1
2628
+
2629
+ except ValueError as e:
2630
+ print(f"Error: {e}")
2631
+ return 1
2632
+
2633
+ else:
2634
+ parser.print_help()
2635
+ return 1
2636
+
2637
+ elif args.command == "mcp":
2638
+ if args.mcp_command == "discover":
2639
+ if args.discover_command == "hosts":
2640
+ return handle_mcp_discover_hosts()
2641
+ elif args.discover_command == "servers":
2642
+ return handle_mcp_discover_servers(env_manager, args.env)
2643
+ else:
2644
+ print("Unknown discover command")
2645
+ return 1
2646
+
2647
+ elif args.mcp_command == "list":
2648
+ if args.list_command == "hosts":
2649
+ return handle_mcp_list_hosts(env_manager, args.env, args.detailed)
2650
+ elif args.list_command == "servers":
2651
+ return handle_mcp_list_servers(env_manager, args.env)
2652
+ else:
2653
+ print("Unknown list command")
2654
+ return 1
2655
+
2656
+ elif args.mcp_command == "backup":
2657
+ if args.backup_command == "restore":
2658
+ return handle_mcp_backup_restore(
2659
+ env_manager,
2660
+ args.host,
2661
+ args.backup_file,
2662
+ args.dry_run,
2663
+ args.auto_approve,
2664
+ )
2665
+ elif args.backup_command == "list":
2666
+ return handle_mcp_backup_list(args.host, args.detailed)
2667
+ elif args.backup_command == "clean":
2668
+ return handle_mcp_backup_clean(
2669
+ args.host,
2670
+ args.older_than_days,
2671
+ args.keep_count,
2672
+ args.dry_run,
2673
+ args.auto_approve,
2674
+ )
2675
+ else:
2676
+ print("Unknown backup command")
2677
+ return 1
2678
+
2679
+ elif args.mcp_command == "configure":
2680
+ return handle_mcp_configure(
2681
+ args.host,
2682
+ args.server_name,
2683
+ args.server_command,
2684
+ args.args,
2685
+ getattr(args, "env_var", None),
2686
+ args.url,
2687
+ args.header,
2688
+ getattr(args, "timeout", None),
2689
+ getattr(args, "trust", False),
2690
+ getattr(args, "cwd", None),
2691
+ getattr(args, "env_file", None),
2692
+ getattr(args, "http_url", None),
2693
+ getattr(args, "include_tools", None),
2694
+ getattr(args, "exclude_tools", None),
2695
+ getattr(args, "input", None),
2696
+ args.no_backup,
2697
+ args.dry_run,
2698
+ args.auto_approve,
2699
+ )
2700
+
2701
+ elif args.mcp_command == "remove":
2702
+ if args.remove_command == "server":
2703
+ return handle_mcp_remove_server(
2704
+ env_manager,
2705
+ args.server_name,
2706
+ args.host,
2707
+ args.env,
2708
+ args.no_backup,
2709
+ args.dry_run,
2710
+ args.auto_approve,
2711
+ )
2712
+ elif args.remove_command == "host":
2713
+ return handle_mcp_remove_host(
2714
+ env_manager,
2715
+ args.host_name,
2716
+ args.no_backup,
2717
+ args.dry_run,
2718
+ args.auto_approve,
2719
+ )
2720
+ else:
2721
+ print("Unknown remove command")
2722
+ return 1
2723
+
2724
+ elif args.mcp_command == "sync":
2725
+ return handle_mcp_sync(
2726
+ from_env=getattr(args, "from_env", None),
2727
+ from_host=getattr(args, "from_host", None),
2728
+ to_hosts=args.to_host,
2729
+ servers=getattr(args, "servers", None),
2730
+ pattern=getattr(args, "pattern", None),
2731
+ dry_run=args.dry_run,
2732
+ auto_approve=args.auto_approve,
2733
+ no_backup=args.no_backup,
2734
+ )
2735
+
2736
+ else:
2737
+ print("Unknown MCP command")
2738
+ return 1
2739
+
2740
+ else:
2741
+ parser.print_help()
2742
+ return 1
2743
+
2744
+ return 0
2745
+
2746
+
2747
+ if __name__ == "__main__":
2748
+ sys.exit(main())