Coverage for fastblocks / mcp / config_cli.py: 0%
321 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -0800
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -0800
1"""Interactive CLI for FastBlocks adapter configuration management."""
3import asyncio
4import json
5import os
6import sys
7import typing as t
8from pathlib import Path
9from typing import Any
11import click
12from acb.console import Console
13from acb.depends import depends
14from mcp_common.ui import ServerPanels
15from rich.panel import Panel
16from rich.prompt import Confirm, Prompt
18from .configuration import (
19 ConfigurationManager,
20 ConfigurationProfile,
21 ConfigurationSchema,
22 ConfigurationStatus,
23 ConfigurationValidationResult,
24 EnvironmentVariable,
25)
26from .health import HealthCheckSystem
27from .registry import AdapterRegistry
29console = depends.get_sync(Console)
32class InteractiveConfigurationCLI:
33 """Interactive CLI for adapter configuration."""
35 def __init__(self) -> None:
36 """Initialize the interactive CLI."""
37 self.registry = AdapterRegistry()
38 self.health = HealthCheckSystem(self.registry)
39 self.config_manager = ConfigurationManager(self.registry)
41 async def initialize(self) -> None:
42 """Initialize all systems."""
43 await self.registry.initialize()
44 await self.config_manager.initialize()
46 async def run_configuration_wizard(self) -> ConfigurationSchema:
47 """Run the interactive configuration wizard."""
48 console.print(
49 Panel.fit(
50 "[bold blue]FastBlocks Adapter Configuration Wizard[/bold blue]\n"
51 "This wizard will help you configure FastBlocks adapters for your project.",
52 title="Welcome",
53 )
54 )
56 # Step 1: Select profile
57 profile = self._select_profile()
59 # Step 2: Select adapters
60 selected_adapters = await self._select_adapters()
62 # Step 3: Configure selected adapters
63 config = await self.config_manager.create_configuration(
64 profile=profile, adapters=selected_adapters
65 )
67 # Step 4: Configure each adapter
68 for adapter_name in selected_adapters:
69 await self._configure_adapter_interactive(config, adapter_name)
71 # Step 5: Configure global settings
72 await self._configure_global_settings(config)
74 # Step 6: Validate configuration
75 console.print("\n[yellow]Validating configuration...[/yellow]")
76 validation_result = await self.config_manager.validate_configuration(config)
77 self._display_validation_result(validation_result)
79 if validation_result.status == ConfigurationStatus.ERROR:
80 if not Confirm.ask("Configuration has errors. Continue anyway?"):
81 console.print("[red]Configuration cancelled.[/red]")
82 sys.exit(1)
84 # Step 7: Save configuration
85 config_name = Prompt.ask(
86 "Configuration name", default=f"{profile.value}_config"
87 )
89 config_file = await self.config_manager.save_configuration(config, config_name)
90 console.print(f"\n[green]✓[/green] Configuration saved to: {config_file}")
92 # Step 8: Generate environment file
93 if Confirm.ask("Generate .env file?", default=True):
94 env_file = await self.config_manager.generate_environment_file(config)
95 console.print(f"[green]✓[/green] Environment file created: {env_file}")
97 # Step 9: Create backup
98 if Confirm.ask("Create backup?", default=True):
99 backup_description = Prompt.ask(
100 "Backup description", default="Initial configuration"
101 )
102 backup = await self.config_manager.backup_configuration(
103 config, config_name, backup_description
104 )
105 console.print(f"[green]✓[/green] Backup created: {backup.id}")
107 return config
109 def _select_profile(self) -> ConfigurationProfile:
110 """Interactive profile selection."""
111 console.print("\n[bold]Select deployment profile:[/bold]")
113 profiles = {
114 "1": (
115 ConfigurationProfile.DEVELOPMENT,
116 "Development - Debug enabled, relaxed security",
117 ),
118 "2": (
119 ConfigurationProfile.STAGING,
120 "Staging - Production-like with debug options",
121 ),
122 "3": (
123 ConfigurationProfile.PRODUCTION,
124 "Production - Optimized for performance and security",
125 ),
126 }
128 for key, (profile, description) in profiles.items():
129 console.print(
130 f" {key}. [cyan]{profile.value.title()}[/cyan] - {description}"
131 )
133 choice = Prompt.ask(
134 "Choose profile", choices=list(profiles.keys()), default="1"
135 )
137 selected_profile, _ = profiles[choice]
138 console.print(
139 f"[green]✓[/green] Selected profile: [cyan]{selected_profile.value.title()}[/cyan]"
140 )
141 return selected_profile
143 def _group_adapters_by_category(
144 self, available_adapters: dict[str, Any]
145 ) -> dict[str, list[tuple[str, Any]]]:
146 """Group adapters by category.
148 Args:
149 available_adapters: Dict of adapter name -> adapter info
151 Returns:
152 Dict of category -> list of (adapter_name, adapter_info)
153 """
154 categories: dict[str, list[tuple[str, Any]]] = {}
156 for adapter_name, adapter_info in available_adapters.items():
157 category = adapter_info.category
158 if category not in categories:
159 categories[category] = []
160 categories[category].append((adapter_name, adapter_info))
162 return categories
164 def _display_adapter_choices(
165 self, categories: dict[str, list[tuple[str, Any]]]
166 ) -> dict[str, str]:
167 """Display adapters by category and build choice mapping.
169 Args:
170 categories: Dict of category -> list of (adapter_name, adapter_info)
172 Returns:
173 Dict mapping choice number to adapter name
174 """
175 adapter_choices = {}
176 choice_num = 1
178 for category, adapters in sorted(categories.items()):
179 console.print(f"\n[bold yellow]{category.title()}:[/bold yellow]")
180 for adapter_name, adapter_info in sorted(adapters):
181 status_color = (
182 "green" if adapter_info.module_status == "stable" else "yellow"
183 )
184 console.print(
185 f" {choice_num:2d}. [cyan]{adapter_name:<20}[/cyan] "
186 f"[{status_color}]{adapter_info.module_status:<12}[/{status_color}] "
187 f"{adapter_info.description or 'No description'}"
188 )
189 adapter_choices[str(choice_num)] = adapter_name
190 choice_num += 1
192 return adapter_choices
194 def _get_recommended_adapters(
195 self, available_adapters: dict[str, Any]
196 ) -> list[str]:
197 """Get recommended default adapters.
199 Args:
200 available_adapters: Dict of available adapters
202 Returns:
203 List of recommended adapter names
204 """
205 recommended = ["app", "templates", "routes"]
206 selected = [name for name in recommended if name in available_adapters]
207 console.print(
208 f"[green]✓[/green] Using recommended adapters: {', '.join(selected)}"
209 )
210 return selected
212 def _parse_adapter_selection(
213 self, selection: str, adapter_choices: dict[str, str]
214 ) -> list[str]:
215 """Parse user's adapter selection.
217 Args:
218 selection: Comma-separated choice numbers
219 adapter_choices: Dict mapping choice number to adapter name
221 Returns:
222 List of selected adapter names
223 """
224 selected_adapters = []
226 for choice in selection.split(","):
227 choice = choice.strip()
228 if choice in adapter_choices:
229 selected_adapters.append(adapter_choices[choice])
230 else:
231 console.print(f"[red]Warning:[/red] Invalid choice '{choice}' ignored")
233 # Print result
234 if selected_adapters:
235 console.print(
236 f"[green]✓[/green] Selected adapters: {', '.join(selected_adapters)}"
237 )
238 else:
239 console.print("[red]No adapters selected![/red]")
241 return selected_adapters
243 async def _select_adapters(self) -> list[str]:
244 """Interactive adapter selection."""
245 console.print("\n[bold]Available Adapters:[/bold]")
247 available_adapters = await self.config_manager.get_available_adapters()
249 # Group and display adapters
250 categories = self._group_adapters_by_category(available_adapters)
251 adapter_choices = self._display_adapter_choices(categories)
253 # Get user selection
254 console.print(
255 "\n[bold]Select adapters (comma-separated numbers, e.g., 1,3,5):[/bold]"
256 )
257 console.print("[dim]Leave empty for recommended defaults[/dim]")
258 selection = Prompt.ask("Adapter numbers", default="")
260 # Return recommended or parsed selection
261 if not selection.strip():
262 return self._get_recommended_adapters(available_adapters)
264 return self._parse_adapter_selection(selection, adapter_choices)
266 def _configure_adapter_env_vars(self, adapter_config: t.Any) -> None:
267 """Configure adapter environment variables."""
268 if adapter_config.environment_variables:
269 console.print("\n[yellow]Environment Variables:[/yellow]")
270 for env_var in adapter_config.environment_variables:
271 self._configure_environment_variable(env_var)
273 def _configure_required_settings(
274 self, adapter_config: t.Any, schema: dict[str, t.Any]
275 ) -> None:
276 """Configure required adapter settings."""
277 for setting in schema.get("required_settings", []):
278 value = Prompt.ask(
279 f"[red]*[/red] {setting['name']} ({setting['type']})",
280 default=str(setting.get("default", "")),
281 )
282 adapter_config.settings[setting["name"]] = self._parse_setting_value(
283 value, setting["type"]
284 )
286 def _configure_optional_settings(
287 self, adapter_config: t.Any, schema: dict[str, t.Any]
288 ) -> None:
289 """Configure optional adapter settings."""
290 if schema.get("optional_settings") and Confirm.ask(
291 "Configure optional settings?"
292 ):
293 for setting in schema.get("optional_settings", []):
294 if Confirm.ask(f"Configure {setting['name']}?"):
295 value = Prompt.ask(
296 f"{setting['name']} ({setting['type']})",
297 default=str(setting.get("default", "")),
298 )
299 adapter_config.settings[setting["name"]] = (
300 self._parse_setting_value(value, setting["type"])
301 )
303 async def _configure_adapter_interactive(
304 self, config: ConfigurationSchema, adapter_name: str
305 ) -> None:
306 """Configure a specific adapter interactively."""
307 console.print(f"\n[bold]Configuring {adapter_name} adapter:[/bold]")
309 adapter_config = config.adapters[adapter_name]
310 schema = await self.config_manager.get_adapter_configuration_schema(
311 adapter_name
312 )
314 # Show adapter information
315 console.print(f"[dim]Category: {schema.get('category', 'Unknown')}[/dim]")
316 if schema.get("description"):
317 console.print(f"[dim]Description: {schema['description']}[/dim]")
319 # Configure environment variables
320 self._configure_adapter_env_vars(adapter_config)
322 # Configure settings
323 if schema.get("required_settings") or schema.get("optional_settings"):
324 console.print("\n[yellow]Adapter Settings:[/yellow]")
325 self._configure_required_settings(adapter_config, schema)
326 self._configure_optional_settings(adapter_config, schema)
328 # Configure dependencies
329 if schema.get("dependencies"):
330 console.print(
331 f"\n[yellow]Dependencies: {', '.join(schema['dependencies'])}[/yellow]"
332 )
333 adapter_config.dependencies.update(schema["dependencies"])
335 def _configure_environment_variable(self, env_var: EnvironmentVariable) -> None:
336 """Configure a single environment variable."""
337 current_value = os.environ.get(
338 env_var.name, env_var.value or env_var.default or ""
339 )
341 if env_var.required:
342 prompt_text = f"[red]*[/red] {env_var.name}"
343 else:
344 prompt_text = env_var.name
346 if env_var.description:
347 console.print(f"[dim] {env_var.description}[/dim]")
349 if env_var.secret:
350 value = Prompt.ask(prompt_text, password=True, default=current_value)
351 else:
352 value = Prompt.ask(prompt_text, default=current_value)
354 env_var.value = value
356 def _parse_setting_value(self, value: str, type_name: str) -> Any:
357 """Parse setting value based on type."""
358 if not value:
359 return None
361 try:
362 if type_name in ("int", "integer"):
363 return int(value)
364 elif type_name in ("float", "number"):
365 return float(value)
366 elif type_name in ("bool", "boolean"):
367 return value.lower() in ("true", "1", "yes", "on")
368 elif type_name in ("list", "array"):
369 return [item.strip() for item in value.split(",") if item.strip()]
370 elif type_name in ("dict", "object"):
371 return json.loads(value)
373 return value
374 except (ValueError, json.JSONDecodeError):
375 console.print(
376 f"[red]Warning:[/red] Could not parse '{value}' as {type_name}, using as string"
377 )
378 return value
380 async def _configure_global_settings(self, config: ConfigurationSchema) -> None:
381 """Configure global settings."""
382 console.print("\n[bold]Global Settings:[/bold]")
384 if Confirm.ask("Configure global settings?"):
385 # Common global settings
386 settings_to_configure = [
387 ("debug", "bool", "Enable debug mode"),
388 ("log_level", "str", "Logging level (DEBUG, INFO, WARNING, ERROR)"),
389 ("secret_key", "str", "Application secret key"),
390 ("database_url", "str", "Database connection URL"),
391 ]
393 for setting_name, setting_type, description in settings_to_configure:
394 if Confirm.ask(f"Configure {setting_name}?"):
395 console.print(f"[dim]{description}[/dim]")
396 value = Prompt.ask(
397 setting_name,
398 password=(setting_name in ("secret_key", "database_url")),
399 )
400 config.global_settings[setting_name] = self._parse_setting_value(
401 value, setting_type
402 )
404 def _display_validation_result(self, result: ConfigurationValidationResult) -> None:
405 """Display configuration validation results."""
406 if result.status == ConfigurationStatus.VALID:
407 console.print("[green]✓ Configuration is valid[/green]")
408 elif result.status == ConfigurationStatus.WARNING:
409 console.print("[yellow]⚠ Configuration has warnings[/yellow]")
410 elif result.status == ConfigurationStatus.ERROR:
411 console.print("[red]✗ Configuration has errors[/red]")
413 if result.errors:
414 console.print("\n[red]Errors:[/red]")
415 for error in result.errors:
416 console.print(f" [red]•[/red] {error}")
418 if result.warnings:
419 console.print("\n[yellow]Warnings:[/yellow]")
420 for warning in result.warnings:
421 console.print(f" [yellow]•[/yellow] {warning}")
424# CLI Commands
427@click.group()
428def config_cli() -> None:
429 """FastBlocks Configuration Management CLI."""
430 pass
433@config_cli.command()
434def wizard() -> None:
435 """Launch the interactive configuration wizard."""
437 async def _wizard() -> None:
438 cli = InteractiveConfigurationCLI()
439 await cli.initialize()
440 await cli.run_configuration_wizard()
442 asyncio.run(_wizard())
445@config_cli.command()
446@click.option(
447 "--profile",
448 type=click.Choice(["development", "staging", "production"]),
449 default="development",
450 help="Configuration profile",
451)
452@click.option("--adapters", help="Comma-separated list of adapters to include")
453@click.option("--output", "-o", help="Output file path")
454def create(profile: str, adapters: str | None, output: str | None) -> None:
455 """Create a new configuration file."""
457 async def _create() -> None:
458 registry = AdapterRegistry()
459 config_manager = ConfigurationManager(registry)
460 await registry.initialize()
461 await config_manager.initialize()
463 profile_enum = ConfigurationProfile(profile)
464 adapter_list = adapters.split(",") if adapters else None
466 config = await config_manager.create_configuration(
467 profile=profile_enum, adapters=adapter_list
468 )
470 config_file = await config_manager.save_configuration(config, output)
471 console.print(f"[green]✓[/green] Configuration created: {config_file}")
473 asyncio.run(_create())
476def _format_json_output(result: ConfigurationValidationResult) -> None:
477 """Format validation result as JSON."""
478 output = {
479 "status": result.status.value,
480 "errors": result.errors,
481 "warnings": result.warnings,
482 "adapter_results": result.adapter_results,
483 }
484 click.echo(json.dumps(output, indent=2))
487def _format_text_output(result: ConfigurationValidationResult) -> None:
488 """Format validation result as human-readable text."""
489 # Print status
490 status_messages = {
491 ConfigurationStatus.VALID: "[green]✓ Configuration is valid[/green]",
492 ConfigurationStatus.WARNING: "[yellow]⚠ Configuration has warnings[/yellow]",
493 ConfigurationStatus.ERROR: "[red]✗ Configuration has errors[/red]",
494 }
495 console.print(status_messages.get(result.status, "Unknown status"))
497 # Print errors
498 if result.errors:
499 console.print("\n[red]Errors:[/red]")
500 for error in result.errors:
501 console.print(f" • {error}")
503 # Print warnings
504 if result.warnings:
505 console.print("\n[yellow]Warnings:[/yellow]")
506 for warning in result.warnings:
507 console.print(f" • {warning}")
510@config_cli.command()
511@click.argument("config_file")
512@click.option("--format", type=click.Choice(["text", "json"]), default="text")
513def validate(config_file: str, format: str) -> None:
514 """Validate a configuration file."""
516 async def _validate() -> None:
517 registry = AdapterRegistry()
518 config_manager = ConfigurationManager(registry)
519 await registry.initialize()
520 await config_manager.initialize()
522 try:
523 config = await config_manager.load_configuration(config_file)
524 result = await config_manager.validate_configuration(config)
526 # Format output based on requested format
527 if format == "json":
528 _format_json_output(result)
529 else:
530 _format_text_output(result)
532 except FileNotFoundError:
533 console.print(
534 f"[red]Error:[/red] Configuration file '{config_file}' not found"
535 )
536 sys.exit(1)
537 except Exception as e:
538 console.print(f"[red]Error:[/red] {e}")
539 sys.exit(1)
541 asyncio.run(_validate())
544@config_cli.command()
545@click.argument("config_file")
546@click.option("--output", "-o", help="Output .env file path")
547def generate_env(config_file: str, output: str | None) -> None:
548 """Generate .env file from configuration."""
550 async def _generate_env() -> None:
551 registry = AdapterRegistry()
552 config_manager = ConfigurationManager(registry)
553 await registry.initialize()
554 await config_manager.initialize()
556 try:
557 config = await config_manager.load_configuration(config_file)
558 output_path = Path(output) if output else None
559 env_file = await config_manager.generate_environment_file(
560 config, output_path
561 )
562 console.print(f"[green]✓[/green] Environment file created: {env_file}")
563 except Exception as e:
564 console.print(f"[red]Error:[/red] {e}")
565 sys.exit(1)
567 asyncio.run(_generate_env())
570@config_cli.command()
571def list_templates() -> None:
572 """List available configuration templates."""
574 async def _list_templates() -> None:
575 registry = AdapterRegistry()
576 config_manager = ConfigurationManager(registry)
577 await config_manager.initialize()
579 templates_dir = config_manager.templates_dir
580 if not templates_dir.exists():
581 console.print("[yellow]No templates directory found[/yellow]")
582 return
584 templates = list(templates_dir.glob("*.yaml"))
585 if not templates:
586 console.print("[yellow]No templates available[/yellow]")
587 return
589 console.print("[bold]Available Templates:[/bold]")
590 for template in sorted(templates):
591 console.print(f" • {template.stem}")
593 asyncio.run(_list_templates())
596@config_cli.command()
597@click.argument("name")
598@click.argument("description")
599def backup(name: str, description: str) -> None:
600 """Create a backup of current configuration."""
602 async def _backup() -> None:
603 # This would need to be implemented based on current active configuration
604 console.print(
605 "[yellow]Backup functionality needs active configuration context[/yellow]"
606 )
608 asyncio.run(_backup())
611@config_cli.command()
612def list_backups() -> None:
613 """List all configuration backups."""
615 async def _list_backups() -> None:
616 registry = AdapterRegistry()
617 config_manager = ConfigurationManager(registry)
618 await config_manager.initialize()
620 backups = await config_manager.list_backups()
622 if not backups:
623 console.print("[yellow]No backups found[/yellow]")
624 return
626 ServerPanels.backups_table(backups)
628 asyncio.run(_list_backups())
631@config_cli.command()
632@click.argument("backup_id")
633@click.option("--output", "-o", help="Output file path")
634def restore(backup_id: str, output: str | None) -> None:
635 """Restore configuration from backup."""
637 async def _restore() -> None:
638 registry = AdapterRegistry()
639 config_manager = ConfigurationManager(registry)
640 await config_manager.initialize()
642 try:
643 config = await config_manager.restore_backup(backup_id)
645 if output:
646 config_file = await config_manager.save_configuration(config, output)
647 else:
648 config_file = await config_manager.save_configuration(config)
650 console.print(f"[green]✓[/green] Configuration restored to: {config_file}")
651 except Exception as e:
652 console.print(f"[red]Error:[/red] {e}")
653 sys.exit(1)
655 asyncio.run(_restore())
658if __name__ == "__main__":
659 config_cli()