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

1"""Command-line interface for FastBlocks MCP adapter management.""" 

2 

3import asyncio 

4import json 

5import typing as t 

6from pathlib import Path 

7 

8import click 

9 

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 

18 

19# Audit output formatting helpers 

20_SEVERITY_COLORS = { 

21 "critical": "red", 

22 "high": "red", 

23 "medium": "yellow", 

24 "low": "white", 

25 "info": "cyan", 

26} 

27 

28# Health check formatting helpers 

29_HEALTH_STATUS_COLORS = { 

30 "healthy": "green", 

31 "warning": "yellow", 

32 "error": "red", 

33} 

34 

35 

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}") 

42 

43 

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") 

47 

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") 

52 

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}") 

60 

61 

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']}") 

72 

73 

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'])}") 

80 

81 if compatibility["warnings"]: 

82 click.echo("\nWarnings:") 

83 for warning in compatibility["warnings"]: 

84 click.echo(f" - {warning}") 

85 

86 

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) 

92 

93 

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") 

102 

103 

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}") 

109 

110 

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 } 

122 

123 

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 

127 

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 } 

135 

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)) 

141 

142 

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") 

146 

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)}") 

155 

156 

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']}") 

163 

164 if report.findings: 

165 click.echo("\nFindings:") 

166 for finding in report.findings: 

167 _display_audit_finding(finding) 

168 

169 if report.recommendations: 

170 click.echo("\nRecommendations:") 

171 for rec in report.recommendations: 

172 click.echo(f"{rec}") 

173 

174 

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") 

183 

184 

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 

191 

192 

193@click.group() 

194def cli() -> None: 

195 """FastBlocks MCP Adapter Management CLI.""" 

196 pass 

197 

198 

199# Add configuration management commands 

200cli.add_command(config_cli, name="config") 

201 

202 

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.""" 

207 

208 async def _list() -> None: 

209 registry, _ = await get_registry_and_health() 

210 adapters = await registry.list_available_adapters() 

211 

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}") 

224 

225 asyncio.run(_list()) 

226 

227 

228@cli.command() 

229@click.option("--category", help="Filter by category") 

230def list_categories(category: str | None = None) -> None: 

231 """List adapter categories.""" 

232 

233 async def _list_categories() -> None: 

234 registry, _ = await get_registry_and_health() 

235 

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}") 

246 

247 asyncio.run(_list_categories()) 

248 

249 

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.""" 

255 

256 async def _inspect() -> None: 

257 registry, _ = await get_registry_and_health() 

258 info = await registry.get_adapter_info(adapter_name) 

259 

260 if not info: 

261 click.echo(f"Adapter '{adapter_name}' not found", err=True) 

262 return 

263 

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}") 

279 

280 asyncio.run(_inspect()) 

281 

282 

283@cli.command() 

284@click.argument("adapter_name") 

285def validate(adapter_name: str) -> None: 

286 """Validate an adapter configuration.""" 

287 

288 async def _validate() -> None: 

289 registry, _ = await get_registry_and_health() 

290 result = await registry.validate_adapter(adapter_name) 

291 

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") 

296 

297 if result["errors"]: 

298 click.echo("Errors:") 

299 for error in result["errors"]: 

300 click.secho(f" - {error}", fg="red") 

301 

302 if result["warnings"]: 

303 click.echo("Warnings:") 

304 for warning in result["warnings"]: 

305 click.secho(f" - {warning}", fg="yellow") 

306 

307 asyncio.run(_validate()) 

308 

309 

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.""" 

318 

319 async def _health() -> None: 

320 registry, health_system = await get_registry_and_health() 

321 

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) 

332 

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) 

339 

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) 

346 

347 asyncio.run(_health()) 

348 

349 

350@cli.command() 

351def statistics() -> None: 

352 """Show adapter statistics.""" 

353 

354 async def _stats() -> None: 

355 registry, _ = await get_registry_and_health() 

356 stats = await registry.get_adapter_statistics() 

357 

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']}") 

362 

363 click.echo("\nBy Category:") 

364 for category, info in stats["categories"].items(): 

365 click.echo(f" {category}: {info['total']} adapters") 

366 

367 click.echo("\nBy Status:") 

368 for status, count in stats["status_breakdown"].items(): 

369 click.echo(f" {status}: {count}") 

370 

371 if stats["active_adapters"]: 

372 click.echo(f"\nActive Adapters: {', '.join(stats['active_adapters'])}") 

373 

374 asyncio.run(_stats()) 

375 

376 

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.""" 

