hatch-xclam 0.7.1.dev3__py3-none-any.whl → 0.8.0.dev1__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 (81) hide show
  1. hatch/__init__.py +1 -1
  2. hatch/cli/__init__.py +71 -0
  3. hatch/cli/__main__.py +1035 -0
  4. hatch/cli/cli_env.py +865 -0
  5. hatch/cli/cli_mcp.py +1965 -0
  6. hatch/cli/cli_package.py +566 -0
  7. hatch/cli/cli_system.py +136 -0
  8. hatch/cli/cli_utils.py +1289 -0
  9. hatch/cli_hatch.py +160 -2838
  10. hatch/mcp_host_config/__init__.py +10 -10
  11. hatch/mcp_host_config/adapters/__init__.py +34 -0
  12. hatch/mcp_host_config/adapters/base.py +170 -0
  13. hatch/mcp_host_config/adapters/claude.py +105 -0
  14. hatch/mcp_host_config/adapters/codex.py +104 -0
  15. hatch/mcp_host_config/adapters/cursor.py +83 -0
  16. hatch/mcp_host_config/adapters/gemini.py +75 -0
  17. hatch/mcp_host_config/adapters/kiro.py +78 -0
  18. hatch/mcp_host_config/adapters/lmstudio.py +79 -0
  19. hatch/mcp_host_config/adapters/registry.py +149 -0
  20. hatch/mcp_host_config/adapters/vscode.py +83 -0
  21. hatch/mcp_host_config/backup.py +5 -3
  22. hatch/mcp_host_config/fields.py +126 -0
  23. hatch/mcp_host_config/models.py +161 -456
  24. hatch/mcp_host_config/reporting.py +57 -16
  25. hatch/mcp_host_config/strategies.py +155 -87
  26. hatch/template_generator.py +1 -1
  27. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/METADATA +3 -2
  28. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/RECORD +52 -43
  29. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/WHEEL +1 -1
  30. hatch_xclam-0.8.0.dev1.dist-info/entry_points.txt +2 -0
  31. tests/cli_test_utils.py +280 -0
  32. tests/integration/cli/__init__.py +14 -0
  33. tests/integration/cli/test_cli_reporter_integration.py +2439 -0
  34. tests/integration/mcp/__init__.py +0 -0
  35. tests/integration/mcp/test_adapter_serialization.py +173 -0
  36. tests/regression/cli/__init__.py +16 -0
  37. tests/regression/cli/test_color_logic.py +268 -0
  38. tests/regression/cli/test_consequence_type.py +298 -0
  39. tests/regression/cli/test_error_formatting.py +328 -0
  40. tests/regression/cli/test_result_reporter.py +586 -0
  41. tests/regression/cli/test_table_formatter.py +211 -0
  42. tests/regression/mcp/__init__.py +0 -0
  43. tests/regression/mcp/test_field_filtering.py +162 -0
  44. tests/test_cli_version.py +7 -5
  45. tests/test_data/fixtures/cli_reporter_fixtures.py +184 -0
  46. tests/unit/__init__.py +0 -0
  47. tests/unit/mcp/__init__.py +0 -0
  48. tests/unit/mcp/test_adapter_protocol.py +138 -0
  49. tests/unit/mcp/test_adapter_registry.py +158 -0
  50. tests/unit/mcp/test_config_model.py +146 -0
  51. hatch_xclam-0.7.1.dev3.dist-info/entry_points.txt +0 -2
  52. tests/integration/test_mcp_kiro_integration.py +0 -153
  53. tests/regression/test_mcp_codex_backup_integration.py +0 -162
  54. tests/regression/test_mcp_codex_host_strategy.py +0 -163
  55. tests/regression/test_mcp_codex_model_validation.py +0 -117
  56. tests/regression/test_mcp_kiro_backup_integration.py +0 -241
  57. tests/regression/test_mcp_kiro_cli_integration.py +0 -141
  58. tests/regression/test_mcp_kiro_decorator_registration.py +0 -71
  59. tests/regression/test_mcp_kiro_host_strategy.py +0 -214
  60. tests/regression/test_mcp_kiro_model_validation.py +0 -116
  61. tests/regression/test_mcp_kiro_omni_conversion.py +0 -104
  62. tests/test_mcp_atomic_operations.py +0 -276
  63. tests/test_mcp_backup_integration.py +0 -308
  64. tests/test_mcp_cli_all_host_specific_args.py +0 -496
  65. tests/test_mcp_cli_backup_management.py +0 -295
  66. tests/test_mcp_cli_direct_management.py +0 -456
  67. tests/test_mcp_cli_discovery_listing.py +0 -582
  68. tests/test_mcp_cli_host_config_integration.py +0 -823
  69. tests/test_mcp_cli_package_management.py +0 -360
  70. tests/test_mcp_cli_partial_updates.py +0 -859
  71. tests/test_mcp_environment_integration.py +0 -520
  72. tests/test_mcp_host_config_backup.py +0 -257
  73. tests/test_mcp_host_configuration_manager.py +0 -331
  74. tests/test_mcp_host_registry_decorator.py +0 -348
  75. tests/test_mcp_pydantic_architecture_v4.py +0 -603
  76. tests/test_mcp_server_config_models.py +0 -242
  77. tests/test_mcp_server_config_type_field.py +0 -221
  78. tests/test_mcp_sync_functionality.py +0 -316
  79. tests/test_mcp_user_feedback_reporting.py +0 -359
  80. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/licenses/LICENSE +0 -0
  81. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/top_level.txt +0 -0
hatch/cli_hatch.py CHANGED
@@ -1,24 +1,112 @@
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
1
+ """Backward compatibility shim for Hatch CLI.
2
+
3
+ .. deprecated:: 0.7.2
4
+ This module is deprecated. Import from ``hatch.cli`` instead.
5
+ This shim will be removed in version 0.9.0.
6
+
7
+ This module re-exports all public symbols from the new hatch.cli package
8
+ to maintain backward compatibility for external consumers who import from
9
+ hatch.cli_hatch directly.
10
+
11
+ Migration Note:
12
+ New code should import from hatch.cli instead:
13
+
14
+ # Old (deprecated):
15
+ from hatch.cli_hatch import main, handle_mcp_configure
16
+
17
+ # New (preferred):
18
+ from hatch.cli import main
19
+ from hatch.cli.cli_mcp import handle_mcp_configure
20
+
21
+ Implementation Modules:
22
+ - hatch.cli.__main__: Entry point and argument parsing
23
+ - hatch.cli.cli_utils: Shared utilities and constants
24
+ - hatch.cli.cli_mcp: MCP host configuration handlers
25
+ - hatch.cli.cli_env: Environment management handlers
26
+ - hatch.cli.cli_package: Package management handlers
27
+ - hatch.cli.cli_system: System commands (create, validate)
28
+
29
+ Exported Symbols:
30
+ - main: CLI entry point
31
+ - All MCP handlers (handle_mcp_*)
32
+ - All utility functions (parse_*, request_confirmation, etc.)
33
+ - Exit code constants (EXIT_SUCCESS, EXIT_ERROR)
34
+ - HatchEnvironmentManager (re-exported for convenience)
8
35
  """
9
36
 
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
37
+ import warnings
38
+
39
+ warnings.warn(
40
+ "hatch.cli_hatch is deprecated since version 0.7.2. "
41
+ "Import from hatch.cli instead. "
42
+ "This module will be removed in version 0.9.0.",
43
+ DeprecationWarning,
44
+ stacklevel=2
45
+ )
46
+
47
+ # Re-export main entry point
48
+ from hatch.cli import main
49
+
50
+ # Re-export utilities
51
+ from hatch.cli.cli_utils import (
52
+ EXIT_SUCCESS,
53
+ EXIT_ERROR,
54
+ get_hatch_version,
55
+ request_confirmation,
56
+ parse_env_vars,
57
+ parse_header,
58
+ parse_input,
59
+ parse_host_list,
60
+ get_package_mcp_server_config,
61
+ )
62
+
63
+ # Re-export MCP handlers (for backward compatibility with tests)
64
+ from hatch.cli.cli_mcp import (
65
+ handle_mcp_discover_hosts,
66
+ handle_mcp_discover_servers,
67
+ handle_mcp_list_hosts,
68
+ handle_mcp_list_servers,
69
+ handle_mcp_show,
70
+ handle_mcp_backup_restore,
71
+ handle_mcp_backup_list,
72
+ handle_mcp_backup_clean,
73
+ handle_mcp_configure,
74
+ handle_mcp_remove,
75
+ handle_mcp_remove_server,
76
+ handle_mcp_remove_host,
77
+ handle_mcp_sync,
78
+ )
79
+
80
+ # Re-export environment handlers
81
+ from hatch.cli.cli_env import (
82
+ handle_env_create,
83
+ handle_env_remove,
84
+ handle_env_list,
85
+ handle_env_use,
86
+ handle_env_current,
87
+ handle_env_show,
88
+ handle_env_python_init,
89
+ handle_env_python_info,
90
+ handle_env_python_remove,
91
+ handle_env_python_shell,
92
+ handle_env_python_add_hatch_mcp,
93
+ )
18
94
 
19
- from hatch_validator import HatchPackageValidator
20
- from hatch_validator.package.package_service import PackageService
95
+ # Re-export package handlers
96
+ from hatch.cli.cli_package import (
97
+ handle_package_add,
98
+ handle_package_remove,
99
+ handle_package_list,
100
+ handle_package_sync,
101
+ )
102
+
103
+ # Re-export system handlers
104
+ from hatch.cli.cli_system import (
105
+ handle_create,
106
+ handle_validate,
107
+ )
21
108
 
109
+ # Re-export commonly used types for backward compatibility
22
110
  from hatch.environment_manager import HatchEnvironmentManager
