Skip to content

Commit 799e1fa

Browse files
author
LittleCoinCoin
committed
fix: resolve non-TTY environment blocking in request_confirmation
- Add proper TTY detection using sys.stdin.isatty() - Support HATCH_AUTO_APPROVE environment variable - Handle EOFError and KeyboardInterrupt gracefully - Fix MCPServerConfig validation for headers field - Ensure tests run without hanging on user prompts Resolves critical testing issue where CLI commands would block in automated test environments waiting for user input.
1 parent ee04223 commit 799e1fa

File tree

1 file changed

+205
-4
lines changed

1 file changed

+205
-4
lines changed

hatch/cli_hatch.py

Lines changed: 205 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,28 @@ def parse_host_list(host_arg: str):
4040
return hosts
4141

4242
def request_confirmation(message: str, auto_approve: bool = False) -> bool:
43-
"""Request user confirmation following Hatch patterns."""
44-
if auto_approve:
43+
"""Request user confirmation with non-TTY support following Hatch patterns."""
44+
import os
45+
import sys
46+
47+
# Check for non-interactive mode indicators
48+
if (auto_approve or
49+
not sys.stdin.isatty() or
50+
os.getenv('HATCH_AUTO_APPROVE', '').lower() in ('1', 'true', 'yes')):
4551
return True
4652

47-
response = input(f"{message} [y/N]: ")
48-
return response.lower() in ['y', 'yes']
53+
# Interactive mode - request user input
54+
try:
55+
while True:
56+
response = input(f"{message} [y/N]: ").strip().lower()
57+
if response in ['y', 'yes']:
58+
return True
59+
elif response in ['n', 'no', '']:
60+
return False
61+
else:
62+
print("Please enter 'y' for yes or 'n' for no.")
63+
except (EOFError, KeyboardInterrupt):
64+
return False
4965