383 

384 async def _register() -> None: 

385 registry, _ = await get_registry_and_health() 

386 

387 if auto_register: 

388 results = await registry.auto_register_available_adapters() 

389 

390 success_count = sum(1 for success in results.values() if success) 

391 total_count = len(results) 

392 

393 click.echo( 

394 f"Auto-registration completed: {success_count}/{total_count} successful" 

395 ) 

396 

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") 

403 

404 asyncio.run(_register()) 

405 

406 

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.""" 

413 

414 async def _audit() -> None: 

415 registry = AdapterRegistry() 

416 env_manager = EnvironmentManager() 

417 auditor = ConfigurationAuditor(env_manager) 

418 

419 await registry.initialize() 

420 

421 config_manager = ConfigurationManager(registry) 

422 await config_manager.initialize() 

423 

424 try: 

425 config = await config_manager.load_configuration(config_file) 

426 report = await auditor.audit_configuration(config) 

427 

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) 

434 

435 except Exception as e: 

436 click.echo(f"Error: {e}", err=True) 

437 

438 asyncio.run(_audit()) 

439 

440 

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.""" 

452 

453 async def _migrate() -> None: 

454 migration_manager = ConfigurationMigrationManager() 

455 

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 

460 

461 # Validate migration compatibility 

462 compatibility = await migration_manager.validate_migration_compatibility( 

463 config_path, target_version 

464 ) 

465 

466 if not compatibility["compatible"]: 

467 _display_migration_incompatibility(compatibility) 

468 return 

469 

470 _display_migration_compatibility(compatibility, target_version) 

471 

472 if not click.confirm("Continue with migration?"): 

473 return 

474 

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}") 

481 

482 # Perform migration 

483 result = await migration_manager.migrate_configuration_file( 

484 config_path, target_version, Path(output) if output else None 

485 ) 

486 

487 if result.success: 

488 _display_migration_success(result) 

489 else: 

490 _display_migration_failure(result) 

491 

492 asyncio.run(_migrate()) 

493 

494 

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 

499 

500 from .config_health import ConfigurationTestType 

501 

502 return [ 

503 ConfigurationTestType(test_type.strip()) for test_type in test_types.split(",") 

504 ] 

505 

506 

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 ) 

512 

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}%") 

522 

523 

524def _display_failed_tests(failed_tests: list[t.Any]) -> None: 

525 """Display failed test details.""" 

526 if not failed_tests: 

527 return 

528 

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") 

537 

538 click.secho( 

539 f" [{test.severity.value.upper()}] {test.test_name}", 

540 fg=severity_color, 

541 ) 

542 click.echo(f" {test.message}") 

543 

544 

545def _display_recommendations(recommendations: list[str]) -> None: 

546 """Display health check recommendations.""" 

547 if not recommendations: 

548 return 

549 

550 click.echo("\nRecommendations:") 

551 for rec in recommendations: 

552 click.echo(f"{rec}") 

553 

554 

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}") 

562 

563 

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.""" 

570 

571 async def _health_check() -> None: 

572 registry = AdapterRegistry() 

573 env_manager = EnvironmentManager() 

574 health_checker = ConfigurationHealthChecker(registry, env_manager) 

575 

576 await registry.initialize() 

577 

578 config_manager = ConfigurationManager(registry) 

579 await config_manager.initialize() 

580 

581 try: 

582 config = await config_manager.load_configuration(config_file) 

583 

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 ) 

589 

590 # Display results 

591 _display_health_summary(report, config_file) 

592 

593 # Show failed tests 

594 failed_tests = [r for r in report.test_results if not r.passed] 

595 _display_failed_tests(failed_tests) 

596 

597 # Show recommendations 

598 _display_recommendations(report.recommendations) 

599 

600 # Save report if requested 

601 await _save_health_report_if_requested(health_checker, report, output) 

602 

603 except Exception as e: 

604 click.echo(f"Error: {e}", err=True) 

605 

606 asyncio.run(_health_check()) 

607 

608 

609if __name__ == "__main__": 

610 cli()