23
111
  from hatch.mcp_host_config import (
24
112
  MCPHostConfigurationManager,
@@ -26,2825 +114,59 @@ from hatch.mcp_host_config import (
26
114
  MCPHostType,
27
115
  MCPServerConfig,
28
116
  )
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
- disabled: Optional[bool] = None,
717
- auto_approve_tools: Optional[list] = None,
718
- disable_tools: Optional[list] = None,
719
- env_vars: Optional[list] = None,
720
- startup_timeout: Optional[int] = None,
721
- tool_timeout: Optional[int] = None,
722
- enabled: Optional[bool] = None,
723
- bearer_token_env_var: Optional[str] = None,
724
- env_header: Optional[list] = None,
725
- no_backup: bool = False,
726
- dry_run: bool = False,
727
- auto_approve: bool = False,
728
- ):
729
- """Handle 'hatch mcp configure' command with ALL host-specific arguments.
730
-
731
- Host-specific arguments are accepted for all hosts. The reporting system will
732
- show unsupported fields as "UNSUPPORTED" in the conversion report rather than
733
- rejecting them upfront.
734
- """
735
- try:
736
- # Validate host type
737
- try:
738
- host_type = MCPHostType(host)
739
- except ValueError:
740
- print(
741
- f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}"
742
- )
743
- return 1
744
-
745
- # Validate Claude Desktop/Code transport restrictions (Issue 2)
746
- if host_type in (MCPHostType.CLAUDE_DESKTOP, MCPHostType.CLAUDE_CODE):
747
- if url is not None:
748
- print(
749
- f"Error: {host} does not support remote servers (--url). Only local servers with --command are supported."
750
- )
751
- return 1
752
-
753
- # Validate argument dependencies
754
- if command and header:
755
- print(
756
- "Error: --header can only be used with --url or --http-url (remote servers), not with --command (local servers)"
757
- )
758
- return 1
759
-
760
- if (url or http_url) and args:
761
- print(
762
- "Error: --args can only be used with --command (local servers), not with --url or --http-url (remote servers)"
763
- )
764
- return 1
765
-
766
- # NOTE: We do NOT validate host-specific arguments here.
767
- # The reporting system will show unsupported fields as "UNSUPPORTED" in the conversion report.
768
- # This allows users to see which fields are not supported by their target host without blocking the operation.
769
-
770
- # Check if server exists (for partial update support)
771
- manager = MCPHostConfigurationManager()
772
- existing_config = manager.get_server_config(host, server_name)
773
- is_update = existing_config is not None
774
-
775
- # Conditional validation: Create requires command OR url OR http_url, update does not
776
- if not is_update:
777
- # Create operation: require command, url, or http_url
778
- if not command and not url and not http_url:
779
- print(
780
- 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)"
781
- )
782
- return 1
783
-
784
- # Parse environment variables, headers, and inputs
785
- env_dict = parse_env_vars(env)
786
- headers_dict = parse_header(header)
787
- inputs_list = parse_input(input)
788
-
789
- # Create Omni configuration (universal model)
790
- # Only include fields that have actual values to ensure model_dump(exclude_unset=True) works correctly
791
- omni_config_data = {"name": server_name}
792
-
793
- if command is not None:
794
- omni_config_data["command"] = command
795
- if args is not None:
796
- # Process args with shlex.split() to handle quoted strings (Issue 4)
797
- processed_args = []
798
- for arg in args:
799
- if arg: # Skip empty strings
800
- try:
801
- # Split quoted strings into individual arguments
802
- split_args = shlex.split(arg)
803
- processed_args.extend(split_args)
804
- except ValueError as e:
805
- # Handle invalid quotes gracefully
806
- print(f"Warning: Invalid quote in argument '{arg}': {e}")
807
- processed_args.append(arg)
808
- omni_config_data["args"] = processed_args if processed_args else None
809
- if env_dict:
810
- omni_config_data["env"] = env_dict
811
- if url is not None:
812
- omni_config_data["url"] = url
813
- if headers_dict:
814
- omni_config_data["headers"] = headers_dict
815
-
816
- # Host-specific fields (Gemini)
817
- if timeout is not None:
818
- omni_config_data["timeout"] = timeout
819
- if trust:
820
- omni_config_data["trust"] = trust
821
- if cwd is not None:
822
- omni_config_data["cwd"] = cwd
823
- if http_url is not None:
824
- omni_config_data["httpUrl"] = http_url
825
- if include_tools is not None:
826
- omni_config_data["includeTools"] = include_tools
827
- if exclude_tools is not None:
828
- omni_config_data["excludeTools"] = exclude_tools
829
-
830
- # Host-specific fields (Cursor/VS Code/LM Studio)
831
- if env_file is not None:
832
- omni_config_data["envFile"] = env_file
833
-
834
- # Host-specific fields (VS Code)
835
- if inputs_list is not None:
836
- omni_config_data["inputs"] = inputs_list
837
-
838
- # Host-specific fields (Kiro)
839
- if disabled is not None:
840
- omni_config_data["disabled"] = disabled
841
- if auto_approve_tools is not None:
842
- omni_config_data["autoApprove"] = auto_approve_tools
843
- if disable_tools is not None:
844
- omni_config_data["disabledTools"] = disable_tools
845
-
846
- # Host-specific fields (Codex)
847
- if env_vars is not None:
848
- omni_config_data["env_vars"] = env_vars
849
- if startup_timeout is not None:
850
- omni_config_data["startup_timeout_sec"] = startup_timeout
851
- if tool_timeout is not None:
852
- omni_config_data["tool_timeout_sec"] = tool_timeout
853
- if enabled is not None:
854
- omni_config_data["enabled"] = enabled
855
- if bearer_token_env_var is not None:
856
- omni_config_data["bearer_token_env_var"] = bearer_token_env_var
857
- if env_header is not None:
858
- # Parse KEY=ENV_VAR_NAME format into dict
859
- env_http_headers = {}
860
- for header_spec in env_header:
861
- if '=' in header_spec:
862
- key, env_var_name = header_spec.split('=', 1)
863
- env_http_headers[key] = env_var_name
864
- if env_http_headers:
865
- omni_config_data["env_http_headers"] = env_http_headers
866
-
867
- # Partial update merge logic
868
- if is_update:
869
- # Merge with existing configuration
870
- existing_data = existing_config.model_dump(
871
- exclude_unset=True, exclude={"name"}
872
- )
873
-
874
- # Handle command/URL/httpUrl switching behavior
875
- # If switching from command to URL or httpUrl: clear command-based fields
876
- if (
877
- url is not None or http_url is not None
878
- ) and existing_config.command is not None:
879
- existing_data.pop("command", None)
880
- existing_data.pop("args", None)
881
- existing_data.pop(
882
- "type", None
883
- ) # Clear type field when switching transports (Issue 1)
884
-
885
- # If switching from URL/httpUrl to command: clear URL-based fields
886
- if command is not None and (
887
- existing_config.url is not None
888
- or getattr(existing_config, "httpUrl", None) is not None
889
- ):
890
- existing_data.pop("url", None)
891
- existing_data.pop("httpUrl", None)
892
- existing_data.pop("headers", None)
893
- existing_data.pop(
894
- "type", None
895
- ) # Clear type field when switching transports (Issue 1)
896
-
897
- # Merge: new values override existing values
898
- merged_data = {**existing_data, **omni_config_data}
899
- omni_config_data = merged_data
900
-
901
- # Create Omni model
902
- omni_config = MCPServerConfigOmni(**omni_config_data)
903
-
904
- # Convert to host-specific model using HOST_MODEL_REGISTRY
905
- host_model_class = HOST_MODEL_REGISTRY.get(host_type)
906
- if not host_model_class:
907
- print(f"Error: No model registered for host '{host}'")
908
- return 1
909
-
910
- # Convert Omni to host-specific model
911
- server_config = host_model_class.from_omni(omni_config)
912
-
913
- # Generate conversion report
914
- report = generate_conversion_report(
915
- operation="update" if is_update else "create",
916
- server_name=server_name,
917
- target_host=host_type,
918
- omni=omni_config,
919
- old_config=existing_config if is_update else None,
920
- dry_run=dry_run,
921
- )
922
-
923
- # Display conversion report
924
- if dry_run:
925
- print(
926
- f"[DRY RUN] Would configure MCP server '{server_name}' on host '{host}':"
927
- )
928
- print(f"[DRY RUN] Command: {command}")
929
- if args:
930
- print(f"[DRY RUN] Args: {args}")
931
- if env_dict:
932
- print(f"[DRY RUN] Environment: {env_dict}")
933
- if url:
934
- print(f"[DRY RUN] URL: {url}")
935
- if headers_dict:
936
- print(f"[DRY RUN] Headers: {headers_dict}")
937
- print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
938
- # Display report in dry-run mode
939
- display_report(report)
940
- return 0
941
-
942
- # Display report before confirmation
943
- display_report(report)
944
-
945
- # Confirm operation unless auto-approved
946
- if not request_confirmation(
947
- f"Configure MCP server '{server_name}' on host '{host}'?", auto_approve
948
- ):
949
- print("Operation cancelled.")
950
- return 0
951
-
952
- # Perform configuration
953
- mcp_manager = MCPHostConfigurationManager()
954
- result = mcp_manager.configure_server(
955
- server_config=server_config, hostname=host, no_backup=no_backup
956
- )
957
-
958
- if result.success:
959
- print(
960
- f"[SUCCESS] Successfully configured MCP server '{server_name}' on host '{host}'"
961
- )
962
- if result.backup_path:
963
- print(f" Backup created: {result.backup_path}")
964
- return 0
965
- else:
966
- print(
967
- f"[ERROR] Failed to configure MCP server '{server_name}' on host '{host}': {result.error_message}"
968
- )
969
- return 1
970
-
971
- except Exception as e:
972
- print(f"Error configuring MCP server: {e}")
973
- return 1
974
-
975
-
976
- def handle_mcp_remove(
977
- host: str,
978
- server_name: str,
979
- no_backup: bool = False,
980
- dry_run: bool = False,
981
- auto_approve: bool = False,
982
- ):
983
- """Handle 'hatch mcp remove' command."""
984
- try:
985
- # Validate host type
986
- try:
987
- host_type = MCPHostType(host)
988
- except ValueError:
989
- print(
990
- f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}"
991
- )
992
- return 1
993
-
994
- if dry_run:
995
- print(
996
- f"[DRY RUN] Would remove MCP server '{server_name}' from host '{host}'"
997
- )
998
- print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
999
- return 0
1000
-
1001
- # Confirm operation unless auto-approved
1002
- if not request_confirmation(
1003
- f"Remove MCP server '{server_name}' from host '{host}'?", auto_approve
1004
- ):
1005
- print("Operation cancelled.")
1006
- return 0
1007
-
1008
- # Perform removal
1009
- mcp_manager = MCPHostConfigurationManager()
1010
- result = mcp_manager.remove_server(
1011
- server_name=server_name, hostname=host, no_backup=no_backup
1012
- )
1013
-
1014
- if result.success:
1015
- print(
1016
- f"[SUCCESS] Successfully removed MCP server '{server_name}' from host '{host}'"
1017
- )
1018
- if result.backup_path:
1019
- print(f" Backup created: {result.backup_path}")
1020
- return 0
1021
- else:
1022
- print(
1023
- f"[ERROR] Failed to remove MCP server '{server_name}' from host '{host}': {result.error_message}"
1024
- )
1025
- return 1
1026
-
1027
- except Exception as e:
1028
- print(f"Error removing MCP server: {e}")
1029
- return 1
1030
-
1031
-
1032
- def parse_host_list(host_arg: str) -> List[str]:
1033
- """Parse comma-separated host list or 'all'."""
1034
- if not host_arg:
1035
- return []
1036
-
1037
- if host_arg.lower() == "all":
1038
- from hatch.mcp_host_config.host_management import MCPHostRegistry
1039
-
1040
- available_hosts = MCPHostRegistry.detect_available_hosts()
1041
- return [host.value for host in available_hosts]
1042
-
1043
- hosts = []
1044
- for host_str in host_arg.split(","):
1045
- host_str = host_str.strip()
1046
- try:
1047
- host_type = MCPHostType(host_str)
1048
- hosts.append(host_type.value)
1049
- except ValueError:
1050
- available = [h.value for h in MCPHostType]
1051
- raise ValueError(f"Unknown host '{host_str}'. Available: {available}")
1052
-
1053
- return hosts
1054
-
1055
-
1056
- def handle_mcp_remove_server(
1057
- env_manager: HatchEnvironmentManager,
1058
- server_name: str,
1059
- hosts: Optional[str] = None,
1060
- env: Optional[str] = None,
1061
- no_backup: bool = False,
1062
- dry_run: bool = False,
1063
- auto_approve: bool = False,
1064
- ):
1065
- """Handle 'hatch mcp remove server' command."""
1066
- try:
1067
- # Determine target hosts
1068
- if hosts:
1069
- target_hosts = parse_host_list(hosts)
1070
- elif env:
1071
- # TODO: Implement environment-based server removal
1072
- print("Error: Environment-based removal not yet implemented")
1073
- return 1
1074
- else:
1075
- print("Error: Must specify either --host or --env")
1076
- return 1
1077
-
1078
- if not target_hosts:
1079
- print("Error: No valid hosts specified")
1080
- return 1
1081
-
1082
- if dry_run:
1083
- print(
1084
- f"[DRY RUN] Would remove MCP server '{server_name}' from hosts: {', '.join(target_hosts)}"
1085
- )
1086
- print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
1087
- return 0
1088
-
1089
- # Confirm operation unless auto-approved
1090
- hosts_str = ", ".join(target_hosts)
1091
- if not request_confirmation(
1092
- f"Remove MCP server '{server_name}' from hosts: {hosts_str}?", auto_approve
1093
- ):
1094
- print("Operation cancelled.")
1095
- return 0
1096
-
1097
- # Perform removal on each host
1098
- mcp_manager = MCPHostConfigurationManager()
1099
- success_count = 0
1100
- total_count = len(target_hosts)
1101
-
1102
- for host in target_hosts:
1103
- result = mcp_manager.remove_server(
1104
- server_name=server_name, hostname=host, no_backup=no_backup
1105
- )
1106
-
1107
- if result.success:
1108
- print(f"[SUCCESS] Successfully removed '{server_name}' from '{host}'")
1109
- if result.backup_path:
1110
- print(f" Backup created: {result.backup_path}")
1111
- success_count += 1
1112
-
1113
- # Update environment tracking for current environment only
1114
- current_env = env_manager.get_current_environment()
1115
- if current_env:
1116
- env_manager.remove_package_host_configuration(
1117
- current_env, server_name, host
1118
- )
1119
- else:
1120
- print(
1121
- f"[ERROR] Failed to remove '{server_name}' from '{host}': {result.error_message}"
1122
- )
1123
-
1124
- # Summary
1125
- if success_count == total_count:
1126
- print(f"[SUCCESS] Removed '{server_name}' from all {total_count} hosts")
1127
- return 0
1128
- elif success_count > 0:
1129
- print(
1130
- f"[PARTIAL SUCCESS] Removed '{server_name}' from {success_count}/{total_count} hosts"
1131
- )
1132
- return 1
1133
- else:
1134
- print(f"[ERROR] Failed to remove '{server_name}' from any hosts")
1135
- return 1
1136
-
1137
- except Exception as e:
1138
- print(f"Error removing MCP server: {e}")
1139
- return 1
1140
-
1141
-
1142
- def handle_mcp_remove_host(
1143
- env_manager: HatchEnvironmentManager,
1144
- host_name: str,
1145
- no_backup: bool = False,
1146
- dry_run: bool = False,
1147
- auto_approve: bool = False,
1148
- ):
1149
- """Handle 'hatch mcp remove host' command."""
1150
- try:
1151
- # Validate host type
1152
- try:
1153
- host_type = MCPHostType(host_name)
1154
- except ValueError:
1155
- print(
1156
- f"Error: Invalid host '{host_name}'. Supported hosts: {[h.value for h in MCPHostType]}"
1157
- )
1158
- return 1
1159
-
1160
- if dry_run:
1161
- print(f"[DRY RUN] Would remove entire host configuration for '{host_name}'")
1162
- print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
1163
- return 0
1164
-
1165
- # Confirm operation unless auto-approved
1166
- if not request_confirmation(
1167
- f"Remove entire host configuration for '{host_name}'? This will remove ALL MCP servers from this host.",
1168
- auto_approve,
1169
- ):
1170
- print("Operation cancelled.")
1171
- return 0
1172
-
1173
- # Perform host configuration removal
1174
- mcp_manager = MCPHostConfigurationManager()
1175
- result = mcp_manager.remove_host_configuration(
1176
- hostname=host_name, no_backup=no_backup
1177
- )
1178
-
1179
- if result.success:
1180
- print(
1181
- f"[SUCCESS] Successfully removed host configuration for '{host_name}'"
1182
- )
1183
- if result.backup_path:
1184
- print(f" Backup created: {result.backup_path}")
1185
-
1186
- # Update environment tracking across all environments
1187
- updates_count = env_manager.clear_host_from_all_packages_all_envs(host_name)
1188
- if updates_count > 0:
1189
- print(f"Updated {updates_count} package entries across environments")
1190
-
1191
- return 0
1192
- else:
1193
- print(
1194
- f"[ERROR] Failed to remove host configuration for '{host_name}': {result.error_message}"
1195
- )
1196
- return 1
1197
-
1198
- except Exception as e:
1199
- print(f"Error removing host configuration: {e}")
1200
- return 1
1201
-
1202
-
1203
- def handle_mcp_sync(
1204
- from_env: Optional[str] = None,
1205
- from_host: Optional[str] = None,
1206
- to_hosts: Optional[str] = None,
1207
- servers: Optional[str] = None,
1208
- pattern: Optional[str] = None,
1209
- dry_run: bool = False,
1210
- auto_approve: bool = False,
1211
- no_backup: bool = False,
1212
- ) -> int:
1213
- """Handle 'hatch mcp sync' command."""
1214
- try:
1215
- # Parse target hosts
1216
- if not to_hosts:
1217
- print("Error: Must specify --to-host")
1218
- return 1
1219
-
1220
- target_hosts = parse_host_list(to_hosts)
1221
-
1222
- # Parse server filters
1223
- server_list = None
1224
- if servers:
1225
- server_list = [s.strip() for s in servers.split(",") if s.strip()]
1226
-
1227
- if dry_run:
1228
- source_desc = (
1229
- f"environment '{from_env}'" if from_env else f"host '{from_host}'"
1230
- )
1231
- target_desc = f"hosts: {', '.join(target_hosts)}"
1232
- print(f"[DRY RUN] Would synchronize from {source_desc} to {target_desc}")
1233
-
1234
- if server_list:
1235
- print(f"[DRY RUN] Server filter: {', '.join(server_list)}")
1236
- elif pattern:
1237
- print(f"[DRY RUN] Pattern filter: {pattern}")
1238
-
1239
- print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
1240
- return 0
1241
-
1242
- # Confirm operation unless auto-approved
1243
- source_desc = f"environment '{from_env}'" if from_env else f"host '{from_host}'"
1244
- target_desc = f"{len(target_hosts)} host(s)"
1245
- if not request_confirmation(
1246
- f"Synchronize MCP configurations from {source_desc} to {target_desc}?",
1247
- auto_approve,
1248
- ):
1249
- print("Operation cancelled.")
1250
- return 0
1251
-
1252
- # Perform synchronization
1253
- mcp_manager = MCPHostConfigurationManager()
1254
- result = mcp_manager.sync_configurations(
1255
- from_env=from_env,
1256
- from_host=from_host,
1257
- to_hosts=target_hosts,
1258
- servers=server_list,
1259
- pattern=pattern,
1260
- no_backup=no_backup,
1261
- )
1262
-
1263
- if result.success:
1264
- print(f"[SUCCESS] Synchronization completed")
1265
- print(f" Servers synced: {result.servers_synced}")
1266
- print(f" Hosts updated: {result.hosts_updated}")
1267
-
1268
- # Show detailed results
1269
- for res in result.results:
1270
- if res.success:
1271
- backup_info = (
1272
- f" (backup: {res.backup_path})" if res.backup_path else ""
1273
- )
1274
- print(f" ✓ {res.hostname}{backup_info}")
1275
- else:
1276
- print(f" ✗ {res.hostname}: {res.error_message}")
1277
-
1278
- return 0
1279
- else:
1280
- print(f"[ERROR] Synchronization failed")
1281
- for res in result.results:
1282
- if not res.success:
1283
- print(f" ✗ {res.hostname}: {res.error_message}")
1284
- return 1
1285
-
1286
- except ValueError as e:
1287
- print(f"Error: {e}")
1288
- return 1
1289
- except Exception as e:
1290
- print(f"Error during synchronization: {e}")
1291
- return 1
1292
-
1293
-
1294
- def main():
1295
- """Main entry point for Hatch CLI.
1296
-
1297
- Parses command-line arguments and executes the requested commands for:
1298
- - Package template creation
1299
- - Package validation
1300
- - Environment management (create, remove, list, use, current)
1301
- - Package management (add, remove, list)
1302
-
1303
- Returns:
1304
- int: Exit code (0 for success, 1 for errors)
1305
- """
1306
- # Configure logging
1307
- logging.basicConfig(
1308
- level=logging.INFO,
1309
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
1310
- )
1311
-
1312
- # Create argument parser
1313
- parser = argparse.ArgumentParser(description="Hatch package manager CLI")
1314
-
1315
- # Add version argument
1316
- parser.add_argument(
1317
- "--version", action="version", version=f"%(prog)s {get_hatch_version()}"
1318
- )
1319
-
1320
- subparsers = parser.add_subparsers(dest="command", help="Command to execute")
1321
-
1322
- # Create template command
1323
- create_parser = subparsers.add_parser(
1324
- "create", help="Create a new package template"
1325
- )
1326
- create_parser.add_argument("name", help="Package name")
1327
- create_parser.add_argument(
1328
- "--dir", "-d", default=".", help="Target directory (default: current directory)"
1329
- )
1330
- create_parser.add_argument(
1331
- "--description", "-D", default="", help="Package description"
1332
- )
1333
-
1334
- # Validate package command
1335
- validate_parser = subparsers.add_parser("validate", help="Validate a package")
1336
- validate_parser.add_argument("package_dir", help="Path to package directory")
1337
-
1338
- # Environment management commands
1339
- env_subparsers = subparsers.add_parser(
1340
- "env", help="Environment management commands"
1341
- ).add_subparsers(dest="env_command", help="Environment command to execute")
1342
-
1343
- # Create environment command
1344
- env_create_parser = env_subparsers.add_parser(
1345
- "create", help="Create a new environment"
1346
- )
1347
- env_create_parser.add_argument("name", help="Environment name")
1348
- env_create_parser.add_argument(
1349
- "--description", "-D", default="", help="Environment description"
1350
- )
1351
- env_create_parser.add_argument(
1352
- "--python-version", help="Python version for the environment (e.g., 3.11, 3.12)"
1353
- )
1354
- env_create_parser.add_argument(
1355
- "--no-python",
1356
- action="store_true",
1357
- help="Don't create a Python environment using conda/mamba",
1358
- )
1359
- env_create_parser.add_argument(
1360
- "--no-hatch-mcp-server",
1361
- action="store_true",
1362
- help="Don't install hatch_mcp_server wrapper in the new environment",
1363
- )
1364
- env_create_parser.add_argument(
1365
- "--hatch_mcp_server_tag",
1366
- help="Git tag/branch reference for hatch_mcp_server wrapper installation (e.g., 'dev', 'v0.1.0')",
1367
- )
1368
-
1369
- # Remove environment command
1370
- env_remove_parser = env_subparsers.add_parser(
1371
- "remove", help="Remove an environment"
1372
- )
1373
- env_remove_parser.add_argument("name", help="Environment name")
1374
-
1375
- # List environments command
1376
- env_subparsers.add_parser("list", help="List all available environments")
1377
-
1378
- # Set current environment command
1379
- env_use_parser = env_subparsers.add_parser(
1380
- "use", help="Set the current environment"
1381
- )
1382
- env_use_parser.add_argument("name", help="Environment name")
1383
-
1384
- # Show current environment command
1385
- env_subparsers.add_parser("current", help="Show the current environment")
1386
-
1387
- # Python environment management commands - advanced subcommands
1388
- env_python_subparsers = env_subparsers.add_parser(
1389
- "python", help="Manage Python environments"
1390
- ).add_subparsers(
1391
- dest="python_command", help="Python environment command to execute"
1392
- )
1393
-
1394
- # Initialize Python environment
1395
- python_init_parser = env_python_subparsers.add_parser(
1396
- "init", help="Initialize Python environment"
1397
- )
1398
- python_init_parser.add_argument(
1399
- "--hatch_env",
1400
- default=None,
1401
- help="Hatch environment name in which the Python environment is located (default: current environment)",
1402
- )
1403
- python_init_parser.add_argument(
1404
- "--python-version", help="Python version (e.g., 3.11, 3.12)"
1405
- )
1406
- python_init_parser.add_argument(
1407
- "--force", action="store_true", help="Force recreation if exists"
1408
- )
1409
- python_init_parser.add_argument(
1410
- "--no-hatch-mcp-server",
1411
- action="store_true",
1412
- help="Don't install hatch_mcp_server wrapper in the Python environment",
1413
- )
1414
- python_init_parser.add_argument(
1415
- "--hatch_mcp_server_tag",
1416
- help="Git tag/branch reference for hatch_mcp_server wrapper installation (e.g., 'dev', 'v0.1.0')",
1417
- )
1418
-
1419
- # Show Python environment info
1420
- python_info_parser = env_python_subparsers.add_parser(
1421
- "info", help="Show Python environment information"
1422
- )
1423
- python_info_parser.add_argument(
1424
- "--hatch_env",
1425
- default=None,
1426
- help="Hatch environment name in which the Python environment is located (default: current environment)",
1427
- )
1428
- python_info_parser.add_argument(
1429
- "--detailed", action="store_true", help="Show detailed diagnostics"
1430
- )
1431
-
1432
- # Hatch MCP server wrapper management commands
1433
- hatch_mcp_parser = env_python_subparsers.add_parser(
1434
- "add-hatch-mcp", help="Add hatch_mcp_server wrapper to the environment"
1435
- )
1436
- ## Install MCP server command
1437
- hatch_mcp_parser.add_argument(
1438
- "--hatch_env",
1439
- default=None,
1440
- help="Hatch environment name. It must possess a valid Python environment. (default: current environment)",
1441
- )
1442
- hatch_mcp_parser.add_argument(
1443
- "--tag",
1444
- default=None,
1445
- help="Git tag/branch reference for wrapper installation (e.g., 'dev', 'v0.1.0')",
1446
- )
1447
-
1448
- # Remove Python environment
1449
- python_remove_parser = env_python_subparsers.add_parser(
1450
- "remove", help="Remove Python environment"
1451
- )
1452
- python_remove_parser.add_argument(
1453
- "--hatch_env",
1454
- default=None,
1455
- help="Hatch environment name in which the Python environment is located (default: current environment)",
1456
- )
1457
- python_remove_parser.add_argument(
1458
- "--force", action="store_true", help="Force removal without confirmation"
1459
- )
1460
-
1461
- # Launch Python shell
1462
- python_shell_parser = env_python_subparsers.add_parser(
1463
- "shell", help="Launch Python shell in environment"
1464
- )
1465
- python_shell_parser.add_argument(
1466
- "--hatch_env",
1467
- default=None,
1468
- help="Hatch environment name in which the Python environment is located (default: current environment)",
1469
- )
1470
- python_shell_parser.add_argument(
1471
- "--cmd", help="Command to run in the shell (optional)"
1472
- )
1473
-
1474
- # MCP host configuration commands
1475
- mcp_subparsers = subparsers.add_parser(
1476
- "mcp", help="MCP host configuration commands"
1477
- ).add_subparsers(dest="mcp_command", help="MCP command to execute")
1478
-
1479
- # MCP discovery commands
1480
- mcp_discover_subparsers = mcp_subparsers.add_parser(
1481
- "discover", help="Discover MCP hosts and servers"
1482
- ).add_subparsers(dest="discover_command", help="Discovery command to execute")
1483
-
1484
- # Discover hosts command
1485
- mcp_discover_hosts_parser = mcp_discover_subparsers.add_parser(
1486
- "hosts", help="Discover available MCP host platforms"
1487
- )
1488
-
1489
- # Discover servers command
1490
- mcp_discover_servers_parser = mcp_discover_subparsers.add_parser(
1491
- "servers", help="Discover configured MCP servers"
1492
- )
1493
- mcp_discover_servers_parser.add_argument(
1494
- "--env",
1495
- "-e",
1496
- default=None,
1497
- help="Environment name (default: current environment)",
1498
- )
1499
-
1500
- # MCP list commands
1501
- mcp_list_subparsers = mcp_subparsers.add_parser(
1502
- "list", help="List MCP hosts and servers"
1503
- ).add_subparsers(dest="list_command", help="List command to execute")
1504
-
1505
- # List hosts command
1506
- mcp_list_hosts_parser = mcp_list_subparsers.add_parser(
1507
- "hosts", help="List configured MCP hosts from environment"
1508
- )
1509
- mcp_list_hosts_parser.add_argument(
1510
- "--env",
1511
- "-e",
1512
- default=None,
1513
- help="Environment name (default: current environment)",
1514
- )
1515
- mcp_list_hosts_parser.add_argument(
1516
- "--detailed",
1517
- action="store_true",
1518
- help="Show detailed host configuration information",
1519
- )
1520
-
1521
- # List servers command
1522
- mcp_list_servers_parser = mcp_list_subparsers.add_parser(
1523
- "servers", help="List configured MCP servers from environment"
1524
- )
1525
- mcp_list_servers_parser.add_argument(
1526
- "--env",
1527
- "-e",
1528
- default=None,
1529
- help="Environment name (default: current environment)",
1530
- )
1531
-
1532
- # MCP backup commands
1533
- mcp_backup_subparsers = mcp_subparsers.add_parser(
1534
- "backup", help="Backup management commands"
1535
- ).add_subparsers(dest="backup_command", help="Backup command to execute")
1536
-
1537
- # Restore backup command
1538
- mcp_backup_restore_parser = mcp_backup_subparsers.add_parser(
1539
- "restore", help="Restore MCP host configuration from backup"
1540
- )
1541
- mcp_backup_restore_parser.add_argument(
1542
- "host", help="Host platform to restore (e.g., claude-desktop, cursor)"
1543
- )
1544
- mcp_backup_restore_parser.add_argument(
1545
- "--backup-file",
1546
- "-f",
1547
- default=None,
1548
- help="Specific backup file to restore (default: latest)",
1549
- )
1550
- mcp_backup_restore_parser.add_argument(
1551
- "--dry-run",
1552
- action="store_true",
1553
- help="Preview restore operation without execution",
1554
- )
1555
- mcp_backup_restore_parser.add_argument(
1556
- "--auto-approve", action="store_true", help="Skip confirmation prompts"
1557
- )
1558
-
1559
- # List backups command
1560
- mcp_backup_list_parser = mcp_backup_subparsers.add_parser(
1561
- "list", help="List available backups for MCP host"
1562
- )
1563
- mcp_backup_list_parser.add_argument(
1564
- "host", help="Host platform to list backups for (e.g., claude-desktop, cursor)"
1565
- )
1566
- mcp_backup_list_parser.add_argument(
1567
- "--detailed", "-d", action="store_true", help="Show detailed backup information"
1568
- )
1569
-
1570
- # Clean backups command
1571
- mcp_backup_clean_parser = mcp_backup_subparsers.add_parser(
1572
- "clean", help="Clean old backups based on criteria"
1573
- )
1574
- mcp_backup_clean_parser.add_argument(
1575
- "host", help="Host platform to clean backups for (e.g., claude-desktop, cursor)"
1576
- )
1577
- mcp_backup_clean_parser.add_argument(
1578
- "--older-than-days", type=int, help="Remove backups older than specified days"
1579
- )
1580
- mcp_backup_clean_parser.add_argument(
1581
- "--keep-count",
1582
- type=int,
1583
- help="Keep only the specified number of newest backups",
1584
- )
1585
- mcp_backup_clean_parser.add_argument(
1586
- "--dry-run",
1587
- action="store_true",
1588
- help="Preview cleanup operation without execution",
1589
- )
1590
- mcp_backup_clean_parser.add_argument(
1591
- "--auto-approve", action="store_true", help="Skip confirmation prompts"
1592
- )
1593
-
1594
- # MCP direct management commands
1595
- mcp_configure_parser = mcp_subparsers.add_parser(
1596
- "configure", help="Configure MCP server directly on host"
1597
- )
1598
- mcp_configure_parser.add_argument(
1599
- "server_name", help="Name for the MCP server [hosts: all]"
1600
- )
1601
- mcp_configure_parser.add_argument(
1602
- "--host",
1603
- required=True,
1604
- help="Host platform to configure (e.g., claude-desktop, cursor) [hosts: all]",
1605
- )
1606
-
1607
- # Create mutually exclusive group for server type
1608
- server_type_group = mcp_configure_parser.add_mutually_exclusive_group()
1609
- server_type_group.add_argument(
1610
- "--command",
1611
- dest="server_command",
1612
- help="Command to execute the MCP server (for local servers) [hosts: all]",
1613
- )
1614
- server_type_group.add_argument(
1615
- "--url", help="Server URL for remote MCP servers (SSE transport) [hosts: all except claude-desktop, claude-code]"
1616
- )
1617
- server_type_group.add_argument(
1618
- "--http-url", help="HTTP streaming endpoint URL [hosts: gemini]"
1619
- )
1620
-
1621
- mcp_configure_parser.add_argument(
1622
- "--args",
1623
- nargs="*",
1624
- help="Arguments for the MCP server command (only with --command) [hosts: all]",
1625
- )
1626
- mcp_configure_parser.add_argument(
1627
- "--env-var",
1628
- action="append",
1629
- help="Environment variables (format: KEY=VALUE) [hosts: all]",
1630
- )
1631
- mcp_configure_parser.add_argument(
1632
- "--header",
1633
- action="append",
1634
- help="HTTP headers for remote servers (format: KEY=VALUE, only with --url) [hosts: all except claude-desktop, claude-code]",
1635
- )
1636
-
1637
- # Host-specific arguments (Gemini)
1638
- mcp_configure_parser.add_argument(
1639
- "--timeout", type=int, help="Request timeout in milliseconds [hosts: gemini]"
1640
- )
1641
- mcp_configure_parser.add_argument(
1642
- "--trust", action="store_true", help="Bypass tool call confirmations [hosts: gemini]"
1643
- )
1644
- mcp_configure_parser.add_argument(
1645
- "--cwd", help="Working directory for stdio transport [hosts: gemini, codex]"
1646
- )
1647
- mcp_configure_parser.add_argument(
1648
- "--include-tools",
1649
- nargs="*",
1650
- help="Tool allowlist / enabled tools [hosts: gemini, codex]",
1651
- )
1652
- mcp_configure_parser.add_argument(
1653
- "--exclude-tools",
1654
- nargs="*",
1655
- help="Tool blocklist / disabled tools [hosts: gemini, codex]",
1656
- )
1657
-
1658
- # Host-specific arguments (Cursor/VS Code/LM Studio)
1659
- mcp_configure_parser.add_argument(
1660
- "--env-file", help="Path to environment file [hosts: cursor, vscode, lmstudio]"
1661
- )
1662
-
1663
- # Host-specific arguments (VS Code)
1664
- mcp_configure_parser.add_argument(
1665
- "--input",
1666
- action="append",
1667
- help="Input variable definitions in format: type,id,description[,password=true] [hosts: vscode]",
1668
- )
1669
-
1670
- # Host-specific arguments (Kiro)
1671
- mcp_configure_parser.add_argument(
1672
- "--disabled",
1673
- action="store_true",
1674
- default=None,
1675
- help="Disable the MCP server [hosts: kiro]"
1676
- )
1677
- mcp_configure_parser.add_argument(
1678
- "--auto-approve-tools",
1679
- action="append",
1680
- help="Tool names to auto-approve without prompting [hosts: kiro]"
1681
- )
1682
- mcp_configure_parser.add_argument(
1683
- "--disable-tools",
1684
- action="append",
1685
- help="Tool names to disable [hosts: kiro]"
1686
- )
1687
-
1688
- # Codex-specific arguments
1689
- mcp_configure_parser.add_argument(
1690
- "--env-vars",
1691
- action="append",
1692
- help="Environment variable names to whitelist/forward [hosts: codex]"
1693
- )
1694
- mcp_configure_parser.add_argument(
1695
- "--startup-timeout",
1696
- type=int,
1697
- help="Server startup timeout in seconds (default: 10) [hosts: codex]"
1698
- )
1699
- mcp_configure_parser.add_argument(
1700
- "--tool-timeout",
1701
- type=int,
1702
- help="Tool execution timeout in seconds (default: 60) [hosts: codex]"
1703
- )
1704
- mcp_configure_parser.add_argument(
1705
- "--enabled",
1706
- action="store_true",
1707
- default=None,
1708
- help="Enable the MCP server [hosts: codex]"
1709
- )
1710
- mcp_configure_parser.add_argument(
1711
- "--bearer-token-env-var",
1712
- type=str,
1713
- help="Name of environment variable containing bearer token for Authorization header [hosts: codex]"
1714
- )
1715
- mcp_configure_parser.add_argument(
1716
- "--env-header",
1717
- action="append",
1718
- help="HTTP header from environment variable in KEY=ENV_VAR_NAME format [hosts: codex]"
1719
- )
1720
-
1721
- mcp_configure_parser.add_argument(
1722
- "--no-backup",
1723
- action="store_true",
1724
- help="Skip backup creation before configuration [hosts: all]",
1725
- )
1726
- mcp_configure_parser.add_argument(
1727
- "--dry-run", action="store_true", help="Preview configuration without execution [hosts: all]"
1728
- )
1729
- mcp_configure_parser.add_argument(
1730
- "--auto-approve", action="store_true", help="Skip confirmation prompts [hosts: all]"
1731
- )
1732
-
1733
- # Remove MCP commands (object-action pattern)
1734
- mcp_remove_subparsers = mcp_subparsers.add_parser(
1735
- "remove", help="Remove MCP servers or host configurations"
1736
- ).add_subparsers(dest="remove_command", help="Remove command to execute")
1737
-
1738
- # Remove server command
1739
- mcp_remove_server_parser = mcp_remove_subparsers.add_parser(
1740
- "server", help="Remove MCP server from hosts"
1741
- )
1742
- mcp_remove_server_parser.add_argument(
1743
- "server_name", help="Name of the MCP server to remove"
1744
- )
1745
- mcp_remove_server_parser.add_argument(
1746
- "--host", help="Target hosts (comma-separated or 'all')"
1747
- )
1748
- mcp_remove_server_parser.add_argument(
1749
- "--env", "-e", help="Environment name (for environment-based removal)"
1750
- )
1751
- mcp_remove_server_parser.add_argument(
1752
- "--no-backup", action="store_true", help="Skip backup creation before removal"
1753
- )
1754
- mcp_remove_server_parser.add_argument(
1755
- "--dry-run", action="store_true", help="Preview removal without execution"
1756
- )
1757
- mcp_remove_server_parser.add_argument(
1758
- "--auto-approve", action="store_true", help="Skip confirmation prompts"
1759
- )
1760
-
1761
- # Remove host command
1762
- mcp_remove_host_parser = mcp_remove_subparsers.add_parser(
1763
- "host", help="Remove entire host configuration"
1764
- )
1765
- mcp_remove_host_parser.add_argument(
1766
- "host_name", help="Host platform to remove (e.g., claude-desktop, cursor)"
1767
- )
1768
- mcp_remove_host_parser.add_argument(
1769
- "--no-backup", action="store_true", help="Skip backup creation before removal"
1770
- )
1771
- mcp_remove_host_parser.add_argument(
1772
- "--dry-run", action="store_true", help="Preview removal without execution"
1773
- )
1774
- mcp_remove_host_parser.add_argument(
1775
- "--auto-approve", action="store_true", help="Skip confirmation prompts"
1776
- )
1777
-
1778
- # MCP synchronization command
1779
- mcp_sync_parser = mcp_subparsers.add_parser(
1780
- "sync", help="Synchronize MCP configurations between environments and hosts"
1781
- )
1782
-
1783
- # Source options (mutually exclusive)
1784
- sync_source_group = mcp_sync_parser.add_mutually_exclusive_group(required=True)
1785
- sync_source_group.add_argument("--from-env", help="Source environment name")
1786
- sync_source_group.add_argument("--from-host", help="Source host platform")
1787
-
1788
- # Target options
1789
- mcp_sync_parser.add_argument(
1790
- "--to-host", required=True, help="Target hosts (comma-separated or 'all')"
1791
- )
1792
-
1793
- # Filter options (mutually exclusive)
1794
- sync_filter_group = mcp_sync_parser.add_mutually_exclusive_group()
1795
- sync_filter_group.add_argument(
1796
- "--servers", help="Specific server names to sync (comma-separated)"
1797
- )
1798
- sync_filter_group.add_argument(
1799
- "--pattern", help="Regex pattern for server selection"
1800
- )
1801
-
1802
- # Standard options
1803
- mcp_sync_parser.add_argument(
1804
- "--dry-run",
1805
- action="store_true",
1806
- help="Preview synchronization without execution",
1807
- )
1808
- mcp_sync_parser.add_argument(
1809
- "--auto-approve", action="store_true", help="Skip confirmation prompts"
1810
- )
1811
- mcp_sync_parser.add_argument(
1812
- "--no-backup",
1813
- action="store_true",
1814
- help="Skip backup creation before synchronization",
1815
- )
1816
-
1817
- # Package management commands
1818
- pkg_subparsers = subparsers.add_parser(
1819
- "package", help="Package management commands"
1820
- ).add_subparsers(dest="pkg_command", help="Package command to execute")
1821
-
1822
- # Add package command
1823
- pkg_add_parser = pkg_subparsers.add_parser(
1824
- "add", help="Add a package to the current environment"
1825
- )
1826
- pkg_add_parser.add_argument(
1827
- "package_path_or_name", help="Path to package directory or name of the package"
1828
- )
1829
- pkg_add_parser.add_argument(
1830
- "--env",
1831
- "-e",
1832
- default=None,
1833
- help="Environment name (default: current environment)",
1834
- )
1835
- pkg_add_parser.add_argument(
1836
- "--version", "-v", default=None, help="Version of the package (optional)"
1837
- )
1838
- pkg_add_parser.add_argument(
1839
- "--force-download",
1840
- "-f",
1841
- action="store_true",
1842
- help="Force download even if package is in cache",
1843
- )
1844
- pkg_add_parser.add_argument(
1845
- "--refresh-registry",
1846
- "-r",
1847
- action="store_true",
1848
- help="Force refresh of registry data",
1849
- )
1850
- pkg_add_parser.add_argument(
1851
- "--auto-approve",
1852
- action="store_true",
1853
- help="Automatically approve changes installation of deps for automation scenario",
1854
- )
1855
- # MCP host configuration integration
1856
- pkg_add_parser.add_argument(
1857
- "--host",
1858
- help="Comma-separated list of MCP host platforms to configure (e.g., claude-desktop,cursor)",
1859
- )
1860
-
1861
- # Remove package command
1862
- pkg_remove_parser = pkg_subparsers.add_parser(
1863
- "remove", help="Remove a package from the current environment"
1864
- )
1865
- pkg_remove_parser.add_argument("package_name", help="Name of the package to remove")
1866
- pkg_remove_parser.add_argument(
1867
- "--env",
1868
- "-e",
1869
- default=None,
1870
- help="Environment name (default: current environment)",
1871
- )
1872
-
1873
- # List packages command
1874
- pkg_list_parser = pkg_subparsers.add_parser(
1875
- "list", help="List packages in an environment"
1876
- )
1877
- pkg_list_parser.add_argument(
1878
- "--env", "-e", help="Environment name (default: current environment)"
1879
- )
1880
-
1881
- # Sync package MCP servers command
1882
- pkg_sync_parser = pkg_subparsers.add_parser(
1883
- "sync", help="Synchronize package MCP servers to host platforms"
1884
- )
1885
- pkg_sync_parser.add_argument(
1886
- "package_name", help="Name of the package whose MCP servers to sync"
1887
- )
1888
- pkg_sync_parser.add_argument(
1889
- "--host",
1890
- required=True,
1891
- help="Comma-separated list of host platforms to sync to (or 'all')",
1892
- )
1893
- pkg_sync_parser.add_argument(
1894
- "--env",
1895
- "-e",
1896
- default=None,
1897
- help="Environment name (default: current environment)",
1898
- )
1899
- pkg_sync_parser.add_argument(
1900
- "--dry-run", action="store_true", help="Preview changes without execution"
1901
- )
1902
- pkg_sync_parser.add_argument(
1903
- "--auto-approve", action="store_true", help="Skip confirmation prompts"
1904
- )
1905
- pkg_sync_parser.add_argument(
1906
- "--no-backup", action="store_true", help="Disable default backup behavior"
1907
- )
1908
-
1909
- # General arguments for the environment manager
1910
- parser.add_argument(
1911
- "--envs-dir",
1912
- default=Path.home() / ".hatch" / "envs",
1913
- help="Directory to store environments",
1914
- )
1915
- parser.add_argument(
1916
- "--cache-ttl",
1917
- type=int,
1918
- default=86400,
1919
- help="Cache TTL in seconds (default: 86400 seconds --> 1 day)",
1920
- )
1921
- parser.add_argument(
1922
- "--cache-dir",
1923
- default=Path.home() / ".hatch" / "cache",
1924
- help="Directory to store cached packages",
1925
- )
1926
-
1927
- args = parser.parse_args()
1928
-
1929
- # Initialize environment manager
1930
- env_manager = HatchEnvironmentManager(
1931
- environments_dir=args.envs_dir,
1932
- cache_ttl=args.cache_ttl,
1933
- cache_dir=args.cache_dir,
1934
- )
1935
-
1936
- # Initialize MCP configuration manager
1937
- mcp_manager = MCPHostConfigurationManager()
1938
-
1939
- # Execute commands
1940
- if args.command == "create":
1941
- target_dir = Path(args.dir).resolve()
1942
- package_dir = create_package_template(
1943
- target_dir=target_dir, package_name=args.name, description=args.description
1944
- )
1945
- print(f"Package template created at: {package_dir}")
1946
-
1947
- elif args.command == "validate":
1948
- package_path = Path(args.package_dir).resolve()
1949
-
1950
- # Create validator with registry data from environment manager
1951
- validator = HatchPackageValidator(
1952
- version="latest",
1953
- allow_local_dependencies=True,
1954
- registry_data=env_manager.registry_data,
1955
- )
1956
-
1957
- # Validate the package
1958
- is_valid, validation_results = validator.validate_package(package_path)
1959
-
1960
- if is_valid:
1961
- print(f"Package validation SUCCESSFUL: {package_path}")
1962
- return 0
1963
- else:
1964
- print(f"Package validation FAILED: {package_path}")
1965
-
1966
- # Print detailed validation results if available
1967
- if validation_results and isinstance(validation_results, dict):
1968
- for category, result in validation_results.items():
1969
- if (
1970
- category != "valid"
1971
- and category != "metadata"
1972
- and isinstance(result, dict)
1973
- ):
1974
- if not result.get("valid", True) and result.get("errors"):
1975
- print(f"\n{category.replace('_', ' ').title()} errors:")
1976
- for error in result["errors"]:
1977
- print(f" - {error}")
1978
-
1979
- return 1
1980
-
1981
- elif args.command == "env":
1982
- if args.env_command == "create":
1983
- # Determine whether to create Python environment
1984
- create_python_env = not args.no_python
1985
- python_version = getattr(args, "python_version", None)
1986
-
1987
- if env_manager.create_environment(
1988
- args.name,
1989
- args.description,
1990
- python_version=python_version,
1991
- create_python_env=create_python_env,
1992
- no_hatch_mcp_server=args.no_hatch_mcp_server,
1993
- hatch_mcp_server_tag=args.hatch_mcp_server_tag,
1994
- ):
1995
- print(f"Environment created: {args.name}")
1996
-
1997
- # Show Python environment status
1998
- if create_python_env and env_manager.is_python_environment_available():
1999
- python_exec = env_manager.python_env_manager.get_python_executable(
2000
- args.name
2001
- )
2002
- if python_exec:
2003
- python_version_info = (
2004
- env_manager.python_env_manager.get_python_version(args.name)
2005
- )
2006
- print(f"Python environment: {python_exec}")
2007
- if python_version_info:
2008
- print(f"Python version: {python_version_info}")
2009
- else:
2010
- print("Python environment creation failed")
2011
- elif create_python_env:
2012
- print("Python environment requested but conda/mamba not available")
2013
-
2014
- return 0
2015
- else:
2016
- print(f"Failed to create environment: {args.name}")
2017
- return 1
2018
-
2019
- elif args.env_command == "remove":
2020
- if env_manager.remove_environment(args.name):
2021
- print(f"Environment removed: {args.name}")
2022
- return 0
2023
- else:
2024
- print(f"Failed to remove environment: {args.name}")
2025
- return 1
2026
-
2027
- elif args.env_command == "list":
2028
- environments = env_manager.list_environments()
2029
- print("Available environments:")
2030
-
2031
- # Check if conda/mamba is available for status info
2032
- conda_available = env_manager.is_python_environment_available()
2033
-
2034
- for env in environments:
2035
- current_marker = "* " if env.get("is_current") else " "
2036
- description = (
2037
- f" - {env.get('description')}" if env.get("description") else ""
2038
- )
2039
-
2040
- # Show basic environment info
2041
- print(f"{current_marker}{env.get('name')}{description}")
2042
-
2043
- # Show Python environment info if available
2044
- python_env = env.get("python_environment", False)
2045
- if python_env:
2046
- python_info = env_manager.get_python_environment_info(
2047
- env.get("name")
2048
- )
2049
- if python_info:
2050
- python_version = python_info.get("python_version", "Unknown")
2051
- conda_env = python_info.get("conda_env_name", "N/A")
2052
- print(f" Python: {python_version} (conda: {conda_env})")
2053
- else:
2054
- print(f" Python: Configured but unavailable")
2055
- elif conda_available:
2056
- print(f" Python: Not configured")
2057
- else:
2058
- print(f" Python: Conda/mamba not available")
2059
-
2060
- # Show conda/mamba status
2061
- if conda_available:
2062
- manager_info = env_manager.python_env_manager.get_manager_info()
2063
- print(f"\nPython Environment Manager:")
2064
- print(
2065
- f" Conda executable: {manager_info.get('conda_executable', 'Not found')}"
2066
- )
2067
- print(
2068
- f" Mamba executable: {manager_info.get('mamba_executable', 'Not found')}"
2069
- )
2070
- print(
2071
- f" Preferred manager: {manager_info.get('preferred_manager', 'N/A')}"
2072
- )
2073
- else:
2074
- print(f"\nPython Environment Manager: Conda/mamba not available")
2075
-
2076
- return 0
2077
-
2078
- elif args.env_command == "use":
2079
- if env_manager.set_current_environment(args.name):
2080
- print(f"Current environment set to: {args.name}")
2081
- return 0
2082
- else:
2083
- print(f"Failed to set environment: {args.name}")
2084
- return 1
2085
-
2086
- elif args.env_command == "current":
2087
- current_env = env_manager.get_current_environment()
2088
- print(f"Current environment: {current_env}")
2089
- return 0
2090
-
2091
- elif args.env_command == "python":
2092
- # Advanced Python environment management
2093
- if args.python_command == "init":
2094
- python_version = getattr(args, "python_version", None)
2095
- force = getattr(args, "force", False)
2096
- no_hatch_mcp_server = getattr(args, "no_hatch_mcp_server", False)
2097
- hatch_mcp_server_tag = getattr(args, "hatch_mcp_server_tag", None)
2098
-
2099
- if env_manager.create_python_environment_only(
2100
- args.hatch_env,
2101
- python_version,
2102
- force,
2103
- no_hatch_mcp_server=no_hatch_mcp_server,
2104
- hatch_mcp_server_tag=hatch_mcp_server_tag,
2105
- ):
2106
- print(f"Python environment initialized for: {args.hatch_env}")
2107
-
2108
- # Show Python environment info
2109
- python_info = env_manager.get_python_environment_info(
2110
- args.hatch_env
2111
- )
2112
- if python_info:
2113
- print(
2114
- f" Python executable: {python_info['python_executable']}"
2115
- )
2116
- print(
2117
- f" Python version: {python_info.get('python_version', 'Unknown')}"
2118
- )
2119
- print(
2120
- f" Conda environment: {python_info.get('conda_env_name', 'N/A')}"
2121
- )
2122
-
2123
- return 0
2124
- else:
2125
- env_name = args.hatch_env or env_manager.get_current_environment()
2126
- print(f"Failed to initialize Python environment for: {env_name}")
2127
- return 1
2128
-
2129
- elif args.python_command == "info":
2130
- detailed = getattr(args, "detailed", False)
2131
-
2132
- python_info = env_manager.get_python_environment_info(args.hatch_env)
2133
-
2134
- if python_info:
2135
- env_name = args.hatch_env or env_manager.get_current_environment()
2136
- print(f"Python environment info for '{env_name}':")
2137
- print(
2138
- f" Status: {'Active' if python_info.get('enabled', False) else 'Inactive'}"
2139
- )
2140
- print(f" Python executable: {python_info['python_executable']}")
2141
- print(
2142
- f" Python version: {python_info.get('python_version', 'Unknown')}"
2143
- )
2144
- print(
2145
- f" Conda environment: {python_info.get('conda_env_name', 'N/A')}"
2146
- )
2147
- print(f" Environment path: {python_info['environment_path']}")
2148
- print(f" Created: {python_info.get('created_at', 'Unknown')}")
2149
- print(f" Package count: {python_info.get('package_count', 0)}")
2150
- print(f" Packages:")
2151
- for pkg in python_info.get("packages", []):
2152
- print(f" - {pkg['name']} ({pkg['version']})")
2153
-
2154
- if detailed:
2155
- print(f"\nDiagnostics:")
2156
- diagnostics = env_manager.get_python_environment_diagnostics(
2157
- args.hatch_env
2158
- )
2159
- if diagnostics:
2160
- for key, value in diagnostics.items():
2161
- print(f" {key}: {value}")
2162
- else:
2163
- print(" No diagnostics available")
2164
-
2165
- return 0
2166
- else:
2167
- env_name = args.hatch_env or env_manager.get_current_environment()
2168
- print(f"No Python environment found for: {env_name}")
2169
-
2170
- # Show diagnostics for missing environment
2171
- if detailed:
2172
- print("\nDiagnostics:")
2173
- general_diagnostics = (
2174
- env_manager.get_python_manager_diagnostics()
2175
- )
2176
- for key, value in general_diagnostics.items():
2177
- print(f" {key}: {value}")
2178
-
2179
- return 1
2180
-
2181
- elif args.python_command == "remove":
2182
- force = getattr(args, "force", False)
2183
-
2184
- if not force:
2185
- # Ask for confirmation using TTY-aware function
2186
- env_name = args.hatch_env or env_manager.get_current_environment()
2187
- if not request_confirmation(
2188
- f"Remove Python environment for '{env_name}'?"
2189
- ):
2190
- print("Operation cancelled")
2191
- return 0
2192
-
2193
- if env_manager.remove_python_environment_only(args.hatch_env):
2194
- env_name = args.hatch_env or env_manager.get_current_environment()
2195
- print(f"Python environment removed from: {env_name}")
2196
- return 0
2197
- else:
2198
- env_name = args.hatch_env or env_manager.get_current_environment()
2199
- print(f"Failed to remove Python environment from: {env_name}")
2200
- return 1
2201
-
2202
- elif args.python_command == "shell":
2203
- cmd = getattr(args, "cmd", None)
2204
-
2205
- if env_manager.launch_python_shell(args.hatch_env, cmd):
2206
- return 0
2207
- else:
2208
- env_name = args.hatch_env or env_manager.get_current_environment()
2209
- print(f"Failed to launch Python shell for: {env_name}")
2210
- return 1
2211
-
2212
- elif args.python_command == "add-hatch-mcp":
2213
- env_name = args.hatch_env or env_manager.get_current_environment()
2214
- tag = args.tag
2215
-
2216
- if env_manager.install_mcp_server(env_name, tag):
2217
- print(
2218
- f"hatch_mcp_server wrapper installed successfully in environment: {env_name}"
2219
- )
2220
- return 0
2221
- else:
2222
- print(
2223
- f"Failed to install hatch_mcp_server wrapper in environment: {env_name}"
2224
- )
2225
- return 1
2226
-
2227
- else:
2228
- print("Unknown Python environment command")
2229
- return 1
2230
-
2231
- elif args.command == "package":
2232
- if args.pkg_command == "add":
2233
- # Add package to environment
2234
- if env_manager.add_package_to_environment(
2235
- args.package_path_or_name,
2236
- args.env,
2237
- args.version,
2238
- args.force_download,
2239
- args.refresh_registry,
2240
- args.auto_approve,
2241
- ):
2242
- print(f"Successfully added package: {args.package_path_or_name}")
2243
-
2244
- # Handle MCP host configuration if requested
2245
- if hasattr(args, "host") and args.host:
2246
- try:
2247
- hosts = parse_host_list(args.host)
2248
- env_name = args.env or env_manager.get_current_environment()
2249
-
2250
- package_name = args.package_path_or_name
2251
- package_service = None
2252
-
2253
- # Check if it's a local package path
2254
- pkg_path = Path(args.package_path_or_name)
2255
- if pkg_path.exists() and pkg_path.is_dir():
2256
- # Local package - load metadata from directory
2257
- with open(pkg_path / "hatch_metadata.json", "r") as f:
2258
- metadata = json.load(f)
2259
- package_service = PackageService(metadata)
2260
- package_name = package_service.get_field("name")
2261
- else:
2262
- # Registry package - get metadata from environment manager
2263
- try:
2264
- env_data = env_manager.get_environment_data(env_name)
2265
- if env_data:
2266
- # Find the package in the environment
2267
- for pkg in env_data.packages:
2268
- if pkg.name == package_name:
2269
- # Create a minimal metadata structure for PackageService
2270
- metadata = {
2271
- "name": pkg.name,
2272
- "version": pkg.version,
2273
- "dependencies": {}, # Will be populated if needed
2274
- }
2275
- package_service = PackageService(metadata)
2276
- break
2277
-
2278
- if package_service is None:
2279
- print(
2280
- f"Warning: Could not find package '{package_name}' in environment '{env_name}'. Skipping dependency analysis."
2281
- )
2282
- package_service = None
2283
- except Exception as e:
2284
- print(
2285
- f"Warning: Could not load package metadata for '{package_name}': {e}. Skipping dependency analysis."
2286
- )
2287
- package_service = None
2288
-
2289
- # Get dependency names if we have package service
2290
- package_names = []
2291
- if package_service:
2292
- # Get Hatch dependencies
2293
- dependencies = package_service.get_dependencies()
2294
- hatch_deps = dependencies.get("hatch", [])
2295
- package_names = [
2296
- dep.get("name") for dep in hatch_deps if dep.get("name")
2297
- ]
2298
-
2299
- # Resolve local dependency paths to actual names
2300
- for i in range(len(package_names)):
2301
- dep_path = Path(package_names[i])
2302
- if dep_path.exists() and dep_path.is_dir():
2303
- try:
2304
- with open(
2305
- dep_path / "hatch_metadata.json", "r"
2306
- ) as f:
2307
- dep_metadata = json.load(f)
2308
- dep_service = PackageService(dep_metadata)
2309
- package_names[i] = dep_service.get_field("name")
2310
- except Exception as e:
2311
- print(
2312
- f"Warning: Could not resolve dependency path '{package_names[i]}': {e}"
2313
- )
2314
-
2315
- # Add the main package to the list
2316
- package_names.append(package_name)
2317
-
2318
- # Get MCP server configuration for all packages
2319
- server_configs = [
2320
- get_package_mcp_server_config(
2321
- env_manager, env_name, pkg_name
2322
- )
2323
- for pkg_name in package_names
2324
- ]
2325
-
2326
- print(
2327
- f"Configuring MCP server for package '{package_name}' on {len(hosts)} host(s)..."
2328
- )
2329
-
2330
- # Configure on each host
2331
- success_count = 0
2332
- for host in hosts: # 'host', here, is a string
2333
- try:
2334
- # Convert string to MCPHostType enum
2335
- host_type = MCPHostType(host)
2336
- host_model_class = HOST_MODEL_REGISTRY.get(host_type)
2337
- if not host_model_class:
2338
- print(
2339
- f"✗ Error: No model registered for host '{host}'"
2340
- )
2341
- continue
2342
-
2343
- host_success_count = 0
2344
- for i, server_config in enumerate(server_configs):
2345
- pkg_name = package_names[i]
2346
- try:
2347
- # Convert MCPServerConfig to Omni model
2348
- # Only include fields that have actual values
2349
- omni_config_data = {"name": server_config.name}
2350
- if server_config.command is not None:
2351
- omni_config_data["command"] = (
2352
- server_config.command
2353
- )
2354
- if server_config.args is not None:
2355
- omni_config_data["args"] = (
2356
- server_config.args
2357
- )
2358
- if server_config.env:
2359
- omni_config_data["env"] = server_config.env
2360
- if server_config.url is not None:
2361
- omni_config_data["url"] = server_config.url
2362
- headers = getattr(
2363
- server_config, "headers", None
2364
- )
2365
- if headers is not None:
2366
- omni_config_data["headers"] = headers
2367
-
2368
- omni_config = MCPServerConfigOmni(
2369
- **omni_config_data
2370
- )
2371
-
2372
- # Convert to host-specific model
2373
- host_config = host_model_class.from_omni(
2374
- omni_config
2375
- )
2376
-
2377
- # Generate and display conversion report
2378
- report = generate_conversion_report(
2379
- operation="create",
2380
- server_name=server_config.name,
2381
- target_host=host_type,
2382
- omni=omni_config,
2383
- dry_run=False,
2384
- )
2385
- display_report(report)
2386
-
2387
- result = mcp_manager.configure_server(
2388
- hostname=host,
2389
- server_config=host_config,
2390
- no_backup=False, # Always backup when adding packages
2391
- )
2392
-
2393
- if result.success:
2394
- print(
2395
- f"✓ Configured {server_config.name} ({pkg_name}) on {host}"
2396
- )
2397
- host_success_count += 1
2398
-
2399
- # Update package metadata with host configuration tracking
2400
- try:
2401
- server_config_dict = {
2402
- "name": server_config.name,
2403
- "command": server_config.command,
2404
- "args": server_config.args,
2405
- }
2406
-
2407
- env_manager.update_package_host_configuration(
2408
- env_name=env_name,
2409
- package_name=pkg_name,
2410
- hostname=host,
2411
- server_config=server_config_dict,
2412
- )
2413
- except Exception as e:
2414
- # Log but don't fail the configuration operation
2415
- print(
2416
- f"[WARNING] Failed to update package metadata for {pkg_name}: {e}"
2417
- )
2418
- else:
2419
- print(
2420
- f"✗ Failed to configure {server_config.name} ({pkg_name}) on {host}: {result.error_message}"
2421
- )
2422
-
2423
- except Exception as e:
2424
- print(
2425
- f"✗ Error configuring {server_config.name} ({pkg_name}) on {host}: {e}"
2426
- )
2427
-
2428
- if host_success_count == len(server_configs):
2429
- success_count += 1
2430
-
2431
- except ValueError as e:
2432
- print(f"✗ Invalid host '{host}': {e}")
2433
- continue
2434
-
2435
- if success_count > 0:
2436
- print(
2437
- f"MCP configuration completed: {success_count}/{len(hosts)} hosts configured"
2438
- )
2439
- else:
2440
- print("Warning: MCP configuration failed on all hosts")
2441
-
2442
- except ValueError as e:
2443
- print(f"Warning: MCP host configuration failed: {e}")
2444
- # Don't fail the entire operation for MCP configuration issues
2445
-
2446
- return 0
2447
- else:
2448
- print(f"Failed to add package: {args.package_path_or_name}")
2449
- return 1
2450
-
2451
- elif args.pkg_command == "remove":
2452
- if env_manager.remove_package(args.package_name, args.env):
2453
- print(f"Successfully removed package: {args.package_name}")
2454
- return 0
2455
- else:
2456
- print(f"Failed to remove package: {args.package_name}")
2457
- return 1
2458
-
2459
- elif args.pkg_command == "list":
2460
- packages = env_manager.list_packages(args.env)
2461
-
2462
- if not packages:
2463
- print(f"No packages found in environment: {args.env}")
2464
- return 0
2465
-
2466
- print(f"Packages in environment '{args.env}':")
2467
- for pkg in packages:
2468
- print(
2469
- f"{pkg['name']} ({pkg['version']})\tHatch compliant: {pkg['hatch_compliant']}\tsource: {pkg['source']['uri']}\tlocation: {pkg['source']['path']}"
2470
- )
2471
- return 0
2472
-
2473
- elif args.pkg_command == "sync":
2474
- try:
2475
- # Parse host list
2476
- hosts = parse_host_list(args.host)
2477
- env_name = args.env or env_manager.get_current_environment()
2478
-
2479
- # Get all packages to sync (main package + dependencies)
2480
- package_names = [args.package_name]
2481
-
2482
- # Try to get dependencies for the main package
2483
- try:
2484
- env_data = env_manager.get_environment_data(env_name)
2485
- if env_data:
2486
- # Find the main package in the environment
2487
- main_package = None
2488
- for pkg in env_data.packages:
2489
- if pkg.name == args.package_name:
2490
- main_package = pkg
2491
- break
2492
-
2493
- if main_package:
2494
- # Create a minimal metadata structure for PackageService
2495
- metadata = {
2496
- "name": main_package.name,
2497
- "version": main_package.version,
2498
- "dependencies": {}, # Will be populated if needed
2499
- }
2500
- package_service = PackageService(metadata)
2501
-
2502
- # Get Hatch dependencies
2503
- dependencies = package_service.get_dependencies()
2504
- hatch_deps = dependencies.get("hatch", [])
2505
- dep_names = [
2506
- dep.get("name") for dep in hatch_deps if dep.get("name")
2507
- ]
2508
-
2509
- # Add dependencies to the sync list (before main package)
2510
- package_names = dep_names + [args.package_name]
2511
- else:
2512
- print(
2513
- f"Warning: Package '{args.package_name}' not found in environment '{env_name}'. Syncing only the specified package."
2514
- )
2515
- else:
2516
- print(
2517
- f"Warning: Could not access environment '{env_name}'. Syncing only the specified package."
2518
- )
2519
- except Exception as e:
2520
- print(
2521
- f"Warning: Could not analyze dependencies for '{args.package_name}': {e}. Syncing only the specified package."
2522
- )
2523
-
2524
- # Get MCP server configurations for all packages
2525
- server_configs = []
2526
- for pkg_name in package_names:
2527
- try:
2528
- config = get_package_mcp_server_config(
2529
- env_manager, env_name, pkg_name
2530
- )
2531
- server_configs.append((pkg_name, config))
2532
- except Exception as e:
2533
- print(
2534
- f"Warning: Could not get MCP configuration for package '{pkg_name}': {e}"
2535
- )
2536
-
2537
- if not server_configs:
2538
- print(
2539
- f"Error: No MCP server configurations found for package '{args.package_name}' or its dependencies"
2540
- )
2541
- return 1
2542
-
2543
- if args.dry_run:
2544
- print(
2545
- f"[DRY RUN] Would synchronize MCP servers for {len(server_configs)} package(s) to hosts: {[h for h in hosts]}"
2546
- )
2547
- for pkg_name, config in server_configs:
2548
- print(
2549
- f"[DRY RUN] - {pkg_name}: {config.name} -> {' '.join(config.args)}"
2550
- )
2551
-
2552
- # Generate and display conversion reports for dry-run mode
2553
- for host in hosts:
2554
- try:
2555
- host_type = MCPHostType(host)
2556
- host_model_class = HOST_MODEL_REGISTRY.get(host_type)
2557
- if not host_model_class:
2558
- print(
2559
- f"[DRY RUN] ✗ Error: No model registered for host '{host}'"
2560
- )
2561
- continue
2562
-
2563
- # Convert to Omni model
2564
- # Only include fields that have actual values
2565
- omni_config_data = {"name": config.name}
2566
- if config.command is not None:
2567
- omni_config_data["command"] = config.command
2568
- if config.args is not None:
2569
- omni_config_data["args"] = config.args
2570
- if config.env:
2571
- omni_config_data["env"] = config.env
2572
- if config.url is not None:
2573
- omni_config_data["url"] = config.url
2574
- headers = getattr(config, "headers", None)
2575
- if headers is not None:
2576
- omni_config_data["headers"] = headers
2577
-
2578
- omni_config = MCPServerConfigOmni(**omni_config_data)
2579
-
2580
- # Generate report
2581
- report = generate_conversion_report(
2582
- operation="create",
2583
- server_name=config.name,
2584
- target_host=host_type,
2585
- omni=omni_config,
2586
- dry_run=True,
2587
- )
2588
- print(f"[DRY RUN] Preview for {pkg_name} on {host}:")
2589
- display_report(report)
2590
- except ValueError as e:
2591
- print(f"[DRY RUN] ✗ Invalid host '{host}': {e}")
2592
- return 0
2593
-
2594
- # Confirm operation unless auto-approved
2595
- package_desc = (
2596
- f"package '{args.package_name}'"
2597
- if len(server_configs) == 1
2598
- else f"{len(server_configs)} packages ('{args.package_name}' + dependencies)"
2599
- )
2600
- if not request_confirmation(
2601
- f"Synchronize MCP servers for {package_desc} to {len(hosts)} host(s)?",
2602
- args.auto_approve,
2603
- ):
2604
- print("Operation cancelled.")
2605
- return 0
2606
-
2607
- # Perform synchronization to each host for all packages
2608
- total_operations = len(server_configs) * len(hosts)
2609
- success_count = 0
2610
-
2611
- for host in hosts:
2612
- try:
2613
- # Convert string to MCPHostType enum
2614
- host_type = MCPHostType(host)
2615
- host_model_class = HOST_MODEL_REGISTRY.get(host_type)
2616
- if not host_model_class:
2617
- print(f"✗ Error: No model registered for host '{host}'")
2618
- continue
2619
-
2620
- for pkg_name, server_config in server_configs:
2621
- try:
2622
- # Convert MCPServerConfig to Omni model
2623
- # Only include fields that have actual values
2624
- omni_config_data = {"name": server_config.name}
2625
- if server_config.command is not None:
2626
- omni_config_data["command"] = server_config.command
2627
- if server_config.args is not None:
2628
- omni_config_data["args"] = server_config.args
2629
- if server_config.env:
2630
- omni_config_data["env"] = server_config.env
2631
- if server_config.url is not None:
2632
- omni_config_data["url"] = server_config.url
2633
- headers = getattr(server_config, "headers", None)
2634
- if headers is not None:
2635
- omni_config_data["headers"] = headers
2636
-
2637
- omni_config = MCPServerConfigOmni(**omni_config_data)
2638
-
2639
- # Convert to host-specific model
2640
- host_config = host_model_class.from_omni(omni_config)
2641
-
2642
- # Generate and display conversion report
2643
- report = generate_conversion_report(
2644
- operation="create",
2645
- server_name=server_config.name,
2646
- target_host=host_type,
2647
- omni=omni_config,
2648
- dry_run=False,
2649
- )
2650
- display_report(report)
2651
-
2652
- result = mcp_manager.configure_server(
2653
- hostname=host,
2654
- server_config=host_config,
2655
- no_backup=args.no_backup,
2656
- )
2657
-
2658
- if result.success:
2659
- print(
2660
- f"[SUCCESS] Successfully configured {server_config.name} ({pkg_name}) on {host}"
2661
- )
2662
- success_count += 1
2663
-
2664
- # Update package metadata with host configuration tracking
2665
- try:
2666
- server_config_dict = {
2667
- "name": server_config.name,
2668
- "command": server_config.command,
2669
- "args": server_config.args,
2670
- }
2671
-
2672
- env_manager.update_package_host_configuration(
2673
- env_name=env_name,
2674
- package_name=pkg_name,
2675
- hostname=host,
2676
- server_config=server_config_dict,
2677
- )
2678
- except Exception as e:
2679
- # Log but don't fail the sync operation
2680
- print(
2681
- f"[WARNING] Failed to update package metadata for {pkg_name}: {e}"
2682
- )
2683
- else:
2684
- print(
2685
- f"[ERROR] Failed to configure {server_config.name} ({pkg_name}) on {host}: {result.error_message}"
2686
- )
2687
-
2688
- except Exception as e:
2689
- print(
2690
- f"[ERROR] Error configuring {server_config.name} ({pkg_name}) on {host}: {e}"
2691
- )
2692
-
2693
- except ValueError as e:
2694
- print(f"✗ Invalid host '{host}': {e}")
2695
- continue
2696
-
2697
- # Report results
2698
- if success_count == total_operations:
2699
- package_desc = (
2700
- f"package '{args.package_name}'"
2701
- if len(server_configs) == 1
2702
- else f"{len(server_configs)} packages"
2703
- )
2704
- print(
2705
- f"Successfully synchronized {package_desc} to all {len(hosts)} host(s)"
2706
- )
2707
- return 0
2708
- elif success_count > 0:
2709
- print(
2710
- f"Partially synchronized: {success_count}/{total_operations} operations succeeded"
2711
- )
2712
- return 1
2713
- else:
2714
- package_desc = (
2715
- f"package '{args.package_name}'"
2716
- if len(server_configs) == 1
2717
- else f"{len(server_configs)} packages"
2718
- )
2719
- print(f"Failed to synchronize {package_desc} to any hosts")
2720
- return 1
2721
-
2722
- except ValueError as e:
2723
- print(f"Error: {e}")
2724
- return 1
2725
-
2726
- else:
2727
- parser.print_help()
2728
- return 1
2729
-
2730
- elif args.command == "mcp":
2731
- if args.mcp_command == "discover":
2732
- if args.discover_command == "hosts":
2733
- return handle_mcp_discover_hosts()
2734
- elif args.discover_command == "servers":
2735
- return handle_mcp_discover_servers(env_manager, args.env)
2736
- else:
2737
- print("Unknown discover command")
2738
- return 1
2739
-
2740
- elif args.mcp_command == "list":
2741
- if args.list_command == "hosts":
2742
- return handle_mcp_list_hosts(env_manager, args.env, args.detailed)
2743
- elif args.list_command == "servers":
2744
- return handle_mcp_list_servers(env_manager, args.env)
2745
- else:
2746
- print("Unknown list command")
2747
- return 1
2748
-
2749
- elif args.mcp_command == "backup":
2750
- if args.backup_command == "restore":
2751
- return handle_mcp_backup_restore(
2752
- env_manager,
2753
- args.host,
2754
- args.backup_file,
2755
- args.dry_run,
2756
- args.auto_approve,
2757
- )
2758
- elif args.backup_command == "list":
2759
- return handle_mcp_backup_list(args.host, args.detailed)
2760
- elif args.backup_command == "clean":
2761
- return handle_mcp_backup_clean(
2762
- args.host,
2763
- args.older_than_days,
2764
- args.keep_count,
2765
- args.dry_run,
2766
- args.auto_approve,
2767
- )
2768
- else:
2769
- print("Unknown backup command")
2770
- return 1
2771
-
2772
- elif args.mcp_command == "configure":
2773
- return handle_mcp_configure(
2774
- args.host,
2775
- args.server_name,
2776
- args.server_command,
2777
- args.args,
2778
- getattr(args, "env_var", None),
2779
- args.url,
2780
- args.header,
2781
- getattr(args, "timeout", None),
2782
- getattr(args, "trust", False),
2783
- getattr(args, "cwd", None),
2784
- getattr(args, "env_file", None),
2785
- getattr(args, "http_url", None),
2786
- getattr(args, "include_tools", None),
2787
- getattr(args, "exclude_tools", None),
2788
- getattr(args, "input", None),
2789
- getattr(args, "disabled", None),
2790
- getattr(args, "auto_approve_tools", None),
2791
- getattr(args, "disable_tools", None),
2792
- getattr(args, "env_vars", None),
2793
- getattr(args, "startup_timeout", None),
2794
- getattr(args, "tool_timeout", None),
2795
- getattr(args, "enabled", None),
2796
- getattr(args, "bearer_token_env_var", None),
2797
- getattr(args, "env_header", None),
2798
- args.no_backup,
2799
- args.dry_run,
2800
- args.auto_approve,
2801
- )
2802
-
2803
- elif args.mcp_command == "remove":
2804
- if args.remove_command == "server":
2805
- return handle_mcp_remove_server(
2806
- env_manager,
2807
- args.server_name,
2808
- args.host,
2809
- args.env,
2810
- args.no_backup,
2811
- args.dry_run,
2812
- args.auto_approve,
2813
- )
2814
- elif args.remove_command == "host":
2815
- return handle_mcp_remove_host(
2816
- env_manager,
2817
- args.host_name,
2818
- args.no_backup,
2819
- args.dry_run,
2820
- args.auto_approve,
2821
- )
2822
- else:
2823
- print("Unknown remove command")
2824
- return 1
2825
-
2826
- elif args.mcp_command == "sync":
2827
- return handle_mcp_sync(
2828
- from_env=getattr(args, "from_env", None),
2829
- from_host=getattr(args, "from_host", None),
2830
- to_hosts=args.to_host,
2831
- servers=getattr(args, "servers", None),
2832
- pattern=getattr(args, "pattern", None),
2833
- dry_run=args.dry_run,
2834
- auto_approve=args.auto_approve,
2835
- no_backup=args.no_backup,
2836
- )
2837
-
2838
- else:
2839
- print("Unknown MCP command")
2840
- return 1
2841
-
2842
- else:
2843
- parser.print_help()
2844
- return 1
2845
-
2846
- return 0
2847
-
2848
117
 