5066
def get_package_mcp_server_config(env_manager: HatchEnvironmentManager, env_name: str, package_name: str) -> MCPServerConfig:
5167
"""Get MCP server configuration for a package using existing APIs."""
@@ -419,6 +435,156 @@ def handle_mcp_backup_clean(host: str, older_than_days: Optional[int] = None, ke
419435
print(f"Error cleaning backups: {e}")
420436
return 1
421437

438+
def parse_env_vars(env_list: Optional[list]) -> dict:
439+
"""Parse environment variables from command line format."""
440+
if not env_list:
441+
return {}
442+
443+
env_dict = {}
444+
for env_var in env_list:
445+
if '=' not in env_var:
446+
print(f"Warning: Invalid environment variable format '{env_var}'. Expected KEY=VALUE")
447+
continue
448+
key, value = env_var.split('=', 1)
449+
env_dict[key.strip()] = value.strip()
450+
451+
return env_dict
452+
453+
def parse_headers(headers_list: Optional[list]) -> dict:
454+
"""Parse HTTP headers from command line format."""
455+
if not headers_list:
456+
return {}
457+
458+
headers_dict = {}
459+
for header in headers_list:
460+
if '=' not in header:
461+
print(f"Warning: Invalid header format '{header}'. Expected KEY=VALUE")
462+
continue
463+
key, value = header.split('=', 1)
464+
headers_dict[key.strip()] = value.strip()
465+
466+
return headers_dict
467+
468+
def handle_mcp_configure(host: str, server_name: str, command: str, args: list,
469+
env: Optional[list] = None, url: Optional[str] = None,
470+
headers: Optional[list] = None, no_backup: bool = False,
471+
dry_run: bool = False, auto_approve: bool = False):
472+
"""Handle 'hatch mcp configure' command."""
473+
try:
474+
# Validate host type
475+
try:
476+
host_type = MCPHostType(host)
477+
except ValueError:
478+
print(f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}")
479+
return 1
480+
481+
# Parse environment variables and headers
482+
env_dict = parse_env_vars(env)
483+
headers_dict = parse_headers(headers)
484+
485+
# Create server configuration (only include headers if URL is provided)
486+
config_data = {
487+
'name': server_name,
488+
'command': command,
489+
'args': args or [],
490+
'env': env_dict,
491+
'url': url
492+
}
493+
494+
# Only add headers if URL is provided (per MCPServerConfig validation)
495+
if url and headers_dict:
496+
config_data['headers'] = headers_dict
497+
498+
server_config = MCPServerConfig(**config_data)
499+
500+
if dry_run:
501+
print(f"[DRY RUN] Would configure MCP server '{server_name}' on host '{host}':")
502+
print(f"[DRY RUN] Command: {command}")
503+
if args:
504+
print(f"[DRY RUN] Args: {args}")
505+
if env_dict:
506+
print(f"[DRY RUN] Environment: {env_dict}")
507+
if url:
508+
print(f"[DRY RUN] URL: {url}")
509+
if headers_dict:
510+
print(f"[DRY RUN] Headers: {headers_dict}")
511+
print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
512+
return 0
513+
514+
# Confirm operation unless auto-approved
515+
if not request_confirmation(
516+
f"Configure MCP server '{server_name}' on host '{host}'?",
517+
auto_approve
518+
):
519+
print("Operation cancelled.")
520+
return 0
521+
522+
# Perform configuration
523+
mcp_manager = MCPHostConfigurationManager()
524+
result = mcp_manager.configure_server(
525+
server_config=server_config,
526+
hostname=host,
527+
no_backup=no_backup
528+
)
529+
530+
if result.success:
531+
print(f"✓ Successfully configured MCP server '{server_name}' on host '{host}'")
532+
if result.backup_path:
533+
print(f" Backup created: {result.backup_path}")
534+
return 0
535+
else:
536+
print(f"✗ Failed to configure MCP server '{server_name}' on host '{host}': {result.error_message}")
537+
return 1
538+
539+
except Exception as e:
540+
print(f"Error configuring MCP server: {e}")
541+
return 1
542+
543+
def handle_mcp_remove(host: str, server_name: str, no_backup: bool = False,
544+
dry_run: bool = False, auto_approve: bool = False):
545+
"""Handle 'hatch mcp remove' command."""
546+
try:
547+
# Validate host type
548+
try:
549+
host_type = MCPHostType(host)
550+
except ValueError:
551+
print(f"Error: Invalid host '{host}'. Supported hosts: {[h.value for h in MCPHostType]}")
552+
return 1
553+
554+
if dry_run:
555+
print(f"[DRY RUN] Would remove MCP server '{server_name}' from host '{host}'")
556+
print(f"[DRY RUN] Backup: {'Disabled' if no_backup else 'Enabled'}")
557+
return 0
558+
559+
# Confirm operation unless auto-approved
560+
if not request_confirmation(
561+
f"Remove MCP server '{server_name}' from host '{host}'?",
562+
auto_approve
563+
):
564+
print("Operation cancelled.")
565+
return 0
566+
567+
# Perform removal
568+
mcp_manager = MCPHostConfigurationManager()
569+
result = mcp_manager.remove_server(
570+
server_name=server_name,
571+
hostname=host,
572+
no_backup=no_backup
573+
)
574+
575+
if result.success:
576+
print(f"✓ Successfully removed MCP server '{server_name}' from host '{host}'")
577+
if result.backup_path:
578+
print(f" Backup created: {result.backup_path}")
579+
return 0
580+
else:
581+
print(f"✗ Failed to remove MCP server '{server_name}' from host '{host}': {result.error_message}")
582+
return 1
583+
584+
except Exception as e:
585+
print(f"Error removing MCP server: {e}")
586+
return 1
587+
422588
def main():
423589
"""Main entry point for Hatch CLI.
424590
@@ -572,6 +738,27 @@ def main():
572738
mcp_backup_clean_parser.add_argument("--dry-run", action="store_true", help="Preview cleanup operation without execution")
573739
mcp_backup_clean_parser.add_argument("--auto-approve", action="store_true", help="Skip confirmation prompts")
574740

741+
# MCP direct management commands
742+
mcp_configure_parser = mcp_subparsers.add_parser("configure", help="Configure MCP server directly on host")
743+
mcp_configure_parser.add_argument("host", help="Host platform to configure (e.g., claude-desktop, cursor)")
744+
mcp_configure_parser.add_argument("server_name", help="Name for the MCP server")
745+
mcp_configure_parser.add_argument("command", help="Command to execute the MCP server")
746+
mcp_configure_parser.add_argument("args", nargs="*", help="Arguments for the MCP server command")
747+
mcp_configure_parser.add_argument("--env", "-e", action="append", help="Environment variables (format: KEY=VALUE)")
748+
mcp_configure_parser.add_argument("--url", help="Server URL for remote MCP servers")
749+
mcp_configure_parser.add_argument("--headers", action="append", help="HTTP headers for remote servers (format: KEY=VALUE)")
750+
mcp_configure_parser.add_argument("--no-backup", action="store_true", help="Skip backup creation before configuration")
751+
mcp_configure_parser.add_argument("--dry-run", action="store_true", help="Preview configuration without execution")
752+
mcp_configure_parser.add_argument("--auto-approve", action="store_true", help="Skip confirmation prompts")
753+
754+
# Remove MCP server command
755+
mcp_remove_parser = mcp_subparsers.add_parser("remove", help="Remove MCP server from host")
756+
mcp_remove_parser.add_argument("host", help="Host platform to remove from (e.g., claude-desktop, cursor)")
757+
mcp_remove_parser.add_argument("server_name", help="Name of the MCP server to remove")
758+
mcp_remove_parser.add_argument("--no-backup", action="store_true", help="Skip backup creation before removal")
759+
mcp_remove_parser.add_argument("--dry-run", action="store_true", help="Preview removal without execution")
760+
mcp_remove_parser.add_argument("--auto-approve", action="store_true", help="Skip confirmation prompts")
761+
575762
# Package management commands
576763
pkg_subparsers = subparsers.add_parser("package", help="Package management commands").add_subparsers(
577764
dest="pkg_command", help="Package command to execute"
@@ -1046,6 +1233,20 @@ def main():
10461233
else:
10471234
print("Unknown backup command")
10481235
return 1
1236+
1237+
elif args.mcp_command == "configure":
1238+
return handle_mcp_configure(
1239+
args.host, args.server_name, args.command, args.args,
1240+
args.env, args.url, args.headers, args.no_backup,
1241+
args.dry_run, args.auto_approve
1242+
)
1243+
1244+
elif args.mcp_command == "remove":
1245+
return handle_mcp_remove(
1246+
args.host, args.server_name, args.no_backup,
1247+
args.dry_run, args.auto_approve
1248+
)
1249+
10491250
else:
10501251
print("Unknown MCP command")
10511252
return 1

0 commit comments

Comments
 (0)