Coverage for fastblocks/mcp/cli.py: 0%
345 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""Command-line interface for FastBlocks MCP adapter management."""
3import asyncio
4import json
5import typing as t
6from pathlib import Path
8import click
10from .config_audit import ConfigurationAuditor
11from .config_cli import config_cli
12from .config_health import ConfigurationHealthChecker
13from .config_migration import ConfigurationMigrationManager
14from .configuration import ConfigurationManager
15from .env_manager import EnvironmentManager
16from .health import HealthCheckSystem
17from .registry import AdapterRegistry
19# Audit output formatting helpers
20_SEVERITY_COLORS = {
21 "critical": "red",
22 "high": "red",
23 "medium": "yellow",
24 "low": "white",
25 "info": "cyan",
26}
28# Health check formatting helpers
29_HEALTH_STATUS_COLORS = {
30 "healthy": "green",
31 "warning": "yellow",
32 "error": "red",
33}
36def _display_health_result_summary(name: str, result) -> None: # type: ignore[no-untyped-def]
37 """Display a single health check result in summary format."""
38 status_color = _HEALTH_STATUS_COLORS.get(result.status, "white")
39 click.echo(f"{name:<20} ", nl=False)
40 click.secho(f"{result.status.upper():<8}", fg=status_color, nl=False)
41 click.echo(f" {result.message}")
44def _display_health_result_detail(adapter_name: str, result) -> None: # type: ignore[no-untyped-def]
45 """Display a single health check result with full details."""
46 status_color = _HEALTH_STATUS_COLORS.get(result.status, "white")
48 click.echo(f"Health Check: {adapter_name}")
49 click.secho(f"Status: {result.status.upper()}", fg=status_color)
50 click.echo(f"Message: {result.message}")
51 click.echo(f"Duration: {result.duration_ms:.2f}ms")
53 if result.details:
54 click.echo("Details:")
55 for key, value in result.details.items():
56 if isinstance(value, list):
57 click.echo(f" {key}: {', '.join(value) if value else 'None'}")
58 else:
59 click.echo(f" {key}: {value}")
62def _display_system_health_summary(summary: dict[str, t.Any]) -> None:
63 """Display overall system health summary."""
64 total = summary["total_adapters"]
65 click.echo("System Health Summary:")
66 click.echo(f"Total Adapters: {total}")
67 if total > 0:
68 click.secho(f"Healthy: {summary['healthy_adapters']}", fg="green")
69 click.secho(f"Warnings: {summary['warning_adapters']}", fg="yellow")
70 click.secho(f"Errors: {summary['error_adapters']}", fg="red")
71 click.echo(f"Unknown: {summary['unknown_adapters']}")
74def _display_migration_compatibility(
75 compatibility: dict[str, t.Any], target_version: str
76) -> None:
77 """Display migration compatibility information."""
78 click.echo(f"Migration from {compatibility['current_version']} to {target_version}")
79 click.echo(f"Steps: {' -> '.join(compatibility['migration_path'])}")
81 if compatibility["warnings"]:
82 click.echo("\nWarnings:")
83 for warning in compatibility["warnings"]:
84 click.echo(f" - {warning}")
87def _display_migration_incompatibility(compatibility: dict[str, t.Any]) -> None:
88 """Display migration incompatibility errors."""
89 click.echo("Migration not possible:", err=True)
90 for warning in compatibility["warnings"]:
91 click.echo(f" - {warning}", err=True)
94def _display_migration_success(result) -> None: # type: ignore[no-untyped-def]
95 """Display successful migration results."""
96 click.secho("✓ Migration completed successfully", fg="green")
97 click.echo(f"Steps applied: {', '.join(result.steps_applied)}")
98 if result.warnings:
99 click.echo("Warnings:")
100 for warning in result.warnings:
101 click.secho(f" - {warning}", fg="yellow")
104def _display_migration_failure(result) -> None: # type: ignore[no-untyped-def]
105 """Display migration failure errors."""
106 click.secho("✗ Migration failed", fg="red")
107 for error in result.errors:
108 click.echo(f" - {error}")
111def _format_finding_for_json(finding: t.Any) -> dict[str, t.Any]:
112 """Format a single finding for JSON output."""
113 return {
114 "id": finding.id,
115 "category": finding.category.value,
116 "severity": finding.severity.value,
117 "title": finding.title,
118 "description": finding.description,
119 "recommendation": finding.recommendation,
120 "affected_items": finding.affected_items,
121 }
124def _write_json_audit_report(report, output_path: str | None) -> None: # type: ignore[no-untyped-def]
125 """Write audit report in JSON format."""
126 import json
128 output_data = {
129 "configuration_name": report.configuration_name,
130 "profile": report.profile,
131 "score": report.score,
132 "summary": report.summary,
133 "findings": [_format_finding_for_json(f) for f in report.findings],
134 }
136 if output_path:
137 with open(output_path, "w") as f:
138 json.dump(output_data, f, indent=2)
139 else:
140 click.echo(json.dumps(output_data, indent=2))
143def _display_audit_finding(finding) -> None: # type: ignore[no-untyped-def]
144 """Display a single audit finding to console."""
145 severity_color = _SEVERITY_COLORS.get(finding.severity.value, "white")
147 click.secho(
148 f"\n[{finding.severity.value.upper()}] {finding.title}",
149 fg=severity_color,
150 )
151 click.echo(f" {finding.description}")
152 click.echo(f" Recommendation: {finding.recommendation}")
153 if finding.affected_items:
154 click.echo(f" Affected: {', '.join(finding.affected_items)}")
157def _display_text_audit_report(report) -> None: # type: ignore[no-untyped-def]
158 """Display audit report in text format to console."""
159 click.echo(f"Configuration Audit Report: {report.configuration_name}")
160 click.echo(f"Profile: {report.profile}")
161 click.echo(f"Score: {report.score}/100")
162 click.echo(f"Total Findings: {report.summary['total_findings']}")
164 if report.findings:
165 click.echo("\nFindings:")
166 for finding in report.findings:
167 _display_audit_finding(finding)
169 if report.recommendations:
170 click.echo("\nRecommendations:")
171 for rec in report.recommendations:
172 click.echo(f" • {rec}")
175def _write_text_audit_report(report, output_path: str) -> None: # type: ignore[no-untyped-def]
176 """Write audit report in text format to file."""
177 with open(output_path, "w") as f:
178 f.write("Configuration Audit Report\n")
179 f.write("========================\n\n")
180 f.write(f"Configuration: {report.configuration_name}\n")
181 f.write(f"Profile: {report.profile}\n")
182 f.write(f"Score: {report.score}/100\n\n")
185async def get_registry_and_health() -> tuple[AdapterRegistry, HealthCheckSystem]:
186 """Get initialized registry and health check system."""
187 registry = AdapterRegistry()
188 await registry.initialize()
189 health = HealthCheckSystem(registry)
190 return registry, health
193@click.group()
194def cli() -> None:
195 """FastBlocks MCP Adapter Management CLI."""
196 pass
199# Add configuration management commands
200cli.add_command(config_cli, name="config")
203@cli.command()
204@click.option("--format", type=click.Choice(["json", "table"]), default="table")
205def list_adapters(format: str) -> None:
206 """List all available adapters."""
208 async def _list() -> None:
209 registry, _ = await get_registry_and_health()
210 adapters = await registry.list_available_adapters()
212 if format == "json":
213 output = {name: info.to_dict() for name, info in adapters.items()}
214 click.echo(json.dumps(output, indent=2))
215 else:
216 click.echo("Available Adapters:")
217 click.echo("-" * 50)
218 for name, info in adapters.items():
219 status_color = "green" if info.module_status == "stable" else "yellow"
220 click.echo(f" {name:<20} {info.category:<12} ", nl=False)
221 click.secho(info.module_status, fg=status_color)
222 if info.description:
223 click.echo(f" {info.description}")
225 asyncio.run(_list())
228@cli.command()
229@click.option("--category", help="Filter by category")
230def list_categories(category: str | None = None) -> None:
231 """List adapter categories."""
233 async def _list_categories() -> None:
234 registry, _ = await get_registry_and_health()
236 if category:
237 adapters = await registry.get_adapters_by_category(category)
238 click.echo(f"Adapters in category '{category}':")
239 for adapter in adapters:
240 click.echo(f" - {adapter.name}")
241 else:
242 categories = await registry.get_categories()
243 click.echo("Available Categories:")
244 for cat in sorted(categories):
245 click.echo(f" - {cat}")
247 asyncio.run(_list_categories())
250@cli.command()
251@click.argument("adapter_name")
252@click.option("--format", type=click.Choice(["json", "text"]), default="text")
253def inspect(adapter_name: str, format: str) -> None:
254 """Inspect a specific adapter."""
256 async def _inspect() -> None:
257 registry, _ = await get_registry_and_health()
258 info = await registry.get_adapter_info(adapter_name)
260 if not info:
261 click.echo(f"Adapter '{adapter_name}' not found", err=True)
262 return
264 if format == "json":
265 click.echo(json.dumps(info.to_dict(), indent=2))
266 else:
267 click.echo(f"Adapter: {info.name}")
268 click.echo(f"Class: {info.class_name}")
269 click.echo(f"Module: {info.module_path}")
270 click.echo(f"Category: {info.category}")
271 click.echo(f"Status: {info.module_status}")
272 click.echo(f"ID: {info.module_id}")
273 if info.description:
274 click.echo(f"Description: {info.description}")
275 if info.protocols:
276 click.echo(f"Protocols: {', '.join(info.protocols)}")
277 if info.settings_class:
278 click.echo(f"Settings Class: {info.settings_class}")
280 asyncio.run(_inspect())
283@cli.command()
284@click.argument("adapter_name")
285def validate(adapter_name: str) -> None:
286 """Validate an adapter configuration."""
288 async def _validate() -> None:
289 registry, _ = await get_registry_and_health()
290 result = await registry.validate_adapter(adapter_name)
292 if result["valid"]:
293 click.secho(f"✓ Adapter '{adapter_name}' is valid", fg="green")
294 else:
295 click.secho(f"✗ Adapter '{adapter_name}' has issues", fg="red")
297 if result["errors"]:
298 click.echo("Errors:")
299 for error in result["errors"]:
300 click.secho(f" - {error}", fg="red")
302 if result["warnings"]:
303 click.echo("Warnings:")
304 for warning in result["warnings"]:
305 click.secho(f" - {warning}", fg="yellow")
307 asyncio.run(_validate())
310@cli.command()
311@click.argument("adapter_name", required=False)
312@click.option("--all", is_flag=True, help="Check all adapters")
313@click.option("--format", type=click.Choice(["json", "text"]), default="text")
314def health(
315 adapter_name: str | None = None, all: bool = False, format: str = "text"
316) -> None:
317 """Perform health checks on adapters."""
319 async def _health() -> None:
320 registry, health_system = await get_registry_and_health()
322 if all:
323 results = await health_system.check_all_adapters()
324 if format == "json":
325 output = {name: result.to_dict() for name, result in results.items()}
326 click.echo(json.dumps(output, indent=2))
327 else:
328 click.echo("Health Check Results:")
329 click.echo("-" * 50)
330 for name, result in results.items():
331 _display_health_result_summary(name, result)
333 elif adapter_name:
334 result = await health_system.check_adapter_health(adapter_name)
335 if format == "json":
336 click.echo(json.dumps(result.to_dict(), indent=2))
337 else:
338 _display_health_result_detail(adapter_name, result)
340 else:
341 summary = health_system.get_system_health_summary()
342 if format == "json":
343 click.echo(json.dumps(summary, indent=2))
344 else:
345 _display_system_health_summary(summary)
347 asyncio.run(_health())
350@cli.command()
351def statistics() -> None:
352 """Show adapter statistics."""
354 async def _stats() -> None:
355 registry, _ = await get_registry_and_health()
356 stats = await registry.get_adapter_statistics()
358 click.echo("Adapter Statistics:")
359 click.echo(f"Total Available: {stats['total_available']}")
360 click.echo(f"Total Active: {stats['total_active']}")
361 click.echo(f"Categories: {stats['total_categories']}")
363 click.echo("\nBy Category:")
364 for category, info in stats["categories"].items():
365 click.echo(f" {category}: {info['total']} adapters")
367 click.echo("\nBy Status:")
368 for status, count in stats["status_breakdown"].items():
369 click.echo(f" {status}: {count}")
371 if stats["active_adapters"]:
372 click.echo(f"\nActive Adapters: {', '.join(stats['active_adapters'])}")
374 asyncio.run(_stats())
377@cli.command()
378@click.option(
379 "--auto-register", is_flag=True, help="Automatically register all adapters"
380)
381def register(auto_register: bool = False) -> None:
382 """Register adapters with the system."""
384 async def _register() -> None:
385 registry, _ = await get_registry_and_health()
387 if auto_register:
388 results = await registry.auto_register_available_adapters()
390 success_count = sum(1 for success in results.values() if success)
391 total_count = len(results)
393 click.echo(
394 f"Auto-registration completed: {success_count}/{total_count} successful"
395 )
397 for name, success in results.items():
398 status = "✓" if success else "✗"
399 color = "green" if success else "red"
400 click.secho(f" {status} {name}", fg=color)
401 else:
402 click.echo("Use --auto-register to register all available adapters")
404 asyncio.run(_register())
407@cli.command()
408@click.argument("config_file")
409@click.option("--output", "-o", help="Output report file")
410@click.option("--format", type=click.Choice(["json", "text"]), default="text")
411def audit(config_file: str, output: str | None, format: str) -> None:
412 """Audit configuration for security and compliance."""
414 async def _audit() -> None:
415 registry = AdapterRegistry()
416 env_manager = EnvironmentManager()
417 auditor = ConfigurationAuditor(env_manager)
419 await registry.initialize()
421 config_manager = ConfigurationManager(registry)
422 await config_manager.initialize()
424 try:
425 config = await config_manager.load_configuration(config_file)
426 report = await auditor.audit_configuration(config)
428 if format == "json":
429 _write_json_audit_report(report, output)
430 else:
431 _display_text_audit_report(report)
432 if output:
433 _write_text_audit_report(report, output)
435 except Exception as e:
436 click.echo(f"Error: {e}", err=True)
438 asyncio.run(_audit())
441@cli.command()
442@click.argument("config_file")
443@click.argument("target_version")
444@click.option(
445 "--backup/--no-backup", default=True, help="Create backup before migration"
446)
447@click.option("--output", "-o", help="Output file for migrated configuration")
448def migrate(
449 config_file: str, target_version: str, backup: bool, output: str | None
450) -> None:
451 """Migrate configuration to target version."""
453 async def _migrate() -> None:
454 migration_manager = ConfigurationMigrationManager()
456 config_path = Path(config_file)
457 if not config_path.exists():
458 click.echo(f"Configuration file not found: {config_file}", err=True)
459 return
461 # Validate migration compatibility
462 compatibility = await migration_manager.validate_migration_compatibility(
463 config_path, target_version
464 )
466 if not compatibility["compatible"]:
467 _display_migration_incompatibility(compatibility)
468 return
470 _display_migration_compatibility(compatibility, target_version)
472 if not click.confirm("Continue with migration?"):
473 return
475 # Create backup if requested
476 if backup:
477 backup_path = await migration_manager.create_migration_backup(
478 config_path, target_version
479 )
480 click.echo(f"Backup created: {backup_path}")
482 # Perform migration
483 result = await migration_manager.migrate_configuration_file(
484 config_path, target_version, Path(output) if output else None
485 )
487 if result.success:
488 _display_migration_success(result)
489 else:
490 _display_migration_failure(result)
492 asyncio.run(_migrate())
495def _parse_test_types(test_types: str | None) -> list[t.Any] | None:
496 """Parse comma-separated test types into a list."""
497 if not test_types:
498 return None
500 from .config_health import ConfigurationTestType
502 return [
503 ConfigurationTestType(test_type.strip()) for test_type in test_types.split(",")
504 ]
507def _display_health_summary(report: t.Any, config_file: str) -> None:
508 """Display health check summary information."""
509 status_color = {"valid": "green", "warning": "yellow", "error": "red"}.get(
510 report.overall_status.value, "white"
511 )
513 click.echo(f"Configuration Health Check: {config_file}")
514 click.secho(
515 f"Overall Status: {report.overall_status.value.upper()}",
516 fg=status_color,
517 )
518 click.echo(f"Total Tests: {report.summary['total_tests']}")
519 click.echo(f"Passed: {report.summary['passed_tests']}")
520 click.echo(f"Failed: {report.summary['failed_tests']}")
521 click.echo(f"Pass Rate: {report.summary['pass_rate']:.1f}%")
524def _display_failed_tests(failed_tests: list[t.Any]) -> None:
525 """Display failed test details."""
526 if not failed_tests:
527 return
529 click.echo(f"\nFailed Tests ({len(failed_tests)}):")
530 for test in failed_tests:
531 severity_color = {
532 "critical": "red",
533 "high": "red",
534 "medium": "yellow",
535 "low": "white",
536 }.get(test.severity.value, "white")
538 click.secho(
539 f" [{test.severity.value.upper()}] {test.test_name}",
540 fg=severity_color,
541 )
542 click.echo(f" {test.message}")
545def _display_recommendations(recommendations: list[str]) -> None:
546 """Display health check recommendations."""
547 if not recommendations:
548 return
550 click.echo("\nRecommendations:")
551 for rec in recommendations:
552 click.echo(f" • {rec}")
555async def _save_health_report_if_requested(
556 health_checker: t.Any, report: t.Any, output: str | None
557) -> None:
558 """Save health report to file if output path is provided."""
559 if output:
560 await health_checker._save_health_report(report, Path(output))
561 click.echo(f"\nReport saved to: {output}")
564@cli.command()
565@click.argument("config_file")
566@click.option("--test-types", help="Comma-separated test types to run")
567@click.option("--output", "-o", help="Output report file")
568def health_check(config_file: str, test_types: str | None, output: str | None) -> None:
569 """Run comprehensive health check on configuration."""
571 async def _health_check() -> None:
572 registry = AdapterRegistry()
573 env_manager = EnvironmentManager()
574 health_checker = ConfigurationHealthChecker(registry, env_manager)
576 await registry.initialize()
578 config_manager = ConfigurationManager(registry)
579 await config_manager.initialize()
581 try:
582 config = await config_manager.load_configuration(config_file)
584 # Parse test types and run health check
585 test_type_list = _parse_test_types(test_types)
586 report = await health_checker.run_comprehensive_health_check(
587 config, test_type_list
588 )
590 # Display results
591 _display_health_summary(report, config_file)
593 # Show failed tests
594 failed_tests = [r for r in report.test_results if not r.passed]
595 _display_failed_tests(failed_tests)
597 # Show recommendations
598 _display_recommendations(report.recommendations)
600 # Save report if requested
601 await _save_health_report_if_requested(health_checker, report, output)
603 except Exception as e:
604 click.echo(f"Error: {e}", err=True)
606 asyncio.run(_health_check())
609if __name__ == "__main__":
610 cli()