2849
- if __name__ == "__main__":
2850
- sys.exit(main())
118
+ __all__ = [
119
+ # Entry point
120
+ 'main',
121
+ # Exit codes
122
+ 'EXIT_SUCCESS',
123
+ 'EXIT_ERROR',
124
+ # Utilities
125
+ 'get_hatch_version',
126
+ 'request_confirmation',
127
+ 'parse_env_vars',
128
+ 'parse_header',
129
+ 'parse_input',
130
+ 'parse_host_list',
131
+ 'get_package_mcp_server_config',
132
+ # MCP handlers
133
+ 'handle_mcp_discover_hosts',
134
+ 'handle_mcp_discover_servers',
135
+ 'handle_mcp_list_hosts',
136
+ 'handle_mcp_list_servers',
137
+ 'handle_mcp_show',
138
+ 'handle_mcp_backup_restore',
139
+ 'handle_mcp_backup_list',
140
+ 'handle_mcp_backup_clean',
141
+ 'handle_mcp_configure',
142
+ 'handle_mcp_remove',
143
+ 'handle_mcp_remove_server',
144
+ 'handle_mcp_remove_host',
145
+ 'handle_mcp_sync',
146
+ # Environment handlers
147
+ 'handle_env_create',
148
+ 'handle_env_remove',
149
+ 'handle_env_list',
150
+ 'handle_env_use',
151
+ 'handle_env_current',
152
+ 'handle_env_show',
153
+ 'handle_env_python_init',
154
+ 'handle_env_python_info',
155
+ 'handle_env_python_remove',
156
+ 'handle_env_python_shell',
157
+ 'handle_env_python_add_hatch_mcp',
158
+ # Package handlers
159
+ 'handle_package_add',
160
+ 'handle_package_remove',
161
+ 'handle_package_list',
162
+ 'handle_package_sync',
163
+ # System handlers
164
+ 'handle_create',
165
+ 'handle_validate',
166
+ # Types
167
+ 'HatchEnvironmentManager',
168
+ 'MCPHostConfigurationManager',
169
+ 'MCPHostRegistry',
170
+ 'MCPHostType',
171
+ 'MCPServerConfig',
172
+ ]